Skip to content

Latest commit

 

History

History
259 lines (168 loc) · 15.3 KB

File metadata and controls

259 lines (168 loc) · 15.3 KB

🔝 Retour au Sommaire

12.14.4 std::execution (Senders/Receivers) : Asynchronisme standardisé 🔥

Le cadre manquant de la programmation asynchrone en C++

Le C++ n'a jamais eu de modèle standard satisfaisant pour la programmation asynchrone. std::async et std::future (C++11) étaient reconnus comme insuffisants dès leur standardisation — API limitée, pas de composition, pas d'annulation, sémantique d'exécution imprévisible (quand et où le travail est-il exécuté ?). Les coroutines C++20 (section 12.6) ont apporté un mécanisme de suspension/reprise, mais sans cadre standard pour les orchestrer — pas de scheduler, pas de thread pool, pas de modèle de composition.

Résultat : chaque projet, chaque bibliothèque, chaque framework a inventé son propre modèle d'asynchronisme. Asio, folly::futures, libdispatch, Intel TBB, HPX — des écosystèmes isolés, incompatibles entre eux, avec des sémantiques de propriété et d'exécution différentes. L'absence de vocabulaire commun a fragmenté l'écosystème C++ de la concurrence pendant plus d'une décennie.

std::execution (P2300), formellement adopté pour C++26 lors de la plénière WG21 de St. Louis en juin 2024, résout ce problème en fournissant un cadre unificateur pour exprimer, composer et exécuter du travail asynchrone et parallèle. Le modèle Sender/Receiver qui en est le cœur est à la programmation asynchrone ce que les Ranges (section 12.5) sont aux algorithmes : une abstraction composable, paresseuse et type-safe.

C'est l'aboutissement d'un effort de plus de dix ans — qui a commencé avec les « executors » et mûri à travers des implémentations industrielles déployées en production, notamment dans le trading haute fréquence et les infrastructures de messagerie.

Pourquoi std::async et std::future ne suffisent pas

Pour comprendre la valeur de std::execution, il faut mesurer les limitations de l'API de concurrence actuelle.

Pas de composition

std::future ne permet pas de chaîner des opérations. Pour exécuter A, puis B quand A est terminé, puis C quand B est terminé, il faut bloquer entre chaque étape :

// std::async/std::future — blocage entre chaque étape
auto futA = std::async(std::launch::async, computeA);  
int resultA = futA.get();   // Bloque le thread courant  

auto futB = std::async(std::launch::async, [resultA] { return computeB(resultA); });  
int resultB = futB.get();   // Bloque encore  

auto futC = std::async(std::launch::async, [resultB] { return computeC(resultB); });  
int resultC = futC.get();   // Bloque encore  

Chaque .get() bloque un thread en attendant le résultat. Pour trois opérations, on consomme quatre threads (le thread courant + trois threads de travail), dont trois sont bloqués la plupart du temps. C'est l'antithèse de l'asynchronisme efficace.

Pas d'annulation

std::future n'a aucun mécanisme d'annulation. Une fois qu'une tâche est lancée, on ne peut pas lui demander de s'arrêter. Dans les systèmes réels — serveurs, applications interactives, pipelines de traitement — l'annulation est une nécessité, pas un luxe.

Sémantique d'exécution opaque

std::async avec std::launch::async garantit une exécution sur un autre thread, mais sans contrôle sur lequel. Avec std::launch::deferred, la tâche est exécutée paresseusement lors du .get() — sur le thread appelant. Il n'y a aucun moyen de dire « exécute ceci sur le thread pool du système » ou « exécute ceci sur un contexte d'I/O ».

Le modèle Sender/Receiver : vue d'ensemble

std::execution repose sur trois concepts fondamentaux qui séparent la description du travail de son exécution :

Sender : décrire le travail

Un sender est un objet qui décrit une unité de travail asynchrone — sans l'exécuter. C'est une description paresseuse : construire un sender ne fait rien. Le travail n'est exécuté que lorsqu'un consommateur (un receiver) est connecté au sender et que l'opération est démarrée.

Un sender peut se terminer de trois manières — les trois canaux de complétion :

  • set_value(values...) — Succès : le travail s'est terminé avec un résultat.
  • set_error(error) — Erreur : le travail a échoué avec une erreur.
  • set_stopped() — Annulation : le travail a été annulé proprement.

Ces trois canaux couvrent tous les cas de terminaison d'une opération asynchrone. Le modèle est intrinsèquement plus riche que std::future, qui ne supporte que succès et exception.

Receiver : consommer le résultat

Un receiver est un « callback multi-canal » qui consomme le résultat d'un sender. Il fournit une implémentation pour chacun des trois canaux de complétion. En pratique, l'utilisateur final manipule rarement les receivers directement — ils servent de colle interne entre les senders.

Scheduler : choisir où exécuter

