Перейти к содержимому

C++20 Корутины

Корутина понимается как приостанавливаемая функция. Узнайте co yield, co return, co await, шаблон генератора и практические варианты использования, такие как асинхронизация ввода-вывода и государственные машины.

Что такое коровы?

coroutine — функция, которая может приостановить его выполнение и быть возобновлена позже. В отличие от обычных функций, которые выполняются от начала до конца, корутины могут приостанавливаться в середине исполнения, давать промежуточные результаты и продолжать там, где они остановились. Это принципиально отличается от потоков: корутины являются кооперативными (они выбирают, когда приостановить), легкими (не нужны нити ОС) и детерминированными (нет гонок данных из параллельного доступа).

C++20 представляет поддержку корутина на уровне языка с тремя новыми ключевыми словами: co_yield (приостановить и произвести значение), co_return (завершить корутин с окончательным значением) и co_await (приостановить до завершения операции асинхронизации). Любая функция, содержащая одно из этих ключевых слов, автоматически является рутиной.

Модель генератора

Генератор — это корутин, который лениво производит последовательность значений. Каждый звонок в co_yield приостанавливает корутин и доставляет значение абоненту. Вот полная, компилируемая реализация генератора:

generator.cpp
#include <iostream>
#include <coroutine>
#include <optional>

// A simple Generator type
template<typename T>
struct Generator {
    struct promise_type {
        T current_value;

        Generator get_return_object() {
            return Generator{std::coroutine_handle<promise_type>::from_promise(*this)};
        }
        std::suspend_always initial_suspend() { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        std::suspend_always yield_value(T value) {
            current_value = std::move(value);
            return {};
        }
        void return_void() {}
        void unhandled_exception() { std::terminate(); }
    };

    std::coroutine_handle<promise_type> handle;

    explicit Generator(std::coroutine_handle<promise_type> h) : handle(h) {}
    ~Generator() { if (handle) handle.destroy(); }

    // Move only
    Generator(Generator&& other) noexcept : handle(other.handle) { other.handle = nullptr; }
    Generator& operator=(Generator&&) = delete;
    Generator(const Generator&) = delete;

    bool next() {
        handle.resume();
        return !handle.done();
    }

    T value() const { return handle.promise().current_value; }
};

// A coroutine that generates Fibonacci numbers
Generator<long long> fibonacci() {
    long long a = 0, b = 1;
    while (true) {
        co_yield a;
        auto next = a + b;
        a = b;
        b = next;
    }
}

// A coroutine that generates a range
Generator<int> range(int start, int end) {
    for (int i = start; i < end; ++i) {
        co_yield i;
    }
}

int main() {
    auto fib = fibonacci();
    std::cout << "First 10 Fibonacci numbers: ";
    for (int i = 0; i < 10 && fib.next(); ++i) {
        std::cout << fib.value() << " ";
    }
    std::cout << "\n";  // 0 1 1 2 3 5 8 13 21 34

    auto r = range(1, 6);
    while (r.next()) {
        std::cout << r.value() << " ";  // 1 2 3 4 5
    }
    std::cout << "\n";

    return 0;
}

Оригинальное название: Promise & Handle

Каждый корутин имеет два ключевых компонента:

promise_type — Контролирует поведение корутина. Компилятор ищет вложенный promise_type в типе возврата корутина. Ключевые методы:
- get_return_object() - создает объект, возвращенный абоненту
- initial_suspend() - возвращает suspend_always (ленивый старт) или suspend_never (жесткий старт)
final_suspend() — что делать, когда заканчивается корутин (должен быть noexcept)
— yield_value(T) — называется co_yield
- return_value(T) или return_void() - называется co_return
- unhandled_exception() - называется, если исключение ускользает

std::coroutine_handle

— стертый шрифтом указатель на корутинную рамку. Методы resume(), done() и destroy(). Корутинная рамка живет на куче и содержит все локальные переменные и точку подвески.

Поток такой: вызывающий создает корутин -> initial_suspend -> Caller calls resume() -> Coroutine работает до co_yield/co_return/co_await -> контроль возвращается абоненту -> повторение.

Co await и ожидаемые объекты

co_await приостанавливает корутин до завершения асинхронной операции. Любой тип, который реализует интерфейс Awaitable, может быть использован с co_await. Это основа для фреймворков ввода/вывода асинхронизации:

co_await_example.cpp
#include <iostream>
#include <coroutine>
#include <chrono>
#include <thread>

// A simple awaitable that simulates async work
struct AsyncTimer {
    std::chrono::milliseconds duration;

    bool await_ready() const noexcept {
        return duration.count() <= 0;  // skip suspend if no wait needed
    }

    void await_suspend(std::coroutine_handle<> handle) const {
        // In production, this would register with an event loop
        // Here we simulate with a thread
        std::thread([handle, d = duration]() {
            std::this_thread::sleep_for(d);
            handle.resume();  // resume the coroutine
        }).detach();
    }

