Using ThreadLocal<T> in C# for Per-Thread State
Learn how C# ThreadLocal<T> provides isolated per-thread state, when to use it instead of ThreadStatic or AsyncLocal, and how it affects memory and disposal.
ThreadLocal<T> in C# stores data that is unique to each thread. When multiple threads access the same ThreadLocal<T> instance, each thread sees its own copy of the value, isolated from every other thread. This is useful when you want to avoid synchronization overhead but still keep per-thread state isolated.
This article covers the core syntax and runtime behavior of ThreadLocal<T>, how it compares with [ThreadStatic] and AsyncLocal<T>, and the memory and disposal considerations that matter in real applications.
Why Thread-Local Storage Exists
In multithreaded applications, shared state typically requires locking or other synchronization mechanisms to prevent race conditions. But some data is inherently per-thread: a database connection, a random number generator, a stopwatch, or a cache that should not be shared. For these cases, locking adds unnecessary overhead and complexity.
Thread-local storage solves this by giving each thread its own independent instance of the data. No lock is needed because no two threads ever access the same copy.
Basic Usage of ThreadLocal<T>
The simplest way to create a ThreadLocal<T> is to pass a factory function to the constructor. This function is called once per thread, the first time that thread accesses the Value property.
var threadLocal = new ThreadLocal<int>(() => Thread.CurrentThread.ManagedThreadId); Parallel.For(0, 10, i => { Console.WriteLine($"Thread {Thread.CurrentThread.ManagedThreadId}: value = {threadLocal.Value}"); });
Each thread that enters the Parallel.For loop calls the factory function once and then reuses the same value for subsequent accesses. The value is lazily initialized: the factory runs only on the first access from each thread.
The Value Property and Lazy Initialization
The Value property is the primary access point. Reading it triggers initialization if the current thread has not yet accessed it. Writing to it stores a value for the current thread only.
var counter = new ThreadLocal<int>(() => 0); void Increment() { counter.Value++; }
If multiple threads call Increment(), each thread maintains its own counter. There is no contention because there is no shared memory being written.
The factory function is not called until Value is first accessed. This means a ThreadLocal<T> that is never used by a given thread does not allocate any storage for that thread.
ThreadLocal<T> vs [ThreadStatic]
Before ThreadLocal<T> was introduced in .NET 4.0, the common approach was the [ThreadStatic] attribute applied to a static field.
[ThreadStatic] private static int _counter;
There are important differences:
| Aspect | [ThreadStatic] | ThreadLocal<T> |
|---|---|---|
| Initialization | No automatic initialization; uninitialized fields start at default(T) | Factory function runs on first access |
| Where it can be used | Only on static fields | Can be used with static fields, instance fields, or variables scoped to a method |
| Cleanup | No automatic disposal | Dispose() releases resources |
| Syntax | Attribute on a field | Generic class instance |
The most significant difference is initialization. With an uninitialized [ThreadStatic] field, every thread sees the default value (null for reference types, 0 for numeric types). There is no hook to run custom initialization per thread. With ThreadLocal<T>, the factory function gives you a clean way to initialize each thread's value.
Memory and Performance Considerations
Each thread that accesses a ThreadLocal<T> gets its own storage slot. The runtime allocates this slot lazily, but once allocated, it remains for the lifetime of the thread. This has two implications:
- Memory usage grows with the number of threads that actually access the instance, not with the number of threads in the process.
- If a thread pool thread accesses many different
ThreadLocal<T>instances, each one consumes a slot in that thread's thread-local storage area.
ThreadLocal<T> implements IDisposable. When you are done with the instance, call Dispose() to release the storage slots held by all threads. This is particularly important in long-running services where thread pool threads are reused indefinitely.
using var threadLocal = new ThreadLocal<List<int>>(() => new List<int>());
The using statement ensures that when the scope ends, the storage is released. Failing to dispose a ThreadLocal<T> in a long-lived process can lead to a slow memory leak, because the slots are not reclaimed until the instance is garbage collected and finalized.
ThreadLocal<T> vs AsyncLocal<T>
A common source of confusion is the difference between ThreadLocal<T> and AsyncLocal<T>. They serve different purposes:
ThreadLocal<T>scopes data to a physical thread. If code runs on a different thread (for example, after anawaitthat resumes on a thread pool thread), it sees a different value.AsyncLocal<T>scopes data to an asynchronous flow. It flows the value acrossawaitboundaries, so the logical execution context retains the value even when the physical thread changes.
var asyncLocal = new AsyncLocal<int>(); asyncLocal.Value = 42; await Task.Delay(100); // This may run on a different thread, but asyncLocal.Value is still 42. Console.WriteLine(asyncLocal.Value);
With ThreadLocal<T>, the equivalent code may print the default value, because the continuation can run on a different thread.
Use ThreadLocal<T> when the data is tied to the physical thread and should not flow across async boundaries. Use AsyncLocal<T> when the data belongs to the logical execution context, such as a correlation ID or a transaction context.
Common Pitfalls
Forgetting to Dispose
As noted, ThreadLocal<T> holds storage slots per thread. In a web application or a service that reuses thread pool threads, failing to dispose can accumulate memory over time.
Using ThreadLocal in Async Code
If you write await inside a method that reads a ThreadLocal<T>, the continuation may run on a different thread. The value you set before the await may not be visible after it. This is not a bug in ThreadLocal<T>; it is the intended behavior. If you need the value to survive across await, use AsyncLocal<T> instead.
Initialization Cost
The factory function runs once per thread. If the factory is expensive, the first access from each thread pays that cost. This is usually acceptable, but be aware of it in high-throughput scenarios where many threads may access the instance.
When to Choose ThreadLocal<T>
ThreadLocal<T> is the right choice when:
- The data is naturally per-thread and should not be shared.
- You need lazy, per-thread initialization.
- You are working with synchronous, thread-based parallelism (for example,
Parallel.Foror a custom thread pool). - You want to avoid locks for per-thread state.
It is not the right choice when the data must flow across async boundaries, or when the data is logically scoped to a request or a transaction rather than a thread.