Az ML programozási nyelv

A jocaml implementáció leírása.

Bevezető

A JoCaml rendszer az Objective Caml nyelv kísérleti kiterjesztése az elosztott join-kalkulus programozási modellel. Ez a modell magasszintű kommunikációs és szinkronizációs csatornákat, mobil ágenseket, leállás-detektálást és automatikus memória-kezelést tartalmaz. A JoCaml segítségével nagy elosztott alkalmazások gyorsan fejleszthetők, a programozók kihasználhatják mind az Objective Caml könnyű programozhatóságát és kiterjesztett könyvtárait, mind pedig a join-kalkulus elosztott és párhuzamos jellemzőit.

A JoCaml tehát egy elosztott funkcionális nyelv, ahol a számítási nyelv az Objective Caml, a vezérlő nyelv pedig a join-kalkulus.
A join-kalkulus egy kísérleti nyelv, ami a folyamat-algebrán alapul. Egyszerű támogatást biztosít a párhuzamos és elosztott programozáshoz. A join-kalkulus programozási modell jellemzői a több gépen futó párhuzamos folyamatok, a statikus típusellenőrzés, a globális lexikális láthatóság, az átlátszó távoli kommunikáció, az ágens-alapú mobilitás és bizonyosfajta hiba-detektálás.

A JoCaml nyelv tulajdonságai:



Folyamatok

Fontos, hogy egy JoCaml program a deklaráción és a kifejezésen kívül folyamatokat (process) is tartalmazhat. Ugyan a program alapvetően deklarációk és kifejezések sorozata, azonban mind a definíciók, mind a kifejezések – bizonyos jól meghatározott körülmények között – tartalmazhatnak folyamatokat. (Azonban a folyamat nem állhat „csak úgy magában”, tehát például a következő program szintaktikailag nem helyes: deklaráció;; kifejezés1;; folyamat;; kifejezés2;;) A folyamatok – a kifejezésekhez hasonlóan – egy számítást írnak le. Valójában a kifejezések és a folyamatok nagyban hasonlítanak egymásra. Azonban a kiértékelésben alapvető különbségek vannak:

Megjegyzés: Már a kiértékelésből is látszik, hogy a folyamatok leginkább a program párhuzamosításában hasznosak, egy szekvenciális programot minden további nélkül megírhatunk folyamatok nélkül is. (Objective Caml-ben nem is voltak folyamtok, ez csak a join-kalkulussal történő kibővítésénél került bele a nyelvbe.)

Kifejezés Folyamat
Mit ír le? A kifejezés egy számítás leírása. A folyamat is egy számítás leírása.
Van-e visszaadott érték? Ha igen, akkor mi? Egy kifejezés minden esetben visszaad egy értéket, mégpedig a számítás eredményét. A folyamatok sosem adnak vissza semmilyen értéket, ha a számításnak mégis lenne valamilyen eredménye, azt meg kell „semmisíteni”.
Milyen a kiértékelés? Szinkron: a rendszer teljesen végrehajtja a kifejezés kiértékelését, és csak azután lép tovább. Aszinkron: a rendszer egy új szálon elindítja a folyamat kiértékelését, majd – mivel visszatérési érték nincs – nem vár a befejezésére, hanem egyből megy tovább (így a folyamat kiértékelése és a program további része egymással párhuzamosan futhat).

A folyamatok tehát a program párhuzamosításában játszanak nagy szerepet. Párhuzamosan futó programoknál az egyik nagy kérdés mindig az, hogy a párhuzamos részek hogyan kommunikálnak egymással.

Csatornák

A csatorna-deklaráció egyetlen önálló nyelvi szerkezetben deklarál egy portot (amely fogadja a csatornára küldött üzeneteket) és egy ezeket figyelő folyamatot (őrzött folyamat). A folyamat azért „őrzött”, mert a végrehajtása csak akkor kezdődik meg, amikor a csatornára üzenet érkezik – a folyamat minden egyes üzenet érkezésekor végrehajtódik (kiértékelődik). Az üzenet több, vagy akár nulla értékből állhat, azonban minden csatornánál már a deklaráláskor meg kell mondani, hogy a csatorna hány és milyen típusú részekből álló üzenetet vár. A várt üzenetekhez formális paramétereket rendelünk, melyeket felhasználhatunk a csatorna őrzött folyamatában is, így a csatorna különböző üzenetekre különbözőképpen reagálhat. Amikor a csatornára üzenet érkezik, akkor a csatorna formális paraméterei felveszik az üzenetben kapott értéket, és az őrzött folyamat végrehajtása megkezdődik. Mivel folyamatról van szó, így a végrehajtás természetesen aszinkron lesz.

A háttérben minden csatornához tartozik egy várakozási sor, ahol a csatornára küldött, de még fel nem dolgozott üzenetek várakoznak. A várakozási sornak csak a memória mérete szab határt.

