Clik here to view.

At the end of my previous post, in which I took a deep-dive into the new .NET 6 API Task.WaitAsync()
, I included a brief side-note about what happens to your Task
when you use Task.WaitAsync()
. Namely, that even if the WaitAsync()
call is cancelled or times-out, the original Task
continues running in the background.
Depending on your familiarity with the Task Parallel Library (TPL) or .NET in general, this may or may not be news to you, so I thought I would take some time to describe some of the potential gotchas at play when you cancel Task
s generally (or they "timeout").
Without special handling, a Task
always run to completion
Let's start by looking at what happens to the "source" Task
when you use the new WaitAsync()
API in .NET 6.
One point you might not consider when calling WaitAsync()
is that even if a timeout occurs, or the cancellation token fires, the source Task
will continue to execute in the background. The caller who executed source.WaitAsync()
won't ever see the output of result of the Task
, but if that Task
has side effects, they will still occur.
For example, in this trivial example, we have a function that loops 10 times, printing to the console every second. We invoke this method and call WaitAsync()
:
using System;
using System.Threading.Tasks;
try
{
await PrintHello().WaitAsync(TimeSpan.FromSeconds(3));
}
catch(Exception)
{
Console.WriteLine("I'm done waiting");
}
// don't exit
Console.ReadLine();
async Task PrintHello()
{
for(var i=0; i<10; i++)
{
Console.WriteLine("Hello number " + i);
await Task.Delay(1_000);
}
}
The output shows that the Task
we awaited was cancelled after 3s, but the PrintHello()
task continued to execute:
Hello number 0
Hello number 1
Hello number 2
Hello number 3
I'm done waiting
Hello number 4
Hello number 5
Hello number 6
Hello number 7
Hello number 8
Hello number 9
WaitAsync()
allows you to control when you stop waiting for a Task
to complete. It does not allow you to arbitrarily stop a Task
from running. The same is true if you use a CancellationToken
with WaitAsync()
, the source Task
will run to completion, but the result won't be observed.
You'll also get a similar behaviour if you use a "poor man's" WaitAsync()
(which is one of the approaches you could use pre-.NET 6):
using System;
using System.Threading.Tasks;
var printHello = PrintHello();
var completedTask = Task.WhenAny(printHello, Task.Delay(TimeSpan.FromSeconds(3));
if (completedTask == printHello)
{
Console.WriteLine("PrintHello finished"); // this won't be called due to the timeout
}
else
{
Console.WriteLine("I'm done waiting");
}
// don't exit
Console.ReadLine();
async Task PrintHello()
{
for(var i=0; i<10; i++)
{
Console.WriteLine("Hello number " + i);
await Task.Delay(1_000);
}
}
As before, the output shows that the printHello
task continues to execute, even after we've stopped waiting for it:
Hello number 0
Hello number 1
Hello number 2
Hello number 3
I'm done waiting
Hello number 4
Hello number 5
Hello number 6
Hello number 7
Hello number 8
Hello number 9
So what if you want to stop a Task
in its tracks, and stop it using resources?
Actually cancelling a task
The only way to get true cancellation of a background Task
, is for the Task
itself to support it. That's why async
APIs should almost always take a CancellationToken
, to provide the caller a mechanism to ask the Task
to stop processing!
For example, we could rewrite the previous program using a CancellationToken
instead:
using System;
using System.Threading;
using System.Threading.Tasks;
try
{
using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(3));
await PrintHello(cts.Token);
}
catch(Exception)
{
Console.WriteLine("I'm done waiting");
}
// don't exit
Console.ReadLine();
async Task<bool> PrintHello(CancellationToken ct)
{
for(var i=0; i<10; i++)
{
Console.WriteLine("Hello number " + i);
ct.ThrowIfCancellationRequested(); // we could exist gracefully, but just throw instead
await Task.Delay(TimeSpan.FromSeconds(1), ct);
}
return true;
}
Running this program shows the following output:
Hello number 0
Hello number 1
Hello number 2
I'm done waiting
We could alternatively re-write the PrintHello
method so that it doesn't throw when cancellation is requested:
async Task<bool> PrintHello(CancellationToken ct)
{
try
{
for(var i=0; i<10; i++)
{
Console.WriteLine("Hello number " + i);
if(ct.IsCancellationRequested())
{
return false;
}
ct.ThrowIfCancellationRequested();
// This will throw if ct is cancelled while waiting
// so need the try catch
await Task.Delay(TimeSpan.FromSeconds(1), ct);
}
return true;
}
catch(TaskCancelledException ex)
{
return false;
}
}
Note, however, that in a recent blog post, Stephen Cleary points out that generally shouldn't silently exit when cancellation is requested. Instead, you should throw.
Handling cancellation cooperatively with a CancellationToken
is generally a best practice, as consumers will typically want to stop a Task
from processing immediately when they stop waiting for it. But what if you want to do something a bit different…
If the Task
keeps running, can I get its result?
While writing this post I realised there was an interesting scenario you could support with the help of the new WaitAsync()
API in .NET 6. Namely, you can await
the source Task
after WaitAsync()
has completed. For example, you could wait a small time for a Task
to complete, and if it doesn't, do something else in the mean time, before coming back to it later:
using System;
using System.Threading.Tasks;
var task = PrintHello();
try
{
// await with a timeout
await task.WaitAsync(TimeSpan.FromSeconds(3));
// if this completes successfully, the job finished before the timeout was exceeded
}
catch(TimeoutException)
{
// Timeout exceeded, do something else for a while
Console.WriteLine("I'm done waiting, doing some other work....");
}
// Ok, we really need that result now
var result = await task;
Console.WriteLine("Received: " + result);
async Task<bool> PrintHello()
{
for(var i=0; i<10; i++)
{
Console.WriteLine("Hello number " + i);
await Task.Delay(TimeSpan.FromSeconds(1));
}
return true;
}
This is similar to the first example in this post, where the task continues to run after we time out. But in this case we subsequently retrieve the result of the completed task, even though the WaitAsync()
task was cancelled:
Hello number 0
Hello number 1
Hello number 2
Hello number 3
I'm done waiting, doing some other work....
Hello number 4
Hello number 5
Hello number 6
Hello number 7
Hello number 8
Hello number 9
Received: True
Building support for cancellation into your async
methods gives the most flexibility for callers, as it allows them to cancel it. And you probably should cancel tasks if you're not waiting for them any more, even if they don't have side effects.
Cancelling calls to Task.Delay()
One example of a Task
without side effects is Task.Delay()
. You've likely used this API before; it waits asynchronously (without blocking a Thread
) for a time period to expire before continuing.
It's possible to use Task.Delay()
as a "timeout", similar to the way I showed previously as a "poor man's WaitAsync
", something like the following:
// Start the actual task we care about (don't await it)
var task = DoSomethingAsync();
// Create the timeout task (don't await it)
var timeout = TimeSpan.FromSeconds(10);
var timeoutTask = Task.Delay(timeout);
// Run the task and timeout in parallel, return the Task that completes first
var completedTask = await Task.WhenAny(task, timeoutTask);
if (completedTask == task)
{
// await the task to bubble up any errors etc
return await task.ConfigureAwait(false);
}
else
{
throw new TimeoutException($"Task timed out after {timeout}");
}
I'm not saying this is the "best" way to create a timeout, you could also use
CancellationTokenSource.CancelAfter()
.
In the previous example we start both the "main" async
task and also call Task.Delay(timeout)
, without await
ing either of them. We then use Task.WhenAny()
to wait for either the task to complete, or the timeout Task
to complete, and handle the result as appropriate.
The "nice" thing about this approach is that you don't necessarily have to have any exception handling. You can throw if you want (as I have in the case of a Timeout
in the previous example), but you could easily use a non-exception approach.
The thing to remember here is that whichever Task
finishes first the other one keeps running.
So why does it matter if a Task.Delay()
keeps running in the background? Well, Task.Delay()
uses a timer under-the-hood (specifically, a TimerQueueTimer
). This is mostly an implementation detail. But if you are creating a lot of calls to Task.Delay()
for some reason, you may be leaking these references. The TimerQueueTimer
instances will be cleaned up when the Task.Delay()
call expires, but if you're creating Task.Delay()
calls faster than they're ending, then you will have a memory leak.
So how can you avoid this leak? The "simple" answer, much as it was before, is to cancel the Task
when you're done with it. For example:
var task = DoSomethingAsync();
var timeout = TimeSpan.FromSeconds(10);
// 👇 Use a CancellationTokenSource, pass the token to Task.Delay
var cts = new CancellationTokenSource();
var timeoutTask = Task.Delay(timeout, cts.Token);
var completedTask = await Task.WhenAny(task, timeoutTask);
if (completedTask == task)
{
cts.Cancel(); // 👈 Cancel the delay
return await task.ConfigureAwait(false);
}
else
{
throw new TimeoutException($"Task timed out after {timeout}");
}
This approach will prevent the Task.Delay()
from leaking, though be aware, the CancellationTokenSource
is also quite heavyweight, so you should take that into account too if you're creating a lot of them!
Summary
This post showed a number of different scenarios around Task
cancellation, and what happens to Task
s that don't support cooperative cancellation with a CancellationToken
. In all cases, the Task
keeps running in the background. If the Task
causes side effects, then you need to be aware these may continue happening. Similarly, even if the Task
doesn't have additional side effects, it may be leaking resources by continuing to run.