асинхронность и конкурентностьasynchrony and concurrency
Новая модель асинхронных вычислений (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.
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.
// 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
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
До P2300 в C++ не было единого словаря для асинхронности — каждая часть экосистемы говорила на своём диалекте:Before P2300, C++ had no single vocabulary for asynchrony — every part of the ecosystem spoke its own dialect:
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").
Ручные колбэк-цепочки ("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.
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.
Базовый 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); });
Каждый этап навешан через 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();
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();
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));
Ошибка в любом звене цепочки коротко замыкает последующие 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
});
Частая ошибка — собрать красивую цепочку 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::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.
В отличие от цепочек на 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.