Programmiersprache C++: Concurrency mit std::execution
std::execution bietet ein Standard-C++-Framework fĂĽr die Verwaltung der asynchronen AusfĂĽhrung auf generischen AusfĂĽhrungsressourcen.

(Bild: heise online / anw)
- Rainer Grimm
Für diesen Beitrag gibt es eine Planänderung. Mein ursprünglicher Plan war es, die C++26-Bibliothek nach der Kernsprache vorzustellen. Der Implementierungsstatus der Bibliothek ist jedoch nicht vollständig genug.
Daher habe ich mich entschieden, mit Concurrency und std::execution
fortzufahren. Ich werde die verbleibenden Features von C++26 vorstellen, sobald ein Compiler sie implementiert.
Aufbau von std::execution
std::execution
hat drei Schlüsselabstraktionen: Scheduler, Sender und Empfänger sowie eine Reihe anpassbarer asynchroner Algorithmen. Meine Präsentation von std::execution
basiert auf dem Proposal P2300R10.
Erste Experimente
FĂĽr meine ersten Experimente habe ich stdexec verwendet. Diese Referenzimplementierung von Nvidia basiert auf der achten Revision des Proposals. Der Zweck dieses Experiments findet sich auf GitHub:
- Provide a proof-of-concept implementation of the design proposed in P2300.
- Provide early access to developers looking to experiment with the Sender model.
- Collaborate with those interested in participating or contributing to the design of P2300 (contributions welcome!).
Man kann stdexec auf godbolt mit dem folgenden Programm ausprobieren:
#include <stdexec/execution.hpp>
#include <exec/static_thread_pool.hpp>
int main()
{
// Declare a pool of 3 worker threads:
exec::static_thread_pool pool(3);
// Get a handle to the thread pool:
auto sched = pool.get_scheduler();
// Describe some work:
// Creates 3 sender pipelines that are executed concurrently by passing to `when_all`
// Each sender is scheduled on `sched` using `on` and starts with `just(n)` that creates a
// Sender that just forwards `n` to the next sender.
// After `just(n)`, we chain `then(fun)` which invokes `fun` using the value provided from `just()`
// Note: No work actually happens here. Everything is lazy and `work` is just an object that statically
// represents the work to later be executed
auto fun = [](int i) { return i*i; };
auto work = stdexec::when_all(
stdexec::starts_on(sched, stdexec::just(0) | stdexec::then(fun)),
stdexec::starts_on(sched, stdexec::just(1) | stdexec::then(fun)),
stdexec::starts_on(sched, stdexec::just(2) | stdexec::then(fun))
);
// Launch the work and wait for the result
auto [i, j, k] = stdexec::sync_wait(std::move(work)).value();
// Print the results:
std::printf("%d %d %d\n", i, j, k);
}
Ich werde dieses Programm in die Syntax der Revision 10 konvertieren. Das Programm beginnt mit der Einbindung der erforderlichen Header: <exec/static_thread_pool.hpp
> fĂĽr die Erstellung eines Thread-Pools, <stdexec/execution.hpp
> fĂĽr ausfĂĽhrungsbezogene Dienste. In der main
-Funktion wird ein static_thread_pool pool
mit acht Threads erstellt. Der Thread-Pool fĂĽhrt Tasks gleichzeitig aus. Die get_scheduler-Memberfunktion des Thread-Pools wird aufgerufen, um ein Scheduler-Objekt sched
zu erhalten. Der Scheduler plant die Tasks im Thread-Pool.
Die Lambda-Funktion fun
nimmt eine Ganzzahl i
als Eingabe und gibt ihr Quadrat (i * i
) zurĂĽck. Dieses Lambda wird auf die Eingabewerte in den nachfolgenden Tasks angewendet. Die Funktion stdexec::when_all
erstellt einen Task, der auf den Abschluss mehrerer Unter-Tasks wartet. Jeder Unter-Task wird mit der Funktion stdexec::starts_on
erstellt, die den Task auf dem angegebenen Scheduler sched
plant. Die Funktion stdexec::just
erstellt einen Task, der einen einzelnen Wert (0, 1 oder 2) erzeugt, und die Funktion stdexec::then
wird verwendet, um das fun
-Lambda auf diesen Wert anzuwenden. Das resultierende Task-Objekt wird work
genannt.
Die Funktion stdexec::sync_wait
wird dann aufgerufen, um synchron auf die Fertigstellung der Task zu warten. Die Funktion std::move
überträgt das Eigentum an des Tasks work
an sync_wait
. Die value
-Memberfunktion wird fĂĽr das Ergebnis von sync_wait
aufgerufen, um die von den Sub-Tasks erzeugten Werte zu erhalten. Diese Werte werden in die Variablen i,
jund k
entpackt.
SchlieĂźlich gibt das Programm die Werte von i, j
und k
mit std::printf
auf der Konsole aus. Diese Werte stellen die Quadrate von 0, 1 bzw. 2 dar.
Der folgende Screenshot zeigt die AusfĂĽhrung des Programms im Compiler-Explorer:
Ich habe zu Beginn dieses Artikels geschrieben, dass std::execution drei Schlüsselabstraktionen hat: Scheduler, Sender und Empfänger sowie eine Reihe anpassbarer asynchroner Algorithmen.
AusfĂĽhrungsressourcen
- stellen den Ort der AusfĂĽhrung dar und
- benötigen keine Darstellung im Code.
Scheduler: sched
- stellt die AusfĂĽhrungsressource dar.
- Das Scheduler-Konzept wird durch einen einzigen Senderalgorithmus definiert:
schedule
. - Der Algorithmus
schedule
gibt einen Sender zurĂĽck, der auf einer vom Scheduler bestimmten AusfĂĽhrungsressource ausgefĂĽhrt wird.
Sender beschreibt Arbeit: when_all, starts_on, just, then
- sendet einige Werte, wenn ein mit diesem Sender verbundener Empfänger diese Werte schließlich empfängt,
just
ist eine sogenannte Senderfabrik.
Empfänger stoppt den Workflow: sync_wait
- Es unterstützt drei Kanäle: Wert, Fehler, gestoppt.
- Es handelt sich um einen sogenannten Sender-Consumer.
- Er ĂĽbermittelt die Arbeit und blockiert den aktuellen
std::thread
und gibt ein optionales Tupel von Werten zurĂĽck, die vom bereitgestellten Sender nach Abschluss der Arbeit gesendet wurden.
Wie geht es weiter?
Nach dieser Einführung werde ich näher auf die Reihe anpassbarer asynchroner Algorithmen eingehen und weitere Beispiele vorstellen. (rme)