Softwareentwicklung: Umgang mit Veränderung – das Thread-Safe-Interface

Das Thread-Safe-Interface ist ein bewährtes Muster, wenn die Synchronisation von Objekten die zentrale Herausforderung ist.

In Pocket speichern vorlesen Druckansicht 13 Kommentare lesen
Gleisanlagen in Maschen im Gegenlicht

Gleisanlagen in Maschen

(Bild: MediaPortal der Deutschen Bahn)

Lesezeit: 5 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. Im heutigen Artikel setze ich meine Reise mit Concurrency-Mustern fort und beschäftige mich mit Veränderung. Das Thread-Safe-Interface ist ein bewährtes Muster für das Synchronisieren von Objekten.

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

Die naive Idee, alle Mitgliedsfunktionen einer Klasse mit einem Lock zu schützen, führt im besten Fall zu einem Performanzproblem und im schlimmsten Fall zu einem Deadlock.

Folgender kleine Codeschnipsel hat ein Deadlock:

struct Critical{
    void memberFunction1(){
        lock(mut);
        memberFunction2();
    ...
}

void memberFunction2(){
        lock(mut);
        ...
    }

    mutex mut;
};

Critical crit;
crit.memberFunction1();

Der Aufruf von crit.memberFunction1 bewirkt, dass der Mutex mut zweimal gelockt wird. Der Einfachheit halber ist der Lock ein scoped lock. Hier sind die beiden Probleme:

  • Wenn lock ein rekursiver Lock ist, ist der zweite lock(mut) in der funktion memberFunction2 überflüssig.
  • Wenn lock ein nicht-rekursive Lock ist, führt der zweite lock(mut) in memberFunction2 zu undefiniertem Verhalten. Oft ist dies ein Deadlock.

Das Thread-Safe-Interface behebt beide Probleme.

Dies ist die einfache Idee des Thread-Safe-Interfaces:

  • Alle Schnittstellenfunktionen (public) verwenden ein Lock.
  • Alle Implementierungsfunktionen (protected und privat) dürfen kein Lock verwenden.
  • Die Schnittstellenfunktionen rufen nur Implementierungsfunktionen auf, aber keine Schnittstellenfunktionen.

Das folgende Programm zeigt die Verwendung des Thread-Safe-Interfaces:

// threadSafeInterface.cpp

#include <iostream>
#include <mutex>
#include <thread>

class Critical{

public:
    void interface1() const {
        std::lock_guard<std::mutex> lockGuard(mut);
        implementation1();
    }
  
    void interface2(){
        std::lock_guard<std::mutex> lockGuard(mut);
        implementation2();
        implementation3();
        implementation1();
    }
   
private: 
    void implementation1() const {
        std::cout << "implementation1: " 
                  << std::this_thread::get_id() << '\n';
    }
    void implementation2(){
        std::cout << "    implementation2: " 
                  << std::this_thread::get_id() << '\n';
    }
    void implementation3(){    
        std::cout << "        implementation3: " 
                  << std::this_thread::get_id() << '\n';
    }
  

    mutable std::mutex mut;                            // (1)

};

int main(){
    
    std::cout << '\n';
    
    std::thread t1([]{ 
        const Critical crit;
        crit.interface1();
    });
    
    std::thread t2([]{
        Critical crit;
        crit.interface2();
        crit.interface1();
    });
    
    Critical crit;
    crit.interface1();
    crit.interface2();
    
    t1.join();
    t2.join();    
    
    std::cout << '\n';
    
}

Drei Threads, darunter der Haupt-Thread, verwenden Instanzen von Critical. Dank des Thread-Safe-Interfaces sind alle Aufrufe der öffentlichen API synchronisiert. Der Mutex mut in Zeile (1) ist veränderbar und kann in der konstanten Mitgliedsfunktion interface1 verwendet werden.

Die Vorteile des Thread-Safe-Interfaces sind dreifach:

  • Ein rekursiver Aufruf eines Mutex ist nicht möglich. Rekursive Aufrufe eines nicht rekursiven Mutex sind in C++ undefiniertes Verhalten und enden in der Regel in einem Deadlock.
  • Das Programm verwendet minimales Locking und damit eine minimale Synchronisierung. Die Verwendung eines std::recursive_mutex in jeder Mitgliedsfunktion der Klasse Critical würde zu einer teuren Synchronisierung führen.
  • Aus der Sicht der User ist Critical einfach zu benutzen, weil die Synchronisierung nur ein Implementierungsdetail ist.

Jede Mitgliedsfunktion der Schnittstelle delegiert ihre Arbeit an die entsprechende Mitgliedsfunktion der Implementierung. Der indirekte Overhead ist ein typischer Nachteil des Thread-Safe-Interfaces.

