Per-thread state with ThreadStatic and ThreadLocal, why it breaks across await, and how AsyncLocal flows with ExecutionContext.
Thread-local storage gives each thread its own copy of a value. [ThreadStatic] marks a static field as per-thread (fast, but the field initialiser runs only once, so other threads see the default). ThreadLocal<T> adds a per-thread factory, optional tracking of all values and Dispose. Both are tied to the thread, so they break across await, where the continuation may run on another thread. AsyncLocal<T> is tied to the logical async flow instead: it is stored in the ExecutionContext and flows into awaited continuations and Task.Run, which is how Activity.Current and logging scopes work.
"There are two different kinds of 'ambient' storage in .NET.
[ThreadStatic]andThreadLocal<T>are bound to the OS thread: each thread gets its own slot.[ThreadStatic]is the fastest, but its field initialiser runs only once, so other threads seedefault.ThreadLocal<T>adds a per-thread factory, optional tracking of all values, andDispose. Both break acrossawait, because the continuation can run on a different pool thread, and pool threads are reused, so leftover values leak into unrelated work.AsyncLocal<T>is bound to the logical async flow instead: it lives in theExecutionContext, which is captured atawait,Task.Run, timers and queued work, so it survives thread hops. Changes flow down into callees, never back up to callers. I use thread-locals for small thread-bound caches,AsyncLocal<T>for tracing and logging context, and I pass business data like tenant or user explicitly or through scoped services."
| Feature | [ThreadStatic] |
ThreadLocal<T> |
AsyncLocal<T> |
|---|---|---|---|
| Bound to | OS thread | OS thread | Async flow (ExecutionContext) |
Survives await thread hops |
No | No | Yes |
| Per-thread initialisation | No: inline initialiser runs once | Yes: valueFactory |
Not applicable |
| Enumerate all values | No | trackAllValues: true → Values |
No |
| Disposal | No | IDisposable |
No |
| Cost of access | Lowest | Low | Low read; each set creates a new context (allocation) |
| Typical use | Thread-bound caches (e.g. StringBuilderCache) |
Per-thread accumulators, non-thread-safe objects | Activity.Current, logging scopes, IHttpContextAccessor |
Request A on thread 7: tenant = "A" -> await db -> resumes on thread 12 -> reads thread 12's tenant
Request B on thread 12: tenant = "B" ...
await runs on any pool thread.AsyncLocal<T> flows| Situation | Value seen |
|---|---|
Set before await, read after |
Same value (context restored on the continuation) |
Set in caller, read in Task.Run / timer / queued work |
Inherited |
Set in a child async method, read in the caller after await child |
Caller's original value |
| Store a mutable object and mutate it in a child | Caller sees the mutation (same object instance) |
Work queued with UnsafeQueueUserWorkItem / under SuppressFlow() |
Not inherited |
| Data | Store it in | Why |
|---|---|---|
| Reusable buffer / formatter cache | [ThreadStatic] |
Thread-bound, fastest |
| Per-thread counters summed later | ThreadLocal<T> with trackAllValues |
Lock-free aggregation |
| Non-thread-safe helper instance per thread | ThreadLocal<T> |
Factory per thread |
| Trace id, activity, logging scope | AsyncLocal<T> (via Activity, ILogger scopes) |
Must cross awaits |
| Tenant, user, transaction, culture for business logic | Explicit parameter or scoped DI service | Testable, visible, no leaks |
In our [multi-tenant SaaS API], middleware put the current tenant id in a [ThreadStatic] field, and repositories read it to filter queries. Under load, a few users reported seeing [another tenant's invoices], and we couldn't reproduce it locally. I added the managed thread id to the logs and saw requests start on one thread and resume on another after an EF Core await; the continuation then read whatever tenant the previous request on that thread had left. I replaced the field with a scoped ITenantContext populated by middleware and injected into the DbContext, added a global EF Core query filter on tenant id, and wrote an integration test that runs two tenants concurrently and asserts isolation. We also banned [ThreadStatic] in application code with an analyser rule; AsyncLocal remained only for logging and tracing context.
Checks the candidate understands thread affinity versus async flow.
Green flags
await.[ThreadStatic] initialiser trap.AsyncLocal<T> flows with ExecutionContext and only downwards.Red flags
async code stays on one thread.[ThreadStatic] for request data.[ThreadStatic] internals and the initialiser trap[ThreadStatic] private static int s_count = 5; // runs ONCE, on the thread that runs the static ctor
[ThreadStatic] private static StringBuilder? s_sb; // correct: lazy per thread
static StringBuilder Get() => s_sb ??= new StringBuilder(256);
The runtime keeps a per-thread block of thread-static storage for each type. Access is a few instructions (the JIT has fast paths for thread statics in recent versions). Because the inline initialiser is part of the type's static constructor, only one thread ever gets it.
ThreadLocal<T> specificsusing var perThread = new ThreadLocal<long>(() => 0, trackAllValues: true);
Parallel.For(0, 1_000_000, i => perThread.Value += i % 7);
long total = perThread.Values.Sum(); // aggregate without locking in the hot loop
trackAllValues (available since .NET Framework 4.5) keeps a list of every thread's value.ThreadLocal<T> instance has an ID; creating many short-lived instances churns IDs and per-thread arrays, so keep them static or long-lived and dispose them.Value on a disposed instance throws ObjectDisposedException.ExecutionContext and AsyncLocal<T> internalsExecutionContext is an immutable map of AsyncLocal keys to values.
set a.Value = 1 -> current EC: {a:1}
await X -> EC {a:1} captured with the continuation
child sets a=7 -> child's EC becomes {a:7} (new object); caller's EC still {a:1}
continuation runs -> runtime restores captured EC {a:1}
AsyncLocal<T> has a constructor overload with a value-changed callback (AsyncLocalValueChangedArgs<T>) that fires when the value changes because of context switches; it's used by framework code, rarely by apps.using (ExecutionContext.SuppressFlow())
{
_timer = new Timer(OnTick, null, 1000, 1000); // timer callbacks won't carry this request's AsyncLocals
}
Useful when a request starts long-lived background work (timers, caches, connection pools): otherwise the background work holds the request's context (activity, scopes, HttpContext holder) for its whole lifetime. ThreadPool.UnsafeQueueUserWorkItem and Thread.UnsafeStart do the same.
AsyncLocal| Feature | Mechanism |
|---|---|
Activity.Current (distributed tracing) |
AsyncLocal<Activity> |
ILogger.BeginScope |
AsyncLocal scope provider |
IHttpContextAccessor.HttpContext |
AsyncLocal holder object (cleared when the request ends) |
CultureInfo.CurrentCulture |
Flows with ExecutionContext in async code |
TransactionScope with TransactionScopeAsyncFlowOption.Enabled |
Flows ambient transaction across awaits |
| Version | Change |
|---|---|
| .NET Framework 4.0 | ThreadLocal<T> |
| .NET Framework 4.5 | trackAllValues; CallContext.LogicalSetData was the async-flow mechanism |
| .NET Framework 4.6 / .NET Core | AsyncLocal<T> |
| .NET 5 | Thread.UnsafeStart |
| .NET 8–10 | Faster thread-static access in the JIT; semantics unchanged |
ConfigureAwait(false) and run it in a console app; observe it still may switch threads (no SynchronizationContext).List<int> in an AsyncLocal, add to it in a child method, and observe the caller sees the mutation.AsyncLocal in a "request", start a Timer without SuppressFlow, and print the value from the timer callback minutes later.AsyncLocal<int> in a loop with [MemoryDiagnoser] to see allocations per set.ThreadLocal<long> with trackAllValues in Parallel.For and compare with Interlocked.Add on a shared counter.using System.Threading;
await Demo.RunAsync();
static class Demo
{
[ThreadStatic] private static int s_threadStatic;
private static readonly ThreadLocal<int> s_threadLocal = new(() => -1, trackAllValues: true);
private static readonly AsyncLocal<int> s_asyncLocal = new();
public static async Task RunAsync()
{
s_threadStatic = 42;
s_threadLocal.Value = 42;
s_asyncLocal.Value = 42;
Console.WriteLine($"Before await on thread {Environment.CurrentManagedThreadId}");
await Task.Delay(50).ConfigureAwait(false);
Console.WriteLine($"After await on thread {Environment.CurrentManagedThreadId}");
Console.WriteLine($"ThreadStatic = {s_threadStatic}"); // 0 if the thread changed
Console.WriteLine($"ThreadLocal = {s_threadLocal.Value}"); // -1 if the thread changed
Console.WriteLine($"AsyncLocal = {s_asyncLocal.Value}"); // 42
await ChildAsync();
Console.WriteLine($"After child, AsyncLocal = {s_asyncLocal.Value}"); // still 42
}
private static async Task ChildAsync()
{
s_asyncLocal.Value = 7; // visible here and below, not to the caller
await Task.Yield();
}
}
[ThreadStatic]What happens: middleware sets a thread-static value read by repositories.
Why it is wrong: after await, code may run on another thread and read another request's value.
Do instead: explicit parameters or scoped DI services.
[ThreadStatic] field inlineWhat happens: [ThreadStatic] static int x = 5;.
Why it is wrong: only one thread gets 5; others see 0.
Do instead: lazy initialisation per thread, or ThreadLocal<T> with a factory.
AsyncLocal changes to be visible to the callerWhat happens: a child async method sets a correlation id and the caller reads it afterwards. Why it is wrong: the caller's context is restored when the child returns. Do instead: return the value, or set it before calling the child.
AsyncLocalWhat happens: a shared mutable bag is stored and mutated by parallel children. Why it is wrong: all flows share the same instance: races and unexpected visibility upward. Do instead: store immutable values; replace rather than mutate.
What happens: a request starts a timer or cache refresh loop.
Why it is wrong: the background work captures the request's ExecutionContext (activity, scopes, HttpContext holder) indefinitely.
Do instead: ExecutionContext.SuppressFlow(), UnsafeQueueUserWorkItem, or start background work at startup.
What happens: a [ThreadStatic] cache holds request data after the work item ends.
Why it is wrong: the next unrelated work item on that thread sees it.
Do instead: reset it in finally, or store only request-independent data.
ThreadLocal<T> per object and never disposingWhat happens: each instance of a class owns a ThreadLocal<T>.
Why it is wrong: per-thread values stay referenced; ID churn adds overhead.
Do instead: static ThreadLocal<T>, or dispose with the owner.
AsyncLocal for business data "because it's convenient"What happens: user id, tenant, transaction flow invisibly through AsyncLocal.
Why it is wrong: hidden dependencies, hard tests, surprising behaviour with background work and parallelism.
Do instead: keep AsyncLocal for cross-cutting diagnostics.
AsyncLocal values in tight loopsWhat happens: a value is set per item in a hot loop.
Why it is wrong: each set allocates a new ExecutionContext.
Do instead: set once per operation, or pass the value as a parameter.
HttpContext after the request ends via IHttpContextAccessorWhat happens: background work reads HttpContextAccessor.HttpContext later.
Why it is wrong: it's null or belongs to an invalid/recycled context.
Do instead: copy the needed values into a message before starting background work.
Ambient state is convenient and dangerous. I keep [ThreadStatic] for small thread-bound caches, AsyncLocal<T> for tracing and logging context, and pass business data such as tenant or user explicitly.
| Data | Store it in | Reason |
|---|---|---|
| Reusable buffer cache | [ThreadStatic] |
Thread-bound, very fast |
| Per-thread counters to sum | ThreadLocal<T> with trackAllValues |
Lock-free aggregation |
| Trace id, logging scope | AsyncLocal<T> |
Must flow across awaits |
| Tenant, user, transaction | Explicit parameter or scoped DI | Testable, no hidden leaks |
The ThreadPool is a process-wide set of reusable background threads that run queued work items: Task.Run, async continuations, timer callbacks and ASP.NET Core requests. Work goes into a global queue or, when queued from a pool thread, into that thread's local queue, and idle threads steal from others. The pool starts up to minimum threads (default: processor count) immediately on demand; above that it adds threads gradually, using a hill-climbing algorithm that measures throughput plus a starvation detector that injects threads when queued work is not progressing. That gradual injection is why blocking pool threads causes latency spikes.
A System.Threading.Thread is a managed wrapper over one dedicated operating-system thread with its own stack and scheduling. Creating one is expensive (a stack reservation, a kernel object, scheduler work), so normal application code uses the ThreadPool through Task.Run, async/await or framework hosts. I only create a Thread directly for long-lived, dedicated work: a message pump, a blocking legacy loop, a thread that needs a specific apartment state, name, priority or stack size, or work that must never compete with pool threads.
How to tell it (STAR)
We had [a rare tenant data leak]. I added thread ids to logs and saw requests switch threads after awaits, so a `[ThreadStatic]` tenant id was being read by the wrong request. I replaced it with a scoped tenant context and a query filter, and added a two-tenant concurrency test. The incident led to a rule banning thread-static state in our app code.
Last updated 31 Oct 2026.