Softwareentwicklung: Umgang mit Veränderung - Guarded Suspension
Guarded Suspension wendet eine besondere Strategie an, um mit Veränderung umzugehen. Sie signalisiert, wenn sie mit ihrer Veränderung fertig ist.
- Rainer Grimm
Als eine wichtige Abstraktion in der modernen Softwareentwicklung bieten Patterns eine klar definierte Terminologie, eine saubere Dokumentation und das Lernen von den Besten. Dieser Beitrag beschäftigt sich weiter mit den Concurrency-Mustern. Guarded Suspension wendet eine besondere Strategie an, um mit Veränderung umzugehen. Sie signalisiert, wenn sie mit ihrer Veränderung fertig ist.
Die Grundvariante der Guarded Suspension kombiniert ein Lock mit einer Vorbedingung, die erfüllt sein muss. Ist die Vorbedingung nicht erfüllt, legt sich der prüfende Thread schlafen. Der überprüfende Thread verwendet ein Lock, um eine Race Condition zu vermeiden, die zu einem Data Race oder einem Deadlock führen kann.
Es gibt verschiedene Varianten der Guarded Suspension:
- Der wartende Thread kann passiv über die Zustandsänderung benachrichtigt werden oder nach der Zustandsänderung aktiv fragen. Das entspricht dem Push- versus Pull-Prinzip.
- Das Warten kann mit oder ohne Zeitbegrenzung erfolgen.
- Die Benachrichtigung kann an einen oder alle wartenden Threads gesendet werden.
In diesem Artikel stelle ich nur die grobe Idee vor, die der Guarded Suspension zugrunde liegt.
Push- versus Pull-Prinzip
Ich möchte mit dem Push-Prinzip beginnen.
Push-Prinzip
Häufig kommen Bedingungsvariablen oder ein Future/Promise-Paar zum Einsatz, um Threads zu synchronisieren. Die Bedingungsvariable oder der Promise sendet die Benachrichtigung an den wartenden Thread. Ein Promise hat keine notify_one-
oder notify_all
-Mitgliedsfunktion. Normalerweise wird ein wertloser set_value
-Aufruf verwendet, um eine Benachrichtigung zu signalisieren. Die folgenden Programmausschnitte zeigen den Thread, der die Benachrichtigung sendet und den wartenden Thread.
- Bedingungsvariable
void waitingForWork(){
std::cout << "Worker: Waiting for work." << '\n';
std::unique_lock<std::mutex> lck(mutex_);
condVar.wait(lck, []{ return dataReady; });
doTheWork();
std::cout << "Work done." << '\n';
}
void setDataReady(){
{
std::lock_guard<std::mutex> lck(mutex_);
dataReady = true;
}
std::cout << "Sender: Data is ready." << '\n';
condVar.notify_one();
}
- Future/Promise-Paar
void waitingForWork(std::future<void>&& fut){
std::cout << "Worker: Waiting for work." << std::endl;
fut.wait();
doTheWork();
std::cout << "Work done." << std::endl;
}
void setDataReady(std::promise<void>&& prom){
std::cout << "Sender: Data is ready." << std::endl;
prom.set_value();
}
Pull-Prinzip
Anstatt passiv auf die Zustandsänderung zu warten, können Entwicklerinnen und Entwickler sie auch aktiv einfordern. Dieses Pull-Prinzip unterstützt C++ nicht von Haus aus, es lässt sich aber beispielsweise mit atomaren Datentypen implementieren.
std::vector<int> mySharedWork;
std::atomic<bool> dataReady(false);
void waitingForWork(){
std::cout << "Waiting " << '\n';
while (!dataReady.load()){
std::this_thread::sleep_for(std::chrono::milliseconds(5));
}
mySharedWork[1] = 2;
std::cout << "Work done " << '\n';
}
void setDataReady(){
mySharedWork = {1, 0, 3};
dataReady = true;
std::cout << "Data prepared" << '\n';
}
Warten mit und ohne Zeitbeschränkung
Eine Bedingungsvariable und ein Future haben drei Mitgliedsfunktionen zum Warten: wait, wait_for
und wait_until
. Die wait_for
-Variante benötigt eine Zeitdauer und die wait_until
-Variante einen Zeitpunkt. Bei den verschiedenen Wartestrategien wartet der Consumer-Thread in dem folgenden Codebeispiel für die Zeitdauer steady_clock::now() + dur
. Der Future fragt nach dem Wert; wenn der Promise noch nicht fertig ist, zeigt der Future nur seine id an:
void producer(promise<int>&& prom){
cout << "PRODUCING THE VALUE 2011\n\n";
this_thread::sleep_for(seconds(5));
prom.set_value(2011);
}
void consumer(shared_future<int> fut,
steady_clock::duration dur){
const auto start = steady_clock::now();
future_status status= fut.wait_until(steady_clock::now() + dur);
if ( status == future_status::ready ){
lock_guard<mutex> lockCout(coutMutex);
cout << this_thread::get_id() << " ready => Result: " << fut.get()
<< '\n';
}
else{
lock_guard<mutex> lockCout(coutMutex);
cout << this_thread::get_id() << " stopped waiting." << '\n';
}
const auto end= steady_clock::now();
lock_guard<mutex> lockCout(coutMutex);
cout << this_thread::get_id() << " waiting time: "
<< getDifference(start,end) << " ms" << '\n';
}
Einen oder alle wartenden Threads benachrichtigen
notify_one
weckt einen der wartenden Threads, notify_all
weckt alle wartenden Threads. Mit notify_one
lässt sich nicht gezielt festlegen, welcher Thread geweckt wird. Die nicht aufgeweckten Threads bleiben im Wartezustand. Mit einem std::future
kann das nicht passieren, weil es eine Eins-zu-Eins-Beziehung zwischen dem Future und dem Promise gibt. Wenn eine Eins-zu-Viele-Beziehung bestehen soll, muss ein std::shared_future
anstelle eines std::future
verwendet werden, da dieser kopiert werden kann.
Das folgende Programm zeigt einen einfachen Arbeitsablauf mit Eins-zu-Eins- und Eins-zu-Viele-Beziehung zwischen Promises und Futures.
// bossWorker.cpp
#include <future>
#include <chrono>
#include <iostream>
#include <random>
#include <string>
#include <thread>
#include <utility>
int getRandomTime(int start, int end){
std::random_device seed;
std::mt19937 engine(seed());
std::uniform_int_distribution<int> dist(start,end);
return dist(engine);
};
class Worker{
public:
explicit Worker(const std::string& n):name(n){};
void operator() (std::promise<void>&& preparedWork,
std::shared_future<void> boss2Worker){
// prepare the work and notfiy the boss
int prepareTime= getRandomTime(500, 2000);
std::this_thread::sleep_for(std::chrono::milliseconds(prepareTime));
preparedWork.set_value(); // (5)
std::cout << name << ": " << "Work prepared after "
<< prepareTime << " milliseconds." << '\n';
// still waiting for the permission to start working
boss2Worker.wait();
}
private:
std::string name;
};
int main(){
std::cout << '\n';
// define the std::promise => Instruction from the boss
std::promise<void> startWorkPromise;
// get the std::shared_future's from the std::promise
std::shared_future<void> startWorkFuture= startWorkPromise.get_future();
std::promise<void> herbPrepared;
std::future<void> waitForHerb = herbPrepared.get_future();
Worker herb(" Herb"); // (1)
std::thread herbWork(herb, std::move(herbPrepared), startWorkFuture);
std::promise<void> scottPrepared;
std::future<void> waitForScott = scottPrepared.get_future();
Worker scott(" Scott"); // (2)
std::thread scottWork(scott, std::move(scottPrepared), startWorkFuture);
std::promise<void> bjarnePrepared;
std::future<void> waitForBjarne = bjarnePrepared.get_future();
Worker bjarne(" Bjarne"); // (3)
std::thread bjarneWork(bjarne, std::move(bjarnePrepared), startWorkFuture);
std::cout << "BOSS: PREPARE YOUR WORK.\n " << '\n';
// waiting for the worker
waitForHerb.wait(), waitForScott.wait(), waitForBjarne.wait(); // (4)
// notify the workers that they should begin to work
std::cout << "\nBOSS: START YOUR WORK. \n" << '\n';
startWorkPromise.set_value(); // (6)
herbWork.join();
scottWork.join();
bjarneWork.join();
}
Der Kerngedanke des Programms ist, dass der Chef (main-thread) drei Arbeiter hat: herb
(Zeile 1), scott
(Zeile 3) und bjarne
(Zeile 3). Jeder Arbeiter wird durch einen Thread repräsentiert. In Zeile (4) wartet der Chef, bis alle Arbeiter ihre Arbeitspakete fertig vorbereitet haben. Das bedeutet, dass jeder Arbeiter nach einer beliebigen Zeit die Mitteilung an den Chef schickt, dass er fertig ist. Die Benachrichtigung des Arbeiters an den Chef ist eine Eins-zu-Eins-Beziehung, da sie einen std::future
verwendet (Zeile 5). Im Gegensatz dazu ist die Anweisung, mit der Arbeit zu beginnen, eine Eins-zu-Viele-Beziehung (Zeile 6) vom Chef an seine Arbeiter. Für diese Eins-zu-Viele-Benachrichtigung ist ein std::shared_future
notwendig.
Eine kleine Sommerpause
In den nächsten zwei Wochen werde ich eine kurze Sommerpause einlegen und keinen Blogartikel veröffentlichen. Mein nächster Artikel erscheint dann am 19. Juni.
Clean Code Schulung
- Clean Code: Best Practices für modernes C++: 20.06.2023 - 22.06.2023 (Präsenzschulung, Termingarantie)
(map)