VI. Szálak használata

Bevezetés

A Java nyelv egyik legnagyobb elônye, hogy a többszálú programfuttatás technológiája a nyelv szerves részét képezi. Mint ismeretes, a szál (thread) nem más, mint egy önálló életet élô, soros végrehajtású programrészlet. Az éppen futtatott program több szálat is elindíthat, amelyek párhuzamosan futnak egymáshoz képest. A szálak azonos címtartományon belül operálnak, ami azt jelenti, hogy közösen használják a program által lefoglalt memóriát. A Java nyelvben a Thread osztály felhasználásával indíthatunk szálakat, amelyeket aztán a Thread és az Object osztályban definiált metódusokkal (például wait(), notify() vagy notifyAll) vezérelhetünk.

A Thread osztály

Ha egy új szálat akarunk indítani, akkor nem kell mást tennünk, mint gyártani egy objektumot a Thread osztály alapján. Mivel a szálnak nem lehet közvetlenül megmondani, hogy melyik metódust kezdje el végrehajtani, ezért két lépésben kell a szál elindítását végrehajtani. Elôször is létre kell hozni egy osztályt, amely tartalmazza a szállal végrehajtani kívánt kódrészletet, egy run() metódust és implementálja a Runnable interfészt. Az osztályból képzett objektum szolgál aztán a szálindító utasítás egyik lehetséges bementi paramétereként:

class Background implements Runnable
 method run()
 ...
 bkg = Background()
 aThread = Thread(bkg)

A szálakhoz neveket is rendelhetünk, amely megkönnyíti a hibakeresést. A szálak igény esetén csoportosíthatóak, amely megkönnyíti vezérlésüket (lásd a ThreadGroup osztályt). A Thread osztály leggyakrabban használt konstruáló metódusai:

Thread(target=Runnable, threadName=String "Név1") (létrehoz egy szálat a megadott névvel, amely az éppen aktuális szálcsoport tagja)

Thread(group=ThreadGroup, target=Runnable, threadName=String "Név2") (létrehoz egy szálat a megadott névvel, amely a megadott szálcsoport tagja lesz)

A szálak létrehozása és futtatása

A fentiek alapján létrehozott szál egészen addig nem fog semmit csinálni (idle), amíg a start() metódust meg nem hívjuk. A szál a start() kiadása után feléled, és végrehajtja a Thread objektum paramétereként megadott objektum run() metódusát. A start() metódust csak egyszer lehet egy szál életében meghívni. A szál egészen addig futni fog, amíg a run() metódus véget nem ér, vagy amíg meg nem hívjuk a stop() metódust. Jól demonstrálja a Thread osztály használatát az alábbi egyszerû példaprogram:

/* lecke06a.nrx */

h1 = Hello1("This is thread 1")
h2 = Hello1("This is thread 2")
Thread(h1, "Thread Test Thread 1").start()
Thread(h2, "Thread Test Thread 2").start()

/* a Hello1 osztály rendelkezik a Runnable felülettel */
 class Hello1 implements Runnable
 Properties inheritable
 message = String
 method Hello1(s = String)
 message = s
 method run()
 loop for 50
   say message
   Thread.currentThread().yield()
 end

Amint látható, elôször is létrehozunk két objektumot a Hello1 osztály alapján. Mivel ez az osztály rendelkezik a Runnable interfésszel, ezért alkalmas arra, hogy run() metódus alatt definiált kódrészletet (egy üdvözlôszöveg 50-szeres megjelenítését) szálakkal hajtassuk végre. Az objektumok definiálása után egy lépésben készítjük el a Thread objektumokat és hívjuk meg a start() metódusukat.

Mivel a Thread osztály maga is tartalmazza a Runnable interfészt, ezért megvan a lehetôség arra is, hogy a szálakat a Thread osztályból képzett alosztályok segítségével hozzuk létre. Az alábbiakban bemutatjuk az elôzô példaprogram ily módon megírt változatát:

