Sociálne siete

SecIT.sk na Facebooku SecIT.sk na Google+ SecIT.sk na Twitteri

Podporte nás


V prípade, že Vám obsah nášho portálu niekedy nejakým spôsobom pomohol, či bol pre Vás prínosom prosím podporte jeho chod ľubovoľnou čiastkou. Ďakujeme!

Štítky

Vyhľadávanie

You are here

Domov
Upozornenie: Obsah je licenčne chránený a bez písomných súhlasov autora článku a vlastníka webovej stránky nesmie byť v žiadnej forme ďalej kopírovaný a šírený v pôvodnom, či v akokoľvek upravenom stave.

Vývoj ovladačů jádra 3. díl - synchronizace

V dnešním poněkud teoretickém dílu se zaměříme na to, jak správně nakládat se sdílenými prostředky. Některým se určitě již začínají nudou protahovat obličeje - v dnešním díle se nebudeme "hrabat" ve vnitřnostech operačního systému Windows. Myslím si, že látka tohoto dílu je ale velmi důležitá, protože ji budete při psaní ovladačů aplikovat velmi často. Špatná práce se sdílenými prostředky totiž vede ke katastrofě.

Jak to může vypadat

Aby to nebylo suchopárné hned od prvních odstavců, ukážeme si jeden příklad špatné práce se sdílenými daty. První příklad je ryze fiktivní. Představme si velmi jednoduchý server, který ve své paměti uchovává určitý blok dat. Nechť tento server umožňuje pomocí jednoduchého rozhraní jiné aplikaci přečíst tento blok dat. Přitom ono čtení vypadá tak, že aplikace pošle požadavek, server se na něj podívá, přečte svůj blok dat a zapíše jej do odpovědi. Jelikož naše serverová aplikace není žádné "ořezávátko", dokáže zpracovávat více požadavků najednou - to znamená, že když k ní dorazí požadavek na přečtení dat, tak jej zpracuje v nově vytvořeném vlákně. To znamená, že blok dat serveru může být čten více vlákny najednou. Toto už zavání špatným přístupem k datům, tudíž předpokládejme, že tato data jsou neměnná a že současné čtení bloku více vlákny si obstará harware (třeba RAM). Uvažme dále, že server si dělá statistiky, kolik požadavků na čtení svých dat dostal. Nechť se toto číslo uchovává v proměnné s názvem Counter. Vždy po odeslání odpovědi na nějaký požadavek se hodnota Counter zvýší o jedničku. Nechť je inkrementace proměnné implementována následujícím kódem:


// Promenna Counter
ULONG Counter;

....

VOID IncrementCounter()
{
   ULONG tmp;
   tmp = Counter;
   tmp = tmp + 1;
   Counter = tmp;
}

Diskuzi o tom, jak je tento kód hezký a efektivní, přeskočme, protože v našem kontextu není podstatná. Inkrementace proměnné Counter pomocí takovéto rutiny nebude pracovat správně. Důvod je prostý. Nechť server zpracovává dva požadavky najednou. Pro jednoduchost ještě předpokládejme, že serverová aplikace běží na jednoprocesorovém stroji s operačním systémem, jenž podporuje multitasking (resp. multithreading). Protože má stroj jenom jeden procesor, v jednom okamžiku bude provádět kód právě jednoho ze dvou vláken, které zpracovávají požadavky. Vlákno č. 1 tedy vstoupí do rutiny IncrementCounter a provede první dva řádky - tedy uschová si do své proměnné tmp hodnotu globální proměnné Counter a svoji proměnnou zvýší o jedničku. Pokud tedy byla v Counter např. hodnota 100, v tmp vlákna č. 1 bude hodnota 101. V tomto okamžiku však dojde k zásahu vyšší moci - je vygenerováno přerušení, na nějž operační systém zareaguje tak, že vlákno č. 1 uspí a probudí vlákno č. 2, které shodou okolností vykoná rutinu IncrementCounter, aniž by jej někdo vyrušoval, a tedy nastaví Counter na 101. Když se po nějaké době opět dostane řada na vlákno č. 1, tak přiřadí do Counter hodnotu své proměnné tmp, která je ale 101. Tady je ale něco špatně - byly zpracovány dva požadavky, avšak proměnná Counter byla zvýšena jenom o jedničku! Důvod je prostý: špatná práce se sdílenými daty.

