Back to Blog
C#

Using ThreadLocal<T> in C# for Per-Thread State

c# threadlocal: Learn how ThreadLocal<T> provides thread-safe, per-thread state in C#, when to use it over ThreadStatic or AsyncLocal, and how it affects memory and pe...

ThreadLocalC# ConcurrencyThread SafetyAsyncLocalThreadStaticParallel Programming
Illustration of multiple parallel threads each holding an isolated data container, representing ThreadLocal storage in C#.

c# threadlocal requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.

ThreadLocal<T> in C# provides a way to store 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 for scenarios where you want to avoid synchronization overhead but still need thread-safe access to state.

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>
InitializationNo automatic initialization; field defaults to default(T)Factory function runs on first access
Type safetyWorks only with static fieldsWorks with instance fields and local variables
CleanupNo automatic disposalDispose() releases resources
SyntaxAttribute on a fieldGeneric class instance

The most significant difference is initialization. With [ThreadStatic], the field starts at its default value (null for reference types, 0 for numeric types) on every thread. 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 an await that resumes on a thread pool thread), it sees a different value.
  • AsyncLocal<T> scopes data to an asynchronous flow. It flows the value across await boundaries, 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 would likely print the default value, because the continuation may 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.For or 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.

c# threadlocal: Practical Usage and Code Examples | RYUSLOG DEV