Die Ausgabe des Programms zeigt die Verschachtelung der drei Threads.

Obwohl das Thread-Safe-Interface einfach zu implementieren scheint, gibt es zwei große Gefahren, die es zu beachten gilt.

Die Verwendung eines statischen Klassenmitglieds oder virtuelle Schnittstellen erfordern besondere Vorsicht.

Statische Mitglieder

Wenn deine Klasse ein statisches Mitglied hat, das nicht konstant ist, muss man alle Aufrufe von Mitgliedsfunktionen auf den Klasseninstanzen synchronisieren.

class Critical{
    
public:
    void interface1() const {
        std::lock_guard<std::mutex> lockGuard(mut);
        implementation1();
    }
    void interface2(){
        std::lock_guard<std::mutex> lockGuard(mut);
        implementation2();
        implementation3();
        implementation1();
    }
    
private: 
    void implementation1() const {
        std::cout << "implementation1: " 
                  << std::this_thread::get_id() << '\n';
        ++called;
    }
    void implementation2(){
        std::cout << "    implementation2: " 
                  << std::this_thread::get_id() << '\n';
        ++called;
    }
    void implementation3(){    
        std::cout << "        implementation3: " 
                  << std::this_thread::get_id() << '\n';
        ++called;
    }
    
    inline static int called{0};         // (1)
    inline static std::mutex mut;

};

Jetzt hat die Klasse Critical ein statisches Mitglied called (32), um zu zählen, wie oft die Implementierungsfunktionen aufgerufen wurden. Alle Instanzen von Critical verwenden das gleiche statische Mitglied und müssen daher synchronisiert werden. Seit C++17 können statische Datenelemente auch inline deklariert werden. Ein statisches Inline-Datenmitglied kann in der Klassendefinition definiert und initialisiert werden.

Virtualität

Wer eine virtuelle Schnittstellenfunktion überschreibt, sollte dafür sorgen, dass die überschreibende Funktion auch dann ein Lock verwendet, wenn sie privat ist.

// threadSafeInterfaceVirtual.cpp

#include <iostream>
#include <mutex>
#include <thread>

class Base{
    
public:
    virtual void interface() {
        std::lock_guard<std::mutex> lockGuard(mut);
        std::cout << "Base with lock" << '\n';
    }
    virtual ~Base() = default;
private:
    std::mutex mut;
};

class Derived: public Base{

    void interface() override {
        std::cout << "Derived without lock" << '\n';
    }

};

int main(){

    std::cout << '\n';

    Base* base1 = new Derived;
    base1->interface();

    Derived der;
    Base& base2 = der;
    base2.interface();

    std::cout << '\n';

}

Bei den Aufrufen base1->interface und base2.interface ist der statische Datentyp von base1 und base2 Base und somit ist interface zugänglich. Da die Mitgliedsfunktion interface virtuell ist, erfolgt der Aufruf zur Laufzeit über den dynamischen Typ Derived. Zum Schluss wird die private Mitgliedsfunktion interface der Klasse Derived aufgerufen.

Die Ausgabe des Programms zeigt den nicht synchronisierten Aufruf der Schnittstellenfunktion von Derived.

Es gibt zwei typische Möglichkeiten, dieses Problem zu lösen:

  • Die Schnittstellenfunktion wird zu einer nicht virtuellen Mitgliedsfunktion. Diese Technik heißt NVI (Non-Virtual Interface). Die nicht virtuelle Mitgliedsfunktion garantiert, dass die Schnittstellenfunktion der Basisklasse Base verwendet wird. Außerdem führt das Überschreiben der Schnittstellenfunktion mit override zu einem Kompilierfehler, da die Schnittstellenfunktion interface nicht virtuell ist.
  • Man deklariert die Mitgliedsfunktion interface als final: virtual void interface() final. Dank final führt das Überschreiben einer als final deklarierten virtuellen Mitgliedsfunktion zu einem Kompilierfehler.

Obwohl ich zwei Möglichkeiten vorgestellt habe, diese Herausforderung der Virtualität zu lösen, empfehle ich dringend, das NVI-Idiom zu bevorzugen. Wer Late Binding (Virtualität) nicht braucht, sollte Early Binding verwenden. Mehr über NVI steht in meinem Artikel: Pattern in der Softwareentwicklung: Die Template-Methode.

Guarded Suspension wendet eine andere Strategie zum Umgang mit Veränderung an. Sie signalisiert, wenn sie mit ihrer Veränderung fertig ist. In meinem nächsten Artikel werde ich genauer auf Guarded Suspension eingehen. (rme)