Un scheduler est un objet qui détermine le contexte d'exécution — le et le quand du travail. Un scheduler peut représenter un thread pool, une event loop I/O, un GPU, un scheduler temps réel, ou tout autre contexte d'exécution :

namespace ex = std::execution;

// Obtenir un scheduler du contexte système (thread pool de la plateforme)
auto sch = ex::system_context().get_scheduler();

// schedule(sch) crée un sender qui, quand démarré,
// transfère l'exécution vers un thread du scheduler
auto start_on_pool = ex::schedule(sch);

La séparation entre le travail (sender) et le lieu d'exécution (scheduler) est une innovation clé : on peut décrire un pipeline de travail une fois, puis l'exécuter sur différents contextes simplement en changeant le scheduler.

Composer des pipelines

La force de std::execution est la composition. Les senders se combinent en pipelines via l'opérateur |, exactement comme les views Ranges :

namespace ex = std::execution;

auto sch = ex::system_context().get_scheduler();

// Pipeline : démarrer sur le pool → calculer A → calculer B → sauvegarder
auto pipeline = ex::schedule(sch)
    | ex::then([] { return fetch_data(); })          // Étape 1
    | ex::then([](Data d) { return process(d); })    // Étape 2
    | ex::then([](Result r) { save(r); });           // Étape 3

// Rien n'est exécuté jusqu'ici — le pipeline est une description

// Démarrer et attendre le résultat
ex::sync_wait(pipeline);

Ce pipeline se lit de haut en bas : démarrer sur un thread du pool, puis exécuter fetch_data, puis process, puis save. Chaque then transforme le résultat de l'étape précédente — exactement comme std::views::transform transforme les éléments d'une séquence.

La différence fondamentale avec std::async/std::future : il n'y a aucun blocage entre les étapes. Le thread qui termine l'étape 1 enchaîne directement sur l'étape 2, sans passer par une synchronisation avec le thread appelant. Le pipeline s'exécute entièrement de manière asynchrone.

Algorithmes sender fondamentaux

Le header <execution> fournit un ensemble d'algorithmes pour construire et composer des senders. Voici les plus fondamentaux :

Démarrage et transformation

namespace ex = std::execution;

// schedule(sch) — Créer un sender qui transfère l'exécution vers le scheduler
auto s1 = ex::schedule(sch);

// just(values...) — Créer un sender qui complète immédiatement avec des valeurs
auto s2 = ex::just(42);  
auto s3 = ex::just(std::string("hello"), 3.14);  

// then(f) — Transformer la valeur de complétion
auto s4 = ex::just(21) | ex::then([](int x) { return x * 2; });
// s4 complète avec 42

Gestion des erreurs et de l'annulation

// upon_error(f) — Transformer l'erreur (comme .or_else() pour expected)
auto s5 = some_sender
    | ex::upon_error([](auto err) { log(err); return default_value; });

// upon_stopped(f) — Réagir à l'annulation
auto s6 = some_sender
    | ex::upon_stopped([] { log("Annulé"); });

// let_error(f) — Remplacer une erreur par un nouveau sender
auto s7 = some_sender
    | ex::let_error([](auto err) { return ex::just(fallback(err)); });

Concurrence structurée

// when_all(s1, s2, ...) — Exécuter plusieurs senders en parallèle,
// attendre que tous terminent
auto parallel = ex::when_all(
    ex::schedule(sch) | ex::then([] { return compute_part_a(); }),
    ex::schedule(sch) | ex::then([] { return compute_part_b(); }),
    ex::schedule(sch) | ex::then([] { return compute_part_c(); })
);
// parallel complète avec un tuple des résultats quand les trois sont terminés

// Combiner avec then pour agréger les résultats
auto aggregated = parallel
    | ex::then([](auto a, auto b, auto c) { return merge(a, b, c); });

when_all est la brique de base de la concurrence structurée (structured concurrency) : les tâches concurrentes sont lancées ensemble, et le sender parent ne complète que lorsque toutes les tâches enfants ont terminé (succès, erreur ou annulation). Il n'y a pas de tâche « orpheline » qui continue à s'exécuter après que son parent a terminé.

Consommation

// sync_wait(sender) — Bloquer le thread courant jusqu'à complétion
auto result = ex::sync_wait(some_sender);
// result est un std::optional<std::tuple<...>> contenant les valeurs de complétion

sync_wait est le point d'entrée principal : il connecte un receiver au sender, démarre l'opération, et bloque jusqu'à complétion. C'est typiquement appelé une seule fois, dans main() ou au point de transition entre code synchrone et code asynchrone.

Concurrence structurée : le principe fondamental

Le modèle Sender/Receiver est conçu autour du principe de concurrence structurée — l'idée que la durée de vie des opérations concurrentes doit être liée à une portée lexicale, exactement comme la durée de vie des variables est liée à leur scope :

Concurrence non structurée (fire-and-forget) :
  main() lance tâche A
  tâche A lance tâche B
  main() termine
  tâche B continue seule → qui nettoie ? qui gère les erreurs ?

