1. gyakorlat¶
Párhuzamos számítógéprendszerek¶
A számítástechnikában a párhuzamos programozás azt jelenti, hogy egy problémát kisebb részfeladatokra bontunk, amelyeket egyszerre, párhuzamosan hajthatunk végre több végrehajtási egység segítségével. A párhuzamosság tehát a feladatok időbeli átfedésére vagy egyidejű végrehajtására utal. A végrehajtási egység itt egy magasabb absztrakciós szintet képvisel, mivel a párhuzamosságot különböző szinteken alkalmazhatunk, például több számítógép között, egy számítógépen belül vagy akár a számítógépen futó program szintjén is. Ezért minden esetben fontos tisztázni, hogy a párhuzamosság alatt párhuzamos hardverrendszerekre vagy párhuzamos szoftverrendszerekre utalunk éppen.
Hardver¶
Hardverrendszerek esetén Flynn-féle osztályozás négy fő csoportot különböztet meg aszerint, hogy mennyi utasításfolyam (instruction stream), illetve adatfolyam (data stream) különíthető el a rendszerben.
-
SISD (Single Instruction stream, Single Data stream): egy egyszerű, egyprocesszoros gép, ahol az összes műveletet szekvenciálisan hajtják végre
-
SIMD (Single Instruction stream, Multiple Data streams): a GPU-k képesek egyszerre több adatot feldolgozni ugyanazzal az utasítással, például több pixel vagy vektor műveletet végezhetnek párhuzamosan
-
MIMD (Multiple Instruction streams, Multiple Data streams): manapság elterjedten használt párhuzamos számítógépek, ahol minden processzor saját utasítássorozatot hajt végre különböző adatfolyamokon
-
MISD (Multiple Instruction streams, Single Data stream): a gyakorlatban nem terjedt el, de a megbízhatóság növelésére szolgálhatnak (redundáns adatfeldolgozás)
Elosztott rendszerek (Grid Computing, Cloud Computing)
Egy hálózatba kötött, földrajzilag elosztott, heterogén számítógépek hálózata ideálisan alkalmas párhuzamos programozás alkalmazására, mivel lehetővé teszi a különböző számítási feladatok párhuzamos végrehajtását több, egymástól független végrehajtási egységen, miközben az erőforrásokat hatékonyan osztja meg a hálózaton keresztül.
Ezzel viszont ezen a gyakorlaton nem foglalkozunk. Ahogy GPU programozással sem. 🙁
Szoftver¶
A processzorok teljesítményének növekedését hosszú ideig a tranzisztorok egyre növekvő számával érték el. Ahogyan a tranzisztorok mérete csökkent, úgy nőtt a sebesség, ezzel párhuzamosan viszont nőtt az energiafogyasztás is. Azonban az energia hővé alakulása az áramkörök túlmelegedéshez vezethetnek, korlátozva az egyprocesszoros rendszerek fejleszthetőségét. A számítási teljesítmény növelése érdekében az ipar a párhuzamosságra váltott: több, "egyszerűbb" processzort helyeznek el az integrált áramkörre, amelyeket többmagos processzoroknak nevezünk.
Ha van már egy párhuzamos végrehajtásra alkalmas hardverünk, akkor arra több fajta párhuzamos programozási szoftvermodellt is leképezhetünk.
- Közös memóriás párhuzamos programozás: folyamatokból és egy közös memóriából áll (a közös memória azt jelenti, hogy a folyamatok ugyanazokat a változókat használhatják, akár egyidőben is megkísérelhetik őket olvasni vagy írni - ennek hatékony menedzselése jelenti a legnagyobb kihívást)
Folyamatinterakciók
A közös memóriás programozás nagy figyelmet kell fordítanunk arra, hogy hogyan biztosítjuk az erőforrásokhoz való kizárólagos hozzáférést (kölcsönös kizárás), hogyan szinkronizáljuk a szálakat és hogyan kerüljük el a programunk holtpontba jutását.
- Osztott memóriás párhuzamos programozás: folyamatainak olyan változói vannak, amelyeket azok kizárólagosan használhatnak, itt tehát az egyidejű hozzáférés ki van zárva (a folyamatok együttműködése üzenetküldéssel valósul meg).
Közös változók hiánya
Az osztott memóriás programozás esetén a folyamatok egymás közötti adatcseréjén, azaz az üzenetküldésen és annak szinkronizációján van a hangsúly.
Közös memóriás párhuzamos programozás esetén, amikor egy felhasználó elindít egy programot, az operációs rendszer létrehoz egy folyamatot (process), azaz egy futó példányt a programból, amely saját memóriaterülettel rendelkezik. A szálak (thread) egy adott folyamat belső végrehajtási egységei, amelyek hozzáférnek a folyamat által használt memóriaterülethez. A szálkezelés lehetőséget biztosít a programozók számára, hogy a programjaikat többé-kevésbé független feladatokra bontsák fel.
Osztott memóriás párhuzamos programok esetén pedig a végrehajtási egységnek magát a párhuzamosan futó folyamatokat tekintjük, amelyek saját memóriaterületükkel rendelkeznek, és üzenetküldés segítségével kommunikálnak egymással.
Lightweight végrehajtási egységek
A hagyományos szálakkal (közvetlenül az operációs rendszer által kezelt erőforrások) ellentétben bizonyos programozási nyelvekben elérhetőek a futtató környezet által közvetlenül kezelt végrehajtási egységek (is). Előnye a kisebb erőforrásigény és a jobb skálázhatóság.
Parallelism vs. Concurrency
Párhuzamosság esetén több feladat egyszerre, párhuzamosan fut, különböző processzorokon vagy magokon, így gyorsítva a számításokat. Ezzel szemben konkurens programozás esetén is több feladattal foglalkozunk, de nem feltétlenül párhuzamosan, hanem felváltva fut, miközben egyetlen processzor osztja meg az időt a különböző feladatok között. A két megközelítés nem zárja ki egymás jelenlétét, egy implementáció lehet egyszerre párhuzamos és konkurens is.
Párhuzamosság előnyei¶
-
Teljesítményjavulás: Megfelelő hardveres támogatás esetén a párhuzamos programozás gyorsabb és hatékonyabb az erőforrás-kihasználása, mivel több végrehajtási egység dolgozik egyszerre. Ez különösen fontos nagy számításigényű alkalmazásoknál, például tudományos számításoknál, szimulációknál vagy mesterséges intelligencia algoritmusoknál.
-
Természetesebb problémaleírás: A párhuzamosság lehetővé teszi, hogy a problémát az összetevőire bontva, természetesebb módon közelítsük meg. A párhuzamos programozási nyelvek általában tartalmaznak szekvenciális utasításokat, amelyeket kiegészítenek párhuzamos konstrukciókkal, ezáltal egyszerűsítve a komplexebb problémák implementációját.
Például egy nagy adatbázisban végzett keresési feladat esetén a párhuzamosság azt jelenti, hogy az adatbázis különböző részeit egyidejűleg több szál dolgozza fel, így csökkenhet a végrehajtási idő. A probléma (egyszerűsített) reprezentációja:
Szekvenciális keresés: 23 kocka | 2 szálas keresés: 14 kocka | 6 szálas keresés: 3 kocka
Nem mindig számíthatunk ekkora javulásra!
Például 2 szál nem kétszer, 3 szál nem háromszor, és 6 szál sem hatszor gyorsítja a végrehajtást. A párhuzamosság hatékonyságát korlátozhatja a szekvenciális részek aránya, a szinkronizációs költségek és az erőforrások korlátai.
Párhuzamosság Java-ban¶
A JDK 21 hivatalos Javadoc-ja itt érthető el.
Thread osztály, Runnable interfész¶
Most, hogy megértettük, mi a párhuzamosság és milyen előnyökkel jár, nézzük meg, hogyan valósítható meg mindez a Java programozási nyelvben. A nyelv már az első verziójától kezdve támogatja a párhuzamosságot, és számos eszközt biztosít arra, hogy a programok egyszerre több szálon fussanak.
A Java-ban a végrehajtási egységet szálnak nevezzük. Szekvenciális programok esetében az összes utasítás a main metódusban fut le, amelyet a main thread hajt végre. A fő szál által létrehozott újabb szálak használatával lehetőség nyílik arra, hogy a program különböző részei párhuzamosan hajtsanak végre műveleteket, ezzel felgyorsítva a feldolgozást és növelve a teljesítményt.
A Java-ban a párhuzamosságot a Thread
osztály és a Runnable
interfész biztosítja,
amelyeket felhasználva könnyedén hozhatunk létre saját szálakat, és
meghatározhatjuk, hogy ezek milyen feladatokat végezzenek. A szálak működési logikáját a run()
metódus
felülírásával tudjuk változtatni. Miután meghatároztuk a működést, a szálakat a start()
metódussal fogjuk tudni elindítani.
Tekintsük a HelloWorld problémának a megoldását párhuzamos Java-ban!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
|
Háttérben futó szálak
A main thread által végreahajtott szekvenciális programokat tekinthetjük egyszálú vezérlésnek, de ez nem jelenti azt, hogy nincsenek további, a háttérben futó szálak. Például a Java rendszer által kezelt daemon szálak olyan háttérfolyamatokat futtatnak, mint a garbage collector vagy a finalizer, és automatikusan megszűnnek, amikor az összes non-daemon szál befejeződött.
A main thread terminálása nem jelenti a teljes program végét, ha van még futó, nem-démon szál. A JVM aktívan tartja az alkalmazást mindaddig, amíg legalább egy ilyen szál fut.
A Thread
osztály számos hasznos metódust biztosít, amelyek segítségével kezelhetjük és monitorozhatjuk a szálak működését, állapotát. Az alábbiakban néhány fontosabb metódust emelünk csak ki:
-
Thread.currentThread()
: Az aktuálisan futó szálra mutató referencia lekérése. -
Thread.sleep(n)
: A szál futását egy megadott időre (n: milliszekundum) felfüggeszti. -
threadId()
: A szál egyedi azonosítóját adja vissza. AgetId()
19-es verzió óta deprecated, használata hosszútávon váratlan viselkedést eredményezhet! -
getName()
: A szál nevét adja vissza. Ez a szálnak adott tetszőleges név, nem egyedi. -
isAlive()
: Ellenőrzi, hogy az adott szál éppen végrehajtás alatt van-e. -
isDaemon()
: Ellenőrzi, hogy az adott szál daemon szál-e. -
getPriority()
éssetPriority()
: A szál prioritásának lekérdezése vagy beállítása. A nagyobb prioritású szál nagyobb eséllyel kap processzoridőt. -
yield()
: Egy jelzés az ütemezőnek, hogy a jelenlegi szál hajlandó átadni a vezérlést más szálaknak, de nem garantálja, hogy ez a váltás valóban meg is történik. -
getState():
Lekérdezi a szál aktuális állapotát. -
join()
: Biztosítja, hogy egy szál befejezze a futását, mielőtt a program továbblépne a következő utasításra.
InterruptedException
Van arra is lehetőség, hogy egy másik szál megszakítson egy szálat, jelezve neki, hogy le kell állnia a végrehajtással, amint lehetséges. Erre akkor lehet szükség, ha egy szál blokkoló műveletet végez, például túl hosszú ideig várakozik (sleep(n)
). Ahhoz, hogy a szál megfelelően kezelhesse a megszakítást, bizonyos metódusok InterruptedException
kivételt dobnak.
Példák a felsorolt metódusokra:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
|
Szálállapotok¶
Egy szál életciklusa során különböző állapotokat vehet fel, amit a fent említett Thread.getState()
metódussal hívhatunk le.
-
NEW
: A szálat létrehozták, de még nem indították el astart()
metódussal. Ebben az állapotban a szál nem hajt végre semmilyen műveletet. -
RUNNABLE
: A szál készen áll a futásra, és a JVM megpróbálja ütemezni a processzoron. Bár "futásra kész", nem feltétlenül fut azonnal, mivel más szálak is versenyezhetnek az erőforrásokért. -
TERMINATED
: A szál a run() metódus végrehajtásával kerül ebbe az állapotba. Végállapot, a szál nem indítható el ismét. -
TIMED_WAITING
: A szál egy meghatározott ideig vár, például asleep()
metódus hívásakor. Az idő lejárta után automatikusan visszakerülhet aRUNNABLE
állapotba. -
WAITING
: A szál határozatlan ideig vár egy másik szál műveletére vár, hogy folytathassa a végrehajtást. Tipikusan valamilyen feltétel teljesülésére van szükség a folytatáshoz, amihez explicit értesítés szükséges. -
BLOCKED
: Amikor egy másik szál jelenleg használja a szükséges erőforrást, és a várakozó szál nem tudja folytatni a végrehajtást, amíg az erőforrás zárolása fel nem oldódik.
WAITING & BLOCKED állapot
Ezeket az állapotokat egy későbbi órán tárgyaljuk részletesebben, mivel a teljes megértésükhöz a monitor koncepció és a szálak közötti szinkronizációs mechanizmusok ismerete szükséges.
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 |
|
Párhuzamosság teljesítmény javítása¶
Nézzünk meg egy gyakorlati példát arra, hogyan javíthat a párhuzamosság egy szekvenciális program futási idején. Ebben a példában egy könyv szavainak előfordulási gyakoriságát szeretnénk megszámlálni. A választott könyv a Biblia, amit erről a linkről le lehet tölteni.
A megoldás során megvizsgálhatjuk, hogy a szekvenciális (1 szálú) megközelítést újabb szálak hozzáadásával párhuzamos feldolgozássá alakítva milyen mértékű javulást érünk el a futási idő tekintetében. A könyv soronkénti beolvasását követően felbontjuk egyenlő részekre, amelyeket aztán az egyes szálak párhuzamosan feldolgoznak (azaz összeszámolják, hogy a paraméterben kapott részhalmazban melyik szóból mennyi található). Az összegzéshez a szálak egy HashMap
-et használnak, amelyet aztán egy másik, globális kulcs-érték párokat tartalmazó kollekcióval egyesítünk.
Folyamatinterakciók: szinkronizáció
A szinkronizációban egy küldő és egy fogadó folyamat vesz részt, mindkettőnek van egy szinkronizációs pontja. A fogadó nem lépheti át a saját szinkronizációs pontját, amíg a küldő el nem érte a sajátját, mivel a fogadónak szüksége van a küldő által előállított adatokra. Ha a fogadó ér hamarabb a szinkronizációs ponthoz, várakozik a küldőre. Ha a küldő ér oda előbb, kétféle viselkedés lehetséges: vagy bevárja a fogadót (szimmetrikus), vagy mindkét folyamat áthaladhat várakozás nélkül (asszimmetrikus).
Ez a mechanizmus kulcsfontosságú a párhuzamos programozásban, mivel garantálja a kívánt végrehajtási sorrendet és az adatok konzisztenciáját.
join()
Annak érdekében, hogy megelőzzük azt, hogy az egyik szál felülírja egy másik szál módosítását több megközelítéssel dolgozhatunk. Ebben a példában a join()
metódust alkalmazzuk, amellyel egy speciális szinkronizációt valósítunk meg: az utasítást hívó szál (esetünkben ez a main lesz) addig blokkolásra kerül, amíg a metódust végrehajtó szál nem terminál.
A metódus esetén szintén gondoskodnunk kell az InterruptedException
kivétel kezeléséről.
Egy időkorlát is megadható paraméterként, amely meghatározza, hogy mennyi ideig várakozzon a hívó szál a befejezére: join(2000)
A join használata kiváltható lenne más megoldással is, amely biztosítja a szálbiztos hozzáférést (a következő órákon ezekkel fogunk foglalkozni).
Ez azt jelenti, hogy szál által végzett összegzés rögzítése a globális adatstruktúrába a megadott sorrendben (a szálakat tartalmazó List
iterálásával)történik - szekvenciálisan. Amennyiben a szálak által végzett "munka" különböző ideig tart, akkor egy hosszú ideig futó szálra történő várakozás plusz időt jelentene az összegzésnél. Viszont biztosak lehetünk benne, az eredmények a várt időben állnak rendelkezésre.
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 74 75 76 77 78 79 80 |
|
Runtime.getRuntime().availableProcessors()
utasítást, amely visszadja a futtatókörnyezet számára elérhető CPU-magok számát.
Megjegyzés
Attól függetlenül, hogy a Biblia körülbelül 800.000 szót tartalmaz, nem minősül egy túl nagy inputnak. Bizonyos területeken, például nagyszabású szövegbányászati vagy genomikai elemzéseknél, a párhuzamosítás által nyújtott gyorsulás kritikus fontosságú lehet. Ezekben az esetekben a különbség a szekvenciális és párhuzamos feldolgozás között több nagyságrendű lehet.
Gyakorló feladatok¶
Feladat
Mi történik az alábbi programrészletben?
1 2 3 4 5 6 7 |
|
Feladat
Hozz létre egy MyRunnable nevű osztályt, amely megvalósítja a Runnable interfészt, és implementálja a run() metódust. Az osztály konstruktora egy egész szám típusú paramétert vár, amelyet a count nevű változóban tárol.
A run() metódus feladata, hogy a count változó értékétől visszafelé számoljon nulláig (for ciklus segítségével), és minden lépésben kiírja a szám értékét a standard outputra sortöréssel.
Hozz létre egy MyThread nevű osztályt, amely a Thread osztályból származik, és szintén implementálja a run() metódust. Az osztály konstruktora egy MyRunnable típusú objektumot vár paraméterként, amelyet eltárol a myRunnable változóban. A run() metódus indítsa el a konstruktorban kapott objektumot szálként.
Feladat
Hozz létre egy Resource nevű osztályt. Az osztály konstruktorának egyetlen egész szám típusú paramétere van, amely a szálak számát jelöli. A konstruktor feladata, hogy inicializálja és feltöltse az array elnevezésű adattagot, amely egy egész számokat tartalmazó tömb. A tömb mérete a megadott szálak számának százszorosával egyenlő (szálak száma × 100), a szálak számát paraméterként kapja meg a konstruktor.
Ezután hozz létre egy Calculator osztályt, amely a Thread osztályból származik és implementálja a run() metódust. A Calculator osztály konstruktora három paramétert fogad:
-
egy Resource típusú objektum referenciáját,
-
egy egész számot, amely egy intervallum kezdőértékét jelöli,
-
egy másik egész számot, amely az intervallum végértékét adja meg.
A run() metódus feladata, hogy végigiteráljon a megadott intervallumon belül található számokon az array tömbben, és az összeget eltárolja a Calculator osztály sum elnevezésű adattagjában.
Teszteld a programot a main metódusban. A metódus szeletelje fel a szálak számával megegyező intervallumra a tömböt és minden intervallumhoz példányosítson egy új Calculator szálat az összegzés elvégzésére. A szálak elindítása után a join() metódus segítségével biztosítani kell, hogy a főprogram csak akkor folytatódjon, ha minden szál befejezte a számításait.