Dienstag, 19. März 2013

STM mit Bankkonten

Clojure zeichnet sich durch ein besonderes Feature aus. Dieses Feature ist die STM (Software Transactional Memory). Die STM kann wie eine transaktionsgesteuerte In Memory Datenbank gesehen werden. Wie bei einer Datenbank ist es auch hier möglich, die Zugriffe auf Variablen innerhalb einer Transaktion laufen zu lassen. Die STM sorgt dafür, dass  bei Änderungen innerhalb einer Transaktion keine inkosistenten Datenkonstelationen entstehen. Lesezugriffe anderer  Funktionen sehen keine "halben" Änderungen der Daten.

Clojure bietet insgesamt 4 Möglichkeiten mit asynchronen Szenarien umzugehen.
  1. atom: Das Szenario für ein atom ist eine synchrone unabhängige Veränderung.
  2. ref: Das Szenario für refs sind synchrone abhängige Veränderungen.
  3. agent: Das Szenario für agents sind asynchrone unabhängige Veränderungen.
  4. vars: Spielen eine (meiner Meinung nach) Sonderrolle und sind für isolierte Veränderungen zu gebrauchen.
Der Blog Beitrag handelt nur die Option 2, die refs ab. Im nachfolgenden Szenario werden refs dafür verwendet um Bankkonten zu verwalten. Bei diesen Bankkonten sollen Beträge zwischen den Konten hin und her bewegt werden. Desweiteren werden in einer weiteren Liste die Aktivitäten zu den einzelnen Kontobewegungen mit protokolliert. Das spannende an dieser Aufgabe ist es, alle diese Aktivitäten sicher und ohne Kollisionen hin zu bekommen.

Aber schauen wir dazu lieber auf ein wenig Clojure Code.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
(ns account)

