Aller au contenu

Les coroutines de C++20

Comprenez les coroutines comme des fonctions suspendables. Découvrez co_yield, co_return, co_await, le patron générateur et des cas d'usage concrets comme les E/S asynchrones et les machines à états.

Qu'est-ce qu'une coroutine ?

Une coroutine est une fonction capable de suspendre son exécution pour être reprise plus tard. Contrairement aux fonctions ordinaires qui s'exécutent du début à la fin, les coroutines peuvent faire une pause en cours d'exécution, produire des résultats intermédiaires et reprendre là où elles s'étaient arrêtées. C'est fondamentalement différent des threads : les coroutines sont coopératives (elles choisissent quand se suspendre), légères (aucun thread du système d'exploitation n'est nécessaire) et déterministes (pas de data races dues à des accès concurrents).

C++20 introduit la prise en charge des coroutines au niveau du langage avec trois nouveaux mots-clés : co_yield (se suspendre et produire une valeur), co_return (terminer la coroutine avec une valeur finale) et co_await (se suspendre jusqu'à la fin d'une opération asynchrone). Toute fonction contenant l'un de ces mots-clés est automatiquement une coroutine.

Le patron générateur

Un générateur est une coroutine qui produit une séquence de valeurs de manière paresseuse. Chaque appel à co_yield suspend la coroutine et transmet une valeur à l'appelant. Voici une implémentation complète et compilable d'un générateur :

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;
}

La mécanique des coroutines : promise et handle

Chaque coroutine comporte deux composants essentiels :

promise_type — Contrôle le comportement de la coroutine. Le compilateur recherche un promise_type imbriqué dans le type de retour de la coroutine. Méthodes clés :
- get_return_object() — crée l'objet retourné à l'appelant
- initial_suspend() — retourne suspend_always (démarrage paresseux) ou suspend_never (démarrage immédiat)
- final_suspend() — ce qu'il faut faire à la fin de la coroutine (doit être noexcept)
- yield_value(T) — appelée lors d'un co_yield
- return_value(T) ou return_void() — appelée lors d'un co_return
- unhandled_exception() — appelée si une exception s'échappe

std::coroutine_handle

— Un pointeur à type effacé vers le frame de la coroutine. Il fournit les méthodes resume(), done() et destroy(). Le frame de la coroutine vit sur le tas et contient toutes les variables locales ainsi que le point de suspension.

Le déroulement est le suivant : l'appelant crée la coroutine -> initial_suspend -> l'appelant appelle resume() -> la coroutine s'exécute jusqu'à co_yield/co_return/co_await -> le contrôle revient à l'appelant -> et ainsi de suite.

co_await et objets awaitables

co_await suspend une coroutine jusqu'à la fin d'une opération asynchrone. Tout type qui implémente l'interface Awaitable peut être utilisé avec co_await. C'est la base des frameworks d'E/S asynchrones :

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;
}

Cas d'usage concrets

Les coroutines excellent dans plusieurs scénarios réels :

Générateurs / séquences paresseuses — Produire des valeurs à la demande sans calculer toute la séquence à l'avance. Idéal pour les séquences infinies, l'analyse de fichiers et les pipelines de données.

E/S asynchrones — Les serveurs réseau, les requêtes en base de données et les opérations sur fichiers peuvent attendre leur achèvement via co_await sans bloquer de threads. Des bibliothèques comme cppcoro, libunifex et Boost.Asio fournissent des primitives d'E/S compatibles avec les coroutines.

Machines à états — Chaque co_yield ou co_await représente une transition d'état. Les variables locales de la coroutine conservent naturellement l'état entre les transitions, ce qui évite de gérer manuellement des structs d'état.

Programmation événementielle — Les frameworks d'interface graphique et les boucles de jeu peuvent utiliser des coroutines pour exprimer une logique séquentielle qui s'étale sur plusieurs frames ou événements.

Analyse syntaxique — Les tokenizers et les parseurs peuvent produire via co_yield des tokens ou des nœuds d'AST au fil du traitement de l'entrée, en conservant implicitement l'état de l'analyse.

Bonne pratique

Les coroutines et les threads résolvent des problèmes différents :

Utilisez les coroutines quand : vous avez besoin de multitâche coopératif, d'évaluation paresseuse ou d'E/S asynchrones sans le surcoût des threads du système d'exploitation. Les coroutines n'ont pas de surcoût de changement de contexte, ne nécessitent aucune primitive de synchronisation et peuvent monter jusqu'à des millions de tâches concurrentes.

Utilisez les threads quand : vous avez besoin d'un vrai parallélisme sur plusieurs cœurs pour du travail limité par le CPU, ou d'un ordonnancement préemptif (une coroutine qui ne se suspend jamais bloque son thread).

Bonne pratique : utilisez les coroutines pour la concurrence limitée par les E/S et les threads pour le parallélisme limité par le CPU. De nombreux serveurs haute performance combinent les deux : un pool de threads exécute des coroutines, chacune gérant une connexion en utilisant co_await pour les E/S.

Piège

Références pendantes : les paramètres d'une coroutine sont copiés dans son frame, mais les références vers des temporaires peuvent devenir pendantes. Prenez toujours les paramètres par valeur, ou assurez-vous que l'objet référencé survit à la coroutine.

Allocation sur le tas : chaque coroutine alloue un frame sur le tas. Pour des coroutines très sollicitées et de courte durée, cela peut poser un problème de performance (même si les compilateurs peuvent l'optimiser grâce à la Heap Allocation eLision Optimization — HALO).

Débogage difficile : les piles de coroutines sont plus difficiles à inspecter dans un débogueur. L'état suspendu se trouve sur le tas, pas sur la pile d'appels. Utilisez la journalisation et la concurrence structurée pour préserver la débogabilité.

Pas de types dans la bibliothèque standard : C++20 fournit les primitives du langage, mais aucun type Generator ou Task standard. Vous devez écrire le vôtre ou utiliser une bibliothèque. C++23 ajoute std::generator.

Points clés
  • Les coroutines sont des fonctions suspendables — elles font une pause avec co_yield/co_await et reprennent plus tard
  • Le patron générateur produit des séquences paresseuses : chaque co_yield suspend la coroutine et transmet une valeur
  • co_await suspend jusqu'à la fin d'une opération asynchrone — les trois méthodes de l'awaitable contrôlent le comportement
  • Le promise_type et le coroutine_handle forment la mécanique qui relie la coroutine à son appelant
  • Les coroutines sont coopératives (pas de préemption) et légères (pas de thread système) — idéales pour la concurrence limitée par les E/S
  • C++20 ne fournit que des primitives ; utilisez std::generator (C++23) ou des bibliothèques comme cppcoro pour le code de production

Quiz — Testez vos connaissances

(20 XP)

1. Quel mot-clé fait d'une fonction une coroutine en C++20 ?

2. À quoi sert `promise_type::yield_value(T)` ?

3. En quoi les coroutines diffèrent-elles des threads ?