Skip to content
jieqi's archive
Go back

C++: Concurrency Pt 1 - Threads

Contents

This series is about concurrency in C++: running code on multiple threads, protecting the data they share, signaling between them, and the ordering rules that govern it all. The map below is the full territory. This post covers the THREADS column — what threads are for, and how to use them in idiomatic C++.

                        C++ CONCURRENCY
                               |
   +-------------+------------+------------+--------------+
   |             |            |            |              |
 THREADS      SHARING      WAITING       TASKS         ORDERING
 (running)  (protecting)  (signaling)  (returning)    (the rules)
   |             |            |            |              |
 std::thread  mutex +      condition    std::async     atomics,
 std::jthread lock_guard/  variables,   futures /      happens-before,
              unique_lock/ latch,       promises,      acquire/release,
              scoped_lock  barrier,     packaged_task  seq_cst
                           semaphore

Motivation

Threads let a program do several things at once. A simple example:

#include <iostream>
#include <thread>

int x = 0;
int y = 0;

void f() { x = 40; }
void g() { y = 2; }

int main() {
    std::thread a{f};
    std::thread b{g};

    a.join();
    b.join();

    std::cout << x + y << "\n";   // 42
}

Two threads run at the same time — one computes x, the other y.

std::thread a{f} creates a new thread inside the same process and sets it running on f immediately. The constructor returns without waiting, so main continues to the next line while f runs alongside it.

a.join() blocks the caller until f has returned. Only the caller waits: while main sits in a.join(), b keeps running, so the join order doesn’t have to match the finish order. After a join returns, everything the thread wrote is visible to the joiner — which is what makes reading x and y afterwards safe.

The alternative is a.detach(), which disowns the thread: it keeps running on its own, and there is no way to wait for it or get anything back. Useful for fire-and-forget work, rarely what you want:

std::thread t{background_logger};
t.detach();   // runs on its own; t no longer refers to it

Every std::thread must be joined or detached before the object is destroyed.

std::jthread

std::thread’s destructor doesn’t join — it calls std::terminate if the thread is still joinable. So every exit path has to be covered by hand:

void risky() {
    std::thread t{work};
    if (failed()) return;   // t destroyed while joinable -> std::terminate(), process dies
    t.join();
}

An exception thrown before the join() does the same thing. std::jthread (C++20) fixes this by joining in its destructor:

void safe() {
    std::jthread t{work};
    if (failed()) return;   // fine, destructor calls t.join()
}                           // fine, destructor calls t.join()

Same RAII pattern as a lock_guard: correct on every exit path, including exceptions.

jthread also carries a stop flag. There’s no way to kill a thread in C++ — that would leave locks held and destructors unrun — so stopping is cooperative: you ask, and the thread returns on its own. If the callable’s first parameter is a std::stop_token, jthread passes one in automatically:

void worker(std::stop_token st) {           // first param is a stop_token,
                                            // so jthread passes one in
    while (!st.stop_requested()) {          // check the flag each round
        std::this_thread::sleep_for(100ms);
    }
    printf("stopped\n");
}

int main() {
    {
        std::jthread t{worker};
        std::this_thread::sleep_for(350ms);
        printf("leaving scope\n");
    }   // destructor: request_stop(), then join()

    printf("done\n");
}
leaving scope
stopped
done

The destructor requests the stop first, then joins — without the request, that join would hang forever on an infinite loop. The closing brace blocks: main waits there until the worker notices and returns.

The flag is polled, not preemptive, so a thread parked in a long sleep_for won’t notice until it wakes. t.request_stop() also works explicitly, and std::stop_source lets one signal cancel several workers.

Prefer jthread by default. std::thread is for when you specifically want detach semantics.

The std::this_thread namespace acts on the current thread — sleep_for above, plus get_id() and yield().

thread_local

thread_local is a storage duration, alongside the familiar three:

automatic  (locals)         one per scope entry      dies at closing brace
static     (globals)        one, program-wide        dies at program exit
dynamic    (new/delete)     as many as you make      dies when you say
thread     (thread_local)   one per thread           dies when that thread exits

A thread_local variable looks like a global in the source, but every thread gets its own copy:

thread_local int perthread = 0;

void work(const char* name) {
    for (int i = 0; i < 1000; ++i)
        ++perthread;                // no race: private copy
    printf("%s: perthread=%d (address %p)\n", name, perthread, (void*)&perthread);
}
thread A: perthread=1000 (address 0x102485edc)
thread B: perthread=1000 (address 0x10248605c)

Both threads increment “the same variable” a thousand times and both see 1000, not 2000 — different addresses, different objects. Each copy is initialized before its first use in that thread and destroyed when that thread exits, destructors included.

The point is to sidestep sharing rather than protect it: no sharing, no race, no mutex. errno works this way, and it’s the usual fix for per-thread random engines, scratch buffers, and logging context.

The costs: access goes through an indirection rather than a fixed address, and memory multiplies by thread count. The trap is thread pools — a thread_local lives as long as the thread, not the task, so state left behind by one task is still there when the next one runs on that thread.

Wrapping Up

Threads run alongside each other in one process, sharing everything but their stacks. std::thread launches one and must be joined or detached before it’s destroyed; std::jthread does the joining in its destructor and carries a stop flag for asking a thread to finish. thread_local gives each thread a private copy of a variable, which avoids sharing altogether.

Everything here worked because the threads never touched the same data. Pt 2 covers what happens when they do, and the tools for it: SHARING.

Appendix: Threads, Processes, and pthread_create

Expand

A process owns resources: a virtual address space, a heap, file descriptors, the loaded code. A thread is an execution context within a process: its own stack, registers, and program counter. Everything else is shared:

        thread A            thread B
        own stack           own stack
        own registers + PC  own registers + PC
             \                 /
        SHARED: heap, globals, code, file descriptors

This is why the threads in the example above could just write to the globals x and y: same address space, no copying, no message-passing. A new process (fork) would have been useless there — the child writes to its own copy of x, invisible to the parent.

pthread_create is the POSIX threads C API — the layer std::thread is a thin wrapper over on Linux and macOS. Constructing a std::thread walks this chain:

std::thread a{f};
   |
   |  1. library: copy f (and any args) into storage owned
   |     by the new thread
   |  2. call pthread_create(...)
   |  3. syscall: kernel creates a new schedulable entity
   |       - fresh stack (~512 KB on macOS, 8 MB on Linux, by default)
   |       - fresh registers + program counter, aimed at f
   |       - added to the scheduler's run queue
   |  4. the returned handle is stored in the std::thread object
   v
constructor returns — possibly before f has even started

From then on the kernel time-slices all threads across cores like anything else it schedules. Note the last line: constructing a thread only guarantees f will run, not that it has started. The only ordering guarantee is at join().

Next Post
C++: Polymorphism - Overloading