A szál a többszálasítás legerősebb és legkomplexebb eszköze a keretrendszerben. A Task Parallel Library (TPL) bemutatkozásával a szálak használata mondhatni elavult megoldásnak számít. Azonban előfordulhat, hogy mégis csak szimpla szálakra van szükségünk, illetve mondhatni elengedhetetlen, hogy tisztában legyünk a TPL tárgyalásánál a szálak működésével.
A Thread a legkisebb építőeleme egy folyamat (processz) futásának. A keretrendszer kioszt egyet az alkalmazás indulásánál, de bármikor kérhetünk még többet belőle. Ha megnézzük a családfáját, a CriticalFinalizerObject-ből származik, ami pedig egyenesen az object ősből.
Szálak létrehozása
Egy szálat többféleképpen is létre tudunk hozni. Az egyik legegyszerűbb módon szinkron delegate használatával, ami lehet másik osztályban is, így:
using System;
using System.Threading;
namespace PeldaThread2
{
class Szal
{
public void TizigSzamol()
{
Console.Write("Elszámolok tízig!");
for (int i = 0; i < 10; i++)
{
Console.Write('.');
Thread.Sleep(1000);
}
Console.Write("\n Tíz!");
}
}
A megírt metódusunk használata hasonlóan egyszerű, mintha nem beszélnénk szálakról. Annyi különbség van, hogy példányosítani kell egy Thread objektumot, aminek átadjuk a fenti metódust, majd meghívuk a szál példányon a Start() metódust.
using System.Threading;
namespace PeldaThread2
{
class Program
{
static void Main(string[] args)
{
var sz = new Szal();
Thread t = new Thread(new ThreadStart(sz.TizigSzamol));
t.Start();
Console.ReadKey();
}
}
}
A program kimenete:
Elszámolok tízig!..........
Tíz!
A várakozásainknak megfelelően a programunk elszámol tízig. Eközben a parancssor nem záródik be. Ez azt jelenti, hogy alapértelmezetten a szálak előtérben indulnak. Ezt le is tudjuk ellenőrizni:
Console.WriteLine(t.IsBackground ? "B" : "F");
Egy gyors IsBackgrund = true után az F5 lenyomására véget is ér az alkalmazásunk futása. Vagyis láthatjuk, hogy a háttérben futó szálakra nem vár a rendszer.
A szálak kezelése mögött egy állapotgép található. Ebből következik, hogy egyszerre csak egy állapotban lehet. Ez azt jelenti, hogy ha például egy futó szálon hívjuk meg a Start() metódust, akkor egy ThreadStateException kivételt kapunk.
Mivel a Thread konstruktora egy delegatet vár, miért ne használhatnánk lambdával?
using System;
using System.Threading;
namespace PeldaThread3
{
class Program
{
static void Main(string[] args)
{
new Thread(() =>
{
Console.Write($"Elszámolok tízig!");
for (int i = 0; i < 10; i++)
{
Console.Write('.');
Thread.Sleep(1000);
}
Console.Write($"\n Tíz!");
}).Start();
Console.ReadKey();
}
}
}
A program kimenete:
Elszámolok tízig!..........
Tíz!
A Thread tulajdonságai, metódusai:
A Thread osztály számos tulajdonsággal és metódussal rendelkezik. Ezek közül itt csak az általam legfontosabbnak ítéltek kerültek ismertetésre. A Thread osztály teljes dokumentációja a következő címen lelhető fel: https://docs.microsoft.com/en-us/dotnet/api/system.threading.thread
public bool IsAlive { get; }
A nevéből adódóan megmondja, hogy a szál él-e.
bool IsBackground { get; set; }
True-val tér vissza, ha háttérszálról beszélünk. Dióhéjban a különbség az előtérben, illetve a háttérben futó szálak között az az, hogy előbbire várni fog az alkalmazásunk, mielőtt leállna, utóbbira viszont nem. A CLR meghívja az Abort függvényt a háttérszálakra, amint az előtérszálak befejezték a munkát.
public string Name { get; set; }
A szál neve. Ha nem állítottunk be nevet, akkor null értéket ad vissza. Fejlesztés és hibakeresés közben jön jól, hogy el tudjuk nevezni.
public ThreadPriority Priority { get; set; }
A szál prioritását tudjuk vele beállítani vagy lekérni. A prioritás az operációs rendszer feladatütemezőjére van hatással. Minél magasabb egy szál prioritása, annál több erőforrást kap az operációs rendszertől más feladatok kárára. A ThreadPriority enum lehetséges értékei: Lowest, BelowNormal, Normal, AboveNormal, Highest. Alapértelmezetten a szál Normal prioritással fog rendelkezni. Ettől eltérni csak optimalizációs céllal szokás, de legtöbb esetben nincs szükség a módosítására.
public ThreadState ThreadState { get; }
Egy ThreadState enum-ban megkapjuk a szál jelenlegi állapotát. A ThreadState a következő értékeket veheti fel:
| Érték | Leírás |
|---|---|
Aborted |
Megszakításra került, de még nem váltott az állapot Stopped-ra. |
AbortRequested |
A megszakítása folyamatban van. |
Background |
Egy háttér szálon fut. |
Running |
Futó állapotban van |
Stopped |
Megállításra került, végállapot |
StopRequested |
Megállítása folyamatban van. |
Suspended |
Felfüggesztett állapotban van. |
SuspendRequested |
Felfüggesztése elindult |
Unstarted |
Futásra kész, de még a Start() metódus nem lett meghívva. |
WaitSleepJoin |
Blokkolt állapotban van. A Sleep() és a különböző zárolások teszik ilyen állapotba. |
A szálak életciklusát az alábbi ábra szemlélteti:
Szálak szüneteltetése és megszakítása
Ahhoz, hogy a későbbi példákban várni tudjunk, szüneteltetni kell a szál végrehajtását. Erre a Thread.Sleep(int) eljárást használjuk, ahol paraméterként meg tudjuk adni, hány ezredmásodpercig aludjon a szálunk. Túlterhelt formában TimeSpan típust is fogad. Mivel az alkalmazásunk a fő szálon fut, ezért azt a szálat is el tudjuk altatni:
using System;
using System.Threading;
namespace PeldaThread
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine(DateTime.Now);
Thread.Sleep(10000);
Console.WriteLine(DateTime.Now);
Console.ReadKey();
}
}
}
A program kimenete:
2020. 12. 22. 13:11:43
2020. 12. 22. 13:11:53
Ha mindent jól csináltunk, a két sor között tíz másodperc különbség lesz. Átadhatunk egy speciális paramétert is a metódusnak, a Timeout.Infinite állandót. Ha megnézzük a dokumentációt, ott ezt találjuk róla:
public const int Infinite = -1;
Ha ezt a paramétert adjuk át a Sleep metódusnak, akkor egészen addig aludni fog a szál, amíg nem állítjuk le Thread.Interrupt() vagy Thread.Abort() hívással. Előbbi ThreadInterruptedException, utóbbi pedig ThreadAbortException típusú kivétellel jutalmazza a hívót.
A Thread.Sleep(int) esetén érdemes megjegyezni, hogy a várakozás sosem addig fog tartani, mint amit megadunk. Mindig lesz valamennyi eltérés az operációs rendszer ütemezője és a többi feladat miatt. Éppen ezért sose alkalmazzuk arra, hogy manuálisan szálakat ütemezzünk a programunkból. Az ilyen alkalmazások, amelyek ezt alkalmazzák, nehezen hibakereshetőek és eltérő hardver-, illetve szoftver konfigurációk esetén nem egyformán működnek.
Megjegyzés: A Thread.Abort() hívása .NET Core és újabb keretrendszerek esetén PlatformNotSupportedException kivételt fog kiváltani. Ennek az oka az, hogy a Unix alapú rendszerek esetén nem lehetséges egy futó szál erőszakos megszakítása a folyamat megállítása nélkül. A Join() utasítással lehet rávenni egy szálat a visszatérésre. Nézzünk egy példát:
using System;
using System.Threading;
public class JoinPelda
{
static Thread thread1, thread2;
public static void Main()
{
thread1 = new Thread(ThreadProc);
thread1.Name = "Thread1";
thread1.Start();
thread2 = new Thread(ThreadProc);
thread2.Name = "Thread2";
thread2.Start();
}
private static void ThreadProc()
{
Console.WriteLine("\nCurrent thread: {0}", Thread.CurrentThread.Name);
if (Thread.CurrentThread.Name == "Thread1"
&& thread2.ThreadState != ThreadState.Unstarted)
{
thread2.Join();
}
Thread.Sleep(4000);
Console.WriteLine("\nCurrent thread: {0}", Thread.CurrentThread.Name);
Console.WriteLine("Thread1: {0}", thread1.ThreadState);
Console.WriteLine("Thread2: {0}\n", thread2.ThreadState);
}
}
A program egy lehetséges kimenete:
Jelenlegi thread: Thread2
Jelenlegi thread: Thread1
Thread2 Join() kérés...
Jelenlegi thread: Thread2
Thread1: WaitSleepJoin
Thread2: Running
Jelenlegi thread: Thread1
Thread1: Running
Thread2: Stopped
Ahogy a példából látható, a Join() eredménye nem azonnali. Csak miután végzett a Sleep(4000) feldolgozásával, akkor tud kilépni. Közben látható, hogy a Thread1 várakozással megállt és a végrehajtást csak a Join() eredményessége után folytatta.
Fontos, hogy soha ne hívjuk meg annak a Thread objektumnak a Join() metódusát, ami az aktuális szálat képviseli (A Thread.CurrentThread statikus tulajdonsága mutatja az aktuális szálat) az aktuális szálból, mert ez deadlock-ot okoz. Mégpedig azért, mert így lényegében az aktuális szál a végtelenségig várna magára.
Adatok átadása szálaknak
Felmerülhet a teljesen jogos igény, hogy a szállal bizony kommunikálni kell. Adatokat kell neki átadni. Mit tudunk tenni? A .NET 2.0 óta rendelkezésünkre áll a ParameterizedThreadStart osztály, amivel a szálnak paramétereket tudunk adni. Sajnos a paramétert object osztályént tudjuk csak átadni. Alakítsuk át az előző példában használt számolós kódot kicsit univerzálisabbra:
using System;
using System.Threading;
namespace PeldaThread4
{
class Szal
{
public void ValameddigSzamol(object meddig)
{
var eddig = (int)meddig;
Console.Write($"\n Elszámolok {eddig}-ig!");
for (int i = 0; i < eddig; i++)
{
Console.Write('.');
Thread.Sleep(1000);
}
Console.Write($"\n {eddig}!");
}
}
}
Használata:
using System.Threading;
namespace PeldaThread4
{
class Program
{
static void Main(string[] args)
{
var sz = new Szal();
Thread d = new Thread(new ParameterizedThreadStart(sz.ValameddigSzamol));
d.Start(3);
Console.ReadKey();
}
}
}
A program kimenete:
Elszámolok 3-ig!...
3!
A ParameterizedThreadStart közvetlen használata helyett elegánsabb lenne egy wrapper osztály használata. Ennek az átgondolását és a megvalósítását az olvasóra bíznánk az eddigi ismeretek alapján afféle házi feladatként.
Érdekesség, hogy ha a szál által futtatott metódust lambdaként adjuk meg, akkor a scope-on belüli változók értékét látja a lambda kifejezés is, ami ismételten egy módszer a ParameterizedThreadStart elkerülésére:
using System;
using System.Threading;
namespace PeldaThread5
{
class Program
{
private static int _eddig;
static void Main(string[] args)
{
//szálon kívülről beállítás
_eddig = 5;
//szál indítása
new Thread(() =>
{
Console.Write($"\n Elszámolok {_eddig}-ig!");
for (int i = 0; i < _eddig; i++)
{
Console.Write('.');
Thread.Sleep(1000);
}
Console.Write($"\n {_eddig}!");
}).Start();
Console.ReadKey();
}
}
}
A program kimenete:
Elszámolok 5-ig!...
5!
A felugró konzolablakban megjelenik, hogy ötig szeretne elszámolni, elszámol ötig, kiírja, leáll. Teljesen az elvárt működés. Mi lenne, ha beállítanék egy másik szálat, ami 3-ig számolna és azt közvetlenül ez után hívnám meg?
A ThreadStatic attribútum
using System;
using System.Threading;
namespace PeldaThread5b
{
class Program
{
private static int _eddig;
public static void Main()
{
//szálon kívülről beállítás
_eddig = 5;
//szál indítása
new Thread(() =>
{
Console.Write($"\n Elszámolok {_eddig}-ig!");
for (int i = 0; i < _eddig; i++)
{
Console.Write('A');
Thread.Sleep(1000);
}
Console.Write($"\n {_eddig}!");
}).Start();
_eddig = 3;
new Thread(() =>
{
Console.Write($"\n Elszámolok {_eddig}-ig!");
for (int i = 0; i < _eddig; i++)
{
Console.Write('B');
Thread.Sleep(1000);
}
Console.Write($"\n {_eddig}!");
}).Start();
Console.ReadKey();
}
}
}
A program kimenete:
Elszámolok 3-ig!
Elszámolok 3-ig!..........
3!
3!
Hát, nem erre számítottunk, ugye? Mégis mi történhetett? Hogy kiderüljön, mi a baj, írjuk át a pontokat A-ra az első szálban és B-re a másodikban. Ennek hatására egyértelműbbé válik a probléma:
A program kimenete:
Elszámolok 3-ig!A
Elszámolok 3-ig!BABBA
3!
3!
Látható, hogy a végrehajtás sorrendje teljesen megjósolhatatlan, de ami még nyugtalanítóbb, az az, hogy mindkettő szál három iterációig futott le. Ez azért történhetett így, mert mindkét szál ugyanazt a változót használja az adatai tárolására. Ahhoz, hogy ezt megakadályozzuk, használjuk a ThreadStatic attribútumot az _eddig mezőnkön.
A ThreadStatic attribútúm azt jelzi a futtatókörnyezet számára, hogy minden szálhoz különállóan rendeljen egy példányt a változóból. Ez két szál esetén azt fogja jelenteni, hogy az _eddig változónkból két statikus példány fog létezni, amelyek nem férhetnek hozzá egymáshoz. Ez azt is jelenti, hogy a ThreadStatic attribútúm nélküli statikus változók nem tekinthetőek szálbiztosnak.
using System;
using System.Threading;
namespace PeldaThread6
{
class Program
{
[ThreadStatic]
private static int _eddig;
static void Main(string[] args)
{
new Thread(() =>
{
_eddig = 5;
Console.Write($"\n Elszámolok {_eddig}-ig!");
for (int i = 0; i < _eddig; i++)
{
Console.Write('A');
Thread.Sleep(1000);
}
Console.Write($"\n {_eddig}!");
}).Start();
new Thread(() =>
{
_eddig = 3;
Console.Write($"\n Elszámolok {_eddig}-ig!");
for (int i = 0; i < _eddig; i++)
{
Console.Write('B');
Thread.Sleep(1000);
}
Console.Write($"\n {_eddig}!");
}).Start();
Console.ReadKey();
}
}
}
A javított kódunk immár a helyes működést produkálja számolás tekintetében.
Elszámolok 3-ig!
Elszámolok 5-ig!ABBABA
3!AA
5!
Kritikus régiók
Tételezzük fel, hogy van egy több szálon futó alkalmazásunk és nem kezelt kivétel történik az egyik szálon. Ez nem jár az alkalmazás azonnali "halálával", mivel külön szálon történt. Természeten az a szál, ami nem kezelt kivételre futott, az nem él tovább. Ezzel le is zárhatnánk a témát, de mi van akkor, ha a szál éppen olyan adatokat módosít, amelyek az egész programra hatással vannak?
Ebben az esetben a szál halála már nem csak lokális probléma, mivel az egész alkalmazás viselkedése kiszámíthatatlanná válik. Éppen ezért szerencsés lenne, ha valamilyen módon tudnánk jelezni a keretrendszernek, hogy bizonyos szálon végzett műveletek közben bekövetkező hibák járjanak a program összeomlásával, ezzel megakadályozva, hogy egy meghatározhatatlan állapotba kerülve további károkat okozzon.
Szerencsére a .NET keretrendszer megadja erre a lehetőséget a Thread osztály BeginCriticalRegion() és EndCriticalRegion() metódusaival.
Használatuk egyszerű:
using System.Threading;
void Test()
{
Thread.BeginCriticalRegion();
//védett kód
Thread.EndCriticalRegion();
}
A BeginCriticalRegion() a futtatókörnyezetnek jelzi a kritikus műveletsor kezdetét. A kritikus műveletsor az EndCriticalRegion() hívásig tart. Ha a két metódushívás között kivétel történne, akkor a futtatókörnyezet azonnal abbahagyja az egész alkalmazás futtatását.
A többi zárolási módszerhez hasonlóan ez is költséggel jár. Továbbá mivel ezek az utasítások közvetlenül a futtatókörnyezet kivételkezelésére és viselkedésére vannak hatással, igyekezzünk minél kisebb blokkokat védeni ezzel a megoldással, szétválasztva a kódot tényleg kritikus és kevésbé kritikus részekre.
Critical Finalizer object
A CriticalFinalizerObject egy olyan speciális osztály, aminek a szerepe, hogy garantálja azt, hogy az objektum finalizer-e (destruktorja) minden esetben le legyen futtatva a Garbage Collector által. Ez furcsán hangozhat, de van oka, hogy miért van rá szükség és miért nem garantálható a finalizer futtatása minden esetben.
A fő ok egyszerű: sebesség. Alapvetően a finalizer futtatás egy költséges művelet és amíg a finalizer fut, addig a GC szál blokkolt állapotú. Amíg egy finalizer fut, addig simán előfordulhat, hogy a Process bezáródik, mert vagy a felhasználó vagy az operációs rendszer leállította. Ebben az esetben értelemszerűen a GC szál futása is megáll. Ez az esetek többségében egyáltalán nem okoz problémát, de az olyan osztályok esetén, mint a Thread, ami szoros kapcsolatban áll az operációs rendszerrel, már tud gondot okozni. Mégpedig azért, mert ebben az esetben nem történne meg az erőforrások megfelelő felszabadítása. A CriticalFinalizerObject osztályból örököltetés lényegében ezt a szituációt hidalja át. Ezen objektumok finalizer metódusa akkor is garantáltan le fog futni, ha éppen a finalizer futása közben a folyamat futása leállna.
Megjegyzés: Mivel az esetek nagyon nagy részében semmi szükség erre a viselkedésre egy általunk készített osztály esetén és a finalizer futtatás költséges, nagyon szűk az az alkalmazási terület, amikor tényleg szükségünk van erre a viselkedésre. Éppen ezért NE örököltessünk közvetlenül ebből az osztályból, csak nagyon indokolt esetben.