Stručný popis vláken POSIX

(IEEE POSIX 1003.1c - 1995 standard, dále je popisována konkrétní knihovna C-funkcí DECthreads z roku 1997, podrobnosti viz manuál)

Poznámka: Kromě příkladů není používána přesná syntaxe funkcí - důraz je na srozumitelnost. Přesná syntaxe je v manuálu.

1. Charakteristika

Knihovna pro podporu vícevláknových aplikací zavádí datové typy, makra a funkce potřebné pro vytvoření vícevláknové aplikace. Předpokládá se využití na jedno či více-procesorovém stroji se sdílenou pamětí, poskytované funkce tudíž poskytují podporu pro programovací techniku (způsob interakce vláken) "data sharing". Pomocí poskytovaných prostředků je samozřejmě možné si vytvořit vlastní (uživatelské) objekty a funkce pro "message passing" ve sdílené paměti.

1.1 Objekty, s kterými se pracuje

Zavádí se tři základní typy objektů:

Reprezentace objektů je pomocí tzv. "handle", což je zobecněný ukazatel na objekt některého z výše uvedených typů. Typy těchto ukazatelů jsou:

 

1.2 Atributové objekty

Kromě toho dále pro každý z uvedených tří základních typů existuje "přidružený" typ pro tzv. atributový objekt, který obsahuje "nastavitelné" vlastnosti vlákna:

 

1.3 Program vlákna

Každé vlákno běží podle (svojí) funkce s předepsaným typem procedure, tj.:

void* prog_name (void* arg);

přičemž podle téže funkce (programu chování vlákna) může běžet více vláken. Proměnné definované ve funkci programu vákna jsou lokální data vlákna.

 

1.4 Vytváření objektů

Objekty se vytváří (a lze je též rušit) dynamicky, i když to tak na první pohled (díky použití inicializátorů - maker) nemusí vypadat. Atributový objekt se buď automaticky (s implicitním nastavením) vyrobí při vytváření objektu, nebo se atributový objekt vytvoří předem, nastaví se voláním spec. funkcí a odkaz na existující atrib. objekt se předá jako parametr do funkce vytvářející třeba vlákno (a lze pak vlastnosti vlákna měnit nastavením atributů i v průběhu výpočtu).

Handle "zapouzdřuje" odkazy na atributový objekt a na datovou strukturu vlastního objektu (třeba vlákna).

 

Příklad 1.1:

int status; /* hodnota 0 znamená úspěšné volání, záporné hodnoty jsou chybové kódy */

pthread_t worker; /* worker je zatím prázdný handle vlákna */

...

status = pthread_create (&worker, NULL, prog_name, ... ); /* volání vytvářecí funkce, předává se odkaz na handle vlákna, dále hodnota NULL je prázdný odkaz na atributový objekt, který se tudíž vytvoří s implicitním nastavením */

... /* handle už je naplněný, vlákno je ve stavu ready, tudíž může už (bez dalších povelů) běžet, jakmile to plánovací pravidla dovolí */

 

Příklad 1.2:

pthread_mutex_t lock1 = PTHREAD_MUTEX_INITIALIZER; /* znamená jak definici handlu lock1, tak jeho naplnění odkazem na nově vytvořený a implicitně nastavený mutex*/

...

pthread_cond_t cond_barrier = PTHREAD_COND_INITIALIZER; /* znamená jak definici handlu cond_barrier, tak jeho naplnění odkazem na nově vytvořený a implicitně nastavený objekt podmínkové proměnné */

 

1.5. Paměťový obraz aplikace

Paměťový obraz vícevláknové aplikace víceméně odpovídá konvenčnímu paměťovému obrazu aplikace v C-jazyce s tím, že:

 

2. Ze života vlákna

2.1 Vytvoření vlákna

Vlákno se vytvoří voláním funkce

pthread_create (thread, attr, prog_name, arg),

kde význam atributů je tento:

Funkce pthread_create() vrací hodnotu typu int, přičemž nula znamená "úspěch", záporná hodnota "neúspěch" a chybový kód.

 

2.2 Stavy a běh vlákna

Stavy vlákna jsou tyto:

 

2.3 Základní atributy vlákna

Jsou uloženy v atributovém objektu sdruženém s vlastním objektem vlákna. Jedná se o:

Při vytváření vláken s implicitním nastavením atributů se atributy dědí od vytvářejícího vlákna. Pokud je vytvářejícím vláknem pouze hlavní program, jsou všechna vlákna v aplikaci v plánovací kategorii FG a mají stejnou prioritu, tj. střídají se ve výpočtu se stejným časovým kvantem.

Uvedená plánovací pravidla jsou samozřejmě použitelná i pro víceprocesorový stroj, tj. máme-li např. 4 procesory a 5 ready vláken (třeba jedno FIFO, dvě RR a dvě FG), plánovací algoritmus spustí FIFO, obě RR a jedno FG.

Příklad 2.1 (modifikace 1.1):

void main (void) {

...

int status; /* hodnota 0 znamená úspěšné volání, záporné hodnoty jsou chybové kódy */

pthread_t worker; /* worker je zatím prázdný handle vlákna */

pthread_attr_t worker_attr; /* zatím prázdný handle atrib. objektu vlákna */

struct sched_param sp; /* struktura obsahující jedinou položku - prioritu vlákna */

...

sp.priority = sched_get_priority_max (SCHED_FIFO); /* on-line zjištění největší hodnoty priority pro plán. kategorii FIFO a dosazení do struktury sp */

pthread_attr_init (&worker_attr); /* vytvoření atrib. objektu vlákna s implicitním nastavením (a "rozumné" naplnění handle atrib. objektu) */

pthread_attr_setschedpolicy (&worker_attr, SCHED_FIFO); /* nastavení plánovací kategorie na FIFO */

pthread_attr_setschedparam (&sp); /* nastavení priority v atrib objektu na maximální možnou */

...

status = pthread_create (&worker, &worker_attr, prog_name, ... ); /* volání vytvářecí funkce, předává se odkaz na handle vlákna a odkaz na nastavený atributový objekt vlákna*/

... /* handle už je naplněný, vlákno worker už je ve stavu running, protože mu dá plánovací mechanismus přednost před vláknem hlavního programu */

 

2.4 Likvidace vlákna "sešlostí věkem"

Běžný způsob ukončení "života vlákna" je dosažení konce jeho programu voláním return (value). Návratová hodnota (viz 1.3) je typovaná void*, čili jde vrátit odkaz na libovolnou informaci, třeba se vrací číslo dělníka, který ukončil práci.

Na "smrt" vlákna může čekat jiné vlákno-sup v operaci

int pthread_join (pthread_t*  na_smrt_koho_se_čeká, void**  kam_prijde_posledni_vule),

čili čekající vlákno dostane hodnotu "odeslanou" ukončeným vláknem. Pokud ale je dříve mrtvé vlákno "pohřbeno" (uvolnění dat) voláním pthread_detach (koho_pohřbít), nelze už nad ním provést operaci pthread_join().

 

2.5 Likvidace vlákna "násilím" z jiného vlákna

Příkaz, kterým se z jednoho vlákna likviduje jiné vlákno (cancelation request) je volání funkce:

pthread_cancel (koho_likvidovat);

Likvidované vlákno:

a) má možnost se "zásadně" bránit voláním funkce

pthread_setcancelstate (int new_state, int* old_state)

kterým lze dosazením new_state nastavit "cancelability state" vlákna na hodnoty PTHREAD_CANCEL_ENABLE / DISABLE. Implicitně je enable.