/* lecke06a.nrx */

h1 = Hello2("This is thread 1")
h2 = Hello2("This is thread 2")
h1.start()
h2.start()

/* a thread osztály kiterjesztése */
 class Hello2 extends Thread
 Properties inheritable
 message = String
 method Hello2(s = String)
 super("Thread Test - Message" s)
 message = s
 method run()
   loop for 50
   say message
 do
 sleep(10)
 catch InterruptedException
 end
 end

A szálak vezérlése

A már említett start() metódus mellett rendelkezésre állnak még egyéb metódusok, amelyekkel a szálakat lehet menedzselni. A start() ellentéte a stop() metódus, amely leállítja a szál futását. Értelemszerûen csak egyszer lehet kiadni egy szál életében. Ha csak ideiglenesen akarjuk leállítani a szál futását, akkor a suspend() metódust használjuk. A suspend() párja a resume(), amely újraindítja a végrehajtást. Ha egy elôre ismert idôtartamra akarjuk a futást szüneteltetni, akkor azt a sleep() metódussal tehetjük meg. A metódusok meghívásához szükségünk van a manipulálni kívánt szál azonosítójára. Ha ezt nem adjuk meg, akkor az aktuális szál azonosítóját helyettesíti be a környezet. Amennyiben tudni akarjuk, hogy melyik az éppen aktuális szálobjektum, akkor azt a Thread.currentThread() metódussal kérdezhetjük le.

A szálak mindaddig futnak, amíg a run() metódusuk be nem fejezôdik, amíg meg nem hívjuk a stop() metódust, illetve addig amíg be nem következik egy lekezeletlen esemény (exception). A szálak tehát elvileg akkor is futhatnak tovább, ha az ôket indító alkalmazás már esetleg befejezôdött. A setDaemon() metódussal megjelölhetjük szálainkat, ha azt akarjuk, hogy a Java környezet automatikusan eltakarítsa ôket, ha már nem futnak más szálak, amelyeket még a szülô-alkalmazás indított el.

A szálak ütemezését a Java környezet végzi a szálakhoz rendelt prioritási értékek alapján. Ez azt jelenti, hogy mindig az a szál fut, amelyik a legmagasabb prioritással rendelkezik. A szálak prioritását menet közben a setPriority metódussal lehet változtatni. A prioritási értékek a Thread.MIN_PRIORITY és Thread.MAX_PRIORITY között változhatnak. Amennyiben több, azonos prioritással rendelkezô szál is jelentkezik futásra, akkor a környezet dönti el, hogy éppen melyik fusson. A legtöbb Java-motor (OS/2, Windows 9x, NT) az úgynevezett idôosztásos ütemezést használja. Ez azt jelenti, hogy az azonos prioritással rendelkezô szálak között a Java környezet folyamatosan váltogat, hogy a szálak mindegyike azonos idôszeleteket kapjon. A Solaris-ra írt Java környezet ugyanakkor round-robin ütemezést használ. Az ütemezés maga preemptive, ami azt jelenti, hogy a környezet abban a pillanatban felfüggeszti az alacsonyabb prioritású szálak futtatását, amint egy olyan szál jelentkezik futásra, amelynek magasabb a prioritása. Az éppen futó szál visszaadja a vezérlést a környezetnek, amennyiben a sleep(), suspend(), wait() vagy yield() metódusok valamelyikét meghívjuk. Ugyancsak megszakad a végrehajtás, amennyiben a szál I/O mûveletre várakozik. Figyeljük meg az elsô példaprogramban, hogy a végrehajtott metódus törzsében szerepel a Thread.currentThread().yield() utasítás! Ezzel azt érjük el, hogy a szál futása közben a vezérlés visszakerül az ütemezôhöz, így a nem idôosztásos rendszereken is biztosan processzoridôhöz jutnak az azonos prioritással rendelkezô szálak. Warp alatt például nyugodtan el is távolíthatnánk az említett sort, mivel az idôosztásos taktika miatt a másik szál is elegendô processzoridôhöz jutna. Ha viszont nem tudjuk, hogy milyen Java környezetben fog futni a programunk, akkor erre a problémára feltétlenül ügyelnünk kell!

