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 aheapmé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>: AServicekiterjeszté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:
READYSCHEDULEDRUNNINGSUCCEEDEDCANCELLEDFAILED
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 (SCHEDULEDis 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: AWorkereredményét adja visszaexception: Amennyiben aWorkerFAILEDállapotba került, akkor a kivétel ide kerül, melyThrowabletí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