b) pokud je stav vlákna z hlediska likvidovatelnosti enable, je mu (voláním pthread_cancel())doručen požadavek na likvidaci (cancellation request). K likvidaci ale nedojde ihned, ale až při přechodu vlákna do stavu waiting voláním nějaké blokující funkce (třeba pthread_cond_wait(), volání je tzv. cancellation point).

c) likvidované vlákno může vždy po nějakém (delším) čase výpočtu samo testovat požadavek na vlastní likvidaci (a provést ji) voláním funkce pthread_testcancel(). Doporučuje se toto provádět např. v každé iteraci nějaké dlouhé smyčky programu vlákna. Tedy každé volání pthread_testcancel() je další (explicitně programátorem označený cancellation point.

To co bylo dosud řečeno, se týká tzv. "synchronní" likvidace (či likvidovatelnosti), tj. jsou známy body (cancellation points) v programu vlákna, kdy k likvidaci může dojít (a jsou to takové body, "aby nebyly potíže", např. se zanechaným zamknutým zámkem).

Kromě příznaku "cancelability state" lze ale ještě v datech vlákna nastavit příznak "cancelability type" voláním

pthread_setcanceltype (int new_type, int* old_type)

Implicitně je hodnota typu PTHREAD_CANCEL_DEFFERED, což znamená "odložení" požadavku na likvidaci do nejbližšího k tomu určeného místa v programu vlákna (cancellation point). Po přepnutí na hodnotu PTHREAD_CANCEL_ASYNCHRONOUS lze vlákno likvidovat okamžitě po vytvoření požadavku bez ohledu na to, v jakém místě svého programu se nachází.

Existuje jakýsi mechanismus "destruktoru" vlákna, tj. likvidované vlákno může provést po akceptování požadavku na likvidaci ještě nějaké akce (třeba odemknutí zámků, které samo zamklo). Pomocí volání funkcí

pthread_cleanup_push (jméno_handleru, argument_handleru)

pthread_cleanup_pop (int zda_počítat_vypouštěný_handler)

lze naplnit (a v průběhu života vlákna měnit) tzv. "handler stack" obsahující adresy funkcí (tzv. cleanup handlery), které jsou postupně ze stacku volány při likvidaci vlákna.

!!! Post-mortem uklízení funguje ale spolehlivě jen pro "synchronní" typ likvidace. Asynchronní likvidace vlákna je samozřejmě obecně riskantní akce a nelze ji nikterak doporučit, snad ji lze připustit jen pro úseky "čistě počítacího" kódu, který manipuluje jen s lokálními daty vlákna. Pokud možno, nepoužíváme ani "synchronní likvidaci".

 

3. Jak se zařídí ochrana globálních dat

Pro synchronizaci běhu vláken nad sdílenými daty (či jinými zdroji, které program využívá) se používají objekty označované jako "mutex" (z angl. mutual exclusion), které fungují jako paměťové zámky.

Typ mutexu (tj. jeho handle) je pthread_mutex_t, vytvoření mutexu již bylo předvedeno v části 1.4, příklad 1.2.

Příslušná dvojice operací - metod objektu je:

Kromě toho lze neblokujícím způsobem zamykat mutex voláním

state = pthread_try_lock (&mutex_handle);

přičemž navrácená hodnota 0 znamená úspěšné uzamčení (a volající vlákno je dále "vlastníkem" zámku, hodnota EBUSY znamená zamčený zámek (někým jiným) - je to vlastně operace označovaná abstraktně jako TEST&SET - atomicky zamyká a vrací starou hodnotu zámku.

V operaci mutex_unlock() je "probuzeno" jedno z eventuelně čekajících vláken (to, které má podle plánovací strategie přednost). Není ale zaručeno, že zámek probuzené vlákno dostane (může ho "předběhnout" některé z běžících vláken).

Poznámka: Lze si to dobře představit pro případ, kdy by bylo čekání na zámek v mutex_lock() implementováno jako programová smyčka (což nejspíš většinou bude). Pak jsou všechna čekající vlákna buď running nebo ready (čili buď běží nebo poběží "každou chvíli") a mají šanci si "uloupnout" zámek v konkurenci s ostatními vlákny, přičemž šance závisí na "privilegiích" vlákna.

Jsou podporovány 3 typy mutexů, označované symbolicky jako:

PTHREAD_MUTEX_NORMAL (default případ, nemusí se explicitně vytvářet a nastavovat atributový objekt) - jedná se o normální zámek, konkrétní vlákno jej může zamknout jen jednou (druhý pokus o zamknutí znamená deadlock),

PTHREAD_MUTEX_RECURSIVE - konkrétní vlastník jej může zamknout víckrát (a stejně-krát jej musí odemknout). Slouží pro impementaci rekurzivních funkcí nad sdílenými daty.

PTHREAD_MUTEX_ERRORCHECK - slouží pro ladění, funguje jako "normal", ale má odkaz na vlákna-vlastníky. Pozná se, když jej chce stejné vlákno zamknout podruhé.

Nastavení vlastností (typu) mutexu se provede voláním funkce pthread_mutexattr_settype() nad jeho atributovým objektem - dosazuje se některá z výše uvedených symbolických hodnot.

 

4. Jak se zařídí pasivní čekání vlákna na splnění nějaké podmínky

Poznámka: Čekání na mutex v operaci mutex_lock() může být implementováno jako programová smyčka (tzv. busy-wait čekání), pročež nejsou mutexy (na rozdíl od semaforů) dobré pro čistě synchronizační účely (kdy jedno vlákno čeká neodhadnutelnou dobu na dosažení nějakého bodu v programu jiného vlákna). V takovém případě je lepší vyrobit  semafor  jako objekt kombinací zámek plus podmínková proměnná (viz dále).

Objekt podmínková proměnná implementuje "kontejner pro čekání vláken" (tj. prostor-čekárnu, implementačně nějaký seznam vláken) na splnění příslušné podmínky. Čekání je "pasivní", tj. všechna vlákna nacházející se "v čekárně" jsou ve stavu waiting.

!! Vlastní podmínka (tj. bool. výraz) je s objektem "podmínková proměnná" spojena jen "logicky", tj. její test je naprogramován v programu čekajících vláken.

Handle podmínkové proměnné má typ pthread_cond_t a atributový objekt pthread_condattr_t (i když kupodivu nemá popsané žádné atributy). Ukázka vytvoření podmínkové proměnné je v příkladu 1.2 v části 1.4.

Pro nedělitelný test výrazu-podmínky a případnou změnu některé proměnné vyskytující se ve výrazu podmínky je třeba s podmínkovou proměnnou sdružit (opět jen logicky) zámek-mutex.

Operace nad podmínkovou proměnnou x sdruženou s mutexem y pak jsou:

Typická ukázka použití podmínkové proměnné pro implementaci bariéry (tj. synchronizačního bodu, ve kterém se sejde víc vláken a pokračují "až jsou všichni") je uvedena v následujícím příkladu 4.1.

 

Příklad 4.1:

/* je použita podmínková proměnná barrier_cond a mutex barrier_lock, synchronizujících se vláken je N, celočíselný čítač bariéry je barrier_cnt */

...

/* kousek programu vláken, které se synchronizují na bariéře */

pthread_mutex_lock (&barrier_lock); /* uzamknutí dat podmínky, zde jen barrier_cnt */

barrier_cnt++; /* změna dat podmínky, dělají všichni */

/* test podmínky - dělají všichni */

if (barrier_cnt < N) { /* dělají všichni až na posledního */

pthread_cond_wait (&cond_barrier, &barrier_lock); /* blokující operace, tj. vlákno přejde do stavu waiting, ale odemkne se zámek - kvůli tomu se do operace dává jeho adresa */

/* sem se vlákno dostane po "probuzení", předtím se ale znovu uzamkne barrier_lock */

}

else { /* dělá jen poslední vlákno, které dosáhne bariéru */

barrier_cnt = 0; /* nějaká změna dat a dále vyslání signálu "probuzení" pro všechna vlákna čekající v podmínkové proměnné cond_barrier */

pthread_cond_broadcast (&cond_barrier);

}

/* sem už přijdou všichni, a pokaždé je zde zamčený zámek, musí se tudíž odemknout */

pthread_mutex_unlock (&barrier_lock);

...

 

Příklad 4.2.:

Ukázka implementace a využití binárního semaforu s konvenční sémantikou (Dijkstrovy operace P() a V(), op. P() je blokující s pasivním čekáním). Semafor (pro jednoduchost nepojmenovaný single-object, nikoliv datový typ, ten lze vyrobit za domácí cvičení) vytvoříme sdružením (opět logickým) celočíselné proměnné count_sem, zámku lock_sem, a podmínkové proměnné cond_sem.

/* count_sem je inicializován třeba na 1, semafor je "otevřený, semafor je použit jako zámek kritické sekce využívané libovolným (a předem neznámým) počtem vláken */

/* kousek programu všech vláken, tj. programový kód kritické sekce se zajištěním "vzájemného vyloučení vláken" */

...

/* operace P(), tj. vstup do kritické sekce začíná zamknutím zámku */

pthread_mutex_lock (&lock_sem);

while (count_sem == 0) {

pthread_cond_wait (&cond_sem, &lock_sem);

/* sem se vlákno dostane po probuzení se zamknutým zámkem a opakuje test podmínky */

}

count_sem = 0; /* vlákno, které "projde", opět podmínku "shodí" na false */

pthread_mutex_unlock (&lock_sem); /* odemknutí zámku, ukončení operace P(), zámek je zde prostředek pro zajištění atomicity operace - tj. zajištění konzistence dat reprezentujících semafor */

.... /* vlastní kritická sekce, její kód může být samozřejmě jiný pro různá vlákna */

pthread_mutex_lock (&lock_sem); /* operace V(), tj. opuštění kritické sekce musí opět začít zamknutím zámku (atomicita operace)*/

count_sem = 1; /* změna blokující podmínky */

pthread_cond_signal (&cond_sem); /* vyslání signálu "probuzení" pro jedno vlákno čekající v podmínkové proměnné cond_sem. Mohla by se vzbudit všechna čekající vlákna (broadcast) a fungovalo by to též, ale s větší časovou režií. */

pthread_mutex_unlock (&lock_sem); /* odemknutí zámku, ukončení operace V() */

...

 

5. Jak se vyrobí monitor

Monitor je objekt-server poskytující služby v konkurenčním prostředí (tj. více vláknům), čili (další) volání služby se může vyskytnout dříve, než skončí předchozí volání (téže či některé jiné služby). Služba nemusí být okamžitě poskytnutelná, v takovém případě se vlákno volající službu blokuje (uspí, stav čekající) v monitoru.

Monitor (uvažujeme pro jednoduchost nepojmenovaný single-object, srovnej s typovým monitorem-třídou v Javě či typovým generickým monitorem v Adě) se (v nejjednodušším případě odpovídajícím Javě) implementuje následovně:

Tělo neblokující (tj. vždy dostupné) funkce-služby monitoru by pak vypadalo zhruba takto:

{

  pthread_mutex_lock (&m_lock);

    .... /* nějaké akce nad daty monitoru, eventuelní výsledek uložen do lokální (zásobník) proměnné result  */

  pthread_mutex_unlock (&m_lock); /* zde může být běh vlákna na nelimitovanou dobu pozastaven,  */

  return (result);                

}

Poznámka: !!! Výsledek by se měl vrátit jako hodnota lok. proměné vlákna (tj. proměnné deklarované ve funkci programu vlákna a lokalizované na zásobníku vlákna). Neměl by to být výraz obsahující "chráněné" proměnné monitoru (nelze předpovědět, kdy bude výraz vyhodnocen). !!! Vrátit jako výsledek ukazatel na data chráněná monitorem je jasně nesmysl.

Tělo blokující (tj. podmíněně dostupné, uvažujeme podmínkovou proměnnou m_cond) služby by pak vypadalo zhruba takto:

{

pthread_mutex_lock (&m_lock);

while (!service_accessible) {

pthread_cond_wait (&m_cond, &m_lock);

/* po probuzení se sem vlákno dostane opět se zamknutým zámkem m_lock a opakuje test */

}

... /* nějaké akce nad daty monitoru,  eventuelní výsledek uložen do lokální (zásobník) proměnné result */

pthread_mutex_unlock (&m_lock);

return (result);    /* vrací  se "lokální" hodnota ze zásobníku vlákna */

}

Některá z dalších služeb monitoru by pak musela vypadat takto:

{

pthread_mutex_lock (&m_lock);

...

service_accessible = 1;

pthread_cond_signal (&m_cond);

pthread_mutex_unlock (&m_lock);

return (result);

}

Poznámka:  Naznačený způsob implementace monitoru je nejjednodušší a odpovídá řešení v Javě (tj. 1 zámek a jedna fronta (podmínková proměnná) pro celý monitor).  Samozřejmě lze též selektivně chránit různé sekce dat různými zámky a pro každou podmínkovou proměnnou též zavést extra zámek (který pak chrání konzistenci dat potřebných pro vyhodnocení příslušné podmínky). Čili s využitím knihovny pthread (obecně vláken v normě POSIX) lze psát efektivnější monitory než v Javě (víc zámků, víc podmínkových proměnných, tj. menší režie a menší kolize vláken nad monitorem)  

6. Potíže v Adě či Javě se nevyskytující

Popisovaná vlákna jsou poměrně "nízkoúrovňový" prostředek, lze v nich (podobně jako v asembleru) zařídit mnohé věci, které nejdou udělat ve vyšším programovacím jazyce (Ada, Java). Na druhé straně ale zase mohou nastat četné nepříjemnosti, s kterými se uživatel vyššího prog. jazyka nesetká (nebo jen omezeně).

Například lze uvést:

1) Nereentrantní C-funkce volané z vláken (řešením je ošetřit taková volání jako kritickou sekci).

2) Hlídání dynamické paměti ve vícevláknovém výpočtu je složitější (srovnej např. s Javou). Např. ukončená vlákna musí důsledně uvolňovat dyn. paměť (viz "clean-up handler", část 2.5).

