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 Tasks 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 awaiting 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 Tasks 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.