A Clojure programozási nyelv

Párhuzamosság

A Clojure nyelv számos lehetőséget biztosít a programozók számára párhuzamos programok írására. Párhuzamosság alatt ebben az esetben elsősorban a szálkezelést értjük, bár nincs kizárva, hogy a jövőben a Clojure is lehetőséget nyújt majd elosztott programok "natív" módon történő írására, addig azonban ilyen feladatok megoldására az adott platformon létező megoldások közül válogathatunk: JVM klaszterezésre alkalmas technológia például a Terracotta, ami 2009 óta Clojure JVM verziója alól is használható (a Keyword típus hashCode függvénye ekkor került implementálásra). A Clojure szálkezelését magasabb absztrakciós szint jellemzi, mint például a Java vagy a .NET platformokon elérhető API-két. Ennek célja elsősorban az, hogy az egymásra ortogonális funkcionális és párhuzamos paradigmát közös nevezőre hozza. A Clojure megoldásai elsőre szokatlannak és idegennek tűnhetnek, de könnyen megérthetőek a tervezési irányelvek, ha a tisztán funkcionális programozás irányából nézzük a kérdést: a hivatkozási helyfüggetlenség (referential transparency) nem lenne teljesíthető abban az esetben, ha egy változó értékét más szálak is gond nélkül felülírhatnák párhuzamosan, miközben egy adott függvény dolgozik azzal a változóval. Ezt a problémát a legtöbb imperatív programozási nyelv zárolással (lockolással) oldja meg, ez azonban újabb problémákhoz vezethet: felmerül a kiéheztetés és a holtpont (deadlock) lehetősége, és ugyan léteznek formális módszerek annak bizonyítására, hogy egy program mentes ezektől a tulajdonságoktól (pl. Owicki-Gries módszer), ezek a módszerek a gyakorlatban legtöbbször nem alkalmazhatók. Fontos megemlítenünk azt is, hogy bár - éppen a fenti, ad hoc jellegű zárolási logikák (locking patternek) elkerülése végett - léteznek bevett módszerek, tervezési minták, amik a zárolással foglalkoznak, de ezek csak nagy körültekintéssel alkalmazhatók, hiszen nem függetlenek a használt platform sajátosságaitól: az ún. double checked locking pattern egyes implementációi például a Java SE 5-ös verziója előtt hibásak, más platformokon ez nem feltétlenül okoz problémát. Látható, hogy a szálkezelés és a párhuzamosság nem egyszerű probléma, a Clojure azonban olyan megoldásokat kínál, amelyek leegyszerűsítik a programverifikációt, és lehetővé teszik zárolásmentes párhuzamos programok írását.

A Clojure négyféle ún. referencia típust (reference type) tartalmaz, amelyek párhuzamosság tekintetében négyféle viselkedést valósítanak meg. A terminológia megtévesztő, ebben az esetben nem a jól ismert érték típus/referencia típus megkülönböztetésről van szó, hanem arról, hogy ezek a referencia típusok közösek abban, hogy valójában mind objektumok, amik valamilyen adatot tárolnak. A tárolt adat természetesen kiolvasható, és meg is változtatható, az adathoz való hozzáférés és az adat felülírása azonban ezeken az objektumokon (a referenciákon) keresztül történik, típustól függően más és más módon. A négyféle referencia típus:

Ezek közül a Var-okkal már megismerkedhettünk a Speciális formák, vezérlési szerkezetek fejezet binding-okkal, értékadásokkal foglalkozó szekciójában. Valóban, a Var-ok azok a referenciák, amiket akkor is használunk, ha nem akarunk egyáltalán párhuzamos kódot írni. A továbbiakban bemutatjuk a másik három referencia típust.

Ref-ek

A Ref nevű referencia típus talán a legérdekesebb a Clojure referencia típusai közül. A Ref-ek a talán már más nyelvekből (pl. Concurrent Haskell) is ismert Szoftver Tranzakciós Memóriát használják annak érdekében, hogy lehetővé tegyék adatok koordinált változtatását - mindezt tranzakciók segítségével érik el.

