В С++ 20 появились coroutines (далее буду называть их корутинами, по-русски). Если кратко - они позволяют писать асинхронный код также как мы пишем синхронный. При этом асинхронный код это не обязательно должен работать с несколькими потоками. Асинхронным может быть код исполняемый в одном потоке.

Под капотом компилятора корутины - это просто синтактический сахар (syntax sugar). Т.е. корутины не создадут дополнительных потоков. Компилятор заменит корутины вызовом нескольких функций и не более того. Но давайте посмотрим как корутины выглядят в коде.

В этой статье я буду делать простейший таймер на основе корутин. При этом напишу классы, для Awaitable и Promise, которые необходимы для работы корутин.

Первая корутина

Собственно программа с корутинами может выглядеть так

timerCoroutineHandler timerCoroutine() {
    co_await Timer::AwaitableTimer(3);
}

int main()
{
    timerCoroutine();
    return 0;
}

Да, все так просто :-), если классы timerCoroutineHandler и Timer::AwaitableTimer кто-то за нас уже сделал... (На практике их обычно делают разработчики асинхронной библиотеки). Но в этой статье мы это все реализуем сами.

Что такое корутины? Судя по документации, корутиной может быть любая функция, в которой есть co_await , co_yeild и/или co_return. Ничего не говориться про то, что в программе должно быть несколько потоков и т.п. Конечно корутины могут работать с несколькими потоками, но сами корутины не имеют механизмов синхронизации между потоками. В этой статье я подробно разберу только co_await. Мне кажется, что он самый инетречный с практической точки зрения.

Если попробовать скомпилировать программу выше, то компилятор скажет нам, что timerCoroutineHandler и Timer::AwaitableTimer не определны. Давайте это исправим и определим их

Awaitable

Собственно co_await это новый оператор, который принимает Avaitable значение. Что же такое Avaitable? Все просто, это обычный класс, который реализует три метода: await_ready, await_suspend и await_resume.

namespace Timer {
  class AwaitableTimer {
  public:
    AwaitableTimer (unsigned int seconds);
    bool await_ready();
    void await_suspend(std::coroutine_handle<> coroutine);
    void await_resume();
  private:
    std::chrono::time_point<std::chrono::steady_clock> _timepoint;
    std::coroutine_handle<> _coroutine;
  };
}

И компилятор будет вполне счастлив :-)

Назначения функций следующее (не стоит волноваться, если назначение функций сразу понятно не будет, далее все это закодим и запустим):

  • await_ready - вызывается в том месте, где находится co_await. Если она возвращает true, то корутина не приостанавливается, т.к. выполнение продолжается так, как будто co_await вообще не было. В случае false - корутина будет "ждать", т.е. ее выполнение будет приостановлено и управление будет передано вызывающей стороне.

  • await_suspend - эта функция будет вызвана сразу после await_ready . Эта функция должна подготовить все необходимое для ожидания. Что именно надо готовить - решает программист. Единственный аргумент этой функции - это handler корутины. Его стоит сохранить где-нибудь, чтобы потом можно было разбудить (resume) нашу корутину. Да, будится именно корутина, в котором попался co_await, т.е. корутина сначала приостановится и чтобы она продолжила работу надо будет ее разбудить (resume) с помощью std::coroutine_handle<>::resume()

  • await_resume - будет вызвана когда корутина будет разбужена (resume). Т.е. когда кто-то вызовет std::coroutine_handle<>::resume() то после этого будет вызван await_resume(). Да-да, вызывать std::coroutine_handle<>::resume() должен именно кто-то :-) Никто автоматически корутину не разбудит.

Когда я пишу "будет вызвана" это означает, что компилятор уберет co_await и расставит функции из класса Timer::AwaitableTimer в нужной последовательности, чтобы они вызывались как описано выше. Как я говорил выше - корутины это всего лишь синтаксический сахар.

Теперь несколько слов про std::coroutine_handle<> . Это класс-шаблон, который реализован в библиотеки stl в С++. У меня он записан с <> в конце. Это означает, что компилятору не надо заботиться о конкретном типе, который будет передан этому шаблону.

В Timer::AwaitableTimer я добавил 2 переменные-члена: _timepoint и _coroutine. Avaitable объекту они не нужны (т. е. Компилятор все скомпилирует и без них). Эти переменные нужны для реализации логики работы таймера

Давайте теперь напишем простенькую реализацию класса Timer::AwaitableTimer . Как понятно из названия - это будет таймер. А значит он должен принимать значение в течении которого он будет "ждать".

// awaitableTimer.cpp
#include "awaitableTimer.hpp"
#include <iostream>

