Skip to content
C++
Idiom
since C++11
Advanced

Executor

Design pattern decoupling callable work from its execution context; formalized in C++26 as the scheduler/sender/receiver model in std::execution.

Executorsince C++11

An executor is an object that decouples the submission of callable work from the execution context that runs it, letting algorithms dispatch tasks uniformly to thread pools, event loops, GPU streams, or inline runners without encoding that choice at the call site.

Overview

Any non-trivial async codebase eventually asks the same question: who decides where work runs? Hard-wiring std::thread or std::async inside a library component couples it to a specific execution strategy, preventing reuse across contexts that differ in threading, scheduling policy, or hardware.

The executor idiom answers this by introducing an intermediary: a lightweight handle to an execution context that accepts callable work and arranges for it to run. The caller controls the executor; the algorithm only knows how to submit through it.

The pattern appeared throughout the C++11–20 ecosystem β€” in Boost.ASIO's io_context, Intel TBB's task_arena, and folly's Executor base class β€” but each framework defined its own interface. Code written against one could not be reused with another, and composing pipelines across framework boundaries required manual glue.

C++26 standardized the vocabulary through the sender/receiver model (formerly P2300). The standard introduces four interlocking concepts:

  • Scheduler β€” a lightweight, copyable handle to an execution context (thread pool, run_loop, GPU queue). A scheduler does not run work itself; it is a factory that produces senders.
  • Sender β€” a lazy description of async work. A sender does not begin executing when constructed. It describes what should happen; execution begins only when the sender is connected to a receiver and the resulting operation state is started.
  • Receiver β€” a generalized callback with three typed completion channels: set_value (success result), set_error (exception or error code), and set_stopped (cancellation signal).
  • Operation State β€” the heap- or stack-resident object produced by connecting a sender to a receiver. Calling start() on it begins the async operation; the operation state's address must remain stable until one of the three completion functions fires.

This replaces the "callable in, future out" interface of std::async (C++11) with a composable graph model where pipelines are built before any work begins.

Syntax

Pre-standard executor (C++11 idiom)

Before C++26, an executor was any type satisfying an informal concept: it exposes an execute method that accepts a callable and arranges for it to run. A minimal thread-pool implementation:

cpp
// C++11 β€” hand-rolled executor concept
class ThreadPoolExecutor {
    std::vector<std::thread>              workers_; // C++11
    std::queue<std::function<void()>>     tasks_;   // C++11
    std::mutex                            mu_;
    std::condition_variable               cv_;
    bool                                  stop_ = false;

public:
    explicit ThreadPoolExecutor(std::size_t n) {
        for (std::size_t i = 0; i < n; ++i)
            workers_.emplace_back([this] {
                for (;;) {
                    std::function<void()> task;
                    {
                        std::unique_lock lk{mu_}; // C++17 CTAD
                        cv_.wait(lk, [this]{ return stop_ || !tasks_.empty(); });
                        if (stop_ && tasks_.empty()) return;
                        task = std::move(tasks_.front());
                        tasks_.pop();
                    }
                    task();
                }
            });
    }

    template<typename F>
    void execute(F&& f) {
        { std::lock_guard lk{mu_}; tasks_.emplace(std::forward<F>(f)); } // C++17 CTAD
        cv_.notify_one();
    }

    ~ThreadPoolExecutor() {
        { std::lock_guard lk{mu_}; stop_ = true; }
        cv_.notify_all();
        for (auto& t : workers_) t.join();
    }
};

// Algorithm parameterized over any executor that satisfies the concept
template<typename Executor, typename F>
void schedule(Executor& ex, F&& task) {
    ex.execute(std::forward<F>(task));
}

The shortfall: no standard way to propagate errors, no cancellation contract, and callers must bolt on a return-value mechanism (std::promise/std::future, callbacks) ad hoc.

C++26 std::execution

The standard library provides #include <execution> (C++26) with a run_loop execution context, sender factories, and adaptors:

cpp
#include <execution>  // C++26
#include <thread>     // C++11
#include <string>     // C++98

