C++ Core Guidelines: Sich um Kinder-Threads kümmern

Wenn ein neuer Thread erzeugt wird, muss die Frage beantwortet werden, ob der Erzeuger wartet, bis sein Kind fertig ist, oder er sich von seinem Kind trennt. Wenn sich das Kind vom Erzeuger trennt und Variablen verwendet, die an die Lebenszeit seines Erzeugers gebunden sind, gilt es zu klären, ob die Variablen für die Lebenszeit des Kindes gültig sind.

In Pocket speichern vorlesen Druckansicht
Lesezeit: 8 Min.
Von
  • Rainer Grimm
Inhaltsverzeichnis

Wenn ein neuer Thread erzeugt wird, muss die Frage beantwortet werden: Wartet der Erzeuger, bis sein Kind fertig ist, oder trennt er sich von seinem Kind? Wenn sich das Kind von seinem Erzeuger trennt und Variablen verwendet, die an die Lebenszeit seines Erzeugers gebunden sind, gilt es, die nächste dränge Frage zu klären: Sind die Variablen für die Lebenszeit des Kindes gültig?

Falls du dich nicht sorgfältig um die Lebenszeit und die Variablen deines Kinder-Threads kümmerst, besitzt dein Programm sehr schnell undefiniertes Verhalten.

Hier sind die Regeln für den heutigen Artikel, die sich genau mit der Lebenszeitproblematik des Kinder-Threads und seinen Variablen beschäftigen:

Die Regeln für den heutigen Artikel hängen stark voneinander ab.

Die Regel CP.23 und CP.24 zu einem scoped und globalen Container muten recht seltsam an. Sie helfen aber sehr, den Unterschied eines Kinder-Threads auf den Punkt zu bringen, auf den entweder der Erzeuger-Thread wartet (join) oder von dem sich dieser trennt (detach).

Hier ist eine leichte Variation eines Codebeispiels aus den C++ Core Guidelines:

void f(int* p)
{
// ...
*p = 99;
// ...
}

int glob = 33;

void some_fct(int* p) // (1)
{
int x = 77;
std::thread t0(f, &x); // OK
std::thread t1(f, p); // OK
std::thread t2(f, &glob); // OK
auto q = make_unique<int>(99);
std::thread t3(f, q.get()); // OK
// ...
t0.join();
t1.join();
t2.join();
t3.join();
// ...
}

void some_fct2(int* p) // (2)
{
int x = 77;
std::thread t0(f, &x); // bad
std::thread t1(f, p); // bad
std::thread t2(f, &glob); // OK
auto q = make_unique<int>(99);
std::thread t3(f, q.get()); // bad
// ...
t0.detach();
t1.detach();
t2.detach();
t3.detach();
// ...
}

Der einzige Unterschied zwischen der Funktion some_fct (1) und some_fct2(2) ist, dass die erste Variante join, dass die zweite Variante detach auf ihren Kinder-Threads aufruft.

Zuerst einmal gilt: Du musst join oder detach auf einem Kinder-Thread aufrufen. Falls du dies nicht tust, erhältst du eine std::terminate-Ausnahme im Destruktor des Kinder-Threads. Darauf komme ich noch in der Regel CP.25 zurück.

Nun aber zu dem Unterschied, join oder detach auf einem Thread aufzurufen.

  • join auf einem Thread auszurufen, bedeutet entsprechend den Guidelines, dass der Kinder-Thread ein scoped Container ist. Oder anders ausgedrückt: Der Container besitzt einen eigenen Gültigkeitsbereich. Was soll das heißen? Der Grund für die Begrifflichkeit ist, dass der thr.join()-Aufruf auf einem thread thr ein Synchronisationspunkt ist. thr.join() sichert zu, dass der Erzeuger des Kinder-Threads wartet, bis sein Kind mit seiner Arbeit fertig ist. Oder anders herum betrachtet. Der Kinder-Thread kann alle Daten seines Erzeugers des Bereichs (scope) verwenden, in der das Kind erzeugt wurde. Entsprechend sind alle Aufrufe der Threads wohl definiert.
  • Im Gegensatz dazu gilt dies nicht, falls auf dem Kinder-Thread detach aufgerufen wurde. detach bedeutet, dass der Erzeuger seinen Verweis auf sein Kind verliert und das Kind länger leben kann als der Erzeuger. Aufgrund dieser Eigenschaft können die Kinder-Threads nur Variablen verwenden, die globale Gültigkeit besitzen. Damit ist der Thread eine Art globaler Container. Verwendet ein Kinder-Thread Variablen aus dem aufrufenden Bereich, stellt dies undefiniertes Verhalten dar.