A csatornáknak két nagy csoportja létezik: szinkron csatorna és aszinkron csatorna (a név a csatornával történő kommunikáció típusára utal).

Aszinkron csatorna

Deklarálásának szintaxisa:
	let def csatornanév! paraméter = őrzött_folyamat

Megjegyzés: azt, hogy egy csatornát deklarálunk a def kulcsszó jelzi, a csatorna aszinkron voltát pedig a csatornanév után lévő „!” (ez a jelölés csak a csatorna-definícióban szerepel, a csatornára történő hivatkozáskor nem kell kiírni)



Az aszinkron csatornák tulajdonságai:

Példa:
Készítsünk egy olyan aszinkron csatornát, mely int típusú értékeket szállít, melyek értékét tüzeléskor kiírja a konzolra:

	# let def echo! x = print_int x;
	# ;;
	val echo : [[int]]

Megjegyzés: mivel a print_int i egy kifejezés, amely visszaadna egy értéket: (), ezért volt szükséges hozzáfűzni egy „;”-t, ami ezt elnyomja. A „print_int x” ugyanis egy kifejezés, míg a „print_int x;” egy folyamat.

Szinkron csatorna

Deklarálásának szintaxisa:
	let def csatornanév paraméter = őrzött_folyamat
A szinkron csatornák tulajdonságai: Példa: a feladat ugyanaz, mint az előbb (egy int típusú értékeket szállító csatorna, amely tüzeléskor kiírja az üzenet értékét a konzolra); azonban ez most szinkron csatorna lesz:
	# let def echo_szinkron x = print_int x; reply
	# ;;
	val print : int -> unit
Megjegyzés: látható, hogy a csatorna – miután kiírta az üzenet aktuális értékét a konzolra – a reply segítségével visszaad egy () értéket.

Szinkron csatornák és függvények

A szinkron csatorna leírásából – de legfőképpen a típusából – kiderül, hogy a szinkron csatornák elég közeli rokonságban vannak a függvényekkel. Ha a szinkron csatornák és a függvények típusát megnézzük, akkor rá kell jönnünk, hogy a két típus egy és ugyanaz (típus1 -> típus2). Ebből következően mindenütt, ahol szinkron csatornák szerepelhetnek, ott szerepelhetnek függvények is, és fordítva, ahol a fordító függvényt vár, ott lehet szinkron csatorna is (természetesen csak akkor, ha a paraméter és az eredmény típusa is egyezik).
Bár a szinkron csatornák és a függvények típusa ugyanaz, azonban a két fogalom több dologban is különbözik egymástól:

Függvény Szinkron csatorna
Több paraméter kezelése: A függvények a több paramétert a Curry-módszer szerint kezelik: a függvénynek igazából mindig csak egy paramétere van (az első), eredményül pedig egy (magasabbrendű) függvényt kapunk, ami majd kezeli a többi paramétert.
A függvény meghívásakor a paramétereket szóközzel elválasztva adjuk meg. Ha kevesebb paramétert adunk meg, akkor nem egy értéket (a függvény visszatérési értékét) kapunk, hanem egy magasabbrendű függvényt, amelynek értéke a meg nem adott paraméterektől függ.
A szinkron csatornáknak is igazából mindig csak egy paramétere van. Ha több paramétert szeretnénk, akkor azt egy összetett adatszerkezetben (konkrétan rendezett n-esben) kell megadni (ez jól látszik abból, hogy ha több paraméter van, akkor azokat kerek zárójelben vesszővel elválasztva kell megadni – pont úgy, ahogyan a rendezett n-eseket).
A csatornára történő üzenetküldéskor a paramétereket (mivel az egy rendezett n-es) szintén kerek zárójelben, vesszővel elválasztva kell megadni.
Ha a csatorna használatakor kevesebb paramétert adunk meg, akkor szintaktikus hibát kapunk.
Megjegyzés: ez a paraméterkezelés nem csak a szinkron csatorna sajátossága: az aszinkron csatornák is pontosan így kezelik az egynél több paramétert.
Érték visszaadása: Az érték visszaadása a függvényeknél implicit módon történik: a függvény visszatérési értéke automatikusan a függvénytörzs (ami egy kifejezés) értéke lesz. A szinkron csatornáknál az érték visszaadása explicit: a törzsben kötelez a reply érték szerkezet használata.

Csatornák használata

Üzenetküldés csatornára:
	csatornanév aktuális_paraméter

A paraméter megadása kötelező, ami mindig egy – a csatorna definíciójában meghatározott számú és típusú elemekből álló – rendezett n-es.
Mint arról korábban szó volt: az aszinkron csatornára történő üzenetküldés egy folyamat, míg a szinkron csatornára történő üzenetküldés egy kifejezés.

Folyamatok használata