int main() {
    std::execution::run_loop loop;                      // C++26

    // Worker thread drives the run_loop; stop_token triggers finish()
    std::jthread worker([&](std::stop_token st) {       // C++20 jthread
        std::stop_callback cb{st, [&]{ loop.finish(); }}; // C++20
        loop.run();   // blocks until loop.finish() is called
    });

    std::execution::scheduler auto sched = loop.get_scheduler(); // C++26

    // Build the pipeline β€” no work executes yet
    std::execution::sender auto work =
        std::execution::just(std::string{"hello, world"})   // C++26: value sender
        | std::execution::then([](std::string s) {          // C++26: transform
              return static_cast<int>(s.size());
          });

    // Pin the pipeline to sched and block until done
    std::execution::sender auto pinned = std::execution::on(sched, std::move(work)); // C++26

    auto [n] = std::this_thread::sync_wait(std::move(pinned)).value(); // C++26
    // n == 13
}

sync_wait returns std::optional<std::tuple<Ts...>> holding the value-channel results. It returns std::nullopt on cancellation and throws on error-channel completion.

Examples

Execution-context-agnostic algorithm

A function that computes a result asynchronously, with the scheduler supplied by the caller:

cpp
// C++26 β€” caller chooses execution context; algorithm stays generic
#include <execution>
#include <span>    // C++20
#include <numeric> // C++98

auto async_dot_product(std::execution::scheduler auto sched,
                       std::span<const float> a,  // C++20
                       std::span<const float> b) {
    return std::execution::on(
        sched,
        std::execution::just(a, b)
            | std::execution::then([](std::span<const float> x,
                                      std::span<const float> y) {
                  float sum = 0.f;
                  for (std::size_t i = 0; i < x.size(); ++i)
                      sum += x[i] * y[i];
                  return sum;
              })
    );
}

// Usage β€” same algorithm, different contexts:
// auto [r1] = std::this_thread::sync_wait(async_dot_product(pool_sched, a, b)).value();
// auto [r2] = std::this_thread::sync_wait(async_dot_product(gpu_sched,  a, b)).value();

Multi-stage pipeline with context transitions

A realistic pipeline: read on an IO scheduler, compute on a CPU pool, then continue on the originating context:

cpp
// C++26 β€” multi-stage pipeline composing two schedulers
auto io_then_compute(std::execution::scheduler auto io_sched,
                     std::execution::scheduler auto cpu_sched,
                     std::string path) {
    return std::execution::on(
               io_sched,
               std::execution::just(std::move(path))
                   | std::execution::then([](std::string p) {
                         return read_file(p); // returns std::vector<std::byte>
                     })
           )
           | std::execution::then([cpu_sched](std::vector<std::byte> raw) {
                 return std::execution::on(
                     cpu_sched,
                     std::execution::just(std::move(raw))
                         | std::execution::then([](std::vector<std::byte> data) {
                               return parse(data); // returns ParseResult
                           })
                 );
             });
}

Execution begins only when a consumer calls sync_wait or connects a receiver β€” constructing the pipeline is pure description with no side effects.

stdexec reference implementation

C++26 is not yet widely shipped, but the sender/receiver model is available today through NVIDIA's stdexec library, which was the principal reference implementation used to develop P2300. For projects that need standards-committee alignment over NVIDIA's extended features, the Beman project (bemanproject/execution) provides a closer mirror of the final wording.

Add stdexec via CMake FetchContent:

cmake
# CMakeLists.txt β€” fetch stdexec at configure time
include(FetchContent)

FetchContent_Declare(
    stdexec
    GIT_REPOSITORY https://github.com/NVIDIA/stdexec.git
    GIT_TAG        main   # pin to a release tag in production
)
FetchContent_MakeAvailable(stdexec)

target_link_libraries(my_target PRIVATE stdexec)

The stdexec headers live under the stdexec/ namespace for scheduler utilities (exec::) and the standard-compatible vocabulary under stdexec::. The following example dispatches work to a static thread pool and blocks for the result β€” the same pattern shown in the C++26 examples above, but compilable on C++20 compilers today:

cpp
// C++20 with stdexec β€” equivalent to the C++26 run_loop example
#include <stdexec/execution.hpp>   // stdexec
#include <exec/static_thread_pool.hpp> // exec extension

#include <cstdio>

int main() {
    exec::static_thread_pool pool{4}; // 4 worker threads

    stdexec::scheduler auto sched = pool.get_scheduler();

    // Build pipeline: schedule onto pool, transform result
    stdexec::sender auto work =
        stdexec::schedule(sched)                          // produces void sender on pool
        | stdexec::then([] {
              // runs on one of the 4 pool threads
              return 6 * 7;
          });

    // Block calling thread until the pipeline completes
    auto [result] = stdexec::sync_wait(std::move(work)).value();
    std::printf("result = %d\n", result); // result = 42
}