Ačkoliv je příklad trochu přitažený za vlasy, názorně ukazuje, co se může při špatné práci se sdílenými prostředky stát. A důsledky mohou být opravdu dalekosáhlé a katastrofické. Představme si skupinu vláken, která pracuje s blokem sdílené paměti. Pokud nebudou dobře synchronizována, může nastat situace, kdy jedno vlákno paměť čte a druhé ji ve stejném okamžiku mění. Když se nějakému vláknu mění paměť "pod rukama", nikdy to nedopadne dobře.

Trocha teorie, která nikoho nezabije

Kód, který přímo pracuje se sdíleným prostředkem, se nazývá kritická sekce (neplést např. s kritickými sekcemi ve Windows!). Z příkladu vidíme, že kód kritické sekce by měl být v jednom okamžiku vykonáván právě jednou. Myšlenkově to můžeme zajistit docela snadno - když vlákno dorazí na začátek kritické sekce, podívá se, jestli už v ní někdo je. Pokud ano, vlákno usne. Jakmile je kritická sekce volná, jedno z čekajících vláken je probuzeno a může do ní vstoupit. Na začátku kritické sekce tedy dochází k blokování vláken - jinak řečeno, vlákna čekají.

Existují dva druhy čekání. Prvním je čekání pasivní - čekající vlákno nezabírá žádný čas procesoru. Přechod do tohoto druhu čekání je však relativně pomalý a probuzení také. Z toho plyne, že pasivní čekání se vyplácí tehdy, pokud je vysoká pravděpodobnost, že vlákno bude čekat dlouho. Naopak aktivně čekající vlákna vytěžují neustále procesor. Přechod do a ze stavu čekání je však rychlejší než u čekání pasivního. Aktivně se tedy vyplatí čekat, pokud víme, že nebudeme čekat dlouho. Vlákno, které čeká pasivně na povolení vstoupit do kritické sekce, prostě usne a je odplánováno z procesoru. Vlákno, které čeká aktivně, se pořád dotazuje, zda už je kritická sekce volná. Vytěžuje tedy procesor.

Z toho, co jsme si řekli, lze odvodit několik pravidel, jejichž dodržování nám zajistí, že budeme se sdílenými prostředky pracovat správně. Tato pravidla bychom mohli formulovat třeba takto:

  • V kritické sekci se v jednom okamžiku může nacházet jenom jedno vlákno
  • Vlákno, které se nachází v kritické sekci, nesmí být blokováno (nesmí čekat) vláknem, které v kritické sekci není. Vlákna čekající na vstup do kritické sekce by pak mohla čekat třeba navždy.
  • Pište programy nezávisle na rychlosti a počtu procesorů a jiných prostředků (disk, paměť atp.). Pokud ve svém programu předpokládáte, že určitý kód se vykoná za 5 vteřin, tak váš program za nějaký čas fungovat nebude, protože váš předpoklad bude porušen díky zvýšení výkonu procesoru.
  • Vlákno se musí v kritické sekci zdržet jen po konečně dlouhou dobu.

Dodržování těchto pravidel vám zaručí správnou práci se sdílenými prostředky a také nebude docházet k trvalému zablokování. Existují různé mechanismy, jenž usnadňují dodržování těchto pravidel. Přitom některé z nich opravdu fungují, jiné ne. Některé z nich si nyní ukážeme.

Zakázíní přerušení

Toto je velmi jednoduchý mechanismus. Říkáte si možná, že až příliš jednoduchý, aby mohl fungovat. A opravdu - nefunguje. Pokud zakážete přerušení, znemožníte tím operačnímu systému (pod pojmem "operační systém" v tomto seriálu myslím primárně Windows) realizovat multitasking a multithreading. Přerušení se zakážou pomocí

