This post is part of the series 'Async/await in C#'. Be sure to check out the rest of the blog posts of the series!
So far this series has looked at threads and at the operating system. This post is about the object in the middle: Task. It predates async/await by a release, and understanding it separately makes the compiler rewrite in the next posts much easier to read.
A Task is a promise of a future result. It is not a thread, not an operation, and not a unit of work. It holds three things: a state, a result or an exception, and a list of things to do when it completes.
#The lifecycle
A task starts in one of two ways. Either it is created already complete, or it is created pending and something completes it later. From there it moves in one direction only:
The three final states matter because they behave differently when you await them:
RanToCompletion gives you the value.Faulted rethrows the exception.Canceled throws OperationCanceledException, which is not the same as faulting. Code that treats cancellation as an error usually gets this wrong.
A task never leaves a final state, and it can only be completed once. That is why the API has both SetResult and TrySetResult: the first throws if the task is already complete, the second returns false. In any code where two things might race to complete the same task, use the Try variants.
#TaskCompletionSource: making anything awaitable
Most tasks come from something that already returns one. TaskCompletionSource<T> is how you make a task out of something that does not: an event, a callback, a message arriving on a queue.
The sample turns "the next time this FileSystemWatcher fires" into an awaitable:
C#
public static Task<string> WaitForNextChangeAsync(this FileSystemWatcher watcher, CancellationToken cancellationToken = default)
{
var taskCompletionSource = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
FileSystemEventHandler? handler = null;
CancellationTokenRegistration registration = default;
handler = (_, e) =>
{
watcher.Changed -= handler;
registration.Dispose();
taskCompletionSource.TrySetResult(e.FullPath);
};
watcher.Changed += handler;
registration = cancellationToken.Register(() =>
{
watcher.Changed -= handler;
taskCompletionSource.TrySetCanceled(cancellationToken);
});
return taskCompletionSource.Task;
}
Three details in there are not optional, and each corresponds to a bug I have seen in real code:
TrySetResult, not SetResult. The event and the cancellation callback race. Whichever loses must not throw.- Unsubscribe. Otherwise the handler keeps the closure, the
TaskCompletionSource and everything they reference alive for the lifetime of the watcher. RunContinuationsAsynchronously. This one deserves its own section.
#Where the continuation runs
When you complete a TaskCompletionSource, the code awaiting it has to run somewhere. By default, it runs on the thread that called SetResult, inside the SetResult call, before it returns.
The sample makes that visible:
None SetResult called on thread 4
None continuation ran on thread 4
None SetResult returned on thread 4
RunContinuationsAsynchronously SetResult called on thread 9
RunContinuationsAsynchronously SetResult returned on thread 9
RunContinuationsAsynchronously continuation ran on thread 5
Read the first block carefully. SetResult was called on thread 4, and it did not return until the continuation had also run on thread 4. Whoever awaited that task ran their code inside the completing thread's call stack.
Now imagine that completing thread is the one calling your FileSystemWatcher handlers, or a socket's receive callback, or a lock holder. The continuation might be an arbitrary amount of your application's code. It runs while the lock is held, or while the event dispatch loop is blocked. If that continuation blocks, or takes a lock the completer already holds, you get a deadlock in a place that looks nothing like the cause.
TaskCreationOptions.RunContinuationsAsynchronously fixes it: SetResult queues the continuation and returns immediately.
#Completed tasks are often free
Async methods that return early are extremely common, so the BCL works hard to avoid allocating for them:| Method | Mean | Gen0 | Allocated |
|---|
| CompletedTask | 0.0000 ns | - | - |
| FromResultCached | 0.0000 ns | - | - |
| FromResultWellKnownValue | 0.0000 ns | - | - |
| FromResultArbitraryValue | 2.9597 ns | 0.0005 | 72 B |
| TaskCompletionSourceSetResult | 5.7064 ns | 0.0005 | 72 B |
Benchmark project, measured with BenchmarkDotNet
Task.CompletedTask is a singleton. Task.FromResult(true) is free too, because the runtime keeps cached tasks for a small set of well-known values: true, false, and small integers. Task.FromResult(42) is not one of those, so it allocates 72 bytes.
The first three rows measure below the resolution of the timer, which is the honest way of saying "a static field read". The practical advice is the boring one: if a method frequently returns the same completed value, cache the Task in a static field instead of calling Task.FromResult each time.
#await versus ContinueWith
ContinueWith is the pre-async way to attach a continuation, and you still see it in older code. It is not equivalent to await:| Method | Mean | Ratio | Allocated |
|---|
| Await | 5.5618 ns | 1.000 | 72 B |
| ContinueWith | 22.6856 ns | 4.080 | 120 B |
| GetAwaiterGetResult | 0.0000 ns | 0.000 | - |
Benchmark project, measured with BenchmarkDotNet
Four times slower and heavier, on an already completed task. But performance is the least of it. ContinueWith also:
- Runs on the
TaskScheduler.Current by default, not the captured context, which is a genuinely surprising default inside a UI application. - Gives you a
Task<Task<T>> when the continuation is itself async, unless you remember Unwrap. - Hands you the antecedent task rather than the value, so exceptions arrive as
AggregateException on .Result rather than being rethrown.
Use await. ContinueWith is a low-level primitive, and Stephen Toub's advice that it is almost always the wrong choice in application code still holds.
The third row is worth noting separately. GetAwaiter().GetResult() on an already completed task is free, and it rethrows the original exception rather than an AggregateException. That makes it the correct way to synchronously extract the result of a task you already know is complete. On a task that is not complete it blocks the thread, with all the consequences from the first post of this series.
#Exceptions arrive differently depending on how you wait
This trips people up constantly, so it is worth stating plainly:
C#
var task = Task.FromException(new InvalidOperationException("boom"));
await task; // throws InvalidOperationException
task.Wait(); // throws AggregateException wrapping InvalidOperationException
_ = task.Result; // throws AggregateException wrapping InvalidOperationException
await unwraps and rethrows the first exception, preserving its stack trace with ExceptionDispatchInfo. Wait and Result do not, because they predate await and a task can hold more than one exception, for example a Task.WhenAll where several operations failed.
This is one more reason catch (Exception ex) around a .Result call so often catches the wrong thing.
#What to take away
- A
Task holds a state, a result or exception, and a list of continuations. It is not a thread and not a unit of work. - It completes once, into one of three final states, and cancellation is not the same as failure.
TaskCompletionSource<T> is how you make anything awaitable. Use TrySet*, unsubscribe your handlers, and pass RunContinuationsAsynchronously.- Without that flag, the continuation runs inside the caller of
SetResult, on its thread and inside its stack. - Completed tasks for well-known values are free. Anything else allocates 72 bytes, so cache them when they are hot.
await is cheaper and better behaved than ContinueWith, and it unwraps exceptions where Wait and Result do not.
The next post looks at what await actually requires of the thing you await. It is not Task: it is a pattern, and you can implement it yourself.
#Additional resources
Do you have a question or a suggestion about this post? Contact me!