Dnes budeme pokračovat v látce nakousnuté v úvodním dílu – zamykání (locking) a thread-safety.
Jak už bylo řečeno v úvodním díle seriálu, zamykání zajišťuje exkluzivní přístup k dané části kódu, tedy, že v jednu chvíli může k vymezenému kódu přistupovat jen jedno vlákno. Na začátek tohoto dílu si tuto látku trochu zopakujeme, ať máme na co navazovat. Následuje ukázka, jak by se to dělat nemělo:
class ThreadUnsafe
{
static int val1, val2;
static void Go()
{
if (val2 != 0) Console.WriteLine(val1 / val2);
val2 = 0;
}
}
Tento postup není thread-safe, pokud by totiž došlo k zavolání Go() oběma vlákny naráz, mohlo by dojít k dělení nulou, a to jak známo, není přípustné ani v matematice, ani v programování (program by vyhodil výjimku). Jedno vlákno by totiž mohlo nastavit proměnnou val2 na nulu zrovna ve chvíli, kdy by se druhá vlákna nacházelo někde mezi if
a Console.WriteLine()
. Řešení je nasnadě: použití lock konstrukce.
class ThreadSafe
{
static object zamek = new object();
static int val1, val2;
static void Go()
{
lock (zamek)
{
if (val2 != 0) Console.WriteLine(val1 / val2);
val2 = 0;
}
}
}
V jednu chvíli může udržet kus kódu zamčený jen jedno vlákno a než ho odemkne, všechna vlákna, která se pokusí ke kódu přistoupit, jsou zablokována a řadí se do fronty v pořadí, ve kterém přišla. V příkladu nahoře chráníme obsah metody Go(), a tím pádem i obsah proměnných val1 a val2.
Vláknu, které čeká v této frontě, se do vlastnosti ThreadState (podrobně probereme v příštím díle) uloží hodnota WaitSleepJoin. Příště si také řekneme, že vlákno v tomto stavu můžeme násilně přerušit pomocí metod Interrupt() a Abort().
Klíčové slovo lock je ve skutečnosti jen taková zkratka (C# nám zase jednou ulehčuje život) pro volání metod Monitor.Enter() a Monitor.Exit() uvnitř try-finally bloku. Ve skutečnosti vidí kód metody Go() z posledního příkladu nějak takhle:
static void Go()
{
Monitor.Enter(locker);
try
{
if (val2 != 0) Console.WriteLine(val1 / val2);
val2 = 0;
}
finally { Monitor.Exit(locker); }
}
Volání Monitor.Exit() bez předchozího zavolání Monitor.Enter() vyhodí výjimku.
Třída Monitor poskytuje i metodu podobnou metodě Enter(). Tou je TryEnter(), která přijímá jeden parametr v milisekundách, nebo typu TimeSpan. Metoda pak vrátí true, pokud uzamknutí proběhlo úspěšně v zadaném čase, v opačném případě vrátí false. Ale pozor, tato metoda neuzamyká kód, jen zkouší, jestli je to možné! Originální, nepřetížená verze TryEnter() nepřijímá žádný parametr – jen zkusí, jestli by náhodou daný kód nešel uzamknout ve chvíli, kdy dojde k zavolání TryEnter().
Synchronizační objekt
Jako synchronizační objekt označujeme něco, podle čeho můžeme synchronizovat práci vláken. Za tímto účelem nám poslouží jakákoliv „věc“ viditelná oběma vlákny. Jediná podmínka je, že musí být referenčního typu. Doporučuje se, aby byl subjekt chráněný proti vnějšímu přepsání, například pomocí modifikátoru private.
class ThreadSafe
{
List <string> list = new List <string>();
void Test()
{
lock (list)
{
list.Add ("Položka 1");
...
Příklad dokazuje, že místo obecného object můžeme jako locker využít i instanci třídy List.
Vložené zamykání
Vlákno může opakovaně uzamknout jeden objekt, buď několikanásobným zavoláním Monitor.Enter() anebo přes vložený zámek (nested lock). K odemknutí dojde až tehdy, kdy zavoláme Monitor.Exit() tolikrát, kolikrát jsme předtím zavolali Monitor.Enter();, nebo až se odemkne ten nejvíc vnější zámek.
static object x = new object();
static void Main()
{
lock (x)
{
Console.WriteLine("Uzamčeno");
Vlozka();
Console.WriteLine("Pořád uzamčeno");
}
//Teprve zde dojde k odemknutí
}
static void Vlozka()
{
lock (x)
{...}
//K odemknutí nedojde, tohle není vnější zámek
}
Kdy zamykat?
Podle nepsaného pravidla by každá proměnná (pole, cokoliv, …) přístupná více jak jedním vláknem měla být „pod zámkem“, když s ní pracujeme. Z následujícího příkladu byste už měli poznat, že to není úplně ideální řešení (ve skutečnosti ideální není vůbec):
class ThreadUnsafe
{
static int x;
static void Inkrementace() { x++; }
static void Prirazeni() { x = 123; }
}
Po jednoduché úpravě bude tento kód thread-safe jak má být:
class ThreadUnsafe
{
static object zamek = new object();
static int x;
static void Inkrementace() { lock (zamek) x++; }
static void Prirazeni() { lock (zamek) x = 123; }
}
Poznámka pro zvídavé – jako alternativa k zamykání existuje neblokující konstrukce, vhodná pro primitivní úlohy. Probereme v pozdějších dílech seriálu (skutečně si ještě počkáte), protože celá problematika není úplně nejjednodušší. Zatím můžete zkusit Google a klíčová slova „thread atomicity“ a „interlocked“.
A co výkon?
Zamykání samo o sobě je velmi rychlou akcí, probíhá v desetinách nanosekund. Pokud dojde k zablokování vlákna, už se celá akce zpomalí na mikrosekundy až milisekundy. Ale i toto zpomalení přece stojí za to, mít stabilní aplikaci.
Může nám ale přinést i totální kolaps aplikace, pokud ho nesprávně použijeme. Existují tři takové scénáře: „Impoverished concurrency“, „deadlocks“ a „lock race“. Pod překlady si asi málokdo dokáže něco představit, tak je ani nebudu zmiňovat.
- „Impoverished concurrency“ nastává, když je uzamčeno zbytečně moc kódu, a vlákna se tím pádem blokují na delší dobu, než je nutné.
- „Deadlock“ je, když jsou dvě vlákna uzamčená navzájem, a tak ani jedno nemůže pokračovat v práci.
- „Lock race“ nastane, když dvě vlákna „závodí“ o to, které dřív uzamkne nějaký kód. Pokud se to ale povede nevhodnému vláknu, může tím narušit běh zbytku aplikace.
Thread-safety
Jako thread-safe označujeme kód, u kterého nemůže dojít k nepředvídatelnému chování. Dosáhneme toho hlavně zamykáním a omezením vzájemné interakce mezi vlákny na minimum. Metoda, která je thread-safe ve všech ohledech, se označuje jako „reentrant“ (znovuvstupující).
„Obecné“ typy, jako různé proměnné, pole, vlastnosti, …, jsou málokdy thread-safe, kvůli jejich velkému množství (pokud se bavíme o nějaké velké aplikaci). Dalším důvodem je pořád dokola omílaný výkon. Mít vše thread-safe by bylo hezké, ale v praxi tedy špatně proveditelné (sice bychom mohli mít všechno uzamčené ve „velkých“ zámcích, ale zase narážíme na ten výkon…), proto se setkáme thread-safe kódem spíše jen na rizikových místech.
Thread-safety a .NET typy
Většina datových typů, kromě primitivních (tedy kromě těch nejzákladnějších typů), nejsou thread-safe. Jasným příkladem thread-unsafe typů jsou kolekce všeho druhu; ukažme si třeba kolekci List:
class ThreadSafe
{
static List<string> list = new List<string>();
static void Main()
{
new Thread(PridatPrvek).Start();
new Thread(PridatPrvek).Start();
}
static void PridatPrvek()
{
for (int i = 0; i < 100; i++)
lock (list)
list.Add("Prvků " + list.Count);
string[] prvky;
lock (list) prvky = list.ToArray();
foreach (string s in prvky) Console.WriteLine(s);
}
}
V tomto případě zamykáme objektem „list“ samotným, což je v podobně snadném scénáři dostačující. Pokud bychom ale měli dvě tyto kolekce a byly by nějak provázané, nejspíš bychom museli použít nějaký nesouvisející objekt jako zámek.
Procházení .NET kolekcemi je také thread-unsafe – dojde k vytvoření výjimky, pokud nějaké vlákno kolekci upraví, zatímco druhé vlákno touto kolekcí prochází. Tentokrát bych ale spíše než použití lockingu zkopíroval prvky kolekce do nějakého pole (během kopírování je ale nutné originální kolekci po dobu kopírování locknout) a procházel tou kopií, originál ať si klidně ostatní vlákna upravují, jak se jim zachce.
Teď něco na zamyšlení. Představte si, že by třída List byla thread-safe. Pomohlo by nám to nějak? Zase tak moc ne. Na vysvětlení si vezmeme následující kód: chceme přidat prvek do naší „thread-safe“ List kolekce.
if (!Kolekce.Contains (novyPrvek)) Kolekce.Add (novyPrvek);
Celý tento kód by musel být pod zámkem, protože ve chvíli, kdy kontrolujeme, jestli není daný prvek už náhodou v kolekci, by jiné vlákno mohlo tentýž prvek přidat a to by mohlo způsobit další nečekané chování. Je tedy vidět, že thread-safe kolekce by byly ve většině případů zbytečné.
Rýpalové by mohli namítnout, proč se zatěžovat se psaním „vestavěné“ thread-safety při psaní vlastních komponent, když se stejně dá „vše“ vyřešit až při jejich použití pomocí kontrukce lock.
Nejhorší případ nastane, když máme statické členy v public typu. Takovým příkladem je třeba struktura DateTime a jedna z jejích vlastností, DateTime.Now. Pokud by dvě vlákna přistoupila k této vlastnosti v jednu chvíli, výstup by mohl být „zkomolený“, nebo by aplikace rovnou skončila výjimkou. Jedinou možností, jak tohle ošetřit z externího kódu, by bylo uzamknout celý typ, tedy použít
lock(typeof(DateTime))
Je to ale všeobecně považované za špatné programátorské vychování, takže nepoužívat! :-)
Naštěstí je ale struktura DateTime (stejně jako ostatní) thread-safe, takže k tomuto nikdy nedojde. U všech vašich komponent (a hlavně u těch, které budete veřejně publikovat) byste se měli postarat o to, aby byly thread-safe samy o sobě.
A jsme zase na konci jednoho z dílů. Příště, jak už jsem se zmínil v článku, probereme metody Interrupt a Abort a vlastnost ThreadState.