__asm cli

a povolí instrukcí

__asm sti

Pokud kritickou sekci ohraničíte těmito instrukcemi, můžete podlehnout dojmu, že je vše zajištěno. To není pravda. Za prvé musíte vždy velmi opatrně psát kód kritických sekcí (obecně by měl být co možná nejkratší a nejjednodušší) a za druhé tímto postupem zakážete přerušení pouze na daném procesoru. Pokud stroj, na kterém váš program běží, disponuje více procesory, vaše opatření se totálně míjí účinkem. Stejný závěr můžeme uničnit o mechanismu založeném na zvýšení IRQL na HIGH_LEVEL.

Na rootkit.com se kdysi objevil "proof-of-concept" rootkit nazvaný AK922, který lehkou úpravou jité rutiny jádra, skrýval soubory. Již tehdy jsem vlastnil stroj s dvoujádrovým procesorem a rootkit mi nefungoval (docházelo k modrým obrazovkám). Zajímalo mne, čím to je způsobeno, a tak jsem se podíval dovnitř. A k mému překvapení jsem zjistil, že kritické sekce jsou zajišťovány pomocí zákazu přerušení...

Semafor

Semafory narozdíl od minulého případu opravdu fungují a dodnes se používají. Jedná se většinou o číselnou proměnnou (ale může to být libovolná jiná struktura), jejíž hodnotu však není možné modifikovat přímo (vyjma inicializace semaforu). Semafor pro okolní svět definuje dvě rutiny, kterými lze hodnotu proměnné ovlivnit. Rutiny zde budu naývat Up() a Down() ("vynálezce" semaforů, E. W. Dijkstra, je pojmenoval V() a P()). Rutina Up() zvýší hodnotu proměnné o jedničku (případně o jiné číslo). Zvýšení hodnoty probíhá atomicky (během provádění atomické operace nemůže dojít k přerušení. Atomické operace mají vždy definovaný stav. Prostě jejich vykonání nelze "rozpůlit", musí proběhnout vcelku). Rutina Down donutí volajícího čekat, dokud proměnná semaforu nenabude kladné hodnoty. Potom je volající probuzen a hodnota proměnné je o jedničku snížena, což je opět implementováno atomicky. Atomicita operací je zajišťována například hardwarově. Pseudokód obou rutin může vypadat třeba takto:

VOID Down( SEMAPHORE s)
{
   While (S <= 0) // Cteni S je atomicke
      Cekej(); 
   S = S - 1;     // Atomicke
   // Detekce kladne hodnoty S 
a dekrementace musi tvorit atomickou operaci
}

VOID Up( SEMAPHORE s)
{
   S = S + 1;     // Atomicke
}

Rutina Cekej() může implementovat jak aktivní, tak pasivní čekání.

Semafory patří mezi základní synchronizační objekty - synchronizační primitiva. Využitím semaforu je možné vytvářet komplikovanější (a pro programátora pohodlnější) synchronizační objekty. Semafor vás od chybování neuchrání. Pokud se semaforem S, jehož proměnná má hodnotu 1, vlákno provedé kód

Down(S);
Down(S);

bude čekat navždy.

Dost bylo teorie. Pojďme se podívat, jaké synchronizační možnosti nám jádro Windows nabízí.

Práce se semafory (KSEMAPHORE)

Semafor se inicializuje pomocí rutiny KeInitializeSemaphore, jenž má následující tvar:


VOID KeInitializeSemaphore
		OUT PKSEMAPHORE Semaphore,
		IN LONG Count,
		IN LONG Limit);

První parametr je ukazatel na paměť, kde se bude uchovávat proměnná semaforu. Tuto paměť musíte alokovat (nejlépe z nestránkované paměti pomocí ExAllocatePool), nebo použít místa na zásobníku (tzn. deklarovat lokální proměnnou typu KSEMAPHORE, pokud je to rozumné). Parametr Limit určuje, jaké maximální hodnoty může proměnná semaforu dosáhnout. Count udává její aktuální hodnotu.

