Softwareentwicklung: Patterns für nebenläufige Anwendungen
Im Bereich der Concurrency gibt es viele bewährte Muster. Sie befassen sich mit Synchronisierungsproblemen, aber auch mit Concurrency-Architekturen.
![Gleisanlagen in Maschen im Gegenlicht](https://heise.cloudimg.io/width/610/q85.png-lossy-85.webp-lossy-85.foil1/_www-heise-de_/imgs/18/4/1/5/0/2/7/6/DB156428-1f49771273f7b62a.jpeg)
Gleisanlagen in Maschen
(Bild: MediaPortal der Deutschen Bahn)
- Rainer Grimm
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. Im Bereich der Concurrency gibt es viele bewährte Muster. Sie befassen sich mit Synchronisierungsproblemen wie Teilen und Veränderung, aber auch mit nebenläufigen Architekturen.
Das Hauptproblem bei Concurrency ist der gemeinsam genutzte, veränderbare Zustand oder, wie Tony Van Eerd es in seinem Vortrag "Lock-free by Example" auf der CppCon 2014 ausdrückte: "Forget what you learned in Kindergarten (ie stop Sharing)". Ein wichtiger Begriff für Concurrency ist Data Race:
- Ein Data Race liegt vor, wenn mindestens zwei Threads gleichzeitig auf eine gemeinsame Variable zugreifen. Mindestens ein Thread versucht dabei, die Variable zu verändern. Wenn ein Programm ein Data Race hat, verhält es sich undefiniert. Das bedeutet, dass alle Ergebnisse möglich sind und der C++-Standard keine Zusicherung mehr gibt.
Eine notwendige Bedingung für ein Data Race ist ein veränderbarer, gemeinsamer Zustand. Wer das Teilen oder die Mutation beherrscht, vermeidet Data Races. Genau darum geht es bei den Synchronisationsmustern. Darüber hinaus befassen sich Klassiker wie das Active Object und das Monitor Object mit der Concurrent-Architektur.
Synchronisationsmuster
Der Schwerpunkt der Synchronisationsmuster liegt auf dem Umgang mit Teilen und Veränderung.
Umgang mit Teilen
Wenn man nicht teilt, können keine Data Races entstehen. Keine gemeinsame Nutzung bedeutet, dass dein Thread auf lokalen Variablen arbeitet. Dies kann durch einen kopierten Wert (Copied Value), die Verwendung von Thread-spezifischem Speicher oder die Übertragung des Ergebnisses eines Threads an die zugehörige Future über einen geschützten Datenkanal erreicht werden.
Copied Value
Wenn ein Thread seine Argumente per Kopie und nicht per Referenz erhält, muss der Zugriff auf die Daten nicht synchronisiert werden. Es gibt keine Data Races und Probleme mit der Lebensdauer geben.
Thread-spezifische Speicher
Thread-spezifische oder Thread-lokale Speicher ermöglicht es mehreren Threads, lokalen Speicher über einen globalen Zugriffspunkt zu nutzen. Durch die Verwendung des Speicherspezifizierers thread_local
wird eine Variable zu einer Thread-lokalen Variable. Das bedeutet, dass man die Thread-lokale Variable ohne Synchronisierung verwenden kannt.
Futures
C++11 bietet Futures und Promises in drei Varianten: std::async, std::packaged_tas
k und das Paar std::promise
und std::future
. Ein Future ist ein schreibgeschützter Platzhalter für den Wert, den der assoziierte Promise setzt. Aus Sicht der Synchronisierung ist die entscheidende Eigenschaft eines Paares aus Promise und Future, dass ein geschützter Datenkanal beide verbindet.
Umgang mit Veränderung
Wenn man nicht gleichzeitig Daten schreibt und liest, ist kein Data Race möglich. Dabei gilt es, zunächst die kritischen Abschnitte mit einem Lock wie Scoped oder Strategized Locking zu schützen. Im objektorientierten Design ist der kritische Abschnitt normalerweise ein Objekt, einschließlich seiner Schnittstelle. Das Thread-Safe-Interface schützt das gesamte Objekt. Bei dem Guarded-Suspension-Muster signalisiert der modifizierende Thread, wenn er mit seiner Arbeit fertig ist.
Scoped Locking
Scoped Locking ist die Idee von Ressourcenbelegung ist Initialisierung (Resource Acquisition Is Initialization, RAII), die auf eine Mutex angewendet wird. 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". Das bedeutet, dass die C++-Laufzeit für die Objektzerstörung und damit für die Freigabe der Ressource verantwortlich ist.
Strategized Locking
Strategized Locking ist die Anwendung des Strategiemusters auf Locks. Das bedeutet, dass die Locking-Strategie in ein Objekt gekapselt ist und zu einer Komponente des Systems wird.
Thread-Safe Interface
Das Thread-Safe-Interface eignet sich gut, wenn die kritischen Abschnitte Objekte sind. Die naive Idee, alle Mitgliedsfunktionen mit einer Lock zu schützen, führt im besten Fall zu einem Performanz-Problem und im schlimmsten Fall zu einem Deadlock.
Mit dem Thread-Safe-Interface werden beide Probleme überwunden. Hier ist die Idee:
- Alle (öffentlichen) Mitgliedsfunktionen der Schnittstelle müssen ein Lock verwenden.
- Alle Mitgliedsfunktionen der Implementierung (
protected
undprivate
) dürfen kein Lock verwenden. - Die Mitgliedsfunktionen der Schnittstelle rufen nur
protected
oderprivate
Mitgliedsfunktionen auf, aber keine öffentlichen Mitgliedsfunktionen.
Guarded Suspension
Die Grundvariante der Guarded Suspension kombiniert ein Lock und eine Vorbedingung, die erfüllt sein muss. Wenn die Vorbedingung nicht erfüllt ist, legt sich der aufrufende Thread schlafen. Der überprüfende Thread verwendet eine Sperre, um eine Race Condition zu vermeiden, die zu einem Data Race oder einem Deadlock führen kann.
Es gibt verschiedene Varianten:
- Der wartende Thread kann passiv über die Zustandsänderung benachrichtigt werden oder aktiv nach der Zustandsänderung fragen.
- Das Warten kann mit oder ohne Zeitbegrenzung erfolgen.
- Die Benachrichtigung kann an einen oder alle wartenden Threads gesendet werden.
Concurrent-Architektur
Das Active Object und das Monitor Object synchronisieren und koordinieren den Aufruf von Mitgliedsfunktionen. Der Hauptunterschied besteht darin, dass das Active Object seine Mitgliedsfunktion in einem anderen Thread ausführt, während sich das Monitor Object im selben Thread wie der Client befindet.
Aktive Object
The active object design pattern decouples method execution from method invocation for objects that each reside in their own thread of control. The goal is to introduce concurrency, by using asynchronous method invocation and a scheduler for handling requests. (Wikipedia:Active Object)
Monitor Object
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. (Pattern-Oriented Software Architecture: Patterns for Concurrent and Networked Objects)
Wie geht's weiter?
In meinem nächsten Artikel werde ich mich mit dem Synchronisationsmuster befassen und insbesondere über die Concurrency-Muster schreiben, die das Teilen von Daten adressieren. (rme)