Softwareentwicklung – Umgang mit Veränderung: Locking

Locking ist eine klassische Methode, um einen gemeinsamen, veränderbaren Zustand zu schützen.

In Pocket speichern vorlesen Druckansicht 10 Kommentare lesen

(Bild: rvlsoft/Shutterstock.com)

Lesezeit: 6 Min.
Von
  • Rainer Grimm
Inhaltsverzeichnis

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.

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

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

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.

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.

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:

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)