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.
- 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. 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.
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.
Ein 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 zweitelock(mut)
in der funktionmemberFunction2
überflüssig. - Wenn
lock
ein nicht-rekursive Lock ist, führt der zweitelock(mut)
inmemberFunction2
zu undefiniertem Verhalten. Oft ist dies ein Deadlock.
Das Thread-Safe-Interface behebt beide Probleme.
Das Thread-Safe-Interface
Dies ist die einfache Idee des Thread-Safe-Interfaces:
- Alle Schnittstellenfunktionen (
public
) verwenden ein Lock. - Alle Implementierungsfunktionen (
protected
undprivat
) 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 KlasseCritical
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.
Gefahren
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 mitoverride
zu einem Kompilierfehler, da die Schnittstellenfunktioninterface
nicht virtuell ist. - Man deklariert die Mitgliedsfunktion
interface
als final:virtual void interface() final
. Dankfinal
führt das Überschreiben einer alsfinal
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.
Wie geht's weiter?
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)