Linux E X P R E S

Facebook

Vývoj jádra VII. – synchronizace

Pravděpodobně nejdůležitějším synchronizačním mechanismem je tzv. spinlock. Jedná se o aktivní čekání, běh zpracování se tedy nezastavuje. Je to vlastně cyklus, ve kterém se stále testuje, zda je možno pokračovat dál.


Stejně jako v běžných programech, i v jádře se musíme postarat o synchronizaci přístupu ke sdíleným prostředkům. Tento díl seriálu bude věnován jednak využití synchronizačních mechanismů, a také některým postupům, které omezují nutnost používat synchronizaci.

Význam spinlocku se pochopitelně projeví jen na víceprocesorových strojích (jinak by ani neměl kdo čekat) - pokud se tedy jádro kompiluje s vypnutou podporou SMP, žádné zamykání se ve skutečnosti do kódu negeneruje (při zapnutém debuggingu se ale i zde kontroluje, zda např. nemůže dojít k uspání). To samozřejmě není žádný důvod, proč je vynechat v případech, kdy je modul určen pro jednoprocesorové stroje (jeho použití na více procesorech by znamenalo úpravy kódu). Tím spíš, že u preemptivních jader zajišťuje (při použití v kontextu procesu) též ochranu před přepnutím kontextu.

Spinlock se používá všude tam, kde se nesmí čekat v uspaném stavu (obsluha přerušení, tasklet, obsluha události časovače apod.), ale i v případech, kde se jedná o velice krátký chráněný úsek (spinlock má menší režii než jiné synchronizační objekty).

Před použitím musíme každý spinlock vždy inicializovat, ať už staticky nebo dynamicky. I s použitím to vypadá takto:

spinlock_t lock = SPIN_LOCK_UNLOCKED;

spinlock_t lock;
spin_lock_init(&lock);

spin_lock(&lock);
...    // kód v kritické sekci
spin_unlock(&lock);

První řádek ukazuje statickou inicializaci, druhé dva řádky deklaraci a dynamickou inicializaci. Níže je pak ukázka zamčení kritické sekce a na jejím konci odemčení.

Pozor na zacyklení, pokus o nové zamčení znamená, že se "had kousne do ocasu" - tedy totální zatuhnutí systému. Lze používat i funkci spin_trylock(), která nezpůsobuje čekání. Namísto toho vrací nenulovou hodnotu, pokud bylo zamčení úspěšné, jinak vrací nulu.

Potřebujete-li použít tentýž spinlock na více místech, z nichž některá jsou obsluha přerušení, tasklet apod., musí se k zamykání (a odemykání) volat speciální verze funkcí, které zajistí zákaz přerušení (buď všech nebo jen softwarových). Bude o tom řeč v příštím dílu seriálu.

Spinlock s rozlišením čtení/zápis

V mnoha případech je obyčejný spinlock zbytečně "přísný" a zastavuje činnost i tehdy, když by nemusel. Je to tehdy, čteme-li data na více procesorech, avšak zápis je jen výjimečný. Pak je lepší použít speciální verzi spinlocku, která režimy rozlišuje. Při zamčení pro čtení je vyloučen zápis (číst může libovolný počet procesorů současně), kdežto zamčení pro zápis blokuje veškerý přístup. Kdo zná např. zamykání databázových tabulek, určitě tomu bude výborně rozumět.

rwlock_t lock = RW_LOCK_UNLOCKED;

// čtení
read_lock(&lock);
...
read_unlock(&lock);

// zápis
write_lock(&lock);
...
write_unlock(&lock);

Je to snad dobře pochopitelné. Oproti obyčejnému spinlocku se to liší jen tím, že pro čtení a zápis voláme různé funkce. Pro zápis máme i "neblokující" variantu write_trylock(), pro čtení ovšem nikoli.

Mezi zde nezmíněné postupy pro práci se sdílenými dat patří využití funkcí pro bitové operace (umožňují atomicky měnit a testovat jednotlivé bity v rámci nějaké proměnné) a seqlock (čtení bez zamykání s případným novým přečtením, pokud mezitím někdo hodnotu změnil).

Semafor

