Synchronisierte Ausgabe-Streams mit C++20
Was passiert, wenn unsynchronisiert auf std::cout geschrieben wird? Ein vollkommenes Durcheinander. Das muss mit C++20 nicht mehr sein.
- Rainer Grimm
Was passiert, wenn unsynchronisiert auf std::cout geschrieben wird? Ein vollkommenes Durcheinander. Das muss mit C++20 nicht mehr sein.
Bevor ich die synchronisierten Ausgabe-Streams in C++20 vorstelle, möchte auf die nicht synchronisierte Ausgabe in C++11 eingehen:
// coutUnsynchronized.cpp
#include <chrono>
#include <iostream>
#include <thread>
class Worker{
public:
Worker(std::string n):name(n) {};
void operator() (){
for (int i = 1; i <= 3; ++i) {
// begin work
std::this_thread::sleep_for(std::chrono::milliseconds(200)); // (3)
// end work
std::cout << name << ": " << "Work " << i << " done !!!" << '\n'; // (4)
}
}
private:
std::string name;
};
int main() {
std::cout << '\n';
std::cout << "Boss: Let's start working.\n\n";
std::thread herb= std::thread(Worker("Herb")); // (1)
std::thread andrei= std::thread(Worker(" Andrei"));
std::thread scott= std::thread(Worker(" Scott"));
std::thread bjarne= std::thread(Worker(" Bjarne"));
std::thread bart= std::thread(Worker(" Bart"));
std::thread jenne= std::thread(Worker(" Jenne")); // (2)
herb.join();
andrei.join();
scott.join();
bjarne.join();
bart.join();
jenne.join();
std::cout << "\n" << "Boss: Let's go home." << '\n'; // (5)
std::cout << '\n';
}
Der Boss beschäftigt sechs Arbeiter (Zeile 1 bis 2). Jeder Arbeiter muss drei Arbeitspakete erledigen, die jeweils eine 1/5 Sekunde benötigen (Zeile 3). Nachdem ein Arbeiter mit einem Arbeitspaket fertig ist, schreit er seinen Namen laut heraus (Zeile 4). Wenn der Boss alle Benachrichtigungen von allen Arbeitern erhalten hat, schickt er seine Truppe (Zeile 5) nach Hause. Was für ein Durcheinander für solch einen einfachen Arbeitsablauf! Jeder Arbeiter schreit seine Nachricht heraus und ignoriert dabei seine Kollegen.
std::cout
ist Thread-sicher: Der C++11-Standard garantiert, dass dustd::cout
nicht schützen musst. Jeder Buchstaben wird atomar geschrieben. Mehrere Ausgabeoperationen wie im Beispiel können sich natürlich vermischen. Dieses Vermischen ist aber nur ein optisches Problem: Das Programm ist wohl definiert. Diese Aussage gilt für die globalen Stream-Objekte. Das Einfügen oder die Entnahme von den globalen Stream-Objekten (std::cout
,std::cin
,std::cerr
undstd::clog
) ist Thread-sicher. Fomaler ausgedrĂĽckt: Schreiben aufstd::cout
stellt kein Data Race, sondern eine Race Condition dar. In meinem Artikel "Race Conditions versus Data Races" gehe ich genauer auf die beiden Begriffe ein.
Wie lässt sich das Problem beseitigen? Mit C++11 ist die Antwort einfach: Setze einen Lock wie std::lock_guard
ein, um synchronisiert auf std::cout
zu schreiben. Mehr Informationen zu Lock in C++11 bietet mein Artikel "Locks statt Mutexe".
// coutSynchronized.cpp
#include <chrono>
#include <iostream>
#include <mutex>
#include <thread>
std::mutex coutMutex; // (1)
class Worker{
public:
Worker(std::string n):name(n) {};
void operator() (){
for (int i = 1; i <= 3; ++i) {
// begin work
std::this_thread::sleep_for(std::chrono::milliseconds(200));
// end work
std::lock_guard<std::mutex> coutLock(coutMutex); // (2)
std::cout << name << ": " << "Work " << i << " done !!!" << '\n';
} // (3)
}
private:
std::string name;
};
int main() {
std::cout << '\n';
std::cout << "Boss: Let's start working." << "\n\n";
std::thread herb= std::thread(Worker("Herb"));
std::thread andrei= std::thread(Worker(" Andrei"));
std::thread scott= std::thread(Worker(" Scott"));
std::thread bjarne= std::thread(Worker(" Bjarne"));
std::thread bart= std::thread(Worker(" Bart"));
std::thread jenne= std::thread(Worker(" Jenne"));
herb.join();
andrei.join();
scott.join();
bjarne.join();
bart.join();
jenne.join();
std::cout << "\n" << "Boss: Let's go home." << '\n';
std::cout << '\n';
}
Der coutMutex
in Zeile 1 schĂĽtzt das geteilte Objekt std::cout
. Indem der coutMutex
in einen std::lock_guard
gesteckt wird, ist sichergestellt, dass der Mutex im Konstruktor (Zeile 2) gelockt und im Destruktor (Zeile 3) des std::lock_guard
freigegeben wird. Dank des coutMutex
, den coutLock
verwaltet, löst sich das vollkommene Durcheinander in Harmonie auf.
Mit C++20 wird das synchronisierte Schreiben auf std::cout
zum Kinderspiel. std::basic_syncbuf
ist ein Wrapper fĂĽr std::basic_streambuf
. Dieser Wrapper häuft seine Ausgabe in einem Puffer an und schreibt seinen Inhalt, wenn er destruiert wird. Konsequenterweise erscheint der Inhalt als kontinuierliche Sequenz von Buchstaben, sodass kein Durcheinander mehr möglich ist.
Dank std::basic_osyncstream
ist es möglich, direkt synchronisiert auf std::cout
zu schreiben, indem ein synchronisierter Ausgabestream zum Einsatz kommt.
Das folgende Programm basiert auf dem vorherigen Programm coutUnsynchronized.cpp
. Zum jetzigen Zeitpunkt unterstĂĽtzt lediglich GCC 11 synchronisierte Ausgabe-Streams:
// synchronizedOutput.cpp
#include <chrono>
#include <iostream>
#include <syncstream>
#include <thread>
class Worker{
public:
Worker(std::string n): name(n) {};
void operator() (){
for (int i = 1; i <= 3; ++i) {
// begin work
std::this_thread::sleep_for(std::chrono::milliseconds(200));
// end work
std::osyncstream syncStream(std::cout); // (1)
syncStream << name << ": " << "Work " << i // (3)
<< " done !!!" << '\n';
} // (2)
}
private:
std::string name;
};
int main() {
std::cout << '\n';
std::cout << "Boss: Let's start working.\n\n";
std::thread herb= std::thread(Worker("Herb"));
std::thread andrei= std::thread(Worker(" Andrei"));
std::thread scott= std::thread(Worker(" Scott"));
std::thread bjarne= std::thread(Worker(" Bjarne"));
std::thread bart= std::thread(Worker(" Bart"));
std::thread jenne= std::thread(Worker(" Jenne"));
herb.join();
andrei.join();
scott.join();
bjarne.join();
bart.join();
jenne.join();
std::cout << "\n" << "Boss: Let's go home." << '\n';
std::cout << '\n';
}
Der einzige Unterschied zum vorherigen Programm coutUnsynchronized.cpp
ist, dass std::cout
in einem std::osyncstream
(Zeile 1) enthalten ist. Wenn der std::osyncstream
seinen Gültigkeitsbereich in Zeile (2) verlässt, werden seine Buchstaben übertragen und std::cout
wird geleert. Ich möchte betonen, dass der Aufruf von std::cout
im main
-Program kein Data Race darstellt und damit nicht synchronisiert werden muss. Die Ausgaben finden vor oder nach der Ausgabe der Threads statt.
Da ich den syncStream
(Zeile 3) nur einmal verwende, ist ein temporäres Objekt in diesem Fall angebrachter. Das folgende Codebeispiel stellt den angepassten Aufruf-Operator vor:
void operator()() {
for (int i = 1; i <= 3; ++i) {
// begin work
std::this_thread::sleep_for(std::chrono::milliseconds(200));
// end work
std::osyncstream(std::cout) << name << ": " << "Work " << i << " done !!!"
<< '\n';
}
}
std::basic_osyncstream syncStream
bietet zwei interessante Methoden an:
syncStream.emit()
gibt die gepufferte Ausgabe aus.syncStream.get_wrapped()
gibt einen Zeiger auf den gewrappten Puffer zurĂĽck.
cppreference.com zeigt, wie sich die Ausgabe verschiedener Ausgabe-Streams mit der get_wrapped
-Methode sequenzieren lassen:
// sequenceOutput.cpp
#include <syncstream>
#include <iostream>
int main() {
std::osyncstream bout1(std::cout);
bout1 << "Hello, ";
{
std::osyncstream(bout1.get_wrapped()) << "Goodbye, " << "Planet!" << '\n';
} // emits the contents of the temporary buffer
bout1 << "World!" << '\n';
} // emits the contents of bout1
Wie geht's weiter?
Jetzt habe ich es vollbracht und C++20 in mehr als 70 Artikeln vollständig vorgestellt. Mehr Informationen zu C++ 20 gibt es nin meinem Buch: C++20: Get the Details.
Es gibt aber ein Feature, auf das ich nochmals genauer eingehen will: Coroutinen. In meinen nächsten Artikel werde ich mich mit den Schlüsselworten co_return
, co_yield
und co_await
rein spielerisch beschäftigen.
()