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!
The thread that resumes after an await is usually not the thread that started the method. That is the whole point, and it creates a problem: anything you attached to the thread is gone.
C#
[ThreadStatic]
private static string? CurrentUser;
CurrentUser = "alice";
await SomethingAsync();
// CurrentUser may be null here
ExecutionContext is the mechanism that solves this. It is the thing that flows across an await, and understanding what it carries, and what it deliberately does not, explains a whole category of confusing behaviour.
#What it is
ExecutionContext is a small immutable object that travels with a logical flow of execution rather than with a thread. When an async method suspends, the context is captured; when the continuation runs, it is restored around the call.
It carries:
AsyncLocal<T> values. This is the main thing, and the only part you interact with directly.- The current culture,
CultureInfo.CurrentCulture and CurrentUICulture. - The security context, notably the Windows impersonation identity.
It does not carry the SynchronizationContext. That is a separate mechanism, it is captured separately by the await itself, and it decides where the continuation runs rather than what state it sees. That is the subject of the next post.
#What flows, in practice
The sample sets an AsyncLocal<string> and follows it around:
before await: request-1 on thread 1
after await: request-1 on thread 4
inside Task.Run: request-1 on thread 6
inside callee: request-2
after callee wrote to it: request-1
with flow suppressed: <null>
Four separate behaviours in six lines. Taking them in order.
It flows across an await. Different thread, same value. This is what makes ambient request ids, trace ids and scoped logging work at all.
It flows into work you start. Task.Run, ThreadPool.QueueUserWorkItem, Timer callbacks and Task.ContinueWith all capture the context at the point you queue them, and restore it when they run.
Writes in a callee do not flow back to the caller. This is the one that surprises people. SetItAsync set the value to request-2, saw request-2 itself, and the caller still saw request-1 afterwards.
The reason is that the context is copy-on-write. Writing to an AsyncLocal does not mutate a shared object, it creates a new ExecutionContext and installs it as the current one for this flow. The caller's flow still points at the old one. So values flow down into called code, never back up.
You can turn it off. ExecutionContext.SuppressFlow() stops the capture for anything started inside the block, which is occasionally what you want for genuinely detached background work.
#AsyncLocal versus ThreadStatic
They look similar and solve opposite problems:
| [ThreadStatic] | AsyncLocal<T> |
|---|
| Tied to | A thread | A logical flow |
Survives an await | No | Yes |
| Survives a thread switch | No | Yes |
| Shared between concurrent operations on one thread | Yes | No |
In async code, [ThreadStatic] is almost always a bug waiting for a thread switch. The sample's tests check exactly that: after an await that changes thread, the AsyncLocal still has its value and the [ThreadStatic] field does not.
#The cost
Ambient state is not free, but the shape of the cost is not obvious:| Method | AsyncLocalCount | Mean | Allocated |
|---|
| SuspendingAsyncMethod | 0 | 1,367.422 ns | 96 B |
| QueueUserWorkItem | 0 | 1,598.319 ns | 112 B |
| UnsafeQueueUserWorkItem | 0 | 1,626.048 ns | 112 B |
| WriteAsyncLocal | 0 | 3.723 ns | - |
| SuspendingAsyncMethod | 1 | 1,330.274 ns | 96 B |
| QueueUserWorkItem | 1 | 1,678.497 ns | 120 B |
| UnsafeQueueUserWorkItem | 1 | 1,635.826 ns | 112 B |
| WriteAsyncLocal | 1 | 3.955 ns | - |
| SuspendingAsyncMethod | 5 | 1,344.521 ns | 96 B |
| QueueUserWorkItem | 5 | 1,637.569 ns | 120 B |
| UnsafeQueueUserWorkItem | 5 | 1,663.684 ns | 112 B |
| WriteAsyncLocal | 5 | 6.188 ns | - |
Benchmark project, measured with BenchmarkDotNet
The timings for the first three rows in each group are dominated by thread pool scheduling and the wait, so ignore the means there and read the allocation column. Three things fall out of it.
With no AsyncLocal in play, the context is free. .NET keeps a shared empty ExecutionContext, and capturing it is a reference copy. This is why the advice "avoid AsyncLocal" is usually pointless: if you are not using it, you are not paying for it.
The capture costs 8 bytes once a value exists. QueueUserWorkItem goes from 112 to 120 bytes as soon as one AsyncLocal holds a value: a field to hold the captured context. UnsafeQueueUserWorkItem stays at 112 because it does not capture.
The suspending async method does not change at all. 96 bytes with zero or five AsyncLocal values. The state machine box already has a field for the context, so carrying a real one instead of the shared empty one costs nothing extra.
Writing gets more expensive as the context grows. 3.7 ns with nothing set, 6.2 ns with five values, because copy-on-write has to copy the existing map. That is the cost that actually scales, and it lands on writes rather than reads. AsyncLocal reads stay cheap; setting a lot of them in a hot loop does not.
#Where this shows up
The mechanism is invisible until it is not. Three places it becomes visible:
Logging scopes. ILogger.BeginScope and every distributed tracing library are built on AsyncLocal. That is how a trace id set at the top of a request is still there five awaits deep in a different assembly.
Culture in tests. Setting CultureInfo.CurrentCulture flows across awaits, which is convenient, and it flows into anything you queue, which occasionally is not. A test that changes culture and forgets to restore it can affect work queued from it.
Leaks. An ExecutionContext is captured by pending continuations, timers and registered callbacks. If an AsyncLocal holds a large object graph, everything waiting on that flow keeps it alive. This is a real source of "why is this object still rooted" investigations.
#What to take away
ExecutionContext is what flows across an await. It carries AsyncLocal<T> values, culture and the security context.- It does not carry the
SynchronizationContext, which is a different mechanism with a different job. - The context flows down into called code and into work you queue. Writes never flow back up, because it is copy-on-write.
[ThreadStatic] does not survive an await. AsyncLocal<T> is the async-safe replacement.- With no
AsyncLocal set, the context is a shared empty instance and costs nothing. - The cost lands on writes and grows with the number of values, not on reads.
ExecutionContext.SuppressFlow opts out, and its AsyncFlowControl must be disposed on the creating thread.
The next post covers the other half of what await captures: the SynchronizationContext, which decides where your continuation runs, and is the reason .Result deadlocks.
#Additional resources
Do you have a question or a suggestion about this post? Contact me!