Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 22

Thread-Local Storage: Per-Thread Data

Recommended resources

Hello! Welcome to our next lesson.

In our last session, we dissected two major performance killers in concurrent code: lock contention and false sharing. You learned that when multiple threads compete for the same lock or unknowingly write to the same cache line, the benefits of parallelism can be completely erased. This is a critical insight for your goal of building a "zero-overhead" telemetry engine.

Today, we'll explore a direct and elegant solution to these problems: thread-local storage. The learning outcome for this lesson is to use thread-local storage for per-thread data. We will see how this C++ feature allows us to create variables that are, by design, private to each thread, effectively eliminating the need for shared locks and preventing the cache-line interference that causes false sharing. This is a foundational technique for the per-thread data capture architecture at the heart of your project.

What is Thread-Local Storage?

At its core, thread-local storage provides a variable with a specific storage duration. You're already familiar with static storage duration (the variable exists for the entire program) and automatic storage duration (the variable exists within a scope, like a function). A variable declared as thread_local has thread storage duration:

  • It is created the first time a thread accesses it.
  • It persists for the entire lifetime of that specific thread.
  • Each thread gets its own independent instance of the variable.
  • It is destroyed when the thread terminates.

This makes thread_local variables behave like global variables, but scoped to an individual thread. Let's watch a short video that demonstrates this concept in action.

C++ Weekly - Ep 445 - C++11's thread_local

The video 'C++ Weekly - Ep 445 - C++11's thread_local' by Jason Turner provides a clear, hands-on demonstration of what thread_local is and how it behaves with multiple threads.

Please watch from 01:11 to 10:10. Focus on these key points: How a thread_local variable behaves like a static variable in a single-threaded context. How, when multiple threads are introduced, each one gets its own independent copy of the variable. The non-deterministic nature of thread execution and how the variable's lifetime is tied to the thread's lifetime.


Syntax and Basic Usage

The syntax is straightforward. You simply add the thread_local specifier to a variable declaration.

#include <iostream>
#include <thread>
#include <string>

// Each thread will have its own 'thread_id_str'
thread_local std::string thread_id_str;

void worker_function(int id) {
    // Initialize this thread's copy of the variable
    thread_id_str = "Worker " + std::to_string(id);

    std::cout << "Thread " << std::this_thread::get_id() 
              << " says: " << thread_id_str << std::endl;
}

int main() {
    std::thread t1(worker_function, 1);
    std::thread t2(worker_function, 2);

    t1.join();
    t2.join();

    return 0;
}

If you run this code, you'll see that each thread correctly prints its own distinct message, even though they are both accessing the same global-looking variable thread_id_str. This is because each thread has its own private copy.

The following article provides a few more examples, including a common pattern of using thread_local to implement a thread-safe Singleton.

thread_local Storage in C++ 11

The article 'thread_local Storage in C++ 11' from GeeksforGeeks offers some simple, self-contained examples to reinforce the syntax.

Briefly review 'Example 1' and 'Example 2' to see the basic implementation patterns. Notice how in Example 2, each thread gets its own unique 'Singleton' instance.

Test your understanding!

Imagine you have a function void process_data() that is called by multiple threads. Inside this function, you need a large, expensive-to-create object (e.g., a memory buffer or a parser state machine) that should only be created once per thread and then reused on subsequent calls by that same thread.

How would you declare this object to achieve this behavior without using locks?

Show answer

You would declare it as a static thread_local variable inside the function.

void process_data() {
    // This object is created only once per thread, the first time this function is called by that thread.
    // It is then reused on all subsequent calls from the same thread.
    static thread_local ExpensiveObject per_thread_object; 

    // ... use per_thread_object ...
}
  • static ensures it has static storage duration within the thread, so it persists across function calls.
  • thread_local ensures each thread gets its own instance.

The Performance Cost of thread_local

So far, thread_local seems like a perfect, "zero-cost" solution to the problems of contention and false sharing. For your goal of building a high-performance profiler, this is exactly what we want. However, in systems programming, there is rarely a true "free lunch." The performance of thread_local can vary dramatically depending on how and where you use it.

This is a deep topic, but understanding the high-level performance characteristics is non-negotiable for your project.

