Patterns in der Softwarearchitektur: Monitor Object
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."
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
Problem
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.
Komponenten
- 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 [2] 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.
- 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.
- 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.
- 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.
Vor- und Nachteile
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.
Beispiel
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.
Wie geht's weiter?
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
Copyright © 2023 Heise Medien