T. J. Dekker nevéhez fűződik a kölcsönös kizárási probléma egyik legkorábbi, két folyamatra adott helyes szoftveres megoldása. A problémával Edsger W. Dijkstra is alapvető fontosságú munkákban foglalkozott.
A megoldandó probléma arról szól, hogy két külön szálon akarunk olyan feladatokat futtatni, amelyek valamilyen közös változót használnak, vagy ugyanarra a kimenetre írnak. A lényeg, hogy mindkét szálon futó függvénynek van olyan része, aminél garantálni kell, hogy ha épp az a kódrész fut, akkor a másik szálon nem fog a hasonlóan kölcsönös kizárást igénylő kódrész futni.
Naív implementáció
Dekker algoritmusának implementálásához két mindkét szál által elérhető változóra van szükség:
- Adott egy két elemű
booltömb, amelyet mindkét szál eléri:wantsToEnter. HawantsToEnter[i]értéketrue, akkor az i-edik szál be akar lépni a védett kódrészbe. Kezdetben a tömb mindkét elemefalseértékű. (4. sor) - Adott egy egész változó (
turn), ami azt jelzi, hogy melyik szál léphet éppen be a védett kódrészbe. Értelem szerint csak a 0 és 1 értéket veheti fel ez a változó. Kezdeti értéke bármelyik lehet, az adott szál fog kezdetben elsőbbséget élvezni. (5. sor)
Hogyan futtatja az id-adik szál a védett kódrészt?
A
wantsToEnter[id]-n keresztül jelzi, hogy be kíván lépni a védett kódrészbe. (14. sor)Megvizsgálja, hogy a másik szál is be kíván-e épp lépni a saját védett kódrészébe. (17. sor) Ha nem, akkor megtörténhet a belépés. (41. sor)
Ha viszont a másik szál is épp használni akarja a védett kódrészt, akkor egy ciklus addig fut, amíg a másik szál
wantsToEnterjelölője hamissá nem válik. (17. sor)A cikluson belül a
turnértéken keresztül megvizsgálja az adott szál, hogy ő van-e soron. (20. sor)Ha nem ő van soron, akkor a belépési szándékot
wantsToEnter[id]értékétfalse-ra állítja. (24. sor) Ezzel a másik szálon futó hasonló függvény ciklusából megtörténhet a kilépés.Ezután az adott sor addig várakozik, amíg nem ő kerül sorra. Ehhez a
turnértékét kell folyamatosan vizsgálni. (27. sor)Ha ő kerül sorra, akkor visszaállítja a
wantsToEnter[id]értékét, így rákényszeríti a másik szálat a várakozásra. (33. sor)Ezt követően, ha a ciklusfeltétel hamissá válik, akkor beléphet az adott szál a védett kódrészbe.
A védett kódrész végrehajtását követően a másik szál számára lehetővé tesszük a belépést a
turnértékének megváltoztatásával. (51. sor)Végül jelzi az adott szál, hogy ő épp nem akar belépni, ezért a
wantsToEnter[id]hamis értéket kap. (54. sor)
| |
Probléma a naív implementációnál
A fenti kód általános értelemben helyesnek tűnhet, C++ programként azonban hibás.
A wantsToEnter tömb elemeit és a turn változót több szál egyszerre olvassa és módosítja. Mivel ezek hagyományos, nem ún. atomi változók, a hozzáférések között data race alakulhat ki.
A C++ memória-modell szerint egy data race a program undefined behavior működését eredményezheti. Ez nem egyszerűen azt jelenti, hogy “nem tudjuk, milyen értéket kapunk”, hanem a fordító semmilyen értelmes többszálú viselkedést nem köteles megőrizni.
Emiatt egy klasszikus, megosztott változókon alapuló Dekker-algoritmust C++-ban nem lehet egyszerű bool és int változókkal implementálni.
std::atomic alapú implementáció
A problémára megoldást adhat, ha a érintett változókat atomiként értelmezzük. Ennek érdekében a kódot az alábbi módon kell módosítanunk.
| |
Fontos, hogy ebben az implementációban az atomi műveletek
std::memory_order_seq_cstmemória-sorrendet használjanak.A Dekker-algoritmus helyessége nemcsak azon alapul, hogy az egyes változók írása és olvasása atomi legyen, hanem azon is, hogy a két szál a releváns atomi műveleteket megfelelő sorrendben figyelje meg.
A
std::memory_order_seq_cstaz atomi műveletekre egy, minden szál számára konzisztens globális sorrendet biztosít. Azstd::atomicműveletek alapértelmezett memória-sorrendje éppenstd::memory_order_seq_cst, ezért a példában ezt nem szükséges külön megadni.Gyengébb memória-sorrend, például
std::memory_order_relaxedhasználata esetén az egyes atomi hozzáférések továbbra is atomikusak lennének, de ez önmagában már nem elegendő Dekker algoritmusának kölcsönös kizárási garanciájához.
std::mutex alapú megvalósítás
Dekker algoritmusa megmutatja, hogyan lehet kizárólag megosztott változók segítségével megvalósítani a kölcsönös kizárást. Modern C++ kódban azonban erre általában nem magát Dekker algoritmusát implementáljuk, hanem a szabványos szinkronizációs primitíveket, például std::mutex-et használunk.
| |