Skip to content

Thread-Local Storage (TLS)

This section covers the thread_local keyword, TLS implementation mechanisms, performance Characteristics, initialization guarantees, TLS in thread pools, and practical patterns such as Thread-local random number generators.

The thread_local keyword [N4950 §6.7.3] specifies that a variable has thread storage duration: A new instance of the variable is created for each thread, and it is destroyed when the thread Exits. The variable is initialized before its first use in each thread.

\mathrm{thread\_local T\, x \implies \forall\, t \in \mathrm{Threads: \exists!\, x_t

Thread-local variables can be declared at namespace scope, at block scope, or as static class Members [N4950 §6.7.3]:

#include <iostream>
#include <thread>
thread_local int thread_id_value = 0;
void print_id() {
thread_id_value = 42;
std::cout << "thread_id_value = " << thread_id_value
<< " (thread " << std::this_thread::get_id() << ")\n";
}
int main() {
std::jthread t1(print_id);
std::jthread t2(print_id);
print_id();
return 0;
}

Each thread sees its own independent copy of thread_id_value. The output of the main thread”s print_id() call depends on whether it runs before or after the worker threads’ calls, but each Thread always sees the value 42 after its own assignment.

C++ defines four storage durations [N4950 §6.7.3]:

Storage DurationAllocationDeallocationInstance Count
automaticOn block entryOn block exitPer invocation
staticBefore mainAfter main returnsExactly one
threadOn thread entryOn thread exitPer thread
dynamicoperator newoperator deleteAs requested

Thread storage duration sits between static and dynamic: like static, the variable persists across Function calls; like dynamic, each thread gets its own independent instance. The standard does not Specify when within a thread’s lifetime the storage is allocated, only that it must be available Before the variable’s first odr-use [N4950 §6.7.3].

When a static class member is declared thread_localEach thread gets its own copy of that Static member. The member is shared across all instances of the class within the same thread:

#include <iostream>
#include <thread>
class ThreadCounter {
public:
static thread_local int count;
ThreadCounter() { ++count; }
~ThreadCounter() { --count; }
};
thread_local int ThreadCounter::count = 0;
void use_counter(int id) {
ThreadCounter c1;
ThreadCounter c2;
std::cout << "Thread " << id << ": count = " << ThreadCounter::count << "\n";
}
int main() {
std::jthread t1(use_counter, 1);
std::jthread t2(use_counter, 2);
use_counter(0);
return 0;
}
// Each thread prints: count = 2

std::this_thread::get_id() [N4950 §31.4.4.1.6] returns a unique identifier for the current thread. The return type is std::thread::idWhich is a lightweight, copyable, and comparable type [N4950 §31.4.4.1.5].

#include <iostream>
#include <thread>
#include <unordered_map>
thread_local int local_counter = 0;
void work() {
++local_counter;
std::cout << "Thread " << std::this_thread::get_id()
<< " local_counter = " << local_counter << "\n";
}
int main() {
std::cout << "Main thread id: " << std::this_thread::get_id() << "\n";
std::jthread t1([&] {
work();
work();
work();
});
std::jthread t2([&] {
work();
work();
});
return 0;
}

Different platforms implement TLS differently, which affects access cost and initialization Behavior.

The __thread keyword is a compiler extension that provides TLS with static initialization only. It Cannot be used with types that require dynamic initialization (non-trivial constructors).

__thread int per_thread_counter = 0; // Static init only
// __thread std::string str; // ERROR: dynamic init not supported

__thread generates efficient code: the variable address is computed from the thread pointer Register with a fixed offset, requiring a single load instruction.

The POSIX pthread_key_create API provides general-purpose TLS with destructor support:

#include <pthread.h>
#include <stdio.h>
static pthread_key_t tls_key;
static pthread_once_t tls_once = PTHREAD_ONCE_INIT;
static void tls_destructor(void* value) {
printf("TLS destructor called\n");
free(value);
}
static void tls_init() {
pthread_key_create(&tls_key, tls_destructor);
}
void set_tls(int value) {
pthread_once(&tls_once, tls_init);
int* ptr = malloc(sizeof(int));
*ptr = value;
pthread_setspecific(tls_key, ptr);
}
int get_tls() {
pthread_once(&tls_once, tls_init);
int* ptr = pthread_getspecific(tls_key);
return ptr ? *ptr : -1;
}

pthread_key_create is slower than __thread because it involves a function call and a hash table Lookup. However, it supports dynamic initialization and destruction callbacks.

On x86-64 Linux, glibc implements thread_local using the fs segment register (or gs on some Systems). The fs register points to the thread_control_block (TCB), which contains a pointer to The TLS area:

fs:0x00 -> Thread pointer (TCB)
fs:offset -> TLS variable 1
fs:offset+N -> TLS variable 2

Access to a thread_local variable with static initialization compiles to:

mov rax, fs:[tls_offset_for_var]

This is a single instruction with a fixed offset, making it nearly as fast as accessing a global Variable. The overhead compared to a regular global is 1 extra cycle (the segment register Load).

Initial Exec vs. General Dynamic TLS Models

Section titled “Initial Exec vs. General Dynamic TLS Models”

The TLS access model determines how the TLS offset is resolved:

  • Initial Exec (IE): The TLS offset is embedded directly in the code (via a GOT entry resolved at load time). Fast access, but the executable cannot be loaded via dlopen with new TLS variables. Used when the TLS variable is defined in the executable or a library loaded at startup.
  • General Dynamic (GD): The TLS offset is resolved via a function call (__tls_get_addr) at runtime. Slower (function call + possible allocation), but works with dynamically loaded libraries.

The compiler selects the model based on whether the variable is in the executable, a startup Library, or a dynamically loaded library. The -ftls-model=initial-exec flag forces the fast model When you know the library will not be dlopenEd.

On ELF platforms (Linux, BSD), thread_local variables are stored in special sections of the Executable or shared object:

SectionPurpose
.tdataTLS data for initialized variables (zero-cost: memory image)
.tbssTLS BSS for zero-initialized variables
.tbssThread-local BSS for zero-initialized variables

The linker combines .tdata and .tbss from all participating modules into a TLS template Described by the PT_TLS program header. Each new thread gets a copy of this template at thread Creation time. The __tls_get_addr function is used for dynamically loaded modules where the offset Cannot be computed at load time.

TLS ModelAccess CostUse Case
Local Exec~0 cycles (offset from TP known)thread_local in the main executable
Initial Exec~1 cycle (segment register load)Executables, startup-linked libraries
General Dynamic~20-50 cycles (function call)Dynamically loaded libraries (.so)

The first access to a thread_local variable with dynamic initialization triggers a guard check and Possibly a constructor call. The compiler generates code equivalent to:

// Conceptual: what the compiler generates for thread_local std::string s;
static char tls_storage[sizeof(std::string)];
static std::atomic<int> init_guard{0};
std::string& get_s() {
if (init_guard.load(std::memory_order_acquire) == 0) {
if (init_guard.exchange(1, std::memory_order_acq_rel) == 0) {
new (tls_storage) std::string();
init_guard.store(2, std::memory_order_release);
} else {
while (init_guard.load(std::memory_order_acquire) != 2) {
// spin-wait for initialization
}
}
}
return *reinterpret_cast<std::string*>(tls_storage);
}

This means:

  • The first access in each thread has a higher cost (guard check + possible initialization).
  • Subsequent accesses are cheap (just the guard check, branch-predicted).
  • If the constructor throws, initialization is retried on the next access.

:::tip Tip Variable’s address in a local variable at the start of the function. The compiler may optimize this Automatically, but explicit caching can help in complex functions. :::

Proof: When Does thread_local Initialization Occur?

Section titled “Proof: When Does thread_local Initialization Occur?”

We prove that thread_local initialization is eager per thread and lazy across threads.

Claim: A thread_local variable with dynamic initialization is initialized on first odr-use in Each thread, and not before [N4950 §6.7.3].

Proof:

  1. [N4950 §6.7.3] states: “A variable with thread storage duration is initialized before its first odr-use (6.3).”
  2. “Odr-use” is defined in [N4950 §6.3] as any use of a variable’s name or reference that requires a definition to exist.
  3. The standard specifies that the initialization is “performed before its first odr-use”. Meaning it occurs no later than the first access.
  4. The compiler may initialize the variable earlier within the same thread (e.g., at thread start), but never in a different thread.
  5. Therefore: initialization is guaranteed to occur before the first access in each thread, and no cross-thread initialization ordering is guaranteed.

\square

Comparison: thread_local vs Function-Local static

Section titled “Comparison: thread_local vs Function-Local static”

Function-local static variables are initialized exactly once, globally, on first call to the Function. thread_local variables are initialized once per thread, on first use in each thread. This distinction has important consequences:

#include <iostream>
#include <thread>
#include <mutex>
#include <string>
std::string& func_static() {
static std::string s = "func_static initialized";
return s;
}
thread_local std::string tls_s = "tls initialized";
void access_both(int id) {
std::cout << "Thread " << id << ": func_static = " << func_static() << "\n";
std::cout << "Thread " << id << ": tls_s = " << tls_s << "\n";
}
int main() {
std::jthread t1(access_both, 1);
std::jthread t2(access_both, 2);
access_both(0);
return 0;
}
PropertyFunction-Local staticthread_local
Instance count1 (global)1 per thread
Initialization threadWhichever thread calls firstEach thread, independently
Initialization synchronizationThread-safe (guaranteed by standard)Thread-safe per thread [N4950 §6.7.3]
DestructionAfter main returnsWhen each thread exits

The standard guarantees thread-safe initialization for both static locals [N4950 §9.8.1] and thread_local variables. The compiler generates a guard variable with atomic operations to ensure That if two threads race to initialize the same thread_local instance (within the same thread’s Execution — which cannot happen for thread_local), exactly one initialization occurs. For Function-local staticThis matters because multiple threads can call the function concurrently.

Dynamic Initialization with thread_local and static Combined

Section titled “Dynamic Initialization with thread_local and static Combined”

A static thread_local variable combines both storage durations. It is initialized once per thread But is also shared across all calls within that thread:

void example() {
static thread_local std::vector<int> buffer;
// Initialized once per thread, persists across calls
buffer.clear();
buffer.push_back(42);
}
#include <iostream>
#include <thread>
#include <mutex>
struct ExpensiveResource {
ExpensiveResource() {
std::cout << "Constructing resource for thread "
<< std::this_thread::get_id() << "\n";
}
~ExpensiveResource() {
std::cout << "Destroying resource for thread "
<< std::this_thread::get_id() << "\n";
}
void use() { }
};
thread_local ExpensiveResource resource;
void worker(int id) {
std::cout << "Thread " << id << " starting\n";
resource.use();
std::cout << "Thread " << id << " done\n";
}
int main() {
std::jthread t1(worker, 1);
std::jthread t2(worker, 2);
worker(0);
return 0;
}
// Output:
// Thread 0 starting
// Constructing resource for thread <main>
// Thread 0 done
// Thread 1 starting
// Constructing resource for thread <t1>
// Thread 1 done
// Thread 2 starting
// Constructing resource for thread <t2>
// Thread 2 done
// Destroying resource for thread <t2>
// Destroying resource for thread <t1>
// Destroying resource for thread <main>

Each thread constructs its own ExpensiveResource on first use and destroys it when the thread Exits.

Thread pools complicate TLS usage because threads are reused across tasks. A thread_local variable Initialized by one task persists into the next task on the same thread:

#include <iostream>
#include <thread>
#include <vector>
thread_local int task_context = 0;
void run_task(int task_id) {
task_context = task_id;
std::cout << "Task " << task_id << " running on thread "
<< std::this_thread::get_id()
<< " context=" << task_context << "\n";
}
int main() {
// Simple thread pool with 2 threads
std::vector<std::jthread> pool(2);
// Run multiple tasks on each thread
for (int i = 0; i < 4; ++i) {
// In a real pool, this would be dispatched to the next available thread
}
return 0;
}

Problem: If task A sets task_context = 1 and task B runs on the same thread later, task_context still has the value 1 from task A. If task B forgets to reset it, it reads stale Data.

Solutions:

  1. Always reset at task start: Each task explicitly reinitializes TLS at the beginning.
  2. Use RAII wrappers: Wrap the TLS variable in a scoped guard that resets it on destruction.
  3. Avoid TLS for task-scoped data: Use a thread_local map keyed by task ID instead.
// RAII TLS reset guard
template<typename T>
class tls_guard {
T& var_;
T old_value_;
public:
tls_guard(T& var, T new_value) : var_(var), old_value_(var) {
var_ = new_value;
}
~tls_guard() { var_ = old_value_; }
};
void run_task_safely(int task_id) {
tls_guard<int> guard(task_context, task_id);
// task_context is set to task_id here
// and restored to old value when guard is destroyed
}

Thread-local variables are destroyed in the reverse order of construction within each thread [N4950 §6.7.3]. This is analogous to how automatic variables are destroyed in reverse order of Construction when a block scope exits [N4950 §9.3.4].

Claim: If thread_local variable A is initialized before thread_local variable B in the Same thread, then B is destroyed before A.

Proof:

  1. [N4950 §6.7.3] specifies that thread-local variables with ordered initialization are initialized in the order of their definitions.
  2. [N4950 §6.7.3] states that destruction of thread-local variables occurs “in the reverse order of construction.”
  3. If A is constructed before BThen by (2), B is destroyed before A.

\square

Cross-Translation-Unit Ordering Is Unspecified

Section titled “Cross-Translation-Unit Ordering Is Unspecified”

The standard does not guarantee initialization order of thread_local variables across Translation units. This is the same problem as the static initialization order fiasco, but per Thread. If thread_local variable A in a.cpp is initialized before thread_local variable B In b.cppThe behavior is unspecified [N4950 §6.7.3]:

a.cpp
thread_local std::string config_key = "default";
// May be initialized before or after logger in b.cpp
// b.cpp
extern thread_local std::string config_key;
thread_local Logger logger; // Logger constructor may read config_key

Fix: Use the construct-on-first-use idiom:

a.cpp
thread_local std::string& config_key() {
static thread_local std::string s = "default";
return s;
}
// b.cpp
thread_local std::string& config_key(); // declaration
thread_local Logger logger; // Logger constructor calls config_key()

When std::exit() is called, thread-local variables in threads other than the calling thread are not destroyed [N4950 §6.9.3.4]. Only the calling thread’s thread-local variables are destroyed During the std::exit() process. This is because std::exit() does not join or terminate other Threads — it terminates the process.

When a shared library is loaded via dlopenAny thread_local variables defined in that library Must be initialized for threads that already exist. The implementation uses __tls_get_addr for General Dynamic TLS model to handle this:

mylib.so
thread_local int lib_tls = 42;

If mylib.so is loaded after threads have been created, those threads will trigger initialization Of lib_tls on their first access. The dynamic linker handles this by allocating TLS storage for The new module and running its initializers lazily.

:::caution Warning Dangerous. If any thread still has references to the TLS storage (e.g., via a pointer obtained Before the unload), the behavior is undefined. The standard does not define safe unloading semantics For TLS [N4950 §6.7.3]. :::

POSIX systems limit the number of TLS slots per process via PTHREAD_KEYS_MAX ( 1024). Each pthread_key_create call consumes one slot, and slots are never reclaimed. The C++ thread_local keyword does not use pthread_key_create (it uses the compiler’s TLS mechanism Instead), but libraries that use the POSIX API directly can exhaust the limit.

std::call_once operates on a shared std::once_flag. Combining it with thread_local can cause Confusion because the once_flag is shared across threads but the thread_local variable is not:

std::once_flag flag;
thread_local std::vector<int> data;
void init() {
std::call_once(flag, [] {
// This runs exactly once globally
// But which thread's 'data' does it populate?
data.push_back(42); // Only the calling thread's data is modified
});
}

The std::call_once body runs exactly once, but the thread_local variable is per-thread. Other Threads will see an empty data vector. Use a regular static variable inside call_once instead.

Thread-local variables are destroyed in the reverse order of construction within each thread [N4950 §6.7.3]. If a destructor of one thread_local variable accesses another thread_local Variable that has already been destroyed, the behavior is undefined.

thread_local std::string logger_prefix = "INFO";
thread_local Logger logger; // Destructor may use logger_prefix
// If logger_prefix is destroyed before logger, the destructor
// of logger accessing logger_prefix is UB.

Fix: Ensure destruction order by declaring variables in the desired destruction order (later Declarations are destroyed first).

If a thread with thread_local variables is detached, the variables remain alive until the thread Exits. If the process exits before detached threads complete, the destructors may not run at all.

After fork()Only the calling thread survives. thread_local variables in other threads are not Copied to the child process. The child process has only the forking thread’s TLS state. Accessing TLS variables that were initialized by other threads (before fork) may lead to stale or Inconsistent state.

thread_local variables cannot be used in constexpr functions because constexpr evaluation Occurs at compile time, where threads do not exist. A thread_local variable is inherently a Runtime construct [N4950 §6.7.3].

Performance Pitfall: Dynamic Initialization in Hot Paths

Section titled “Performance Pitfall: Dynamic Initialization in Hot Paths”

If a thread_local variable with a non-trivial constructor is accessed for the first time inside a Tight loop, the initialization cost is paid on the first iteration. This is fine, but if the Constructor acquires a lock or performs I/O, the cost can be significant:

thread_local std::mt19937 rng{std::random_device{}()};
void hot_path() {
// First call triggers construction of rng + random_device
// Subsequent calls are cheap
int v = std::uniform_int_distribution<int>{0, 100}(rng);
}
#include <iostream>
#include <thread>
#include <random>
#include <vector>
#include <algorithm>
#include <numeric>
thread_local std::mt19937 rng{std::random_device{}()};
int random_int(int min, int max) {
std::uniform_int_distribution<int> dist(min, max);
return dist(rng);
}
int main() {
constexpr int num_threads = 8;
constexpr int samples_per_thread = 100'000;
std::vector<std::pair<long long, long long>> results(num_threads);
std::vector<std::jthread> threads;
for (int i = 0; i < num_threads; ++i) {
threads.emplace_back([&results, i] {
long long sum = 0;
long long count = 0;
for (int j = 0; j < samples_per_thread; ++j) {
sum += random_int(1, 100);
++count;
}
results[i] = {sum, count};
});
}
long long total_sum = 0;
long long total_count = 0;
for (const auto& [s, c] : results) {
total_sum += s;
total_count += c;
}
double mean = static_cast<double>(total_sum) / total_count;
std::cout << "Overall mean: " << mean << "\n";
std::cout << "Expected mean: 50.5\n";
return 0;
}

Each thread has its own std::mt19937 instance, so there is no contention for the random number Generator. This is both faster (no lock contention) and more correct (the generator state is Not shared, so the random sequence quality is preserved).

:::tip Tip Simulations and other embarrassingly parallel stochastic computations. Each thread’s generator is Independent, so there are no synchronization overheads or sequence quality concerns.

Thread-Local Memory Pool (Advanced Pattern)

Section titled “Thread-Local Memory Pool (Advanced Pattern)”

A common performance pattern is to use thread_local to implement a per-thread memory pool, Avoiding contention on a global allocator:

#include <iostream>
#include <thread>
#include <vector>
#include <new>
class ThreadLocalPool {
static constexpr std::size_t BLOCK_SIZE = 4096;
struct Block {
alignas(std::max_align_t) char data[BLOCK_SIZE];
Block* next = nullptr;
};
static thread_local Block* head;
static thread_local char* current;
static thread_local char* end;
public:
static void* allocate(std::size_t n) {
n = (n + alignof(std::max_align_t) - 1) & ~(alignof(std::max_align_t) - 1);
if (current + n > end) {
Block* block = new Block();
block->next = head;
head = block;
current = block->data;
end = current + BLOCK_SIZE;
}
void* ptr = current;
current += n;
return ptr;
}
static void deallocate_all() {
while (head) {
Block* next = head->next;
delete head;
head = next;
}
current = nullptr;
end = nullptr;
}
};
thread_local ThreadLocalPool::Block* ThreadLocalPool::head = nullptr;
thread_local char* ThreadLocalPool::current = nullptr;
thread_local char* ThreadLocalPool::end = nullptr;
struct PooledObject {
int data[16];
static void* operator new(std::size_t n) {
return ThreadLocalPool::allocate(n);
}
};
void pooled_work(int count) {
std::vector<PooledObject*> objs;
objs.reserve(count);
for (int i = 0; i < count; ++i) {
objs.push_back(new PooledObject{});
}
for (auto* p : objs) delete p;
ThreadLocalPool::deallocate_all();
}
int main() {
constexpr int count = 10000;
std::vector<std::jthread> threads;
for (int i = 0; i < 4; ++i) {
threads.emplace_back(pooled_work, count);
}
return 0;
}

This pattern provides fast bump-pointer allocation within each thread, completely eliminating Allocator contention. The trade-off is that individual deallocations are not supported — only bulk Deallocation of the entire pool.

PrimitiveC++ StandardUse CaseReusable
std::mutexC++11 [§31.4.3.3]Mutual exclusionYes
std::shared_mutexC++17 [§31.4.3.4]Reader-writer lockingYes
std::condition_variableC++11 [§31.5.4]Waiting on conditionsYes
std::latchC++20 [§31.4.4.3]One-shot barrierNo
std::barrierC++20 [§31.4.4.5]Reusable phase barrierYes
std::jthreadC++20 [§31.4.4.4]Auto-join + cancellationN/A
thread_localC++11 [§6.7.3]Per-thread dataN/A

Example 1: Applying key concepts

When working with thread-local storage (tls), follow these steps:

  1. Identify the problem requirements and constraints
  2. Select the appropriate algorithm, data structure, or technique
  3. Implement the solution step by step
  4. Test with edge cases and verify correctness

:::