Skip to content
C++
Library
since C++11
Intermediate

thread_local Storage Duration

C++ thread_local — per-thread variables, initialization semantics, destruction order, async pitfalls, and performance characteristics.

thread_localsince C++11

thread_local is a storage-class specifier (C++11) that gives each thread its own independent instance of a variable, initialized on first use per thread and destroyed when that thread exits.

Overview

C++11 defines four storage durations: automatic, static, dynamic, and thread. thread_local variables have thread storage duration — semantically equivalent to static storage duration, but scoped to a thread rather than the program. Every thread gets its own copy; reads and writes never race with other threads, so no synchronization is needed for the variable itself.

The keyword can be combined with static or extern at namespace scope (redundant but legal), is required for static data members, and implies static for local variables. A function-local thread_local variable is initialized the first time control passes through its declaration in that thread, not the first time any thread reaches it.

thread_local objects have their destructors called when the owning thread exits, in reverse order of construction — mirroring the rules for static objects at program exit.


Syntax

cpp
// Namespace scope — external linkage by default
thread_local int g_counter = 0;

// Internal linkage (C++11)
static thread_local std::string t_prefix;

// Function-local — initialized once per thread on first entry
void worker() {
    thread_local int call_count = 0;   // implicitly static; not re-initialized
    ++call_count;
}

// Static data member — must be defined at namespace scope
struct Ctx {
    thread_local static std::vector<int> scratch;  // C++11
};
thread_local std::vector<int> Ctx::scratch;

// C++26: thread_local in structured bindings (P1091 accepted for C++26)
// thread_local auto [x, y] = make_pair(1, 2);  // C++26 only

Examples

Thread-Local RNG

The canonical motivation: std::mt19937 is not thread-safe, but wrapping it in a mutex serializes every random draw. A thread-local engine eliminates both the race and the contention.

cpp
// C++11 — each thread seeds independently from hardware entropy
thread_local std::mt19937 tls_rng{std::random_device{}()};

int random_int(int lo, int hi) {
    return std::uniform_int_distribution{lo, hi}(tls_rng);  // no mutex
}

// Works correctly with parallel STL (C++17)
std::for_each(std::execution::par_unseq, v.begin(), v.end(),  // C++17
    [](int& x) { x = random_int(0, 99); });

Per-Thread Cache

cpp
// Thread-local read-through cache: zero contention on hits
struct ItemCache {
    thread_local static std::unordered_map<int, Item> store;  // C++11

    static const Item& get(int id) {
        auto [it, inserted] = store.emplace(id, Item{});  // C++17 structured binding
        if (inserted)
            it->second = global_store.fetch(id);  // global_store must be thread-safe
        return it->second;
    }

    static void invalidate(int id) { store.erase(id); }
};
thread_local std::unordered_map<int, Item> ItemCache::store;

Reusable Scratch Buffer

cpp
// Avoids per-call heap allocation in hot formatting paths
std::string_view format_record(const Record& r, std::span<char> /*unused*/) {
    thread_local std::string buf;
    buf.clear();
    std::format_to(std::back_inserter(buf),  // C++20
        "[{}] {}: {}", r.timestamp, r.level, r.message);
    return buf;  // stable until next call on this thread
}

errno-style Per-Thread Error State

cpp
// C's errno has been thread_local since POSIX; replicate the pattern in C++
thread_local int   tls_error_code = 0;
thread_local char  tls_error_msg[256] = {};

void set_error(int code, std::string_view msg) noexcept {
    tls_error_code = code;
    msg.copy(tls_error_msg, sizeof(tls_error_msg) - 1);
    tls_error_msg[std::min(msg.size(), sizeof(tls_error_msg) - 1)] = '\0';
}

int  get_error()     noexcept { return tls_error_code; }
void clear_error()   noexcept { tls_error_code = 0; tls_error_msg[0] = '\0'; }

Best Practices