(use 'clojure.pprint)

;simpel record definition for an account
(defrecord Account [nr name ammount])

; a list for logging events
(def account-logs (ref ()))

(defn clear-account-logs []
  (dosync
    (ref-set account-logs ())))

(defn- base-transfer
  "Make a money transfer from to with an amount."
  [from to ammount]
  (alter from update-in [:ammount] - ammount)
  (alter to update-in [:ammount] + ammount))

(defn- base-transfer-log
  "Logs a money transfer."
  [from to ammount]
  (alter account-logs conj (str System.DateTime/Now " " ammount " " (:name @ from) "->" (:name @ to))))

(defn- create-safe-transfer [transfer-fn log-fn]
  (fn [from to ammount]
    (dosync
      (transfer-fn from to ammount)
      (log-fn from to ammount))))

; construct a transfer method
(def transfer (create-safe-transfer base-transfer base-transfer-log))

(defn many-transfers-sleep
  "Makes a money transfer from to with an ammount.
   The transfer will executed times count. 
   Between each one there is a sleep time in mili seconds"
  [from to ammount times sleep]
  (dotimes [x times]
    (System.Threading.Thread/Sleep sleep)
    (transfer from to ammount)
    ))

(defn status [& accounts]
  (let [accounts (apply vector accounts)]
    (pprint (count @account-logs))
    (pprint accounts)))

In der Zeile 1 geben wir den Namespace unserer Datei an. Zeile 3 enthält eine use Anweisung für die Clojure pprint Funktionen. Diese benutzen wir später um die Kontostände auszugeben.

In Zeile 6 definieren wir uns einen Record, der die Kontonummer(nr), Namen(name) und Kontostand(ammount) aufnimmt. Es geht bei dem Sample ja um die Demonstration der STM und nicht um echte Kontenbewegungen.

In Zeile 9 kommen wir das erste mal mit einen ref Datentype in Berührung. Hier wird die Variable account-logs angelegt. Der Initiale Wert dieser Referenz ist eine leere Liste. Achtung: "-" Zeichen in Clojure Variablen und Funktionsnamen sind erlaubt und durchaus üblich.

In Zeile 11 startet die Definition der Funktion clear-account-logs. Wichtig hier ist, dass die Manipulation eines Ref Typen nur innerhalb einer dosync Anweisung erfolgen darf. Versuche der Manipulation außerhalb einer dosync Anweisung sind nicht zulässig und werden von Clojure nicht ausgeführt. Mit der ref-set Funktion wird die account-logs Liste wieder auf eine leere Liste gesetzt.

In Zeile 15 beginnt die Funktion base-transfer. Die ersten beiden Parameter ("from" und "to") von base-transfer sind refs von Typen Account. "ammount" ist der Betrag der von den "from" Konto abgebucht und zum "to" Konto zu gebucht wird. Die Manipulation der refs hier erfolgt mit der alter Funktion. In der ersten alter Anweisung wird das "from" Konto ärmer (- ammount) und das "to" Konto reicher (+ ammount).

Zeile 21 enthält eine Funktion base-transfer-log. Die Funktion hat die selben Parameter wie base-transfer, aber hier wird nur die log Variable account-logs um einen Satz ergänzt. Dieses erfolgt in der Zeile 24 mit Hilfe der alter Funktion. Hier wird mit der conj Funktion die Liste entsprechend um eine Zeile pro Aufruf ergänzt. Das Format der Log Zeile wird mit der str Funktion zusammen gebaut.

Zeile 26 ist ein wenig tricky. Hierbei handelt es sich um einen Closure. Die Funktion create-safe-transfer hat als Rückgabe Wert eine neue Funktion. Diese neue generierte Funktion hat die Übergabe Parameter "from", "to" und "ammount". Innerhalb dieser neuen Funktion werden die beiden übergebenen Funktionen transfer-fn und log-fn innerhalb eines dosync Blocks aufgerufen und verarbeiten dabei die Übergabeparameter "from", "to" und "ammount". Das ist jetzt schon richtig tiefe funktionale Programmierung. Wir haben eine Funktion, die Funktionen als Übergabeparameter erwartet und selbst wieder eine Funktion als Rückgabewert liefert.

Alle 3 vorangegangenen Funktionen wurden mit defn- implementiert. defn- erzeugt eine in Namespace private Funktion. Ich möchte nicht, dass die 3 erzeugten Funktionen nach außen für andere Namespaces sichtbar werden. Die Funktionen base-transfer und base-transfer-log würden bei einem direkten Aufruf auch nur Fehler erzeugen, da der umschließende dosync Block fehlt.

In Zeile 33 benutzen wir nun unsere create-safe-transfer Funktion und generieren uns die transfer Funktion aus den beiden übergebenen Funktionen base-transfer und base-transfer-log.

Das interessante an dieser create Funktion ist die Erweiterbarkeit an dieser Stelle. Wir könnten uns die Transfer Funktion auch aus vier anstatt aus zwei übergebenen Funktionen konstruieren lassen. Mögliche Szenarien wären das Protokollieren und Vermerken von Buchungen, die größer als 10000 sind, oder das Überprüfen und Protokollieren, ob der Kontostand ins Negative geht. Anstatt von 2 hätte die create Funktion dann 4 Aufruf Funktionen, die innerhalb des dosync Blocks aufgerufen würden. An dieser Stelle könnte die create Funktion auch generisch umgeschrieben werden. Aber für dieses Sample soll es erst einmal so einfach bleiben.

Es folgen jetzt noch zwei weitere Funktionen, die nur dazu gebraucht werden um die STM auf Herz und Nieren zu prüfen.

Die Funktion many-transfers-sleep steuert funktional nichts mehr zur API bei, aber wird später bei den Sample dazu genutz, um Last auf das System zu geben. Diese Funktion hat die 3 üblichen Parameter "from", "to" und "ammount". Der vierte Parameter "times" bestimmt die Anzahl einer dotimes Blocks. "sleep" ist eine Wartezeit in Millsekunden zwischen den Aufrufen. Wir können jetzt einen Transfer hundert oder tausend mal wiederholen lassen.

Die letzte Funktion status in Zeile 45 gibt über die pprint Funktion die übergeben Accounts und die Anzahl der Logliste aus.

So, jetzt gilt es das ganze an der REPL auszuprobieren und zu testen.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
; use for our three used function
(use '[account :only (many-transfers-sleep clear-account-logs status)])

; two sample accounts
(def account1 (ref (account.Account. 1 "Thomas" 2000.0)))
(def account2 (ref (account.Account. 2 "Andrea" 500.0)))

; transfer 100 bucks 10 times and sleep 1000 mseconds between every transfer
(many-transfers-sleep account1 account2 100 10 1000)

;transfers via future
(clear-account-logs)

(future (many-transfers-sleep account1 account2 25 200 50))
(future (many-transfers-sleep account2 account1 31 200 60))
(future (many-transfers-sleep account1 account2 17 200 70))
(future (many-transfers-sleep account1 account2 8 200 80))
(future (many-transfers-sleep account2 account1 19 200 90))

(status @account1 @account2)

Wir können jetzt Stück für Stück unsere Funktion ausprobieren.

Mit der use Anweisung in Zeile 2 geben wir die 3 benutzen Funktionen bekannt.

In Zeile 5 und 6 legen wir uns zwei Test Konten an, um die Tests damit durchführen zu können.

In Zeile 9 haben wir einen exemplarischen Aufruf der Funktion many-transfers-sleep. Es werden von account1 nach account2 100 übertragen. Das ganze 10 mal mit jeweils 1000 Millisekunden (also 1 Sekunde) Pause. Das Ergebnis dieser Zeile ist leider ein wenig ernüchternd. Wenn diese Zeile an der REPL ausgeführt wird, wird many-transfers-sleep synchron auf den REPL Thread ausgeführt. Die REPL bleibt "hängen" und kommt nach 10 Sekunden wieder. OK, dafür brauchen wir keine STM.

Da müssen wir den Test also anders aufziehen. Um die STM wirklich auf Herz und Nieren zu prüfen, bedienen wir uns des future von Clojure. future ist in diesem Fall ein Makro, was die innenliegende Anweisung auf einen anderen Thread ausführt.

Schauen wir dazu exemplarisch auf die Zeile 14. Hier wird der Aufruf der Funktion many-transfers-sleep innerhalb des future ausgeführt. Wenn diese Zeile an der REPL ausprobiert wird, dann steht der REPL Thread sofort wieder zur Verfügung, so dass wir die nächste Anweisung tippen könnten. Die Anweisung innerhalb des future wird auf einen anderen Thread ausgeführt. Das ist jetzt schon interessanter.

Also, um das Ganze richtig zu testen, führen wir jetzt die Zeile 12 - 18 am Block aus. Was passiert hier? In Zeile 12 wird unsere Log Liste gelöscht. In den Zeilen 14 - 18 werden verschiedene Aufrufe von many-transfers-sleep asynchron abgesetzt. In den Zeilen 14,16 und 17 werden jeweils Beträge von account1 nach account2 transferiert. Diese sind 25, 17 und 8 (in Summe 50). In den Zeilen 15 und 18 geht es dann in die andere Richtung mit jeweils 31 und 19 (in Summe 50). Jeder dieser Transfers wird dann 200 mal ausgeführt. Da die transferierten Summen jeweils 50 hin wie zurück sind, ist das Ganze ein Nullsummen Spiel. Um die ganze Sache noch ein wenig unberechenbarer zu machen, sind die Wartezeiten (der letzte Parameter) ein wenig unterschiedlich. Nach dem Abschluss aller Transfers müssen die beiden Konten auf dem gleichen Stand sein wie vor dem Start der Anweisungen. Die Anzahl der Logeinträge muss genau 1000 sein.

Mit dem Aufruf der Status Funktion in Zeile 20 kann der Stand der Dinge jeweils abgefragt werden.
Also die Zeilen 12 bis 18 in die REPL pasten und ausführen. Dann alle 2 Sekunden die status Funktion ausführen, um zu beobachten, wie die Dinge sich entwickeln.



Jetzt kann natürlich experimentiert und getestet werden.
  • Änderung der Beträge
  • Änderung der Transferzeiten.
  • Änderung der Transferanzahl
  • Weitere Konten
  • u.s.w

Zusammenfassung

Ich für meinen Teil habe mit diesem Code rumgespielt, und versucht die STM auszuhebeln. Mir ist es nicht gelungen. Alle berechneten Werte waren absolut korrekt. Es ist schon beindruckend, wie einfach das arbeiten mit refs ist. Einfach den dosync Block um die Anweisung herum und das war's. Es sind genau solche Funktionalitäten, die Clojure zu einer eindrucksvollen Sprache machen.

Ich habe auf einen Github Eintrag verzichtet. Der komplette Quellcode ist innerhalb dieses Postings vorhanden.