Die neue Zeitbibliothek von C++11 erweist sich als elementarer Bestandteil der Threading-Schnittstelle: Sowohl Threads, Locks und Bedingungsvariablen als auch Futures haben ein Verständnis von Zeit. Dank ihrer Unterstützung kann ein Entwickler unterschiedliche Wartestrategien verfolgen.
Gemein ist den vier Komponenten, dass sie für eine Zeitspanne oder bis zu einem gewissen Zeitpunkt schlafen, warten oder nur blockieren. Die Methoden rund um das Zeitmanagement in Multithreading-Programmen folgen dabei einer einfachen Konvention. Methoden, die mit »_for« enden, parametrisiert C++11 mit einer Zeitdauer. Methoden, die mit »_until« enden, verwenden dafür einen Zeitpunkt. Details zu Zeitdauer und Zeitpunkten verrät ein Artikel aus dem Linux-Magazin [1]. Einen einfachen Überblick zu den verschiedenen Methoden liefert Tabelle 1. Sie beziehen in den Begriff “Warten” auch das Schlafen und Blockieren ein.
Tabelle 1
Methoden für absolutes und relatives Warten
|
Multithreading-Komponenten |
Warten bis |
Wartezeit |
|---|---|---|
|
std::thread th |
th.sleep_until(in2min) |
th.sleep_for(2s) |
|
std::unique_lock lk |
lk.try_lock_until(in2min) |
lk.try_lock_for(2s) |
|
std::conditon_variable cv |
cv.wait_until(in2min) |
cv.wait_for(2s) |
|
std::future fu |
fu.wait_until(in2min) |
fu.wait_for(2s) |
|
std::shared_future shFu |
shFu.wait_until(in2min) |
shFu.wait_for(2s) |
In der Tabelle bezeichnet »in2min« einen Zeitpunkt, der 2 Minuten in der Zukunft liegt, »2s« verweist auf eine Zeitdauer von 2 Sekunden. Während der Anwender aber »in2min« mit dem Ausdruck
auto in2min= std::chrono::steady_clock ::now() + std::chrono::minutes(2)
aufwändig definieren muss, gehört der Ausdruck »2s« dank C++14 automatisch als fundamentales Literal zum Repertoire. C++14 enthält zudem noch weitere fundamentale Literale zu typischen Zeitspannen, die CPP-Referenz unter [4] weiß mehr.
Wartestrategien mit Zukünften
Als zentrale Idee des etwas später beschriebenen Programms fungiert ein Promise [5]. Dieses Klassen-Template stellt den so genannten Futures üblicherweise Werte oder Exceptions zur Verfügung, die eine Future auf asynchrone Weise abholen. Im konkreten Fall greifen vier Futures [5] zu unterschiedlichen Zeitpunkten auf das Ergebnis des Promise zu, was jedoch nur mit geteilten Futures (»std::shared_future« ) funktioniert.
Zeitgeber in C++11
Bei drei verschiedenen Zeitgebern in C++11 stellt sich natürlich die Frage, worin diese sich unterscheiden.
- »std::chrono::system_clock« : Stellt die natürliche Zeit (Wall Clock) dar. Er bietet die Hilfsfunktionen »to_time_t« und »form_time_t« [2] an, um Zeitpunkte in Datumsausgaben zu konvertieren.
- »std::chrono::steady_clock« : Gibt als einziger Zeitgeber die Garantie, dass niemand ihn neu stellen kann. Damit ist »std::chrono::steady_clock« die ideale Uhr, um bis zu einem Zeitpunkt oder für eine bestimmte Zeitdauer zu warten.
- »std::chrono::high_resolution_clock« : Ist der Zeitgeber mit der höchsten Auflösung. Er kann als Alias auf »std::chrono::system_clock« oder auch »std::chrono::steady_clock« implementiert sein.
Der C++-Standard gibt zwar keine Garantie für die Genauigkeit, den Startzeitpunkt oder den gültigen Zeitbereich, die ein Zeitgeber darstellen kann. Der Startzeitpunkt von »std::chrono:system_clock« ist aber typischerweise der 1. Januar 1970, die so genannte Unix-Zeit [3]. Bei »std::chrono::steady_clock« kommt mit Vorliebe der Bootzeitpunkt des Rechners zum Tragen.
Beim Warten auf das Ergebnis, das das Promise ausgibt, verfolgt jede Future eine andere Strategie. C++ führt alle Promises und Futures in separaten Threads aus. Der Einfachheit halber spricht der Text daher in der folgenden Aufzählung nicht mehr von Future, sondern von Threads, welche die Future besitzen.
- »consumeThread1« : Wartet bis zu 4 Sekunden auf das Ergebnis des Promise.
- »consumeThread2« : Wartet bis zu 20 Sekunden auf das Ergebnis des Promise.
- »consumeThread3« : Erkundigt sich beim Promise, ob das Ergebnis vorliegt, und legt sich dann wieder 700 Millisekunden schlafen.
- »consumeThread4« : Erkundigt sich beim Promise nach dem Ergebnis. Legt sich beim ersten Mal 1 Millisekunde schlafen und verdoppelt anschließend jeweils seine Schlafperiode.
Deutlich wird hier also, dass die geteilten »consumeThread« recht unterschiedliche Strategien verfolgen.
Auf ins Schlaflabor
Nun zu dem eigentlichen Programm, das in Listing 1 zu sehen ist. In der »main()« -Funktion generiert es zu Beginn das Promise (Zeile 83). Bevor der Autor das Promise in einen separaten Thread verschiebt (Zeile 85), erzeugt dieser eine Future (Zeile 84). Da das Promise keine Copy-Semantik unterstützt, kann das Programm es nur in den Thread schieben (per Move-Semantik, [6]). Das gilt hingegen nicht für die geteilten Futures in den Zeilen 87 bis 90, diese lassen sich in ihren Thread kopieren.
Die Hilfsfunktion »getDifference()« , die sich über die Zeilen 9 bis 13 erstreckt, kommt im Beispielprogramm gleich mehrfach zum Einsatz. Sie benötigt als Eingabeparameter zwei Zeitpunkte. Ihr Rückgabewert ist die vergangene Zeit in Millisekunden. Weiter geht es zu den einzelnen Threads:
- »producerThread« : Er führt die Funktion »producer()« in den Zeilen 15 bis 19 aus. Die Funktion stellt nach einem fünfsekündigen Powernap das Ergebnis »2011« zur Verfügung. Genau auf dieses warten im weiteren Verlauf bereits die Futures.
- »consumerThread1« : Führt die Funktion »consumer()« aus (Zeilen 21 bis 35). In ihr verharrt der Thread vom aktuellen Zeitpunkt ausgehend maximal 4 Sekunden (Zeile 23), bevor er sich wieder seiner Arbeit widmet. In dieser Zeit steht natürlich das Ergebnis des Promise noch nicht bereit.
- »consumerThread2« : Auch er startet die »consumer()« -Funktion. In ihr wartet er vom aktuellen Zeitpunkt ausgehend maximal 20 Sekunden (Zeile 23), bis er seine Arbeit fortsetzt.
- »consumerThread3« : Ruft die Funktion »consumePeriodically()« auf (Zeilen 37 bis 55). Der Thread schläft immer für 700 Millisekunden (Zeile 41) und erkundigt sich dann, ob das Ergebnis des Promise schon da ist (Zeile 42). Dank der Zeitangabe von 0 Sekunden (»std::chrono::seconds(0)« ) wartet er in diesem Fall ausnahmsweise nicht. Steht das Ergebnis der Berechnung bereit, gibt er es in Zeile 49 aus.
- »consumerThread4« : Verwendet die Funktion »consumeWithBackoff()« (Zeilen 57 bis 77). Er schläft in der ersten Iteration 1 Millisekunde und verdoppelt bei jeder weiteren Iteration seine Schlafdauer. Ansonsten gleicht seine Strategie der des »consumerThread3« .
Nachdem der in der Aufzählung zuerst erwähnte Producer-Thread also ein Ergebnis generiert hat, schreiten die verschiedenen Consumer-Threads zur Tat, um es weiterzuverarbeiten. Dabei verfolgen sie allerdings ziemlich unterschiedliche Strategien. Einige warten nur kurz, andere etwas länger und wieder andere halten kurze Nickerchen von gleichbleibender oder sich steigernder Länge zwischen ihren Anfragen.
Voll synchron
Der gemeinsame Zeitgeber, mit dem das Programm die aktuellen Zeitpunkte bestimmt, ist ebenso eine geteilte Variable wie »std::cout« . Dennoch bedarf es keiner Synchronisation. Zum einen ist der Methodenaufruf »std::chrono::steady_clock::now()« Thread-sicher (zum Beispiel in den Zeilen 22 und 32), zum anderen sichert die C++-Laufzeit zu, dass das Programm die Zeichen atomar auf »std::cout« schreibt. Lediglich wegen der Optik ist »std::cout« in einem »std::lock_guard« verpackt (beispielsweise in den Zeilen 25, 29 und 33).
Listing 1
Vier Wartestrategien
001 #include <utility>
002 #include <iostream>
003 #include <future>
004 #include <thread>
005 #include <utility>
006
007 std::mutex coutMutex;
008
009 long double getDifference(const std::chrono::steady_clock::time_point& tp1,const std::chrono::steady_clock::time_point& tp2){
010 auto diff= tp2- tp1;
011 auto res= std::chrono::duration <double, std::milli> (diff).count();
012 return res;
013 }
014
015 int producer(std::promise<int>&& prom){
016 std::cout << "PRODUCING THE VALUE 2011\n\n";
017 std::this_thread::sleep_for(std::chrono::seconds(5));
018 prom.set_value(2011);
019 }
020
021 void consumer(std::shared_future<int> fut,std::chrono::steady_clock::duration dur){
022 auto start = std::chrono::steady_clock::now();
023 std::future_status status= fut.wait_until(std::chrono::steady_clock::now() + dur);
024 if ( status == std::future_status::ready ){
025 std::lock_guard<std::mutex> lockCout(coutMutex);
026 std::cout << std::this_thread::get_id() << " ready => Result: " << fut.get() << std::endl;
027 }
028 else{
029 std::lock_guard<std::mutex> lockCout(coutMutex);
030 std::cout << std::this_thread::get_id() << " stopped waiting." << std::endl;
031 }
032 auto end= std::chrono::steady_clock::now();
033 std::lock_guard<std::mutex> lockCout(coutMutex);
034 std::cout << std::this_thread::get_id() << " waiting time: " << getDifference(start,end) << " ms" << std::endl;
035 }
036
037 void consumePeriodically(std::shared_future<int> fut){
038 auto start = std::chrono::steady_clock::now();
039 std::future_status status;
040 do {
041 std::this_thread::sleep_for(std::chrono::milliseconds(700));
042 status = fut.wait_for(std::chrono::seconds(0));
043 if (status == std::future_status::timeout) {
044 std::lock_guard<std::mutex> lockCout(coutMutex);
045 std::cout << " " << std::this_thread::get_id() << " still waiting." << std::endl;
046 }
047 if (status == std::future_status::ready) {
048 std::lock_guard<std::mutex> lockCout(coutMutex);
049 std::cout << " " << std::this_thread::get_id() << " waiting done => Result: " << fut.get() << std::endl;
050 }
051 } while (status != std::future_status::ready);
052 auto end= std::chrono::steady_clock::now();
053 std::lock_guard<std::mutex> lockCout(coutMutex);
054 std::cout << " " << std::this_thread::get_id() << " waiting time: " << getDifference(start,end) << " ms" << std::endl;
055 }
056
057 void consumeWithBackoff(std::shared_future<int> fut){
058 auto start = std::chrono::steady_clock::now();
059 std::future_status status;
060 auto dur= std::chrono::milliseconds(1);
061 do {
062 std::this_thread::sleep_for(dur);
063 status = fut.wait_for(std::chrono::seconds(0));
064 dur *= 2;
065 if (status == std::future_status::timeout) {
066 std::lock_guard<std::mutex> lockCout(coutMutex);
067 std::cout << " " << std::this_thread::get_id() << " still waiting." << std::endl;
068 }
069 if (status == std::future_status::ready) {
070 std::lock_guard<std::mutex> lockCout(coutMutex);
071 std::cout << " " << std::this_thread::get_id() << " waiting done => Result: " << fut.get() << std::endl;
072 }
073 } while (status != std::future_status::ready);
074 auto end= std::chrono::steady_clock::now();
075 std::lock_guard<std::mutex> lockCout(coutMutex);
076 std::cout << " " << std::this_thread::get_id() <<
" waiting time: " << getDifference(start,end) << " ms" << std::endl;
077 }
078
079 int main(){
080
081 std::cout << std::endl;
082
083 std::promise<int> prom;
084 std::shared_future<int> future= prom.get_future();
085 std::thread producerThread(producer,std::move(prom));
086
087 std::thread consumerThread1(consumer,future,std::chrono::seconds(4));
088 std::thread consumerThread2(consumer,future,std::chrono::seconds(20));
089 std::thread consumerThread3(consumePeriodically,future);
090 std::thread consumerThread4(consumeWithBackoff,future);
091
092 consumerThread1.join();
093 consumerThread2.join();
094 consumerThread3.join();
095 consumerThread4.join();
096 producerThread.join();
097
098 std::cout << std::endl;
099
100 }
Trotz des geordneten Schreibens verlangt das Verständnis der Ausgabe von »std::cout« in Abbildung 1 vom Entwickler einige Konzentration. Im ersten Schritt kommt das Promise zum Einsatz, ihm folgen die Futures. Als erster Thread will »consumerThread4« wissen, ob das Ergebnis bereits vorliegt. Die Ausgabe des zugehörigen Thread ist in der Abbildung acht Zeichen eingerückt. Davon abgesehen gibt er wie alle anderen Threads seine ID aus. Unmittelbar danach folgt »consumerThread3« . Seine Ausgabe rückt das Programm um vier Zeichen ein. Bleiben noch »consumerThread1« und »consumerThread2« , beide sind nicht eingerückt.
Den Abschluss bildet ein Überblick aller wartenden Threads:
- »consumeThread1« : Wartet vergeblich rund 4 Sekunden (4000,18 Millisekunden), ohne das Ergebnis zu erhalten.
- »consumeThread2« : Erhält das Ergebnis nach rund 5 Sekunden (5000,3 Millisekunden), obwohl er maximal bis zu 20 Sekunden warten würde.
- »consumeThread3« : Erhält das Ergebnis dank seiner gleichförmigen Nickerchen nach etwa 5,6 Sekunden (5601,76 Millisekunden), was 8-mal 700 Millisekunden entspricht.
- »consumeThread4« : Erhält das Ergebnis schließlich nach etwa 8,2 Sekunden (8193,81 Millisekunden). Oder anders formuliert: Er wartet dank der sich steigernden Intervalle zirka 3 Sekunden zu lange, denn das Ergebnis steht bereits nach 5 Sekunden (Zeile 17) fest.
Warten lohnt sich also für alle – außer für den ersten Consume-Thread –, will aber offenbar auch gelernt sein. Am schnellsten kommt »consumeThread2« ans Ziel.
Wie geht’s weiter?
Das Jahr 2017 steht schon fast vor der Tür und damit auch der nächste C++-Standard: C++17. Er wird wohl nicht – wie von vielen in der Community erhofft – der ganz große Wurf. Weihnachten für C++-Entwickler folgt vermutlich erst 2020.
Dennoch haben es vor allem zwei lang ersehnte Features bereits in die nächste Iteration des C++-Standards geschafft: Eine standardisierte Schnittstelle für das Dateisystem sowie eine parallelisierte Version der Standard Template Library. Grund genug also, um mit dem nächsten Artikel einen Blick in die C++-Zukunft zu werfen.
Infos
- Rainer Grimm, “Die Zeit verstehen”: Linux-Magazin 04/16, S. 90
- »std::chrono::system_clock« : http://en.cppreference.com/w/cpp/chrono/system_clock
- Unix-Zeit: https://de.wikipedia.org/wiki/Unixzeit
- Einige typische Literale zur Zeitdauer: http://en.cppreference.com/w/cpp/chrono/duration
- Rainer Grimm, “Alle im Einklang”: Linux-Magazin 10/12, S. 90, https://www.linux-magazin.de/Ausgaben/2012/10/C-11
- Rainer Grimm, “Rasch verschoben”: Linux-Magazin 12/12, S. 96, https://www.linux-magazin.de/Ausgaben/2012/12/C-11







