4. Többszálú alkalmazások készítése¶
A gyakorlat célja¶
A gyakorlat célja, hogy megismertesse a hallgatókat a többszálas programozás során követendő alapelvekkel. Érintett témakörök (többek között):
- Szálak indítása (
Thread) - Szálak leállítása
- Szálbiztos (thread safe) osztályok készítése a
lockkulcsszó alkalmazásával ThreadPoolhasználata- Jelzés és jelzésre várakozás szál szinkronizáció
ManualResetEventsegítségével (WaitHandle) - WinUI szálkezelési sajátosságok (
DispatcherQueue)
Természetesen, mivel a témakör hatalmas, csak alapszintű tudást fogunk szerezni, de e tudás birtokában már képesek leszünk önállóan is elindulni a bonyolultabb feladatok megvalósításában.
A kapcsolódó előadások: Konkurens (többszálú) alkalmazások fejlesztése.
Előfeltételek¶
A gyakorlat elvégzéséhez szükséges eszközök:
- Visual Studio 2022
- Windows Desktop Development Workload
- Windows 10 vagy Windows 11 operációs rendszer (Linux és macOS nem alkalmas)
Megoldás¶
A kész megoldás letöltése
Lényeges, hogy a labor során a laborvezetőt követve kell dolgozni, tilos (és értelmetlen) a kész megoldás letöltése. Ugyanakkor az utólagos önálló gyakorlás során hasznos lehet a kész megoldás áttekintése, így ezt elérhetővé tesszük.
A megoldás GitHubon érhető el. A legegyszerűbb mód a letöltésére, ha parancssorból a git clone utasítással leklónozzuk a gépünkre a megoldas ágat:
git clone https://github.com/bmeviauab00/lab-tobbszalu-kiindulo -b megoldas
Ehhez telepítve kell legyen a gépre a parancssori git, bővebb információ itt.
Bevezető¶
A párhuzamosan futó szálak kezelése kiemelt fontosságú terület, melyet minden szoftverfejlesztőnek legalább alapszinten ismernie kell. A gyakorlat során alapszintű, de kiemelt fontosságú problémákat oldunk meg, ezért törekednünk kell arra, hogy ne csak a végeredményt, hanem az elvégzett módosítások értelmét és indokait is megértsük.
A feladat során egyszerű WinUI alkalmazást fogunk felruházni többszálas képességekkel, egyre komplexebb feladatokat megoldva. Az alapprobléma a következő: van egy függvényünk, mely hosszú ideig fut, s mint látni fogjuk, ennek „direktben” történő hívása a felületről kellemetlen következményekkel jár. A megoldás során egy meglévő alkalmazást fogunk kiegészíteni saját kódrészletekkel. Az újonnan beszúrandó sorokat az útmutatóban kiemelt háttér jelzi.
0. Feladat - Ismerkedés a kiinduló alkalmazással, előkészítés¶
Klónozzuk le a 4. gyakorlathoz tartozó kiinduló alkalmazás repositoryját:
- Nyissunk egy command prompt-ot
- Navigáljunk el egy tetszőleges mappába, például c:\work\NEPTUN
- Adjuk ki a következő parancsot:
git clone https://github.com/bmeviauab00/lab-tobbszalu-kiindulo.git - Nyissuk meg a SuperCalculator.sln solutiont Visual Studio-ban.
A feladatunk az, hogy egy bináris formában megkapott algoritmus futtatásához WinUI technológiával felhasználói felületet készítsünk. A bináris forma .NET esetében egy .dll kiterjesztésű fájlt jelent, ami programozói szemmel egy osztálykönyvtár. A fájl neve esetünkben Algorithms.dll, megtalálható a leklónozott Git repositoryban.
A kiinduló alkalmazásban a felhasználói felület elő is van készítve. Futtassuk az alkalmazást:
Az alkalmazás felületén meg tudjuk adni az algoritmus bemenő paramétereit (double számok tömbje): a példánkban mindig két double szám paraméterrel hívjuk az algoritmust, ezt a két felső szövegmezőben lehet megadni.
A feladatunk az, hogy a Calculate Result gombra kattintás során futtassuk az algoritmust a megadott paraméterekkel, majd, ha végzett, akkor a Result alatti listázó mező új sorában jelenítsük meg a kapott eredményt a bemenő paraméterekkel együtt.
Következő lépésben ismerkedjünk meg a letöltött Visual Studio solutionnel:
A keretalkalmazás egy WinUI 3 alapú alkalmazás. A felület alapvetően kész, definíciója a MainWindow.xaml fájlban található. Ez számunkra a gyakorlat célját tekintve kevésbé izgalmas, de otthon a gyakorlás kedvéért érdemes áttekinteni.
Felület kialakítása a MainWindow.xaml-ben
Az ablakfelület kialakításának alapjai:
- A gyökérelem (root) "szokásosan" egy
Grid. - A gyökér
Gridfelső sorában található a kétTextBox-ot és aButton-t tartalmazóStackPanel. - A gyökér
Gridalsó sorában egy másikGridtalálható. ATextBox-szal ellentétben aListBoxnem rendelkezikHeadertulajdonsággal, így ezt nekünk kellett egy különálló "Result" szövegűTextBlockformájában bevezetni. Ezt aGrid-et azért vezettük be (egy "egyszerűbb"StackPanelhelyett), mert így lehetett elérni, hogy a felső sorában a "Result"TextBlockfix magasságú legyen, az alsó sorban pedig aListBoxtöltse ki a teljes maradó helyet (a felső sor magasságaAuto, az alsó sor magassága*). - A "Calculate Result" szövegű gomb szép példa arra, hogy a
ButtonContent-jének sokszor nemcsak egy egyszerű szöveget adunk meg. A példában egySymbolIconés aTextBlockkompozíciója (StackPanelsegítségével megvalósítva), ezáltal tudjunk a egy megfelelő ikont/szimbólumot rendelni, mely feldobja a megjelenését. - Arra is látunk példát, hogy a
ListBoxhogyan tehető görgethetővé, ha már sok elem van benne (vagy túl szélesek az elemek). Ehhez aScrollViewer-ét kell megfelelően paraméterezni. - A
ListBoxItemContainerStyletulajdonságával aListBoxelemre adhatunk meg stílusokat. A példában aPadding-et vettük kisebbre az alapértelmezettnél, enélkül aListBoxelemek magassága helypazarlóan nagy lenne.
A MainWindow.xaml.cs forrásfájl a főablakhoz tartozó code behind fájl, ezt tekintsük át, főbb elemei a következők:
- Az eredmény és a paraméterek
ListBox-ba történő naplózásához találunk egyShowResultnevű segédfüggvényt. - A
CalculateResultButton_Clicka gomb a Calculate Result gomb kattintásához tartozó eseménykezelő. Azt látjuk, hogy a két szövegdobozból kiolvassa a paraméterek értékét, és megpróbálja számmá alakítani. Ha sikerül, akkor itt történik majd az algoritmus hívása (ez nincs még megvalósítva), illetve, ha nem sikerül, akkor aDisplayInvalidElementDialogsegítségével egy üzenetablakban tájékoztatja a felhasználót az érvénytelen paraméterekről. - A konstruktorból hívott
AddKeyboardAcceleratorToChangeThemefüggvény számunkra nem releváns, a világos és sötét téma közötti váltást teszi lehetővé (futás közben érdemes kipróbálni, Ctrl+T billentyűkombináció).
A DLL-ben levő kód felhasználása¶
A kiinduló projektben megtaláljuk a Algorithm.dll-t. Ebben lefordított formában egy Algorithms névtérben levő SuperAlgorithm nevű osztály található, melynek egy Calculate nevű statikus művelete van. Ahhoz, hogy egy projektben fel tudjuk használni a DLL-ben levő osztályokat, a DLL-re a projektünkben egy ún. referenciát kell felvegyünk.
-
Solution Explorerben a projektünk Dependencies node-jára jobbklikkelve válasszuk az Add Project reference opciót!
Külső referenciák
Itt valójában nem egy másik Visual Studio projektre adunk referenciát, de így a legegyszerűbb előhozni ezt az ablakot.
Megemlítendő még, hogy külső osztálykönyvtárak esetében már nem DLL-eket szoktunk referálni egy rendes projektben, hanem a .NET csomagkezelő rendszeréből a NuGet-ről szokás a külső csomagokat beszerezni. Most az Algorithm.dll esetünkben nincs NuGet-en publikálva, ezért kell kézzel felvegyük azt.
-
Az előugró ablak jobb alsó sarokban található Browse gomb segítségével keressük meg és válasszuk ki projekt External almappájában található Algorithms.dll fájlt, majd hagyjuk jóvá a hozzáadást az OK gombbal!
A Solution Explorerben egy projekt alatti Dependencies csomópontot lenyitva láthatjuk a hivatkozott külső függőségeket. Itt most már megjelenik az Assemblyk között előbb felvett Algorithms referencia is. A Frameworks kategóriában a .NET keretrendszer csomagjait találjuk. Az Analyzerek pedig statikus kódelemző eszközök fordítás időben. Illetve itt lennének még a projekt vagy a NuGet referenciák is.
Kattintsunk Algorithms referencián jobb gombbal és válasszuk a View in Object Browser funkciót. Ekkor megnyílik az Object Browser tabfül, ahol megtekinthetjük, hogy az adott DLL-ben milyen névterek, osztályok találhatók, illetve ezeknek milyen tagjaik (tagváltozó, tagfüggvény, property, event) vannak. Ezeket a Visual Studio a DLL metaadataiból az ún. reflection mechanizmus segítségével olvassa ki (ilyen kódot akár mi is írhatunk).
Az alábbi ábrának megfelelően az Object Browserben baloldalt keressük ki az Algorithms csomópontot, nyissuk le, és láthatóvá válik, hogy egy Algorithms névtér van benne, abban pedig egy SuperAlgorithm osztály. Ezt kiválasztva középen megjelennek az osztály függvényei, itt egy függvényt kiválasztva pedig az adott függvény pontos szignatúrája:
1. Feladat – Művelet futtatása a főszálon¶
Most már rátérhetünk az algoritmus futtatására. Első lépésben ezt az alkalmazásunk fő szálán tesszük meg.
-
A főablakon lévő gomb
Clickeseménykezelőjében hívjuk meg a számoló függvényünket. Ehhez a Solution Explorerben nyissuk meg aMainWindow.xaml.cscode behind fájlt, és keressük meg aCalculateResultButton_Clickeseménykezelőt. Egészítsük ki a kódot az újonnan behivatkozott algoritmus meghívásával.private void CalculateResultButton_Click(object sender, RoutedEventArgs e) { if (double.TryParse(param1TextBox.Text, out var p1) && double.TryParse(param2TextBox.Text, out var p2)) { var parameters = new double[] { p1, p2 }; var result = Algorithms.SuperAlgorithm.Calculate(parameters); ShowResult(parameters, result); } else DisplayInvalidElementDialog(); } -
Próbáljuk ki az alkalmazást, és vegyük észre, hogy az ablak a számolás ideje alatt nem reagál a mozgatásra, átméretezésre, a felület gyakorlatilag befagy.
Az alkalmazásunk eseményvezérelt, mint minden Windows alkalmazás. Az operációs rendszer a különböző interakciókról (pl. mozgatás, átméretezés, egérkattintás) értesíti az alkalmazásunkat: mivel a gombnyomást követően az alkalmazásunk egyetlen szála a kalkulációval van elfoglalva, nem tudja azonnal feldolgozni a további felhasználói utasításokat. Amint a számítás lefutott (és az eredmények megjelennek a listában) a korábban kapott parancsok is végrehajtásra kerülnek.
2. Feladat – Végezzük a számítást külön szálban¶
Következő lépésben a számítás elvégzésére egy külön szálat fogunk indítani, hogy az ne blokkolja a felhasználói felületet.
-
Készítsünk egy új függvényt a
MainWindowosztályban, mely a feldolgozó szál belépési pontja lesz.private void CalculatorThread(object arg) { var parameters = (double[])arg; var result = Algorithms.SuperAlgorithm.Calculate(parameters); ShowResult(parameters, result); } -
Indítsuk el a szálat a gomb
Clickeseménykezelőjében. Ehhez cseréljük le a korábban hozzáadott kódot:private void CalculateResultButton_Click(object sender, RoutedEventArgs e) { if (double.TryParse(param1TextBox.Text, out var p1) && double.TryParse(param2TextBox.Text, out var p2)) { var parameters = new double[] { p1, p2 }; var th = new Thread(CalculatorThread); th.Start(parameters); } else DisplayInvalidElementDialog(); }A Thread objektum
Startműveletében átadott paramétert kapja meg aCalculatorThreadszálfüggvényünk. -
Futtassuk az alkalmazást F5-tel (most fontos, hogy így, a debuggerben futtassuk)! The application called an interface that was marshalled for a different thread. (0x8001010E (RPC_E_WRONG_THREAD)) hibaüzenetet kapunk a
ShowResultmetódusban, ugyanis nem abból a szálból próbálunk hozzáférni a UI elemhez / vezérlőhöz, amelyik létrehozta (a vezérlőt). A következő feladatban ezt a problémát analizáljuk és oldjuk meg.
3. Feladat – a DispatcherQueue.HasThreadAccess és DispatcherQueue.TryEnqueue használata¶
Az előző pontban a problémát a következő okozza. WinUI alkalmazásoknál él az alábbi szabály: az ablakok/felületelemek/vezérlőelemek alapvetően nem szálvédett (thread safe) objektumok, így egy ablakhoz/felületelemhez/vezérlőhöz csak abból a szálból szabad hozzáférni (pl. propertyjét olvasni, állítani, műveletét meghívni), amelyik szál az adott ablakot/felületelemet/vezérlőt létrehozta, máskülönben kivételt kapunk.
Alkalmazásunkban azért kaptunk kivételt, mert a resultListBox vezérlőt a fő szálban hoztuk létre, a ShowResult metódusban az eredmény megjelenítésekor viszont egy másik szálból férünk hozzá (resultListBox.Items.Add művelet hívása).
Kérdés, hogyan lehet mégis valamilyen módon ezekhez a felületelemekhez/vezérlőkhöz egy másik szálból hozzáférni. A megoldást a DispatcherQueue alkalmazása jelenti, mely abban nyújt segítséget, hogy a vezérlőkhöz mindig a megfelelő szálból történjen a hozzáférés:
DispatcherQueueobjektumTryEnqueuefüggvénye a vezérlőelemet létrehozó szálon futtatja le a számára paraméterként megadott függvényt (mely függvényből így már közvetlenül hozzáférhetünk a vezérlőhöz).- A
DispatcherQueueobjektumHasThreadAccesstulajdonsága azt segít eldönteni, szükség van-e egyáltalán az előző pontban említettTryEnqueuealkalmazására. Ha a tulajdonság értéke- igaz, akkor a vezérlőhöz közvetlenül is hozzáférhetünk (mert az aktuális szál megegyezik a vezérlőt létrehozó szállal), ellenben ha
- hamis, akkor a vezérlőhöz csak "kerülő úton", a
DispatcherQueueobjektumTryEnqueuesegítségével férhetünk hozzá (mert az aktuális szál NEM egyezik a vezérlőt létrehozó szállal).
A DispatcherQueue segítségével tehát el tudjuk kerülni korábbi kivételünket (a vezérlőhöz, esetünkben a resultListBox-hoz való hozzáférést a megfelelő szálra tudjuk "irányítani"). Ezt fogjuk a következőkben megtenni.
Note
A DispatcherQueue objektum a Window osztály leszármazottakban érhető el aDispatcherQueue tulajdonságán keresztül (más osztályokban pedig a DispatcherQueue.GetForCurrentThread() statikus művelet segítségével szerezhető meg).
Módosítanunk kell a ShowResult metódust annak érdekében, hogy mellékszálból történő hívás esetén se dobjon kivételt.
private void ShowResult(double[] parameters, double result)
{
// Closing the window the DispatcherQueue property may return null, so we have to perform a null check
if (this.DispatcherQueue == null)
return;
if (this.DispatcherQueue.HasThreadAccess)
{
var item = new ListBoxItem()
{
Content = $"{parameters[0]} # {parameters[1]} = {result}"
};
resultListBox.Items.Add(item);
resultListBox.ScrollIntoView(item);
}
else
{
this.DispatcherQueue.TryEnqueue( () => ShowResult(parameters, result) );
}
}
Próbáljuk ki!
Ez a megoldás már működőképes, főbb elemei a következők:
- A
DispatcherQueuenullvizsgálat szerepe: a főablak bezárása után aDispatcherQueuemárnull, nem használható. - A
DispatcherQueue.HasThreadAccesssegítségével megnézzük, hogy a hívó szál hozzáférhet-e közvetlenül a vezérlőkhöz (esetünkben aListBox-hoz):- Ha igen, minden úgy történik, mint eddig, a
ListBox-ot kezelő kód változatlan. - Ha nem, a
DispatcherQueue.TryEnqueuesegítségével férünk hozzá a vezérlőhöz. A következő trükköt alkalmazzuk. ATryEnqueuefüggvénynek egy olyan paraméter nélküli, egysoros függvényt adunk meg lambda kifejezés formájában, mellyel aShowResultfüggvényünket hívja meg (gyakorlatilag rekurzívan), a paramétereket tovább passzolva számára. Ez nekünk azért jó, mert ez aShowResulthívás már azon a szálon történik, mely a vezérlőt létrehozta (az alkalmazás fő szála), ebben aHasThreadAccessértéke már igaz, és hozzá tudunk férni közvetlenül aListBox-unkhoz. Ez a rekurzív megközelítés egy bevett minta a redundáns kódok elkerülésére.
- Ha igen, minden úgy történik, mint eddig, a
Tegyünk töréspontot a ShowResult művelet első sorára, és az alkalmazást futtatva győződjünk meg arról, hogy a ShowResult művelet első hívásakor HasThreadAccess még hamis (így megtörténik a TryEnqueue hívása), majd ennek hatására még egyszer meghívódik a ShowResult, de ekkor a HasThreadAccess értéke már igaz.
Vegyük ki a töréspontot, így futtassuk az alkalmazást: vegyük észre, hogy amíg egy számítás fut, újabbakat is indíthatunk, hiszen a felületünk végig reszponzív maradt (a korábban tapasztalt hiba pedig már nem jelentkezik).
4. feladat – Művelet végzése Threadpool szálon¶
Az előző megoldás egy jellemzője, hogy mindig új szálat hoz létre a művelethez. Esetünkben ennek nincs különösebb jelentősége, de ez a megközelítés egy olyan kiszolgáló alkalmazás esetében, amely nagyszámú kérést szolgál ki úgy, hogy minden kéréshez külön szálat indít, már problémás lehet. Két okból is:
- Ha a szálfüggvény gyorsan lefut (egy kliens kiszolgálása gyors), akkor a CPU nagy részét arra pazaroljuk, hogy szálakat indítsunk és állítsunk le, ezek ugyanis önmagukban is erőforrásigényesek.
- Túl nagy számú szál is létrejöhet, ennyit kell ütemeznie az operációs rendszernek, ami feleslegesen pazarolja az erőforrásokat.
Egy másik probléma jelen megoldásunkkal: mivel a számítás ún. előtérszálon fut (az újonnan létrehozott szálak alapértelmezésben előtérszálak), hiába zárjuk be az alkalmazást, a program tovább fut a háttérben mindaddig, amíg végre nem hajtódik az utoljára indított számolás is: egy processz futása ugyanis akkor fejeződik csak be, ha már nincs futó előtérszála.
Módosítsuk a gomb eseménykezelőjét, hogy új szál indítása helyett threadpool szálon futtassa a számítást. Ehhez csak a gombnyomás eseménykezelőjét kell ismét átírni.
private void CalculateResultButton_Click(object sender, RoutedEventArgs e)
{
if (double.TryParse(param1TextBox.Text, out var p1) && double.TryParse(param2TextBox.Text, out var p2))
{
var parameters = new double[] { p1, p2 };
ThreadPool.QueueUserWorkItem(CalculatorThread, parameters);
}
else
DisplayInvalidElementDialog();
}
Próbáljuk ki az alkalmazást, és vegyük észre, hogy az alkalmazás az ablak bezárásakor azonnal leáll, nem foglalkozik az esetlegesen még futó szálakkal (mert a threadpool szálak háttér szálak).
5. Feladat – Termelő-fogyasztó alapú megoldás¶
Az előző feladatok megoldása során önmagában egy jól működő komplett megoldását kaptuk az eredeti problémának, mely lehetővé teszi, hogy akár több munkaszál is párhuzamosan dolgozzon a háttérben a számításon, ha a gombot sokszor egymás után megnyomjuk. A következőkben úgy fogjuk módosítani az alkalmazásunkat, hogy a gombnyomásra ne mindig keletkezzen új szál, hanem a feladatok bekerüljenek egy feladatsorba, ahonnan több, a háttérben folyamatosan futó szál egymás után fogja kivenni őket és végrehajtani. Ez a feladat a klasszikus termelő-fogyasztó probléma, mely a gyakorlatban is sokszor előfordul, a működését az alábbi ábra szemlélteti.
Termelő fogyasztó vs ThreadPool
Ha belegondolunk, a ThreadPool is egy speciális, a .NET által számunkra biztosított termelő-fogyasztó és ütemező mechanizmus. A következőkben egy más jellegű termelő-fogyasztó megoldást dolgozunk ki annak érdekében, hogy bizonyos szálkezeléssel kapcsolatos konkurencia problémákkal találkozhassunk.
A főszálunk a termelő, a Calculate result gombra kattintva hoz létre egy új feladatot. Fogyasztó/feldolgozó munkaszálból azért indítunk majd többet, mert így több CPU magot is ki tudunk használni, valamint a feladatok végrehajtását párhuzamosítani tudjuk.
A feladatok ideiglenes tárolására a kiinduló projektünkben már némiképpen előkészített DataFifo osztályt tudjuk használni (a Solution Explorerben a Data mappában található). Nézzük meg a forráskódját. Egy egyszerű FIFO sort valósít meg, melyben double[] elemeket tárol. A Put metódus hozzáfűzi a belső lista végéhez az új párokat, míg a TryGet metódus visszaadja (és eltávolítja) a belső lista első elemét. Amennyiben a lista üres, a függvény nem tud visszaadni elemet. Ilyenkor a false visszatérési értékkel jelzi ezt.
-
Módosítsuk a gomb eseménykezelőjét, hogy ne
ThreadPoolba dolgozzon, hanem a FIFO-ba:private void CalculateResultButton_Click(object sender, RoutedEventArgs e) { if (double.TryParse(param1TextBox.Text, out var p1) && double.TryParse(param2TextBox.Text, out var p2)) { var parameters = new double[] { p1, p2 }; _fifo.Put(parameters); } else DisplayInvalidElementDialog(); } -
Készítsük el az új szálkezelő függvény naiv implementációját az űrlap osztályunkban:
private void WorkerThread() { while (true) { if (_fifo.TryGet(out var data)) { double result = Algorithms.SuperAlgorithm.Calculate(data); ShowResult(data, result); } Thread.Sleep(500); } }A
Thread.Sleepbevezetésére azért van szükség, mert e nélkül a munkaszálak üres FIFO esetén folyamatosan feleslegesen pörögnének, semmi hasznos műveletet nem végezve is 100%-ban kiterhelnének egy-egy CPU magot. Megoldásunk nem ideális, később továbbfejlesztjük. -
Hozzuk létre, és indítsuk el a feldolgozó szálakat a konstruktorban:
new Thread(WorkerThread) { Name = "Worker thread 1" }.Start(); new Thread(WorkerThread) { Name = "Worker thread 2" }.Start(); new Thread(WorkerThread) { Name = "Worker thread 3" }.Start(); -
Indítsuk el az alkalmazást, majd zárjuk is be azonnal anélkül, hogy a Calculate Result gombra kattintanánk. Az tapasztaljuk, hogy az ablakunk bezáródik ugyan, de a processzünk tovább fut, az alkalmazás bezárására csak a Visual Studioból, vagy a Task Managerből van lehetőség:
A feldolgozó szálak előtérszálak, kilépéskor megakadályozzák a processz megszűnését. Az egyik megoldás az lehetne, ha a szálak
IsBackgroundtulajdonságáttrue-ra állítanánk a létrehozásukat követően. A másik megoldás, hogy kilépéskor gondoskodunk a feldolgozó szálak kiléptetéséről. Egyelőre tegyük félre ezt a problémát, később visszatérünk rá. -
Indítsuk el az alkalmazást. Azt tapasztaljuk, hogy miután kattintunk a Calculate Result gombon (csak egyszer kattintsunk rajta) nagy valószínűséggel kivételt fogunk kapni. A probléma az, hogy a
DataFifonem szálbiztos, inkonzisztensé vált. Két eredő ok is húzódik a háttérben:
Probléma 1¶
Nézzük a következő forgatókönyvet:
- A sor üres. A feldolgozó szálak egy
whileciklusban folyamatosan pollozzák a FIFO-t, vagyis hívják aTryGetmetódusát. - A felhasználó egy feladatot tesz a sorba.
- Az egyik feldolgozó szál a
TryGetmetódusban azt látja, van adat a sorban, vagyisif ( _innerList.Count > 0 )kódsor feltétele teljesül, és rálép a következő kódsorra. Tegyük fel, hogy ez a szál ebben a pillanatban elveszti a futási jogát, már nincs ideje kivenni az adatot a sorból. - Egy másik feldolgozó szál is éppen ekkor ejti meg az
if ( _innerList.Count > 0 )vizsgálatot, nála is teljesül a feltétel, és ez a szál ki is veszi az adatot a sorból. - Az első szálunk újra ütemezésre kerül, felébred, ő is megpróbálja kivenni az adatot a sorból: a sor viszont már üres, a másik szálunk kivette az egyetlen adatot a sorból az orra előtt. Így az
_innerList[0]hozzáférés kivételt eredményez.
Ezt a problémát csak úgy tudjuk elkerülni, ha a sor ürességének a vizsgálatát és az elem kivételét "oszthatatlanná" tesszük: ez azt jelenti, hogy amíg az egyik szál nem végzett mindkettővel, addig a többi szálnak várnia kell!
Thread.Sleep(500)
Az ürességvizsgálatot figyelő kódsort követő Thread.Sleep(500); kódsornak csak az a szerepe a példakódunkban, hogy a fenti peches forgatókönyv bekövetkezésének a valószínűségét megnövelje, s így a példát szemléletesebbé tegye (mivel ilyenkor szinte biztos, hogy átütemeződik a szál). A későbbiekben ezt ki is fogjuk venni, egyelőre hagyjuk benne.
Probléma 2¶
A DataFifo osztály egyidőben több szálból is hozzáférhet a List<double[]> típusú _innerList tagváltozóhoz. Ugyanakkor, ha megnézzük a List<T> dokumentációját, azt találjuk, hogy az osztály nem szálbiztos (not thread safe). Ez esetben viszont ezt nem tehetjük meg, nekünk kell a kölcsönös kizárást zárakkal biztosítanunk: meg kell oldjuk, hogy a szálaink egyidőben csak egy metódusához / tulajdonságához / tagváltozójához férjenek hozzá (pontosabban inkonzisztencia csak egyidejű írás, illetve egyidejű írás és olvasás esetén léphet fel, de az írókat és az olvasókat a legtöbb esetben nem szoktuk megkülönböztetni, itt sem tesszük).
A következő lépésben a DataFifo osztályunkat szálbiztossá tesszük, amivel megakadályozzuk, hogy a fenti két probléma bekövetkezhessen.
6. feladat – Tegyük szálbiztossá a DataFifo osztályt¶
A DataFifo osztály szálbiztossá tételéhez szükségünk van egy objektumra (ez bármilyen referencia típusú objektum lehet), melyet kulcsként használhatunk a zárolásnál. Ezt követően a lock kulcsszó segítségével el tudjuk érni, hogy egyszerre mindig csak egy szál tartózkodjon az adott kulccsal védett blokkokban.
-
Vegyünk fel egy
objecttípusú mezőt_syncRootnéven aDataFifoosztályba.private object _syncRoot = new object(); -
Egészítsük ki a
Putés aTryGetfüggvényeket a zárolással.public void Put(double[] data) { lock (_syncRoot) { _innerList.Add(data); } }public bool TryGet(out double[] data) { lock (_syncRoot) { if (_innerList.Count > 0) { Thread.Sleep(500); data = _innerList[0]; _innerList.RemoveAt(0); return true; } } data = null; return false; }Surround with
Használjuk a Visual Studio Surround with funkcióját a CTRL + K, CTRL + S billentyű kombinációjával a körülvenni kívánt kijelölt kódrészleten.
Most már nem szabad kivételt kapnunk.
Ki is vehetjük a TryGet metódusból a mesterséges késleltetést (Thread.Sleep(500); sor).
Lockolás this-en
Felmerülhet a kérdés, hogy miért vezettünk be egy külön _syncRoot tagváltozót és használtuk ezt zárolásra a lock paramétereként, amikor a this-t is használhattuk volna helyette (a DataFifo referencia típus, így ennek nem lenne akadálya). A this alkalmazása azonban sértené az osztályunk egységbezárását! Ne feledjük: a this egy referencia az objektumunkra, de más osztályoknak is van ugyanerre az objektumra referenciájuk (pl. esetünkben a MainWindow-nak van referenciája a DataFifo-ra), és ha ezek a külső osztályok zárat tesznek a lock segítségével az objektumra, akkor az "interferál" az általunk az osztályon belük használt zárolással (mivel this alkalmazása miatt a külső és belső lock-ok paramétere ugyanaz lesz). Így pl. egy külső zárral teljesen meg lehet "bénítani" a TryGet és Put művelet működését. Ezzel szemben az általunk választott megoldásban a lock paramétere, a _syncRoot változó privát, ehhez már külső osztályok nem férhetnek hozzá, így nem is zavarhatják meg az osztályunk belső működését.
7. feladat – Hatékony jelzés megvalósítása¶
ManualResetEvent használata¶
A WorkerThread-ben folyamatosan futó while ciklus ún. aktív várakozást valósít meg, ami mindig kerülendő. Ha a Thread.Sleep-et nem tettük volna a ciklusmagba, akkor ezzel maximumra ki is terhelné a processzort. A Thread.Sleep megoldja ugyan a processzor terhelés problémát, de bevezet egy másikat: ha mindhárom munkaszálunk éppen alvó állapotba lépett, mikor beérkezik egy új adat, akkor feleslegesen várunk 500 ms-ot az adat feldolgozásának megkezdéséig.
A következőkben úgy fogjuk módosítani az alkalmazást, hogy blokkolva várakozzon, amíg adat nem kerül a FIFO-ba (amikor viszont adat kerül bele, azonnal kezdje meg a feldolgozást). Annak jelzésére, hogy van-e adat a sorban egy ManualResetEvent-et fogunk használni.
-
Adjunk hozzá egy
MaunalResetEventpéldányt aDataFifoosztályunkhoz_hasDatanéven.// A false konstruktor paraméter eredményeképpen kezdetben az esemény nem jelzett (kapu csukva) private ManualResetEvent _hasData = new ManualResetEvent(false); -
A
_hasDataalkalmazásunkban kapuként viselkedik. Amikor adat kerül a listába „kinyitjuk”, míg amikor kiürül a lista „bezárjuk”.Az esemény szemantikája és elnevezése
Lényeges, hogy jó válasszuk meg az eseményünk szemantikáját és ezt a változónk nevével pontosan ki is fejezzük. A példánkban a
_hasDatanév jól kifejezi, hogy pontosan akkor és csak akkor jelzett az eseményünk (nyitott a kapu), amikor van feldolgozandó adat. Most már "csak" az a dolgunk, hogy ezt a szemantikát megvalósítsuk: jelzettbe tegyük az eseményt, mikor adat kerül a FIFO-ba, és jelzetlenbe, amikor kiürül a FIFO.public void Put(double[] data) { lock (_syncRoot) { _innerList.Add(data); _hasData.Set(); } }public bool TryGet(out double[] data) { lock (_syncRoot) { if (_innerList.Count > 0) { data = _innerList[0]; _innerList.RemoveAt(0); if (_innerList.Count == 0) { _hasData.Reset(); } return true; } } data = null; return false; }
Jelzésre várakozás (blokkoló a Get)¶
Az előző pontban megoldottuk a jelzést, ám ez önmagában nem sokat ér, hiszen nem várakoznak rá. Ennek megvalósítása jön most.
-
Módosítsuk a metódust az alábbiak szerint: szúrjuk be a
_hasDataeseményre várakozást.public bool TryGet(out double[] data) { lock (_syncRoot) { _hasData.WaitOne(); if (_innerList.Count > 0) // ...A WaitOne művelet visszatérési értéke
A
WaitOneművelet egyboolértékkel tér vissza, mely igaz, ha aWaitOneparaméterében megadott időkorlát előtt jelzett állapotba kerül az esemény (ill. ennek megfelelően hamis, ha lejárt az időkorlát). A példánkban nem adtunk meg időkorlátot paraméterben, mely végtelen időkorlát alkalmazását jelenti. Ennek megfelelően nem is vizsgáljuk a visszatérési értékét (mert végtelen ideig vár jelzésre). -
Ezzel a
Thread.SleepaWorkerThread-ben feleslegessé vált, kommentezzük ki!A fenti megoldás futtatásakor azt tapasztaljuk, hogy az alkalmazásunk felülete az első gombnyomást követően befagy. Az előző megoldásunkban ugyanis egy amatőr hibát követtünk el. A lock-olt kódrészleten belül várakozunk a
_hasDatajelzésére, így a főszálnak lehetősége sincs arra, hogy aPutműveletben (egy szinténlock-kal védett részen belül) jelzést küldjön_hasData-val. Gyakorlatilag egy holtpont (deadlock) helyzet alakult ki. Fontos, hogy a kódot nézve gondoljuk át részleteiben:- A
TryGet-ben az egyik munkaszál (mely bejutott alockblokkba a három közül), a_hasData.WaitOne()sorban arra vár, hogy a fő szálPut-ban a_hasData-t jelzettbe állítsa. - A
Put-ban alocksorban fő szál arra vár, hogy az előző pontban említett munkaszál aTryGet-ben kilépjen alockblokkból.
Kölcsönösen egymásra várnak végtelen ideig, ez a holtpont/deadlock klasszikus esete.
Próbálkozhatnánk egy időkorlát megadásával (ms) a várakozásnál (ez nem kell megvalósítani):
if (_hasData.WaitOne(100))Ez önmagában sem lenne elegáns megoldás, ráadásul a folyamatosan pollozó munkaszálak jelentősen kiéheztetnék a Put-ot hívó szálat! Helyette, az elegáns és követendő minta az, hogy lock-on belül kerüljük a blokkolva várakozást.
Javításként cseréljük meg a
lock-ot és aWaitOne-t:public bool TryGet(out double[] data) { _hasData.WaitOne(); lock (_syncRoot) { if (_innerList.Count > 0) { data = _innerList[0]; _innerList.RemoveAt(0); if (_innerList.Count == 0) { _hasData.Reset(); } return true; } } data = null; return false; }Próbáljuk ki az alkalmazást, most már jól működik.
- A
-
A
lock-on belüli üresség-vizsgálat szerepe.Az előző lépésben a
TryGet-ben bevezettünk_hasDatanéven egyMaunalResetEventobjektumot. Ez pontosan akkor van jelzett állapotban, amikor a FIFO-ban van adat. Kérdés, szükség van-e még most is a lock blokkban az sor üresség vizsgálatra (if (_innerList.Count > 0)). Első érzésre redundánsnak gondolhatjuk. De próbáljuk ki, azif-ben az ürességvizsgálat helyett adjunk meg egy fixtrueértéket, ezzel semlegesítve azifhatását (azért dolgozunk így, hogy könnyű legyen visszacsinálni):... lock (_syncRoot) { if (true) { data = _innerList[0]; ... }Próbáljuk ki. Egy kivételt fogunk kapni, amikor kattintunk a gombon: így már nem szálbiztos a megoldásunk. Vezessük le, miért:
- Amikor elindul az alkalmazás, mindhárom feldolgozó szál a
TryGet_hasData.WaitOne();soránál vár arra, hogy adat kerüljön a FIFO-ba. - A gombra kattintáskor a
Putművelet_hasData-t jelzettre állítja. - A
TryGet_hasData.WaitOne();során mindhárom szál átjut (ez egy ManualResetEvent, ha jelezett, minden szál mehet tovább). - A
TryGetlockblokkjába egyetlen szál jut be, a másik kettő itt vár (lock blokkban egyszerre egy szál lehet): ez a szál kiveszi az egyetlen elemet az_innerListlistából, majd elhagyja alockblokkot. - Most már be tud jutni a
lock-nál várakozó két szálból (ezek már korábban túljutottak ahasData.WaitOne()híváson!!!) egy másik is alockblokkba, az is megpróbálja a 0. elemet kivenni az_innerListlistából. De az már nincs ott (az előző lépésben az elsőnek bejutó szál elcsente az orra elől): ebből lesz a kivétel.
A megoldás: biztosítani kell a
lockblockban, hogy ha időközben egy másik szál kiürítette a sort, akkor a szálunk már ne próbáljon elemet kivenni belőle. Vagyis vissza kell tenni a korábbi üresség vizsgálatot. Tegyük is ezt meg! A megoldásunk így jól működik. Előfordulhat ugyan, hogy feleslegesen fordulunk a listához, de ezzel így most megelégszünk. - Amikor elindul az alkalmazás, mindhárom feldolgozó szál a
Összefoglalva:
- Az üresség vizsgálatra a
ManualResetEventbevezetése után is szükség van. - A
ManualResetEventaz a célja, hogy feleslegesen ne pollozzuk gyakran a sort, ha az üres, vagyis az ún. aktív várakozást kerüljük el a segítségével.
A konkurens, többszálú környezetben való programozás nehézségei
Jól illusztrálja a feladat, hogy milyen alapos átgondolást igényel a konkurens, többszálú környezetben való programozás. Tulajdonképpen még szerencsénk is volt az előzőekben, mert jól reprodukálhatóan előjött a hiba. A gyakorlatban azonban ez ritkán van így. Sajnos sokkal gyakoribb, hogy a konkurenciahibák időnkénti, nem reprodukálható problémákat okoznak. Az ilyen jellegű feladatok megoldását mindig nagyon át kell gondolni, nem lehet az "addig-próbálkozom-míg-jó-nem-lesz-a-kézi-teszt-során" elv mentén leprogramozni.
System.Collections.Concurrent
A .NET keretrendszerben több beépített szálbiztosságra felkészített osztály is található a System.Collections.Concurrent névtérben. A fenti példában a DataFifo osztályt a System.Collections.Concurrent.ConcurrentQueue osztállyal kiválthattuk volna.
8. feladat – Kulturált leállás¶
Korábban félretettük azt a problémát, hogy az ablakunk bezárásakor a processzünk „beragad”, ugyanis a feldolgozó munkaszálak előtérszálak, kiléptetésüket eddig nem oldottuk meg. Célunk, hogy a végtelen while ciklust kiváltva a munkaszálaink az alkalmazás bezárásakor kulturált módon álljanak le.
-
Egy
ManualResetEventsegítségével jelezzük a leállítást a FIFO-ban aTryGet-ben történő várakozás során. A FIFO-ban vegyünk fel egy újManualResetEvent-et, és vezessünk be egyReleaseműveletet, amellyel a várakozásainkat zárhatjuk rövidre (új eseményünk jelzett állapotba állítható).private ManualResetEvent _releaseTryGet = new ManualResetEvent(false); public void Release() { _releaseTryGet.Set(); } -
A
TryGet-ben erre az eseményre is várakozzunk. AWaitAnymetódus akkor engedi tovább a futtatást, ha a paraméterként megadottWaitHandletípusú objektumok közül valamelyik jelzett állapotba kerül, és visszaadja annak tömbbéli indexét. Tényleges adatfeldolgozást pedig csak akkor szeretnénk, ha a_hasDatajelzett (amikor is aWaitAny0-val tér vissza).public bool TryGet(out double[] data) { if (WaitHandle.WaitAny(new[] { _hasData, _releaseTryGet }) == 0) { lock (_syncRoot) { if (_innerList.Count > 0) { data = _innerList[0]; _innerList.RemoveAt(0); if (_innerList.Count == 0) { _hasData.Reset(); } return true; } } } data = null; return false; } -
MainWindow.xaml.cs-ban vegyünk fel egy flag tagváltozót a bezárás jelzésére:private bool _isClosed = false; -
A főablak bezárásakor állítsuk jelzettre az új eseményt és billentsünk be a flag-et is: a
MainWindowosztályClosedeseményére iratkozzunk fel a konstruktorban, és írjuk meg a megfelelő eseménykezelő függvényt:public MainWindow() { ... Closed += MainWindow_Closed; } private void MainWindow_Closed(object sender, WindowEventArgs args) { _isClosed = true; _fifo.Release(); } -
Írjuk át a while ciklust az előző pontban felvett flag figyelésére.
private void WorkerThread() { while (!_isClosed) { -
Végül biztosítsuk, hogy a már bezáródó ablak esetében ne próbáljunk üzeneteket kiírni
private void ShowResult(double[] parameters, double result) { if (_isClosed) return; -
Futtassuk az alkalmazást, és ellenőrizzük, kilépéskor a processzünk valóban befejezi-e a futását.
Kitekintés: Task, async, await¶
A gyakorlat során az alacsonyabb szintű szálkezelési technikákkal kívántunk megismerkedni. Ugyanakkor megoldásunkat (legalábbis részben) építhettük volna a .NET aszinkron programozást támogató magasabb szintű eszközeire és mechanizmusaira, úgymint Task/Task<T> osztályok és async/await kulcsszavak.





