Корутины в C++: часть 3 — практика и паттерны использования

В двух первых частях мы разобрали, как компилятор разворачивает корутины и как написать свой 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_await task.

Хороший стиль — сделать тип, который:

  • либо обязан быть ожидаемым (co_await task), иначе предупреждение/анализ;
  • либо является явно «fire‑and‑forget» и сам уничтожается после выполнения.

3. Блокирующие вызовы внутри корутины

Корутина не отменяет правила: нельзя блокировать поток внутри event loop’а. Вызовы вида:

co_await ReadAsync(...);
std::this_thread::sleep_for(1s); // блокирует поток!

убьют латентность всей системы. Всё, что потенциально долго — должно быть либо:

  • вынесено в отдельный пул потоков;
  • либо само представлено как awaitable.

Резюме серии

  1. В части 1 мы увидели, что корутина — это конечный автомат с фреймом и State, а co_await разворачивается в три вызова await_*.
  2. В части 2 написали свой task<T>, разобрали контракт awaitable/awaiter и увидели, как через них управлять временем жизни и возобновлением.
  3. В этой части посмотрели, как всё это применять в:
    • асинхронном I/O;
    • игровых и сценарных системах;
    • пайплайнах обработки данных;
    • а также поговорили про паттерны и антипаттерны.

Корутины в C++ — мощный, но низкоуровневый инструмент. Они не убирают сложность, но позволяют перенести её из ручных state machine в структурированный, проверяемый компилятором код, сохраняя при этом полный контроль над производительностью и устройством рантайма.