Ref típusú változót például így vezethetünk be:

(def r (ref 0)) ; bevezetünk egy ref típusú változót, a kezdeti értéke 0

Látható, hogy valójában egy Var-t definiáltunk (a def speciális formával), de a Var egy Ref típusú objektumot tartalmaz. Így később bármikor hozzáférhetünk a Ref-ünk tartalmához az r nevű Var-on keresztül. Például visszakaphatjuk a Ref értékét (ami most 0), ha használjuk a deref formát vagy a @ reader makrót:

@r ; egyenértékű a (deref r)-rel: r értékét adja vissza (ami most 0)

Ez eddig még nem túl érdekes, hiszen még semmi párhuzamosságról nem volt szó. Kérdés az, hogy mit csinálhatunk ezzel a Ref-fel több szálból, párhuzamosan? Egyáltalán hogyan indíthatunk új szálat Clojure-ből? Írjunk tehát egy olyan függvényt, ami egy másik Clojure függvényt futtat másik szálon! Ehhez használnunk kell a java.lang csomagban lévő Thread osztályt, úgyhogy importáljuk is be! Definiáljunk egy külön névteret a projektünknek, legyen a neve mondjuk "refs":

(ns refs
  (:import [java.lang Thread]))

Ezek után már csak létre kell hoznunk egy Thread objektumot, és meghívni a start metódusát. Szerencsére minden Clojure függvény megvalósítja a Runnable interfészt, így egyszerű dolgunk van, csak át kell adnunk a paraméterként kapott függvényt a Thread konstruktorának:

(defn on-thread [f]     ; futtat egy függvényt külön szálon, mivel minden Clojure függvény
  (.start (Thread. f))) ; megvalósítja a Javás runnable interfészt

Ezzel minden együtt van ahhoz, hogy a Ref-ünkkel kísérletezgethessünk. Indíthatunk például egy külön szálat, ami 100-szor egymás után kiírja az r értékét, bizonyos késleltetéssel:

(on-thread                       ; új szálon
  #(dotimes [_ 100]              ; csináld 100-szor:
     (Thread/sleep 100)          ;    - várj 100 ms-ot
     (println "r értéke:" @r)))  ;    - írd ki r értékét

Most már csak azt kell tudnunk, hogyan változtathatjuk meg az r értékét. A ref-ek értékének megváltoztatására szolgál például az alter függvény. Íme egy példa az alter függvény használatára:

(alter r inc)

Látható, hogy az alter függvény két paramétert vár:

A frissítőfüggvény mindig megkapja a Ref régi értékét, és egy új értéket ad vissza: ez lesz a Ref-ünk új értéke. A fenti kódban tehát az inc függvény veszi r értékét (ami kezdetben 0), és kiszámolja belőle r új értékét: azaz az 1-et. Az alter függvény pedig gondoskodik róla, hogy az inc által visszaadott érték legyen r új értéke. Azonban ha lefuttatjuk a fenti kódot, hibát fogunk kapni. Miért?

A válasz az, hogy a Ref-ek értéke csak tranzakcióból változtatható: indítanunk kell tehát egy tranzakciót ahhoz, hogy a fenti kódot működésre bírhassuk. Tranzakciók indítására a dosync makrót használhatjuk:

(dosync
  (alter r inc))

A tranzakciók természetesen, ahogy a nevük sugallja, a Szoftver Tranzakciós Memória (STM) felügyelete alatt állnak. Az STM biztosítja a tranzakciókra:

Valamilyen szinten párhuzam vonható tehát az adatbáziskezelő rendszerek tranzakciós rendszere és az STM között. Az izolált végrehajtással kapcsolatban fontos megemlíteni még azt is, hogy a Clojure tranzakciókezelő rendszere ún. snapshot izolációt használ. A snapshot izoláció lényegében annyit jelent, hogy minden tranzakció nem közvetlenül a Ref-ekben tárolt adatokon dolgozik, hanem egy "pillanatképen", (ún. snapshot-on), amit a rendszer a tranzakció indulásakor "készít". A tranzakció futása közben végig ezeket az értékeket "látja", még akkor is, ha közben a Ref-ek valódi értékei már régen megváltoztak. Ez azt jelenti, hogy ilyenkor az adatok egyidejűleg több "verzióban" vannak jelen a memóriában - adatbáziskezeléses terminológiával ezt hívják úgy, hogy Multiversion concurrency control (MVCC).

Ez a megoldás kezdetben furcsa lehet, de ha jobban megvizsgáljuk, szükséges a funkcionális programozás szempontjából: egy tranzakción belül egy programozónak nem kell azon aggódnia, hogy kétszer kiolvasva ugyanazt a Ref-et, vajon mindkétszer ugyanazt az értéket kapja-e: adatbáziskezeléses szóhasználattal ezt a problémát "repeatable read"-nek nevezik, funkcionális programozásban pedig hivatkozási helyfüggetlenségként (referential transparency) emlegetjük - vegyük észre, hogy ez a két fogalom valójában ugyanazt a problémát takarja!

Azonban látható, hogy az STM által tárolt különböző verziójú Ref-eket egyszer csak "közös nevezőre" kell majd hozni. Mikor történik ez meg? A válasz a tranzakciókezelő rendszerekből ismerős commit/retry mechanizmusban rejlik. A Ref-ek snapshot-beli értékének "visszaírása" a tranzakció végén történik, az ún. commit művelet részeként. Ha a tranzakcióban hiba történt (például a Ref értéke megváltozott a tranzakció futása alatt), akkor a commit nem tud sikeresen lefutni, ilyenkor a tranzakció retry-ol: újra kiértékelődik a dosync kifejezés, és előről kezdődik az egész tranzakció. Legtöbbször azonban nincs értelme addig várni, míg lefut az egész tranzakció: a rendszer már az alter kifejezés kiértékelésekor el tudja dönteni, hogy a megváltoztatni kívánt Ref értékét más tranzakció felülírta-e már. Ilyenkor a tranzakció nem jut el a commit-ig sem, azonnal retry-ol. Próbáljuk ki tehát, mi történik, ha r értékét két külön szálból konkurens módon változtatjuk:

(on-thread                            ; új szálon
  #(dosync                            ; tranzakciót indítunk:
     (println "T1 alter előtt:" @r)   ;     - kiírjuk az r értékét alter előtt
     (alter r inc)                    ;     - frissítjük r értékét (megnöveljük)
     (println "T1 alter után:" @r)    ;     - kiírjuk az r értékét alter után
     (Thread/sleep 5000)              ;     - várj 5000 ms-ot
     (println "T1 vége")))            ;     - tranzakció vége

(on-thread                            ; másik szálon
  #(dosync                            ; másik tranzakciót indítunk:
     (println "T2 alter előtt:" @r)   ;     - kiírjuk az r értékét alter előtt
     (Thread/sleep 1000)              ;     - várj 1000 ms-ot
     (alter r inc)                    ;     - frissítjük r értékét (megnöveljük)
     (println "T2 alter után:" @r)    ;     - kiírjuk az r értékét alter után
     (println "T2 vége")))            ;     - tranzakció vége