exec::static_thread_pool is not part of the C++26 standard but is the de facto reference pool for testing and benchmarking sender-based code. The standard itself does not mandate a thread pool; it only requires run_loop.

Boost.ASIO comparison

Boost.ASIO's executor model predates std::execution and inspired parts of it, but the two have different vocabularies. ASIO models an executor as a type with dispatch, post, and defer member functions; P2300 replaced this with the scheduler/sender split. In newer ASIO versions (1.28+) the any_io_executor type bridges the gap: it wraps an ASIO executor as a type-erased handle compatible with the updated ASIO algorithms, and work is underway to align asio::execution::executor with std::execution::scheduler.

The following two snippets perform the same operation β€” submit a task and obtain its integer result β€” first in ASIO, then in std::execution:

cpp
// C++17 β€” Boost.ASIO io_context + asio::post
#include <boost/asio.hpp>
#include <future>  // C++11

int main() {
    boost::asio::io_context ioc;

    // Keep ioc alive while work is pending
    auto guard = boost::asio::make_work_guard(ioc);

    std::future<int> fut = boost::asio::post(
        ioc,
        std::packaged_task<int()>([] { return 6 * 7; })
    );

    // Drive the io_context on a separate thread
    std::thread worker([&]{ ioc.run(); });

    int result = fut.get();   // blocks until task completes
    guard.reset();            // allow ioc.run() to return
    worker.join();

    // result == 42
}
cpp
// C++26 β€” std::execution equivalent
#include <execution>
#include <thread>  // C++11

int main() {
    std::execution::run_loop loop;

    std::jthread worker([&](std::stop_token st) {        // C++20
        std::stop_callback cb{st, [&]{ loop.finish(); }};
        loop.run();
    });

    auto sched = loop.get_scheduler();

    auto [result] = std::this_thread::sync_wait(         // C++26
        std::execution::on(
            sched,
            std::execution::just() | std::execution::then([] { return 6 * 7; })
        )
    ).value();

    // result == 42
}

The structural difference is that ASIO's post is eager β€” the lambda is immediately enqueued β€” whereas std::execution::on builds a description that is only started when sync_wait connects a receiver. This laziness enables the adaptor composition (then, when_all, let_value) that makes std::execution pipelines recomposable without re-invoking the submission machinery.

Custom scheduler implementation

Understanding what the scheduler concept requires is best demonstrated by building a minimal one. A conforming scheduler must expose a schedule() member that returns a sender. The sender's connect() must return an operation state whose start() enqueues the receiver's set_value call onto the execution context. The scheduler itself should be equality-comparable so that std::execution::on can detect context transitions.

The following wraps a single background thread and a std::deque work queue into a minimal but fully concept-satisfying scheduler:

cpp
// C++20 β€” minimal custom scheduler satisfying std::execution::scheduler
#include <execution>     // C++26 concepts used for documentation clarity
#include <thread>        // C++11
#include <deque>         // C++98
#include <mutex>         // C++11
#include <condition_variable> // C++11
#include <functional>    // C++11

// Forward declarations
struct SingleThreadScheduler;

// --- Operation state ---
// Produced by SingleThreadSender::connect(). Holds the receiver and a pointer
// back to the queue so start() can enqueue the completion.
template<typename Receiver>
struct SingleThreadOpState {
    Receiver            rcv;
    SingleThreadScheduler* sched;

    // start() is called by the consumer (e.g. sync_wait) to begin the operation
    friend void tag_invoke(stdexec::start_t, SingleThreadOpState& self) noexcept {
        // Enqueue: run set_value on the worker thread
        self.sched->enqueue([r = std::move(self.rcv)]() mutable {
            stdexec::set_value(std::move(r)); // C++26: fire value channel
        });
    }
};

// --- Sender ---
struct SingleThreadSender {
    using completion_signatures =
        stdexec::completion_signatures<stdexec::set_value_t()>; // C++26

    SingleThreadScheduler* sched;

    template<typename Receiver>
    friend auto tag_invoke(stdexec::connect_t,
                           SingleThreadSender self,
                           Receiver&& rcv) {
        return SingleThreadOpState<std::remove_cvref_t<Receiver>>{
            std::forward<Receiver>(rcv), self.sched
        };
    }
};

// --- Scheduler ---
struct SingleThreadScheduler {
    // Opaque id for equality comparison (two handles into the same pool are equal)
    struct Id { void* ptr; };