Prefer static function-local over global thread_local. A function-local thread_local is initialized lazily on first call, making the initialization point deterministic and keeping the variable out of the global namespace.

Bound cache size explicitly. Thread-local caches grow without bound because no shared eviction policy can reach them. Cap size or add an LRU limit; otherwise long-lived thread pools accumulate unbounded memory.

Seed RNGs from a truly random source. std::random_device{}() is adequate in most environments, but avoid seeding all engines from std::chrono::steady_clock — threads created in rapid succession receive identical seeds and produce correlated sequences.

Check for fiber compatibility. TLS is implemented per OS thread. If your application uses fibers or coroutines that migrate between OS threads (Windows fibers, some green-thread libraries), a thread_local variable's value changes when the fiber is rescheduled to a different carrier thread. MSVC's /GT flag adds fiber-safety guards for static TLS; dynamic TLS (via TlsAlloc) is always fiber-unsafe.


Common Pitfalls

std::async and the Default Launch Policy

cpp
thread_local int t_id = current_thread_id();

auto fut = std::async([]() {          // C++11 — default policy: async | deferred
    return t_id;                      // UNDEFINED which thread's t_id this reads
});
// With std::launch::deferred, the lambda runs on the calling thread at .get() time.
// With std::launch::async, it runs on a new thread.
// The default policy gives the runtime freedom to choose either.

If you access thread_local variables inside a task launched with std::async, always specify std::launch::async explicitly — otherwise which thread's storage is accessed is unspecified.

Initialization of Non-POD Variables Can Throw

thread_local variables with dynamic initialization (constructors, non-trivial initializers) run that initialization inside the thread. An uncaught exception propagates to the thread's entry point — not to the creator. A std::vector that throws std::bad_alloc during thread-local construction will terminate if the thread's callable does not catch it.

DLL Unloading on Windows

On Windows, thread_local destructors for variables inside a DLL run during DLL_THREAD_DETACH and DLL_PROCESS_DETACH. If the DLL is unloaded while threads that hold those thread-local objects are still alive, the destructors will be called with a partially unloaded module — accessing vtables, static data, or calling into the DLL's code at that point is undefined behavior. Use explicit cleanup APIs (TlsAlloc/TlsFree) for shared library code that must tolerate hot-unload.

Destruction After std::atexit Handlers

Static-duration objects are destroyed after main() returns, but thread_local objects for the main thread are destroyed before static objects — the standard requires thread-local destructors to run before the first static destructor. This means a thread_local destructor must not call into a static object that has already been destroyed; the risk is the reverse of the usual static-destruction-order problem.

Unused Thread-Local Variables in Dead Threads

Thread pool threads that initialize heavy thread_local objects (e.g., a 1 MB arena) never release that memory until the thread itself exits. With a pool of 32 threads each holding a large TLS object, the resident memory footprint is 32× that object's size for the pool's lifetime. Size thread-local caches appropriately for the thread count, not just the workload.


Comparison with Alternatives

MechanismPer-threadSynchronizationTypical overheadBest for
thread_localYesNoneTLS segment lookup (1–5 ns)RNG, caches, scratch buffers
std::mutex + sharedNoRequiredLock/unlock (10–100 ns)Shared mutable state
std::atomicNoBuilt-inCAS (1–10 ns)Counters, flags, lock-free queues
boost::thread_specific_ptrYesNoneSimilar to TLSPre-C++11 or pointer semantics

On x86-64 Linux (ELF TLS), accessing a thread_local variable in the same shared object costs roughly one mov plus a segment-relative load — effectively free. Cross-DSO access (calling into another .so that holds the TLS) goes through a __tls_get_addr call and is measurably slower. Minimize cross-DSO thread-local access in hot paths.


Initialization and Destruction Order

