Kooperatives Unterbrechen eines Threads in C++20
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.
- Rainer Grimm
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_toke
n
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 Threadjthr
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.
- Embedded-Programmierung mit modernem C++: 12. bis 14. April 2021 (9 bis 17 Uhr)
- Clean Code mit modernem C++: 22. bis 24. Juni 2021 (9 bis 17 Uhr)