How to Use AsyncLocal in C# for Async State Flow
In modern asynchronous C# programming, managing ambient context
across multiple threads can be challenging. This article explains what
AsyncLocal<T> is, why traditional thread-local
storage fails in asynchronous workflows, how
AsyncLocal<T> leverages the .NET
ExecutionContext to persist data across await
boundaries, and the underlying copy-on-write mechanics that govern its
behavior.
The Problem with ThreadLocal in Async Code
Before AsyncLocal<T>, developers relied on
ThreadLocal<T> to store contextual data specific to
the executing thread. However, asynchronous programming using
async and await fundamentally breaks the
assumption of single-thread execution:
- When an
awaityields execution, the calling thread is returned to theThreadPool. - When the awaited task completes, the continuation may resume on an entirely different thread.
- Because
ThreadLocal<T>is strictly tied to an individual managed thread ID, any state stored inside it is lost or swapped when execution resumes on a different thread.
What is AsyncLocal<T>?
Introduced in .NET 4.6, AsyncLocal<T> represents
ambient data that is local to a specific asynchronous control flow
rather than a specific physical thread.
private static readonly AsyncLocal<string> _correlationId = new AsyncLocal<string>();Regardless of how many times execution yields or which thread pool
threads are assigned to run the continuations, the data stored inside
_correlationId.Value remains accessible throughout that
logical path of execution.
How AsyncLocal
Flows Across await Boundaries
The persistence of AsyncLocal<T> values is powered
by the .NET ExecutionContext.
- Context Capture: When an asynchronous method begins
or invokes an
awaitexpression, the runtime captures the currentExecutionContext. This context contains an internal dictionary holding references to all activeAsyncLocal<T>values. - Context Restoration: When the awaited operation
completes and a thread becomes available to run the continuation, the
runtime restores the captured
ExecutionContextonto that thread before executing the remaining code. - Thread Transparency: Because the context travels
with the delegate representing the continuation, your code observes the
same
AsyncLocal<T>values even if execution resumes on a different thread.
Flow Mechanics: Downward Flow and Copy-on-Write
AsyncLocal<T> follows a "flow down, not up" model
governed by copy-on-write semantics.
- Parent to Child (Downward): A child asynchronous
method inherits the
AsyncLocal<T>values set by its caller. - Child to Parent (Upward Isolation): If a child
method modifies an
AsyncLocal<T>.Value, that mutation only affects the child's local context. The parent's context remains unchanged when execution returns to the parent.
public async Task ProcessAsync()
{
_correlationId.Value = "Initial";
await ModifyContextAsync();
// Output is still "Initial" because child modifications do not flow upward.
Console.WriteLine(_correlationId.Value);
}
private async Task ModifyContextAsync()
{
// The child starts with "Initial", but reassigning creates a fork.
_correlationId.Value = "Modified";
await Task.Yield();
}Note on Reference Types: If the value stored in
AsyncLocal<T> is a mutable reference type (such as a
class or List<T>), mutating properties
or items of that object will be visible across both parent and
child, because both contexts hold references pointing to the same
instance in memory. To avoid unexpected side effects, store immutable
objects or value types in AsyncLocal<T>.
Suppressing Context Flow
In high-throughput scenarios, capturing and restoring the
ExecutionContext carries a minor performance cost. If you
spawn a background task that does not need ambient state, you can
suppress context flow:
using (ExecutionContext.SuppressFlow())
{
Task.Run(() =>
{
// _correlationId.Value will be null here
});
}
// Context flow is automatically restored when leaving the using scopeMonitoring Changes with AsyncLocalValueChangedArgs
AsyncLocal<T> provides a constructor that accepts
a callback delegate. This delegate triggers whenever the ambient value
changes, whether due to direct assignment or because the context was
restored:
private static readonly AsyncLocal<string> _traceId = new AsyncLocal<string>(args =>
{
Console.WriteLine($"Previous: {args.PreviousValue}, Current: {args.CurrentValue}, Thread Context Changed: {args.ThreadContextChanged}");
});This feature is particularly useful for profiling, cleanup, or synchronizing native tracking contexts.
Common Use Cases
- Distributed Tracing: Storing Correlation IDs or Trace Identifiers across HTTP pipelines and background jobs.
- Security Contexts: Propagating user identity, claims, or tenancy identifiers across asynchronous business logic layers.
- Ambient Scopes: Managing database transactions or diagnostic activity scopes without passing context objects manually through every method signature.