zurück zum Artikel

Patterns in der Softwarearchitektur: Monitor Object

Rainer Grimm
Fernglas

(Bild: Gemeinfrei (US Navy))

Das Entwurfsmuster Monitor Object synchronisiert die gleichzeitige Ausführung von Mitgliedsfunktionen.

Patterns sind eine wichtige Abstraktion in der modernen Softwareentwicklung und Softwarearchitektur. Sie bieten eine klar definierte Terminologie, eine saubere Dokumentation und das Lernen von den Besten. Das Buch "Pattern-Oriented Software Architecture: Patterns for Concurrent and Networked Objects" [1] definiert ein Monitor Object folgendermaßen: "The monitor object design pattern synchronizes concurrent member function execution to ensure that only one member function at a time runs within an object. It also allows object’s member functions to schedule their execution sequences cooperatively."

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++.

Das Entwurfsmuster Monitor Object synchronisiert also die gleichzeitige Ausführung von Mitgliedsfunktionen, um sicherzustellen, dass immer nur eine Mitgliedsfunktion innerhalb eines Objekts ausgeführt wird. Außerdem ermöglicht es den Mitgliedsfunktionen eines Objekts, ihre Ausführungssequenzen gemeinsam zu planen.

Auch bekannt als

Wenn viele Threads gleichzeitig auf ein gemeinsames Objekt zugreifen, gibt es folgende Herausforderungen:

Lösung

Ein Client (Thread) kann auf die synchronisierten Mitgliedsfunktionen des Monitor Object zugreifen, und aufgrund des Monitor Lock kann zu einem bestimmten Zeitpunkt nur eine synchronisierte Mitgliedsfunktion ausgeführt werden. Jedes Monitor Object besitzt eine Monitor-Bedingung, die die wartenden Clients benachrichtigt.

Während das Monitor Lock den exklusiven Zugriff auf die synchronisierten Mitgliedsfunktionen sicherstellt, garantiert die Monitor-Bedingung minimale Wartezeiten für die Clients. Im Wesentlichen schützt das Monitor Lock vor Data Races und der Condition Monitor vor Deadlocks.

Dynamisches Verhalten

Die Interaktion zwischen dem Monitor Object und seinen Komponenten verläuft in verschiedenen Phasen.

  1. Wenn ein Client eine synchronisierte Mitgliedsfunktion auf einem Monitor Object aufruft, muss er zunächst das globale Monitor Lock anlegen. Nach dem erfolgreichen Lock führt er die synchronisierte Mitgliedsfunktion aus und hebt am Ende das Lock des Monitors auf. Ist das Lock nicht erfolgreich, wird der Client blockiert.
  2. Wenn der Client blockiert ist, wartet er, bis die Monitor-Bedingung eine Benachrichtigung sendet. Diese Benachrichtigung erfolgt, wenn das Monitor Lock aufgelöst wird. Die Benachrichtigung kann nun an einen oder alle wartenden Clients gesendet werden. Warten bedeutet in der Regel ressourcenschonendes Schlafen im Gegensatz zum Busy-Waiting.
  3. Wenn ein Client die Benachrichtigung erhält, dass er die Arbeit wieder aufnehmen soll, legt er das Monitor-Lock an und führt die synchronisierte Mitgliedsfunktion aus. Das Monitor Lock wird am Ende der synchronisierten Funktion wieder freigegeben. Die Monitor-Bedingung sendet eine Benachrichtigung, um zu signalisieren, dass der nächste Client seine synchronisierte Mitgliedsfunktion ausführen kann.

Was sind die Vor- und Nachteile des Monitor Objects?

Vorteile

Nachteile

Das folgende Beispiel definiert eine ThreadSafeQueue.

// monitorObject.cpp

#include <condition_variable>
#include <functional>
#include <queue>
#include <iostream>
#include <mutex>
#include <random>
#include <thread>

class Monitor {
public:
    void lock() const {
        monitMutex.lock();
    }

    void unlock() const {
        monitMutex.unlock();
    }

    void notify_one() const noexcept {
        monitCond.notify_one();
    }

    template <typename Predicate>
    void wait(Predicate pred) const {                 // (10)
        std::unique_lock<std::mutex> monitLock(monitMutex);
        monitCond.wait(monitLock, pred);
    }
    
private:
    mutable std::mutex monitMutex;
    mutable std::condition_variable monitCond;
};

template <typename T>                                  // (1)
class ThreadSafeQueue: public Monitor {
 public:
    void add(T val){ 
        lock();
        myQueue.push(val);                             // (6)
        unlock();
        notify_one();
    }
    
    T get(){ 
        wait( [this] { return ! myQueue.empty(); } );  // (2)
        lock();
        auto val = myQueue.front();                    // (4)
        myQueue.pop();                                 // (5)
        unlock();
        return val;
    }

private:
    std::queue<T> myQueue;                            // (3)
};


class Dice {
public:
    int operator()(){ return rand(); }
private:
    std::function<int()> rand = 
    std::bind(std::uniform_int_distribution<>(1, 6), 
              std::default_random_engine());
};


int main(){
    
    std::cout << '\n';
    
    constexpr auto NumberThreads = 10;
    
    ThreadSafeQueue<int> safeQueue;                      // (7)

    auto addLambda = [&safeQueue](int val){ 
      safeQueue.add(val);                                // (8)
      std::cout << val << " "
        << std::this_thread::get_id() << "; "; 
    }; 
    auto getLambda = [&safeQueue]{ safeQueue.get(); };  // (9)

    std::vector<std::thread> addThreads(NumberThreads);
    Dice dice;
    for (auto& thr: addThreads) thr = 
      std::thread(addLambda, dice() );

    std::vector<std::thread> getThreads(NumberThreads);
    for (auto& thr: getThreads) thr = std::thread(getLambda);

    for (auto& thr: addThreads) thr.join();
    for (auto& thr: getThreads) thr.join();
    
    std::cout << "\n\n";
     
}

