coroutines и modulescoroutines and modules
Функция, которая может приостановить своё исполнение — сохранив весь контекст — и возобновиться позже, потенциально в другом месте кода. Три новых ключевых слова: co_await, co_yield, co_return. Самая тяжёлая по концептуальному объёму фича стандарта.A function that can suspend its own execution — preserving its entire context — and resume later, potentially somewhere else in the code. Three new keywords: co_await, co_yield, co_return. The heaviest single feature in the standard by conceptual volume.
Корутина — функция, тело которой содержит хотя бы одно из трёх новых ключевых слов: co_await (приостановиться в ожидании значения), co_yield (отдать значение наружу и приостановиться) или co_return (завершить корутину, вернув значение). Наличие любого из них меняет саму природу функции: компилятор трансформирует её тело в конечный автомат (state machine), а не в обычную последовательность инструкций со стековым кадром, который исчезает при возврате.A coroutine is a function whose body contains at least one of three new keywords: co_await (suspend while waiting for a value), co_yield (hand a value out and suspend), or co_return (finish the coroutine, optionally returning a value). Having any of them changes the very nature of the function: the compiler transforms its body into a state machine, rather than an ordinary sequence of instructions with a stack frame that disappears on return.
Важное разграничение сразу: coroutines — не про многопоточность. Корутина может (и часто исполняется) полностью в одном потоке, кооперативно передавая управление в точках приостановки. Это ортогональная концепция: многопоточность — про параллельное исполнение на нескольких ядрах, coroutines — про структуру приостановки/возобновления одной логической последовательности вычислений, независимо от того, сколько потоков в этом участвует (может быть один, может быть несколько — зависит от того, что делает конкретный awaiter). Подробности про сами потоки, mutex, atomic и memory model — в cpp_concurrency.md, здесь эта тема сознательно не пересекается.An important distinction up front: coroutines are not about multithreading. A coroutine can (and often does) run entirely on one thread, cooperatively handing control back and forth at suspension points. This is an orthogonal concept: multithreading is about parallel execution across multiple cores; coroutines are about the structure of suspending/resuming a single logical sequence of computation, regardless of how many threads are involved (it could be one, it could be several — depends on what the specific awaiter does). The details of threads themselves, mutex, atomic, and the memory model live in cpp_concurrency.md — that ground is deliberately not covered again here.
read_file_async("data.txt", [](std::string data) {
parse(data, [](Parsed p) {
save_to_db(p, []() {
// "callback hell" — вложенность растёт// "callback hell" — nesting keeps growing
// с каждым асинхронным шагом,// with every async step,
// обработка ошибок расползается по коллбэкам// error handling is scattered across callbacks
});
});
});
Task<void> process() {
auto data = co_await read_file_async("data.txt");
auto parsed = co_await parse(data);
co_await save_to_db(parsed);
// читается как синхронный код,// reads like synchronous code,
// но каждый co_await — точка приостановки// but every co_await is a suspension point
} // Task<T> — НЕ из стандарта, нужна своя/сторонняя реализация// Task<T> is NOT from the standard — needs a custom/third-party implementation
Асинхронный код (сетевые запросы, файловый I/O, генераторы значений) до coroutines писался тремя способами, каждый — с собственной ценой:Asynchronous code (network requests, file I/O, value generators) before coroutines was written in three ways, each with its own cost:
Каждый следующий асинхронный шаг оборачивался в лямбду внутри предыдущей — вложенность росла линейно с числом шагов, обработка ошибок требовала протаскивать error-callback через каждый уровень, а логический порядок исполнения переставал совпадать с текстовым порядком кода.Every next async step got wrapped in a lambda inside the previous one — nesting grew linearly with the number of steps, error handling required threading an error-callback through every level, and the logical execution order stopped matching the textual order of the code.
Единственная реальная альтернатива callback-ам без нового языкового механизма — написать явный конечный автомат самостоятельно: enum состояний, switch по нему, ручное сохранение "локальных переменных" в полях объекта. Именно это компилятор теперь делает автоматически за co_await/co_yield — но вручную это многословно и легко сломать при рефакторинге.The only real alternative to callbacks without a new language mechanism was to hand-write an explicit finite state machine: a state enum, a switch over it, "local variables" manually stashed in object fields. This is exactly what the compiler now does automatically behind co_await/co_yield — but doing it by hand is verbose and easy to break during refactoring.
.then()-цепочки (см. Concurrency TS в cpp_ts_extensions.md) улучшали ситуацию по сравнению с сырыми callback-ами, но код с условным ветвлением или циклом асинхронных шагов внутри цепочки продолжений быстро становился таким же нечитаемым, как исходные callback-и — просто в другой синтаксической обёртке..then() chains (see the Concurrency TS in cpp_ts_extensions.md) improved on raw callbacks, but code with conditional branching or a loop of async steps inside a continuation chain quickly became just as unreadable as the original callbacks — just wrapped in different syntax.
Компилятор, встретив co_await/co_yield/co_return в теле функции, трансформирует её в конечный автомат. Всё, что нужно этому автомату для продолжения работы после приостановки — локальные переменные, точка возобновления, параметры функции — выносится из обычного стека в отдельно аллоцированный coroutine frame. Обычно эта аллокация происходит в куче (heap), потому что стековый кадр вызывающей функции может уже не существовать к моменту возобновления корутины.When the compiler encounters co_await/co_yield/co_return in a function body, it transforms it into a state machine. Everything that machine needs to keep going after a suspension — local variables, the resume point, function parameters — gets moved out of the ordinary stack into a separately allocated coroutine frame. That allocation is usually on the heap, because the calling function's stack frame may no longer exist by the time the coroutine resumes.
Протокол, который делает это возможным, состоит из двух частей, которые нужно реализовать самостоятельно (стандарт задаёт только интерфейс):The protocol that makes this possible has two parts you must implement yourself (the standard only defines the interface):
promise_type — объект, живущий внутри coroutine frame, управляющий тем, что происходит при старте, при co_yield/co_return, и при необработанном исключении.promise_type — an object living inside the coroutine frame that controls what happens at start-up, at co_yield/co_return, and on an unhandled exception.await_ready, await_suspend, await_resume), определяющий, что именно происходит в точке каждого co_await: нужно ли вообще приостанавливаться, что делать в момент приостановки (например, зарегистрировать callback у сетевого сокета), и что вернуть при возобновлении.An awaiter — a type with three methods (await_ready, await_suspend, await_resume) that defines exactly what happens at each co_await point: whether to suspend at all, what to do at the moment of suspension (say, register a callback with a network socket), and what to return upon resumption.generator-стиль: co_yield (примерный синтаксис, требует своего promise_type)generator-style: co_yield (illustrative syntax, requires its own promise_type)
Generator<int> range(int n) {
for (int i = 0; i < n; ++i) {
co_yield i; // отдать i наружу, приостановиться,// hand i out, suspend,
// возобновиться с этой же точки при следующем запросе// resume from this exact point on the next request
}
}
// Generator<T> здесь условен — сам стандарт C++20 такого типа не даёт,// Generator<T> here is illustrative — C++20 itself provides no such type,
// std::generator появится только в C++23// std::generator only arrives in C++23
Ключевой факт, который стоит держать в голове: C++20 стандартизует только механизм — низкоуровневый протокол приостановки/возобновления. Он сознательно НЕ включает готовых высокоуровневых типов вроде Task<T> для асинхронных цепочек или Generator<T> для генераторов значений. std::generator появится только в C++23; полноценные асинхронные Task-абстракции по большей части остаются за пределами стандартной библиотеки вовсе, и на практике для них используют сторонние библиотеки (например, cppcoro).The key fact worth holding onto: C++20 standardizes only the mechanism — the low-level suspend/resume protocol. It deliberately does NOT include ready-made high-level types like Task<T> for async chains or Generator<T> for value generators. std::generator only shows up in C++23; full-blown async Task abstractions remain outside the standard library almost entirely, and in practice people reach for third-party libraries (cppcoro, for instance).
Второй кусок протокола, который стоит увидеть отдельно от promise_type — это awaiter, определяющий поведение конкретного co_await-выражения. Простейший возможный awaiter — тот, что никогда не приостанавливается на самом деле, потому что значение уже готово (например, обёртка над уже вычисленным результатом, синхронно доступным без реального ожидания):The second piece of the protocol worth seeing on its own, separate from promise_type, is the awaiter — it defines the behavior of a specific co_await expression. The simplest possible awaiter is one that never actually suspends, because the value is already available (say, a wrapper over an already-computed result that's synchronously available with no real waiting involved):
template <class T>
struct ReadyAwaiter {
T value;
bool await_ready() const noexcept {
return true; // значение уже есть — приостанавливаться незачем// the value is already there — no need to suspend
}
void await_suspend(std::coroutine_handle<>) const noexcept {
// не вызывается вообще, раз await_ready() == true// never called at all, since await_ready() == true
}
T await_resume() noexcept {
return std::move(value); // именно это становится результатом co_await// this is exactly what co_await evaluates to
}
};
// использование внутри корутины:// usage inside a coroutine:
// int x = co_await ReadyAwaiter<int>{42}; // x == 42, без единой реальной приостановки// int x = co_await ReadyAwaiter<int>{42}; // x == 42, with zero real suspensions
Три метода покрывают весь протокол ожидания: await_ready решает, нужно ли приостанавливаться вообще (возврат true — короткий путь без приостановки, как в примере выше); если await_ready вернул false, вызывается await_suspend, получающий handle текущей корутины — именно здесь реальный awaiter регистрирует callback у таймера/сокета/другого источника событий, который позже возобновит корутину через этот handle; наконец await_resume вызывается при возобновлении и его возвращаемое значение становится значением всего выражения co_await.The three methods cover the entire awaiting protocol: await_ready decides whether to suspend at all (returning true is the short path with no suspension, as in the example above); if await_ready returns false, await_suspend is called, receiving a handle to the current coroutine — this is exactly where a real awaiter registers a callback with a timer/socket/other event source that will later resume the coroutine through that handle; finally await_resume is called upon resumption, and its return value becomes the value of the entire co_await expression.
Ленивая последовательность чисел Фибоначчи — значения вычисляются по одному, по запросу, а не материализуются целиком в векторе заранее. Concептуально показывает суть co_yield; Generator<T> здесь условен (в духе примера из "how" выше) — реальный код использует cppcoro или std::generator из C++23.A lazy Fibonacci sequence — values are computed one at a time, on demand, rather than materialized wholesale into a vector up front. This is meant to show the essence of co_yield conceptually; Generator<T> here is illustrative (in the spirit of the "how" example above) — real code would reach for cppcoro or C++23's std::generator.
Generator<std::uint64_t> fibonacci() {
std::uint64_t a = 0, b = 1;
while (true) {
co_yield a; // отдать текущее значение, приостановиться// hand out the current value, suspend
auto next = a + b;
a = b;
b = next;
}
}
// вызывающий код запрашивает ровно столько значений, сколько нужно:// calling code pulls exactly as many values as it needs:
for (auto value : fibonacci() | std::views::take(10)) {
std::cout << value << ' ';
}
Псевдокод уровня "как это выглядело бы поверх готовой Task-библиотеки" — сам код не компилируется без сторонней реализации Task/awaiter (например, cppcoro или собственного тонкого слоя поверх ASIO/io_uring), но именно так выглядит замена callback-цепочки на плоский, линейно читаемый код.Illustrative-level pseudocode for "what this would look like on top of a ready-made Task library" — the code itself doesn't compile without a third-party Task/awaiter implementation (cppcoro, say, or a thin layer of your own over ASIO/io_uring), but this is exactly what replacing a callback chain with flat, linearly readable code looks like.
// требует библиотеки, дающей Task<T> и awaiter для сетевого сокета// requires a library providing Task<T> and an awaiter for the network socket
Task<std::string> fetch_user_profile(Socket& sock, int user_id) {
co_await sock.connect("api.internal", 443); // не блокирует поток, пока идёт TCP handshake// doesn't block the thread while the TCP handshake runs
co_await sock.send(build_request(user_id));
std::string response = co_await sock.receive(); // возобновление здесь произойдёт, когда придут данные// resumption here happens once data arrives
co_return parse_profile(response);
}
Тяжёлый ресурс (соединение с БД, загруженная модель) создаётся только в момент первого реального обращения, а не заранее — корутина приостанавливается сразу после старта и возобновляется только тогда, когда потребитель действительно запрашивает значение. Концептуальный пример, LazyValue<T> — условный тип, как и Generator/Task выше.A heavy resource (a DB connection, a loaded model) gets created only at the point of first actual access, not up front — the coroutine suspends right after starting and resumes only once a consumer actually requests the value. A conceptual example; LazyValue<T> is illustrative, same as Generator/Task above.
LazyValue<Connection> lazy_connection(std::string dsn) {
std::cout << "suspended — connection not opened yet\n";
co_return open_connection(dsn); // выполнится только при первом await значения// only runs on the first await of the value
}
// на этой строке соединение ещё не открыто:// at this line the connection isn't open yet:
auto lazy = lazy_connection("host=db1");
// открытие происходит только здесь, в момент реального обращения:// the open only happens here, at the moment of real access:
Connection& conn = co_await lazy;
Одна и та же последовательность из трёх асинхронных шагов (прочитать конфиг, подключиться к серверу, отправить heartbeat) — вариант с callback-ами и вариант с co_await, побочно демонстрирующий, куда девается обработка ошибок (в callback-варианте она размазана по каждому уровню, в coroutine-варианте — обычный try/catch вокруг всей последовательности).The same sequence of three async steps (read config, connect to server, send a heartbeat) — a callback version and a co_await version, which incidentally shows what happens to error handling (smeared across every level in the callback version, an ordinary try/catch around the whole sequence in the coroutine version).
// было — callback hell + error-callback на каждом уровне// before — callback hell + an error-callback at every level
void start_session(std::function<void(std::error_code)> on_error) {
read_config_async([=](Config cfg, std::error_code ec) {
if (ec) { on_error(ec); return; }
connect_async(cfg, [=](Session s, std::error_code ec2) {
if (ec2) { on_error(ec2); return; }
send_heartbeat_async(s, [=](std::error_code ec3) {
if (ec3) on_error(ec3);
});
});
});
}
// стало — линейный код, одна точка обработки ошибок// after — linear code, a single error-handling point
Task<void> start_session() {
try {
Config cfg = co_await read_config_async();
Session s = co_await connect_async(cfg);
co_await send_heartbeat_async(s);
} catch (const std::system_error& e) {
// одно место для всех трёх шагов, а не три отдельных error-callback'а// one place for all three steps, instead of three separate error-callbacks
}
}
Без сторонней библиотеки (cppcoro) или std::generator из C++23 любое практическое использование coroutines требует реализовать промежуточный слой самостоятельно — обычно несколько десятков строк шаблонного кода на каждый тип корутины (Task, Generator и т.п.), прежде чем можно написать хотя бы одну содержательную корутину. Именно из-за этого многие команды в 2020–2023 годах откладывали принятие coroutines до появления удобных библиотек поверх голого механизма.Without a third-party library (cppcoro) or C++23's std::generator, any practical use of coroutines requires implementing that intermediate layer yourself — typically several dozen lines of boilerplate per coroutine type (Task, Generator, etc.) before you can write even one meaningful coroutine. This is exactly why many teams in 2020–2023 delayed adopting coroutines until convenient libraries appeared on top of the bare mechanism.
Компилятор может применить HALO (heap allocation elision) и разместить coroutine frame на стеке вызывающей функции, если статически докажет, что время жизни корутины не выходит за пределы вызывающей функции — но это именно оптимизация, применяемая по усмотрению компилятора, а не языковая гарантия. Прямая параллель — cpp17/guaranteed-copy-elision.html: там гарантия есть только для конкретных синтаксических форм (prvalue), а NRVO остаётся опциональной оптимизацией; здесь ситуация ровно как с NRVO — рассчитывать на heap elision в hot path без профилирования не стоит.The compiler may apply HALO (heap allocation elision) and place the coroutine frame on the calling function's stack if it can statically prove the coroutine's lifetime doesn't outlive the caller — but that's strictly an optimization applied at the compiler's discretion, not a language guarantee. The direct parallel is cpp17/guaranteed-copy-elision.html: there, the guarantee only covers specific syntactic forms (prvalues), while NRVO remains an optional optimization; here the situation is exactly like NRVO — don't count on heap elision in a hot path without profiling.
Параметры, переданные в корутину по ссылке, должны оставаться живыми, пока корутина не завершится целиком — не просто до первого co_await. Корутина может возобновиться значительно позже своего создания (после сетевого ответа, таймера и т.п.), и если в качестве аргумента была передана ссылка на временный объект, к моменту возобновления он уже разрушен — обращение к нему через сохранённую в coroutine frame ссылку есть use-after-free.Parameters passed to a coroutine by reference must stay alive until the coroutine finishes entirely — not merely until the first co_await. A coroutine can resume significantly later than it was created (after a network reply, a timer, etc.), and if a reference to a temporary object was passed as an argument, that temporary is already destroyed by the time the coroutine resumes — accessing it through the reference stashed in the coroutine frame is a use-after-free.