cd ~/blogs

Modern C++ Concurrency: The Complete Guide (From C++11 to C++26)

For decades, writing safe, high-performance concurrent C++ required navigating a minefield of platform-specific APIs and undefined behavior. This article provides an exhaustive examination of the standard's solution, tracing the language's evolution from C++11's foundational thread-and-mutex primitives to the composable, zero-overhead asynchronous execution models of C++26. Dive into a rigorous taxonomy detailing the theoretical underpinnings of thread-level parallelism, stackless coroutines, portable SIMD, and the forthcoming sender-receiver framework for modern systems programming.


Abstract

The contemporary landscape of high-performance systems programming demands increasingly sophisticated command of concurrent and parallel execution models. Over the course of six successive revisions to the ISO C++ standard, spanning C++11 through C++26, the language has undergone tremendous change: from a state in which concurrency was entirely absent from the specification to one in which the standard library furnishes a comprehensive, layered taxonomy of abstractions encompassing thread-level parallelism, cooperative multitasking via coroutines, data-parallel execution policies, portable SIMD vectorization, standardized concurrent data structures, and a fully structured sender-receiver asynchronous execution framework.

This article dives into these features and their evolution, providing a rigorous taxonomy of the concurrency and parallelism abstractions introduced in each revision of the standard. We will explore the theoretical underpinnings of these models, their practical implications for software architecture and design, and how they enable developers to write safe, efficient, and scalable concurrent code in modern C++.


1. Motivation and Contemporary Relevance

For approximately four decades, the semiconductor industry delivered exponential growth in single-threaded processor performance—a trajectory colloquially attributed to Moore’s Law and, more precisely, to Dennard Scaling. The cessation of Dennard scaling in the mid-2000s precipitated a fundamental architectural shift: processor vendors began increasing core counts rather than the clock frequencies. Contemporary commodity hardware routinely incorporates 8, 16, or in excess of 128 processing cores.

This architectural reality carries a consequential implication for software engineering: programs that fail to exploit concurrent and parallel execution will utilize only a diminishing fraction of the available computational resources. As a result, mastery of concurrent programming paradigms has become an indispensable skill for software developers aiming to achieve high performance and scalability in their applications.


2. Conceptual Foundations and Taxonomic Distinctions

Before embarking upon a detailed examination of the available mechanisms, it is essential to establish precise definitions for four often-conflated concepts: concurrency, parallelism, asynchrony and cooperative multitasking.

2.1 Concurrency

Concurrency is a structural property of a program. A program is concurrent if it is decomposed into independently advancing computations whose execution may overlap in time. Crucially, concurrency does not mandate simultaneous execution; on a uniprocessor system, concurrent tasks are interleaved through preemptive or cooperative scheduling, producing the illusion of simultaneity.

    Concurrent execution on a single processor:
    
    Task A: ████░░░░████░░░░████
    Task B: ░░░░████░░░░████░░░░
            -------time------>
    (Tasks are interleaved, not simultaneous)

2.2 Parallelism

Parallelism is an execution property. It denotes the truly simultaneous execution of computations on distinct processing units. Parallelism presupposes concurrency; one cannot execute tasks simultaneously without first decomposing them, but the converse does not hold.

    Parallel execution on multiple processors:

    Core 0: ████████████████████
    Core 1: ████████████████████
            -------time------>
    (Tasks are executed simultaneously on separate cores)

2.3 Asynchrony

Asynchrony is a temporal property of execution, primarily concerned with managing latency. An asynchronous operation is one in which the initiation of a task is temporally decoupled from its completion. The invoking thread initiates a request (such as a disk I/O or network fetch) but does not block (idle) while waiting for the result; instead, it proceeds to execute other useful work.

While parallelism is about doing multiple things at the exact same time, asynchrony is about not waiting for one thing to finish before starting another.

2.4 Cooperative Multitasking

Cooperative multitasking introduces a specific structural modality to achieve concurrency and asynchrony within a single thread of execution. Unlike preemptive threads, where the operating system kernel determines scheduling decisions, cooperative tasks (coroutines) yield control voluntarily at programmer-designated suspension points.