thread_local variables follow initialization rules analogous to static variables, but scoped to each thread rather than to the program. The standard (C++20 [basic.stc.thread]/2 and [basic.start.dynamic]) specifies:

  • Constant initialization happens before anything else, just like for static.
  • Dynamic initialization of non-local thread_local variables is implementation-defined as to whether it occurs before the first statement of the thread's entry function or is deferred until first use. Most implementations (GCC, Clang, MSVC) use lazy initialization: a guard variable per thread-local object triggers initialization on first access.
  • Across translation units, the initialization order of non-local thread_local objects is indeterminately sequenced — the same problem as the static initialization order fiasco, but per-thread.

Destruction runs when the thread exits, in the reverse order of construction within that thread. This is symmetric with static objects at program exit, but occurs independently for each thread. The practical consequence:

cpp
// tls_a and tls_b are in different translation units.
// Initialization order between them is unspecified.
// Destruction order is the reverse of whichever order
// the implementation chose for construction.
thread_local A tls_a;   // TU 1
thread_local B tls_b;   // TU 2 — may construct before or after tls_a

The thread-local equivalent of the static initialization order fiasco arises when one thread_local object's constructor or destructor accesses another whose lifetime has not yet started (or has already ended) in the same thread. The standard remedy is the same: wrap the dependency in a function returning a reference to a function-local thread_local, which is guaranteed to be initialized before it is used.

cpp
// Safe: initialization is deferred to first call, order is deterministic
Logger& tls_logger() {
    thread_local Logger inst;  // initialized on first call in this thread
    return inst;
}

// Risky: non-local thread_locals — order between TUs is unspecified
thread_local Logger g_logger;      // TU A
thread_local Sink   g_sink;        // TU B — may destruct before g_logger

Main-thread interplay with static destructors: for the main thread, thread_local destructors run before static destructors. A thread_local destructor on the main thread must not call into a static object that may already be destroyed — the risk is the mirror-image of the usual problem.


Coroutines and thread_local (C++20 Pitfall)

C++20 stackless coroutines store their frame on the heap and can be resumed on a different thread than the one that suspended them. This interacts badly with thread_local.

What happens: when a coroutine suspends at a co_await point and is later resumed by a thread-pool thread, execution continues on a new OS thread. Any thread_local variable accessed after the co_await now resolves to that thread's instance — a completely different object than the one seen before suspension.

cpp
// C++20 — illustrates the silent value change across a co_await
thread_local int t_request_id = 0;

Task process_request(int id) {
    t_request_id = id;                    // set on thread A
    co_await schedule_on_pool();          // resumes on thread B (or C, or ...)
    // t_request_id is now thread B's instance — value is 0 (or whatever
    // thread B last wrote), NOT the id we set above. Silent bug.
    log("processing request", t_request_id);  // logs wrong id
}

This is not undefined behavior — TLS access is always valid — but the value silently changes, which is nearly always a logic bug.

Capturing a reference across a suspension point is dangerous for the same reason:

cpp
thread_local std::string t_trace;

Task bad_coroutine() {
    std::string& ref = t_trace;  // ref points to thread A's t_trace
    co_await some_async_work();  // resumed on thread B
    ref = "done";                // writes to thread A's t_trace from thread B —
                                 // NOT a data race (one write, one thread at a
                                 // time) but logically wrong; or IS a race if
                                 // thread A also accesses t_trace concurrently
}

Fixes:

  1. Copy the value before suspending. If the coroutine needs a thread-local value across an await point, copy it into a regular coroutine-local variable (which lives in the frame, not in TLS):
cpp
Task process_request(int id) {
    const int req_id = id;        // ordinary local — safe across co_await
    co_await schedule_on_pool();
    log("processing request", req_id);  // correct
}
  1. Pin the coroutine to a single thread. If thread-affinity is required (e.g., the coroutine relies heavily on per-thread state), ensure the scheduler always resumes it on the originating thread. Some frameworks (Boost.Asio io_context, single-threaded executors) do this automatically.

  2. Avoid thread_local inside coroutine bodies that cross thread boundaries. Use coroutine-frame variables, std::atomic, or explicit context objects passed as parameters instead.