Concurrence structurée (sender/receiver) :
  main() crée un pipeline : A | when_all(B, C)
  Le pipeline complète quand A, B et C ont TOUS terminé
  main() peut ensuite nettoyer en toute sécurité
  Aucune tâche orpheline

Ce principe élimine une classe entière de bugs de concurrence : fuites de tâches, accès à des ressources déjà libérées, erreurs non gérées dans des tâches de fond. C'est l'équivalent du RAII (section 6.3) pour la concurrence — la durée de vie des opérations asynchrones est gérée automatiquement par la structure du code.

Comparaison avec les alternatives

vs std::async/std::future (C++11)

Aspect std::async/future std::execution
Composition Bloquante (.get()) Non-bloquante (| then)
Annulation Non supportée Native (set_stopped)
Contexte d'exécution Opaque Explicite (scheduler)
Gestion d'erreurs Exceptions uniquement set_error + upon_error
Concurrence structurée Non Oui (when_all)
Performance Overhead de synchronisation Zero overhead possible

vs Asio (bibliothèque tierce)

Asio (section 22.4) est le framework de networking et d'asynchronisme le plus utilisé en C++. std::execution ne remplace pas Asio — les deux sont complémentaires. std::execution fournit le vocabulaire et les concepts ; Asio fournit les primitives I/O (sockets, timers, résolution DNS). L'intégration entre les deux est un objectif explicite du comité et des auteurs d'Asio.

vs coroutines (C++20)

Les coroutines et les senders sont des abstractions complémentaires. Les coroutines fournissent un mécanisme de suspension/reprise au niveau de la fonction ; les senders fournissent un cadre de composition et d'ordonnancement au niveau du pipeline. Les deux s'intègrent : un sender peut être co_awaité dans une coroutine, et un corps de coroutine peut être encapsulé dans un sender.

L'écosystème en mars 2026

Implémentations de référence

stdexec (NVIDIA) — L'implémentation de référence la plus complète de P2300. Disponible sur GitHub, activement développée, utilisée en production chez NVIDIA pour l'exécution GPU. Inclut des extensions NVIDIA spécifiques (senders pour CUDA) en plus du standard. Disponible sur Compiler Explorer.

libunifex (Meta/Facebook) — L'implémentation qui a précédé et influencé P2300. Utilisée en production à grande échelle chez Meta. Moins alignée avec la version finale du standard mais riche en retours d'expérience.

Beman Project execution — Implémentation communautaire en cours de développement, visant une conformité stricte au standard C++26.

Intégration dans les bibliothèques standard

En mars 2026, aucune bibliothèque standard de production (libstdc++, libc++, MSVC STL) ne fournit encore std::execution. L'intégration est en cours dans les trois implémentations. Les développeurs qui souhaitent utiliser le modèle Sender/Receiver dès maintenant doivent passer par stdexec ou libunifex.

Le plan pour C++26 et au-delà

La proposition P2300 pose les fondations, mais le comité a identifié (via P3109) des compléments prioritaires :

  • Contexte d'exécution système (P2079) — Un scheduler standard qui utilise le thread pool de la plateforme. Indispensable pour que les utilisateurs puissent écrire du code concurrent sans implémenter leur propre thread pool.
  • Async scope — Un mécanisme pour lancer des tâches concurrentes et les rejoindre de manière structurée, remplaçant le problématique ensure_started.
  • Intégration I/O — L'intégration avec les primitives I/O asynchrones (io_uring sur Linux, IOCP sur Windows) est un objectif à plus long terme, potentiellement pour C++29.

Organisation des sous-sections

Les trois sous-sections suivantes approfondissent chaque aspect :

  • 12.14.4.1 — Le modèle Sender/Receiver en détail : concepts fondamentaux, operation state, protocole de connexion.
  • 12.14.4.2 — Remplacement de std::async et std::future : migration et patterns équivalents.
  • 12.14.4.3 — Intégration avec les schedulers et thread pools : contextes d'exécution, concurrence pratique.

Pourquoi std::execution est un changement fondamental

std::execution n'est pas simplement une bibliothèque de plus pour la concurrence. C'est un vocabulaire commun — un ensemble de concepts (au sens C++20 du terme) que toutes les bibliothèques de concurrence pourront partager. Un sender produit par Asio pourra être composé avec un sender produit par un framework GPU. Un scheduler de thread pool pourra être échangé avec un scheduler temps réel sans modifier le pipeline de travail.

Cette interopérabilité est ce qui manquait à l'écosystème C++. Les Ranges ont unifié le traitement de données séquentielles ; std::execution unifie le traitement de données concurrentes et asynchrones. C'est la pièce maîtresse de la programmation concurrente en C++ pour la décennie à venir.


📎 12.6 Coroutines (C++20)

📎 21 Threads et Programmation Concurrente

📎 22.4 Librairies réseau modernes (Asio)

⏭️ Modèle Sender/Receiver : Concepts fondamentaux