How to safely cancel a task using a CancellationToken and await Task.WhenAll

8.2k views Asked by At

I have a framework which creates a CancellationTokenSource, configures CancelAfter, then calls an async method and passes the Token. The async method then spawns many tasks, passing the cancellation token to each of them, and then awaits the collection of tasks. These tasks each contain logic to gracefully cancel by polling IsCancellationRequested.

My issue is that if I pass the CancellationToken into Task.Run() an AggregateException is thrown containing a TaskCanceledException. This prevents the tasks from gracefully canceling.

To get around this I can not pass the CancelationToken into Task.Run, however I'm not sure what I will be losing. For instance, I like the idea that if my task hangs and cannot perform the graceful cancel this exception will force it down. I was thinking I could string along two CancelationTokens to handle this, one 'graceful' and the other 'force'. However, I don't like that solution.

Here is some psudo-code representing what I described above..

public async Task Main()
{
    CancellationTokenSource cts = new CancellationTokenSource();
    cts.CancelAfter(30000);
    await  this.Run(cts.Token);
}

public async Task Run(CancellationToken cancelationToken)
{
    HashSet<Task> tasks = new HashSet<Task>();
    foreach (var work in this.GetWorkNotPictured)
    {
        // Here is where I could pass the Token, 
        //   however If I do I cannot cancel gracefully
        //   My dilemma here is by not passing I lose the ability to force
        //   down the thread (via exception) if         
        //   it's hung for whatever reason
        tasks.Add(Task.Run(() => this.DoWork(work, cancelationToken))
    }

    await Task.WhenAll(tasks);

    // Clean up regardless of if we canceled
    this.CleanUpAfterWork();

    // It is now safe to throw as we have gracefully canceled
    cancelationToken.ThrowIfCancellationRequested();
}

public static void DoWork(work, cancelationToken)
{
    while (work.IsWorking)
    {
        if (cancelationToken.IsCancellationRequested)
          return // cancel gracefully
        work.DoNextWork();
    }
}
2

There are 2 answers

3
Servy On

Provide the CancellationToken to Task.Run in addition to passing it to the method doing the work. When you do this Task.Run can see that the exception thrown was caused by the CancellationToken it was given, and will mark the Task as cancelled.

tasks.Add(Task.Run(() => this.DoWork(work, cancelationToken),
    cancelationToken));

Once you've done this you can ensure that DoWork throws when the token is cancelled, rather than checking IsCancellationRequested to try to end by being marked as "completed successfully".

3
Stephen Cleary On

I recommend that you follow the standard cancellation pattern of throwing an exception rather than just returning:

public static void DoWork(work, cancellationToken)
{
  while (work.IsWorking)
  {
    cancellationToken.ThrowIfCancellationRequested();
    work.DoNextWork();
  }
}

If you have cleanup work to do, that's what finally is for (or using, if you can refactor that way):

public async Task Run(CancellationToken cancellationToken)
{
  HashSet<Task> tasks = new HashSet<Task>();
  foreach (var work in this.GetWorkNotPictured)
  {
    tasks.Add(Task.Run(() => this.DoWork(work, cancellationToken))
  }

  try
  {
    await Task.WhenAll(tasks);
  }
  finally
  {
    this.CleanUpAfterWork();
  }
}