Per-Thread Arena / Pool Pattern

A thread_local bump allocator gives each thread its own private memory pool, eliminating lock contention and malloc overhead in allocation-heavy workloads.

How it works: a bump (linear) allocator maintains a pointer into a pre-allocated block. Allocation is a single pointer increment — no lock, no free-list scan, no kernel call. The block is released when the thread exits (via the thread_local destructor).

cpp
// Minimal per-thread bump allocator — C++17
class BumpPool {
public:
    explicit BumpPool(std::size_t cap = 256 * 1024)
        : buf_(static_cast<char*>(::operator new(cap))),
          capacity_(cap), offset_(0) {}

    ~BumpPool() {
        ::operator delete(buf_);
    }

    void* allocate(std::size_t size, std::size_t align = alignof(std::max_align_t)) {
        void* p = buf_ + offset_;
        std::size_t space = capacity_ - offset_;
        if (!std::align(align, size, p, space))
            throw std::bad_alloc();
        offset_ = static_cast<char*>(p) - buf_ + size;
        return p;
    }

    void reset() noexcept { offset_ = 0; }

    // Non-copyable
    BumpPool(const BumpPool&) = delete;
    BumpPool& operator=(const BumpPool&) = delete;

private:
    char*       buf_;
    std::size_t capacity_;
    std::size_t offset_;
};

// Each thread gets its own private pool — zero contention
thread_local BumpPool t_pool;

void* fast_alloc(std::size_t n) {
    return t_pool.allocate(n);
}

Using it with STL containers: pair t_pool with a stateful allocator conforming to std::allocator_traits to let standard containers allocate from the per-thread pool without any locking:

cpp
template <typename T>
struct PoolAlloc {
    using value_type = T;

    T* allocate(std::size_t n) {
        return static_cast<T*>(t_pool.allocate(n * sizeof(T), alignof(T)));
    }
    void deallocate(T*, std::size_t) noexcept {} // pool owns the memory

    bool operator==(const PoolAlloc&) const noexcept { return true; }
};

// Lock-free, allocation-cheap per-thread work queue
using FastVec = std::vector<int, PoolAlloc<int>>;

void worker_task() {
    FastVec scratch;          // allocates from this thread's bump pool
    scratch.reserve(1024);
    // ... fill and process ...
    t_pool.reset();           // reclaim entire pool in O(1) when done
}

Why this beats a shared pool with a mutex: with a mutex-protected global pool, every allocation acquires a lock — contention on a 32-thread machine serializes allocations completely. With thread_local, each thread owns its slab; there is nothing to contend on.

Caveats:

  • Objects allocated from the pool must not outlive the thread (or must be moved to heap-owned storage before the thread exits).
  • reset() invalidates all pointers into the pool. Only call it when you know all previously allocated objects are done.
  • For heterogeneous-size allocations with individual free semantics, prefer a proper slab or pool allocator (e.g., jemalloc, tcmalloc) over a bump allocator.

TLS Access Cost: Same-DSO vs Cross-DSO (ELF)

On x86-64 Linux (ELF TLS), the cost of accessing a thread_local variable depends critically on where the variable is defined relative to where it is accessed.

Same-DSO access (executable or same .so)

When the variable is defined in a statically-linked executable or in the same shared object as the access site, the linker can compute the TLS offset at link time. The compiler generates a single fs:-relative load — one instruction, roughly the same cost as a cache-warm memory access (~1–5 ns):

asm
; x86-64 — same-DSO thread_local int counter
mov eax, DWORD PTR fs:counter@tpoff   ; direct segment-relative load

Thread-local variables in the main executable always use this fast path. The initial-exec TLS model (-ftls-model=initial-exec) forces this path for variables in shared objects too, at the cost of reserving TLS space even before the .so is loaded.