3) Inverze priorit, např.:

Řešení: vlákno, které se pokouší zamknout zámek si předtím zvýší prioritu (obecně: minimalizovat využívání priorit a neužívat všelijaké kombinace plánovacích způsobů).

 

7. Stručný návod, jak s vlákny experimentovat na Eryxu

  1. Celý program se pro jednoduchost rrealizuje jako jeden soubor, třeba aplikace.c
  2. Na Eryxu se vyrobí extra (pod)adresář, třeba APLIKACE a soubor programu se do adresáře nakopíruje
  3. Přepne se shell příkazem bash.
  4. Přeložení a sestavení programu se provede příkazem cc -o aplikace aplikace.c -pthread 2> a.txt
  5. Chybový report lze prohlížet po příkazu cat a.txt|more
  6. Menší opravy se provedou editorem vi (příkaz vi aplikace.c) pro větší opravy lze doporučit přenos  souboru do Windows prostředí (WinScp).
  7. Úspěšně přeložený a sestavený program se spustí příkazem aplikace.
  8. Pokud se program řádně neukončí a zůstane "viset", vrátíme se do shellu ctrlZ, zjistíme ID neukončeného procesu příkazem ps, a ukončíme výpočet příkazem kill -9 ID.

 

St.R. 2.8.2001, 24.10.2002, 4.11.2003