AlgoMaster Logo

Thread-Local Storage (thread_local)

Low Priority15 min readUpdated September 21, 2026
Listen to this chapter
Unlock Audio

Some state should not be shared between threads, even when the variable lives at file or global scope. A request ID, a random number generator, a scratch buffer reused across calls: these are per-thread by nature, and sharing them produces either incorrect results or a queue of threads contending on a mutex. The thread_local keyword, added in C++11, gives every thread its own independent copy of a variable, with a lifetime that matches the thread itself. This chapter covers how thread_local storage works, where it is useful in everyday concurrent code, and the corner cases.

What thread_local Actually Does

A normal static or global variable has one storage location for the whole program. Every thread sees the same bytes. A thread_local variable has one storage location per thread. Each thread gets its own copy, with its own value, constructed independently and destroyed when that thread exits.

Both threads increment requestCounter, and both threads start their counter at zero. There is no single requestCounter: there are two, one inside each thread. The main thread has a third copy of its own, which this example never touches.

Compare this with a regular static counter:

Two threads run bumpUnsafe() 100,000 times each, which should produce 200,000. It rarely does, because sharedCounter++ is not atomic: each thread reads, increments, and writes back, and both threads constantly clobber each other's update. This is a data race, with the undefined behavior status that goes with it. The fix for a truly shared counter is std::atomic<int> or a mutex. But the question that matters for this chapter is: does the counter actually need to be shared? Most of the time the answer is no. Each thread really wants its own count, and thread_local provides that with no synchronization.

The diagram shows the storage layout. A static variable is one box in the program's data segment that every thread points at. A thread_local variable lives inside per-thread storage, so each thread has its own box. Three threads means three independent counters. The compiler and the runtime work together to make requestCounter resolve to the right box depending on which thread is executing the code, so the same line of source reads and writes a different memory location in each thread.

Storage Duration and Lifetime

A thread_local variable has thread storage duration. It comes into existence the first time the thread reaches the variable's declaration (or sooner, if the variable is at namespace scope and the implementation chooses), and it is destroyed when the thread exits. That is different from a regular static, which lives for the whole program, and different from an automatic variable, which dies at the end of its block.

The order of events. The worker thread constructs its ctx first because it accesses ctx.requestId inside handleOrder(). The main thread constructs its ctx when main first touches it via std::cout. Each TraceContext is destroyed when its owning thread exits, which is why there are two separate "Destroying" lines, one for each thread.

A subtle but important rule: a thread_local variable is constructed at most once per thread, not once per use. It behaves like a static local variable in that sense, except the "once" is "once per thread" rather than "once for the program". After the first access, subsequent accesses read the existing value.

Per-Thread Trace IDs for Logging

A real-world reason to use thread_local: logging. In a server that handles many requests concurrently, every log line emitted while processing request X should carry X's identifier, without threading that identifier through every function call. A thread_local variable holds the current request's trace ID, the logger reads it on every line, and nobody has to pass it as a parameter.

The key line is currentTraceId = traceId;. Each thread writes to its own copy of currentTraceId. When log() reads currentTraceId, it sees the value its own thread set, not the other thread's. No mutex around currentTraceId is needed because there is no shared state to protect. The mutex on std::cout is unrelated, only stopping output streams from interleaving character by character.

A common production pattern wraps this in a small RAII helper so the trace ID is automatically reset when the request finishes:

TraceScope saves the old value, installs the new one, and restores the old one on destruction. Because currentTraceId is thread_local, this saving and restoring is entirely thread-local too, so calling process() on one thread does not perturb currentTraceId on another.

Per-Thread Random Number Generators

std::mt19937 (and the rest of the <random> engines) holds a substantial amount of internal state, and that state is mutated on every draw. Sharing one generator across threads without a lock is a data race. Putting a mutex around it works, but every thread that wants a random number now waits for the others. The clean fix is one generator per thread.

Each thread has its own rng, each rng is seeded by a fresh call to std::random_device{}() the first time the thread touches the variable, and the two threads never contend on the generator. This is one of the most popular uses for thread_local in real C++ code, including inside many simulation, sampling, and load-testing libraries.

thread_local access typically goes through a TLS (thread-local storage) slot rather than a direct memory load. On Linux/x86-64 with the initial-exec model, that is usually one extra instruction with negligible overhead, but in shared libraries using the general-dynamic model it can require a runtime function call (__tls_get_addr) per access. Read into a local variable inside a hot loop instead of touching the thread_local every iteration.

The cost note matters for tight loops over a thread_local:

Compilers can often hoist the TLS access out of the loop on their own, but the explicit local reference makes it unambiguous and works on every optimization level.

Per-Thread Caches: Currency Conversion Rates

Some lookups are pure functions of their input but expensive to compute, and many threads will independently ask for the same answers. A shared cache requires a mutex (or a lock-free data structure) and turns into a contention hotspot. A per-thread cache duplicates a bit of memory but never blocks.

