Сеанс разоблачения магии.

Думаю, многие согласятся, что реализация корутин в C++20 с первого взгляда выглядит страшновато, а документация скорее более запутывает, чем вносит ясность. Многие воспринимают работу приостанавливаемых функций как некую магию со своими странными co_abracadabra()'ми и прочими promise_type'ами.
В этой статье я хочу разоблачить якобы стоящую за корутинами магию, сдёрнуть покровы и показать, что спрятано под столом у фокусника.

А начну я, как ни странно, с Питона. Рассмотрим простенькую корутину-генератор на Питоне, которая генерирует последовательность спаренных чисел с заданным шагом (это чтобы в дальнейшем было более интересно, с двумя yield в ней). И к ней же пример её вызова.

def magicians_hat(start, end, step):
    rabbit = start
    while rabbit <= end:
        yield rabbit
        rabbit += 1
        yield rabbit
        rabbit += step

hat = magicians_hat(0, 10, 2)

for rabbits in hat:
    print(f"{rabbits} rabbit(s)")

Запустив программу, получим такой вывод:

0 rabbit(s)
1 rabbit(s)
3 rabbit(s)
4 rabbit(s)
6 rabbit(s)
7 rabbit(s)
9 rabbit(s)
10 rabbit(s)

Теперь возьмём в руки старый добрый C++98 и начнём готовить свой вариант этого фокуса.
Вероятно, генератор должен выглядеть примерно как-то так:

some_hat magicians_hat(int start, int end, int step) {
    for (int rabbit = start; rabbit <= end; rabbit += step) {
        yield rabbit;
        yield ++rabbit;
    }
}

Однако пока непонятно, чем будет some_hat и как реализовать yield. Очевидно, что some_hat должен быть каким-то объектом, который можно возобновлять и проверять завершённость. Поэтому для начала создадим базовый интерфейс для работы с шляпами фокусника, ничего сложного, всё по канонам.

struct magicians_hat_base {
    int state_;             // Текущее состояние шляпы
    int current_rabbit_;    // Очередной вытащенный кролик

    magicians_hat_base() : state_(1){}  // Конструктор
    virtual ~magicians_hat_base() {}    // Деструктор, виртуальный естественно
    // Функция возобновления
    virtual void pullout_rabbit() {
        state_ = 0;
    }
    // Проверка завершённости
    bool done() const {
        return state_ == 0;
    }
    // Уничтожение
    void destroy() {
        delete this;
    }
};

Теперь будем реализовывать нашу генерирующую шляпу. Для этого наследуемся от базового объекта, и переопределим pullout_rabbit.
Ключевой момент - для сохранения состояния генератора между вызовами сделаем все "локальные" переменные полями класса, а также будем сохранять позицию, в которую надо перейти при следующем вызове.

struct some_hat : magicians_hat_base {
    // Наши локальные переменные уходят в поля класса
    int start_, end_, step_;
    int rabbit_;
    // Инициализация экземпляра шляпы указанными значениями
    some_hat(int start, int end, int step) :
        start_(start), end_(end), step_(step){}

    virtual ~some_hat(){}

    virtual void pull_rabbit();
};

Сама же функция генератора перепишется так:

    virtual void pullout_rabbit() {
        switch(state_) {
        case 1:
            for (rabbit_ = start_; rabbit_ <= end_; rabbit_ += step_) {
                // Это наш такой yield rabbit вручную
                current_rabbit_ = rabbit_; state_ = 2; return;
        case 2:
                // Это снова наш такой yield rabbit вручную
                current_rabbit_ = ++rabbit_; state_ = 3; return;
        case 3:;
            }
            state_ = 0;
        }
    }

Для тех, кого такой синтаксис switch вводит в ступор, напишем в более понятном виде, результат тот же:

    virtual void pullout_rabbit() {
        switch(state_) {
        case 1: goto state1;
        case 2: goto state2;
        case 3: goto state3;
        default:
            return;
        }

      state1:
        for (rabbit_ = start_; rabbit_ <= end_; rabbit_ += step_) {
            // Это наш такой yield rabbit вручную
            current_rabbit_ = rabbit_; state_ = 2; return;
          state2:
            // Это снова наш такой yield rabbit вручную
            current_rabbit_ = ++rabbit_; state_ = 3; return;
          state3:;
        }
        state_ = 0;
    }

И теперь использование

int main() {
    some_hat* hat = new some_hat(0, 10, 2);
    for(;;) {
        hat->pullout_rabbit();
        if (hat->done()) {
            break;
        }
        std::cout << hat->current_rabbit_ << " rabbit(s)\n";
    }
    hat->destroy();
    return 0;
}

И при запуске программы получаем:

0 rabbit(s)
1 rabbit(s)
3 rabbit(s)
4 rabbit(s)
6 rabbit(s)
7 rabbit(s)
9 rabbit(s)
10 rabbit(s)

Бинго, как в Питоне! Посмотреть на godbolt

Внимательно следите за руками - только что, на глазах почтенной публики, без библиотек и обращения к ОС, без единой ассемблерной вставки, мы реализовали беcстековую асимметричную корутину, работающую от как минимум С++98 и до наших дней!
Всем спасибо за внимание, можем расходиться.

Вы ещё здесь? Ждёте продолжения? Тогда снова pullout_rabbit(), я покажу вам, насколько глубока шляпа фокусника!

Как оно на самом деле

Вы удивитесь, но на самом деле главное отличие механизма работы корутин C++20 от приведённого примера в том, что вместо pullout_rabbit пишется resume, а тело самой корутины дополнительно обёрнуто в try{}catch(...){}. Более принципиальных отличий нет, только синтаксический сахар отвод глаз.