Már több szó esett a folyamatokról (például a csatorna deklarálásakor egy folyamatot kell megadni, az aszinkron csatorna meghívása maga is egy folyamat, létre tudunk hozni folyamatokból álló szekvenciát és elágazást, stb.), azonban arról még nem, hogy ezeket hogyan lehet a programban használni. A program ugyanis alapvetően deklarációk és kifejezések listája, így ha „csak úgy” beleírnánk a programba, hogy például „echo 42”, akkor szintaktikus hibát kapnánk (hiszen ez se nem deklaráció, se nem kifejezés; ez egy folyamat). A folyamatok – a kifejezésekkel ellentétben – valójában nagyon korlátozott formában fordulhatnak csak elő a programban: például nem szerepelhetnek kötés jobb oldalán, nem adhatók át paraméterként (sem függvénynek, sem csatornának) stb.

A folyamatot ezért valamilyen módon „át kell alakítani” („be kell csomagolni”) egy kifejezésbe, ha ki szeretnénk értékeltetni.
A spawn folyamatpéldányból kifejezést állít elő, melynek kiértékelésekor egy konkurens folyamat indul el. A spawn { folyamat } tehát már egy kifejezés, amely elindítja a folyamat kiértékelését, majd azonnal visszatér egy () értékkel. A folyamat kiértékelése a program további részének kiértékelésével párhuzamosan történik.

Nézzük a következő programot:

	spawn { echo 1 } ;;
	spawn { echo 2 } ;;

A programban mind a két spawn csak elindította a folyamatok kiértékelését, így az echo 1 és az echo 2 folyamatok kiértékelése egymással párhuzamosan történik. Ezért előre nem tudható, hogy a fenti program 12-t, vagy 21-t fog kiírni.

A csatornák tulajdonságai

Párhuzamos végrehajtást a párhuzamos kompozíció operátorral ( „|” ) is el lehet érni. Ez egy bináris infix operátor, amely a megadott két folyamatot egymással párhuzamosan értékeli ki.

Szintaxis:

	folyamat1 | folyamat2 (eredményül egy folyamatot kapunk)