Thread-Local Storage Data Structures, Variant II
This diagram illustrates some of the complex underlying machinery that an operating system and C++ runtime use to manage thread-local storage. Accessing `thread_local` data might involve navigating through structures like a Thread Control Block (TCB) and a Dynamic Thread Vector (DTV). This complexity hints that access isn't always a simple memory read and can have hidden costs.

The following article is a classic deep-dive into this topic. It's dense, but we will walk through the key conclusions that are directly relevant to you.

0+0 > 0: C++ thread-local storage performance

The article '0+0 > 0: C++ thread-local storage performance' by Yosef Kreinin is an essential read for anyone serious about performance. It dissects the assembly code generated for different thread_local use cases and reveals the hidden costs.

Read through the article, focusing on the following sections and key ideas. Don't worry about understanding every line of assembly; focus on the author's conclusions. The Fast Path: Read the first section, which shows that accessing a thread_local variable without a constructor in an executable is incredibly fast—often a single instruction. Constructors: Read this section to understand how adding a constructor introduces overhead. The compiler adds a 'guard' check before every access to see if the object has been initialized. Shared Libraries: This is a major 'gotcha'. See how putting the variable in a shared library (like a .dll or .so) changes the access from a single instruction to a function call (__tls_get_addr), which is much slower. Constructors in Shared Libraries: See how the overheads combine, leading to multiple function calls and even worse performance. This is the '0 + 0 > 0' scenario. Summary of performance guidelines: This section is your key takeaway. It provides a clear, actionable list of rules for using thread_local efficiently.

Performance Guidelines Summary

Based on the article, here are the crucial rules for high-performance use of thread_local:

  1. Best Performance: Accessing a thread_local variable of a simple type (like a pointer, int, float, or other Plain Old Data - POD) that is compiled directly into your main executable is extremely fast.
  2. Avoid Non-Trivial Constructors: If your thread_local object has a complex constructor, the compiler will inject a check before every access to ensure it's initialized. This adds a small but measurable overhead, especially in a tight loop.
  3. Beware of Shared Libraries: Placing thread_local variables inside a shared library that is loaded dynamically introduces significant overhead, often requiring a function call to resolve the variable's address.
  4. The Golden Rule for Performance: For your telemetry engine, the fastest and safest pattern will be to use a thread_local pointer.

Let's look at that last point.

// In a header file or main executable
#include "MyBuffer.h" // The buffer we will design later

// This is extremely fast to access! It's just a pointer.
thread_local MyBuffer* current_thread_buffer = nullptr;

void on_thread_start() {
    // We explicitly create and manage the buffer ourselves.
    // This avoids any hidden constructor overhead on access.
    if (current_thread_buffer == nullptr) {
        current_thread_buffer = new MyBuffer();
    }
}

void log_event(...) {
    // Access is fast, and we have a pointer to our per-thread data structure.
    current_thread_buffer->push_event(...);
}

void on_thread_stop() {
    delete current_thread_buffer;
    current_thread_buffer = nullptr;
}

This pattern gives us the best of both worlds: we get an efficient, thread-private data structure that avoids contention and false sharing, and we get extremely fast access to it via a simple thread_local pointer, sidestepping all the performance gotchas.


Conclusion

In this lesson, you've added a vital tool to your C++ concurrency toolkit. It's a high-level abstraction that elegantly solves low-level hardware performance problems.

Key Takeaways:

  • thread_local creates a separate instance of a variable for each thread, making it a natural fit for per-thread data structures.
  • It is a direct and effective solution to both lock contention (no shared data = no lock) and false sharing (separate instances are on separate cache lines).
  • For maximum performance, be mindful of the hidden costs. The fastest pattern, and the one we will use for your project, is to use a thread_local pointer and manage the object's lifetime manually.

You now understand the why (contention/false sharing) and the how (using thread_local) of a key architectural pattern for your telemetry profiler.

In our next and final lesson for this module, we will wrap things up by formally defining the "observer effect". You will see how all the concepts we've covered—from mutexes and condition variables to the performance implications of cache lines and thread-local storage—are all aimed at one central goal: measuring a system without changing its behavior.