Zárolásra akkor van szükségünk, ha két vagy több szál ugyan azt a heap-en elhelyezkedÅ‘ referencia tÃpust vagy annak állapotát szeretné módosÃtani. A stack-el ellentétben a szálak nem rendelkeznek külön heap területtel. Ez lehetÅ‘vé teszi, hogy adatokat és erÅ‘forrásokat osszunk meg közöttük. Cserébe viszont a mi feladatunk megoldani azt, hogy egy osztott erÅ‘forrást egy idÅ‘pillanatban csak egy szál tudjon használni. Ezen folyamatot nevezzük zárolásnak.
A zárolás egy erÅ‘forrás igényes művelet, mivel az operációs rendszer beavatkozását igényli. Éppen ezért a legjobb zárolás az, amelyikre nincs is szükség. Ha sok szál ugyan ahhoz az osztott erÅ‘forráshoz szeretne hozzáférni, akkor a szálak nagy része várakozással fogja tölteni az idejét effektÃv munkavégzés helyett és elÅ‘fordulhat, hogy lassabban fog futni Ãgy, mintha csak egy szálon futott volna a művelet.
Éppen ezért a több szál bevezetését csak olyan problémák esetén használjuk optimalizációs célra, ahol van is értelme. Tételezzük fel, hogy a következÅ‘ számÃtást szeretnénk párhuzamosÃtani:
c = a + b
e = c * d
Ez tipikusan egy nem párhuzamosÃtható probléma, mivel az e változó függ a c változóra. Ezért, ha két szálon futna a program, semmi elÅ‘nnyel nem járna, mert egy idÅ‘ben két számÃtás nem fog tudni párhuzamosan futni.
Fontos kiemelni, hogy zárolásra nem minden referencia tÃpus esetén van szükségünk, mivel bizonyos osztályok a belsÅ‘ kódjukban megoldják ezt valamilyen módszerrel. Az ilyen objektumokat szálbiztosnak nevezzük és ha a .NET-ben egy osztály szálbiztos, akkor az explicit módon jelezve van a dokumentációban.
KülönbözÅ‘ zárolási módszerek léteznek különbözÅ‘ szituációkhoz. Az egyik legegyszerűbb, ha nyelv beépÃtett lock blokkját használjuk. Nézzünk egy példát, ami ezt szemlélteti:
using System;
using System.Threading;
namespace ThreadPelda4
{
internal class Program
{
// Közös lock objektum, amihez a szálak hozzáférnek
private static object lockObject = new object();
private static void Szal1()
{
for (int i = 0; i < 5; i++)
{
lock (lockObject)
{
// Zárolást igénylő szakasz. Csak egy szál férhet hozzá egyszerre.
// Igyekezzünk minimalizálni a zárolt kód mennyiségét.
Console.WriteLine($"Szál 1 - Iteráció {i}");
}
Thread.Sleep(1000);
}
}
private static void Szal2()
{
for (int i = 0; i < 10; i++)
{
lock (lockObject)
{
Console.WriteLine($"Szál 2 - Iteráció {i}");
}
Thread.Sleep(1500);
}
}
private static void Main(string[] args)
{
Thread thread1 = new Thread(Szal1);
Thread thread2 = new Thread(Szal2);
thread1.Start();
thread2.Start();
thread1.Join();
thread2.Join();
Console.WriteLine("Mindkét szál befejeződött.");
}
}
}
A program egy lehetséges kimenete:
Szál 2 - Iteráció 0
Szál 1 - Iteráció 0
Szál 1 - Iteráció 1
Szál 2 - Iteráció 1
Szál 1 - Iteráció 2
Szál 1 - Iteráció 3
Szál 2 - Iteráció 2
Szál 1 - Iteráció 4
Szál 2 - Iteráció 3
Szál 2 - Iteráció 4
Szál 2 - Iteráció 5
Szál 2 - Iteráció 6
Szál 2 - Iteráció 7
Szál 2 - Iteráció 8
Szál 2 - Iteráció 9
Mindkét szál befejezÅ‘dött.
A zároláshoz használt objektum bármilyen referencia tÃpus lehet igazából. Azonban a .NET 9 bevezeti a Lock tÃpust, ami egy specializált objektum, kifejezetten zárolásra kitalálva. ElÅ‘nye, hogy 1-3%-kal gyorsabb működést eredményez, mintha egy hagyományos referencia tÃpust alkalmaznánk.
Pár szabály, amit a lock használatakor érdemes betartani:
-
Kerüljük a
thismutató használatát, mivel ez minden esetben az éppen aktuális objektum példányra mutat és két eltérő osztályból könnyen lábon lőhetjük magunkat. -
Igyekezzünk csak a lehető legszűkebb blokkot zárolni.
-
A lock belsejében keletkező kivétel a zárolást megszünteti, a vezérlés kiugrik a blokkból. Ezért ha kell, a kivételt a lock blokkon belül kezeljük, ha nagyon kell.
Monitor és Mutex
Ha nem a speciális Lock tÃpust alkalmazzuk zároláskor, akkor az a háttérben egy Monitor nevezetű zárolási módszert alkalmaz. Ez egy folyamaton belüli szinkronizációra kiválóan alkalmas, de mi a helyzet akkor, ha folyamatokon átÃvelÅ‘en szeretnénk zárolni egy erÅ‘forrás használatát két szál között?
Ebben az esetben az operációs rendszerhez kell fordulnunk és az Å‘ segÃtségével tudjuk megoldani a feladatot mutex vagy szemafor segÃtségével.
A mutex a mutual exclusion (kölcsönös kizárás) rövidÃtése. Lényegében olyan, mint a lock, csak folyamatokon átÃvelÅ‘en működik. .NET esetén ezt a Mutex osztály valósÃtja meg.
Egy tipikus mutex alkalmazási példa lehet az, hogy letiltjuk a programunk második példányának futását.
using System;
using System.Threading;
internal static class Program
{
private const string MutexName = @"CsharpTutorial/PeldaMutex";
private static Mutex mutex;
private static void Main(string[] args)
{
if (Mutex.TryOpenExisting(MutexName, out mutex))
{
//van másik példány
Console.WriteLine("Az alkalmazás már fut.");
Environment.Exit(1);
}
//nincs másik példány, létrehozzuk a mutexet
using (mutex = new Mutex(initiallyOwned: true, MutexName))
{
Console.WriteLine("Az alkalmazás elindult. Nyomj meg egy gombot a kilépéshez...");
Console.ReadKey();
}
}
}
A program kimenete, ha még nem fut:
Az alkalmazás elindult. Nyomj meg egy gombot a kilépéshez...
Ha eközben egy másik példányt futtatunk, akkor pedig ezt a kimenetet kapjuk:
Az alkalmazás már fut.
A Mutex használatához egy egyedi névre lesz szükségünk. A Mutex osztály TryOpenExisting metódusával tudjuk ellenőrizni, hogy a mutex létezik-e már vagy sem. Ha létezik, akkor a metódus igaz értékkel fog visszatérni és a kimeneti (out) változójában visszakapjuk azt.
A WaitOne() metódusával tudnánk ezt követÅ‘en zárolni, aminek egy várakozási idÅ‘korlát is megadható a deadlock elkerülés miatt és a ReleaseMutex() hÃvással tudnánk felszabadÃtani, azonban a program futásának ellenÅ‘rzéséhez bÅ‘ven elég, hogy tudjuk, létezik-e már az adott nevű vagy sem. Ha létezik, akkor kilépünk egy nem nullás hibakóddal, jelezve, hogy a program futása hibával ért véget.
Amennyiben az adott nevű Mutex nem létezik, akkor létrehozzuk azt. A konstruktor initiallyOwned paraméterét ha igazra állÃtjuk, akkor zárolt állapotban jön létre és nem kell a WaitOne() hÃvást eszközölni.
Feltűnhet, hogy a kódban nincs ReleaseMutex() hÃvásom. Ennek az oka az, hogy ez a tÃpus implementálja az IDisposable interfészt, ezért a using blokk végén hÃvódó Dispose metódus gondoskodik arról, hogy a Mutex megfelelÅ‘en legyen felszabadÃtva.
Szemaforok
Abban az esetben, ha nem kölcsönös kizárást, hanem például azt szeretnénk elérni, hogy egy erőforráshoz egyszerre csak egy meghatározott számú szál férjen hozzá, akkor szemafort kell használnunk. Ez lényegében egy számláló, ami megadja, hogy egy erőforráshoz hány szál férhet még hozzá. Ez a számot a szemafor létrehozásakor kell megadni.
Ha az értékét létrehozáskor 1-re állÃtjuk, akkor egy bináris szemafort kapunk, ami lényegében úgy viselkedik majd, mint egy Mutex.
A szemafor egy gyakorlati alkalmazása lehet például egy hálózati kapcsolatot lebonyolÃtó szoftverben az egyszerre párhuzamosan kiszolgálható kliensek számának limitálása, hogy a gép ne fogyjon ki erÅ‘forrásokból.
.NET esetén szemaforból két tÃpusunk is van: a Semaphore és SemaphoreSlim. A Semaphore osztály az operációs rendszerre épülÅ‘en oldja meg a zárolást, ezáltal több folyamat között is használható. A SemaphoreSlim teljesen .NET szinten van megvalósÃtva és kifejezetten .NET szálakhoz lett optimalizálva. Cserébe viszont csak egy folyamaton belül működik, hiszen nem fordul az operációs rendszerhez.
A korábbi Mutex példa szemaforokkal megvalósÃtott változata:
using System;
using System.Threading;
internal static class Program
{
private const string SemaphoreName = @"CsharpTutorial/PeldaSemaphore";
private static Semaphore? semaphore;
private static void Main(string[] args)
{
//A TryOpenExisting csak Windows rendszeren működik
if (Semaphore.TryOpenExisting(SemaphoreName, out semaphore) && !semaphore.WaitOne(10))
{
//van másik példány
Console.WriteLine("Az alkalmazás már fut.");
Environment.Exit(1);
}
//nincs másik példány, létrehozzuk a szemafort
using (semaphore = new Semaphore(0, 1, SemaphoreName))
{
Console.WriteLine("Az alkalmazás elindult. Nyomj meg egy gombot a kilépéshez...");
Console.ReadKey();
}
}
}
Ezen példa csak Windows esetén működőképes, mivel Linux és Mac rendszerek esetén a szemaforok nem rendelkeznek névvel, ezért ilyen célra, mint ami a példa alkalmazásban szerepel, nem alkalmazhatóak.
A kód nagyon hasonló. A TryOpenExisting után ellenÅ‘rizzük a WaitOne hÃvással, hogy van-e még szabad hely a Mutex-ben. A metódus paraméterében a 10-es szám milliszekundumban határozza meg a maximális várakozási idÅ‘t, ha nem lenne szabad hely.
A szemafor létrehozásánál két számot kell megadnunk. Az elsÅ‘ a kezdőérték, amit én 0-ra állÃtottam, mert a létrehozás pillanatában már elhasználom a szabad helyet. A második paraméter pedig a maximálisan egyszerre megengedett szálak száma, amit 1-re állÃtok. A harmadik paraméter pedig a szemafor neve.
A program kimenetében identikusan működik a Mutex példával.
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.