    std::deque<std::function<void()>> queue_; // C++11
    std::mutex                        mu_;
    std::condition_variable           cv_;
    bool                              stop_ = false;
    std::thread                       worker_;

    SingleThreadScheduler()
        : worker_([this] {
              for (;;) {
                  std::function<void()> work;
                  {
                      std::unique_lock lk{mu_}; // C++17 CTAD
                      cv_.wait(lk, [this]{ return stop_ || !queue_.empty(); });
                      if (stop_ && queue_.empty()) return;
                      work = std::move(queue_.front());
                      queue_.pop_front();
                  }
                  work();
              }
          })
    {}

    void enqueue(std::function<void()> f) {
        { std::lock_guard lk{mu_}; queue_.push_back(std::move(f)); }
        cv_.notify_one();
    }

    // schedule() is the only requirement the scheduler concept imposes
    friend SingleThreadSender tag_invoke(stdexec::schedule_t,
                                         SingleThreadScheduler& self) noexcept {
        return SingleThreadSender{&self};
    }

    // Equality: two scheduler handles are equal iff they refer to the same context
    friend bool operator==(const SingleThreadScheduler& a,
                           const SingleThreadScheduler& b) noexcept {
        return &a == &b;
    }

    ~SingleThreadScheduler() {
        { std::lock_guard lk{mu_}; stop_ = true; }
        cv_.notify_all();
        worker_.join();
    }
};

// Usage
int main() {
    SingleThreadScheduler sched;

    auto [v] = stdexec::sync_wait(
        stdexec::schedule(sched) | stdexec::then([] { return 99; })
    ).value();
    // v == 99
}

The key insight is that schedule() does not enqueue anything. It constructs a sender description. Enqueueing happens only inside start(), after the consumer has provided a receiver and committed to owning the operation state. This is what keeps senders composable: an on() adaptor can intercept between connect and start to redirect the work to a different context.

Coroutine interop

Senders and C++20 coroutines are complementary mechanisms, not competing ones. Coroutines express sequential async logic with co_await; senders express composable async pipelines. The integration point is stdexec::as_awaitable, which wraps any sender as an awaitable so that a coroutine can co_await it directly. The result is delivered through the value channel, errors are thrown, and stop signals cancel the coroutine.

cpp
// C++20 coroutines + stdexec β€” suspending on a sender
#include <stdexec/execution.hpp>
#include <exec/static_thread_pool.hpp>
#include <exec/task.hpp>   // exec::task<T>: a coroutine type built on senders

exec::task<int> compute_on_pool(stdexec::scheduler auto pool_sched) {
    // co_await schedule(pool_sched) transfers execution to a pool thread.
    // The coroutine suspends here; the pool thread resumes it.
    co_await stdexec::schedule(pool_sched);  // C++20 co_await on sender

    // From this point forward we are running on the thread pool
    int a = 21;
    int b = 21;
    co_return a + b;
}

int main() {
    exec::static_thread_pool pool{4};

    // exec::task<T> is itself a sender, so sync_wait consumes it
    auto [result] = stdexec::sync_wait(
        compute_on_pool(pool.get_scheduler())
    ).value();
    // result == 42
}

as_awaitable lets any sender be awaited inside a coroutine body, even one returned by a custom scheduler:

cpp
// C++20 β€” co_await any sender via as_awaitable
exec::task<std::string> fetch_and_transform(stdexec::scheduler auto sched) {
    // Await an arbitrary sender pipeline directly
    int n = co_await stdexec::as_awaitable(
        stdexec::on(sched, stdexec::just(std::string{"hello"})
            | stdexec::then([](std::string s) { return static_cast<int>(s.size()); })
        )
    );
    co_return "length was " + std::to_string(n);
}

The lifetime contract is satisfied automatically: co_await keeps the coroutine frame (which contains the operation state) alive until the sender fires a completion. This eliminates the most dangerous manual lifetime hazard in raw sender code.

Inline executor for testing

Unit tests that use a thread pool introduce non-determinism: task ordering, timing, and context switching all vary between runs. An inline scheduler sidesteps this by running submitted work synchronously, in the calling thread, at the moment start() is invoked. There are no threads, no queues, and no race conditions; the behavior is fully deterministic and debuggable with a single-threaded debugger.

cpp
// C++20 β€” InlineScheduler: runs work synchronously in the caller's thread
#include <stdexec/execution.hpp>

struct InlineScheduler;

template<typename Receiver>
struct InlineOpState {
    Receiver rcv;