Der Kerngedanke des Beispiels ist, dass das Monitor Object in einer Klasse gekapselt ist und daher wiederverwendet werden kann. Die Klasse Monitor verwendet einen std::mutex als Monitor Lock und eine std::condition_variable [3] als Monitor-Bedingung. Die Klasse Monitor bietet die minimale Schnittstelle an, die ein Monitor Object unterstützen sollte.

ThreadSafeQueue in (1) erweitert std::queue in um eine threadsichere Schnittstelle. ThreadSafeQueue leitet sich von der Klasse Monitor ab und verwendet deren Mitgliedsfunktionen, um die synchronisierten Mitgliedsfunktionen add und get zu unterstützen. Die Mitgliedsfunktionen add und get verwenden das Lock des Monitors, um das Monitor Object zu schützen. Dies gilt insbesondere für die nicht thread-sichere myQueue. add benachrichtigt den wartenden Thread, wenn ein neues Element zu myQueue hinzugefügt wurde. Diese Benachrichtigung ist thread-sicher. Die Mitgliedsfunktion get (3) verdient mehr Aufmerksamkeit. Zunächst wird die wait-Mitgliedsfunktion der zugrunde liegenden Bedingungsvariablen aufgerufen. Dieser wait-Aufruf benötigt ein zusätzliches Prädikat, um sich vor lost und spurious wakeups (C++ Core Guidelines: Sei dir der Gefahren von Bedingungsvariablen bewusst [4]) zu schützen. Die Operationen zur Änderung der myQueue (4) und (5) müssen ebenfalls geschützt werden, da sie sich mit dem Aufruf myQueue.push(val) (6) überschneiden können. Das Monitor Object safeQueue (7) verwendet die Lambda-Funktionen in (8) und (9), um eine Zahl aus der synchronisierten safeQueue hinzuzufügen oder zu entfernen. ThreadSafeQueue selbst ist ein Klassen-Template und kann Werte eines beliebigen Typs aufnehmen. Einhundert Clients fügen der safeQueue 100 Zufallszahlen zwischen 1 und 6 hinzu (Zeile 7), während einhundert Clients diese 100 Zahlen gleichzeitig aus der safeQueue entfernen. Die Ausgabe des Programms zeigt die Zahlen und die Thread-IDs.

Mit C++20 kann das Programm monitorObject.cpp weiter verbessert werden. Zunächst füge ich den Header <concepts> ein und verwende das Concept std::predicate als eingeschränkten Template-Parameter in dem Funktions-Template wait (10). Das Concept std::predicate sorgt dafür, dass das Funktions-Template wait nur mit einem Prädikat instanziiert werden kann. Prädikate sind Callables, die als Ergebnis einen booleschen Wert zurückgeben.

template <std::predicate Predicate>
void wait(Predicate pred) const {
    std::unique_lock<std::mutex> monitLock(monitMutex);
    monitCond.wait(monitLock, pred);
}

Zweitens verwende ich std::jthread [5] anstelle von std::thread. std::jthread ist ein verbesserter std::thread in C++20, der automatisch in seinem Destruktor join aufruft, falls dies notwendig ist.

int main() {
    
    std::cout << '\n';
    
    constexpr auto NumberThreads = 100;
    
    ThreadSafeQueue<int> safeQueue;

    auto addLambda = [&safeQueue](int val){ 
      safeQueue.add(val);
      std::cout << val << " "
      << std::this_thread::get_id() << "; "; 
    }; 
    auto getLambda = [&safeQueue]{ safeQueue.get(); };

    std::vector<std::jthread> addThreads(NumberThreads);
    Dice dice;
    for (auto& thr: addThreads) thr = 
      std::jthread(addLambda, dice());

    std::vector<std::jthread> getThreads(NumberThreads);
    for (auto& thr: getThreads) thr = std::jthread(getLambda);
    
    std::cout << "\n\n";
     
}

Das Active Object [6] und das Monitor Object sind ähnlich, unterscheiden sich aber in einigen wichtigen Punkten. Beide Architekturmuster synchronisieren den Zugriff auf ein gemeinsames Objekt. Die Mitgliedsfunktionen eines Active Objects werden in einem anderen Thread ausgeführt, die Mitgliedsfunktionen des Monitor Objects jedoch im selben Thread. Das Active Object entkoppelt den Aufruf der Mitgliedsfunktionen besser von der Ausführung der Mitgliedsfunktionen und ist daher einfacher zu warten.

Fertig! Ich habe etwa 50 Artikel zu Entwurfsmustern [7] geschrieben. In meinen nächsten Artikeln werde ich über ein ziemlich unbekanntes Feature in C++17 schreiben, tiefer in C++20 eintauchen und den kommenden neuen C++-Standard C++23 vorstellen. Ich beginne diese Reise mit C++23. (rme [8])


URL dieses Artikels:
https://www.heise.de/-9195485

Links in diesem Artikel:
[1] https://www.dre.vanderbilt.edu/~schmidt/POSA/POSA2/
[2] https://heise.de/-8990744
[3] https://en.cppreference.com/w/cpp/thread/condition_variable
[4] https://heise.de/-4063822
[5] https://en.cppreference.com/w/cpp/thread/jthread
[6] https://heise.de/-9186720
[7] https://www.modernescpp.com/index.php/category/patterns
[8] mailto:rme@ix.de