Programmiersprache C++: Komposition von Sendern mit std::execution
Die meisten Sender-Adapter können mithilfe des Pipe-Operators zusammengesetzt werden.
(Bild: SerbioVas/Shutterstock)
- Rainer Grimm
Nach meinen Erläuterungen zum asynchronen Ausführen von Inclusive Scan mit std::execution widme ich mich nun der Komposition von Sendern.
Lassen Sie mich mit einem einfachen Beispiel fĂĽr die Komposition mit dem Pipe-Operator beginnen. Anstelle der verschachtelten Funktionsaufrufe
call1(call2(input))
lässt sich alternativ schreiben
call1 | call2(input)
oder sogar:
input | call1 | call2
Funktionskomposition
Dieses Beispiel ist zugegebenermaßen sehr einfach. Machen wir es etwas komplizierter. Der Proposal P2300R10 vergleicht den Aufruf einer verschachtelten Funktion mit dem Aufruf einer Funktion unter Verwendung eines temporären Objekts und einer Komposition unter Verwendung des Pipe-Operators.
Alle drei Funktionskompositionen berechnen 610 = (123*5)-5 unter Verwendung eines Thread-Pools und CUDA. Man beachte in den folgenden Codebeispielen insbesondere das Lambda []{ return 123; }). Dieses Lambda wird nicht ausgewertet.
Aufruf verschachtelter Funktionen
auto snd = execution::then(
execution::continues_on(
execution::then(
execution::continues_on(
execution::then(
execution::schedule(thread_pool.scheduler())
[]{ return 123; }),
cuda::new_stream_scheduler()),
[](int i){ return 123 * 5; }),
thread_pool.scheduler()),
[](int i){ return i - 5; });
auto [result] = this_thread::sync_wait(snd).value();
// result == 610
Es ist nicht einfach, diese Verschachtelung von Funktionsaufrufen zu verstehen und zu erkennen, welche Funktionskörper zusammengehören, oder zu verstehen, warum das Lambda nicht ausgeführt wird. Es macht auch keinen Spaß, diese Komposition zu debuggen oder zu ändern.
Funktionsaufruf mit temporären Objekten
auto snd0 = execution::schedule(thread_pool.scheduler());
auto snd1 = execution::then(snd0, []{ return 123; });
auto snd2 = execution::continues_on(snd1, cuda::new_stream_scheduler());
auto snd3 = execution::then(snd2, [](int i){ return 123 * 5; })
auto snd4 = execution::continues_on(snd3, thread_pool.scheduler())
auto snd5 = execution::then(snd4, [](int i){ return i - 5; });
auto [result] = *this_thread::sync_wait(snd4);
// result == 610
Die Verwendung von temporären Variablen kann sehr hilfreich sein, um die Struktur der Komposition zu verstehen. Nun ist die Abfolge der Funktionsaufrufe leicht zu erkennen. Es wird auch ersichtlich, warum die Lambda-Funktion []{ return 123; } nicht ausgeführt wird. Es gibt keinen Sender-Consumer wie this_thread::sync_wait(snd4). Die Sender-Adapter wie then und continue_on sind "lazy". Sie erzeugen nur dann einen Wert, wenn sie dazu aufgefordert werden.
Aus Sicht der Lesbarkeit gefällt mir diese Lösung, aber sie hat einen gravierenden Nachteil: Es werden viele temporäre Objekte erstellt.
Funktionskomposition mit dem Pipe-Operator
auto snd = execution::schedule(thread_pool.scheduler())
| execution::then([]{ return 123; })
| execution::continues_on(cuda::new_stream_scheduler())
| execution::then([](int i){ return 123 * 5; })
| execution:: Double quote continues_on(thread_pool.scheduler())
| execution::then([](int i){ return i - 5; });
auto [result] = this_thread::sync_wait(snd).value();
// result == 610
Die Funktionskomposition mit dem Pipe-Operator löst beide Probleme. Erstens ist sie lesbar und zweitens werden keine unnötigen temporären Variablen benötigt.
Die folgenden Sender-Adapter sind nicht pipe-fähig.
execution::when_allundexecution::when_all_with_variant: Beide Sender-Adapter nehmen eine beliebige Anzahl von Sendern auf. Es wäre daher unklar, welcher Sender angepasst werden sollte.execution::starts_on: Dieser Sender-Adapter ändert die Ausführungsressource, auf der der Sender ausgeführt wird. Er passt den Sender nicht an.
Layout ist wichtig
FĂĽr die Funktionskomposition ist es wichtig, das Layout lesbar zu gestalten. Mache also keinen "schlauen" Einzeiler daraus:
auto snd = execution::schedule(thread_pool.scheduler()) | execution::then([]{ return 123; }) | execution::continues_on(cuda::new_stream_scheduler()) | execution::then([](int i){ return 123 * 5; }) | execution::continues_on(thread_pool.scheduler()) | execution::then([](int i){ return i - 5; });
Wie geht's weiter?
std::execution bietet einige Sender-Fabriken an, insbesondere viele Sender, um den Arbeitsablauf zu modellieren. Ich werde sie in meinem nächsten Beitrag vorstellen.
(map)