    // start() runs the work immediately β€” no thread switch, no queue
    friend void tag_invoke(stdexec::start_t, InlineOpState& self) noexcept {
        stdexec::set_value(std::move(self.rcv)); // C++26: inline dispatch
    }
};

struct InlineSender {
    using completion_signatures =
        stdexec::completion_signatures<stdexec::set_value_t()>;

    template<typename Receiver>
    friend auto tag_invoke(stdexec::connect_t, InlineSender, Receiver&& rcv) {
        return InlineOpState<std::remove_cvref_t<Receiver>>{std::forward<Receiver>(rcv)};
    }
};

struct InlineScheduler {
    friend InlineSender tag_invoke(stdexec::schedule_t, InlineScheduler) noexcept {
        return {};
    }
    friend bool operator==(InlineScheduler, InlineScheduler) noexcept { return true; }
};

// --- Usage in a test ---
// #include "my_algorithm.hpp"
// TEST(MyAlgorithmTest, ComputesResult) {
//     InlineScheduler sched;
//     auto [v] = stdexec::sync_wait(my_algorithm(sched, 6, 7)).value();
//     EXPECT_EQ(v, 42);
// }

Prefer InlineScheduler as the default scheduler in test fixtures. Because it runs synchronously, sync_wait returns before the next line of the test, making assertions straightforward without std::future or condition variables.

Do not use InlineScheduler in production pipelines that rely on parallelism. Pipelines that fan out with when_all and expect tasks to run concurrently will serialize incorrectly on an inline scheduler, producing correct but unexpectedly slow results. Reserve it for unit tests and algorithm correctness verification.

run_loop lifecycle

std::execution::run_loop (C++26) is the standard library's reference single-threaded execution context. It is not a thread pool; it owns no threads. Instead, it exposes a blocking run() call that the application drives from whatever thread it chooses. This design makes it useful for embedding into existing event loops (libuv, Qt, ASIO) or as a test harness where the caller controls the drive thread.

The full lifecycle:

cpp
// C++26 β€” complete run_loop lifecycle
#include <execution>  // C++26
#include <thread>     // C++11
#include <atomic>     // C++11
#include <cassert>

int main() {
    // 1. Construction: run_loop is ready to accept work but is not running yet.
    std::execution::run_loop loop;                             // C++26

    // 2. Obtain a scheduler handle (lightweight, copyable, cheap to pass by value).
    //    The scheduler does not start the loop; it only produces senders.
    std::execution::scheduler auto sched = loop.get_scheduler(); // C++26

    // 3. Spawn a worker thread that calls run(), which blocks until finish() is
    //    signalled. Using jthread (C++20) ties the thread lifetime to the scope and
    //    provides a stop_token for cooperative cancellation.
    std::jthread worker([&](std::stop_token st) {              // C++20
        // 4. Register a stop callback: when the jthread is requested to stop,
        //    call finish() so that run() can return.
        std::stop_callback cb{st, [&]{ loop.finish(); }};      // C++20

        // 5. run() blocks here, dequeuing and executing submitted work items
        //    one at a time in FIFO order until finish() is called.
        loop.run();
    });

    // 6. Submit work by building senders against sched and consuming them with
    //    sync_wait.  Work is enqueued lazily when sync_wait calls start().
    std::atomic<int> result{0};

    auto [v] = std::this_thread::sync_wait(                    // C++26
        std::execution::on(
            sched,
            std::execution::just(21)
                | std::execution::then([](int x) { return x * 2; })
        )
    ).value();

    result.store(v, std::memory_order_relaxed);

    // 7. finish() can also be called explicitly (here via jthread destructor
    //    requesting a stop, which triggers the stop_callback above).
    //    worker's destructor calls request_stop() then joins the thread.

    assert(result.load() == 42);
}

Several behaviors are worth noting:

  • run_loop serializes all work onto one thread. There is no parallelism; this is intentional β€” the loop is meant to be the dispatch spine of a single-threaded event loop, not a parallel work pool.
  • finish() is idempotent and thread-safe. It marks the loop as complete; any subsequent calls to run() return immediately without processing further items.
  • Submitted work that has not yet started when finish() is called will not execute. Drain outstanding senders before calling finish() if all work must complete.
  • The get_scheduler() handle remains valid as long as the run_loop object is alive. Schedulers outliving their run_loop are dangling handles.

Use run_loop as the integration shim when adapting a third-party event loop to std::execution. The foreign loop calls run() on each iteration tick; submitted senders execute without introducing a second thread.