Vajon milyen egy tipikus futtatás, ha a fenti három szálat (a fenti kettőt + a kiíró szálat) elkezdjük kiértékelni egy REPL-ben? Az alábbiakban megmutatjuk a konzolos output-ot, magyarázatokkal kiegészítve:

    T1 alter előtt: 0
    T1 alter után: 1    <- T1 itt frissíti r értékét, de még nem commitolt
    T2 alter előtt: 0   <- T2 ezért még a régi értéket látja!
    r értéke: 0         <- és a külső kiíró szál is...
    r értéke: 0
    r értéke: 0
    r értéke: 0
    r értéke: 0
    r értéke: 0
    r értéke: 0
    r értéke: 0
    r értéke: 0
    r értéke: 0
    r értéke: 0
    T2 alter előtt: 0  <- T2 megpróbálta frissíteni r értékét, de látja, hogy egy másik tranzakció, ami még nem commitolt, már frissítette r értékét 
                        => így ezt az értéket elavultnak tekinthetjük, és T1 retry-ol (újra meghívódik) 
    r értéke: 0
    r értéke: 0
    r értéke: 0
    r értéke: 0
    r értéke: 0
    r értéke: 0
    r értéke: 0
    r értéke: 0
    r értéke: 0
    r értéke: 0
    r értéke: 0
    T2 alter előtt: 0 <- T2 ismét retry-olt 
    r értéke: 0
    r értéke: 0
    r értéke: 0
    r értéke: 0
    r értéke: 0
    r értéke: 0
    r értéke: 0
    r értéke: 0
    r értéke: 0
    r értéke: 0
    r értéke: 0
    T2 alter előtt: 0 <- és ismét...
    r értéke: 0
    r értéke: 0
    r értéke: 0
    r értéke: 0
    r értéke: 0
    r értéke: 0
    r értéke: 0
    r értéke: 0
    r értéke: 0
    r értéke: 0
    r értéke: 0
    T2 alter előtt: 0 <- és ismét.....
    r értéke: 0
    r értéke: 0
    r értéke: 0
    r értéke: 0
    r értéke: 0
    r értéke: 0
    T1 vége          <- T1 végre befejezte, sikeresen commitolt
    r értéke: 1      <- r új értéke a commit után rögtön látható bármelyik szálból
    r értéke: 1
    r értéke: 1
    r értéke: 1
    T2 alter előtt: 1 <- a retry-oló T2 már a friss értékkel dolgozhat
    r értéke: 1
    r értéke: 1
    r értéke: 1
    r értéke: 1
    r értéke: 1
    r értéke: 1
    r értéke: 1
    r értéke: 1
    r értéke: 1
    r értéke: 1
    T2 alter után: 2 <- így ő is le tud futni most már gond nélkül 
    T2 vége
    r értéke: 2 <- ezután már nincs változás
    ...

A fenti kód, bár nem túl életszerű, jól bemutatja a Ref-ek működését. Valós kódban ilyen tranzakciókat természetesen nem túl célszerű írni; itt ugyan szemléltetés szempontjából használtuk a konzolos kiíratást, képzeljük el azonban, mi történne, ha a tranzakciókat naplóznánk valahová, például szövegfájlba, vagy az eseménynaplóba, és mindezt a dosync kifejezés törzsében tennénk! Mivel a tranzakciók ismételhető műveletek (retry-olnak, ha hiba van), a naplófájljainkat hamar teleszemetelnénk fölösleges bejegyzésekkel. A figyelmetlenségek elkerülése végett a Clojure nyelvben valamennyi input-output műveletet célszerű az io! makróval elvégezni, ami kivételt dob abban az esetben, ha éppen egy tranzakción belül lennénk.

Agent-ek

Az Agent-ek olyan referencia típusok, amik lehetővé teszik aszinkron függvényhívások megvalósítását. Agent létrehozására az agent függvény szolgál, a dereferentálás pedig teljesen ugyanúgy néz ki, mint Ref-ek esetében (az API ezen része minden referencia típusra egységes):

(def my-agent (agent [])) ; => agent típusú változó bevezetése, benne egy üres vektort: [] tárolunk.
@my-agent ; (deref my-agent) is lehetne

Agent-ek esetében az általuk tárolt érték megváltoztatását üzenetküldéssel érhetjük el: "üzenet" lehet egy tetszőleges Clojure függvény, ami legalább egy paramétert vár: az Agent régi értékét, és egy értékkel tér vissza: az Agent új értékével. Például egy lehetséges üzenetfüggvény definíciója:

(defn update-message [old-agent item]  ; old-agent: az ágensben tárolt régi érték, item: további paraméter
   (Thread/sleep 2000)         ; alszik 2 mp-et
   (conj old-agent item))      ; "hozzárakja" az elemet a régi vektorhoz

Ezek után az üzenetet a send függvény segítségével küldhetjük el az Agent-nek. A send függvény a következő paramétereket várja:

Az üzenetek aszinkron voltát jól mutatja a következő kód:

(do
  (send my-agent update-message "foo")  ; belerakjuk a "foo" stringet, de nem várja meg míg lefut a 2 mp-es sleep
  (send my-agent update-message "bar")  ; továbbmegy, és küld egy üzenetet "bar"-ral is
  (println @my-agent)                   ; ekkor még egyik sem futott le, ezért itt: [] -> még üres a vektor 
  (Thread/sleep 5000)                   ; megvárjuk míg lefutnak
  (println @my-agent))                  ; itt már: [foo bar]

Az Agent-ek speciális tulajdonsága, hogy abban az esetben, amikor egy üzenetfüggvényben kivétel lép fel, a kivételt nem kötelező lekezelnünk: ilyenkor az Agent egy speciális, ún. hibaállapotba megy át.

Atom-ok

Ha megértettük a Ref-ek működését, az Atom-ok megértése sem fog nehézséget okozni. A legfőbb különbség köztük, hogy az Atom-ok a Ref-ekkel szemben nem használják az STM-et, így tranzakciókra sincs szükség az Atom-ok által tárolt értékek megváltoztatására. Mivel az Atom-ok nem biztosítják a tranzakcionalitást, a Ref-ek által biztosított atomicitás, izoláció, konzisztencia hármasból csak az atomicitás és az izoláció marad.

Atomokat az atom függvénnyel tudunk létrehozni, az értékét pedig a már megismert dereferentálás művelettel kérhetjük le:

(def my-atom (atom 42)) ; definiálunk egy atom típusú "változót"                           
@my-atom                ; ez lehetne (deref foo) is

Az Atomok értékét a swap! függvénnyel tudjuk megváltoztatni. Ennek két paramétere van:

A frissítőfüggvényre ugyanazok érvényesek, mint a Ref-ek esetén: a frissítőfüggvény megkapja az atom régi értékét, és kiszámítja belőle az újat. A használata tehát lényegében ugyanúgy néz ki, ahogy a Ref-ek esetén az alter függvényt használjuk:

(swap! my-atom inc)

Már csak az a kérdés, hogy hogyan biztosítják az Atomok az izolációt? Erre a swap! függvény működése ad választ: a swap! függvény a futásának végén megpróbálja felülírni az Atom értékét a frissítőfüggvény által kiszámított új értékre. Ha ekkor a swap! azt látja, hogy a frissíteni kívánt Atom értéke a swap! kiértékelésének kezdete óta megváltozott (egy másik szálból felülírták az értékét, amíg a frissítőfüggvény dolgozott), akkor a swap! megismétli a műveletet: ismét meghívja a frissítőfüggvényt, de már a friss értékkel. Ezt egészen addig csinálja, amíg a swap! végén, a visszaírás előtt nem lát inkonzisztenciát, konfliktust az adatok között. Legvégül pedig az Atom értékének felülírása szálbiztos módon, Compare-And-Set (CAS) szemantikával működik.

future-ök

A Clojure a négy alap referencia típuson kívül más konstrukciókat is kínál, amelyeket igénybe vehetünk párhuzamos programok írásakor. Ilyenek az alábbiakban bemutatott future-ök és promise-ok is.

Az aszinkron függvényhívások megvalósítására már megismertünk egy eszközt: ezek voltak az Agent-ek. Az Agent-ek mellett a future-ök is használhatók hasonló célokra, egy tetszőleges kifejezés azonnali kiértékelését halaszthatjuk el "későbbre". Lássuk például a következő kódot:

(let [f (future
          (dotimes [i 10]       ; előbb elszámolunk 0..9 -ig
            (Thread/sleep 500)
            (println i))
          42)]                  ; és csak aztán adjuk vissza a visszatérési értéket, a 42-t
  (println "számolunk...")
  (println @f))  ; ez a hívás addig blokkol, amíg f értékét ki nem számoltuk

Látható, hogy a future makró egy ún. future objektummal tér vissza; a future makró pedig szemléletesen azt jelenti, hogy a makróban megadott kifejezés értéke "valamikor majd" ki lesz számolva: lehet hogy nem külön szálon, de az is lehet, hogy igen. Ha szükségünk van a future objektum értékére, akkor a megszokott dereferentálás (@ vagy deref) műveletet használjuk. Ez egy blokkoló hívás lesz abban az esetben, ha a future objektum értéke éppen még nem került kiszámításra.

promise-ok

A promise-ok hasonló célt szolgálnak, mint a future-ök (egy tetszőleges szimbólum kiértékelését halaszthatjuk el velük "valamikorra"), de teljesen máshogy viselkednek: egyrészt, a future-öknél láttuk, hogy már egy future kifejezés megadásakor rögtön meg kell mondanunk, mi is az a kifejezés, aminek a kiértékelését "halogatjuk", a promise-ok esetében épp ellenkezőleg: a promise-ra tekintsünk úgy, mint egy helyőrzőre, a jelentése szemléletesen: "majd valamikor később adok neki értéket".

Tehát, ha definiálok egy promise-t a következőképpen:

(def my-promise (promise))

Akkor később adhatok neki értéket:

(deliver my-promise 42)

Az értékét ezután kiírhatom a megszokott dereferentálás művelettel:

(println @my-promise)

Ez egy blokkoló kiértékelést jelent abban az esetben, ha a promise még nem kapott értéket a deliver függvénnyel. Lássunk egy összetettebb példát!

(let [p (promise)    ; promise, azaz: "majd később adok neki értéket"
      f (future      ; future, azaz: "kezdd el kiszámolni, akár külön szálon is"
          (Thread/sleep 2000)
          (deliver p 42))]     ; értéket adunk a promise-nak
  (println "kiiras...")
  (println @p)) ; => amíg a promise nem kapott értéket, addig ez a kiértékelés blokkolódik!

A fenti példában egy külön szálon adunk értéket a promise-nak, és a fő, kiíró szál egészen addig blokkolódik, amíg ez az értékadás (a deliver hívás) ki nem értékelődik.

Látszik tehát, hogy a promise-ok igen hasznosak tudnak lenni néha, egyik nagy hátrányuk azonban, hogy nem mentesek a holtponttól. Tekintsük a következő egyszerű programot, ami az étkező filozófusok probléma egy leegyszerűsített változata:

(def fork1 (promise))  ; "majd adok a fork1-nek értéket..."
(def fork2 (promise))  ; "majd adok a fork2-nek értéket..."

(future  
  (do 
    (println @fork1)          ; írd ki a fork1 értékét,
    (deliver fork2 "Kant")))  ; majd ha kiírtad, a fork2-nek csak ezután adunk értéket 

(future
  (do 
    (println @fork2)               ; írd ki a fork2 értékét,
    (deliver fork1 "Aristurtle"))) ; és ha kiírtad, a fork1-nek csak eztán adunk értéket

Látszik, hogy promise-ok segítségével kialakíthatók körkörös függőségek. Szerencsére nem annyira rossz a helyzet, mint az imperatív nyelvek esetében: a control flow egy adott ágán végighaladva a holtpont mindenképpen determinisztikus lesz (vagy terminál a program, vagy nem), függetlenül az ütemezőtől, így írhatunk rá teszteket.

Érdemes megjegyezni, hogy a négy referenciatípus közös tulajdonsága, hogy eseménykezelőket rendelhetünk hozzá. Ez olyan függvény, ami akkor fog lefutni, amikor a referencia értéke megváltozik.