Linux IPC - I.¶
A legegyszerűbben úgy kerülhetünk párhuzamos programozási környezetbe, ha veszünk egy hagyományos szekvenciális programozási nyelvet és kiegészítjük párhuzamos konstrukciókkal, például ahogyaan ebben az anyagrészben is eljárünk, ahol a C nyelvet egészítjük ki Linux rendszerhívásokkal.
A párhuzamos folyamatok közötti kommunikáció (IPC - Inter Process Communication) kifejezetten azokra a mechanizmusokra utal, amelyeket az operációs rendszer biztosít a folyamatok számára a közös adatok kezeléséhez. Ezzel a módszerrel akár közös memóriás, akár osztott memóriás modellben is programozhatunk.
A fork() hívás¶
A fork() hívás szolgál arra, hogy egy új folyamatot hozzunk létre a hívó folyamat duplikálásával. Ez nem a teljes hivó folyamatra vonatkozik a kezdetektől, hanem egy olyan lemásolt folyamatra, ami a fork() hívás helyétől kezdve fogja ugyanazt a programot futtatni. Erről a legegyszerűbben úgy is megbizonyosodhatunk, ha az alább található fork() hívásunk elé szintén elhelyezünk egy tetszőleges kiíratást (ez csak egyszer fog kiírásra kerülni - a duplikálás előtt csak egy folyamatunk volt).
Egy folyamatot bizonyos ideig szüneteltetni tudunk a sleep(unsigned int seconds) függvény segítségével, amely paraméterben a szüneteltés (altatás) hosszát várja másodpercben. Egészítsük ki a példánkat elegendően hosszú altatással a fork() hívás előtt és után is, majd futtasuk a programot. Nyissunk egy új terminált, és a ps -ao pid,ppid,psr,comm
utasítás segítségével bizonyosodjunk meg róla, hogy a folyamat valóban megduplázódott (akár az utasítás többszöri kiadásával a terminálban).
1 2 3 4 5 6 7 |
|
Egészítsük ki a programunkat több fork() hívással. Az egymást követő n db fork() hívás 2^n db folyamatot eredményez. A fork és a sleep függvényeket használatához szükség lesz még az <unistd.h>
header fájlra, ezen kívül a standard I/O műveletekre van még szükségünk (<stdio.h>
).
Folyamatok azonosítói¶
Előző példában megnéztük hogy tudunk új processzt létrehozni, azonban ezeket a processzeket szeretnék azonosítani is valamilyen módon, illetve a folyamatok között alá- és fölérendeltségi viszonyt is meg szeretnék állapítani. Egy fork() hívás esetén tehát megkülönbözhetünk szülő és gyerek folyamatot: a függvényhívás visszatérési értéke 0 lesz gyerek folyamat esetén, nagyobb, mint 0 lesz szülő processz esetén, egyéb esetben pedig hiba történt a folyamat létrehozásakor. A pid_t
típus egy signed int típust jelent, ami megegyezik a GNU C library esetén az int típussal (a hordozhatóság érdekében pid_t
típust kellene használni).
Mintaprogramunk tartalmazza a gyermek folyamat esetén alkalmazandó _exit(EXIT_SUCCESS)
és a szülő folyamat esetén alkalmazandó exit(EXIT_SUCCESS)
függvényhívásokat (<stdlib.h>
). Ezáltal megelőzhetjük a standard I/O pufferek (pl. stdout, stderr) többszöri kiírását, mielőtt a teljes programunk terminálna.
Fontos megjegyezni továbbá, hogy megkülönböztetjük a processzek azonosítóit a C programon belül és a folyamatok Linux rendszerbeli azonosítóit. Hogy lekérjük az adott folyamat azonosítóját, használjuk a getpid() függvényt, a folyamat szülőjéhez tartozó azonosító lekéréséhez pedig a getppid() függvényt. További érdekesség, hogy a C programon belül a szülő szál a gyerek folyamat Linux rendszerbeli azonosítóját kapja meg.
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 |
|
Virtuális memória¶
Ahhoz, hogy közös memóriát használó folyamatokat tudjunk létrehozni, további függvényhívásokra lesz majd szükség. Először viszont nézzük meg, hogyan reagál az alábbi program, ha egy közösnek tűnő változót mindkét folyamat módosítja:
1 2 3 4 5 6 7 8 9 10 11 |
|
Ahogy látjuk, a két folyamat egymástól függetlenül módosítja a változó értékét. Amikor a fork() hívás megtörténik, a szülőhöz tartozó memóriaterületről másolat készül, amit a gyermek folyamat örököl. Előfordulhat, hogy a függvényhíváskor nem történik rögtön másolat, hanem a szülő és a gyermek folyamat megosztja egymás között a memóriaterületet, és csupán akkor készül el a másolat, amikor valamelyik folyamat módosítaná azt (lásd Copy-on-Write).
Másfelől, ha kiegészítjük a programunkat a változó memóriacímének kiíratásával, láthatjuk, hogy a két memóriacím megegyezik. Ez azonban nem jelenti azt, hogy a szülő és a gyermek folyamathoz tartozó változó ugyanazon a fizikai pozícióban van eltárolva. A modern számítógépek virtuális memóriacímzése miatt egyezik meg a memóriacím (azaz minden folyamat memóriája ugyanazon a virtuális címen kezdődik). Ezt szintén leellenőrizhetjük, ha az x változó címét kiiratjuk a programban.
Folyamatok ábrázolása¶
A következő példában gondoljuk végig hányszor kerül kiíratásra a "Hello world!" üzenet. Továbbra is igaz, hogy n fork() hívás esetén 2^n folyamat jön létre, azonban gondoljuk végig a (cikluson belüli) függvényhívások és a kiíratások sorrendjét.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
Az egymást követő fork() hívások után létrejövő folyamatok könnyen ábrázolhatóak egy faként (draw.io forrás):
1 2 3 4 5 6 7 8 9 |
|
Folyamatok bevárása és számuk korlátozása¶
Bizonyos esetekben szükség lehet arra, hogy az egyes folyamatok bevárják egymást, például ha egy adott problémát több részfeladatra bontottunk és a részfeladatokat feldolgozó (al)folyamatok különböző ideig számolnak, majd a részeredményeket összegezni szeretnék a fő folyamatban (tipikus példa: tömbösszegzés). Erre szolgál nekünk a wait(NULL) függvény (sys/wait.h
), amellyel képes a programunk egy folyamatot bevárni (nem tudjuk megmondani, melyiket). További megkötés, hogy csak a szülő folyamat képes bevárni a gyerek folyamatokat a wait(NULL) segítségével. A szülő folyamat addig lesz blokkolva, amíg a gyermekfolyamat nem küld vissza egy kilépési állapotot az operációs rendszernek, a vezérlés aztán visszakerül a szülő folyamathoz (NULL helyett egy pointer segítségével a kilépési állapot logolható lenne - ezzel nem foglalkozunk).
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 |
|
Bizonyos esetben (vagy is inkább az esetek túlnyomó többségében) szükség van arra, hogy egy szülő folyamat bizonyos számú gyerek folyamatot hozzon létre anélkül, hogy a gyerek folyamatok további (al)folyamatok szülőjeként viselkedjen. Ezt a legkönyebben úgy érthetjük el, hogy a gyerek folyamatokat létrehozó ciklusból a break
utasítás segítségével kilépünk. A manuálisan létrehozott szülő-gyerekfolyamatok nehezen menedzselhető párhuzamos programot eredményeznek!
Az n db létrehozott gyerek folyamat bevárásához n db wait(NULL) utasítás szükséges. Ebben a példában a gyerek folyamatok 3 másodpercig vannak szüneteltetve, azonban érdemes végiggondolni, hogy hogyan tudnánk különböző ideig szüneteltetni az egyes folyamatokat. Önmagában a "hagyományos módon" történő randomszám generálás nem jó megoldás, hiszen az egyes folyamatok ugyanabból a seed-ból indíthatják a pszeudorandom szám generálást (emlékeztetőül: szülő folyamat memóriaterületének lemásolása). Azonban kombinálhatjuk a random szám generálását egy egyedi azonosítóval, például minden folyamat rendelkezik egy Linux rendszerbeli egyedi azonosítóval (getpid()).
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 |
|
Gyakorló feladatok¶
(1) Mi az alábbi program kimenete és miért? Az indoklás egy lehetséges módja a folyamatok ábrázolása faként a korábbi példához hasonlóan. Használjunk egységes jelölést (pl. a bal részfa jelölje mindig >0 esetet, a jobb részfa pedig a =0 esetet).
1 2 3 4 5 6 7 8 9 10 11 |
|
(2) Készíts egy programot Linux IPC segítségével, amely egy 100 elemű, véletlenszerű számokkal feltöltött tömb összegzését végzi el négy gyerekfolyamat segítségével. Minden gyerekfolyamat a tömb egyenlő részét kapja meg, és kiszámítja a kapott intervallumban található számok összegét, majd az eredményt kiírja a standard kimenetre. A szülő folyamat feladata, hogy elindítsa a gyerekfolyamatokat, majd megvárja azok befejeződését.