Metoda Down je zde zastoupena funkcí KeWaitForSingleObject, jejíž deklarace vypadá takto:


NTSTATUS KeWaitForSingleObject(
		IN PVOID Object,
		IN KWAIT_REASON WaitReason,
		IN KPROCESSOR_MODE ProcessorMode,
		IN BOOLEAN Alertable,
		IN PLARGE_INTEGER Timeout OPTIONAL);

Tato rutina se podívá na hodnotu proměnné semaforu. Pokud je hodnota kladná, je snížena o jedničku a volající vlákno pokračuje ve své činnosti. Pokud je však hodnota nekladná, vlákno je uspáno. Parametrem Timeout lze nastavit, jak dlouho bude vlákno čekat na příznivou hodnotu čítače semaforu. Pokud zadáte NULL, bude se čekat do nekonečna. Parametr Alertable určuje, zda vlákno může vykonávat APC (popíšeme si v dalších dílech. Prozatím si to můžete představit jako přerušení. Vlákno je donuceno vykonat nějakou činnost a pak se opět vrátí do předchozího stavu), nebo může být ze stavu čekání probuzeno jádrem operačního systému. ProcessorMode může nabývat hodnot KernelMode (Ring 0) nebo UserMode (Ring 3). Podle něho se rozhoduje, v jakém režimu bude vlákno čekat. Pokud se jedná o vlákno jádra, specifikujte KernelMode. WaitReason umožňuje zohlednit situace, kdy víme, že vlákno bude čekat relativně dlouho. Pokud WaitReason nastavíme na UserRequest, bude moci procesor během čekání vlákna vykonávat i kód v uživatelském režimu. Pokud specifikujeme Executive, možné to nebude. Prvním parametrem je adresa paměti synchronizačního objektu (tedy například struktura KSEMAPHORE). Tato funkce je určena pro většinu synchronizačních objektů - používá se jak pro semafory, tak pro eventy (události), procesy, vlákna či některé druhy mutexů.

Parametr Timeout udává časový interval v stonanosekundových jednotkách - jedna vteřina má 10^7 těchto jednotek. Pokud specifikujeme kladný počet, jedná se o absolutní čas počítaný od prvního ledna 1601. Dokud tento čas nenastane, vlákno bude čekat. Pokud specifikujeme zápornou hodnotu, bude se jednat o relativní čas. Většinou tedy použijeme zápornou hodnotu. Například hodnota -50000000 zajistí čekání dlouhé maximálně 5 vteřin.

Rutina vrátí STATUS_TIMEOUT, pokud vlákno na synchronizační obkekt neúspěšně čekalo zadaný časový interval. STATUS_SUCCESS je vráceno, pokud bylo vlákno probuzeno díky příznivému stavu synchronizačního objektu (tzn. vlákno může vstoupit do kritické sekce). STATUS_ALERTED je vrácen, pokud bylo vlákno probuzeno jádrem operačního systému (je tam nedokumentovaná funkce, která to zařídí) a STATUS_USER_APC se objeví, když je vlákno nuceno vykonat APC.

Metoda Up je zde suplována funkcí s na první pohled podivným názvem KeReleaseSemaphore. Podivné se to může zdát proto, že funkce zvyšuje hodnotu čítače semaforu a přitom má v názvu slůvko "snížit". Ale můžeme se na to dívat i z jiného pohledu: kritická sekce je hlídána synchronizačním objektem (např. semaforem). Pokud je vlákno v kritické sekci, tak můžeme říci, že vlastní tento synchronizační objekt. Vlákna, která čekají před kritickou sekcí, vlastně čekají, až jim tento objekt bude dán do vlastnictví. Pomocí KeReleaseSemaphore se vlákno vzdává vlastnictví semaforu ve prospěch vláken čekajících. Potom už název dává větší smysl. Deklarace je takováto:


LONG KeReleaeSemaphore(
		IN PKSEMAPHORE Semaphore,
		IN KPRIORITY   Increment,
		IN LONG Adjustment,
		IN BOOLEAN Wait);