Szinkronizáció

Amikor több szál ugyanazt az objektumot akarja használni, akkor az esetek többségében szinkronizációra lehet szükség. Ez azt jelenti, hogy a környezetnek gondoskodnia kell arról, hogy az objektumot egy idôben csak egy szál használhassa. Ezt a problémát a Java a többi nyelvhez hasonlóan oldja meg. Ha egy szál használ egy objektumot, akkor a környezet úgymond zárolja az objektumot, hogy más szálak ne férhessenek hozzá. Amikor a szál befejezi az objektum használatát, akkor a környezet feloldja a zárat és ezzel felszabadítja az objektumot. A zárral védendô objektumokat a protect kulcsszóval kell megjelölni. Ha például nem akarjuk, hogy egy metódust egyszerre több szál is használjon, akkor a definiáláskor egyszerûen megadjuk a protect kulcsszót a metódus neve után:

method zaroltMetodus() protect
...

Nemcsak a metódusok, hanem tetszôleges objektumok is védelmezhetôek a do protect ... end struktúra alkalmazásával:

do protect tetszoleges_objektum
   ...
end

A zárolás egyedül abban az esetben nem érvényes, amennyiben az objektumot zároló szál a zár feloldása elôtt újra használni akarja az objektumot (gondoljunk például egy rekurzív függvényhívásra), mivel ellenkezô esetben a szál végtelen várakozásba kezdene.

Különleges szerepe van a szálak szinkronizálásában a wait() és a notify() metódusoknak. Ha a wait() metódust egy zárolt blokkon belül használjuk, akkor a szál felfüggeszti mûködését, s csak akkor éled fel újra, ha egy másik, ugyanazt azt objektumot zároló blokkból kiadjuk a notify() parancsot. Arról van tehát szó, hogy a wait() és notify() metódusok segítségével úgymond üzenhetnek egymásnak az egy bizonyos védett objektumot közösen használó szálak. Amennyiben több szál is várakozik ugyanarra az objektumra, akkor a környezet dönti el, hogy melyik szál jut elsôként az objektumhoz. A NotifyAll metódussal igény esetén fel lehet ébreszteni az összes alvó szálat.

Tekintsük meg az alábbi példaprogramot, amely jól példázza az említett metódusok használatát! A program két szálból áll, amelyek egymással versengve manipulálnak egy várakozási sort (queue), amelyben a felhasználó által begépelt üzeneteket tároljuk. Az egyik szál (Producer) eltárolja, a másik (Consumer) pedig kiolvassa az üzeneteket. Ha azt akarjuk, hogy a queue kezelése rendben menjen, akkor le kell védenünk azokat a kódrészleteket, amelyek a várakozási sor írását illetve kiolvasását végzik:

/* lecke06c.nrx */

 -- ezt a szálat Consumernek nevezzük
 Thread.currentThread().setName("Consumer szál")

 -- elkészítjük a Producer szálat majd elindítjuk
 p = Producer() -- a Producer szál objektuma
 t = Thread(p, "Producer szál") 
 t.start()      -- elindítjuk a Producer szálat

 s = Rexx " "
 loop until s = "exit" -- az exit parancsra kilépünk a programból
    Thread.currentThread().sleep(5000) -- várunk 5 másodpercet
    s = p.readMessage()
    say "šzenet érkezett:" s
 end

 say "A program leállt"
 exit 0

 -- Producer osztály
 class Producer implements Runnable
 Properties constant
 MaxEntries = int 4  -- max 4 üzenetet tárolunk
 Properties inheritable
 queue = Vector()    -- a queue egy dinamikusan bôvülô tömb

 -- run metódus
 method run()
   getMessages()

 -- producer metódus
 method getMessages()
 loop forever
    say "Gépelje be üzenetét! (Eddig "queue.size() "üzenet van a queue-ban)"
    newMessage = ask
    do protect this -- a readMessage ellenében le kell védenünk
        -- ellenôrízzük, hogy tele van-e a queue
        loop while queue.size() == MaxEntries
            say "A queue megtelt!"
            wait()
            catch InterruptedException
        end
        queue.addElement(newMessage) -- új üzenet hozzáadása
        notify() -- figyelmeztetjük a readMessage-t 
    end
 end

 -- a readMessage metódus
 method readMessage() protect returns Rexx
 loop while queue.size() == 0
    wait()
    catch InterruptedException
 end
 s = Rexx queue.firstElement() -- kiolvassuk a queue elsô elemét
 queue.removeElement(s)        -- eltávolítjuk a kiolvasott elemet
 notify()                      -- figyelmeztetjük getMessage-t
 return s

A példaprogram elején nevet adunk a fôszálnak (Consumer), majd elindítjuk a Producer szálat, amely az üzenetek bekérését és queue-ba írását végzi. A fôszál ezek után egy ciklusban folytatódik, amely csak akkor fejezôdik be, ha a felhasználó üzenetként exitet gépel be. A ciklus egy öt másodperces várakozással kezdôdik, majd pedig meghívjuk a readMessage metódust, amely queue-kezelô metódus révén zárolással védett. Ha nincs üzenet a queue-ban, akkor a fôszál várakozik a beolvasó szál notify() üzenetére. A beolvasó szál (Producer) tulajdonképpen a getMessage metódust hajtja végre, amely egy végtelen hurokban olvassa be a felhasználó üzeneteit és tárolja el a queue-ban. Minden egyes új elem eltárolása után figyelmezteti a kiolvasó szálat az új üzenet érkezésére. Ha megtelik a queue (maximum 4 üzenet lehet benne) akkor a szál ezt jelzi, és átmegy várakozásba, amíg a kiolvasó szál nem közli vele, hogy kivett egy üzenetet a queue-ból. Ha négy alá csökken a queue-ban lévô üzenetek száma, akkor a szál újra fogadni kezdi a bevitt üzeneteket. Mint már említettük, az exit üzenetre a fôszál befejezi a mûködést és az exit utasítással lép ki. Az exit hatására környezet eltakarítja a memóriából mindkét szál nyomait.


REXX GYÍK:

K1. Miért van szükség a szálak használatára?
V1. Programozás közben sokszor szembesülünk olyan feladatokkal, amelyek megoldása viszonylag sok idôt vesz igénybe. Jó példa lehet erre egy számításigényes feladat vagy egy fájl letöltése az internetrôl. Mivel az esetek többségében nem akarjuk a felhasználót várakoztatni, ezért az idôigényes feladatok végrehajtását szálakra bízzuk. A felhasználó így tovább használhatja a program többi részét (például a felhasználói felületet), és azt fogja tapasztalni, hogy a program mindig reagál a parancsaira.

K2. Milyen elônye és hátránya van annak, hogy az azonos alkalmazáshoz tartozó szálak azonos címtérben futnak?
Az elôny az, hogy a szálak gyorsan indíthatóak és könnyen tudnak egymással kommunikálni. A hátrány az, hogy a szálak egymás mûködését károsan befolyásolhatják, hiszen a használt memória közös.


Gyakorlat:

1. Távolítsuk el az elsô példaprogramból a Thread.currentThread().yield() sort, majd futtassuk az így létrehozott programot Windows és Solaris alatt. Mi történik? Mi a magyarázat?

Kádár Zsolt
2000. 03. 13.
[ Elôzô lecke | Következô lecke | Tartalom ]