Spojení vláken
Předtím, než se začnu zaobírat hlavním tématem tohoto dílu, zmíním se o jedné užitečné možnosti při použití vláken. Tuto možnost využijeme v případě, kdy chceme jedno vlákno nechat čekat na dokončení práce vlákna jiného. Představme si následující příklad.
public class WorkerThread
{
public void PerformActions()
{
for (int i = 0; i < 1000; i++)
{
Console.WriteLine("Vystup vlakna {0} : {1}", Thread.CurrentThread.Name, i);
}
}
}
public class ThreadsJoiningExam
{
public static void RunExamWithoutJoining()
{
Thread.CurrentThread.Name = "Vychozi vlakno";
WorkerThread lWorker = new WorkerThread();
ThreadStart lWorkerStartInfo = new ThreadStart(lWorker.PerformActions);
Thread lSecondThread = new Thread(lWorkerStartInfo);
lSecondThread.Name = "Druhe vlakno";
lSecondThread.Start();
System.Windows.Forms.MessageBox.Show("Zdravi vas vlakno " + Thread.CurrentThread.Name);
}
}
Metoda PerformActions třídy WorkerThread je vykonávána asynchronně a její tělo zajišťuje vypsaní čísel od 0 do 999, což aplikaci chviličku zabere. Po spuštění vlákna používající výše zmíněnou metodu se z výchozího vlákna pomocí metody Show třídy MessageBox zobrazí informační box s nějakým textem. Pokud si tento příklad spustíme běh programu bude vypadat tak, že zatímco jsou na konzoli vypisována čísla, zobrazí se také informační box.
Samozřejmě vlákna se za tímto účelem, tedy k realizaci (pseudo) paralelních operací používají, ale v určitých případech potřebujeme počkat na dokončení práce nějakého vlákna, abychom mohli pokračovat v práci prováděné vláknem jiným. To se hodí v situacích, kdy je jedno vlákno závislé na dokončení operací prováděných jiným vláknem. Za tímto účelem využijeme metody Join třídy Thread. Takto vypadá náš příklad po menší úpravě, která je představována právě použitím zmíněné metody.
public static void RunExamWithJoining()
{
Thread.CurrentThread.Name = "Vychozi vlakno";
WorkerThread lWorker = new WorkerThread();
ThreadStart lWorkerStartInfo = new ThreadStart(lWorker.PerformActions);
Thread lSecondThread = new Thread(lWorkerStartInfo);
lSecondThread.Name = "Druhe vlakno";
lSecondThread.Start();
//zablokujeme vychozi (prvni) vlakno dokud druhe vlakno neskonci svou cinnost
lSecondThread.Join();
System.Windows.Forms.MessageBox.Show("Zdravi vas vlakno " + Thread.CurrentThread.Name);
}
Nyní bude chování programu po spuštění jiné a to v tom, že vlákno, ze kterého je metoda Join zavolána počká na dokončení vlákna provádějící výpis čísel na konzoli.
Synchronizace vláken
Doposud jsme si ukazovali pouze jednoduché vícevláknové aplikace. Jednoduché myslím v tom, že si nikdy nekonkurovali v přístupu k nějakým zdrojům. Pokud takovouto aplikaci vytvoříme, můžeme narazit na nepříjemné problémy. Na ukázku toho, jaké problémy můžou při používání takovýchto aplikací nastat, jsem vytvořil jednoduchý příklad, který by to měl jasně demonstrovat.
/// <summary>
/// Priklad ukazujici mozne problemy pristupu nekolika vlaken
/// ke sdilenym datum
/// </summary>
public class UnsychronizedExam
{
static int count;
static int count2;
public static void Run()
{
Thread lCheckerThread = new Thread(new ThreadStart(DoCheck));
lCheckerThread.Start();
//vytvoreni deseti vlaken, ktera budou
//asynchronne vykonavat metodu DoSomeWork
for (int i = 0; i < 10; i++)
{
Thread lThread = new Thread(new ThreadStart(DoSomeWork));
lThread.Start();
}
//ujistime se, ze vsechna vlakna skonci
Thread.Sleep(500);
//ukonceni cinnosti kontrolujiciho vlakna
lCheckerThread.Abort();
Console.WriteLine("Hotovo");
}
internal static void DoSomeWork()
{
for (int i = 0; i < 5; i++)
{
count++;
//simulace nejake casove narocne cinnosti
Thread.Sleep(10);
count2++;
}
}
/// <summary>
/// Metoda, ktera kontroluje jestli jsou hodnoty obout dat. clenu
/// shodne. Je volana vlaknem pro provadeni teto kontroly
/// </summary>
internal static void DoCheck()
{
while(true)
{
if (count != count2)
{
Console.WriteLine("Hodnoty nejsou synchronizovany !!");
}
}
}
}
V příkladu je vytvořeno deset vláken, které spouští metodu DoSomeWork. Metoda DoSomeWork zařizuje inkrementaci obou statických datových členů s tím, že mezi jednotlivými inkrementacemi je pomocí metody Sleep simulována nějaká činnost. Řekněme, že pro korektní běh aplikace je nezbytné, aby hodnoty obou statických datových členů byly shodné.
Pro kontrolu této podmínky je v příkladu metoda DoCheck, která je spouštěna separátním vláknem, aby byla kontrola prováděna „neustále“. V případě, že je zjištěno, že hodnoty datových členů nesouhlasí je o tom vypsána hláška na systémovou konzoli. A pokud si tento příklad zkusíte spustit, uvidíte, že tato hláška se vám na obrazovce objeví hned několikrát.
Proč je tomu tak? Jelikož úsek kódu, který je obsažen v metodě DoSomeWork, je vykonáván asynchronně a také je v metodě DoCheck asynchronně vykonáváno čtení datových členů se kterými kód v metodě DoSomeWork manipuluje, nastanou situace, ve kterých, když jsou hodnoty kontrolovány (DoCheck), tak je jiné vlákno právě „uprostřed“ provádění změn hodnot datových členů (DoSomeWork). Jak takovýmto nekontrolovaným přístupům vlákny k jednotlivým hodnotám zabránit?
To zařídíme pomocí takzvané synchronizace. Každý objekt v prostředí .NET může mít svůj monitor, který umožňuje řídit přístup ke specifickým částem zdrojového kódu. Za účelem využití tohoto monitoru je nám k dispozici třídy System.Threading.Monitor a její metody. Pomocí metod této třídy jsme schopni uzamknout část zdrojového kódu a tím zabránit vykonání tohoto kódu jiným vláknem.
Tím pádem jsme schopni zabránit situacím, podobným té z předchozího příkladu, kdy jsou hodnoty datových členů porovnávány ve chvíli, kdy jiné vlákno právě provádí změny jejich hodnot. Náprava tohoto problému by mohla vypadat následovně.
/// <summary>
/// Ukazka zakladniho pouziti tridy Monitor
/// </summary>
public class SynchronizingExam
{
private int count;
private int count2;
public static void Run()
{
SynchronizingExam lInstance = new SynchronizingExam();
Thread lCheckerThread = new Thread(new ThreadStart(lInstance.DoCheck));
lCheckerThread.Start();
//spustime deset vlaken a nechame je vykonavat cinnost
//metody DoSomeWork
for (int i = 0; i < 10; i++)
{
Thread lThread = new Thread(new ThreadStart(lInstance.DoSomeWork));
lThread.Start();
}
//ujistime se, ze vsechna vlakna skonci svou praci
Thread.Sleep(500);
//ukoncime cinnost kontrolniho vlakna
lCheckerThread.Abort();
Console.WriteLine("Hotovo");
}
internal void DoSomeWork()
{
//zamkneme instanci
Monitor.Enter(this);
count++;
//simulace nejake casove narocne cinnosti
Thread.Sleep(10);
count2++;
//odemkneme instanci
Monitor.Exit(this);
}
internal void DoCheck()
{
while(true)
{
Monitor.Enter(this);
if (count != count2)
{
Console.WriteLine("Hodnoty nejsou synchronizovany !!");
}
Monitor.Exit(this);
}
}
}
Po spuštění tohoto příkladu byste zprávu o chybné synchronizaci hodnot již vidět neměli. Je tomu samozřejmě díky použití třídy Monitor a to tak, že v metodě DoSomeWork, která mění hodnoty datových členů, si hned na jejím začátku získáme zámek na aktuální instanci, což zařídíme pomocí metody Enter, jíž parametrem předáme, pro který objekt chceme zámek získat.
Dokud zámek neuvolníme pomocí metody Exit, je zaručeno, že žádné jiné vlákno nezačne provádět kód metody DoSomeWork, poněvadž nebude schopno získat zámek pro instanci. Do té doby, než bude zámek pomocí metody Exit uvolněn, ostatní vlákna, chtějící vykonat synchronizovaný kód (ten mezi Enter a Exit), budou čekat ve frontě na uvolnění zámku objektu.
Jelikož je synchronizován i kód metody DoCheck, nemůže se stát, že by byla splněna podmínka nerovnosti hodnot datových členů, protože zámek instance není nikdy v metodě DoSomeWork uvolněn dříve, než proběhne změna hodnoty obou dvou datových členů. Tudíž se nikdy nezačne provádět kód metody DoCheck v době kdy nějaké jiné vlákno pracuje s metodou DoSomeWork a to proto, že se vláknu volající metodu DoCheck nedostane zámku a bude muset čekat na jeho uvolnění.
V příkladu ukazujícím synchronizaci jsem již učinil datové členy a metody instančními a to proto, abych mohl získávat a uvolňovat zámek na specifické instanci. Získání a uvolňování zámků je možné i v případě používaný statických členů. V těchto případech se doporučuje používat jako objekt pro použití zámku instance třídy System.Type, získaná pomocí klíčového slova typeof. Takže by se získávání zámku v našem příkladu změnilo do následující podoby.
internal void DoSomeWork()
{
Monitor.Enter(typeof(SynchronizingExam));
count++;
Thread.Sleep(10);
count2++;
Monitor.Exit(typeof(SynchronizingExam));
}
Dalším možným objektem pro práci se zámky je datový člen pouze pro čtení (readonly).
Klíčové slovo lock
V jazyku C# můžeme namísto volání metod Enter a Exit třídy Monitor použít klíčové slovo lock. Použití tohoto slova ve výsledku stejně znamená jeho přeložení na volání výše zmíněných metod v kombinaci, ale navíc s bloky try a finally. Takže následující kód:
//ziskani zamku
lock(this)
{
count++;
Thread.Sleep(10);
count2++;
}
//uvolneni zamku
..se vlastně přeloží do této podoby:
Monitor.Enter(this);
try
{
count++;
Thread.Sleep(10);
count2++;
}
finally
{
Monitor.Exit(this);
}
Takže pokud chcete pracovat se zámky objektů, je lepší pokud budete používat právě toto klíčové slovo jazyku C#, které vám ušetří psaní zdrojového kódu. A pokud se z nějakého důvodu rozhodnete používat místo slova lock volání metod třídy Monitor, vždy se ujistěte, jestli není vhodné použít bloky try a finally pro případ, že by kód v synchronizovaném bloku zapříčinil vyhození výjimky, poněvadž pokud by k tomu došlo, nebyl by zámek vůbec uvolněn. Takže myslím, že to často vhodné bude.
Atomické operace
Jedním z dalších problémů, které se mohou vyskytnout při používání více vláken v aplikaci je problém nazývaný „Race condition“ nebo také „Data race“. O co jde? Těmito slovy je označována situace, kdy výstup programu závisí na tom, které ze dvou nebo více vláken vykoná určitou část kódu dříve. To znamená, že pokud spustíme program vícekrát, dostaneme různé výsledky. Jedním z příkladů této situace je manipulace s hodnotou nějaké proměnné. Pokud se například rozhodneme inkrementovat hodnotu nějaké proměnné, běhové prostředí vnitřně provede tři operace.
První operace je představována načtením hodnoty proměnné do registru, po té je hodnota v registru zvýšena a jako třetí operace je tato hodnota zapsána zpět do proměnné. Takže se v určitých situacích může stát, že před tím, než jedno vlákno, po provedení prvních dvou operací, stačí zapsat novou hodnotu do proměnné, tak jiné vlákno si zatím přečte starou hodnotu z proměnné, tu inkrementuje a hodnotu později zapsanou prvním vláknem přepíše tou svou.
To ve výsledku znamená, že místo aby se hodnota inkrementovala například o hodnotu dvě, zvýší se pouze o hodnotu jedna. Jednu simulaci tohoto problému jsem vytvořil jako příklad, jehož zdrojový kód zde v článku neuvedu, ale všem je k dispozici v příkladech ke stažení. Jak takovémuto problému předejít?
Řešení tohoto problému představuje třída System.Threading.Interlocked, která je určena k provádění atomických operací. Jako atomické jsou zde prováděny operace jako zvýšení či snížení hodnoty proměnné.
/// <summary>
/// Ukazka pouziti tridy Interlocked
/// </summary>
public class InterlockedExam
{
private static int sharedInt;
public static void Run()
{
Thread lSecondThread = new Thread(new ThreadStart(DoSomeWork));
lSecondThread.Start();
DoSomeWork();
lSecondThread.Join();
Console.WriteLine("Konecna hodnota je {0}", sharedInt);
}
internal static void DoSomeWork()
{
for(int i = 0; i < 5; i++)
{
//zvyseni hodnoty pomoci tridy Interlocked
Interlocked.Increment(ref sharedInt);
}
}
}
Třída Interlocked mimo jiné nabízí metodu pro inkrementaci hodnoty proměnné a právě tato metoda, jak můžete vidět, je použita v ukázkovém příkladu, kde jí pomocí klíčového slova ref předáme odkaz na proměnnou, jejíž hodnota má být zvýšena jako atomická operace.
Zdrojové kódy představující příklady, které se vztahují k tomuto dílu, získáte zde.
Příští díl seriálu bude pojednávat opět o práci s vlákny a mimo jiné se podíváme na další využití třídy Monitor.