radoslawik
radoslawik

Reputation: 1202

How to keep cancelling the task until a condition is met (TaskCanceledException)

I want to call a method after some delay when an event is raised, but any subsequent events should "restart" this delay. Quick example to illustrate, the view should be updated when scrollbar position changes, but only 1 second after the user has finished scrolling.

Now I can see many ways of implementing that, but the most intuitive would be to use Task.Delay + ContinueWith + cancellation token. However, I am experiencing some issues, more precisely subsequent calls to my function cause the TaskCanceledException exception and I started to wonder how I could get rid of that. Here is my code:

private CancellationTokenSource? _cts;

private async void Update()
{
    _cts?.Cancel();
    _cts = new();
    await Task.Delay(TimeSpan.FromSeconds(1), _cts.Token)
       .ContinueWith(o => Debug.WriteLine("Update now!"), 
       TaskContinuationOptions.OnlyOnRanToCompletion);
}

I have found a workaround that works pretty nicely, but I would like to make the first idea work.

private CancellationTokenSource? _cts;
private CancellationTokenRegistration? _cancellationTokenRegistration;

private void Update()
{
    _cancellationTokenRegistration?.Unregister();
    _cts = new();
    _cancellationTokenRegistration = _cts.Token.Register(() => Debug.WriteLine("Update now!"));
    _cts.CancelAfter(1000);
}

Upvotes: 1

Views: 621

Answers (5)

Muhammad Javad
Muhammad Javad

Reputation: 127

I implemented the same scenario in a JavaScript application using Timer. I believe it's the same in the .NET world. Anyway handling this use-case when the user calls a method repeatedly with Task.Delay() will put more pressure on GC & thread pool

var timer = new Timer()
{
    Enabled = true,
    Interval = TimeSpan.FromSeconds(5).TotalMilliseconds,
};

timer.Elapsed += (sender, eventArgs) =>
{
    timer.Stop();
    // do stuff
}
    
void OnKeyUp()
{
    timer.Stop();
    timer.Start();
}

Upvotes: 0

John Wu
John Wu

Reputation: 52290

You can combine a state variable and a delay to avoid messing with timers or task cancelation. This is far simpler IMO.

Add this state variable to your class/form:

private DateTime _nextRefresh = DateTime.MaxValue;

And here's how you refresh:

private async void Update() 
{
    await RefreshInOneSecond();
}

private async Task RefreshInOneSecond()
{
    _nextRefresh = DateTime.Now.AddSeconds(1);
    await Task.Delay(1000);
    if (_nextRefresh <= DateTime.Now)
    {
        _nextRefresh = DateTime.MaxValue;
        Refresh();
    }
}

If you call RefreshInOneSecond repeatedly, it pushes out the _nextRefresh timestamp until later, so any refreshes already in flight will do nothing.

Demo on DotNetFiddle

Upvotes: 2

Enigmativity
Enigmativity

Reputation: 117175

You should consider using Microsoft's Reactive Framework (aka Rx) - NuGet System.Reactive and add using System.Reactive.Linq;.

You didn't say hat UI you're using, so for Windows Forms also add System.Reactive.Windows.Forms and for WPF System.Reactive.Windows.Threading.

Then you can do this:

Panel panel = new Panel(); // assuming this is a scrollable control

IObservable<EventPattern<ScrollEventArgs>> query =
    Observable
        .FromEventPattern<ScrollEventHandler, ScrollEventArgs>(
            h => panel.Scroll += h,
            h => panel.Scroll -= h)
        .Select(sea => Observable.Timer(TimeSpan.FromSeconds(1.0)).Select(_ => sea))
        .Switch();
    
IDisposable subscription = query.Subscribe(sea => Console.WriteLine("Hello"));

The query is firing for every Scroll event and starts a one second timer. The Switch operator watches for every Timer produces and only connects to the latest one produced, thus ignoring the previous Scroll events.

And that's it.

After scrolling has a 1 second pause the word "Hello" is written to the console. If you begin scrolling again then after every further 1 second pause it fires again.

Upvotes: 2

IV.
IV.

Reputation: 9463

In my own experience I've dealt with lots of scenarios just like the one you describe, e.g. update something one second after the mouse stops moving etc.

For a long time I would do timer restarts just the way you describe, by cancelling an old task and starting a new one. But I never really liked how messy that was, so I came up with an alternative that I use in production code. Long-term it has proven quite reliable. It takes advantage of the captured context associated with a task. Multiple instances of TaskCanceledException no longer occur.

class WatchDogTimer
{
    int _wdtCount = 0;
    public TimeSpan Interval { get; set; } = TimeSpan.FromSeconds(1);
    public void Restart(Action onRanToCompletion)
    {
        _wdtCount++;
        var capturedCount = _wdtCount;
        Task
            .Delay(Interval)
            .GetAwaiter()
            .OnCompleted(() =>
            {
                // If the 'captured' localCount has not changed after awaiting the Interval, 
                // it indicates that no new 'bones' have been thrown during that interval.        
                if (capturedCount.Equals(_wdtCount))
                {
                    onRanToCompletion();
                }
            });
    }
} 

Another nice perk is that it doesn't rely on platform timers and works just as well in iOS/Android as it does in WinForms/WPF.


For purposes of demonstration, this can be exercised in a quick console demo where the MockUpdateView() action is sent to the WDT 10 times at 500 ms intervals. It will only execute one time, 500 ms after the last restart is received.

    static void Main(string[] args)
    {
        Console.Title = "Test WDT";
        var wdt = new WatchDogTimer { Interval = TimeSpan.FromMilliseconds(500) };

        Console.WriteLine(DateTime.Now.ToLongTimeString());

        // "Update view 500 ms after the last restart."
        for (int i = 0; i < 10; i++)
        {
            wdt.Restart(onRanToCompletion: ()=>MockUpdateView());
            Thread.Sleep(TimeSpan.FromMilliseconds(500));
        }
        Console.ReadKey();
    }
    static void MockUpdateView()
    {
        Console.WriteLine($"Update now! WDT expired {DateTime.Now.ToLongTimeString()}");
    }
}

So, with 500 ms times 10 restarts this verifies one event at 5 seconds from the start.

console output

Upvotes: 2

JonasH
JonasH

Reputation: 36649

One approach is to create a timer and reset this whenever the user does something. For example using System.Timers.Timer

timer = new Timer(1000);
timer.SynchronizingObject = myControl; // Needs a winforms object for synchronization
timer.Elapsed += OnElapsed; 
timer.Start(); // Don't forget to stop the timer whenever you are done

...
private void OnUserUpdate(){
    timer.Interval = 1000; // Setting the interval will reset the timer
}

There are multiple timers to chose from, I believe the same pattern is possible with the other timers. DispatchTimer might be most suitable if you use WPF.

Note that both System.Timers.Timer and Task.Delay uses System.Threading.Timer in the background. It is possible to use this directly, just call the .Change method to reset it. But be aware that this raises the event on a taskpool thread, so you need to provide your own synchronization.

Upvotes: 0

Related Questions