1

I have a long running calculation that is dependant on an input value. If the input value is changed while the calculation is running, the current calculation should be canceled and a new calculation started after the previous one has completed.

The basic idea is as follows:

Task _latestTask = Task.CompletedTask;
CancellationTokenSource _cancellationTokenSource;

int Value
{
    get => _value;
    set
    {
        _value = value;
        UpdateCalculation();
    }
}

void UpdateCalculation()
{
    _cancellationTokenSource?.Cancel();
    _cancellationTokenSource = new CancellationTokenSource();
    var cancellationToken = _cancellationTokenSource.Token;
    var newTask = new Task(() => DoCalculation(Value, cancellationToken));
    _latestTask.ContinueWith(antecedent => newTask.Start(), cancellationToken);
    _latestTask = newTask;
}

However, I am finding that depending on how often Value is set, it's possible that the continuation task is cancelled before the new task is started. The whole chain of tasks stop.

How should I organize things so that the changed value causes the current calculation to be abandoned and a new calculation started once I know the previous task has completed?

fractor
  • 1,534
  • 2
  • 15
  • 30
  • 1
    Have you tried removing the `cancellationToken` from the `ContinueWith` method? Also is it guaranteed that the `Value` will be updated by a single thread, or you want the whole mechanism to be thread-safe as well? – Theodor Zoulias Aug 14 '20 at 21:42
  • I would just rewrite it with `await` in infinite loop... Also indeed catching exception needed for that is a bad style... – Alexei Levenkov Aug 14 '20 at 22:41
  • 1
    @TheodorZoulias Yes, this works perfectly now. Seems obvious in retrospect, thanks. It is called from the UI thread in response to a user change so doesn't need to to be thread-safe. Thanks for your help. – fractor Aug 15 '20 at 06:39
  • @AlexeiLevenkov Can you give an infinite loop example? Not sure what you mean. I understand the point about await and exception handling. – fractor Aug 15 '20 at 06:42

1 Answers1

2

Here is how you could refactor your UpdateCalculation method in order to replace the old school ContinueWith with the newer async/await technology, and also to ensure that the CancellationTokenSource will be eventually disposed.

async void UpdateCalculation()
{
    _cancellationTokenSource?.Cancel();
    using (var cts = new CancellationTokenSource())
    {
        _cancellationTokenSource = cts;
        var previousTask = _latestTask;
        var newTask = new Task(() => DoCalculation(Value, cts.Token), cts.Token);
        _latestTask = newTask;
        // Prevent an exception from any task to crash the application
        // It is possible that the newTask.Start() will throw too
        try { await previousTask } catch { }
        try { newTask.Start(); await newTask; } catch { }
        // Ensure that the CTS will not be canceled after is has been disposed
        if (_cancellationTokenSource == cts) _cancellationTokenSource = null;
    }
}

This implementation is not thread-safe. It requires that the UpdateCalculation will always be called from the UI thread.

The documentation issues a strong warning about the necessity of disposing the CancellationTokenSource:

Always call Dispose before you release your last reference to the CancellationTokenSource. Otherwise, the resources it is using will not be freed until the garbage collector calls the CancellationTokenSource object's Finalize method.

Related question: When to dispose CancellationTokenSource?

Theodor Zoulias
  • 34,835
  • 7
  • 69
  • 104
  • I can't use a using statement here because the current calculation needs to be cancelable from elsewhere, however, I take the point about disposing of the source. Apologies for the sloppy question. – fractor Aug 17 '20 at 10:40
  • If we supply a token to the Task constructor, don't we end up with a similar problem to the one that prompted the original question? e.g. "System.InvalidOperationException: Start may not be called on a task that has completed."? – fractor Aug 17 '20 at 10:44
  • Yes, supplying a token opens the possibility for the `Task.Start` to throw. So it complicates things in your case, because you must handle this exception. On the other hand if the `DoCalculation` is not very responsive on the cancellation of the token, supplying a token in the `Task` constructor may improve the efficiency of the cancellation. – Theodor Zoulias Aug 17 '20 at 12:05
  • I don't want an exception on Task.Start. I want it to run and not be canceled before it is started. This is the problem I originally asked about. – fractor Aug 17 '20 at 12:14
  • @fractor I assumed that you are only interested about the task of the most recent update of the `Value` property, and the tasks of the preceding updates should either get canceled or even better don't start at all. Why is it important to start a task, if it's going to get immediately canceled anyway? Btw in retrospect your initial code with `ContinueWith` looks OK as is, and should not cause the whole chain of tasks to stop. Are you sure that you use the UI thread everywhere? Check if you have other `ContinueWith` elsewhere, because their actions are running on `ThreadPool` threads. – Theodor Zoulias Aug 17 '20 at 12:43
  • The previous tasks should get canceled, but they must not be canceled before they are started since the subsequent attempt to start a canceled task throws an exception. var cts = new CancellationTokenSource(); var t = new Task(() => { }, cts.Token); cts.Cancel(); t.Start(); // Throws InvalidOperationException – fractor Aug 17 '20 at 13:02
  • @fractor yes, but what's the problem with this exception? It's not stopping the chain of tasks in your original code. It is called from a fire-and-forget continuation. Unless you are calling `Start` from elsewhere too. – Theodor Zoulias Aug 17 '20 at 13:25
  • 1
    Yes, I see what you mean. I'm left wondering why removing the cancellation token from the call to ContinueWith resolved my issue. – fractor Aug 17 '20 at 14:09
  • 1
    I also see that _cancellationTokenSource.Cancel can be called elsewhere since Dispose won't be called until the new task has completed. Thanks. – fractor Aug 17 '20 at 14:26
  • 1
    I do find `System.InvalidOperationException: Start may not be called on a task that has completed.` uncomfortable though. – fractor Aug 17 '20 at 14:41
  • 1
    @fractor I agree about being uncomfortable. TBH I was unaware of this behavior until I wrote this answer. :-) – Theodor Zoulias Aug 17 '20 at 18:43