Softwareentwicklung – Umgang mit Veränderung: Locking
Locking ist eine klassische Methode, um einen gemeinsamen, veränderbaren Zustand zu schützen.
(Bild: rvlsoft/Shutterstock.com)
- Rainer Grimm
Patterns sind eine wichtige Abstraktion in der modernen Softwareentwicklung. Sie bieten eine klar definierte Terminologie, eine saubere Dokumentation und das Lernen von den Besten. Dieser Beitrag beschäftigt sich weiter mit den Concurrency-Mustern. Locking ist eine klassische Methode, um einen gemeinsamen, veränderbaren Zustand zu schützen. Heute stelle ich die beiden Varianten vor: Scoped Locking und Strategized Locking.
Mit Locking existiert eine einfache Idee, um einen kritischen Abschnitt zu schützen. Ein kritischer Abschnitt ist ein Teil des Codes, den ein Thread exklusiv verwenden muss.
Scoped Locking
Scoped Locking ist das Konzept von RAII, das auf eine Mutex angewendet wird. Scoped Locking ist auch als Synchronized Block und Guard bekannt. Der Kerngedanke dieses Idioms besteht darin, den Erwerb und die Freigabe von Ressourcen an die Lebensdauer eines Objekts zu binden. Wie der Name schon sagt, ist die Lebensdauer des Objekts "scoped". Scoped bedeutet, dass die C++-Laufzeit für die Objektzerstörung und damit für die Freigabe der Ressource verantwortlich ist.
Die Klasse ScopedLock
implementiert Scoped Locking.
// scopedLock.cpp
#include <iostream>
#include <mutex>
#include <new>
#include <string>
class ScopedLock{
private:
std::mutex& mut;
public:
explicit ScopedLock(std::mutex& m): mut(m){ // (1)
mut.lock(); // (2)
std::cout << "Lock the mutex: " << &mut << '\n';
}
~ScopedLock(){
std::cout << "Release the mutex: " << &mut << '\n';
mut.unlock(); // (3)
}
};
int main(){
std::cout << '\n';
std::mutex mutex1;
ScopedLock scopedLock1{mutex1};
std::cout << "\nBefore local scope" << '\n';
{
std::mutex mutex2;
ScopedLock scopedLock2{mutex2};
} // (4)
std::cout << "After local scope" << '\n';
std::cout << "\nBefore try-catch block" << '\n';
try{
std::mutex mutex3;
ScopedLock scopedLock3{mutex3};
throw std::bad_alloc();
} // (5)
catch (std::bad_alloc& e){
std::cout << e.what();
}
std::cout << "\nAfter try-catch block" << '\n';
std::cout << '\n';
}
ScopedLock
erhält seine Mutex per Referenz (1). Der Mutex wird im Konstruktor gelockt (2) und im Destruktor wieder freigegeben (3). Dank des RAII-Idioms erfolgt die Zerstörung des Objekts und damit auch die Freigabe des Mutex automatisch.
Der Geltungsbereich von scopedLock1
endet am Ende der main
-Funktion. Folglich wird mutex1
entsperrt. Das Gleiche gilt für mutex2
und mutex3
. Sie werden automatisch am Ende ihres lokalen Geltungsbereichs freigegeben (4 und 5). Bei mutex3
wird auch der Destruktor von scopedLock3
aufgerufen, wenn eine Ausnahme auftritt. Interessant ist, dass mutex3
den Speicher von mutex2
wiederverwendet, da beide die gleiche Adresse besitzen.
Scoped Locking hat die folgenden Vor- und Nachteile:
Vorteile:
- Robustheit, da der Lock automatisch erworben und freigegeben wird.
Nachteile:
- Das rekursive Locken einer
std::mutex
ist ein undefiniertes Verhalten und führt typischerweise zu einem Deadlock.
- Locks werden nicht automatisch freigegeben, wenn die C-Funktion longjmp verwendet wird; longjpm ruft keine C++-Destruktoren von scoped-Objekten auf.
C++17 unterstützt Locks in vier Varianten. C++ hat einen std::lock_guard
/ std::scoped_lock
für die einfachen und ein std::unique_lock
/ std::shared_lock
für die fortgeschrittenen Anwendungsfälle wie das explizite Locken oder Freigeben des Mutex. Mehr über Mutex und Locks steht in meinem Artikel "Locks".
Strategized Locking setzt gerne Scoped Locking an.
Strategized Locking
Angenommen, Code wie eine Bibliothek soll in verschiedenen Domänen auch nebenläufig verwendet werden. Um sicherzugehen, schützt man die kritischen Abschnitte mit einer Lock. Wenn die Bibliothek nun in einer Single-Thread-Umgebung läuft, entsteht ein Performanzproblem, weil ein teurer Synchronisationsmechanismus zum Einsatz kommt, der unnötig ist. In diesem Fall bietet sich Strategized Locking an: die Anwendung des Strategy Patterns auf das Locking. Das bedeutet, dass du deine Locking-Strategie in ein Objekt packst und es zu einer Komponente deines Systems machst.
Zwei typische Methoden zur Umsetzung von Strategized Locking sind Polymorphismus zur Laufzeit (Objektorientierung) oder Polymorphismus zur Compile-Zeit (Templates). Beide Wege verbessern die Anpassung und Erweiterung der Locking-Strategie, erleichtern die Pflege des Systems und unterstützen die Wiederverwendung von Komponenten. Die Implementierung des Strategized Locking Sperrens zur Laufzeit oder zur Compile-Zeit unterscheidet sich in verschiedenen Aspekten.
Vorteile:
Polymorphismus zur Laufzeit
- ermöglicht es, die Locking-Strategie zur Laufzeit zu konfigurieren, und
- ist für Entwickler, die einen objektorientierten Hintergrund haben, leichter zu verstehen.
Polymorphismus zur Compile-Zeit
- besitzt keinen Abstraktionsnachteil und
- besitzt eine flache Hierarchie.
Nachteile:
Polymorphismus zur Laufzeit
- benötigt eine zusätzliche Zeigerindirektion und
- kann eine tiefe Ableitungshierarchie haben.
Polymorphismus zur Compilezeit
- kann im Fehlerfall lange Fehlermeldungen erzeugen (das ändert sich mit Concepts wie
BasicLockable
in C++20).
Nach dieser theoretischen Diskussion werde ich das Strategized Locking in beiden Varianten implementieren. Das Strategized Locking unterstützt in meinem Beispiel keines, exklusives und geteiltes Locking. Der Einfachheit halber habe ich bereits vorhandene Mutexe verwendet.
Polymorphismus zur Laufzeit
Das Programm strategizedLockingRuntime.cpp
verwendet drei verschiedene Locking-Strategien.
// strategizedLockingRuntime.cpp
#include <iostream>
#include <mutex>
#include <shared_mutex>
class Lock { // (4)
public:
virtual void lock() const = 0;
virtual void unlock() const = 0;
};
class StrategizedLocking {
Lock& lock; // (1)
public:
StrategizedLocking(Lock& l): lock(l){ // (2)
lock.lock();
}
~StrategizedLocking(){ // (3)
lock.unlock();
}
};
struct NullObjectMutex{
void lock(){}
void unlock(){}
};
class NoLock : public Lock { // (5)
void lock() const override {
std::cout << "NoLock::lock: " << '\n';
nullObjectMutex.lock();
}
void unlock() const override {
std::cout << "NoLock::unlock: " << '\n';
nullObjectMutex.unlock();
}
mutable NullObjectMutex nullObjectMutex; // (10)
};
class ExclusiveLock : public Lock { // (6)
void lock() const override {
std::cout << " ExclusiveLock::lock: " << '\n';
mutex.lock();
}
void unlock() const override {
std::cout << " ExclusiveLock::unlock: " << '\n';
mutex.unlock();
}
mutable std::mutex mutex; // (11)
};
class SharedLock : public Lock { // (7)
void lock() const override {
std::cout << " SharedLock::lock_shared: " << '\n';
sharedMutex.lock_shared(); // (8)
}
void unlock() const override {
std::cout << " SharedLock::unlock_shared: " << '\n';
sharedMutex.unlock_shared(); // (9)
}
mutable std::shared_mutex sharedMutex; // (12)
};
int main() {
std::cout << '\n';
NoLock noLock;
StrategizedLocking stratLock1{noLock};
{
ExclusiveLock exLock;
StrategizedLocking stratLock2{exLock};
{
SharedLock sharLock;
StrategizedLocking startLock3{sharLock};
}
}
std::cout << '\n';
}
Die Klasse StrategizedLocking
besitzt ein Lock (1). StrategizedLocking
modelliert Scoped Locking und lockt daher den Mutex im Konstruktor (2) und gibt ihn im Destruktor (3) wieder frei. Lock
(4) ist eine abstrakte Klasse und definiert die Schnittstelle der abgeleiteten Klassen. Dies sind die Klassen NoLock (5), ExclusiveLock
(6) und SharedLock
(7). SharedLock
ruft lock_shared
(8) und unlock_shared
(9) auf seinem std::shared_mutex
auf. Jede dieser Locks hält einen der Mutexe NullObjectMutex
(10), std::mutex
(11) oder std::shared_mutex
(Zeile 12). NullObjectMutex
ist ein Noop-Platzhalter. Die Mutexe werden als mutable
deklariert. Daher sind sie in konstanten Mitgliedsfunktionen wie lock
und unlock
verwendbar.
Polymorphismus zur Compiletime
Die Template-basierte Implementierung ist der objektorientierten Implementierung sehr ähnlich. Anstelle einer abstrakten Basisklasse Lock
definiere ich das Concept BasicLockable.
Mehr Information über Concepts gibt es in meinen vorherigen Artikel: Concepts.
template <typename T>
concept BasicLockable = requires(T lo) {
lo.lock();
lo.unlock();
};
BasicLockable
verlangt von seinem Typparameter T,
dass er die Mitgliedsfunktionen lock
und unlock
implementiert. Folglich akzeptiert das Klassen-Template StrategizedLocking
nur Typparameter, die diese Einschränkung erfüllen.
template <BasicLockable Lock>
class StrategizedLocking {
...
Zum Schluss folgt die Template-basierte Implementierung.
// strategizedLockingCompileTime.cpp
#include <iostream>
#include <mutex>
#include <shared_mutex>
template <typename T>
concept BasicLockable = requires(T lo) {
lo.lock();
lo.unlock();
};
template <BasicLockable Lock>
class StrategizedLocking {
Lock& lock;
public:
StrategizedLocking(Lock& l): lock(l){
lock.lock();
}
~StrategizedLocking(){
lock.unlock();
}
};
struct NullObjectMutex {
void lock(){}
void unlock(){}
};
class NoLock{
public:
void lock() const {
std::cout << "NoLock::lock: " << '\n';
nullObjectMutex.lock();
}
void unlock() const {
std::cout << "NoLock::unlock: " << '\n';
nullObjectMutex.lock();
}
mutable NullObjectMutex nullObjectMutex;
};
class ExclusiveLock {
public:
void lock() const {
std::cout << " ExclusiveLock::lock: " << '\n';
mutex.lock();
}
void unlock() const {
std::cout << " ExclusiveLock::unlock: " << '\n';
mutex.unlock();
}
mutable std::mutex mutex;
};
class SharedLock {
public:
void lock() const {
std::cout << " SharedLock::lock_shared: " << '\n';
sharedMutex.lock_shared();
}
void unlock() const {
std::cout << " SharedLock::unlock_shared: " << '\n';
sharedMutex.unlock_shared();
}
mutable std::shared_mutex sharedMutex;
};
int main() {
std::cout << '\n';
NoLock noLock;
StrategizedLocking<NoLock> stratLock1{noLock};
{
ExclusiveLock exLock;
StrategizedLocking<ExclusiveLock> stratLock2{exLock};
{
SharedLock sharLock;
StrategizedLocking<SharedLock> startLock3{sharLock};
}
}
std::cout << '\n';
}
Die Programme strategizedLockingRuntime.cpp
und strategizedLockingCompileTime.cpp
erzeugen die gleiche Ausgabe:
Wie geht's weiter?
Guarded Suspension wendet eine andere Strategie zum Umgang mit Veränderung an. Es signalisiert, wenn die Veränderung vollzogen ist. In meinem nächsten Artikel werde ich genauer auf Guarded Suspension eingehen. (rme)