No kernel transition occurs; context switching is performed entirely in user space at a cost typically two or three orders of magnitude lower than a kernel-mediated thread context switch. In modern C++, cooperative multitasking is a primary mechanism for orchestrating asynchrony without the overhead of preemptive OS threads.

The relationship among these four concepts may be summarized as follows:

    Taxonomy of Execution Models

    Concurrency (structural decomposition)
    |- Preemptive multithreading (OS-scheduled)
    |  └─ may exhibit parallelism (on multi-core hardware)
    └─ Cooperative multitasking (coroutines, user-scheduled)
       └─ typically single-threaded, but orchestrates asynchrony

    Asynchrony (temporal decoupling / latency hiding)
    |- Callbacks and promises (historical / explicit state)
    └─ Coroutine-driven (sequential syntax, asynchronous execution)

    Parallelism (simultaneous execution / compute throughput)
    |- Thread-level parallelism (multiple OS threads)
    └─ Data-level parallelism (SIMD / vectorization)

With these distinctions established, we proceed to examine the concrete facilities provided by the C++ standard.


3. Thread-Level Concurrency: Primitives, Lifecycle Management and Thread-Local Storage

3.1 std::thread (C++11)

The std::thread class, introduced in C++11, constitutes the most fundamental unit of concurrent execution in the standard library. Each std::thread object represents a single thread of execution managed by the operating system.

#include <iostream>
#include <thread>
#include <vector>

void worker(int id) {
    std::cout << "Thread " << id
              << " executing on hardware thread "
              << std::this_thread::get_id() << '\n';
}

int main() {
    const unsigned int n = std::thread::hardware_concurrency();
    std::cout << n << " hardware threads detected\n";

    std::vector<std::thread> threads;
    for (unsigned int i = 0; i < n; ++i)
        threads.emplace_back(worker, i);
    
    for (auto &t : threads)
        t.join();
    
    return 0;
}

Several critical invariants govern the lifecycle of std::thread objects:

  • A std::thread object that remains in a joinable state at the point of destruction precipitates a call to std::terminate(), resulting in abnormal program termination. The programmer bears the obligation of invoking either .join() (which blocks until the thread completes) or .detach() (which relinquishes ownership of the thread, allowing it to execute independently).
  • Function arguments are copied or moved into the new thread’s invocation state by default. To pass arguments by reference, one must employ std::ref() or std::cref().
  • Thread objects are move-only; they cannot be copied, reflecting the non-duplicate nature of underlying OS resources.
  • The std::thread::hardware_concurrency() function provides a hint to the number of hardware threads available, but it is not guaranteed to be accurate or non-zero.

3.2 std::jthread (C++20): RAII-Conformant Thread Management

The std::jthread class, introduced in C++20, remediates the well-documented deficiency of std::thread with respect to exception safety and resource management. The “j” in std::jthread stands for “joining,” reflecting its design to automatically join the associated thread upon destruction. It provides two principal enhancements:

  1. Automatic Joining in the destructor, conforming to the Resource Acquisition Is Initialization (RAII) idiom.
  2. Cooperative cancellation via the std::stop_token and std::stop_source mechanism.
#include <iostream>
#include <thread>
#include <chrono>
#include <stop_token>

void cancelable_worker(std::stop_token stoken, int id) {
    while (!stoken.stop_requested()) {
        std::cout << "Thread " << id << " performing computation...\n";
        std::this_thread::sleep_for(std::chrono::milliseconds(200));
    }
    std::cout << "Thread " << id
              << " received cancellation request. Performing cleanup.\n";
}

int main() {
    std::jthread t1(cancelable_worker, 1);
    std::jthread t2(cancelable_worker, 2);

    std::this_thread::sleep_for(std::chrono::seconds(1));

    t1.request_stop();
    t2.request_stop();

    // No explicit join invocation is required;
    // the destructors handle synchronization.
}

In contemporary C++ codebases, std::jthread should generally be the default choice for thread creation because it eliminates an entire class of resource-management errors. std::thread retains its utility primarily in those rarer circumstances where detached execution is specifically required.

3.3 Thread-Local Storage: The thread_local specifier (C++11)

A frequently underappreciated yet profoundly important facility for concurrent systems programming is the thread_local storage duration specifier, introduced in C++11. The most performant form of synchronization is, of course, the complete elimination of synchronization; thread_local achieves precisely this by ensuring that each thread of execution receives its own distinct instance of a variable, thereby obviating the need for any inter-thread coordination when accessing it.