namespace Timer {
  AwaitableTimer::AwaitableTimer (unsigned int seconds) {
    std::cout << "AwaitableTimer ctor" << std::endl;
    _timepoint = std::chrono::steady_clock::now() + std::chrono::seconds(seconds);
  }

  bool AwaitableTimer::await_ready() {
    std::cout << "AwaitableTimer::await_ready" << std::endl;
    return _timepoint <= std::chrono::steady_clock::now();
  }
    
  void AwaitableTimer::await_suspend(std::coroutine_handle<> coroutine) {
    std::cout << "AwaitableTimer::await_suspend" << std::endl;
    _coroutine = coroutine;
  }
  void AwaitableTimer::await_resume() {
    std::cout << "AwaitableTimer::await_resume" << std::endl;
  }
}

Думаю код понятен. Я добавил cout, чтобы можно было посмотреть что именно происходит "по капотом" когда мы используем корутины.

AwaitableTimer::await_suspend сохраняет переданный хендлер корутины (std::coroutine_handle<>). Если заглянуть в исходный код, то хендлер это класс с одним void* дата мембером и десятком функций. Выражение _coroutine = coroutine по сути сохранит указатель void* в котором хранится state корутины. Этот указатель внутренний, компилятор сам им манипулирует.

Promise

Чтобы наша корутина заработала компилятору потребуется еще один класс — Promise. Этот Promise не имеет ничего общего с std::promise, просто названия одинаковые. Promise нужен чтобы определить как будет вести себя сама корутина. В нашем примере это timerCoroutine(). Если внимательно читать документацию, то компилятор определяет какой Promise использовать для конкретной корутины при помощи typename coroutine_traits<timerCoroutineHandler>::promise_type, где timerCoroutineHandler это возвращаемый тип функции timerCoroutine. Строго говоря не обязательно возвращать Promise из корутины. Можно, например, сделать специализацию класса coroutine_traits. Но в данном примере я просто верну Promise.

// coroutinePromise.hpp
#pragma once
#include <iostream>
#include <coroutine>

struct promise;

struct timerCoroutineHandler : std::coroutine_handle<Coroutine::promise> {
  using promise_type = Coroutine::promise;
};

struct promise {
  timerCoroutineHandler get_return_object() {
    std::cout << "promise::get_return_object" << std::endl;
    return { timerCoroutineHandler::from_promise(*this) };
  }
  std::suspend_never initial_suspend() noexcept {
    std::cout << "promise::initial_suspend" << std::endl;
    return {};
  }
  std::suspend_never final_suspend() noexcept {
    std::cout << "promise::final_suspend" << std::endl;
    return {};
  }
  void return_void() {
    std::cout << "promise::return_void" << std::endl;
  }
  void unhandled_exception() {
    std::cout << "promise::unhandled_exception" << std::endl;
  }
};

По сути в это реализции нет полезного кода, одни только cout. Но давайте разберем побробнее.

В структуре timerCoroutineHandler определен только тип promise_type (using в данном случае определяет синоним для типа promise). Этот promise_type будет использован coroutine_traits для определения типа promise в корутине.

Зачем нужен этот Promise? Он управляет поведением корутины. Компилятор сам создаст инстанс promise’a, затем поставит вызовы функций из этого промайза в функцию timerCoroutine. Если очень упрощенно, то функция timerCoroutine будет переписана так (перепишет ее компилятор).

// Эту стуктуру снедерирует сам компилятор для сохранения состояния корутины и аргументов.
struct coroutine_frame { ... };

timerCoroutineHandler timerCoroutine() {
  // Создается инстанс coroutine_frame
  auto* f = new coroutine_frame();

  // Вызов функции get_return_object(), которую мы определили ранее
  auto returnObject = f→promise.get_return_object();
  
  co_await f→promise.initial_suspend();
  
  try{
    // Тело функции, которое написал пользователь. В нашем примере это
    co_await co_await Timer::AwaitableTimer(3);
    // На этом шаге присходит возврат из функции
    f->promise.return_void();
  }
  catch (...)
  {
    f->promise.unhandled_exception();
  }
  // Удаление всех переменных с auto storage durations, т. е. Тех которые обычно выделяются в стеке в этой функции.
  co_await f→promise.final_suspend();
  // Удаление coroutine_frame
  // Возврат к резьюмеру
}

Возможно последовательность вызова функций не будет сразу понятна, но думаю далее все встанет на свои места.

