Tasks sind relativ spät in den neuen C++11 Standard aufgenommen worden. Sie bieten eine deutlich höhere Abstraktion als Threads an und sind diesen fast immer vorzuziehen.
Tasks als Datenkanäle
Tasks verhalten sich wie Datenkanäle. Auf der einen Seite ist der Sender, der einen Wert in den Datenkanal setzt. Auf der anderen Seite ist der Empfänger, der den Wert abholt. Der Sender wird in C++ als Promise, der Empfänger als Future bezeichnet. Oder anders ausgedrückt. Der Sender verspricht (Promise) dem Sender in der Zukunft (Future) einen Wert zu liefern.
Noch ein paar Details. Der Sender kann mehrere Empfänger bedienen, er kann neben Werten auch Ausnahmen oder Benachrichtigungen übermitteln. Der get-Aufruf des Empfängers ist blockierend. Das heißt, ruft der Empfänger get auf, muss er gegebenenfalls warten, bis der Sender einen Wert in den Kanal gesetzt hat. Task gibt es in drei Variationen in C++. Als asynchrone Funktionsaufrufe mit std::async, als einfacher Wrapper und aufrufbare Einheiten mit std::packaged_task und explizt als Paar std::promise und std::future.
Um die Unterschiede von Threads und Tasks besser zu verstehen, ist es am einfachsten, diese gegenüber zu stellen.
Threads versus Tasks
Der kleine Codeschnipsel soll als Anschauungsmaterial dienen.
int res;
std::thread t([&]{res= 3+4;});
t.join();
std::cout << res << std:::endl;
auto fut=std::async([]{return 3+4;});
std::cout << fut.get() << std::endl;
Sowohl der Kinderthread als auch der Promise berechnen 3+4 und geben das Ergebnis aus. Durch den std::async-Aufruf wird ein Datenkanal mit den beiden Endpunkten fut und async erzeugt. fut repräsentiert in diesem konkreten Fall den Future, async den Promise. Mit fut.get holt der Future den Wert, den der Promise zur Verfügung gestellt hat, ab. Dieser get-Aufruf kann natürlich auch deutlich später erfolgen.
Nun zu den Unterschieden.
Zuerst benötigt ein Thread die Headerdatei <thread>, ein Task die Headerdatei <future>. Die Beteiligten bei Threads sind der Erzeuger- und Kinderthread, beim Task der Promise und der Future. Während die gemeinsame Variable res beim Thread verwendet wird um das Ergebnis an den Erzeuger zu übermitteln, verwenden Promise und Future einen Datenkanal. Dieser Kanal wird durch den std::async-Aufruf erzeugt. Mit fut.get kann der Future auf den Wert zugreifen. Während die gemeinsame Variable gegebenenfalls durch ein Lock geschützt werden muss, ist die Gefahr eines kritischen Wettlaufs bei dem Task automatisch gebannt. Der Threaderzeuger wartet durch seinen t.join-Aufruf, bis sein Kinderthread fertig ist. Im Gegensatz dazu ist der fut.get-Aufruf blockierend. Tritt eine Ausnahme im Kinderthread auf, beendet sich sowohl der Kinder- als auch der Erzeugerthread. Das heißt konsequenterweise das ganze Programm. Der Promise kann im Gegensatz dazu eine Ausnahme an den Future schicken. Dieser muss dann mit der Ausnahme umgehen. Während der Kinderthread nur Werte übermitteln kann, kann der Promise Werte, Ausnahmen und Benachrichtigungen an den Future schicken.
Ein Punkt, den ich explizit hervorheben will, unterstreicht deutlich, dass Tasks eine deutlich höhere Abstraktion anbieten als Threads. Ein Task erzeugt nicht notwendigerweise einen neuen Threads. Das heißt, dass die C++-Laufzeit selbständig entscheidet, wnn ein neuer Thread zu starten ist. Grundlage für die Entscheidung der C++-Laufzeit kann die Komplexität des Arbeitspakets des Futures, die Anzahl der Prozessoren oder die Last des Systems sein.
Wie geht's weiter?
Die Grundlagen sind gelegt. Im nächsten Blog gehe ich genauer auf std::async ein.
Go to Leanpub/cpplibrary "What every professional C++ programmer should know about the C++ standard library". Hole dir dein E-Book. Unterstütze meinen Blog.
Weiterlesen...