The cache map is declared thread_local inside the function. The first call from each thread populates that thread's map; subsequent calls hit the cache. Threads never contend on the map because there is one map per thread. If thread A and thread B both ask for "EUR", they each pay the lookup cost once on their first call, then read from their own private cache afterwards. This is a tradeoff: the cost is some duplicated work (the first lookup happens once per thread instead of once per process) and some duplicated memory, in exchange for zero synchronization in the hot path.

A reusable string buffer is another example of the same pattern:

The buffer is reused across calls within the same thread but never shared with another thread. After the first call grows the buffer to a useful capacity, subsequent calls reuse that capacity without allocating. The thread that owns the buffer keeps it alive until the thread exits.

Initialization Order Across Threads

This is where thread_local gets a little subtle. For a thread_local variable at namespace scope:

  • The main thread treats it the same way it treats normal static initialization, more or less. Dynamic initialization can happen before main() starts or on first use, depending on the implementation.
  • Other threads construct their copy on first use (or at thread start, if the implementation chooses), and always dynamically, even when the type has constexpr initialization.

Three threads, three Counter constructions. Each thread's Counter is constructed lazily, on the first call to useCounter() from that thread. "Main starting" is printed before the first construction message: the main thread did not initialize its Counter until the call to useCounter() at the end. That is the "constructed on first use" model.

A function-scope thread_local follows the same rule as function-scope static: it is constructed the first time control flows through the declaration in that thread. The C++ standard guarantees this is thread-safe per thread, in the sense that one thread's initialization does not race with itself, but there is only ever one initialization per thread, not one for the whole program.

Each thread pays the construction cost of the variable's type, once. With a thread_local std::unordered_map that has a 10,000-bucket initial size, every thread allocates 10,000 buckets on first use. Default-construct cheaply and grow on demand for many short-lived worker threads.

thread_local vs static: Side-by-Side

A quick reference table for the differences:

Traitstatic (namespace or function)thread_local
Number of copiesOne for the entire programOne per thread
Storage durationWhole programLifetime of the owning thread
ConstructionOnce, at program start or first useOnce per thread, at thread start or first use
DestructionOnce, at program exitOnce per thread, at thread exit (main thread destroys at program exit)
Race risk when written from multiple threadsYes, needs synchronizationNo, each thread sees only its own copy
Access costDirect memory loadTLS slot lookup, usually one extra instruction
Typical useProcess-wide counters, registries, mutexesPer-thread cache, RNG, scratch buffer, trace context

A thread_local static int x; is allowed, but the static is implied: any namespace-scope or function-scope thread_local already has static linkage. The extra keyword does not hurt; some codebases write it for emphasis. A thread_local variable inside a class must also be declared static, because non-static class members are part of an object instance, and thread_local only applies to static-duration storage.

Each thread sees its own OrderProcessor::ordersHandled starting at zero. Three threads, three independent counts that all reach 5.

Pitfalls Worth Knowing

A few practical hazards.

Destructors of `thread_local` objects in the main thread run during program exit, not when `main()` returns. They run in reverse construction order, alongside ordinary static destructors. When a thread_local destructor depends on some other global that has already been destroyed, "static destruction order" bugs appear, familiar from non-threaded code. The fix is the same: avoid thread_local types whose destructors touch other globals, or use the Construct On First Use idiom.

Destructors of `thread_local` objects in worker threads run when the thread exits, before the thread's resources are reclaimed. That is usually fine. The catch is that the destructor runs in the worker thread's context. A destructor that tries to acquire a mutex held by some other thread that has already exited creates a problem that is awkward to debug. Keep thread_local destructors simple.

`thread_local` inside a function template instantiates a separate variable per template instantiation, just like `static` does. Each combination of template arguments gets its own per-thread variable. This is usually what is intended, but it can surprise code that expects one variable across all instantiations.

The two cachedDefault calls instantiate two different functions, so they hold two different thread_local variables. Each thread has its own copy of each.

Shared libraries change the picture. When a thread_local variable lives inside a .so/.dll loaded with dlopen, the dynamic linker has to handle TLS allocation lazily and through the slower general-dynamic access model. This is more expensive than static-linked TLS access, and on some systems older than a few years it can cost a function call per read. For application code this is rarely noticeable. For library code in the hottest paths, profile before committing to thread_local.

Reading another thread's `thread_local` variable is impossible by design. The point of thread_local is that there is no shared address. To read thread X's counter from thread Y, use a mutex-protected map keyed by std::this_thread::get_id(), or a thread-safe atomic per worker stored in a shared array.

A Model

Think of thread_local as a per-thread global. It has the convenience of a global (declare it once, read it anywhere in the thread) without the curse of a global (shared mutable state that requires synchronization). When reading or writing a thread_local, ask: "What does each thread's copy look like at this point?" That framing answers most questions about behavior.

The decision tree for picking storage is straightforward:

If two threads truly need to coordinate on the same data, a mutex or atomic is required (both covered earlier in this section). If each wants its own working copy, thread_local is the cleanest tool. The mistake is using a mutex around state that nobody needs to share, paying the synchronization cost for nothing.

Quiz

Thread Local Storage Quiz

10 quizzes