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.
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.
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.
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.
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.
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.