#include <iostream>
#include <thread>
#include <vector>
#include <numeric>

// Each thread accumulates into its own private counter.
// No mutex, no atomic, no container whatsoever.
thread_local int local_counter = 0;

void accumulate_work(int iterations) {
    for (int i = 0; i < iterations; i++)
        ++local_counter; // Purely thread-local; no synchronization required
    
    // Thread-local result can be published at the end via a single 
    // atomic or locked operation - amortising the cost.
    std::cout << "Thread " << std::this_thread::get_id()
              << " local_counter = " << local_counter << '\n';
}

int main() {
    std::vector<std::jthread> threads;
    for (int i = 0; i < 4; i++)
        threads.emplace_back(accumulate_work, (i + 1) * 1'000'000);
    // Each thread independently reports their expected number.
}

Applicability in Lock-Free Infrastructure

The thread_local specifier is not merely a convenience; it is a foundational building block in the implementation of high-performance lock-free infrastructure. Production-grade implementations of hazard pointer mechanisms (discussed in later sections) rely extensively on thread_local storage to maintain each thread’s roster of active hazard pointers. By keeping this per-thread metadata in thread-local storage, the read-side fast path avoids all cross-thread lock contention:

#include <thread>
#include <array>
#include <atomic>
#include <vector>

// Simplified illustration of per-thread hazard pointer storage
struct HazardPointerRegistry {
    static constexpr int MAX_HP_PER_THREAD = 4;
    struct ThreadRecord {
        std::array<std::atomic<void*>, MAX_HP_PER_THREAD> hazard_ptrs{};
        std::vector<void*> retired_list;
    };

    // Each thread transparently receives its own ThreadRecord.
    // No locking is required to access one's own record.
    static ThreadRecord& get_thread_record() {
        thread_local ThreadRecord record;
        return record;
    }
};

Lifecycle Semantics

Several subtleties attend the use of thread_local that merit careful consideration:

AspectBehavior
InitializationBlock-scope thread_local variables undergo lazy initialization upon initial control-flow traversal. Namespace-scope variables are typically initialized at thread instantiation, prior to the execution of the thread’s primary routine.
DestructionObject destruction occurs upon thread termination, strictly following a Last-In, First-Out (LIFO) protocol based on the completion sequence of their initialization.
Interaction with staticApplying static to block-scope thread_local variables is semantically redundant, as thread storage duration inherently persists state. At namespace scope, static explicitly restricts the default external linkage to internal linkage.
Dynamic librariesThe behavior of thread_local within late-loaded shared objects is governed by the operating system’s ABI rather than the C++ Standard. It requires retroactive Thread Local Storage (TLS) allocation, which may fail if the library was compiled with rigid TLS optimization models.
#include <iostream>
#include <thread>
#include <vector>
#include <string>

class ThreadContext {
  private:
    std::vector<std::string> _events;
  public:
    ThreadContext() {
        std::cout << "ThreadContext constructed on thread "
                  << std::this_thread::get_id() << '\n';
    }
    ~ThreadContext() {
        std::cout << "ThreadContext destroyed on thread "
                  << std::this_thread::get_id() << '\n';
    }

    void record_event(const std::string& event) {
        _events.push_back(event);
    }

    size_t event_count() const { return _events.size(); }
};

// One instance per thread; constructed on first access, destroyed on thread exit.
ThreadContext& get_context() {
    thread_local ThreadContext ctx;
    return ctx;
}

int main() {
    std::jthread t1([] {
        get_context().record_event("task_started");
        get_context().record_event("task_completed");
        std::cout << "Thread 1 events: "
                  << get_context().event_count() << '\n'; // 2
    });

    std::jthread t2([] {
        get_context().record_event("initialization");
        std::cout << "Thread 2 events: "
                  << get_context().event_count() << '\n'; // 1
    });

    // Main thread has its own untouched context
    std::cout << "Main thread events: "
              << get_context().event_count() << '\n'; // 0
}
Note: The programs above may output interleaved prints from different threads, for example: "Thread Thread 2Thread 3Thread 4 executing on hardware thread ..." since no synchronization is applied to `std::cout`. In production code, you would typically want to synchronize access to shared resources like `std::cout` to avoid such interleaving. However, the examples are intentionally simplified to focus on the concurrency primitives rather than I/O synchronization which will be covered in later sections.