První parametr je opět adresa paměti, kde je uložen čítač semaforu. Pokud volání funkce umožní nějakému vláknu vstoupit do kritické sekce, tak jeho priorita bude zvýšena o Increment. K čítači semaforu se přičte hodnota Adjustement. Tento parametr musí být kladné číslo. Poslední parametr dávejte FALSE - TRUE má smysl jenom za podmínky, pokud byste chtěli hned poté volat funkci KeWaitForSingleObject. Podrobnější informace o čtvrtém parametru si přečtěte v DDK (ale o moc víc toho tam není).

Mutex (RKMUTEX)

Standardní mutex je v podstatě semafor, jehož čítač může nabývat hodnot 0 nebo 1. Mutex se inicializuje podobně jako semafor a to rutinou KeInitializeMutex:


VOID KeInitializeMutex(
		OUT PRKMUTEX Mutex,
		IN ULONG Level);

Parametr Level je podle DDK rezervovaný a musí být 0 (ale určitě nějaké použití má). Vnitřní čítač mutexu je inicializován na hodnotu 1 (také se říká, že je inicializován do signálního stavu) - to znamená, že nepatří žádnému vláknu. Vlákno si mutex přivlastní funkcí KeWaitForSingleObject. Díky omezení hodnot čítače může být mutex v daném okamžiku vlastněn jen jedním vláknem (což u semaforu neplatí). Vlákno se "zbaví" mutexu pomocí následující rutinky:


LONG KeReleaseMutex(
		in PRKMUTEX  Mutex,
		IN BOOLEAN Wait);

Pro parametry platí v podstatě to samé jako u KeReleaeSemaphore.

Existují ještě další varianty mutexů, které jsou rychlejší a mají pár dalších vymožeností. Jedná se o tzv. "guarded" a "fast" mutexy. Jejich používání se příliš neliší od používání normláních mutexů. Pokud by vás zajímaly podrobnosti, odkazuji na nápovědu DDK.

Událost (KEVENT)

Event je další synchronizační objekt, který můžete použít. Docela se podobá binárnímu semaforu. Má dva stavy - stav signální a nesignální. Chování v nesignálním stavu je stejné jako u semaforu s čítačem nekladným - pomocí KeWaitForSingleObject vlákna čekají, až je událost přepnuta do signálního stavu. Existují dva scénáře chování události v signálním stavu. Pokud je událost Notifikační, všechna čekající vlákna se při jejím přechodu do signálního stavu probudí a vstoupí do "kritické" sekce. Událost zůstane v signálním stavu, dokud ji někdo manuálně nepřepne do stavu nesignálního (takže nedochází k uspání vláken později volajících KeWaitForSingleObject). Druhý scénář nastává, je-li událost synchronizační - z čekajících vláken se vybere jedno, které je probuzeno a puštěno do kritické sekce. Po tomto úkonu se událost přepne do nesignálního stavu. Vlákna jsou tedy do kritické sekce pouštěna postupně.

Užití obou typů událostí je celkem zřejmé. Pokud chcete dát informaci o tom, že se něco stalo, používá se notifikační událost. Například operační systém při nízkém stavu volné paměti nastaví jistou notifikační událost, takže se o situaci dozví všichni, kdo na ni čekají. Pro synchronizaci přístupu k datům se používá synchronizační událost.

S událostmi se zachází podobně jako se semafory. Napřed je nutné inicializovat vnitřní data události. K tomu slouží následující rutiny:


VOID KeInitializeEvent(
		OUT PRKEVENT Event,
		IN EVENT_TYPE EventType,
		IN BOOLEAN State);

PKEVENT IoCreateNotificationEvent(
		IN PUNICODE_STRING EventName,
		OUT PHANDLE EventHandle);

PKEVENT IoCreateSynchronizationEvent(
		IN PUNICODE_STRING EventName,
		OUT PHANDLE EventHandle);

