Threads

Threads sind eine Abstraktion des Betriebssystems, die es ermöglicht, mehrere Ausführungsfäden, jeweils mit eigenem Stack und eigenen Registern ausgestattet, in einem gemeinsamen Adressraum arbeiten zu lassen. Der Einsatz lohnt sich insbesondere auf Mehrprozessormaschinen oder Prozessoren mit mehreren Kernen, die auf gemeinsamen Speicher operieren.

Prinzipiell ist festzuhalten, dass der Umgang mit Threads sehr fehleranfällig sein kann, wenn mehrere Threads im gemeinsamen Speicher konkurrierend auf die gleiche Datenstruktur zugreifen. Vielfach ist Programmtext, der bei einer sequentiellen Ausführung wohldefiniert und korrekt ist, nicht thread safe, d.h. nicht mehr länger wohldefiniert, wenn mehrere Threads ihn gleichzeitig nutzen. Ein Problem waren hier die älteren Fassungen der GEMM-Packfunktionen, die mit globalen Speicher gearbeitet haben. Dies kann nicht gut gehen, wenn mehrere Threads konkurrierend die gleichen globalen Datenstrukturen für verschiedene Matrix-Matrix-Multiplikationen nutzen.

Zwar gibt es die Idee für Threads bereits seit 1965, interessant wurden sie aber erst mit der Einführung von Mehrprozessormaschinen mit gemeinsamen Speicher Anfang der 1990er-Jahre. Nach anfänglichen divergierenden Bibliotheksschnittstellen, wurde 1995 im Rahmen des POSIX-Standards eine standardisierte Schnittstelle für Threads entwickelt. Diese Schnittstelle wurde sehr lange auch von C++ genutzt. Wegen ihrer Unhandlichkeit in C++ entstanden darauf aufbauende Bibliotheken, insbesondere in der Boost-Library. Letztere Schnittstelle wurde bei C++11 weitgehend unverändert in den C++-Standard übernommen und diesen werden wir hier im einzelnen vorstellen.

Ein Prozess beginnt zunächst mit genau einem Thread, d.h. wenn main() aufgerufen wird, haben wir noch keine Parallelisierung. Threads können jederzeit erzeugt werden und es ist möglich, auf das Ende eines Threads zu warten. Außerdem stehen weitere Synchronisierungsmöglichkeiten zwischen Threads zur Verfügung.

Threads unterstützen das Fork-and-Join-Pattern, d.h. wir erzeugen mehrere Threads und am Ende warten wir darauf, dass diese mit dem Aufruf des Funktionsobjekt fertig sind. Diese Synchronisierung am Ende ist verpflichtend, wenn die Threads nicht explizit unabhängig gemacht werden (detached).

Um Threads in C++ erzeugen, benötigen wir Objekte des Typs std::thread aus <thread>. Ein solches Objekt kann sich in einem der folgenden Zustände befinden:

Bei den größeren Matrizen kann sich die Initialisierung etwas hinziehen. Entsprechend lohnt es sich, diese zu parallelisieren. Dies ist besonders einfach, wenn wir mehrere Matrizen zu initialisieren haben:

using namespace hpc::matvec;
GeMatrix<double> A(1000, 1000);
GeMatrix<double> B(1000, 1000);
GeMatrix<double> C(1000, 1000);

/* start three threads that initialize A, B, and C */
std::thread t1([&](){ randomInit(A); });
std::thread t2([&](){ randomInit(B); });
std::thread t3([&](){ randomInit(C); });

/* wait until they are finished */
t1.join(); t2.join(); t3.join();

Alle drei Thread-Objekte werden hier sofort mit einem Lambda-Ausdruck initialisiert. Entsprechend werden jeweils die Threads sofort erzeugt und diese legen sofort los mit dem Aufruf des Lambda-Ausdrucks. Danach haben wir insgesamt vier Threads: drei neu erzeugte und nach wie vor der erste Thread, der unmittelbar danach weitermacht. Dieser Teil entspricht dem fork des Fork-and-Join-Patterns.

Danach warten wir auf die Vollendung der einzelnen Threads, indem wir für jeden Thread die join-Methode aufrufen. Das blockiert den Aufrufer jeweils bis der Aufruf des jeweiligen Funktionsobjekts beendet ist. Sollte der Aufruf bereits beendet sein, kehrt join sofort zurück.

Auf die Synchronisierung darf nicht ohne weiteres verzichtet werden. Im folgenden Beispiel werden drei Threads erzeugt und jedes der Thread-Objekte wird am Ende des Blocks abgebaut, ohne dass diese Synchronisierung stattfindet:

/* start three threads that initialize A, B, and C */
{
   std::thread t1([&](){ randomInit(A); });
   std::thread t2([&](){ randomInit(B); });
   std::thread t3([&](){ randomInit(C); });
}

Dann kommt es bei der Ausführung zum Crash:

$shell> g++ -O3 -std=c++11 -o bad_init1 -I/home/numerik/pub/hpc/session13 bad_init1.cpp
$shell> ./bad_init1
terminate called without an active exception
/home/borchert/hpc/commons/uebungen/tmp/shell.sh: line 2: 18582 Abort                   (core dumped) ./bad_init1

Der Crash ist hier von C++-Standard vorgeschrieben. Wenn ein Thread-Objekt abgebaut wird (d.h. der destructor aufgerufen wird), dann muss das Objekt in einem der beiden Endzustände sein, d.h. es wurde entweder join oder detach aufgerufen.

Aufgaben