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.