Cross-DSO access (variable in a different .so)

When a thread_local variable is defined in .so A and accessed from .so B (or the executable), the linker cannot resolve the offset at link time because the DSO may be loaded dynamically. The compiler emits a call to __tls_get_addr, a glibc helper that looks up the variable's block in the Dynamic Thread Vector (DTV):

asm
; x86-64 — cross-DSO thread_local (general-dynamic TLS model)
lea  rdi, tls_var@tlsgd[rip]
call __tls_get_addr@PLT             ; function call through PLT
; rax now holds the address of this thread's copy

__tls_get_addr is fast (it is essentially an array lookup into the DTV), but it is still a function call through the PLT — measurably slower than a single mov. Profiling typically shows 2–3× the latency of the same-DSO path, plus it prevents certain compiler optimizations.

The compiler is smart about repeated accesses: if you access the same cross-DSO thread_local multiple times in one inlined function, the compiler calls __tls_get_addr once and reuses the address. Keeping the access in a single inlined call site is therefore the easiest mitigation.

Practical guidance:

ScenarioTLS modelCostNotes
Variable in executablelocal-exec~1 nsFastest; linker resolves at link time
Variable in same .soinitial-exec~1–2 nsSafe if the .so is not dlopen-ed into processes that loaded other initial-exec users first
Variable in different .sogeneral-dynamic~3–10 nsRequires __tls_get_addr; avoid in sub-microsecond hot paths
Variable accessed via dlopengeneral-dynamic~3–10 nsOnly option for plugins; cannot use initial-exec

Tip: if a shared library needs thread_local that is always accessed from within the library itself, compile it with -ftls-model=local-dynamic or -ftls-model=initial-exec (if dlopen is not required). This avoids the PLT call.

dlopen and TLS limits: using initial-exec TLS in a library that is later dlopen-ed can cause dlopen to fail on older glibc versions because the TCB (Thread Control Block) cannot be resized after threads have started. Libraries intended for dlopen must use the general-dynamic model.


Thread-Local Objects with Non-Trivial Destructors

Every non-trivial thread_local object gets its destructor called when its owning thread exits. The runtime registers each such object during initialization and walks the list in reverse-construction order at thread-exit time — the same guarantee atexit provides for static objects, but scoped to a single thread. On POSIX systems this mechanism is implemented with pthread_key_create under the hood: the C++ runtime registers one or more thread-specific keys whose destructors drive the TLS cleanup list.

The analogy to pthread_key_create is instructive. When you call pthread_key_create(&key, destructor), the destructor is invoked with the key's value when the thread exits. The C++ runtime does exactly this internally — it stores a pointer to each thread_local object's destructor alongside its address, then fires them in sequence during pthread_key_destructor processing. The standard permits at most PTHREAD_DESTRUCTOR_ITERATIONS (typically 4) rounds of destructor calls; objects that re-register themselves (or each other) during destruction can loop, so non-trivial TLS destructors should be careful not to resurrect other thread-local objects.

cpp
// C++11 — non-trivial destructor; called automatically at thread exit
struct Connection {
    Connection()  { /* open socket */ }
    ~Connection() { /* flush and close — guaranteed to run at thread exit */ }
    void send(std::string_view msg);
};

thread_local Connection tls_conn;   // one connection per thread, auto-closed

void worker() {
    tls_conn.send("hello");
    // No explicit cleanup needed; ~Connection() fires when this thread exits
}

Destruction order across translation units is the reverse of construction order within a thread, but construction order between TUs is unspecified — exactly the static-initialization-order fiasco, thread-edition. If tls_a (TU 1) depends on tls_b (TU 2) during destruction, wrap the dependency in a function-local thread_local to enforce ordering:

cpp
// C++11 — safe inter-TLS dependency via function-local thread_local
struct Sink   { ~Sink()   { /* flush */  } };
struct Logger { ~Logger() { tls_sink().flush_final(); } };  // needs Sink alive