Falls dich ein detached Thread immer noch durcheinander bringt, möchte ich eine kleine Analogie anbieten. Wenn du eine Datei anlegst und den Verweis auf diese verlierst, existiert die Datei trotzdem noch. Das Gleiche gilt für einen Thread, auf dem du detach aufgerufen hast. Falls du detach auf einem Thread aufrufst, wird der "thread of execution" weiter ausgeführt, obwohl du den Verweis auf diesen Thread verloren hast. Du ahnst es vermutlich: t0 ist lediglich der Verweis auf den "thread of execution", der mit dem Aufruf std::thread t0(f, &x) gestartet wurde.

Wie ich bereits schrieb: Du musst auf einem Kinder-Thread join oder detach aufrufen.

Im folgenden Programm habe ich vergessen, join auf dem Kinder-Thread aufzurufen:

// threadWithoutJoin.cpp

#include <iostream>
#include <thread>

int main(){

std::thread t([]{std::cout << std::this_thread::get_id() << std::endl;});

}

Die Ausführung des Programms endet sehr abrupt.

Und hier kommt die Erklärung: Die Lebenszeit des erzeugten Threads endet mit seiner aufrufbaren Einheit. Diese Einheit ist alles, was sich wie eine Funktion anfühlt. Das kann eine Funktion, ein Funktionsobjekt oder eine Lambda-Funktion wie in diesem Fall sein. Der Erzeuger besitzt zwei Möglichkeiten. Er wartet entweder, bis sein Kind fertig ist (t.join()) oder er trennt sich von seinem Kind: t.detach(). Ein Thread mit einer aufrufbaren Einheit – Threads können ohne aufrufbare Einheit erzeugt werden – wird joinable genannt, falls weder ein t.join()- oder t.detach()-Aufruf auf ihm stattgefunden hat. Der Destruktor eines joinable Threads wirft eine std::terminate-Ausnahme, die in einem std::abort endet. Daher beendet sich das Programm so abrupt.

Die Regel besitzt den Titel "Prefer gsl::joining_thread over std::thread", denn der gsl::joining_thread ruft join automatisch am Ende seines Gültigkeitsbereichs auf. Leider konnte ich keine Implementierung von gsl::joining_thread in der Guidelines Support Library finden. Dank des scoped_thread von Anthony Williams ist dies aber keine Einschränkung:

// scoped_thread.cpp

#include <iostream>
#include <thread>
#include <utility>


class scoped_thread{
std::thread t;
public:
explicit scoped_thread(std::thread t_): t(std::move(t_)){
if ( !t.joinable()) throw std::logic_error("No thread");
}
~scoped_thread(){
t.join();
}
scoped_thread(scoped_thread&)= delete;
scoped_thread& operator=(scoped_thread const &)= delete;
};

int main(){

scoped_thread t(std::thread([]{std::cout << std::this_thread::get_id() << std::endl;}));

}

Der scoped_thread prüft in seinem Konstruktor, ob der verwendete Thread noch joinable ist und ruft in seinem Destruktor join auf diesem auf.

Diese Regel hört sich seltsam an. Der C++11-Standard bietet es zwar an, detach auf einem Thread thr aufzurufen, aber wir sollten es nicht tun! Der Grund ist ganz einfach. Richtig thr.detach() einzusetzen ist anspruchsvoll. Die Regel C.24 bringt es bereits auf den Punkt: "Think of a thread as a global container." Klar, du bist immer auf der sicheren Seite, wenn du nur Variablen mit globaler Gültigkeit in deinem Thread, auf dem du detach aufgerufen hast, verwendest. Nein!

#include <iostream>
#include <string>
#include <thread>

void func(){
std::string s{"C++11"};
std::thread t([&s]{ std::cout << s << std::endl;});
t.detach();
}

int main(){
func();
}

Das war einfach. Die Lambda-Funktion nimmt ihr Argument s per Referenz an. Das ist undefiniertes Verhalten, denn der Kinder-Thread verwendet s, obwohl diese nicht mehr gültig ist. Stopp! Das ist das offensichtliche Problem. Das nicht so offensichtliche Problem ist std::cout. std::cout besitzt einen statischen Gültigkeitsbereich. Das bedeutet, dass der Gültigkeitsbereich von std::cout dann endet, wenn das Programm sich beendet. Dies ist eine Race Condition, denn zu diesem Zeitpunkt kann der thread t std::cout noch verwenden.

Wir sind noch nicht fertig mit den Regeln zur Concurrency in den C++ Core Guidelines. In meinem nächsten Artikel werden weitere Regeln folgen. In diesen beschäftige ich mich mit der Übergabe von Daten an Threads, geteilten Besitzverhältnissen von Threads und den Kosten für das Erzeugen und Destruieren von Threads. ()