Obsahem tohoto dílu bude zběžné porovnání dostupných timerů v C# s hlavním zaměřením na ten ze třídy System.Threading. Dále se podíváme na Local Storage, klíčové slovo volatile a na atomicitu.
Nejjednodušším způsobem, jak zavolat nějakou metodu periodicky (po pravidelně se opakujících intervalech), je použití časovačů (timerů). V .NET frameworku je jich hned několik. Když už bereme vlákna, podíváme se nejdříve na ten ze System.Threading.Timer. Třída Timer je velice jednoduchá – obsahuje jen konstruktor a dvě metody (jaká úleva, není toho tolik k popisování ani k pamatování).
Definice této třídy vypadá následovně:
public sealed class Timer : MarshalByRefObject, IDisposable
{
public Timer (TimerCallback tick, object state, prvniTick, interval);
public bool Change (prvniTick, interval); // změna intervalu
public void Dispose();
}
//PrvniTick - čas, jak dlouho má Timer
// udělat první tick
//interval - intervaly mezi dalšími ticky
// použijte Timeout.Infinite, pokud chcete
// jen jeden tick
V následujícím příkladu, jakmile zapnete program, se spustí odpočítávání 5 vteřin, pak se vypíše na obrazovku nápis „tik ťak“, který se bude opakovat každou vteřinu, dokud uživatel nestiskne Enter.
class Program
{
static void Main()
{
using(new Timer(Tick, "tik ťak", 5000, 1000))
{
Console.ReadLine();
}
}
static void Tick(object data)
{
// Spustí se na vlákně ve fondu vláken
Console.WriteLine(data);
}
}
Jak už jsem řekl, v .NETu existují i jiné timery. Teď se tedy podíváme na namespace System.Timers. Třída Timer z tohoto namespace obaluje tu ze System.Threading, přidává nějakou funkcionalitu navíc a pár změn:
- Je to komponenta, takže ho můžeme přetáhnout z Toolboxu a pracovat s ním ve Visual Studio Designeru.
- Má vlastnost Interval namísto metody Change.
- Má událost Elapsed namísto callback delegátu.
- Vlastnost Enabled(bool) pro spuštění a zastavení časovače (výchozí je false).
- Metody Start a Stop (pro případ, že by někomu nevyhovovala vlastnost Enabled).
- Vlastnost AutoReset pro indikaci, jestli se má časovač spouštět znovu (výchozí je true).
Většinu těchto změn ukazuje následující kód:
using System;
using System.Timers;
class SystemTimer
{
static void Main()
{
var tmr = new Timer(); // Bez argumentů
tmr.Interval = 500;
tmr.Elapsed += tmr_Elapsed; // Událost místo callback delegátu
tmr.Start(); // Spustí timer
Console.ReadLine();
tmr.Stop(); // Pozastaví timer
Console.ReadLine();
tmr.Start(); // Spustí timer (od předchozí hodnoty)
Console.ReadLine();
tmr.Dispose(); // Permanentně stopne timer
}
static void tmr_Elapsed(object sender, EventArgs e)
{
Console.WriteLine("tik ťak");
}
}
Existuje ještě třetí timer, který se pro změnu nachází v namespace System.Windows.Forms. Radikálně se liší od timerů z Threading a Timers, protože nepoužívá thread pool, ale vždy vypaluje událost Tick na stejném vlákně, jako byl vytvořen. Pokud ho tedy vytvoříme na primárním vlákně, může přistupovat k ovládacímu prvku a měnit jej v závislosti na „tikání“ bez použití Control.Invoke. Další vlastnosti má společné s timerem ze System.Timers.
WPF má ekvivalentní timer k tomu u WinForms, jen má jiný název – DispatcherTimer.
Local Storage
Dalším tématem (nesouvisejícím s timery), na které se dnes podíváme, je Local Storage neboli „lokální úložiště“. S tímto pojmem jste se mohli setkat například u ASP.NET, Silverlightu a podobných technologií. Vypadá to tak, že každé vlákno dostane přidělené datové úložiště izolované od ostatních vláken. Je užitečné pro ukládání různých informací o zabezpečení, protokolů, ale dá se použít i na jiná data. Kdybychom taková data předávali jako parametry metodám (pokud chceme, aby celé vlákno mělo k těmto datům přístup), bylo by to nepohodlné a měly by k nim přístup jen naše vlastní metody.
Dvě nejdůležitější metody jsou Thread.GetData, která umí číst data z Local Storage, a ThreadSetData, která naopak zapisuje. Obě metody potřebují ke své funkčnosti instanci třídy LocalDataStoreSlot, jež reprezentuje slot (představíme-li si Local Storage jako skříňku, pak je slot něco jako přihrádka - takových přihrádek můžeme mít tolik, kolik chceme). Konstruktor LocalDataStoreSlotu přijímá parametr typu string, který reprezentuje název slotu. Na více vláknech mohou mít sloty stejný název a přitom svoje data nesdílí. Je to kvůli tomu, že každé vlákno má svůj vlastní Local Storage. Příklad využití LS:
class Test
{
// Stejný objekt LocalDataStoreSlot
//můžeme použít napříč všemi vlákny
LocalDataStoreSlot secSlot = Thread.GetNamedDataSlot("securityLevel");
// Tato vlastnost bude mít na každém vlákně
// jinou hodnotu
int SecurityLevel
{
get
{
object data = Thread.GetData(secSlot);
return data == null ? 0 : (int)data;
}
set
{
Thread.SetData(secSlot, value);
}
}
// Další kód...
}
Metoda Thread.FreeNamedDataSlot zruší slot daného jména na všech vláknech, ale jen tehdy, pokud se už zadané sloty nepoužívají a byly sklizeny garbage collectorem.
To bylo k problematice Local Storage vše, v dnešní poslední kapitole se podíváme na klíčové slovo volatile a na atomicitu.
Neblokující konstrukce
Na úplném začátku druhého dílu byla tabulka nejrůznějších metod synchronizace a na jejím konci byly konstrukce volatile a Interlocked. Jak už víte, synchronizace můžeme dosáhnout pomocí zamykání, ale to patří mezi blokující konstrukce – vlákno čeká, dokud není zámek otevřený. Naštěstí máme k dispozici neblokující konstrukce, které jsou vhodné pro velmi jednoduché a rychlé operace (tzv. atomické, vysvětleno níže), nedochází totiž k žádnému čekání ani blokování.
Atomicita a Interlocked
Slovo atomicita vám může připomínat jistě známější slovíčko atom; není to náhoda, obě jsou z řeckého slova atomos, tedy nedělitelný. Atomická operace se skládá jen z jedné nedělitelné operace (např. sečtení proměnných není atomická operace, protože se musí hodnoty načíst a pak teprve sečíst – sčítání je dělitelná operace). Atomickou operací je například u 32-bitových procesorů přiřazení čísla do proměnné typu int, která je 32-bitová.
class Atomicity
{
static int x, y;
static long z;
static void Test()
{
long myLocal;
x = 3; // Atomické
z = 3; // Neatomické (z je 64-bit)
myLocal = z; // Neatomické (z je 64-bit)
y += x; // Neatomické (čtení a zapisování)
x++; // Neatomické (čtení a zapisování)
}
}
Práce s 64-bitovými čísly na 32-bitových procesorech není atomická operace, protože vyžaduje alokování dvou 32-bitových míst v paměti. Pokud nějaké vlákno A načítá 64-bitové číslo, zatímco vlákno B ho upravuje, může vlákno A dostat jakýsi mix obou hodnot (protože jedno 64-bitové číslo je složené vlastně ze dvou). Z tohoto odstavce je tedy jasné, proč práce s takovými čísly není atomická.
Atomické nejsou ani unární operátory (takové, které pracují s jednou proměnnou) typu x++. Nejdřív se musí aktuální hodnota „x“ načíst, pak přičíst jedničku a nakonec uložit novou hodnotu. Představte si takovouhle třídu:
class ThreadUnsafe
{
static int x = 1000;
static void Go() { for (int i = 0; i < 100; i++) x--; }
}
Možná byste čekali, že pokud metodu Go zavolá deset vláken najednou, proměnná x bude mít hodnotu 0 (cyklus proběhne 100x na deseti vláknech). To nám ale nikdo nezaručí, protože je možné, že jedno vlákno přistoupí k proměnné, zatímco druhé bude získávat její hodnotu, snižovat ji a zapisovat zpátky.
Jedním ze způsobů, jak toto nebezpečí ošetřit, je obalit uvedenou (neatomickou) operaci do locku. Nikdy jsme si to neřekli, ale teď vám to možná došlo – locking vlastně udělá z obalené operace atomickou. Existuje ale druhý, výhodnější způsob. Ten provedeme pomocí třídy Interlocked, která je jednodušší a rychlejší, pokud ji použijeme pro jednoduché operace.
class Program
{
static long sum;
static void Main()
{
// Inkrementace/dekrementace:
Interlocked.Increment(ref sum); // to samé jako: sum++
Interlocked.Decrement(ref sum); // sum--
// Přičtení/odečtení čísla:
Interlocked.Add(ref sum, 3); // sum += 3
Interlocked.Add(ref sum, -2); // sum -= 2
// Přečtení hodnoty 64-bit čísla
Console.WriteLine(Interlocked.Read(ref sum)); // sum == 1
// Přečte hodnotu a pak zapíše novou
// Následující řádek napíše "1" a pak změní hodnotu
// sum na 10
Console.WriteLine(Interlocked.Exchange(ref sum, 10)); // sum == 10
// Změní hodnotu proměnné, ale jen pokud se
// momentálně rovná zadané hodnotě (10)
Interlocked.CompareExchange(ref sum, 123, 10); // sum == 123
// Finální hodnota proměnné sum:
Console.WriteLine(Interlocked.Read(ref sum));
Console.ReadKey();
}
}
Používání třídy Interlocked je výhodné, protože obsahuje už předpřipravené atomické metody pro hojně používané operace. Zároveň nemůže dojít k jejímu zablokování, takže nemusíme nést následky přerušení práce.
Memory barriers a volatilita
Vezměme si tento kód:
class Unsafe
{
static bool konec, boolean;
static void Main()
{
new Thread(Wait).Start();
Thread.Sleep(1000);
boolean = true;
konec = true;
Console.WriteLine("Něco se děje...");
}
static void Wait()
{
while (!konec) ;
Console.WriteLine("A je klid, " + boolean);
Console.ReadKey();
}
}
Prohlédněte si jej. Nedělá nic komplikovaného: máme metodu, která se zavolá a je uzavřená v cyklu. Po jedné vteřině ji z toho cyklu osvobodíme nastavením proměnné konec na true a metoda pak vypíše: „A je klid,“ společně s hodnotou proměnné boolean.
Teď si položme otázky: je možné, aby metoda Wait byla pořád uzavřená ve while cyklu i po tom, co se proměnná konec nastaví na true? A je vůbec možné, aby metoda Wait napsala: „A je klid, False“?
Vypadá to nepravděpodobně, že? Ale odpověď na obě otázky je ano. Na víceprocesorových strojích, jakmile se každé vlákno přidělí na jiný procesor, se může stát, že se obě proměnné konec a boolean uloží do cache (vyrovnávací paměti), aby se k nim umožnil rychlejší přístup. Hrozí ale prodleva mezi tím, než se zapíší zpátky do paměti, a nemusí se nutně zapsat ve stejném pořadí, jako se uložily.
Toto riziko můžeme obejít použitím statických metod Thread.VolatileRead a Thread.VolatileWrite při práci s proměnnými. VolatileRead vlastně znamená „přečti poslední hodnotu“ a VolatileWrite zase „zapiš okamžitě do paměti“. Stejného výsledku dosáhneme i elegantněji - deklarováním proměnné jako volatile (v překladu „nestálý“, stejně jako paměť RAM):
volatile static bool konec, boolean;
Pokud proměnnou deklarujeme takto, říkáme tím vlastně: „nekešuj tuhle proměnnou“.
Stejného výsledku bychom dosáhli i použitím prostého locku. Fungovalo by to, protože vedlejším efektem zamykání je vytvoření tzv. „memory barrier“ – máme jistotu, že při vstupu do locku bude mít proměnná svojí nejaktuálnější hodnotu a před opuštěním locku se poslední hodnota zapíše do paměti.
Použít tento postup by bylo nutné v případě, že bychom potřebovali přistupovat k proměnným konec a boolean atomicky, například takhle:
lock (locker) { if (konec) boolean = true; }
Volatilita se týká jen primitivních typů, jiné typy se necachují a nemůžou být ani deklarovány s klíčovým slovem volatile.
To je pro dnešek vše. V následujících dvou dílech probereme zbývající synchronizační konstrukce, jimiž jsou Wait a Pulse.