Сейчас давайте поговорим про функции из нашего Promise.

  • get_return_object — используется для получения значения которое будет возвращено из корутины, в нашем случае это timerCoroutineHandler. Его мы создаем с помощью функции timerCoroutineHandler::from_promise, которая есть в std::coroutine_handle. return { ... } это выполнение конструктора класса, который будет возвращен из функции.

  • initial_suspend — вызывется в начале выполнения корутины и может приостановить ее. В нашем примере приостанавливать корутину мы не будем, поэтому возвращаем suspend_never. Это значит, что корутина приостанавливаться не будет.

  • final_suspend по аналигии с initial_suspend приостановит корутину в самом конце. Но это также не нужно, поэтому возвращаем suspend_never

  • return_void — это нужно чтобы удовлетворить компилятор, который предполагает наличие co_return в самом конце корутины. Она ничего не возвращает, просто должна быть.

  • unhandled_exception — обработка исключений в теле корутины.

Ну что-ж, наша программа готова, давайте ее скомпилируем и запустим. Нам понадобится такие файлы

И вот что получаем в консоле

promise::get_return_object
promise::initial_suspend
timerCoroutine: Начало работы
AwaitableTimer ctor
AwaitableTimer::await_ready
AwaitableTimer::await_suspend
timerCoroutine завершена

И все? А почему корутина не просыпается (resume)? И почему программа выполняется мгновенно и не ждет 3 секунды? Это же таймер?

Как я уже говорил выше, корутины это всего лишь синтаксический сахар, при этом никто корутину не будет резьюмить и никто не будет запучкать наш таймер. Это все должны мы сделать сами.

Executor

Как наверное понятно из названия Executor будет что-то выполнять. Executor не имеет непосредственного отношения к корутинам. Это уже мое изобретение. В нашем примере он будет ожидать таймер и передавать управление корутине как только таймер окончится.

Но перед тем, как переходить к рассмотрению реализации Executor’a, давайте посмотрим как он будет использоваться. Для этого немного модифицируем нашу исходную программу.

// timerCoroutine.cpp
#include "timerCoroutine.hpp"
#include "awaitableTimer.hpp"
#include <iostream>
#include <memory>

timerCoroutineHandler timerCoroutine(Executor::Executor& executor) {
  std::cout << "timerCoroutine: Начало работы" << std::endl;
  co_await Timer::AwaitableTimer(3, executor);
  std::cout << "timerCoroutine: Окнчание работы" << std::endl;
}

// main.cpp
int main() {
  Executor::Executor executor{};
  timerCoroutine(executor);

  while(!executor.is_empty()) {
    executor.execute();
    std::this_thread::sleep_for(std::chrono::milliseconds(200));
  }
    
  return 0;
}

Как видно Executor это класс, который передается в корутину и далее, в AwiatableTimer. Если вы знакомы с Boost.Asio, то там используется похожий подход, только вместо executor’a используется io_context.

После выполнения корутины (timerCoroutine) мы запускаем наш executor. Он работает в цикле. Функция std::this_thread::sleep_for нужна чтобы не сильно грузить процессор итерациями цикла. Ее может и не быть, для функционирования программы она не обязательна. Что делают функции is_empty() и execute() будет понятно чуть позже, когда мы напишем класс Executor.

Но нам надо немного изменить Timer::AwaitableTimer, т. к. его конструктор сейчас не принимает executor в качестве аргумента.

// awaitableTimer.hpp
#pragma once
#include <chrono>
#include <coroutine>
#include "executor.hpp"

namespace Executor {
  class Executor;
}

namespace Timer {
  class AwaitableTimer {
  public:
    AwaitableTimer (unsigned int seconds, Executor::Executor& executor);
    bool await_ready();
    void await_suspend(std::coroutine_handle<> coroutine);
    void await_resume();
    bool is_expired();
    void resume();
    ~AwaitableTimer();
  private:
    std::chrono::time_point<std::chrono::steady_clock> _timepoint;
    std::coroutine_handle<> _coroutine;
  };
}

// awaitable.cpp
namespace Timer {
  AwaitableTimer::AwaitableTimer (unsigned int seconds, Executor::Executor& executor) {
    std::cout << "AwaitableTimer ctor" << std::endl;
    _timepoint = std::chrono::steady_clock::now() + std::chrono::seconds(seconds);
    executor.enque(this);
  }

  bool AwaitableTimer::is_expired() {
    return _timepoint <= std::chrono::steady_clock::now();
  }

  void AwaitableTimer::resume() {
    std::cout << "AwaitableTimer::resume" << std::endl;
    _coroutine.resume();
  }

  AwaitableTimer::~AwaitableTimer() {
    std::cout << "AwaitableTimer dtor" << std::endl;
  }
}

Изменений не много. Добавлен вызов executor.enque(this) из конструктора, на которую мы посмотрим чуть позже, функция AwaitableTimer::resume(), которая резьюмит корутину и функция AwaitableTimer::is_expired(), логика работы который думаю понятна.

И наконец Execuitor

// executor.hpp
#pragma once
#include <vector>
#include "awaitableTimer.hpp"

