Nebenläufigkeit ist aus modernen Softwaresystemen nicht mehr wegzudenken. Sei es, um Performancesteigerungen durch paralleles Ausführen auf mehreren Prozessorkernen zu erzielen, oder weil das zugrundeliegende Problem inherent nebenläufig ist. In einem anderen Blogartikel haben wir uns bereits mit der Parallelisierung von Programmen bzw. Algorithmen beschäftig.

Heute soll es nun um echte Nebenläufigkeit gehen. Mit „echter Nebenläufigkeit“ meine ich, dass die Nebenläufigkeit nicht Mittel zum Zweck ist, sondern im zu lösenden Problem schon drinsteckt. Ein gutes Beispiel ist z.B. ein Webserver, der quasi gleichzeitig Anfragen von verschiedenen Clients beantwortet. Um einen solchen Webserver zu programmieren, ist Nebenläufigkeit ein gutes Modell: jeder Client wird als separater Thread programmiert und kann damit isoliert betrachtet werden.

Jeder der schon einmal ein nebenläufiges Programm geschrieben hat, weiß allerdings wie schwer es sein kann, Daten zwischen verschiedenen Threads auszutauschen: Race Conditions und Deadlocks sind oft die Folge. In diesem Artikel geht es um „Software Transactional Memory“, kurz STM, einer alternativen Technik zur Kommunikation zwischen Threads. Wir setzen in der Serverkomponente unseres Produkts CheckpadMED ausschließlich auf STM zur Kommunikation zwischen Threads und sind damit sehr zufrieden. STM ist sprachunabhängig und steht für eine Vielzahl von funktionalen Sprachen (z.B. Clojure, Haskell, OCaml, Scala) und anderen Sprache (z.B. C/C++, C#, Java) zur Verfügung. Funktionale Sprachen scheinen aber für den Einsatz von STM deutlich besser gerüstet zu sein, da dort unveränderbare Datenstrukturen und der Verzicht auf global-veränderbare Variablen Standard sind. In diesem Artikel zeigen wir, wie STM in Haskell funktioniert, die Konzepte sind aber in den anderen Sprachen sehr ähnlich und übertragbar.

Wie in der Einleitung motiviert, ist Nebenläufigkeit eine schöne Abstraktion um z.B. einen Webserver zu programmieren. Kompliziert wird es allerdings, sobald Zustand in‘s Spiel kommt (wie sooft). Wenn nämlich mehrere Threads auf einem geteilten Zustand operieren, kann es durch das gleichzeitige Modifizieren dieses Zustands zu Race Conditions und damit zu korrupten Daten kommen. Der übliche Weg, Race Conditions zu vermeiden und die Kommunikation zwischen verschiedenen Threads zu synchronisieren, sind Locks. Wenn man beim Einsatz von Locks allerdings nicht sehr vorsichtig ist, kommt es leicht zu Deadlocks, d.h. das Programm hängt. Jeder, der schon mal mit Locks gearbeitet hat, weiß wie schwierig es ist, Deadlocks und Race Conditions zu vermeiden bzw. zu debuggen.

Starten wir mit einem praktischen Beispiel. Wir wählen das Standardbeispiel, nämlich die Modellierung von Überweisungen zwischen Bankkonten. Dabei soll die wichtige Eigenschaft gelten, dass kein Geld verloren gehen kann: wenn wir einen Betrag N von Konto k1 auf Konto k2 übertragen, soll entweder der Betrag von k1 abgebucht und auf k2 aufgebucht werden, oder beide Konten sollen unverändert bleiben.

Diese Eigenschaft können wir direkt als Haskell-Code hinschreiben. Wir nehmen dazu zunächst an, dass ein Konto durch den Typen Account modelliert wird und dass es zwei Funktion deposit und withdraw zum Einzahlen bzw. Abbuchen gibt. Der Einfachheit halber zahlen wir nur ganze Beträge (Typ Int) auf Konten ein.

Die Funktion zum Überweisen von k1 auf k2 sieht dann so aus:

transfer :: Account -> Account -> Int -> IO ()
transfer k1 k2 amount =
    atomically (do deposit k2 amount
                   withdraw k1 amount)

Durch das atomically wird der übergebene Codeblock als atomar gekennzeichnet und das STM-Laufzeitsystem sorgt dafür, dass der Block entweder ganz oder gar nicht ausgeführt wird.

In der Datenbank-Welt kennt man ein solches Konzept unter dem Begriff Transaktion. Im obigen Beispielcode ist also der Block

               (do deposit k2 amount
                   withdraw k1 amount)

eine Transaktion.

Nun möchten wir auch noch den Typen Account sowie die Funktionen deposit und withdraw mit Leben füllen. In STM kommunizieren Threads über sogenannte Transaktionsvariablen. D.h. alle Daten, die von mehrere Threads verändert werden, müssen in eine Transaktionsvariable gepackt werden. Eine Transaktionsvariable hat den Typ TVar a, wobei a der Typ der Inhalts ist. Für unser Konto definieren wir also

type Account = TVar Int

Auf einer solche TVar stehen drei wichtige Operationen zur Verfügung:

newTVar   :: a -> STM (TVar a)      -- neue Transaktionsvariable anlegen
readTVar  :: TVar a -> STM a        -- Inhalt lesen
writeTVar :: TVar a -> a -> STM ()  -- Inhalt schreiben

Alle drei Operationen laufen also in der STM-Monade und diese drei Operationen sind (im wesentlichen) auch die einzigen Operationen, die in der STM-Monade zur Verfügung stehen. Damit wird bereits im Typsystem unterschieden zwischen normalen seiteneffektbehafteten Operationen (IO-Monade) und den transaktionellen Operation der STM-Welt. Die atomically-Funktion hat folgenden Typ:

atomically :: STM a -> IO a

Daraus sehen wir, dass Operationen auf Transkationsvariablen immer innerhalb eines atomically-Blocks ausgeführt werden müssen, das stellt das Typsystem sicher. Den einzigen Fehler, den ein Programmierer hier noch machen kann, ist eine falsche Einteilung des Programms in atomare Blöcke. Dies aber ist ein logischer Fehler, und solche Fehler kann ein Compiler oder ein Laufzeitsystem nur schwerlich verhindern.

So, mit diesem Wissen ausgestattet können wir nun die Operationen withdraw und deposit implementieren.

deposit :: Account -> Int -> STM ()
deposit k amount =
    do bal <- readTVar k
       writeTVar k (bal + amount)

withdraw :: Account -> Int -> STM ()
withdraw k amount = deposit k (- amount)

Damit haben wir nun mit STM ein ganz einfaches Bankkonto implementiert. Wenn nun die transfer-Funktion aus mehreren Threads aufgerufen wird, stellt das STM-Laufzeitsystem sicher, dass sich die Threads nicht in‘s Gehege kommen und dass somit alle Kontostände immer konsistent sind.

Wie aber sorgt das STM-Laufzeitsystem für diese schöne Eigenschaft? Um diese Frage und um weiterführende STM-Operationen zum Blockieren und zur Kombination von Transaktionen möchten wir uns im nächsten Artikel kümmern.

Zusammenfassend lässt sich sagen: STM ist eine Alternative zu Locks. Mit STM werden Codeblöcke als atomar gekennzeichnet und das Laufzeitsystem kümmert sich darum, dass so gekennzeichnete Blöcke auch wirklich atomar ausgeführt werden. Damit lassen sich Race Conditions und Deadlocks viel einfacher verhindern als dies beim Einsatz von Locks der Fall ist.

Ein weiterer Vorteil von STM gegenüber von Locks, der bisher unerwähnt geblieben ist: mit STM geschriebene Komponenten lassen sich sehr gut kombinieren, da sich atomare Blöcke aus verschiedenen Komponenten einfach zu größeren Blöcken zusammensetzen lassen. Wenn man also einmal darüber nachgedacht hat, wie die atomaren Blöcke innerhalb der Komponenten sein sollen, muss man mit STM nur noch darüber nachdenken, ob und wie es atomare Operationen über Komponentengrenzen hinaus geben muss.

Mit Locks ist das nicht so einfach. Hier muss man oft bei der Kombination zweier Komponenten das Locking-Protokoll aufbohren und neu durchdenken, um die Komponenten zusammenbringen zu können. Eine genauere Erklärung hierzu findet sich, wie auch das vorhergehende Beispiel, in Simon Peyton-Jones wunderschönem Artikel Beautiful Concurrency aus dem Buch Beautiful Code (O‘Reilly, 2007).

Ich freue mich über Rückmeldungen!