Kooperatives Unterbrechen eines Threads in C++20

Modernes C++ Rainer Grimm  –  217 Kommentare

Vor C++20 ließen sich Threads nicht unterbrechen. Mit C++20 kann man an einen Thread die Anfrage stellen, dass er sich beendet. Ihr kann er dann nachkommen.

Zuerst einmal: Warum ist es keine gute Idee, einen Thread zu beenden? Diese Antwort ist einfach. Man weiß nicht, in welchem Zustand der Thread ist, wenn man ihn beendet. Dies sind zwei mögliche Gefahren:

  • Der Thread kann nur teilweise mit seinem Job fertig sein. Natürlich ist nun der Stand seines Jobs und damit auch der des Programms unbekannt. Am Ende führt das zum undefinierten Verhalten, und keine zuverlässige Aussage über das Programm ist mehr möglich.
  • Der Thread kann sich gerade in einem kritischen Bereich befinden und einen Mutex gelockt haben. Wird der Thread in dieser Phase beendet, führt das mit hoher Wahrscheinlichkeit zu einem Deadlock.

Einen Thread abrupt zu unterbrechen ist keine gute Idee. Da ist es schon besser, den Thread freundlich zu fragen, ob er sich beenden lassen will. Das ist genau, wofür kooperatives Unterbrechen in C++20 steht. Man fragt den Thread freundlich, ob er sich beenden will, und der Thread kann diesem Wunsch nachkommen oder ihn ignorieren.

Kooperatives Unterbrechen

Die zusätzliche Fähigkeit der kooperativen Unterbrechung in C++20 basiert auf den drei neuen Datentypen std::stop_token, std::stop_callback und std::stop_source. Sie ermöglichen es einem Thread, einen Thread asynchron zu beenden oder zu fragen, ob ein Thread ein Stoppsignal erhalten hat. Der std::stop_token lässt sich dafür an eine Operation übergeben. Dieses Stopp-Token kann anschließend dazu verwendet werden, die Operation zu fragen, ob an sie der Wunsch zur Beendigung geschickt wurde. Anderseits lässt sich mit std::stop_token ein Callback mittels std::stop_callback registrieren. Die Stoppanfrage wird von std::stop_source geschickt. Ihr Signal betrifft alle assoziierten std::stop_token. Die drei Klassen std::stop_source, std::stop_token und std::stop_callback teilen sich die Besitzverhältnisse des assoziierten Stoppzustands. Die Aufrufe request_stop(), stop_requested() und stop_possible() sind atomar.

Ein std::stop_source lässt sich auf zwei Arten erzeugen:

stop_source();                                      // (1)
explicit stop_source(std::nostopstate_t) noexcept; // (2)

Der Default-Konstruktor (1) erzeugt ein std::stop_source mit einem Stoppzustand. Der Konstruktor, der std::nostopstate_t als Argument annimmt, erzeugt eine std::stop_source ohne assoziierten Stoppzustand.

Die Komponente std::stop_source src bietet die folgenden Methoden an, um mit Stoppanfragen umzugehen:

src.stop_possible() bedeutet, dass src einen assoziierten Stoppzustand besitzt. src.stop_requested() gibt dann true zurück, wenn src einen assoziierten Stoppzustand besitzt und nicht bereits früher zu stoppen angefordert wurde. Der Aufruf src.get_token() gibt den Stopp-Token zurück. Dank ihm lässt sich prüfen, ob eine Stoppanfrage bereits erfolgt ist oder durchgeführt werden kann.

Das Stopp-Token stoken beobachtet die Stoppquelle src. Die folgende Tabelle stellt die Methoden der std::stop_token stoken vor:

Ein Default-konstruiertes Token besitzt keinen assoziierten Stoppzustand. stoken.stop_possible gibt true zurück, falls stoken einen assoziierten Stoppzustand besitzt. stoken_stop_requested() gibt dann true zurück, wenn der Stopp-Token einen assoziierten Stoppzustand besitzt und bereits eine Stoppanfrage erhalten hat.

Falls der std::stop_token zeitweise deaktiviert werden soll, lässt er sich mit einem Default-konstruierten Token ersetzen. Dieses hat keinen assoziierten Stoppzustand. Die folgenden Zeilen zeigen, wie sich die Fähigkeit eines Threads, Stoppanfragen zu erhalten, zeitweise deaktivieren lässt:

std::jthread jthr([](std::stop_token stoken) {
...
std::stop_token interruptDisabled;
std::swap(stoken, interruptDisabled); // (1)
... // (2)
std::swap(stoken, interruptDisabled);
...
}

std::stop_token interruptDisabled besitzt keinen assoziierten Stoppzustand. Das heißt, dass der Thread jthr in allen Zeilen außer (1) und (2) Stoppanfragen annehmen kann.

Wer den Codeschnipsel sorgfältig studiert, dem fällt wohl std::jthread auf. std::jthread in C++20 ist ein erweiterter std::thread aus C++11. Das "j" in jthread steht für joinable, denn ein std::jthread joint automatisch in seinem Destruktor. Ursprünglich hieß dieser neue Thread ithread: "i" steht für interruptable. Ich stelle std::jthread im nächsten Artikel genauer vor.

Das nächste Beispiel zeigt, wie sich std::jthread zusammen mit einem Callback verwenden lässt:

// invokeCallback.cpp

#include <chrono>
#include <iostream>
#include <thread>
#include <vector>

using namespace::std::literals;

auto func = [](std::stop_token stoken) { // (1)
int counter{0};
auto thread_id = std::this_thread::get_id();
std::stop_callback callBack(stoken, [&counter, thread_id] { // (2)
std::cout << "Thread id: " << thread_id
<< "; counter: " << counter << '\n';
});
while (counter < 10) {
std::this_thread::sleep_for(0.2s);
++counter;
}
};

int main() {

std::cout << '\n';

std::vector<std::jthread> vecThreads(10);
for(auto& thr: vecThreads) thr = std::jthread(func);

std::this_thread::sleep_for(1s); // (3)

for(auto& thr: vecThreads) thr.request_stop(); // (4)

std::cout << '\n';

}

Jeder der zehn Threads ruft die Lambda-Funktion func (1) auf. Der Callback (2) stellt die ID des Threads und den Zähler dar. Dank des einsekundigen Schlafens des main-Threads (3) und des Schlafens der Kinder-Threads besitzt der Zähler zum Zeitpunkt des Callback-Aufrufs den Wert 4. Der Aufruf thr.request_stop() (4) startet den Callback auf jedem Thread.

Wie geht's weiter?

Wie ich im Artikel bereits erwähnt habe, besitzt std::thread eine große Schwäche. Wenn du vergisst ihn zu joinen, ruft sein Destruktor std::terminate auf. Damit beendet sich das Programm. std::jthread (C++20) überwindet dieses unintuitive Verhalten und lässt sich unterbrechen.

Neue Online-Seminare

Ich freue mich darauf, neue Online-Seminare anbieten zu dürfen. Das erste Seminar stellt die Vorteile von modernem C++ in der Embedded-Programmierung vor, und das zweite geht auf Best Practices für modernes C++ ein. Natürlich sind zum jetzigen Zeitpunkt noch viele Plätze frei.