Mezi nejznámější synchronizační objekty patří semafor. Jedná se vlastně o čítač, který se dekrementuje při vstupu do kritické sekce a při jejím opouštění se zase inkrementuje. Pokud hodnota klesne na nulu, je běh následujícího procesu pozastaven, než se hodnota opět zvýší. V jádře se semafor často používá poněkud jednodušším způsobem, protože zde většinou pouze supluje vzájemné vyloučení (mutex; viz další část) - výchozí hodnota semaforu je 1 nebo 0 (tzn. otevřeno nebo uzavřeno).

Semafor se před použitím musí samozřejmě inicializovat. Protože se jedná o pasivní čekání, nelze ho používat tam, kde by to vadilo. Další důležitou věcí je, že lze čekat přerušitelně (proces je probuditelný zvnějšku, např. signálem), nebo nepřerušitelně. Pokud je to jen trochu možné, používáme vždy přerušitelné čekání - jinak hrozí, že proces v tomto stavu uvízne navždy a jeho alokované prostředky nebudou vráceny systému.

struct semaphore sem;
sema_init(&sem, 2);

DECLARE_MUTEX(sem);

// přerušitelné čekání
if (down_interruptible(&sem))
  return -EINTR; 
...
up(&sem);

V příkladu opět nejprve uvádím dva způsoby inicializace - první je dynamická inicializace semaforu v plnohodnotném použití, druhý pak statická inicializace v rámci deklaračního makra (s degradací semaforu na mutex). Pak následuje použití s přerušitelným čekáním. Všimněte si, že se vrací chybová hodnota -EINTR, typická pro přerušená systémová volání. Druhou možností je v takovém případě vracet -ERESTARTSYS, ale to by se mělo dělat jen v tehdy, uvede-li se vše do stavu (z pohledu uživatele), v jakém byl systém před tímto systémovým voláním.

Semafor s rozlišením čtení/zápis

Zde platí totéž co u spinlocků. Také semafor má svoji verzi rozlišující čtení a zápis. Je dobře ji používat, pokud to lze - příznivě se to projeví na rychlosti běhu procesů.

struct rw_semaphore sem;
init_rwsem(&sem);

if (!down_read_trylock(&sem))
  return;
...
up_read(&sem);

Příklad ukazuje pouze "čtecí" část (realizovanou pomocí neblokující funkce), zápisová by byla podobná. Pozor na tři věci - jednak že tu nejsou přerušitelné verze funkcí a dále pak, že zápis má vždy přednost před čtením (při častějších požadavcích na zápis je lepší používat obyčejný semafor). A konečně třetí důležitou věcí je, že neblokující funkce vrací opačnou hodnotu než u down_trylock() (nenulovou při úspěšné dekrementaci semaforu).

Mutex

Používání semaforu "pouze" k realizace vzájemného vyloučení je poněkud perverzní a zbytečně snižuje výkon. Bohužel, donedávna to nešlo dělat jinak, implementace mutexu jako takového prostě v jádře nebyla. To už se ale změnilo, od verze 2.6.16 máme k dispozici skutečné mutexy a API pro semafory bude změněno tak, aby naplňovalo skutečný účel semaforů. Proto by měly moduly, u kterých není nutná kompatibilita se staršími verzemi jádra, používat již novou podobu vzájemného vyloučení. S mutexy se pracuje prakticky stejně jako s původní implementací semaforů:
DEFINE_MUTEX(m);

if (!mutex_trylock(&m))
  return;
...
mutex_unlock(&m);

Zde si prosím povšimněte dvou věcí: návratová hodnota je jako u funkcí down_read_trylock() nebo spin_trylock(), a inicializace se provádí makrem DEFINE_MUTEX (neplést s DECLARE_MUTEX!).

Přerušení, odložené zpracování, časovače. Tato kapitola měla mj. za cíl vytvořit potřebné zázemí pro tu příští - pro obsluhu přerušení, využití odloženého zpracování dat a práci s časovači jádra. Právě tyto věci jsou totiž z hlediska přístupu k datům nejkritičtější a jakákoli chyba se krutě vymstí. Nyní už nám tedy nic nebrání pustit se do této velmi důležité oblasti, která tvoří prakticky páteř implementace ovladačů zařízení.

