🔝 Retour au Sommaire
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.
Pour comprendre la valeur de std::execution, il faut mesurer les limitations de l'API de concurrence actuelle.
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.
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.
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 ».
std::execution repose sur trois concepts fondamentaux qui séparent la description du travail de son exécution :
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.
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.
Un scheduler est un objet qui détermine le contexte d'exécution — le où 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.
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.
Le header <execution> fournit un ensemble d'algorithmes pour construire et composer des senders. Voici les plus fondamentaux :
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// 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)); });// 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é.
// 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étionsync_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.
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.
| 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 |
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.
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.
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.
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.
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.
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::asyncetstd::future: migration et patterns équivalents. - 12.14.4.3 — Intégration avec les schedulers et thread pools : contextes d'exécution, concurrence pratique.
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.