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.

In Pocket speichern vorlesen Druckansicht

(Bild: Yellow duck / shutterstock.com)

Lesezeit: 4 Min.
Von
  • Rainer Grimm
Inhaltsverzeichnis

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.

Modernes C++ – Rainer Grimm

Rainer Grimm ist seit vielen Jahren als Softwarearchitekt, Team- und Schulungsleiter tätig. Er schreibt gerne Artikel zu den Programmiersprachen C++, Python und Haskell, spricht aber auch gerne und häufig auf Fachkonferenzen. Auf seinem Blog Modernes C++ beschäftigt er sich intensiv mit seiner Leidenschaft C++.

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.

Ich möchte mit dem Push-Prinzip beginnen.

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();

}

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';
}

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';
}

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.

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.

(map)