19. Osztott rendszerek és konkurens programozás
Szálkezelés
A szál (thread) a processzor egyfajta szoftveres megfelelője, minimális kontextussal. Ha a szálat megállítjuk, a kontextus elmenthető és továbbfuttatáshoz visszatölthető.
Folyamat: A folyamat (process vagy task) egy vagy több szálat összefogó nagyobb egység. Egy folyamat szálai közös memóriaterületen (címtartományon) dolgoznak, azonban különböző folyamatok nem látják egymás memóriaterületét.
Szál vs. folyamat: A szálak közötti váltáshoz nem kell igénybe venni az OS szolgáltatásait, míg a folyamatok közötti váltásnál ahhoz, hogy a régi és új folyamat memóriaterülete elkülönüljön a memóriavezérlő (MMU) tartalmának jó részét át kell írni, amihez csak a kernel szintnek van joga. A folyamatok létrehozása, törlése és a kontextusváltás közöttük sokkal költségesebb a szálakénál.
Kontextusváltás: A másik folyamatnak/szálnak történő vezérlésátadás az operációs rendszer által, így egy processzor több szálat/ folyamatot is végre tud hajtani. Költséges művelet, mivel a CPU-nak el kell mentenie a folyamat állapotát (regiszterek, program számláló, stb.) és vissza kell állítania a következő folyamatét.
Ütemezés: Az ütemezés lényege az erőforrások (processzoridő, memória) hatékony kihasználása, ezek folyamatok közötti elosztása. Az ütemezés során a folyamatoknak különböző prioritásokat adhatunk, amik alapján az operációs rendszer dönt arról, hogy melyik folyamatot futtatja. Az ütemezés lehet preemptív (a kernel bármikor megszakíthatja a folyamatot) vagy kooperatív (a folyamatoknak maguknak kell megszakítaniuk egymást). Az ütemezés lehet időosztásos (time-sharing) vagy prioritásos (priority-based).
-
A preemptív ütemezés során a kernel megszakíthatja a folyamatot, ha egy magasabb prioritású folyamat érkezik. Ezzel a válaszidőt csökkenthetjük, de a kontextusváltás költségesebb, mivel a kernelnek el kell mentenie a folyamat állapotát és vissza kell állítania a következő folyamatét.
-
A kooperatív ütemezés során a folyamatoknak maguknak kell időnként visszaadniuk az irányítást az operációs rendszernek (yield), de a válaszidő növekedhet, mivel a folyamatok nem biztos, hogy időben megszakítják egymást.
Gyakori típusai:
- FIFO: Elsőként érkező elsőként kiszolgálás (First In First Out). Az elsőként érkező folyamatot azonnal kiszolgálja a rendszer, a következő folyamat csak akkor indulhat el, ha az előző befejeződött.
- Round Robin: Az időosztásos ütemezés egy változata, ahol minden folyamatnak egyenlő időtartamú időkeretet (time slice) adunk, aminek leteltével a következő folyamatra váltunk.
- EDF: A legközelebbi határidővel rendelkező folyamatot szolgálja ki először (Earliest Deadline First).
Linux kernelben:
- Régebben CFS (Completely Fair Scheduler), 6.6 óta EEVDF (Earliest Eligible Virtual Deadline First).
Race Condition
A race condition akkor fordul elő, amikor két vagy több szál párhuzamosan próbál hozzáférni egy megosztott erőforráshoz, és az erőforrás állapota a szálak közötti verseny miatt megváltozik. Ez hibás működéshez vezethet, mivel a szálak nem garantáltan a várt sorrendben férnek hozzá az erőforráshoz.
Példa
Tegyük fel, hogy van egy globális változónk, amelyet két szál próbál módosítani:
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#include <stdlib.h>
int shared_variable = 0;
void* thread_function(void* arg) {
for (int i = 0; i < 1000000; i++) {
shared_variable++;
}
return NULL;
}
int main() {
pthread_t thread1, thread2;
pthread_create(&thread1, NULL, thread_function, NULL);
pthread_create(&thread2, NULL, thread_function, NULL);
pthread_join(thread1, NULL);
pthread_join(thread2, NULL);
printf("Final value of shared_variable: %d\n", shared_variable);
return 0;
}
A fenti kód két szálat indít, amelyek mindketten növelik a shared_variable értékét. Mivel a két szál párhuzamosan fut, előfordulhat, hogy a shared_variable értéke nem 2000000 lesz, hanem kevesebb, mivel a két szál közötti verseny miatt a változó értéke nem mindig frissül helyesen. Ezért a program működése nem determinisztikus, és a végső érték változhat a különböző futások során.
A race condition elkerülésére különböző szinkronizációs mechanizmusokat alkalmazhatunk, mint például:
Atomi műveletek és atomi változók:
- Atomi műveletek: Ezek olyan műveletek, amelyeket a processzor egyetlen lépésben hajt végre, így garantálva, hogy más szálak nem tudják megzavarni őket. Az atomi műveletek használata segít elkerülni a versenyhelyzeteket, mivel biztosítják, hogy a művelet végrehajtása során a változó állapota nem változik meg más szálak által.
- Atomi változók: Ezek olyan változók, amelyek garantálják, hogy a műveletek végrehajtása atomikus módon történik. Az atomi változók használata segít elkerülni a versenyhelyzeteket, mivel biztosítják, hogy a művelet végrehajtása során a változó állapota nem változik meg más szálak által.
Zárak:
- Mutex: Egy kölcsönös kizárás (mutual exclusion) mechanizmus, amely biztosítja, hogy csak egy szál férhessen hozzá az erőforráshoz egy időben.
- RwLock: Egy olvasó-író zár, amely lehetővé teszi, hogy több szál olvashassa az erőforrást egy időben, de csak egy szál írhatja azt.
- Semaphore: Egy számláló alapú szinkronizációs mechanizmus, amely lehetővé teszi, hogy több szál férjen hozzá az erőforráshoz, de korlátozott számú példányban.
- Monitor: Egy magas szintű szinkronizációs mechanizmus, amely a mutex és a feltételes változók kombinációját használja.
Csatornák:
-
Channel/BlockingQueue: Egy szinkronizált kommunikációs mechanizmus, amely lehetővé teszi a szálak közötti üzenetküldést és adatátvitelt. A csatornák biztosítják, hogy az adatok biztonságosan és szinkronizált módon kerülnek átadásra a szálak között.
-
Feltételes változók: Ezek lehetővé teszik a szálak közötti kommunikációt és szinkronizációt, lehetővé téve a szálak számára, hogy várakozzanak egy adott feltétel teljesülésére.
A race condition megelőzése érdekében fontos, hogy a szálak közötti kommunikációt és erőforrás-hozzáférést gondosan tervezzük meg.
Szinkronizáció
A szinkronizáció célja a párhuzamosan futó szálak közötti koordináció és együttműködés biztosítása. A szinkronizációs mechanizmusok segítenek elkerülni a versenyhelyzeteket és biztosítják, hogy a szálak biztonságosan férjenek hozzá a megosztott erőforrásokhoz.
Szinkronizációs mechanizmusok típusai:
- Atomikus műveletek (Atomic Operations)
- Zárak (Locks)
- Szemaforok (Semaphores)
- Számlálók (Counters)
- Feltételes változók (Condition Variables)
- Csatornák (Channels)
- Szinkronizált adattípusok (Synchronized Data Types)
Deadlock
A deadlock (holtpont) akkor fordul elő, amikor két vagy több szál kölcsönösen várakozik egymásra, és egyikük sem tudja folytatni a végrehajtást. Ez általában akkor történik meg, amikor a szálak különböző erőforrásokat próbálnak megszerezni, és ezek az erőforrások kölcsönösen blokkolják egymást.
Példa
public class DeadlockExample {
private static final Object lock1 = new Object();
private static final Object lock2 = new Object();
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
synchronized (lock1) {
System.out.println("Thread 1: Holding lock 1...");
try { Thread.sleep(100); } catch (InterruptedException e) {}
System.out.println("Thread 1: Waiting for lock 2...");
synchronized (lock2) {
System.out.println("Thread 1: Acquired lock 2!");
}
}
});
Thread thread2 = new Thread(() -> {
synchronized (lock2) {
System.out.println("Thread 2: Holding lock 2...");
try { Thread.sleep(100); } catch (InterruptedException e) {}
System.out.println("Thread 2: Waiting for lock 1...");
synchronized (lock1) {
System.out.println("Thread 2: Acquired lock 1!");
}
}
});
thread1.start();
thread2.start();
}
}
A fenti kód két szálat indít, amelyek kölcsönösen várakoznak egymásra. Az első szál megszerzi az lock1 zárat, majd várakozik a lock2 zárra, míg a második szál megszerzi az lock2 zárat és várakozik az lock1 zárra. Ez deadlockhoz vezet, mivel egyik szál sem tudja folytatni a végrehajtást.
A deadlock elkerülésére különböző stratégiákat alkalmazhatunk, mint például:
- Erőforrások sorrendje: Az erőforrásokat mindig egy meghatározott sorrendben kérjük el, így elkerülve a kölcsönös várakozást.
- Időkorlátok: Az erőforrások megszerzésére időkorlátokat állíthatunk be, így ha egy szál nem tudja megszerezni az erőforrást, akkor visszaléphet és megpróbálhatja később.
- Deadlock detektálás: A rendszer folyamatosan figyeli a szálak állapotát, és ha deadlockot észlel, akkor megpróbálja feloldani azt (például egy szál leállításával). Ez általában költséges, mivel a rendszernek folyamatosan figyelnie kell a szálak állapotát.
Blokkoló műveletek
A blokkoló műveletek olyan műveletek, amelyek megakadályozzák a szálak további végrehajtását, amíg egy adott feltétel teljesül. Ezek a műveletek általában szinkronizációs mechanizmusokkal (például zárakkal vagy feltételes változókkal) valósulnak meg, de például fájlműveletekre (I/O) való várakozás, vagy a hálózati kommunikáció egyes stádiumai is blokkoló műveleteknek tekinthetők.
~~~ admonish note title="Megjegyzés" Kooperatív multitasking rendszereknél veszélyes lehet, mivel a blokkoló műveletek miatt a rendszer nem tudja hatékonyan kihasználni a processzoridőt. Ez főleg modern coroutine (async/await) alapú konkurens kódokban okozhat problémát, ahol a blokkoló műveletek miatt a szálak/taskok blokkolják az event loopot, és a válaszidő drasztikusan megnőhet. ~~~
Memória (stack és heap) használata szálakban
Minden szálnak saját verem (stack) memóriája van, amely a lokális változók és a függvényhívások tárolására szolgál. A verem mérete általában korlátozott, és a szálak közötti verem megosztása nem lehetséges. A heap memória viszont megosztott a szálak között, és a dinamikusan allokált objektumok tárolására szolgál. A heap memória használata során azonban figyelni kell a versenyhelyzetekre és a szinkronizációra, mivel több szál is hozzáférhet ugyanahhoz az objektumhoz.
A heap memória használata során a következő szempontokat érdemes figyelembe venni:
- Memória allokálás: A heap memóriát dinamikusan allokálhatjuk (pl
malloc,realloc), és felszabadíthatjuk (plfree) műveletekkel. - Memória szivárgás: A heap memória szivárgás akkor fordul elő, amikor a program nem szabadítja fel a már nem használt memóriaterületeket. Ez hosszú távon memóriahiányhoz vezethet. A legtöbb modern programozási nyelvben van beépített memória kezelés, amely segít elkerülni a memória szivárgás leggyakoribb forrásait (pl. garbage collector).
- Szinkronizáció: A heap memória használata során figyelni kell a szinkronizációra, mivel több szál is hozzáférhet ugyanahhoz az objektumhoz. A mutexek és egyéb szinkronizációs mechanizmusok használata segíthet elkerülni a versenyhelyzeteket.
A konkurens programozás nyelvi eszközei
TODO - Figure out what they meant by this
Szinkronizációhoz és kommunikációhoz használható adatszerkezetek
TODO - Figure out what they meant by this