асинхронность и конкурентностьasynchrony and concurrency

std::execution

Новая модель асинхронных вычислений (P2300) на основе "отправителей" (sender) и "получателей" (receiver). Sender описывает асинхронную операцию, но не запускает её — она ленивая: ничего не происходит, пока sender не соединён с receiver и не запущен явно.A new model of asynchronous computation (P2300) built on "senders" and "receivers". A sender describes an asynchronous operation without starting it — it is lazy: nothing happens until it is connected to a receiver and started explicitly.

крупнейшее library-добавление C++26the largest library addition in C++26 частый вопрос: чем отличается от std::future/std::asynccommon question: how it differs from std::future/std::async

Что этоWhat it is

Sender — объект, описывающий асинхронную операцию (например, "прочитать файл" или "выполнить работу на планировщике"), но не выполняющий её при создании. Receiver — обработчик завершения с тремя каналами: set_value (успех), set_error (ошибка) и set_stopped (отмена). Соединение sender и receiver через connect() даёт "operation state" — объект, хранящий всё состояние операции; вызов start() на нём запускает выполнение. Библиотека предоставляет алгебру композиции senders — then, let_value, when_all и другие — так что сложные асинхронные графы строятся из простых кусочков декларативно.A sender is an object describing an asynchronous operation (e.g. "read a file" or "run work on a scheduler") without executing it at construction time. A receiver is the completion handler with three channels: set_value (success), set_error (failure), and set_stopped (cancellation). Connecting a sender and a receiver via connect() produces an "operation state" — an object holding all the operation's state; calling start() on it begins execution. The library provides an algebra for composing senders — then, let_value, when_all, and others — so complex asynchronous graphs are built declaratively out of small pieces.

до C++26 — фрагментированный ландшафтbefore C++26 — a fragmented landscape
// std::future — одноразовый, не композируется, часто блокирующий .get()// std::future — one-shot, doesn't compose, often a blocking .get()
std::future<int> f = std::async([]{ return compute(); });
int result = f.get();  // блокирует поток; нельзя навесить продолжение без ручного колбэка// blocks the thread; no way to attach a continuation without a manual callback
C++26 — одна алгебра sendersC++26 — one sender algebra
namespace ex = std::execution;

auto work = ex::just(42)
             | ex::then([](int x) { return x * 2; });

auto result = std::this_thread::sync_wait(work);  // запускает и синхронно ждёт результат// starts it and synchronously waits for the result

Зачем ввелиWhy it was introduced

До P2300 в C++ не было единого словаря для асинхронности — каждая часть экосистемы говорила на своём диалекте:Before P2300, C++ had no single vocabulary for asynchrony — every part of the ecosystem spoke its own dialect:

01

std::future не композируется и заставляет платить за аллокацииstd::future doesn't compose and forces you to pay for allocations

