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
schedulegibt 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,
justist 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::threadund 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)