"Dokončení" (completion)

Někdy potřebujeme čekat, než skončí nějaké zpracování "venku". Máme např. proces (nebo vlákno, včetně speciálních vláken jádra), který se stará o veškerou správu nějakého zařízení, a tomuto procesu zadáme práci. Než proces dokončí svou činnost, musíme s naším klientským procesem čekat. A k tomu se právě hodí objekt zvaný completion. Z následujícího příkladu je vidět, jak se s tímto mechanismem pracuje. Pozor na to, že čekání je nepřerušitelné!

DECLARE_COMPLETION(compl);

wait_for_completion(&compl);

...
complete_all(&compl);

V příkladu je nejprve uvedena deklarace a inicializace. Druhý příkaz se volá tam, kde se má čekat na dokončení. A konečně třetí oznamuje, že byla činnost dokončena, a uvolní všechny čekající procesy (vlákna). Lze použít také funkci complete(), která uvolní pouze jediného čekatele, případně complete_and_exit() (speciální funkce pro ukončení vlákna jádra; používá se při uvolňování modulu).

Atomické proměnné

Explicitní synchronizace přináší mnohé problémy. Kromě režie na správu mechanismu je to také hrozba deadlocku při nevhodném uspořádání přístupu ke sdíleným prostředkům. Proto se snažíme synchronizaci vyhýbat. Jedním ze základních prostředků, jak "to řešit jinak", je využití atomických proměnných.

Potřebujeme-li např. čítač nebo nějakou jinou proměnnou používat z různých kontextů, musí být každá manipulace s touto proměnnou atomická (což ani třeba u inkrementace nemusí být vždy samozřejmostí). V Linuxu máme k dispozici speciální datový typ atomic_t, který má většinou délku 32 bitů (ovšem kvůli přenositelnosti počítejte pouze s 24!) a veškeré operace s ním jsou na všech platformách atomické. Aby vše správně fungovalo, musí se s proměnnou tohoto typu pracovat zásadně přes API:

atomic_t i = ATOMIC_INIT(0);

atomic_set(&i, 10);
...
if (atomic_dec_test(&i)) {
....
}

Příklad ukazuje inicializaci (statickou), nastavení hodnoty a dekrementaci s testem nové hodnoty. Funkcí je k dispozici mnohem víc, najdete je v hlavičkovém souboru asm/atomic.h.

Ještě dodám, že na 64bitových platformách je k dispozici také atomický typ o délce 64 bitů (atomic64_t). Dále pak lze používat typ atomic_long_t, pro jehož délku platí obdobná pravidla jako pro "obyčejný" typ long.

Read-Copy-Update (RCU)

Občas nastane situace, že se nějaká data velice často čtou, ale jen zřídka mění. Mohou to být např. nějaké konfigurační hodnoty (měněné např. přes ioctl() nebo přes sysfs). Pak je výhodné použít mechanismus RCU, který při změně dat vždy vytvoří jejich kopii a na tu se aplikuje změna. Není tedy třeba prakticky nijak chránit přístup k datům, je ale nutno počítat s poněkud větší režií zápisu.

Mechanismus pro RCU zajišťuje, aby ke změně dat došlo v okamžiku, kdy se s nimi nepracuje, a musí také odstranit případné reference na stará data. Práce s RCU vypadá takto:

mytype_t* data;

rcu_read_lock();
...
rcu_read_unlock();

Veškeré čtecí operace se musí umístit mezi uvedené příkazy a běh se zde nesmí uspat. Změna dat se musí implementovat tak, že se nejdřív alokuje paměť pro nová data (např. kmalloc()), pak se data překopírují (např. memcpy()) a upraví, změní se ukazatel a nakonec se zavolá call_rcu(), kde se předá ukazatel na úklidovou funkci (ta je pak zavolána v okamžiku, kdy se odstraní všechny případné reference, a musí uvolnit alokovanou paměť). Je to celé trochu složitější, ale bohužel tu na to není dost prostoru.

Diskuze (0) Nahoru