A következő példa szemantikailag ekvivalens az előzővel:
spawn { echo 1 | echo 2 } ;;
A párhuzamos kompozíció operátor tulajdonságai:

  1. A szinkron és az aszinkron csatornák típusa különböző. Ha rossz környezetben használjuk őket, akkor hibaüzenetet kapunk:
    	# spawn { echo_szinkron 1 } ;;
    	Characters 7-14:
    	Expecting an asynchronous channel, but receive int -> unit
    
  2. Mivel a csatornák elsőleges (first class) elemek, így küldhetők üzenetként egy másik csatornára (szinkronra és aszinkronra is), és a szinkron csatornáknál visszatérési értékként is használhatóak. Az olyan csatornát, amelynek (valamelyik) paramétere és / vagy értéke is csatorna magasabb-rendű csatornának hívjuk.
    A következő példa egy magasabb-rendű (szinkron) csatornát (twice) definiál, melynek paramétere és visszatérési értéke is csatorna: a paramétere egy egy-változós egy-értékű szinkron csatorna (vagy egy egy-változós egy-értékű függvény), visszatérési értéke pedig egy ugyanilyen típusú csatorna, amely azonban az üzeneteire kétszer is végre fogja hajtani a műveletet. (Fontos, hogy a bemeneti paraméter olyan csatorna vagy függvény legyen, amelynél a paraméter és eredmény ugyanolyan típusú.)
    	# let def twice f =
    	# let def r x = reply f (f x) in
    	# reply r
    	# ;;
    	val twice : ('a -> 'a) -> 'a -> 'a
    
  3. Az előző példán látszik, hogy a csatornák is – mint szinte minden más típus – polimorfak is lehetnek (tehát tartalmazhatnak típusváltozót – ugyanis JoCaml-ben csak paraméteres polimorfizmus van). A következő példán látható, hogy 'a típusváltozó egyszer int, máskor string:
    	# let def succ x = reply x+1 ;;
    	# let def double s = reply s^s ;;
    	#
    	# let f = twice succ in
    	# let g = twice double in
    	# print_int (f 0) ; print_string (g "X") ;;
    	val succ : int -> int
    	val double : string -> string
    	-> 2XXXX
    
  4. Érték korlátozás (value restriction): gyenge típusváltozó ('_a) használata. Az ilyen típusváltozó csak egyszer testesítődik meg (első használatkor eldől a tényleges típusa, utána már más típussal nem lehet használni).Például nézzük a következő példát:
    Definiálunk egy olyan csatornát, ami egy adott aszinkron port-nevet hív meg egy adott paraméterrel; a csatorna folyamata csak akkor értékelhető ki, ha mind a port-név, mind a paraméter adott.
    	# let def port! p | arg! x = p x ;;
    	val arg : <<'_a>>
    	val port : <<<<'_a>>>>
    

    Az őrzött folyamatból látszik, hogy a p egy tetszőleges paraméterű aszinkron csatorna és az x is tetszőleges típusú. Azonban a p paraméterének típusa az x típusával egyező kell, hogy legyen (ugyanis az x üzenetet küldjük el a p csatornára). Ezért a p és az x nem lehetnek tetszőleges típusúak, mert ez esetben a spawn { port echo | arg "szoveg"} is helyes lenne, ami nyilvánvalóan típushibás. (Ha mindkét típusban 'a típusváltozó szerepelne, akkor ez minden használatkor más típust vehetne fel, például az port echo folyamatban int lenne, míg az arg "szoveg" folyamatban string.)
    Éppen ezért, ha vagy a p, vagy az x típusa eldől, akkor ezt a típust le kell rögzíteni (az '_a gyenge típusváltozó minden előfordulásában felveszi az adott típust), így a másik résztvevő típusa is biztosítottan helyes típus lesz.
    Csatornák esetében gyenge típusváltozót akkor kapunk, amikor az egy definícióban szereplő port-nevek osztoznak a típusváltozón.

Kivételkezelés párhuzamos program esetében

A kifejezések kivételkezelése a try … kifejezés írásával lehetséges (folyamatokhoz nem kapcsolhatunk try-t). Ha a kivételt nem kezeljük le (vagy azért mert nem is tartozott hozzá try, vagy azért, mert ugyan volt try, de a kivételt nem tudta lekezelni), akkor a viselkedés attól függ, hogy a hívó vár-e a kiértékelés eredményére.

Megjegyzés: a folyamatok kiértékelésére a hívó sosem vár, de elképzelhető, hogy egy kifejezés kiértékelésére sem vár, például abban az esetben, ha a kifejezés egy folyamat része (pl. a kifejezés;folyamat szekvencia egy folyamat, mert a szekvencia utolsó tagja folyamat – ebben az esetben az összetett folyamat kiértékelésére a hívó nem vár, de a kiértékelés magában foglalja a kifejezés kiértékelését is).

  1. Aszinkron szálak kivételkezelése (a hívó nem vár a kiértékelés eredményére): a kivételt nem lehet elkapni, a rendszer kiírja a kivételt a standard output-ra, majd az aszinkron szál terminál. A kivétel semmilyen más szálat nem érint.
    Példa:
    	# spawn {
    	# { failwith "Kivetel"; print_string "Bye";}
    	# | { for i = 1 to 10 do print_string "-" done; }
    	# } ;;
    	-> Uncaught exception: Failure("Kivetel")
    	-> - - - - - - - - - -
    
    A példában a „failwith "Kivetel"; print_string "Bye";” és a „for i = 1 to 10 do print_string "-" done;” folyamatok egymással párhuzamosan futnak. Az első folyamat váltja ki a kivételt, melynek hatására ennek a résznek a kiértékelése abbamarad (látható, hogy a Bye már nem íródik ki), azonban ez a kivétel a többi párhuzamosan futó részt „nem zavarja” (a másik szál így gond nélkül ki tudja írni a kötőjeleit).
  2. Szinkron szálak kivételkezelése (a hívó vár a kiértékelés eredményére – ez csak kifejezés esetén lehetséges; de mint láttuk kifejezés esetén sincs mindig így): ha a kivételt nem kezeljük le, akkor a kivételt a hívó fogja megkapni (az, hogy utána mit kezd a kivétellel, az már csak rajta múlik).
    Példa:
    	# let def die () = failwith "die"; reply ;;
    	#
    	# try
    	# die ()
    	# with _ -> print_string "dead\n" ;;
    	val die : unit -> 'a
    	-> dead
    
    A failwith "die"; reply folyamat kiértékelésére a die szinkron csatornára üzenetet küldő kifejezés vár, így ő kapja a kivételt, amelyet le is kezel (kiírja, hogy dead).
  3. Egy egy rész kiértékelésére akár többen is várhatnak.Mi történik ebben az esetben, ki kapja a kivételt? A válasz: mindenki. Tehát, ha a számítás eredményére többen is várakoznak, akkor a kivétel többszöröződik, és az összes – a kivételt kiváltó rész kiértékelésére váró – szálra dobódik.
    Példa:
    	# let def a () | b () = failwith "die"; reply to a | reply to b ;;
    	#
    	# spawn {
    	# { (try a () with _ -> print_string "hello a\n"); }
    	# | { (try b () with _ -> print_string "hello b\n"); }
    	# } ;;
    	val b : unit -> unit
    	val a : unit -> unit
    	-> hello a
    	-> hello b
    
    Látszik, hogy a kivételt mind az a, mind a b csatornára üzenetet küldő kifejezés megkapta (és mindkettő le is kezelte).

Felelősök