4. Mutual Exclusion, Synchronization Constructs and Thread-Safe Initialization

When multiple threads of execution access shared mutable state, the absence of synchronization gives rise to data races—a form of undefined behavior under the C++ memory model. The standard library provides a graduated hierarchy of synchronization primitives to address this concern.

4.1 Mutexes and RAII Lock Guards

The fundamental mechanism for mutual exclusion is the std::mutex, which guarantees that at most one thread may hold ownership at any given time:

#include <iostream>
#include <thread>
#include <mutex>
#include <vector>

std::mutex mtx;
int shared_counter = 0;

void increment(int iterations) {
    for (int i = 0; i < iterations; i++) {
        std::lock_guard<std::mutex> lock(mtx);
        ++shared_counter;
    }
}

int main() {
    std::vector<std::jthread> threads;
    for (int i = 0; i < 10; ++i)
        threads.emplace_back(increment, 100'000);
    
    threads.clear(); // jthread destructor joins each thread

    std::cout << "Counter = " << shared_counter << '\n';
    // Deterministically yields 1,000,000
}

The standard provides several RAII lock wrapper types, each suited to diverse usage patterns:

Lock TypeApplication Context
std::lock_guardSimple scoped mutual exclusion (C++11)
std::unique_lockDeferred locking, Ownership transfer, manual unlock/relock, or use with std::condition_variable
std::scoped_lockSimultaneous acquisition of multiple mutexes with deadlock avoidance (C++17)
std::shared_lockRead-side acquisition of a reader-writer mutex

4.2 Deadlock Avoidance via std::scoped_lock (C++17)

A well-known pathology in concurrent programming arises when two or more threads each hold a lock and await the release of a lock held by the other—a condition known as deadlock. The std::scoped_lock class template, introduced in C++17, accepts an arbitrary number of mutex arguments and acquires them using a deadlock-avoidance algorithm (typically based on the try-and-back-off strategy).

#include <mutex>

std::mutex mtx1, mtx2;

void safe_transfer() {
    // Acquires both mutexes in a deadlock-safe manner
    std::scoped_lock lock(mtx1, mtx2);
    // ... perform transfer between accounts ...
}

4.3 Reader-Writer Mutual Exclusion via std::shared_mutex (C++17)

In workloads characterized by preponderance of read operations over write operations, a conventional mutex imposes unnecessary serialization among readers. The std::shared_mutex permits multiple concurrent readers while maintaining exclusive access for writers:

#include <shared_mutex>
#include <mutex>
#include <map>
#include <string>

class ThreadSafeCache {
    mutable std::shared_mutex _mtx;
    std::map<std::string, std::string> _data;

  public:
    std::string read(const std::string &key) const {
        std::shared_lock lock(_mtx); // Concurrent readers permitted
        auto it = _data.find(key);
        return (it != _data.end()) ? it->second : "";
    }

    void write(const std::string &key, const std::string &value) {
        std::unique_lock lock(_mtx); // Exclusive access for writers
        _data[key] = value;
    }
};

4.4 Condition Variables: Event-Driven Synchronization

The std::condition_variable provides a mechanism by which a thread may suspend execution until a predicate over shared state becomes true, avoiding the inefficiency of busy-waiting. A canonical application is the implementation of a thread-safe bounded or unbounded queue:

#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue>
#include <syncstream>
#include <stop_token>

template <typename T>
class ThreadSafeQueue {
    std::queue<T> _queue;
    mutable std::mutex _mtx;
    std::condition_variable _cv;

  public:
    void push(T value) {
        {
            std::lock_guard lock(_mtx);
            _queue.push(std::move(value));
        }
        _cv.notify_one(); // Notify one waiting consumer
    }

    T pop() {
        std::unique_lock lock(_mtx);
        _cv.wait(lock, [this] { return !_queue.empty(); }); // Wait until not empty
        T value = std::move(_queue.front());
        _queue.pop();
        return value;
    }
};

int main() {
    ThreadSafeQueue<int> q;
    
    std::jthread producer([&](std::stop_token st) {
        for (int i = 0; i < 10 && !st.stop_requested(); ++i) {
            q.push(i);
            std::osyncstream(std::cout) << "Produced: " << i << '\n';
        }
        q.push(-1); // sentinel value to signal termination
    });

    std::jthread consumer([&]() {
        while (true) {
            int val = q.pop();
            if (val == -1)
                break;
            std::osyncstream(std::cout) << "Consumed: " << val << '\n';
        }
    });
}

It is imperative to observe that _cv.wait() must always be invoked with a predicate to guard against spurious wakeups—a well-documented property of condition variable implementations across all major operating systems.

4.5 Thread-Safe Initialization: std::call_once and Magic Statics (C++11)

The Singleton pattern and lazy initialization of shared resources are notorious sources of race conditions in concurrent programs. The historically prevalent “Double-Checked Locking Pattern” (DCLP), widely deployed in pre-C++11 code, is in fact broken without explicit memory fence operations—a subtlety that eluded even experienced practitioners for many years. C++11 provides two robust mechanisms that render such fragile patterns unnecessary.

4.5.1 std::call_once and std::once_flag

The std::call_once function guarantees that a callable is executed exactly once across all threads, regardless of how many threads concurrently attempt the initialization.

#include <mutex>
#include <memory>
#include <iostream>
#include <thread>
#include <vector>
#include <chrono>
#include <string>
#include <syncstream>

class DatabaseConnection {
  public:
    DatabaseConnection() {
        std::osyncstream(std::cout) << "Establishing database connection "
                  << "(expensive operation)...\n";
        std::this_thread::sleep_for(std::chrono::milliseconds(500));
    }

    void query(const std::string &sql) {
        std::osyncstream(std::cout) << "Executing query: " << sql << '\n';
    }
};

class ConnectionPool {
    std::once_flag _init_flag;
    std::unique_ptr<DatabaseConnection> _connection;
  public:
    DatabaseConnection& get_connection() {
        // Regardless of how many threads invoke this concurrently
        // the initialization lambda executes exactly once.
        // All other threads block until initialization completes.
        std::call_once(_init_flag, [this] {
            _connection = std::make_unique<DatabaseConnection>();
        });
        return *_connection;
    }
};

int main() {
    ConnectionPool pool;

    std::vector<std::jthread> threads;
    for (int i = 0; i < 8; ++i) {
        threads.emplace_back([&pool, i] {
            pool.get_connection().query("SELECT * FROM _table" + std::to_string(i)
            );
        });
    }
}

The std::once_flag object maintains the internal synchronization state. It is non-copyable, non-movable, and should typically reside as a member of the class governing the resource to be initialized.

4.5.2 Thread-Safe Block-Scope Statics (“Magic Statics”)

Perhaps even more consequentially, the C++11 standard mandated that the initialization of block-scope static variables must be performed in a thread-safe manner. This guarantee-colloquially referred to as “Magic Statics”—is enforced by the compiler, which emits the necessary synchronization instructions (typically a double-checked locking pattern implemented with acquire/release semantics or an equivalent mechanism) automatically:

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

class Logger {
  public:
    Logger() {
        std::cout << "Logger instance constructed (once, thread-safely)\n";
    }

    void log(const std::string &message) {
        std::cout << "[LOG] " << message << '\n';
    }
};

// The C++11 standard guarantees that this initialization is thread-safe.
// If multiple threads invoke get_logger() concurrently,
// exactly one performs the construction; all others block until it completes.
Logger& get_logger() {
    static Logger instance; // "Magic Static" - thread-safe by mandate
    return instance;
}

int main() {
    std::vector<std::jthread> threads;
    for (int i = 0; i < 8; ++i) {
        threads.emplace_back([i] {
            get_logger().log("Message from thread " + std::to_string(i));
        });
    }
}

This facility renders the Meyers Singleton pattern both correct and trivially simple in C++11 and beyond. There is no need for explicit mutex acquisition, std::call_once, or any manual synchronization whatsoever—the language guarantees correctness.

The distinction between the two mechanisms may be summarized as follows:

MechanismUse CaseReinvocableException Behavior
std::call_onceInitialization of member variables, resources requiring explicit controlNo (once flag is consumed)If the callable throws, the flag is not set; another thread may retry
Block-scope staticSingleton instances, function-local cachesNo (initialized once per program lifetime)If the constructor throws, initialization is reattempted on next entry

4.6 Latches, Barriers and Semaphores (C++20)

C++20 introduced three higher-level synchronization primitives that encapsulate patterns previously requiring manual implementation:

std::latch: One-Shot Countdown Synchronization

A latch is a single-use synchronization primitive initialized with a counter. Threads decrement the counter upon arrival; when it reaches zero, all waiting threads are released.

#include <iostream>
#include <thread>
#include <latch>
#include <vector>

void latch_example() {
    constexpr int N = 5;
    std::latch startup_latch(N);

    auto worker = [&](int id) {
        // ... perform initialization work ...
        std::cout << "Worker " << id << " initialization complete\n";
        startup_latch.arrive_and_wait(); // Decrement latch and wait for others
        std::cout << "Worker " << id << " proceeding with main computation\n";
    };

    std::vector<std::jthread> threads;
    for (int i = 0; i < N; ++i)
        threads.emplace_back(worker, i);
}

std::barrier: Reusable Phase Synchronization

A barrier generalizes the latch to support multiple phases. After all participants arrive at the barrier, an optional completion function is invoked, and the barrier resets for the subsequent phase.

#include <iostream>
#include <thread>
#include <barrier>
#include <vector>

void barrier_example() {
    constexpr int N = 4;
    auto on_phase_complete = []() noexcept {
        std::cout << "--- Phase boundary reached ---\n";
    };

    std::barrier sync_point(N, on_phase_complete);

    auto worker = [&] (int id) {
        for (int phase = 0; phase < 3; ++phase) {
            std::cout << "Worker " << id
                      << " executing phase " << phase << '\n';
            sync_point.arrive_and_wait(); // Wait for all workers to complete phase
        }
    };

    std::vector<std::jthread> threads;
    for (int i = 0; i < N; i++)
        threads.emplace_back(worker, i);
}

std::counting_semaphore: Bounded Concurrent Access

A counting semaphore limits the number of threads that may concurrently access a resource. This is particularly applicable to connection pool management, rate-limiting, and bounded producer-consumer scenarios.

#include <iostream>
#include <thread>
#include <semaphore>
#include <vector>
#include <chrono>

void semaphore_example() {
    std::counting_semaphore<3> sem(3); // Maximum three concurrent acquisitions

    auto limited_worker = [&sem](int id) {
        sem.acquire(); // Wait for an available slot
        std::cout << "Worker " << id
                  << " entered critical section\n";
        std::this_thread::sleep_for(std::chrono::milliseconds(500));
        std::cout << "Worker " << id
                  << " departing the critical section\n";
        sem.release(); // Release the slot
    };

    std::vector<std::jthread> threads;
    for (int i = 0; i < 10; ++i)
        threads.emplace_back(limited_worker, i);
}

5. Deferred Computation: Futures, Promises, and Asynchronous Launch

5.1 std::async and std::future

The std::async facility provides the most concise mechanism for offloading a computation and subsequently retrieving its result. The returned std::future object serves as a handle to the deferred result:

#include <iostream>
#include <future>
#include <vector>
#include <numeric>

long long parallel_sum(const std::vector<int>& data) {
    auto mid = data.begin() + data.size() / 2;

    auto future_sum = std::async(std::launch::async, [&] {
        return std::accumulate(data.begin(), mid, 0LL);
    });

    long long second_half = std::accumulate(mid, data.end(), 0LL);

    return future_sum.get() + second_half; // Waits for the async task to complete
}

int main() {
    std::vector<int> data(100'000'000, 1);
    std::cout << "Sum = " << parallel_sum(data) << '\n';
}

It is pertinent to observe that the std::launch::async policy guarantees execution on a new thread, whereas std::launch::deferred defers execution until .get() or .wait() is invoked. When neither policy is specified, the implementation is permitted to select either strategy—a source of non-determinism that has attracted considerable criticism in the literature.

5.2 std::shared_future: Multiple Consumers of a single Asynchronous Result. (C++11)

A critical and frequently overlooked distinction within the futures-and-promises taxonomy concerns the consumption semantics of std::future. The std::future class is strictly move-only and single-use: invoking .get() transfers ownership of the result, rendering subsequent invocations undefined behavior. This design reflects the single-consumer model but proves inadequate when multiple threads must await the same asynchronous result-a common fan-out broadcast pattern.

The std::shared_future class, also introduced in C++11, addresses this requirement. It is copyable, and multiple copies may concurrently invoke .get() on the same shared state, each receiving a const reference to the result:

#include <iostream>
#include <future>
#include <vector>
#include <thread>

// Simulate an expensive one-time computation whose result must be consumed by multiple downstream threads.
std::shared_future<int> load_configuration() {
    // std::async return std::future; .share() converts to shared_future
    return std::async(std::launch::async, [] {
        std::this_thread::sleep_for(std::chrono::seconds(1));
        return std::string("database_url=postgres://prod-server/mydb;"
        "pool_size=64;"
        "timeout=30s");
    }).share();
}

void worker(int id, std::shared_future<std::string> config_future) {
    // All workers block here until the single computation completes.
    // Each revieves a const reference to the same result
    const std::string& config = config_future.get();

    std::cout << "Worker" << id
              << " received config (length): " << config.size() << '\n';
    // ... proceed with thread-specific work using the shared config ...
}

int main() {
    // Launch the configuration load once
    auto shared_config = load_configuration();

    // Distribute the shared_future to multiple consumers
    std::vector<std::jthread> workers;
    for (int i = 0; i < 8; ++i) {
        workers.emplace_back(worker, i, shared_config);
        // Copies are safe
    }
    // All 8 workers share the same underlying asynchronous result.
    // The configuration is computed exactly once.
}

Semantic Distinction: std::future vs std::shared_future

std::future (single consumer):

Producer ----- set_value() ----> Shared State ----> get() ----> Consumer (result moved out; future is invalidated)

std::shared_future (multiple consumers):
Producer ----- set_value() ----> Shared State 
                                    |---> get() ----> Consumer 1 (const ref to result)
                                    |
                                    |----> get() ----> Consumer 2 (const ref to same result)
                                    |
                                    ...
                                    |----> get() ----> Consumer N (const ref to same result)
                                    (const ref; state remains valid)
Characteristicstd::future<T>std::shared_future<T>
CopyabilityNo (move-only)Yes
.get() invocationExactly once; moves the resultMultiple times; returns const T&
Thread safety of .get()Not safe to call from multiple threadsSafe to call concurrently from multiple threads
Conversion.share() -> std::shared_futureN/A
Typical use caseSingle consumer, task chainingFan-out broadcast, shared configuration, barrier-like synchronization

Integrated with std::promise

The std::shared_future pattern composes naturally with std::promise for manual inter-thread signalling to multiple waiters:

#include <future>
#include <thread>
#include <vector>
#include <iostream>

int main() {
    std::promise<int> signal;
    std::shared_future<int> broadcast = signal.get_future().share();

    // Multiple consumer threads, each awaiting the same signal
    std::vector<std::jthread> consumers;
    for (int i = 0; i < 4; ++i) {
        consumers.emplace_back([i, broadcast] {
            int value = broadcast.get(); // blocks until promise is fulfilled
            std::cout << "Consumer " << i
                      << " received broadcast value: " << 
                      value << '\n';
        });
    }

    // Producer fulfils the promise once; all consumers unblock
    std::this_thread::sleep_for(std::chrono::milliseconds(500));
    signal.set_value(42); // All consumers receive the same value
}

This pattern is functionally analogous to a std::latch with an associated payload—a one-shot barrier that also delivers data to all waiting participants.

5.3 std::promise and std::future: Explicit Inter-Thread Communication Channels

While std::async binds together the initiation of asynchronous work and the retrieval of its result, the std::promise and std::future decouples these concerns, offering lower-level mechanism to explicitly establish a one-shot communication channel between threads.

#include <iostream>
#include <future>
#include <thread>

int main() {
    std::promise<std::string> promise;
    std::future<std::string> future = promise.get_future();

    std::jthread t([&promise] {
        std::this_thread::sleep_for(std::chrono::seconds(1));
        promise.set_value("Result transmitted from producer thread");
    });
    
    // The following invocation blocks until the promise is fulfilled
    std::cout << future.get() << '\n'; 
}