Patterns in der Softwarearchitektur: Monitor Object

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

In Pocket speichern vorlesen Druckansicht 3 Kommentare lesen
Fernglas

(Bild: Gemeinfrei (US Navy))

Lesezeit: 8 Min.
Von
  • Rainer Grimm
Inhaltsverzeichnis

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" 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

  • Thread-sicheres passives Objekt

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

  • Aufgrund des gleichzeitigen Zugriffs muss das gemeinsame Objekt vor nicht synchronisierten Lese- und Schreiboperationen geschützt werden, um Data Races zu vermeiden.
  • Die notwendige Synchronisierung sollte Teil der Implementierung und nicht Teil der Schnittstelle sein.
  • Wenn ein Thread mit dem gemeinsamen Objekt fertig ist, sollte eine Benachrichtigung ausgelöst werden, damit der nächste Thread das gemeinsame Objekt nutzen kann. Dieser Mechanismus verbessert die Performanz des Systems.
  • Nach der Ausführung einer Mitgliedsfunktion müssen die Invarianten des gemeinsam genutzten Objekts erhalten bleiben.

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.

  • Monitor Object: Das Monitor Object unterstützt eine oder mehrere Mitgliedsfunktionen. Jeder Client muss über diese Mitgliedsfunktionen auf das Objekt zugreifen, und jede Mitgliedsfunktion läuft im Thread des Clients.
  • Synchronisierte Mitgliedsfunktionen: Die synchronisierten Mitgliedsfunktionen sind die vom Monitor Object unterstützten Mitgliedsfunktionen. Zu einem bestimmten Zeitpunkt kann immer nur eine Mitgliedsfunktion ausgeführt werden. Das Thread-Safe-Interface hilft dabei, zwischen den Mitgliedsfunktionen, die die Schnittstelle darstellen (synchronisierte Mitgliedsfunktionen), und den Mitgliedsfunktionen, die die Implementierung darstellen, des Monitor Object zu unterscheiden.
  • Monitor Lock: Jedes Monitor Object verfügt über ein Monitor Lock, das sicherstellt, dass zu einem bestimmten Zeitpunkt höchstens ein Client auf das Monitor Object zugreifen kann.
  • Monitor-Bedingung: Die Monitor-Bedingung ermöglicht es separaten Threads, ihre Aufrufe von Mitgliedsfunktionen auf dem Monitor Object zu planen. Wenn der aktuelle Client mit dem Aufruf der synchronisierten Mitgliedsfunktionen fertig ist, wird der nächste wartende Client geweckt, um die synchronisierten Mitgliedsfunktionen des Monitor Object aufzurufen.

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

  • Der Client weiß nichts von der impliziten Synchronisierung des Monitor Object, und die Synchronisierung ist vollständig in der Implementierung gekapselt.
  • Die aufgerufenen synchronisierten Mitgliedsfunktionen werden schließlich automatisch eingeplant. Der Benachrichtigungs-/Wartungsmechanismus der Monitor-Bedingung verhält sich wie ein einfacher Scheduler.

Nachteile

  • Es ist typischerweise ziemlich schwierig, den Synchronisationsmechanismus der synchronisierten Mitgliedsfunktionen zu ändern, da die Funktionalität und die Synchronisation stark gekoppelt sind.
  • Wenn eine synchronisierte Mitgliedsfunktion direkt oder indirekt dasselbe Monitor Object aufruft, kann es zu einem Deadlock kommen.

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