std::future одноразовый (нельзя повторно запустить операцию), обычно требует выделения разделяемого состояния в куче, а продолжение ("что делать после готовности результата") либо реализуется через блокирующий .get(), либо вручную колбэками — стандартной композиции ("сделай A, затем B, затем C, не блокируя поток") не было.std::future is one-shot (the operation can't be re-run), typically requires heap-allocating shared state, and a continuation ("what to do once the result is ready") is either done via a blocking .get() or wired up by hand with callbacks — there was no standard composition ("do A, then B, then C, without blocking the thread").

02

Колбэки теряют структуру и проброс ошибокCallbacks lose structure and error propagation

Ручные колбэк-цепочки ("callback hell") усложняют чтение, а обработка ошибок в каждом звене цепочки должна быть продумана отдельно и вручную — нет единого канала ошибки, который автоматически распространяется по всей цепочке, как это происходит с исключениями в синхронном коде.Manual callback chains ("callback hell") hurt readability, and error handling at every link of the chain has to be thought through and wired up separately by hand — there's no single error channel that automatically propagates through the whole chain the way exceptions do in synchronous code.

03

Не было стандартного понятия "выполни это на том планировщике"There was no standard notion of "run this on that scheduler"

Networking TS, кастомные thread-pool API каждой библиотеки, GPU-фреймворки — у каждого было своё понятие "исполнителя" (executor), несовместимое с остальными. P2300 даёт единую алгебру, работающую одинаково поверх thread pool, GPU и I/O, без навязывания аллокаций в куче или исключений для управления потоком выполнения.The Networking TS, every library's custom thread-pool API, GPU frameworks — each had its own notion of an "executor," incompatible with the rest. P2300 gives one algebra that works uniformly across a thread pool, a GPU, and I/O, without mandating heap allocation or exceptions for control flow.

Как это работает под капотомHow it works under the hood

Базовый sender ex::just(value) — это sender, который сразу и без ошибок завершается со значением value. Оператор | (аналогично ranges) навешивает на sender адаптер вроде then, порождая новый sender — построение цепочки не выполняет никакой работы, это просто описание графа вычислений. Выполнение начинается только при вызове connect() + start(), что обычно скрыто внутри вспомогательной функции вроде std::this_thread::sync_wait.The basic sender ex::just(value) is a sender that completes immediately and without error with the value value. The | operator (analogous to ranges) attaches an adapter like then to a sender, producing a new sender — building the chain performs no work at all, it is purely a description of the computation graph. Execution begins only when connect() + start() is called, which is usually hidden inside a helper such as std::this_thread::sync_wait.

namespace ex = std::execution;

// just(42) ничего не выполняет сам по себе — это лишь описание "значение 42 готово"// just(42) executes nothing on its own — it merely describes "the value 42 is ready"
auto sender = ex::just(42) | ex::then([](int x) { return x * 2; });

// ничего не произошло до этой строки: sync_wait соединяет с receiver и запускает// nothing has happened until this line: sync_wait connects a receiver and starts it
auto [result] = std::this_thread::sync_wait(sender).value();  // result == 84// result == 84

Планировщик (scheduler) — объект, у которого есть метод schedule(), возвращающий sender, завершающийся на нужном исполнителе (thread pool, GPU-очередь и т.д.). Пристыковав к нему then(...), вы описываете "выполнить эту работу именно там".A scheduler is an object with a schedule() method that returns a sender which completes on the desired execution context (a thread pool, a GPU queue, etc.). Attaching then(...) to it describes "run this work specifically there".

thread_pool pool(4);
auto sched = pool.get_scheduler();

auto work = sched.schedule()
           | ex::then([] { return heavy_cpu_task(); });  // выполнится на потоке пула// runs on a pool thread

when_all объединяет несколько senders в один, который завершается, когда завершились все входные — это структурированный параллелизм: оба под-sender стартуют независимо, но результат доступен только когда оба готовы.when_all combines several senders into one that completes once all of the inputs have completed — this is structured concurrency: both sub-senders start independently, but the result is only available once both are ready.

auto combined = ex::when_all(fetch_user_sender(), fetch_orders_sender())
                | ex::then([](User u, Orders o) { return merge(u, o); });

Примеры из реального кодаReal-world examples

типичные сценарии использованияtypical usage scenarios
01

Конвейер: чтение файла → парсинг → валидацияPipeline: read a file → parse → validate

Каждый этап навешан через then/let_value — цепочка читается сверху вниз как последовательность шагов, но ни один не выполняется, пока вся цепочка не запущена целиком.Each stage is chained via then/let_value — the chain reads top to bottom as a sequence of steps, but none of them execute until the whole chain is started.

namespace ex = std::execution;

auto pipeline = ex::just(std::string{"config.json"})
    | ex::let_value([](std::string& path) { return async_read_file(path); })
    | ex::then([](std::string content) { return parse_json(content); })
    | ex::then([](JsonValue doc) {
          validate_schema(doc);
          return doc;
      });

auto [doc] = std::this_thread::sync_wait(pipeline).value();
02

Параллельный запрос к двум независимым сервисамConcurrently querying two independent services

when_all запускает оба sender конкурентно и объединяет результаты только когда оба готовы — без ручного управления потоками или futures.when_all starts both senders concurrently and combines the results only once both are ready — no manual thread management or futures.

auto combined = ex::when_all(
                       fetch_pricing_sender("service-a"),
                       fetch_inventory_sender("service-b"))
                 | ex::then([](Pricing price, Inventory stock) {
                       return Quote{price, stock};
                   });

auto [quote] = std::this_thread::sync_wait(combined).value();
03

CPU-работа на thread pool, пока I/O идёт отдельноCPU-bound work on a thread pool while I/O runs elsewhere

schedule() планировщика thread pool переносит выполнение последующего then на поток пула — I/O-часть при этом может выполняться на отдельном I/O-контексте, каждая работа на своём подходящем исполнителе.The thread pool scheduler's schedule() moves execution of the following then onto a pool thread — the I/O part can meanwhile run on a separate I/O context, each piece of work on the executor suited to it.

thread_pool cpu_pool(8);
auto sched = cpu_pool.get_scheduler();

auto cpu_work = sched.schedule()
                | ex::then([](){ return compress_frame(); });  // на потоке пула// on a pool thread

auto io_work = async_write_to_socket_sender();       // на отдельном I/O-контексте// on a separate I/O context

auto both = ex::when_all(std::move(cpu_work), std::move(io_work));
04

Обработка ошибок через upon_error без раскрутки стекаError handling via upon_error with no stack unwinding

Ошибка в любом звене цепочки коротко замыкает последующие then-шаги (аналогично исключению), но без раскрутки стека — она просто перенаправляется по каналу set_error к ближайшему обработчику.An error at any link of the chain short-circuits the subsequent then steps (similarly to an exception), but with no stack unwinding — it is simply routed through the set_error channel to the nearest handler.

auto pipeline = async_read_file_sender("config.json")
    | ex::then([](std::string s) { return parse_json(s); })   // может "провалиться" в error-канал// may "fall through" into the error channel
    | ex::upon_error([](std::exception_ptr ep) {
          log_warning("falling back to defaults");
          return default_config();               // восстанавливает value-канал// recovers back into the value channel
      });

ГраблиGotchas

Sender ленив — построение цепочки не запускает еёSenders are lazy — building the chain doesn't start it

Частая ошибка — собрать красивую цепочку then/when_all, не вызвать sync_wait (или не соединить/запустить operation state вручную), и удивляться, что "ничего не происходит". Sender — это просто данные, описывающие работу; сама работа стартует только явным запуском.A common bug is building a nice then/when_all chain, forgetting to call sync_wait (or to connect/start the operation state manually), and being confused that "nothing happens." A sender is just data describing the work; the work itself starts only on an explicit start.

Это не std::future/std::async — нет неявного потока и неявной блокировкиThis is not std::future/std::async — no implicit thread, no implicit blocking

std::async может незаметно породить поток; sender сам по себе не порождает ничего — исполнитель определяется явно планировщиком (или его отсутствием). sync_wait действительно блокирует вызывающий поток до завершения, но это осознанный выбор конкретной точки интеграции с синхронным кодом, а не поведение по умолчанию всей модели.std::async can silently spawn a thread; a sender by itself spawns nothing — the execution context is determined explicitly by a scheduler (or its absence). sync_wait does block the calling thread until completion, but that's a deliberate choice at one specific integration point with synchronous code, not the default behavior of the whole model.

Много мелких then() — это нормально, аллокации в куче не навязаныMany small then() steps are fine — no forced heap allocation

В отличие от цепочек на std::function-колбэках, где каждое звено обычно означает отдельную аллокацию в куче (type erasure), типы sender компонуются на этапе компиляции — вся цепочка обычно один составной тип, известный статически, и не требует аллокации на каждый шаг.Unlike chains built on std::function-based callbacks, where every link typically means a separate heap allocation (type erasure), sender types are composed at compile time — the whole chain is usually one composite type known statically, requiring no allocation per step.