Use run_loop in unit tests that need a real execution context but not a thread pool. Pair it with InlineScheduler (above) when testing algorithms that must not block, and with run_loop when testing algorithms that require a real dequeue/dispatch cycle.

Best Practices

Parameterize over schedulers, not thread handles. A function accepting std::execution::scheduler auto works with any conforming execution context. A function accepting std::thread& or a pool pointer is locked to one implementation.

Keep senders lazy. A sender must not begin work in its constructor. Code that launches a thread or enqueues a task during sender construction breaks composition: an on() adaptor can no longer redirect that work to the correct scheduler, and two senders cannot be safely restarted.

Handle all three completion channels. Every receiver implementation must supply set_value, set_error, and set_stopped. Omitting set_stopped silently converts cancellation into a hang; omitting set_error converts a propagated exception into std::terminate.

Prefer run_loop for integration and testing. std::execution::run_loop is the reference single-threaded event loop: deterministic, easy to control, zero external dependencies. It is the right starting point when adapting a third-party async framework to the std::execution model, or when writing unit tests that need a real execution context without a thread pool.

Avoid std::async as a substitute. std::async (C++11) is not an executor. Its default launch policy is implementation-defined, its std::future destructor blocks when the future is the last handle, and it provides no cancellation or composition model. Do not retrofit it into executor-parameterized code.

Use stdexec or bemanproject/execution to adopt the model before C++26 ships. Both libraries are header-only and can be pulled in via CMake FetchContent. stdexec tracks the latest P2300 revision closely and is used by major GPU frameworks; bemanproject/execution prioritizes conformance to the final standard wording.

Use InlineScheduler in test fixtures to eliminate thread non-determinism. Switching a test from a real thread pool to an inline scheduler reduces flakiness without changing the algorithm under test.

Common Pitfalls

Destroying an operation state before completion. Once start() is called on an operation state, its address must remain stable until a completion function fires. Storing the operation state in a temporary, moving it, or letting it go out of scope early is undefined behavior. This is the most common bug in hand-written sender implementations.

Confusing scheduler lifetime with execution-context lifetime. A scheduler is a lightweight handle β€” cheap to copy, trivial to pass by value. The execution context it points to (the thread pool, the run_loop) has a separate, longer lifetime. Storing a scheduler and then destroying the pool it refers to produces a dangling reference that may not crash immediately.

Blocking inside a sender on the owning scheduler's thread. Calling sync_wait from a thread that belongs to the scheduler you are waiting on is a deadlock. The worker cannot dequeue the completion because it is blocked waiting for it. The pattern is structurally identical to calling std::future::get inside a task submitted to the same thread pool.

Assuming the value channel carries exceptions. Exceptions are routed through set_error, not set_value. A receiver that ignores set_error will std::terminate when a sender propagates an uncaught exception. Always wire all three channels even if the error handler is just a rethrow.

Using type-erased std::function in hot paths. The pre-standard executor pattern typically stores work as std::function<void()>, which allocates on the heap for non-trivial callables. In tight dispatch loops this dominates. The C++26 sender model avoids this by representing the continuation type statically in the operation state, enabling allocation-free dispatch when the operation state fits on the stack.

Calling run_loop::finish() before all work is submitted. finish() does not drain the queue β€” it stops the loop from accepting further dequeue iterations. Work that was submitted but not yet started is silently dropped. Coordinate shutdown carefully: submit a terminal sentinel sender, or use a std::barrier (C++20) to confirm all producers are done before requesting stop.

Using InlineScheduler for parallelism tests. An inline scheduler serializes everything onto the calling thread. A when_all that is expected to run its branches concurrently will instead run them sequentially, producing correct values but masking data races and ordering bugs that would surface on a real thread pool.

See Also

  • std::execution β€” C++26 header containing the full sender/receiver vocabulary
  • std::jthread (C++20) β€” cooperative thread with std::stop_token integration; the natural worker thread for run_loop-based execution contexts
  • std::future / std::promise (C++11) β€” shared-state mechanism for one-shot async values; useful in isolation but does not compose into pipelines
  • reference/idioms/command β€” the command pattern is the conceptual ancestor of a submitted work unit; executors generalize command dispatch across heterogeneous execution resources
  • NVIDIA stdexec β€” reference implementation of P2300; usable today on C++20 compilers
  • Beman execution β€” standards-committee-aligned implementation tracking the final C++26 wording
  • P2300 proposal β€” the design document for std::execution; the rationale sections explain every vocabulary decision