namespace Timer {
  class AwaitableTimer;
}

namespace Executor {
  class Executor {
    std::vector<Timer::AwaitableTimer*> _timers;
  public:
    void execute();
    void enque(Timer::AwaitableTimer* t);
    bool is_empty();
  };
}

// executor.cpp
#include "executor.hpp"
#include <iostream>

namespace Executor {
  void Executor::execute() {
    std::erase_if(_timers, [](Timer::AwaitableTimer* t) {
      if (t->is_expired()) {
        t->resume();
        std::cout << "coroutine resumed" << std::endl;
        return true;
      }
      return false;
    });
  };

  void Executor::enque(Timer::AwaitableTimer* t) {
    _timers.push_back(t);
  }
  bool Executor::is_empty() {
    return _timers.empty();
  }
}

Executor содержит в себе вектор указателей на AwaitableTimer*. Этот вектор как раз и будет использоваться для того, чтобы понять какой таймер окончился и какую корутину надо резьюмить.

Возможно возникает вопрос, почему raw pointer, почему не smart pointer? Это связано с тем, как мы создаем экземпляр AwaitableTimer. Он создается в корутине в этой строке co_await Timer::AwaitableTimer(3, executor).

Он создается в стэке компилятором, а значит время жизни таймера связано с самой корутиной. Такой подход может быть не самым удачным, т. к. мы сохраняем указатель на объект выделенный в стэке другой функции. Потенциально можем словить dangling pointer, если мы попробуем обратиться к указателю после завершения корутины. Под завершением корутины я имею в виду полное ее завершение, когда она дождется таймера. Более удачным может быть такая конструкция

timerCoroutineHandler timerCoroutine(Executor::Executor& executor) {
  auto timer = std::make_shared(3, executor);
  co_await timer.get_awaiter();
}

В этом случае в функции get_awaiter AwaitableTimer можно сделать shared_from_this и уже его передать в enque. Но это обсуждение выходит за рамки этой статьи, поэтому реализовывать это не будем.

Функция enque добавляет таймер в вектор. Далее будем прокручивать все таймеры в _timers и проверять какие завершились.

Функция is_empty проверяет опустела ли очередь из таймеров или нет.

Самая интересная функция execute. Она перебирает все таймеры в _timers, используя функцию erase_if. Эта функция в качестве второго аргумента принимает функцию которая будет вызвана для каждого таймера и если она вернет true, то таймер будет удален из вектора _timers. Как раз здесь и происходить resume корутины, в которой находится таймер. И после резьюма таймер сразу удаляется. Это связано с тем, что после резьюма, корутина отработает до конца и все переменные в фрайме корутины будут удаллены, в том числе и таймер, указатель на который храниться в векторе _timers. Поэтому сразу после вызова t->resume(), t становиться невалидным и его надо сразу удалить из вектора timers. Поэтому после резьюма возвращается true и erase_if удалит этот элемент из вектора.

И давайте теперь запустим программу. Вот что она выведет на экран.

promise::get_return_object
promise::initial_suspend
timerCoroutine: Начало работы
AwaitableTimer ctor
AwaitableTimer::await_ready
AwaitableTimer::await_suspend
После timerCoroutine

AwaitableTimer::resume
AwaitableTimer::await_resume
AwaitableTimer dtor
timerCoroutine: Окнчание работы
promise::return_void
promise::final_suspend
coroutine resumed
Завершение программы

Теперь больше похоже на правду. Наша программа работает около 3х секунд и мы видим, что для корутина была разбужена (resume).

Условно выполнение программы можно разделить на две части. Сначала идет подготовка, запускается timerCoroutine и создается таймер. Затем мы ждем завершения таймера и резьюмим корутину уже из цикла, в котором работает executor. По сути мы входим в функцию timerCoroutine дважды. Сначала, когда мы ее вызвали (и она работает до первого co_await) и затем когда мы ее зарезьюмили, в этом случае она отрабатыват от co_await и до конца. При этом когда она дорабатыват до конца, мы возвращаемся в цикл executor’a, а не в то место, где она была вызвана изначально. Также отмечу, что «coroutine resumed» печатается уже после того, как был вызван деструктор для AwaitableTimer (AwaitableTimer dtor). Это к тому, зачем была использована erase_if для удаления элемента из вектора _timers.

И в заключении схема работы программы с основными шагами.

Схема работы корутин
Схема работы корутин

Надеюсь мой экскурс в корутины был интересным :-)

Если кто-то хочет глубже погрузится в корутины, рекомендую Asymmetric Transfer

Комментарии (1)


  1. maisvendoo
    26.11.2025 19:49

    далее буду называть их корутинами, по‑русски

    По-русски, если я верно помню, это называется сопрограммы