📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

How does thread-local storage work in .NET? Compare [ThreadStatic], ThreadLocal of T and AsyncLocal of T.

Per-thread state with ThreadStatic and ThreadLocal, why it breaks across await, and how AsyncLocal flows with ExecutionContext.

Senior Theory Advanced asynclocal executioncontext thread-local-storage threadlocal

🎯 Key points

  • [ThreadStatic] gives one static slot per thread; its initialiser runs only once
  • ThreadLocal<T> adds a per-thread factory, Values tracking and Dispose
  • Thread-bound storage does not survive await because the thread can change
  • AsyncLocal<T> lives in ExecutionContext and flows into awaits and Task.Run
  • AsyncLocal changes flow down to callees, not back up to callers
  • Pool threads are reused, so leftover thread-local state can leak between requests
  • Prefer explicit parameters or scoped services for business context

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

"There are two different kinds of 'ambient' storage in .NET. [ThreadStatic] and ThreadLocal<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 see default. ThreadLocal<T> adds a per-thread factory, optional tracking of all values, and Dispose. Both break across await, 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 the ExecutionContext, which is captured at await, 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."


1. The three options

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

2. Why thread-locals break in async code

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"  ...
  • The continuation after await runs on any pool thread.
  • The pool reuses threads, so a value left by request B is visible to request A.
  • Result: wrong data, intermittent, almost impossible to reproduce under light load.

3. How 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

4. What to use when

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

Production example

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.

🔍 What the interviewer is checking

Checks the candidate understands thread affinity versus async flow.

Green flags

  • Explains why thread-local state breaks across await.
  • Knows the [ThreadStatic] initialiser trap.
  • Explains that AsyncLocal<T> flows with ExecutionContext and only downwards.
  • Prefers explicit context for business data.

Red flags

  • Thinks async code stays on one thread.
  • Uses [ThreadStatic] for request data.

📚 Learn in depth

1. [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.

2. ThreadLocal<T> specifics

using 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.
  • Each 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.

3. ExecutionContext and AsyncLocal<T> internals

ExecutionContext 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}
  • Setting a value allocates a new context (copy-on-write). Frequent sets in hot paths have a cost.
  • When an async method returns, the runtime restores the caller's context, which is why child changes don't flow up.
  • 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.

4. Suppressing flow

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.

5. How frameworks use 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

6. Version notes

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

7. Try it yourself

  1. Run the code example and note which values survive the thread hop.
  2. Remove ConfigureAwait(false) and run it in a console app; observe it still may switch threads (no SynchronizationContext).
  3. Store a List<int> in an AsyncLocal, add to it in a child method, and observe the caller sees the mutation.
  4. Set an AsyncLocal in a "request", start a Timer without SuppressFlow, and print the value from the timer callback minutes later.
  5. Benchmark setting an AsyncLocal<int> in a loop with [MemoryDiagnoser] to see allocations per set.
  6. Use ThreadLocal<long> with trackAllValues in Parallel.For and compare with Interlocked.Add on a shared counter.

💻 Code

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();
    }
}

⚠ Common mistakes

1. Storing request or tenant context in [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.

2. Initialising a [ThreadStatic] field inline

What 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.

3. Expecting child AsyncLocal changes to be visible to the caller

What 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.

4. Storing mutable objects in AsyncLocal

What 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.

5. Leaking request context into background work

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.

6. Not clearing thread-local state on pool threads

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.

7. Creating ThreadLocal<T> per object and never disposing

What 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.

8. Using 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.

9. Setting AsyncLocal values in tight loops

What 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.

10. Accessing HttpContext after the request ends via IHttpContextAccessor

What 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.

🏛 Senior insight: trade-offs

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

🔗 Follow-ups the interviewer may ask

  1. When: Candidate says pool threads are reused Why: Checks why thread-local state can leak between requests
    How to answer

    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.

  2. When: Candidate uses thread-local caches Why: Probes thread identity and dedicated threads
    How to answer

    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.

🛠 Real-world problems: how you handle them

Tenant data leaked across requests through [ThreadStatic]
Situation
A [multi-tenant SaaS API] set the current tenant id in a `[ThreadStatic]` field from middleware.
Signals
Rare reports of users seeing [another tenant's invoices]; impossible to reproduce locally under light load.
Investigation
Logs with thread ids showed the request started on one thread and continued on another after an EF Core `await`. The continuation read the field value left by a different request.
Resolution
Replaced the field with a scoped `ITenantContext` resolved from DI, and added a global EF Core query filter based on it.
Prevention
Added an analyser ban on `[ThreadStatic]` in application code and a concurrency integration test with two tenants.

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.

More in Threads

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me