První procedurka naplní zadanou paměť daty události. Umožňuje nastavit typ události (druhý parametr může nabývat hodnot SynchronizationEvent, nebo NotificationEvent) a počáteční stav události (TRUE znamená signální stav). Zbylé dvě rutiny vytváří pojmenovanou událost. Vrací jak ukazatel na její paměť, tak i handle. Handle v režimu jádra málokdy využijete u synchronizačních objektů. Rutiny se však dají použít také k otevření již existující události - vyhledává se podle jména. Událost vytvořenou pomocí IoXXX rutin musíte zrušit voláním funkce ZwClose. Jinde něco takového není potřeba, protože paměť s daty synchronizačního objektu poskytujete vy, a tudíž se předpokládá, že si zrušení eventu (či jiného synchrozinačního objektu) zařídíte vlastními prostředky.

Stav události se mění přes KeSetEvent. Událost se restartuje do nesignálního stavu pomocí KeResetEvent či KeClearEvent. Prototypy funkcí následují:


LONG KeSetEvent(
		IN PRKEVENT Event,
		IN KPRIORITY Increment,
		IN BOOLEAN Wait);

LONG KeResetEvent(
		IN PRKEVENT Event);

VOID KeClearEvent(
		IN PRKEVENT Event);

Na událost se čeká opět pomocí funkce KeWaitForSingleObject.

Spin lock (KSPIN_LOCK)

Spin lock je synchronizační objekt, jenž se používá u kritických sekcí, na jejichž uvolnění se nečeká dlouho. Pokud synchronizujete pomocí spin locku, vlákna nejsou uspána. Nečeká se pasivně, ale aktivně. To je výhodné, pokud vlákno čeká krátký časový interval. Spin locků se používá při synchronizaci přístupu k datovým strukturám.

Spin lock se inicializuje stejně jako ostatní synchronizační objekty. Ani název rutiny nepřekvapí.


VOID KeInitializeSpinLock(
		OUT PKSPIN_LOCK Spinlock);

Při vstupu do kritické sekce vlákno zavolá KeAcquireSpinLock. Tím si spin lock přivlastní. Pokud již spin lock vlastní jiné vlákno, naše vlákno aktivně čeká (od toho název "spin lock"). Pro zrušení vlastnictví spin locku slouží KeReleaseSpinLock.


VOID KeAcquireSpinLock(
		IN PKSPIN_LOCK Spinlock,
		OUT PKIRQL OldIrql);

VOID KeReleaseSpinLock(
		IN PKSPIN_LOCK Spinlock,
		IN KIRQL NewIrql);

Možná vás zaráží druhé parametry obou rutin. Vysvětlení je ale jednoduché - při přivlastnění spin locku se zvýší IRQL na DISPATCH_LEVEL a na adresu předanou v druhém parametru se uloží původní IRQL. Tuto hodnotu musíte předat jako druhý parametr při uvolnění vlastnictví, aby vlákno fungovalo opět pod stejným IRQL jako před kritickou sekcí.

Kritické sekce hlídané spin locky by měly obsahovat co nejméně kódu, neměly by vyvolávat žádné výjimky, přerušení či obsahovat další kritické sekce se spin locky. Vlákno by nemělo držet spin lock déle jak 25 mikrosekund. Nezapomínejte také na to, že kritické sekce hlídané spin locky se vykonávají s IRQL DISPATCH_LEVEL, tudíž například nemůžete používat stránkovanou paměť.

Závěrečné poznámky

  • Semafory, mutexy a události lze použít i v uživatelském režimu
  • Pro čekání na více objektů najednou lze využít rutinku KeWaitForMultipleObject, pro níž platí stejná pravidla jako pro KeWaitForSingleObject. Pro bližší informace odkazuji na DDK.
  • Tento poněkud teoretický výklad nepokrývá všechny synchronizační objekty. Pouze ty základní.


Podporte nás


Páčil sa Vám tento článok? Ak áno, prosím podporte nás ľubovoľnou čiastkou. Ďakujeme!


ITC manažer Security-portal.cz Soom.cz
Hoax.cz Antivirove centrum Crypto-world.info