Подытожим, чем же является корутина с точки зрения реализации: подобно лямбда-функциям с захватом, корутина является экземпляром анонимного класса, который принято называть фреймом корутины. "Локальные" переменные корутины, которые "переживают" точки остановки корутины, становятся как-бы полями этого класса (фрейма), который помимо них также содержит и некоторые служебные поля.
Сама описанная программистом логика конкретной корутины реализуется в функции resume этого фрейма, а возможность приостановки и возобновления выполнения корутины обеспечивается созданием простой стэйт-машины aka "конечный автомат", который при очередном вызове функции просто переходит к её нужной части, в зависимости от сохранённого состояния корутины.

Достоинства и недостатки таких корутин

Оценивая меч Хаттори Хандзо, сравнивай его со всеми другими мечами, сделанными не Хаттори Хандзо.

Сравнивать достоинства и недостатки таких корутин можно с другим принципиальным подходом к реализации в виде стековых корутин.

Достоинства:

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

  • Очень быстрые приостановка и возобновление корутины - приостановка это просто запомнить номер состояния и возврат, возобновление - это обычный вызов функции без параметров и один переход внутри неё. Для стековых корутин требуется сохранять и восстанавливать состояние всех регистров процессора.

Недостатки по сути продолжение достоинств

  • Невозможность приостановки во вложенных вызовах. Бесстековые корутины могут приостанавливаться только на "верхнем" уровне вложенности, в заранее известных компилятору местах. Стековые же корутины это просто функции, которые могут на любой глубине вложенных вызовов в любом произвольном месте осуществить приостановку и переключится обратно на запустившую их функцию. Компилятору не нужно делать никаких дополнительных действий или создавать "скрытые" классы.

Детали реализации

Как я уже говорил, описание реализации корутин в документации выглядит страшновато и с первого раза его мало кто может понять. Очень много различных деталей и узлов, которые сложно уложить в голове в единую систему. Но причина для этого есть. В отличии от других языков, где реализации корутин "приколочены гвоздями", у нас в C++ программисту предоставляется большая гибкость в тонкой настройке как поведения корутин, так и их семантики.
Существует множество точек для установки своих "хуков", в которых можно вписать свой код, добиваясь точных выстрелов в ногу. Я постараюсь постепенно распутать для вас этот клубок, где всё завязано друг на друга.
Начнём c

co_await и Awaitable объекты

Видишь switch и case? И я не вижу. А он есть.

Помните тот развесистый switch на всю функцию pullout_rabbit, которым мы реализовали конечный автомат для приостановки и возобновления работы функции? Давайте посмотрим, как компилятор создаёт и прячет его.
Ключом к пониманию работы является ключевое (извиняюсь за тавтологию) слово
co_await.
Встретив его как унарный оператор в функции, компилятор понимает, что она является корутиной, и в этом месте разработчик хочет иметь возможность приостановить её.
В терминах нашей доморощенной реализации, он вставляет здесь очередной case нашего внешнего switch'а и return после него. А дальше программисту предоставляется возможность задать, что конкретно сделать перед этим case'ом и сразу после него.
Задается это операндом оператора co_await. Им должен быть объект, который принято назвать "Awaitable", или как я их называю "объект, об который корутина может споткнуться".

Awaitable объект это тот, который имеет три метода:

  1. bool await_ready() - constexpr, const, noexcept по вкусу. Проверяет, надо ли приостанавливать корутину. Если await_ready вернёт true, то объект ожидания уже готов и корутину останавливать не нужно. Если же вернуть false, то корутину надо приостановить. Тогда сначала сохраняется состояние корутины, а затем у объекта ожидания вызывается метод:

  2. await_suspend(std::coroutine_handle<> suspended) - этот метод объекта вызывается при остановке корутины с двумя целями. Во-первых, здесь можно описать дополнительные действия, которые вы хотите выполнить при остановке корутины. Во-вторых, в неё передается так называемый "хендл" корутины, используя который можно возобновить её выполнение. Помните, я писал, что все детали реализации "увязаны в клубок", где всё цепляется за всё, поэтому хендл корутины подробнее рассмотрим позже. Пока достаточно знать, что с помощью handle.resume() - можно возобновить её выполнение. У функции await_suspend может быть три варианта типа возвращаемого значения:

    • void - корутина всегда точно приостанавливается, происходит return в точку вызова корутины.

    • bool - возврат true действует так же как void, корутина приостанавливается, происходит return в точку вызова корутины. Возврат false означает, что корутина передумала останавливаться и продолжает выполнение.

    • std::coroutine_handle<> - возврат хендла какой-либо корутины вызывает возобновление выполнения корутины, чей хендл был возвращён. Стоит отметить, что может быть вернут и хендл этой же корутины, которая сейчас приостанавливается, это то же самое, что возвращать false. Существует специальный хендл, std::noop_coroutine(), выдающий "пустую" корутину. После вызова другой корутины всё равно происходит возврат в точку вызова текущей корутины.

  3. auto await_resume() - метод вызывается сразу после точки возобновления корутины. Его результат становится результатом оператора co_await. Вызов происходит всегда, вне зависимости от того, приостанавливалась ли корутина или нет.

