Synchronisierte Ausgabe-Streams mit C++20

Modernes C++ Rainer Grimm  –  176 Kommentare

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 du std::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 und std::clog) ist Thread-sicher. Fomaler ausgedrückt: Schreiben auf std::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.