TRT
Sign in
AlgorithmsUpdated

C++ Concurrency: Threads and Atomics

Learn how std::thread, std::mutex, and std::atomic share work safely, with the same programs in C++, Java, and Python.

The Runtime Theory Team4 min read

Modern hardware runs many things at once. Programs that use only one core leave most of the machine idle.

C++ offers a small complete toolkit for sharing work. A thread executes code beside other threads. A mutex lets one thread at a time enter a critical section. A condition variable parks a thread until some condition turns true. An atomic performs indivisible updates without locking.

Each tool removes a specific class of undefined behavior. This article shows all four, with every program written in three languages.

Threads run code beside each other

A thread is an independent stream of execution inside one process. Threads share the same memory. They run beside each other on available cores.

Creating a thread costs tens of microseconds plus megabytes of virtual stack space. That price rules out one thread per tiny task. Servers use thread pools: a fixed crew of workers that pull jobs from a queue.

Every thread must be joined or detached before its handle dies. Joining waits for completion. A C++ thread destroyed while still running terminates the whole program.

The same two-worker program in three languages:

#include <thread>
#include <iostream>
// Run two workers beside the main thread, then wait for both.
// join() blocks until the worker finishes: no result is lost.
void worker(int id) {
    // Each thread runs this function with its own id argument.
    std::cout << "worker " << id << "\n";
}
int main() {
    // Launch two execution streams; order of output may vary.
    std::thread t1(worker, 1);
    std::thread t2(worker, 2);
    t1.join(); // wait for worker 1 before continuing
    t2.join(); // wait for worker 2 before continuing
    return 0; // both workers are done here
} // a thread handle destroyed without join would terminate the program

All three launch two workers and wait for both. Output order varies run to run: that nondeterminism is the defining property of threads. Any code that assumes an order without synchronization is already broken.

A race corrupts data, it does not merely miscount

A data race happens when two threads touch the same memory, at least one writes, and neither uses synchronization. In C++ a data race is undefined behavior: the standard guarantees nothing at all about the program afterward.

Undefined behavior is stronger than a wrong number. The optimizer may hoist the load out of the loop, keep the counter in a register, or merge iterations. The result changes with optimization flags and processor models. Testing cannot catch it because any outcome, including the right one, is permitted.

The fix is a mutex. A mutex guards a critical section: a region that one thread at a time may enter. The same locked counter in three languages:

#include <thread>
#include <mutex>
// Count to 200,000 from two threads without losing a single update.
// The mutex serializes the increments: correctness first, speed second.
std::mutex mtx; // the guard: held during each increment
long counter = 0; // shared state: touched only while holding mtx
void bump() {
    // Each thread adds 100,000 under the lock.
    for (int i = 0; i < 100000; i++) {
        // lock_guard locks here and unlocks at scope end, always.
        std::lock_guard<std::mutex> lock(mtx);
        counter++; // exactly one thread runs this line at a time
    }
}
// Main launches two bump() threads, joins both, finds counter == 200000.

Without the lock the final count lands somewhere below 200,000, differently on each run. With the lock it reads exactly 200,000 every time. Determinism under concurrency always comes from synchronization, never from timing luck.

An uncontended lock costs about 20 to 40 nanoseconds on Linux: a user-space compare-and-swap pair. A contended lock falls into the kernel and costs microseconds plus a wakeup. Holding a lock across slow I/O turns every other thread's nanoseconds into milliseconds of queueing, so critical sections stay short.

Waiting needs a predicate, not a bare sleep

Threads often wait for events: a queue gaining an item, a flag turning true, a shutdown request arriving. A condition variable parks the waiter without burning CPU and wakes it when another thread signals.

Waiting must test a predicate: a boolean condition rechecked under the lock. The wait call can return without any signal, through spurious wakeups and notify-before-wait races. Code that waits without a predicate misses events once in a million runs and never in CI.

The pattern has three roles. The shared flag says what changed. The mutex protects the flag. The variable carries the wakeup. Producers lock, set the flag, unlock, and notify. Consumers lock and wait on the predicate, which retests the flag after every wakeup.

Reach for unique_lock where condition variables appear, since waiting must release the lock while parked and reacquire it on return. Prefer shared_mutex only when measurements show read-heavy contention worth the complexity.

Atomics order memory, not only values

An atomic makes one read-modify-write indivisible. No thread observes a half-applied update. On x86 a fetch-add compiles to a single locked instruction costing about 10 to 20 cycles uncontended.

Atomicity alone does not order surrounding code. Memory ordering controls which other writes become visible alongside the atomic one:

OrderGuaranteeUse it for
relaxedatomicity only, no orderingcounters nobody reads for decisions
acquire and releasewrites before the release reach the matching acquireflags that publish data
seq_cst, the defaultone total order across all threadsanything uncertain, at fence cost

The workhorse pattern pairs a payload with a flag. The producer writes the payload, then sets the flag with release semantics. The consumer reads the flag with acquire semantics, then trusts the payload. The same publication in three languages:

#include <atomic>
#include <string>
// Publish a payload from one thread to another with ordering included.
// Release/acquire pairs the flag with every write before it.
std::string payload; // written by the producer, read by the consumer
std::atomic<bool> ready{false}; // the flag: false until published
void producer() {
    payload = "result-42"; // 1. build the payload fully first
    // 2. release: no earlier write may pass this store.
    ready.store(true, std::memory_order_release);
}
void consumer() {
    // 3. acquire: loop until the flag arrives.
    while (!ready.load(std::memory_order_acquire)) {}
    // 4. payload is complete here: release pairs with acquire.
    std::string copy = payload; // safe: sees "result-42"
}

A relaxed flag would set the bit correctly while leaving the payload garbage: the consumer wakes, reads half-written data, and corrupts downstream state. Ordering is what makes publication safe, not the atomicity of the flag alone.

Default to sequential consistency and relax deliberately. Use acquire and release for message passing with an explicit flag-plus-payload shape. Reserve relaxed for statistics nobody orders against.

Servers handle thousands of connections with pooled workers. Browsers parse, lay out, and paint on cooperating threads. Databases latch pages with atomics on hot paths and mutexes elsewhere. Game engines simulate, render, and stream assets side by side.

The discipline stays constant across all of them. Share nothing, or guard what you share. Wait on predicates, not timers. Order publication, not only the flag. Measure contention instead of assuming it.

Watch the concurrency and synchronization trace to see interleavings execute step by step. Then revisit RAII and smart pointers for the lock guards that make mutex use exception-safe.

Spotted a mistake or something broken? Report a problem

Not started

Sign in to save your learning progress.

Sign in to save