7. gyakorlat¶
Párhuzamosítás¶
Az eddig bemutatott projektek esetén a programok egy szálon futottak szekvenciálisan. Ez azt jelenti, hogy a végrehajtás "sorról sorra" történik (a fordítás optimalizációit és a JVM trükkjeit figyelmen kívül hagyva).
A Java nyelv egy többszálú programozási nyelv, ami azt jelenti, hogy olyan programok készíthetők a segítségével, melyek egyszerre több szálon futnak. Mivel a legtöbb mai számítógép (még a beágyazott rendszerek is) támogatja ezt a futtatási módszert, így kihasználható a nyelv adta lehetőség.
A párhuzamos programozás több kérdést vet fel, melyek egy része a közös erőforrás használatáról és a szálak közötti szinkronizációról szólnak. Ezek egy részével a kurzus előadásán és Operációs rendszerek kurzuson találkoztak a hallgatók. Jelen gyakorlat főleg a JavaFX-ben alkalmazható megoldásokkal foglalkozik.
Maximumkeresés párhuzamosítása¶
A következő példa szemlélteti a naiv maximumkeresést egy konzolos alkalmazásban.
Az alkalmazás létrehozása (Programozás-I ismétlés):
- IntelliJ IDEA: File - New Project
- Java (nem kell kiválasztani semmilyen frameworkot) - Next
- Create project from template - Command Line App
- A projekt helye és a csomag tetszőleges (pl. Asztal,
hu.alkfejl.max
)
Amennyiben a fenti lépések alapján nem sikerült létrehozni a projektet, az IntelliJ honlapján képekkel illusztrálva ugyanez a leírás megtalálható.
A következő kódrészlet készít egy ARRAY_SIZE
méretű tömböt és feltölti véletlenszerűen generált adatokkal. A tömbön végig kell iterálni és meghatározni a maximális értékű elemét.
1 2 3 4 5 6 |
|
A cél az, hogy az algoritmus futási idejét mérjük és összehasonlítsuk a párhuzamos futtatás eredményével. Ezt a System.nanoTime()
hívások segítségével tehetjük meg az alábbi formában.
1 2 3 4 5 6 7 |
|
Az így előállt programot futtatva és kiíratva a max
és a singleThreadTime
értékeit az alábbi kimenetet kapjuk:
1 2 |
|
A futásidő a használt számítógép erőforrásaitól függően változhat (és minden futtatás során egy kicsit változik is), de ez az érték tekintendő alapvonalnak, amihez képest a párhuzamos megvalósítás "jóságát" vizsgáljuk.
Az az ötletünk támadhat, hogy a nagy elemszámú tömböt több kis részre osztjuk (teoretikusan, másolás nélkül) és a kis részeken egyszerre kellene végrehajtani a maximumkeresést. Az egyes lokális maximumokat megvárva azok közül kiválasztható a tényleges, globális maximum értéke.
Ahhoz, hogy a maximumkeresés több szálon legyen végrehajtva, egy új osztályt kell létrehozni, mely a Thread
osztályból öröklődik és a run()
metódust felüldefiniálja. Az osztály a következőképpen épül fel.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
|
A konstruktorban át kell adni a vizsgálandó tömböt és azt, hogy a tömb melyik részén végezze el a keresést. Az, hogy hány részre van osztva a keresési tér a szálak számától függ.
1 2 3 4 5 6 7 8 9 10 11 |
|
A szálak számától függően feldaraboljuk a tömböt, 4 szál esetén 4 részre és mindegyik szál az egyiken dolgozik. A threads
lista szükséges ahhoz, hogy a szálak által produkált eredményeket elérjük. A thread.start()
hívás után a program futása úgymond "kettéválik", a for
ciklus is folytatódik, illetve a thread
objektum futása egy külön szálon megkezdődik.
A példában 4 db szál külön életet él, viszont a main
függvényben szeretnénk a lokális maximumértékeiket megszerezni. Ahhoz hogy megvárjuk egy szál futásának végét, a .join()
hívással blokkolható a fő szál amíg a kívánt szál run
metódusa véget nem ér.
1 2 3 4 5 6 7 8 |
|
A join()
metódus InterruptedException
kivételt dobhat, így try-catch
blokkba kell tenni és lekezelni az esetlegesen dobott kivételt.
A szálak létrehozása elé és a globális maximumérték meghatározása mögé beszúrva System.nanoTime()
hívásokat, lemérhető a párhuzamos megvalósítás futásideje.
1 2 3 4 5 |
|
Látható, hogy ugyanazt a maximumértéket találták meg (elvárt viselkedés), illetve a párhuzamos futás 163 ms-sel gyorsabban végzett. A szálak menedzselése és a kontextusváltások plusz terhet jelentenek a JVM számára, így túl sok szálat indítani nem érdemes, illetve kis adaton (kis tömbméret esetén) nem fog túl sok javulást hozni.
Ha a MaxSearchThread::call
és a main
metódusokban elhelyezzük a System.out.println(Thread.currentThread().getName());
sort, akkor a konzolon követhető, hogy milyen nevű threadek lettek létrehozva, illetve melyik fut.
1 2 3 4 5 |
|
Feladat
-
Módosítsuk az
ARRAY_SIZE
értékét: próbáljuk ki milyen futásidők születnek, amennyiben sokkal kisebb, vagy sokkal nagyobb tömbön kell keresni. Esetleg szükséges lehet aheap
méretét megnövelni, melyhez itt található leírás. -
Módosítsuk a
NUMBER_OF_THREADS
értékét: kevesebb vagy több szál futtatása esetén milyen eredmények születnek?
A megvalósított projekt megtalálható pub/Alkalmazásfejlesztés-I/07
mappában, 01_ParallelCommandLine néven.
JavaFX¶
Felhasználói felületek esetén is fontos szerepe van a párhuzamosításnak. Az eddigi gyakorlatok során minden esetben egy helyi SQLite adatbázishoz kapcsolódott az alkalmazás, így nem tapasztaltunk késleltetést. Amennyiben az adatbázis egy szerveren van, a szerver terheltsége és hálózati késleltetés függvényében akár másodperceket is várhatunk a válaszra. A várakozás során a felhasználói felület teljesen le van fagyva.
A modern kor felhasználója nem tud várni, bármi történik reszponzív kell maradjon az alkalmazás: görgetni, nyomkodni lehessen. Ez elérhető olyan formában, hogy a potenciálisan hosszan tartó folyamatokat külön szál végzi el, mely "vissza szól" a UI-nak, ha kész van.
JavaFX esetében van egy kitüntetett szál, amin az összes UI esemény feldolgozása zajlik. Ezt a szálat hívják JavaFX Application Thread-nek. A SceneGraph Node-jai nem Thread-safe-ek, azaz több szálon történő hozzáférésük nem engedélyezett. Ennek előnye, hogy gyorsabb, mert nem kell szinkron blokkokkal operálni, ugyanakkor nem tudjuk csak egy szálról használni őket. A UI elemekhez csak az Application Thread-ről férhetünk hozzá. Ennek eredményeképpen ajánlatos a hosszan futó feladatokat elkerülni a felületen, mivel az Application Thread addig blokkolva lesz és így egy nem reszponzív felületet kapunk.
Nézzünk egy egyszerű példát:
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 |
|
Amikor a Go
gombot megnyomja a felhasználó akkor a konzolon látszik, hogy hanyadik iterációban tart a program (Processing 5 of 5
), ugyanakkor a UI-on egészen addig az Initial state
szöveg látszódik a Label
-en, ameddig mind az 5 iteráció le nem ment, azaz addig blokkolva van a UI.
Miközben fut a Go
, próbáljuk megnyomni az Exit
gombot.
Azt tapasztaljuk, hogy ez csak akkor fog lefutni, azaz akkor lép ki az alkalmazásból, amikor a runTask
lefutott.
A fenti probléma megoldása, hogy ne futtassunk hosszú feladatokat az eseménykezelőkben, pontosabban azokat egy külön szálon indítsuk el a háttérben, ne pedig az Application Thread-et blokkoljuk ezzel.
Elsőként lássunk egy rossz példát, melynek során egy külön thread-ben indítjuk a runTask
-ot.
A start
-on belül csak az eseménykezelőt írjuk át a következőképpen:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
1 |
|
, mely a label.setText(status);
utasításon keletkezik.
A JavaFX Runtime ellenőrzi, hogy UI-t befolyásoló utasításokat csak az Application Thread-ről adhatunk ki.
A helyes megoldás csak kicsiben különbözik a fentitől.
A sima label.setText(status)
hívás helyett a következőt kell használni:
1 |
|
A Platform.runLater
a paraméterben megadott Runnable
-t futtatja valamikor a jövőben az Application Thread-en, így nem okoz olyan problémát, mint amit az előző esetben tapasztaltunk.
A fenitek csak idealizált környezetben alkalmazhatók, illetve van egy szabály, amit már meg is sértünk, hiszen külön szálból visszaszólunk az Application Thread-nek, hogy frissítse a label
szövegét, ami korántsem elegáns.
Ezen felül ebben a környezetben a task nem tud visszaadni eredményt, nincs megbízható kivételkezelés, nem tudunk megbízhatóan leállítani, újraindítani és ütemezni task futtatásokat.
A következőkben ezeket a problémákat oldjuk meg a JavaFX Concurrency API segítségével.
JavaFX Concurrency API¶
A JavaFX Concurrency API a hagyományos concurrency API-ra épít, azaz a java.util.concurrent
csomag alatt található elemekre.
Maga a JavaFX Concurrency API egyáltalán nem terjedelmes, viszont annál hasznosabb kiegészítéseket tartalmaz a GUI alkalmazások többszálúsításához.
Az egyik központi elem a Worker<V>
interface, mely egy feladatot (task-ot) reprezentál, amit egy vagy több háttérszálon kell végrehajtani.
Ami nagyon fontos, hogy a Worker
státuszát az Application Thread követni tudja.
A Worker
három implementációját is megkapjuk, melyek a következő absztrakt osztályok:
Task<V>
: "Egyszer használatos" feladat futtatásához.Service<V>
: Többször is futtatható feladat futtatásáhozScheduledService<V>
: AService
kiterjesztése úgy, hogy meghatározott időintervallumonként futtatja a rendszer.
A fenti osztályok generikus paramétere a Worker
visszatérési típusát adja meg.
Amennyiben egy Worker
nem ad vissza eredményt, úgy használjuk a Void
megadást!
Worker állapotai¶
Egy Worker
a következő állapotok egyikében lehet, melyet a Worker.State
enum ír le:
READY
SCHEDULED
RUNNING
SUCCEEDED
CANCELLED
FAILED
Az állapotok közötti lehetséges átmeteket a következő ábra mutatja be.
Amikor egy Worker
-t létrehozunk, akkor a READY
állapotba kerül.
A végrehajtás előtt ütemezett, azaz SCHEDULED
állapotba kerül, melyből a futó (RUNNING
) állapotba kerülhet.
Ezután 3 féle forgatókönyv létezik:
- Futás közben kezeletlen kivétel keletkezik ->
FAILED
cancel()
metódus hívás eredményeképpenCANCELLED
állapotba kerül. Mivel bármikor dönthetünk úgy, hogy mégsem szeretnénk futtatni aWorker
-t, így nem csak aRUNNING
, hanem aREADY
ésSCHEDULED
állapotokból is átkerülhetünkCANCELLED
állapotba- Emellett nyilván sikeresen is lefuthat egy
Worker
, melynek eredményeképpen aSUCCEEDED
állapotba kerül
A Service-ek ezen 3 állapotból visszakerülnek a READY állapotba, hogy a futtatás megismételhető legyen (az ábrán ezt a szaggatott vonalak jelzik).
Az állapotok közötti váltások WorkerStateEvent
eseményeket is generálnak, így könnyen kezelhetjük, azt hogy például mi történjen hiba esetén (pl.: néhányszor próbálkozunk még az újrafuttatással utána viszont jelezzük a problémát).
Worker property-jei¶
Minden Worker
a következő property-kkel rendelkezik, melyeket a létrehozáskor lehetőségünk van megadni:
title
: a task nevemessage
: visszajelzések küldésére használatos (pl.:Processed 1 out of 10
)running
: megmondja, hogy a feladat éppen fut-e (SCHEDULED
is már igazat ad vissza)state
: az előző fejezetben bemutatott állapotok közül adja vissza az éppen aktuálisatworkDone, totalWork, progress
: a feladat készültségét lehet jelezni velevalue
: AWorker
eredményét adja visszaexception
: Amennyiben aWorker
FAILED
állapotba került, akkor a kivétel ide kerül, melyThrowable
típusú, azaz tovább is dobhatjuk az Application Thread-en, ha úgy tartja kedvünk
Ha a futó Worker
állapotáról szeretnénk információkat megjeleníteni, akkor nyugodtan kössünk vezérlőket ezekhez a property-khez, illetve használhatunk Invalidation
- és ChangeListener
-eket is, melyekben UI elemeket hivatkozunk.
Nézzük, hogyan lehet Task
használata mellett elvégezni az előző feladatokat:
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 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 |
|
Az első és legfontosabb, hogy a feladatot egy Task
formájában adjuk meg, melyet egy field-ben el is tárolunk.
Az init blokkban megadunk egy eseménykezelőt is, mely akkor fut le, ha a task SUCCEEDED
állapotba kerül.
Hasonló eseménykezelőket találhatunk a különböző állapotokhoz, illetve van egy általános eseménykezelő is, melynek meg lehet adni az esemény típusát:
1 2 3 |
|
Az absztrakt call
metódus kifejtésében annyi változik, hogy a property-k értékét frissítjük az updateXXX
metódusok használatával, így a folyamatról a UI-on is megjeleníthetünk információkat, ahogy azt meg is tesszük majd jelen esetben.
Amennyiben InterruptedException
-t kapunk, akkor leállítjuk a for ciklus futását és visszatérünk az aktuális i értékkel (ilyenkor vizsgáljuk, hogy azért kaptunk-e ilyen kivételt, mert valaki a cancel()
-t meghívta-e).
Ezután a felületre kivezetünk néhány property-t (binding-ok segítségével), hogy követni tudjuk a háttérben futó feladat állapotát.
Megjegyzés
A Service
osztály belül egy Task
-ot tartalmaz, illetve használata nagyban megegyezik a Task
használatával.
Itt nem fogjuk bemutatni részletesen sem a Service
sem pedig a ScheduledService
használatát.
Kontakt alkalmazás továbbfejlesztése¶
A kiinduló projekt megtalálható a pub/Alkalmazásfejlesztés-I/06/01_contacts_final
mappában.
Figyelem
Ne felejtsük el átírni az application.properties
fájlban az adatbázis elérését!
A táblázat jobb oldalán, minden sorban található egy "Delete" és egy "Edit" gomb. Tegyük fel, hogy ezek hatására egy lassú hálózati kommunikáción keresztüli akció fut le, ami blokkolja a UI felületet és nem lehet görgetni még be nem fejeződik. Ennek imitálásához egy 5 másodperces várakozást tegyünk be a DAO megvalósításába.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
Ami a kontextusban változott: a stmt.executeUpdate();
elé került egy sleep.
A várakozást jelentő függvényhívás is dobhat InterruptedException
kivételt, amit le kell kezelni!
A catch
ág képes arra, hogy több kivételosztályt is elkapjon egyszerre, és az osztályokat |
(vagy) jellel kell elválasztani.
Ugyanez a módosítás kerüljön bele az összes Contact DAO metódusba, kivéve a findAll
-t.
A módosult metódusok: save
, delete
.
A futtatás utáni elvárt eredmény, hogy az alkalmazás indulása után minden művelet sokáig tart és addig a táblázat nem görgethető, hozzáadás ablak kifagy.
Ahhoz hogy reszponzív maradjon a UI, amíg a back-end fut, a következő változtatások szükségesek.
MainWindowsController¶
A hu.alkfejl.controller
csomagban található MainWindowController
osztály deleteContact
metódusát kell felokosítani ahhoz, hogy a PersonController.getInstance().delete(p);
metódushívás ne legyen blokkoló. Ehhez az előbb ismertetett Thread-eket kell alkalmazni.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
|
Amikor a felhasználó megerősíti, hogy ténylegesen ki akarja törölni a kiválasztott sort, akkor egy Task
(javafx.concurrent.Task
) objektumot hozunk létre.
Az IDEA legenerálja a szükséges kódrészletet, és a call metódust kell megvalósítani.
Jelen esetben ez csak tovább hív a DAO-ba (eddig is ezt csinálta az itt talált kódrészlet).
Mint ahogy azt már láttuk, a Task
objektum önmagában nem futtatható, egy Thread
-et kell létrehozni belőle, ami tudja futtatni.
Az eseménykezelő feladata, hogy frissítse a táblázatot a Task
futásának végeztével (elvárható, hogy a törölt sor nem jelenik meg újra).
AddEditContactController¶
Új kontakt hozzáadásánál és a módosításánál is a onSave
metódus fut le.
Ami eltér az előző fejezettől az az,hogy most kellene tudnunk azt is, hogy milyen eredménnyel futott le a módosítás és ennek függvényében jelezni a felhasználó felé, hogy sikeres-e a művelet. Az alábbi kódrészlet a onSave
függvényt taglalja.
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 |
|
A call
nem sokban különbözik az előzőtől.
Utána viszont hasznát vesszük a megírt loadFXML
metódusnak, mivel frissítenünk kell majd a táblázatunkat a fő ablakon.
A refreshTable
metódust publikusra kellett módosítanunk, de ez rendben van, hiszen miért ne frissíthetnénk a táblázatot egy-egy kérés alkalmával.
A módosítások után a UI reszponzív marad, de 5 másodperc után ad visszajelzést, a kért esemény lefutása után.
Videók¶
- Szálak Bevezetés
- JavaFX szálak bevezetés
- JavaFX Worker szálak (JavaFX Concurrency API)
- Contacts alkalmazás szálakkal
Referenciák¶
- Párhuzamos programozás jegyzet - A. Kertész, L. Schrettner, /pub
- Java Multithreading, TutorialsPoint
- Concurrency in JavaFX