    void await_resume() const noexcept {
        // Called when the coroutine resumes — return value becomes
        // the result of the co_await expression
    }
};

// The three Awaitable methods:
// 1. await_ready()  — can we skip suspending? (optimization)
// 2. await_suspend() — what to do when suspending
// 3. await_resume()  — what value to produce when resuming

// A Task type for async coroutines
struct Task {
    struct promise_type {
        Task get_return_object() { return {}; }
        std::suspend_never initial_suspend() { return {}; }
        std::suspend_never final_suspend() noexcept { return {}; }
        void return_void() {}
        void unhandled_exception() { std::terminate(); }
    };
};

Task async_work() {
    std::cout << "Starting async work...\n";
    co_await AsyncTimer{std::chrono::milliseconds(100)};
    std::cout << "Phase 1 complete\n";
    co_await AsyncTimer{std::chrono::milliseconds(100)};
    std::cout << "Phase 2 complete\n";
}

int main() {
    async_work();
    std::this_thread::sleep_for(std::chrono::milliseconds(500));
    return 0;
}

Случаи практического использования

Корутины превосходят в нескольких реальных сценариях:

Генераторы/ленивые последовательности — производят значения по требованию без предварительного вычисления всей последовательности. Идеально подходит для бесконечных последовательностей, анализа файлов и конвейеров данных.

Async I/O — сетевые серверы, запросы к базам данных и файловые операции co_await завершение без блокировки нитей. Библиотеки как cppcoro, libunifexИ увеличить. Asio предоставляет готовые к корутину I/O примитивы.

Государственные машины — каждый co_yield или co_await представляет собой государственный переход. Местные переменные корутина естественным образом сохраняют состояние между переходами, устраняя ручные структуры состояния.

Event-Driven Programming — фреймворки пользовательского интерфейса и игровые циклы могут использовать кодуины для выражения последовательной логики, которая охватывает несколько кадров или событий.

Парсинг — токенизаторы и парсеры могут co_yield токены или узлы AST по мере их обработки ввода, неявно сохраняя состояние парсинга.

Лучшая практика

Корутины и нити решают разные задачи:

Используйте корутин, когда : вам нужна совместная многозадачность, ленивая оценка или асинхронизация ввода/вывода без накладных расходов на потоки ОС. Корутины не имеют накладных расходов на переключение контекста, не требуются примитивы синхронизации и могут масштабироваться до миллионов одновременных задач.

Используйте потоки, когда **: вам нужен истинный параллелизм между ядрами ЦП для работы, связанной с ЦП, или когда вам нужно предварительное планирование (коротин, который никогда не приостанавливается, будет блокировать его поток).

Наилучшая практика: Используйте корутин для I/O-связанной параллели и потоков для CPU-связанного параллелизма. Многие высокопроизводительные серверы объединяют оба: пул потоков запускает корутины, где каждый корутин обрабатывает одно соединение с использованием co_await для ввода/вывода.

Подводный камень

Нечеткие ссылки: Корутинные параметры копируются в кадр, но ссылки на временные могут болтаться. Всегда берите параметры по значению или убедитесь, что объект, на который делается ссылка, пережил корутин.

Распределение расходов: Каждый корутин выделяет каркас на кучу. Для очень горячих, недолговечных корутин это может быть проблемой производительности (хотя компиляторы могут оптимизировать с помощью оптимизации распределения кучи eLision — HALO).

Сложность отладки: Корутинные стопки сложнее проверить в отладчиках. Подвесное состояние находится на куче, а не в стеке вызовов. Используйте журналирование и структурированную параллель для поддержания отладки.

Нет типов стандартных библиотек : C++20 предоставляет языковые примитивы, но нет стандартных типов Generator или Task. Вы должны написать свой собственный или использовать библиотеку. C++23 добавляет std::generator.

Ключевые выводы
  • Корутины являются приостанавливаемыми функциями — они приостанавливаются с co_yield/co_await и возобновляются позже.
  • Модель генератора производит ленивые последовательности: каждый co_yield приостанавливает и обеспечивает одно значение.
  • co_await приостанавливается до завершения операции асинхронизации — три метода ожидаемого контролируют поведение.
  • promise_type и coroutine_handle образуют механизм, который соединяет корутин с абонентом.
  • Корутины являются кооперативными (без упреждения) и легкими (без нити ОС) - идеально подходят для сопряженной с I / O параллели
  • C++20 предоставляет только примитивы; используйте std::generator (C++23) или библиотеки, такие как cppcoro для производственного кода

Тест — проверьте свои знания

(20 XP)

1. Какое ключевое слово делает функцию корутиной в C++20?

2. Что такое `promise_type::yield_value(T)`?

3. Чем корутины отличаются от нитей?