Таким образом, auto res = co_await expr(); разворачивается примерно в такой псевдо-код:

    // Где-то скрыто в начале функции, переход к точке возобновления
    switch(resume_state) {
        ....
        // auto res = co_await expr();
        {
            // Так как awaitable "переживает" точку приостановки, располагаться он
            // всегда будет во фрейме корутины.
            // Однако инициализация его происходит только сейчас
            awaitable = expr();
            if (!awaitable.await_ready()) {
                resume_state = SomeConst;
                // Вариант с void  await_suspend
                awaitable.await_suspend(my_handle);
                return; // Приостанавливаемся
                // Или вариант с bool await_suspend
                if (awaitable.await_suspend(my_handle)) {
                    return; // Приостанавливаемся
                }
                // Или вариант с std::coroutine_handle<> await_suspend
                if (auto new_coro = awaitable.await_suspend(my_handle);
                        new_coro != my_handle) {
                    new_coro.resume();  // Возобновляем другую корутину
                    return; // Приостанавливаемся
                }
            }
          case SomeConst: // Точка возобновления
            res = awaitable.await_resume();
        }
        ....

Если операндом co_await является не Awaitable-объект, компилятор пытается вызвать или obj.operator co_await() или operator co_await(static_cast<Awaitable&&>(obj)).
Таким образом, можно для обычных объектов создать к ним Awaitable-обёртку.

В стандартную библиотеку входят два готовых типа Awaitable объектов:

Оба они ничего не делают в await_suspend и ничего не возвращают в await_resume.

ВАЖНО! При реализации await_suspend в своих Awaitable-объектах особо обратите внимание, что если вы каким-либо образом в этой функции сами возобновите только что остановленную корутину, вызвав handle.resume() (прямо здесь, или передав хендл корутины в другой поток), то сам Awaitable-объект возможно будет разрушен при возобновлении корутины, и после этого обращаться к любым данным этого объекта уже нельзя.

Возвращаемый корутиной объект

В отличии от других языков программирования, в C++ решили (возможно пока) не создавать какой-либо стандартный класс для передачи результатов работы корутины вовне, а предоставить программисту возможность создавать свои варианты таких объектов, для обеспечения гибкой настройки под свои нужды. Компилятор узнаёт о типе такого объекта из возвращаемого корутиной типа. При вызове корутины создаётся экземпляр объекта
этого типа, который возвращается в точку вызова, и через него пользователь корутины может получить доступ к управлению ею, способами, которые заложил в этот объект программист.

Этот тип подобен шляпе фокусника, внешне он может предоставлять ручки, за которые можно дёргать и "доставать кролика" из корутины, внутри же он имеет двойное дно, за которым прячутся механизмы для обеспечения "магии". Компилятор выясняет что именно будет "вторым дном" с помощью std::coroutine_traits<ВозвращаемыйТип, ТипыПараметровКорутины...>::promise_type.

По умолчанию "вторым дном" является подтип promise_type, вложенный в тип, возвращаемый корутиной (может быть как типом, так и псевдонимом), и если к внешнему типу нет никаких требований, то к promise_type компилятор предъявляет жёсткие требования по составу методов.

При старте корутины компилятор создаёт во фрейме корутины скрытый объект promise этого типа, и использует его методы для обеспечения работы корутины.

Вот минимальная реализация корутины, которая скомпилируется и выполнится:

#include <coroutine>
#include <iostream>

// Тип для возврата из нашей корутины, имя может быть произвольным
struct some_hat {
    // А вот этот вложенный тип должен быть обязательно и обязательно с таким именем
    // При старте корутины компилятор создаёт во фрейме корутины скрытый объект
    // "promise" этого типа
    // При желании можно создавать в нём разные дополнительные поля и методы,
    // тогда они будут входить во фрейм любой корутины, возвращающей some_hat
    struct promise_type {
        // Здесь перечислен минимальный набор методов, которые обязательны к наличию в promise_type
        // Этот метод вызывает компилятор при старте корутины, чтобы сформировать возвращаемый ей объект
        some_hat get_return_object() { return {}; }
        // Этот метод вызывается, если при выполнении корутины произошло необработанное исключение
        void unhandled_exception() {}
        // Этот метод нужен для работы co_return;
        void return_void() {}
        // Этот метод вызывается перед выполнением основного тела корутины: co_await promise.initial_suspend()
        // Тип метода не обязательно такой, главное возвращать Awaitable объект.
        // В данном случае, так как внешний объект самый примитивный и пока не содержит логики для возобновления
        // корутины, мы возвращаем std::suspend_never, чтобы она не приостанавливалась при старте, а сразу выполнялась.
        // Обычно же возвращают std::suspend_always, для отложенного запуска.
        std::suspend_never initial_suspend() { return {}; }
        // Этот метод вызывается после выполнения основного тела корутины: co_await promise.final_suspend()
        // Тип метода не обязательно такой, главное возвращать Awaitable объект.
        // noexcept обязательно, вызов происходит вне try/catch блока
        // В данном случае мы не останавливаемся, и фрейм корутины уничтожается автоматически
        std::suspend_never final_suspend() noexcept { return {}; }
    };
};

// Наша первая корутина
some_hat test_coro() {
    std::cout << "test_coro run\n";
    // Чтобы компилятор воспринял функцию как корутину, в ней обязательно должно быть хотя бы одно из co_await, co_yield, co_return
    co_return;
}

int main() {
    // Создаём объект корутины. Сейчас мы пока нигде в ней не сделали точек приостановки, поэтому она сразу
    // выполнится полностью, то есть эффект как от вызова простой функции. Позже мы расширим функционал работы с ней.
    some_hat hat = test_coro();
    return 0;
}

Фрейм корутины и её хендл

Для хранения состояния и переменных корутины при её первом запуске создаётся так называемый "фрейм" корутины. Для себя можно рассматривать его как экземпляр скрытого анонимного класса, содержащий служебные поля и переменные корутины, которые должны "переживать" точки её приостановки. Служебные поля - обычно это указатели на функции возобновления корутины и её уничтожения, а также "состояние для возобновления", то есть номер точки перехода внутри функции возобновления, на который надо передать управление при её вызове. Также во фрейме корутины располагается ранее рассмотренный объект promise. Служебные поля являются скрытыми (хотя и могут быть видны в отладчике) и к ним невозможно достучаться по имени из самой корутины.

Для взаимодействия с самой корутиной предназначен её хендл, определяемый стандартом как класс std::coroutine_handle<Promise = void>. Для Promise=void существует специализация, которая "стирает тип" Promise и является "универсальной", хендл корутин любого типа может быть конвертирован в этот тип.

Внутри хендл хранит указатель на, как расплывчато написано в стандарте, "to the coroutine state". Хотя обычно это указатель на фрейм корутины.
Основные методы:

  • resume(), operator() - Возобновить выполнение корутины. Если корутина не приостановлена, то это UB, и обычно очень больно.

  • done() - узнать, завершена ли корутина полностью.

  • destroy() - уничтожить фрейм корутины. Если корутина не приостановлена, то это UB, и тоже очень больно.

  • address() - получить адрес, на который указывает хендл. В текущих реализациях это фрейм корутины.

  • promise() - есть у типизированного хендла, и возвращает ссылку на объект promise, лежащий во фрейме корутины.

Также хендл корутины можно получить, зная адрес её фрейма или адрес её promise, с помощью статических функций:

Аллокация фрейма корутины

При вызове корутины компилятор в первую очередь создаёт её фрейм, динамически выделяя для неё память. Допускается, что если компилятор видит, что время жизни фрейма гарантировано не превышает текущей функции (хендл корутины не уходит никуда вовне текущей функции), то память для него выделяется на стеке.

Если в promise_type переопределены operator new, operator delete, то для выделения и освобождения памяти компилятор использует их. При этом, хотя operator new переопределён для типа promise_type, размер в operator new запрашивается не sizeof(promise_type), а для всего фрейма.

Если один из переопределённых operator new имеет дополнительные параметры, и их типы совпадают с типами параметров корутины, то будет использована именно эта перегрузка, в которую передадут фактические параметры вызова корутины.

Если в promise_type есть метод get_return_object_on_allocation_failure - то компилятор будет использовать non-throwing версию operator new, и в случае неудачи будет вызывать get_return_object_on_allocation_failure вместо get_return_object.

Передача параметров в корутину

Даже если программист написал корутину без точек приостановки, они всё-равно есть в её скрытом окружении - initial_suspend и final_suspend, поэтому параметры вызова корутины всегда должны "переживать" остановки и всегда копируются во фрейм корутины. При этом нужно очень внимательно относится к параметрам, передаваемым по ссылке - в отличии от обычной функции, корутина может иметь большее время жизни, чем объект, ссылка на который в неё передана. Передавая параметры в корутину по ссылке, чётко понимайте, что вы делаете. Если корутина является методом класса, то учтите, что в неё скрытым параметром передаётся ссылка на экземпляр этого класса, и всё вышесказанное относится и к этой ссылке - она должна оставаться валидной на всё время использования корутины.

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

Конструктор promise_type

Если один из конструкторов promise_type подходит для вызова с фактическим параметрами корутины, то будет вызван он, иначе будет вызван дефолтный конструктор. Параметры корутины всегда копируются во фрейм корутины. При вызове конструктора с параметрами, для тех из них, которые принимаются по ссылке, ссылка будет на уже скопированные
во фрейм параметры, то есть валидна на время жизни корутины.

co_return

Следующим ключевым словом, вводимым с корутинами, является co_return.
Встретив его, компилятор во-первых понимает, что функция является корутиной, во-вторых, создает код выхода из корутины:

  • если используется просто co_return; или co_return expr; и expr имеет тип void, то вызывается promise.return_void().

  • если используется co_return expr; и expr не типа void, то вызывается promise.return_value(expr).

  • Далее происходит переход к конечной точке корутины, в которой вызывается co_await promise.final_suspend().

В виде псевдо-кода co_return выглядит так:

try {
    .... тело корутины
    // co_return;
    promise.return_void();
    goto final_point;
    .... или
    //co_return 1;
    promise.return_value(1);
    goto final_point;
    ....
} catch(...) {
    promise.unhandled_exception();
}
final_point:
    co_await promise.final_suspend();
....

Отметим, что в promise_type одновременно может быть только или метод return_void, или return_value, но не оба сразу.
А вот методов return_value может быть несколько, с разными типами аргументов.
Достижение конца корутины равнозначно co_return;, за исключением UB, если в promise_type не обнаружен return_void().

Получается, что то, что нужно сделать с тем, что вернул co_return, и вообще что предпринять при его вызове - возлагается на разработчика promise_type. Обычно переданное значение куда-либо сохраняют, например в promise, а во "внешнем" возвращаемом из корутины объекте делают метод типа get_result для получения сохранённого значения. Хотя вашу фантазию ничто не ограничивает, и вы можете создать какое-то свое поведение, например, отправить результат по email, или отформатировать диск.
Уже существует несколько различных библиотек со своими реализациями "обвеса" вокруг корутин, со своими взглядами на семантику возврата, возможно со временем какая-то из них станет стандартом.

co_yield

Ключевое слово co_yield expr; просто разворачивается в

co_await promise.yield_value(expr);

Разработчик promise_type должен реализовать в нём метод yield_value, в котором что-то сделать с переданным значением, и вернуть Awaitable объект, на котором корутина может остановиться. Обычно просто в promise запоминается указатель на переданное значение (этого достаточно, так как оно гарантировано переживёт точку приостановки корутины) и возвращают std::suspend_always, а во "внешнем" возвращаемом объекте делают метод типа current_value() для получения ссылки на это значение.

await_transform

В теле конкретных корутин могут встречаться остановки на самых разнообразных Awaitable-объектах. Однако в promise_type также есть способ "перехвата" их различных типов, с целью кастомизировать или изменять их поведение.

Встретив co_await expr компилятор сначала проверяет выражение co_await promise.await_transform(expr). Если такое выражение является правильным с точки зрения компилятора, то будет использован такой вариант (кроме приостановки на promise.initial_suspend и promise.final_suspend, там трансформация не вызывается).

Усложняем минимальную реализацию

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

Давайте для начала просто добавим отладочного вывода, чтобы посмотреть более детально процесс работы.
Посмотреть на godbolt

#include <coroutine>
#include <iostream>
#include <stdint.h>

struct some_hat {
    some_hat() {std::cout << this << " hat created\n";}
    ~some_hat() {std::cout << this << " hat destroyed\n";}
    struct promise_type {
        promise_type() {
            // В методах promise_type получить хендл корутины довольно просто:
            auto h = std::coroutine_handle<promise_type>::from_promise(*this);
            std::cout << this << " promise created, Handle address " << h.address() << ", offset from promise " <<
                ((uintptr_t)this - (uintptr_t)h.address()) << "\n";
        }
        ~promise_type() {std::cout << this << " promise destroyed\n";}

        some_hat get_return_object() {
            std::cout << "get_return_object called\n";
            return {};
        }

        std::suspend_never initial_suspend() {
            std::cout << this << " initial_suspend called\n";
            return {};
        }
        std::suspend_never final_suspend() noexcept {
            std::cout << this << " final_suspend called\n";
            return {};
        }

        void return_void() { std::cout << this << " return_void called\n"; }

        void unhandled_exception() {}

        static void* operator new(size_t size) {
            void* res = ::operator new(size);
            std::cout << "Call new for coro frame: " << size << " bytes, sizeof(promise_type) " << sizeof(promise_type) << ", return " << res << "\n";
            return res;
        }
        static void operator delete(void* ptr) {
            std::cout << "Destroy coro frame at " << ptr << "\n";
            ::operator delete(ptr);
        }
    };
};

some_hat test_coro() {
    uint64_t local = 1;
    std::cout << "Coroutine ran, local var at " << (void*)&local << "\n";
    //co_await std::suspend_never{};
    co_return;
}

int main() {
    char local_var = 0;
    std::cout << "Local var in stack at " << (void*)&local_var << "\n";
    some_hat hat = test_coro();
    std::cout << "after coro hat is " << (void*)&hat << "\n";
    return 0;
}

C Clang получаем примерно такое:

Local var in stack at 0x7fff8d74a60b
Call new for coro frame: 24 bytes, sizeof(promise_type) 1, return 0x5d302bbb52c0
0x5d302bbb52d0 promise created, Handle address 0x5d302bbb52c0, offset from promise 16
get_return_object called
0x7fff8d74a60a hat created
0x5d302bbb52d0 initial_suspend called
Coroutine ran, local var at 0x7fff8d74a5a8
0x5d302bbb52d0 return_void called
0x5d302bbb52d0 final_suspend called
0x5d302bbb52d0 promise destroyed
Destroy coro frame at 0x5d302bbb52c0
after coro hat is 0x7fff8d74a60a
0x7fff8d74a60a hat destroyed

Видно, что сначала выделяется память под фрейм, размером больше, чем promise_type.
Далее конструируется promise, видно, что handle.address() - как раз созданный нами фрейм, a promise размещён в нём, со смещением 16 байт. Из-за возможных различиях в реализациях и возможных разных выравниваний promise_type, не закладывайтесь на такое постоянное смещение, всегда используйте функции handle.promise() и std::coroutine_handle<promise_type>::from_promise`.

Далее вызовом get_return_object конструируется возвращаемый объект. Видно, что благодаря RVO и copy elision возвращаемый объект создаётся сразу на стеке вызывающей функции, то есть он может вообще не иметь конструкторов копирования и перемещения.
Затем вызывается co_await promise.initial_suspend(), после чего начинается выполнение тела корутины.

Обратите внимание, что при компиляции Clang'ом, так как в теле корутины нет точек остановки, локальной переменной local нет необходимости "переживать" их, и она размещается просто на стеке, а не во фрейме корутины. Если расскоментировать строку с co_await std::suspend_never{};, то это создаст в корутине точку приостановки, и мы увидим, что размер фрейма корутины увеличится, а переменная local - уйдёт во фрейм:

Local var in stack at 0x7ffd5391921b
Call new for coro frame: 32 bytes, sizeof(promise_type) 1, return 0x63d5492322c0
...
Coroutine ran, local var at 0x63d5492322d8
...

Если же напишем так:

some_hat test_coro() {
    {
        uint64_t local = 1;
        std::cout << "Coroutine ran, local var at " << (void*)&local << "\n";
    }
    co_await std::suspend_never{};
}

То переменной local опять не нужно переживать точку приостановки, и она снова уйдёт на стек:

Call new for coro frame: 24 bytes, sizeof(promise_type) 1, return 0x5e40866a72c0
...
Coroutine ran, local var at 0x7ffe76d44868
...

К сожалению, пока ни GCC (15.2), ни MSVC (19.44) не делают таких оптимизаций, и в них все локальные переменные, независимо от времени жизни,
помещаются во фрейм корутины.

Теперь, когда мы разобрались как работают друг с другом части корутины, реализуем возможность внешнего управления корутиной и получением результатов.
Этот простой пример даст понятие, как это делать.
Показательно, что в корутине для примера используется и co_yield, и co_return, и во внешнем объекте имеются отдельные методы для получения как co_yield'еного значения, так и co_return'утого, просто чтобы показать, что "одно другому не мешает" и их можно совмещать.

#include <coroutine>
#include <iostream>
#include <optional>

struct some_hat {
    // Запустить получение следующего значения
    bool next() {
        if (!coro_.done()) {
            // возобновляем выполнение корутины
            coro_.resume();
        }
        return !coro_.done();
    }
    // Получить очередное значение
    int current() const {
        return *coro_.promise().cv;
    }
    // Получить результат
    int result() const {
        return *coro_.promise().result;
    }

    struct promise_type {
        int* cv{};    // yielded value, будем сохранять указатель на него
        std::optional<int> result;   // returned value

        some_hat get_return_object() {
            // Получим хендл корутины и вернём объект с ней
            auto h = std::coroutine_handle<promise_type>::from_promise(*this);
            return {h};
        }
        // Теперь будем корутину сразу приостанавливать при создании, чтобы тело корутины запускалось только по требованию
        std::suspend_always initial_suspend() { return {}; }
        // Теперь после выполнения тела корутины внешний объект всё ещё может обращаться к promise, поэтому
        // чтобы фрейм не разрушился сразу, надо в конце приостановить корутину. А удалить её должен внешний объект.
        std::suspend_always final_suspend() noexcept { return {}; }
        // Тут запоминаем co_return'утое значение
        void return_value(int v) {
            result = v;
        }
        // А тут co_yield'енное
        std::suspend_always yield_value(int& v) {
            // Переданный нам по ссылке объект точно переживёт точку остановки, и указатель
            // будет валиден до следующего возобновления.
            cv = &v;
            return {};
        }

        void unhandled_exception() {std::terminate();}
    };
    // Теперь так как на final_suspend корутина останавливается, за уничтожение фрейма отвечаем мы
    ~some_hat() { coro_.destroy(); }
    // хендл нашей корутины
    std::coroutine_handle<promise_type> coro_;
};

// Корутина с нашей шляпой фокусника
some_hat magicians_hat(int start, int end, int step) {
    for (int rabbit = start; rabbit <= end; rabbit += step) {
        co_yield rabbit;
        co_yield ++rabbit;
    }
    co_return 42;
}

int main() {
    some_hat hat = magicians_hat(0, 10, 2);
    while(hat.next()) {
        std::cout << hat.current() << " rabbits(s)\n";
    }
    std::cout << hat.result() << "\n";

    return 0;
}

Стандартные генераторы

Начиная с C++23 есть стандартизованный класс для работы с co_yield и корутинами-генераторами: std::generator, который позволяет просто использовать корутины-генераторы в range-based for, так же, как в Python.

С ним использование нашей "шляпы фокусника" будет выглядеть очень просто:

#include <iostream>
#include <generator>

std::generator<int> magicians_hat(int start, int end, int step) {
    for (int rabbit = start; rabbit <= end; rabbit += step) {
        co_yield rabbit;
        co_yield ++rabbit;
    }
}

int main() {
    for (int rabbit : magicians_hat(0, 10, 2)) {
        std::cout << rabbit << " rabbit(s)\n";
    }
    return 0;
}

Однако совместить co_yield и co_return здесь не получится.

Многопоточность

Тут не будет много слов. Реализация корутин со стороны компилятора сделана без какой-либо оглядки на многопоточность, и всю синхронизацию по безопасному использованию корутин в нескольких потоках должен делать их пользователь. Если вы посмотрите в самом начале на нашу "доморощенную реализацию корутин", то вполне увидите, что вызов корутины из разных потоков по сути является конкурентным доступом к разделяемым данным - фрейму корутины, со всеми вытекающими последствиями и мерами предосторожности.

Также замечу, что в принципе корутину можно спокойно остановить в одном потоке, а возобновить в другом. Если вы собираетесь их использовать в таком сценарии, например, реализовывать систему задач на тредпуле и т.п., то прежде всего стоит обратить внимание на такие моменты:

  • Двойной запуск корутины - вызывать resume() когда корутина работает - UB, и почти всегда будет ломать программу. Используйте какую-либо синхронизацию или очереди, чтобы случайно не запустить корутину одновременно в нескольких потоках.

  • В реализациях await_suspend ваших Awaitable объектов, как только вы передаёте её хендл в другой поток, обращаться к любым данным объекта или корутины становится небезопасно - она уже может быть возобновлена другим потоком, что приведёт к разрушению awaitable-объекта, или даже уже полностью завершится. Если какие-то из этих данных вам будут всё-таки нужны после этого, предварительно скопируйте их в локальные переменные в await_suspend.

  • Стандартные мьютексы - не рассчитаны на захват в одном потоке, а освобождение в другом, поэтому использовать их в корутинах надо очень аккуратно - если в корутине захватили мьютекс, проследите, чтобы она до момента его освобождения или не приостанавливалась, или после приостановки обязательно возобновлялась в том же потоке, в котором захватила мьютекс. Также некоторые библиотеки предоставляют свои варианта асинхронных примитивов синхронизации, заточенных для использования с корутинами.

Симметричные корутины

Если управление после приостановки корутины всегда возвращается к вызвавшему её запуск, такая схема работы называется "асимметричные корутины". Однако Awaitable объекты поддерживают и так называемую "симметричную передачу управления". Это реализуется, когда из await_suspend возвращается хендл какой-либо корутины. Тогда сначала происходит возобновление работы корутины, чей хендл был вернут. Это соответствует примерно такому псевдо-коду:

    ....
    }
    // вариант с std::coroutine_handle<> await_suspend
    if (auto new_coro = awaitable.await_suspend(my_handle); new_coro != my_handle) {
        new_coro.resume();  // Возобновляем другую корутину
        return; // Приостанавливаемся
    }
    ....

Стоит отметить, что вызванная таким образом корутина далее сама может при своей приостановке или окончании вызвать запуск какой-либо корутины, в том числе и той, из которой она была запущена.

Обычно для этого реализуют возвращаемый корутиной объект таким образом, чтобы он сам мог быть Awaitable-объектом, сам или через operator co_await. И при вызове co_await на таком объекте - запускать управляемую им корутину. При этом в его promise_type реализуют final_suspend::await_suspend так, что он снова возобновляет запустившую его корутину. Однако, если корутина к примеру в большом цикле много раз запускает таким образом другие корутины, а они при завершении снова возобновляют вызывавшую их, то легко добиться переполнения стека, поэтому такая техника крайне нуждается, чтобы компилятор использовал оптимизацию "хвостовых вызовов", которая в этом случае заменит вызовы resume на обычные переходы. Оптимизация -O2 обычно уже включает её, а -O0 - нет.
Для GCC и Clang включить отдельно такую оптимизацию можно как -foptimize-sibling-calls.

Заключение

Тех, кому вся эта машинерия кажется сложной, хочу успокоить. Обычно она пишется один раз или берётся какая-либо готовая реализация, а дальше сами корутины пишутся легко, просто, удобно, красиво.

В статье я постарался охватить наиболее ключевые моменты для понимания работы корутин в C++, но возможно описал ещё не все тонкости. Однако надеюсь, у вас теперь появилось более полное представление о том, как оно работает, и документацию или cppcoro теперь будет легче понять.

Спасибо за внимание!

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


  1. Cheater
    12.01.2026 19:12

    Если честно объяснение непонятное с самой первой структуры magicians_hat_base.

    Int state_ - у структуры 4294967295 состояний? Или всё-таки какие-то из них недостижимые? Или структура сама не знает, какие состояния достижимые и какие нет? Подобные соглашения должны быть в описании класса.

    " // Функция возобновления virtual void pullout_rabbit() " - мне казалось что таким именем будет названа функция вытаскивания кролика, а не функция "возобновления" чего-либо? Почему "возобновления"? Если я сделаю pullout_rabbit(); pullout_rabbit(); это 2 раза возобновить что-то? Это ошибка или нет?

    Int current_rabbit_ - зачем заводить в структуре поле "вытащенный кролик" если "вытащенный кролик" это по смыслу возвращаемое значение, а не хранимое? В базовом классе нет никакой логики завязанной на это поле, просто взяли в стиле C завели поле без знания как им управлять и без гарантий что потомок это реализует.

    Что такое состояния 1, 0, как пользователь должен догадаться что из них что означает? Pullout_rabbit() можно вызывать бесконечно много раз - это баг или фича? Разве pullout_rabbit не должен вести себя по-разному в зависимости от done()?


    1. orefkov Автор
      12.01.2026 19:12

      Да, с базовым примером мною копнуто глубоко, аж до 1998 года, туда не только лишь всё могут. У базовой структуре нет состояний, есть поле, в котором наследники могут хранить любое своё состояние. Базовый класс знает только то, что 0 - завершено. И сколько после этого pullout_rabbit не вызывай , он ничего больше не сделает. Если чуть внимательнее рассмотрите код, сами поймете. Довольно странно ждать от примера для пояснения низкоуровневых механизмов высоких уровней абстракции, наоборот от них стараются избавиться, чтобы показать суть.


    1. orefkov Автор
      12.01.2026 19:12

      Добавлю к предыдущему ответу. В каждом коде есть кнопка "Объяснить код с SourceCraft". Вот что она мне например выдала:

      Представленный фрагмент кода на C++ описывает структуру magicians_hat_base, которая служит базовым интерфейсом для работы с «шляпой фокусника». Эта структура предназначена для реализации генератора, который можно возобновлять и проверять на завершённость.
      Структура содержит:
      Поле state_ для хранения текущего состояния шляпы.
      Поле current_rabbit_ для хранения очередного вытащенного «кролика».
      Конструктор, инициализирующий состояние шляпы.
      Виртуальный деструктор для корректного уничтожения объектов.
      Виртуальную функцию pullout_rabbit для возобновления работы генератора.
      Функцию done для проверки завершённости работы генератора.
      Функцию destroy для уничтожения объекта.
      Этот код демонстрирует подход к реализации корутин в C++ до появления корутин в C++20, используя техники, доступные в C++98. Он позволяет создавать генераторы, которые можно приостанавливать и возобновлять, сохраняя состояние между вызовами.

      Представленный фрагмент кода на C++ описывает структуру some_hat, которая наследуется от базового класса magicians_hat_base. Эта структура предназначена для реализации генератора, аналогичного корутине в Python, который генерирует последовательность чисел с заданным шагом.
      В some_hat определены поля класса для хранения начальных параметров генератора (start_, end_, step_) и текущего значения (rabbit_). Конструктор инициализирует эти поля переданными значениями. Виртуальная функция pull_rabbit предназначена для реализации логики генератора, которая будет переопределена в дочернем классе. Этот код демонстрирует подход к реализации корутин в C++ до появления корутин в C++20, используя техники, доступные в C++98. Он позволяет понять, как можно реализовать генератор с сохранением состояния между вызовами, используя классы и виртуальные функции.

      Данный фрагмент кода на C++ демонстрирует реализацию генератора, который имитирует поведение корутины. Метод pullout_rabbit использует состояние (state_) для управления выполнением и имитирует ключевое слово yield из Python, позволяя приостановить выполнение и возобновить его с того же места при следующем вызове. В методе используется switch для управления потоком выполнения в зависимости от текущего состояния (state_). В каждом случае (case 1, case 2) происходит установка значения current_rabbit_ и изменение состояния, что позволяет продолжить выполнение с нужного места при следующем вызове метода. Этот подход демонстрирует, как можно реализовать генератор в C++ до появления корутин в C++20, используя состояния и переменные класса для сохранения контекста выполнения между вызовами.

      Я ни на что не намекаю, но даже ИИ смог разобраться с этим примером кода.


      1. Cheater
        12.01.2026 19:12

        В этом высере ИИ, которым вы судя по всему подменяете своё мнение, не адресован ни один из моих вопросов выше, которые я бы задал, будь я незнакомым с корутинами и прочитав декларацию базового класса.


        1. orefkov Автор
          12.01.2026 19:12

          Ваши вопросы мне напоминают человека, которому для примера схематичного устройства автомобиля показывают карт, а он спрашивает - "а почему одноместный? Где прикуриватель и дифференциал? Как тут кондиционер может работать, если окон, которые надо закрыть, тут вообще нет? ".
          Возможно, вам сама концепция "генераторов" совсем не знакома.


          1. Cheater
            12.01.2026 19:12

            Да вообще ужас, безумных вещей каких-то требую, например чтобы функция "достать кролика" возвращала объект кролик. А возвращаемое генератором значение у вас является частью состояния генератора. Вы уверены, что проблемы с пониманием генераторов у меня, а не у вас?


            1. orefkov Автор
              12.01.2026 19:12

              Вы когда ручку игрового автомата дёргаете, выигрыш из ручки выпадает, или из лотка? Я бы мог конечно сделать std::optional<int> pullout_rabbit (), но это совсем не то, как работает механизм корутин в C++. У них как раз ручка resume отдельно, результат отдельно. Именно это и должен показать этот пример, не то, как бы вам хотелось, а то, как оно есть.


              1. Cheater
                12.01.2026 19:12

                Ну так и сделали бы генератор в обычном смысле (функция (void)->кролик), или систему разных генераторов, и вокруг этого генератора - класс magicians_hat имитирующий корутину и хранящий генератор + копию возвращаемого им значения + ссылки на внутренние переменные генератора если они есть. Чтобы разделить где собственно функционал генератора, и где то что хранится во фрейме корутины.

                Иначе возникает много вопросов вида в чём вообще смысл наследоваться от базового класса с парой полей и пустым функционалом, хранить возвращаемого кролика, иметь генератор типа void().

                struct Rabbit {};
                
                const int STATE_DONE = 0;
                
                
                template<class T>
                concept RabbitGenerator 
                = requires(T &t) {
                    { t.gen_rabbit() } -> std::same_as<std::optional<Rabbit>>;
                    { t.state() } -> std::same_as<int&>;
                };
                
                
                
                template<RabbitGenerator G>
                class magicians_hat {
                public:
                    magicians_hat(G&& generator):
                        generator_(generator) {}
                
                    void pullout_rabbit() {
                        if (generator_.state() == STATE_DONE) return;
                
                        if (auto yielded_rabbit = generator_.gen_rabbit()) {
                            yielded_rabbit_ = yielded_rabbit.value();
                        }
                    }
                
                    // интерфейс coroutine_handle
                    Rabbit& promise() { return yielded_rabbit_; }
                    void resume();
                    void destroy();
                    //...
                    
                private:
                    G       generator_;
                    Rabbit  yielded_rabbit_;
                };
                


                1. orefkov Автор
                  12.01.2026 19:12

                  Да, теперь точно вижу, что вы не поняли назначение как моего примера. так и вообще, для чего в туториалах даются примеры.
                  У вас, во-первых, получился не базовый класс для построения самих генераторов, а просто класс для получения значений из генераторов.
                  Во-вторых - его многословность и детали, не относящиеся к построению стейт-машин никак не ведёт к цели статьи - объяснению внутреннего механизма работы корутин.
                  В-третьих - раз уж вы решили сделать класс для запуска генераторов "по-современному", то и делайте его правильно - где begin(), end(), где iterator, *iterator, ++iterator?
                  Да уж, концепты в C++98, сильно :)
                  Для чего promise() в корутинах, тоже совершенно не так поняли.
                  Ну и state_ в примере - в стэйт-машинах это не "готов-не готов", а точка перехода внутри функции-генератора, она не может быть ограниченна двумя значениями, в наследниках может быть много точек перехода.


                1. orefkov Автор
                  12.01.2026 19:12

                  А, понял. Вы свои примером реализовали заготовку для питоновского for r in hat:.
                  А цель статьи - показать как внутри C++ реализовывается def magicians_hat(start, end, step):.


    1. pavlushk0
      12.01.2026 19:12

      Тут человек превозмог, посягнул на корутины, решил с нами поделиться а вы ему тут ревью устраиваете.