В двух первых частях мы разобрали, как компилятор разворачивает корутины и как написать свой task<T> и awaiter. Теперь сосредоточимся на практическом применении:
- как оборачивать асинхронный I/O;
- как использовать корутины для сценариев и «логики во времени» в игровых/симуляционных системах;
- какие паттерны работают хорошо, а какие превращаются в боль.
Асинхронный I/O: корутина поверх event loop’а
Корутины не делают ввод‑вывод «магически асинхронным». Нам всё равно нужен event loop (epoll, kqueue, IOCP, libuv, собственный reactor). Задача корутин — сделать пользовательский код линейным, убрав явные state machine.
Абстрактный awaitable для I/O
Представим, что у нас есть примитив IoContext, который умеет регистрировать интерес к событию и вызывать callback:
struct IoContext {
using Callback = std::function<void()>;
void ReadAsync(Socket s, void* Buf, size_t Len, Callback Cb);
void Run(); // крутит цикл событий
};
Сделаем awaitable, который будет ждать завершения ReadAsync:
struct ReadOperation {
IoContext& Ctx;
Socket S;
void* Buf;
size_t Len;
struct Awaiter {
ReadOperation& Op;
bool Completed = false;
bool await_ready() const noexcept { return false; }
void await_suspend(std::coroutine_handle<> H) {
Op.Ctx.ReadAsync(Op.S, Op.Buf, Op.Len, [this, H] {
Completed = true;
H.resume();
});
}
void await_resume() const noexcept {
// можно вернуть количество прочитанных байт или бросить исключение
}
};
Awaiter operator co_await() noexcept { return Awaiter{*this}; }
};
Использование в корутине:
task<void> HandleClient(IoContext& Ctx, Socket S) {
char Buf[1024];
for (;;) {
co_await ReadOperation{Ctx, S, Buf, sizeof(Buf)};
// обработать данные в Buf...
}
}
С точки зрения компилятора это обычный конечный автомат; с точки зрения программиста — линейный код, а не вложенный ад из callback’ов.
Паттерн: «корутина как долгоживущий актор»
Хорошая модель — считать каждую корутину актором, который:
- имеет свой внутренний стейт (
Stateфрейма); - принимает события (I/O, таймеры) через
co_await; - никогда не блокирует поток — только
co_awaitвнешние операции.
Так строятся, например:
- корутинные HTTP‑сервера;
- движки ботов/агентов;
- игровые loop’ы, где каждый NPC — своя корутина‑актор.
Игровые и сценарные системы
В играх и симуляциях часто нужна логика «во времени»:
- подождать 3 секунды;
- пока игрок не подошёл к триггеру — ничего не делать;
- проиграть кат‑сцену, затем дать управление.
Без корутин это превращается в лес флагов и state machine:
switch (State) {
case Idle:
if (TriggerActivated()) { State = Wait3Sec; StartTimer(3s); }
break;
case Wait3Sec:
if (TimerFired()) { State = PlayCutscene; }
break;
// ...
}
С корутинами сценарий можно записать так:
task<void> CutsceneController() {
co_await WaitTrigger("door_enter");
co_await WaitSeconds(3);
co_await PlayCutscene("intro");
co_await WaitCutsceneEnd("intro");
co_await FadeOut();
co_await FadeIn();
}
Где каждое Wait* и Play* — awaitable поверх движкового event loop’а.
Паттерны для движков
- Один планировщик на «мир» — держим очередь активных корутин и гоняем их в игровом тике.
- Явное завершение — корутина при завершении должна либо сама отписаться от всех событий, либо runtime должен это гарантировать.
- Изоляция по данным — корутина оперирует ссылками на игровые сущности; важно, чтобы при их уничтожении либо:
- корутина завершалась;
- либо имела слабые ссылки и проверяла валидность на каждом шаге.
Пайплайны и обработка данных
Коррутины удобно использовать как пользовательские генераторы:
generator<int> Produce() {
for (int i = 0; i < 100; ++i) {
co_yield i;
}
}
generator<int> FilterEven(generator<int>& Input) {
for (int v : Input) {
if (v % 2 == 0) co_yield v;
}
}
Под капотом — те же promise_type и автоматика, но для вызывающего кода это обычный range‑подобный объект.
Паттерны:
- канальный стиль:
source | filter | transform | sink; - коррутинный парсер: читает по кусочку, выдаёт токены;
- протоколы: обработка потока сообщений с естественными
co_awaitна I/O.
Антипаттерны и подводные камни
1. Корутины «везде, где можно»
Добавление co_await в каждую функцию быстро приводит к:
- сложному трассированию (стек вызовов рвётся на границах корутин);
- тяжёлым фреймам (десятки захваченных объектов);
- непредсказуемым аллокациям.
Рекомендация: делать корутинной только границу асинхронности, а не каждую вспомогательную функцию. Внутри корутины использовать обычные функции.
2. Непрозрачное владение фреймами
Если task или другой тип не очевидно владеет фреймом, легко сделать висящий handle. Нужна чёткая политика:
- кто вызывает
destroy()и когда; - что происходит, если пользователь «забывает»
co_awaittask.
Хороший стиль — сделать тип, который:
- либо обязан быть ожидаемым (
co_await task), иначе предупреждение/анализ; - либо является явно «fire‑and‑forget» и сам уничтожается после выполнения.
3. Блокирующие вызовы внутри корутины
Корутина не отменяет правила: нельзя блокировать поток внутри event loop’а. Вызовы вида:
co_await ReadAsync(...);
std::this_thread::sleep_for(1s); // блокирует поток!
убьют латентность всей системы. Всё, что потенциально долго — должно быть либо:
- вынесено в отдельный пул потоков;
- либо само представлено как awaitable.
Резюме серии
- В части 1 мы увидели, что корутина — это конечный автомат с фреймом и
State, аco_awaitразворачивается в три вызоваawait_*. - В части 2 написали свой
task<T>, разобрали контракт awaitable/awaiter и увидели, как через них управлять временем жизни и возобновлением. - В этой части посмотрели, как всё это применять в:
- асинхронном I/O;
- игровых и сценарных системах;
- пайплайнах обработки данных;
- а также поговорили про паттерны и антипаттерны.
Корутины в C++ — мощный, но низкоуровневый инструмент. Они не убирают сложность, но позволяют перенести её из ручных state machine в структурированный, проверяемый компилятором код, сохраняя при этом полный контроль над производительностью и устройством рантайма.