Sink& tls_sink() {
    thread_local Sink inst;   // constructed before Logger on first call
    return inst;
}
Logger& tls_logger() {
    (void)tls_sink();         // force Sink to initialize first
    thread_local Logger inst;
    return inst;
}

Because tls_sink() is called before tls_logger() is constructed, the runtime constructs Sink first; its destructor therefore runs after Logger's destructor, ensuring the dependency is still alive when it is needed.


TLS in Shared Libraries: TLS Models and __attribute__((tls_model))

The TLS model determines how the linker resolves a thread-local variable's address, and choosing the wrong model for a shared library can cost either correctness or throughput. The ELF TLS specification defines four models — local-exec, initial-exec, local-dynamic, and general-dynamic — ranked from fastest to most flexible. The compiler selects a model automatically based on whether the translation unit is being compiled for position-independent code (-fPIC) and whether the variable is local to the DSO, but you can override the choice per-variable with __attribute__((tls_model("..."))).

The two models that matter most in practice for shared library authors are initial-exec and general-dynamic:

  • initial-exec assumes the variable's TLS block is allocated in the Static TLS (the fixed-size region reserved by the dynamic linker at process start). The compiler emits a GOT-relative load of a pre-computed TLS offset, avoiding any call to __tls_get_addr. Access is roughly as fast as the executable's own local-exec variables. The constraint: the library must be listed in the process's link-time dependencies (i.e., linked with -l or NEEDED, not loaded via dlopen). Attempting dlopen of an initial-exec TLS library into an already-running process will fail on glibc versions before 2.25 (and is still fragile after); newer glibc relaxes this for libraries that fit in reserved static TLS space, but the risk remains.

  • general-dynamic (the default for -fPIC code accessing external or potentially-dlopen-ed TLS) emits a call to __tls_get_addr for every access to the variable from outside the defining DSO. It is the only safe model for plugins. Each call traverses the Dynamic Thread Vector — an array of pointers to each DSO's TLS block for the current thread — adding a function-call overhead of roughly 3–10 ns per unique access site.

cpp
// C++11 — override TLS model per-variable (GCC/Clang extension)

// Fast path: safe only in libraries that are never dlopen-ed
thread_local __attribute__((tls_model("initial-exec")))
int t_fast_counter = 0;

// Default (general-dynamic): safe for dlopen, slower cross-DSO access
thread_local __attribute__((tls_model("general-dynamic")))
int t_safe_counter = 0;

// local-dynamic: one __tls_get_addr call per function, address reused for
// multiple variables — a middle ground when a DSO owns several TLS vars
// and is not dlopen-ed from the outside
thread_local __attribute__((tls_model("local-dynamic")))
int t_local_var = 0;

Performance implications in practice. Hot paths that access a cross-DSO thread_local on every iteration should hoist the address out of the loop. The compiler already does this when it can see all accesses inline, but an opaque call boundary defeats the optimization:

cpp
// C++11 — hoist cross-DSO TLS address to avoid repeated __tls_get_addr
extern thread_local int tls_from_other_so;   // defined in libother.so

void process(const std::vector<int>& data) {
    int& local_ref = tls_from_other_so;  // one __tls_get_addr call
    for (int x : data)
        local_ref += x;                  // subsequent accesses: no PLT call
}

Build-system note. When compiling a shared library intended for use as both a linked dependency and a dlopen target, compile with the default TLS model (general-dynamic). If performance is critical and dlopen is not required, add -ftls-model=initial-exec to the library's compile flags rather than annotating every variable; this applies initial-exec uniformly and avoids the annotation maintenance burden.


See Also

  • std::mutex — synchronization for shared mutable state
  • std::atomic — lock-free operations on single values
  • std::jthread — C++20 joinable thread with stop token
  • std::async — launch policies and their interaction with TLS