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
lock
kulcsszó alkalmazásával ThreadPool
használata- Jelzés és jelzésre várakozás szál szinkronizáció
ManualResetEvent
segí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
Grid
felső sorában található a kétTextBox
-ot és aButton
-t tartalmazóStackPanel
. - A gyökér
Grid
alsó sorában egy másikGrid
található. ATextBox
-szal ellentétben aListBox
nem rendelkezikHeader
tulajdonsággal, így ezt nekünk kellett egy különálló "Result" szövegűTextBlock
formájában bevezetni. Ezt aGrid
-et azért vezettük be (egy "egyszerűbb"StackPanel
helyett), mert így lehetett elérni, hogy a felső sorában a "Result"TextBlock
fix magasságú legyen, az alsó sorban pedig aListBox
tö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
Button
Content
-jének sokszor nemcsak egy egyszerű szöveget adunk meg. A példában egySymbolIcon
és aTextBlock
kompozíciója (StackPanel
segí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
ListBox
hogyan 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
ListBox
ItemContainerStyle
tulajdonságával aListBox
elemre adhatunk meg stílusokat. A példában aPadding
-et vettük kisebbre az alapértelmezettnél, enélkül aListBox
elemek 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 egyShowResult
nevű segédfüggvényt. - A
CalculateResultButton_Click
a 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 aDisplayInvalidElementDialog
segítségével egy üzenetablakban tájékoztatja a felhasználót az érvénytelen paraméterekről. - A konstruktorból hívott
AddKeyboardAcceleratorToChangeTheme
fü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ő rendeszeré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
Click
eseménykezelőjében hívjuk meg a számoló függvényünket. Ehhez a Solution Explorerben nyissuk meg aMainWindow.xaml.cs
code behind fájlt, és keressük meg aCalculateResultButton_Click
esemé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
MainWindow
osztá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
Click
esemé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
Start
műveletében átadott paramétert kapja meg aCalculatorThread
szá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
ShowResult
metó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:
DispatcherQueue
objektumTryEnqueue
fü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
DispatcherQueue
objektumHasThreadAccess
tulajdonsága azt segít eldönteni, szükség van-e egyáltalán az előző pontban említettTryEnqueue
alkalmazá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
DispatcherQueue
objektumTryEnqueue
segí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
DispatcherQueue
null
vizsgálat szerepe: a főablak bezárása után aDispatcherQueue
márnull
, nem használható. - A
DispatcherQueue.HasThreadAccess
segí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.TryEnqueue
segítségével férünk hozzá a vezérlőhöz. A következő trükköt alkalmazzuk. ATryEnqueue
függvénynek egy olyan paraméter nélküli, egysoros függvényt adunk meg lambda kifejezés formájában, mellyel aShowResult
fü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 aShowResult
hí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
ThreadPool
ba 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 naív 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.Sleep
bevezeté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
IsBackground
tulajdonsá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
DataFifo
nem 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
while
ciklusban folyamatosan pollozzák a FIFO-t, vagyis hívják aTryGet
metódusát. - A felhasználó egy feladatot tesz a sorba.
- Az egyik feldolgozó szál a
TryGet
metó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ábiztossá 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
object
típusú mezőt_syncRoot
néven aDataFifo
osztályba.private object _syncRoot = new object();
-
Egészítsük ki a
Put
és aTryGet
fü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
MaunalResetEvent
példányt aDataFifo
osztályunkhoz_hasData
néven.// A false konstruktor paraméter eredményeképpen kezdetben az esemény nem jelzett (kapu csukva) private ManualResetEvent _hasData = new ManualResetEvent(false);
-
A
_hasData
alkalmazá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
_hasData
né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
_hasData
esemé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
WaitOne
művelet egybool
értékkel tér vissza, mely igaz, ha aWaitOne
paramé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.Sleep
aWorkerThread
-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
_hasData
jelzésére, így a főszálnak lehetősége sincs arra, hogy aPut
mű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 alock
blokkba 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 alock
sorban fő szál arra vár, hogy az előző pontban említett munkaszál aTryGet
-ben kilépjen alock
blokkbó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_hasData
néven egyMaunalResetEvent
objektumot. 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 azif
hatá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
Put
mű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
TryGet
lock
blokkjá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_innerList
listából, majd elhagyja alock
blokkot. - 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 alock
blokkba, az is megpróbálja a 0. elemet kivenni az_innerList
listá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
lock
blockban, 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
ManualResetEvent
bevezetése után is szükség van. - A
ManualResetEvent
az 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
ManualResetEvent
segí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 egyRelease
mű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. AWaitAny
metódus akkor engedi tovább a futtatást, ha a paraméterként megadottWaitHandle
tí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_hasData
jelzett (amikor is aWaitAny
0-val tér vissza).public bool TryGet(out double[] data) { if (WaitHandle.WaitAny(new[] { _hasData, _releaseTryGet }) == 0) { lock (_syncRoot) {
-
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
MainWindow
osztályClosed
esemé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 az 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.