Paralelní programování s použitím vláken dle normy POSIX 1003.1

Ing. Petr Lampa

URL:mailto:lampa@dcse.fee.vutbr.cz

Ústav informatiky a výpočetní techniky, Fakulta elektrotechniky a informatiky VUT v Brně, Božetěchova 2, 612 66 Brno

Vlákna

V klasických systémech Unix je jednotkou provádění proces. Pro paralelní programování je k dispozici spouštění paralelních procesů. Každý spuštěný proces však má svůj vlastní adresový prostor a proto je třeba používat pro synchronizaci a komunikaci prostředky komunikace mezi procesy. Tyto mechanismy jsou obvykle relativně pomalé, protože vyžadují přepínání kontextu mezi nezávislými procesy s různými adresovými prostory. Pro paralelní programování je mnohem výhodnější model, kdy je více paralelních procesů soustředěno ve stejném adresovém prostoru a mohou tak přímo mezi sebou komunikovat sdílenou pamětí a přepínání kontextu nevyžaduje přepínaní adresového prostoru. Při programování na úrovni jazyka C se nabízí jako přirozený model paralelního programování paralelní provádění nezávislých funkcí. Jednotkou provádění pak není proces, ale paralelně prováděná funkce. Pro tuto jednotku provádění byl přijat název vlákno (thread). Každé vlákno má vyhrazeno svůj vlastní zásobník, na kterém má uloženy lokální proměnné, parametry a návratové adresy aktivních funkcí. Každé vlákno může vyvolat kteroukoli funkci programu a používat kteroukoli globální proměnnou. Skupina vláken v jednom adresovém prostoru pak tvoří proces. Systémové zdroje jsou přidělovány na úrovni procesů, takže všechna vlákna sdílí deskriptory otevřených souborů, nastavení zpracování signálů, zámky souborů, apod.


Obr. 1. Adresové prostory nezávislých procesů a vláken v jednom procesu

Implementace vláken

Podpora vláken může být zabudována přímo do jádra operačního systému, anebo může být implementována na úrovni knihovních funkcí v uživatelském procesu. Podpora vláken na úrovni knihoven a jádra je v různých podobách dostupná na celé řadě systémů. Hlavním problémem, který bránil širšímu využití, byla vzájemná nekompatibilita těchto implementací. Ve světě otevřených operačních systémů nabývají stále většího významu normy IEEE POSIX 1003, které jsou podporovány jak v prostředí systémů Unix, tak ve specializovaných systémech (QNX, Lynx, apod.). V srpnu 1995 byl po dlouhém období upřesňování a doplňování schválen standard POSIX 1003.1c, definující rozhraní pro použití vláken na úrovni jazyka C. Tento standard by měl být implementován ve všech komerčních systémech Unix (AIX 4.1, SOLARIS 2.5, IRIX 6.2, HP-UX) a existují také dvě volně dostupné implementace na úrovní knihoven pro systémy Linux a BSD ([1], [2]).

Podpora vláken v jádru systému je nezbytná pro víceprocesorové systémy, protože přidělování procesorů na úrovni procesů nedovoluje jednomu procesu využít více procesorů než jednoho. Podpora vláken na úrovni uživatelského procesu byla použita pro implementaci vláken v době, kdy nebyla podpora vláken zahrnuta do běžně dostupných operačních systémů (např. OSF DCE [3]). Vytváří v jednom procesu více paralelně běžících vláken. Více vláken v uživatelském procesu je tedy zobrazeno na jeden proces z hlediska jádra systému - zobrazení N:1.

Pokud je podpora vláken zabudována do jádra operačního systému, je procesor přidělován ne na úrovni procesů, ale na úrovni vláken. Každé vlákno je jakoby jedním procesem z klasických operačních systémů. Při volání služeb jádra systému, které pozastavují proces, jako je například read(), není pozastaven proces, ale pouze prováděné vlákno. Termín proces v těchto systémech zahrnuje několik paralelně běžících vláken. Klasický proces odpovídá procesu s jedním aktivním vláknem. Identita procesu, majitelství, ochrana, spouštění procesů zůstává zachována, ale vše je interpretováno ve smyslu "identita procesu, jehož součástí je vlákno, které je právě prováděno". Pokud je každé vlákno uvnitř procesu identifikováno a obhospodařováno jádrem systému, jedná se o zobrazení uživatelských vláken na systémové N:N [4].

Při větším počtu systémových vláken vytvořených uvnitř jednoho procesu stoupá režie spojená se systémovou identifikací vláken. Přitom počet fyzicky souběžně prováděných vláken nemůže být větší než je počet procesorů. Pokud většina vláken čeká na synchronizaci, jsou datové struktury pro systémovou identifikaci vláken v jádru z větší části mrtvou režií a navíc je nelze odložit na disk. Proto některé implementace vláken dovolují vytvořit zobrazení vláken uživatelských na systémová typu N:M, kde N > M. Vlákna na úrovni uživatelské jsou obhospodařována knihovnami, které umožňují vytvářet, spouštět a synchronizovat vlákna. Uživatelská vlákna jsou knihovnou přiřazena na vlákna jádra systému a přiřazení může být od typu N:1, až po N:N. Vlákna na úrovni jádra systému jsou pak nazývána Light Weight Process. Přiřazení uživatelských vláken na systémová může být buď volné (unbound), anebo pevné (bound) [5].

Naskýtá se otázka, jak volit přiřazení uživatelských vláken na systémová. Volné přiřazení je systémově nezávislé, systém může přiřadit systémová vlákna podle počtu procesorů. Pevné přiřazení předpokládá detailní znalost architektury systému, teoreticky lze dosáhnout vyšší efektivity pevným přiřazením skupin disjunktně prováděných vláken na systémová vlákna. Systémovým vláknům pak lze u některých implementacích přiřadit konkrétní procesory, na kterých mají běžet. V praxi je ovšem vytváření paralelně prováděných skupin vláken podle počtu dostupných procesorů nereálné. Proto se v tomto případě volí spíše pevné přiřazení typu N:N. Zvolený typ přiřazení je závislý na typu aplikace. Pokud je většina vláken použita pro řešení komunikace, či uživatelského rozhraní, dá se očekávat, že budou většinu času zablokována a postačí podstatně menší počet systémových vláken. V tomto případě je z hlediska využití systémových zdrojů daleko efektivnější použít volné přiřazení a ponechat vazbu a alokaci systémových vláken na systému a knihovnách. Pokud se jedná o aplikaci, kde bude většina vláken v daný okamžik aktivní, pak je běh aplikace rychlejší při pevné vazbě na stejný počet systémových vláken. Toto pravidlo je ověřeno testy na dvouprocesorovém systému Sun (obr. 2.).

Následující tabulka shrnuje typu podporovaných zobrazení v dostupných systémech:

OSF DCE N:1 POSIX 1003.4a Draft 4
AIX 4.1 N:N POSIX 1003.4a Draft 7
SOLARIS 2.5 M:N POSIX 1003.1c
SVR4.2/MP M:N -
Pokud používá více vláken stejné datové struktury, musí koordinovat svůj běh synchronizací. Podpora vláken na obou úrovních zahrnuje synchronizační prostředky pro vzájemné vyloučení provádění kritických sekcí a pozastavení vlákna. Vlákna však mohou používat některé datové struktury nepřímo voláním funkcí. Například mnohé funkce standardní knihovny jazyka C používají statické datové struktury (ctime(), rand(), strtok(), malloc(), apod.). Podpora vláken musí proto také zahrnovat upravené standardní knihovny, které lze bezpečně používat v prostředí vláken. Upravené knihovny musí obsahovat funkce standardní knihovny jazyka C, funkce dle normy POSIX 1003.1 a 1003.2 a další potřebné funkce (XPG4, Spec 1170), ovšem vícenásobně přístupné.

Vytvoření vlákna

Vlákno je na úrovni jazyka C tvořeno samostatnou paralelně prováděnou funkcí ve tvaru:
void *func(void *arg);
Parametr funkce arg je předán při spuštění vlákna a může obsahovat adresu libovolného objektu v programu. Jedna funkce může být samozřejmě spuštěna vícekrát a může tak řídit činnost několika paralelně prováděných vláken. Pro vytvoření a spuštění nového vlákna je určena funkce pthread_create():
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
				 void *(*func)(void *), void *arg);
Parametr thread je návratový, je v něm vrácena identifikace vytvořeného vlákna. Parametr attr definuje atributy vytvořeného vlákna, pro vytvoření vlákna se standardními atributy lze zadat hodnotu NULL. Funkce func je funkce řídící běh vlákna. Nově vytvořené vlákno začne svůj běh vyvoláním funkce func() s parametrem arg. Ukončením funkce func() je automaticky ukončeno vlákno a případně předán stav ukončení. Úspěšnost vytvoření vlákna je signalizována výsledkem funkce pthread_creat(), podobně jako u jiných funkcí norem POSIX 1003. Hodnota 0 znamená úspěšnost, jiná hodnota je kód chyby. Vlákna jsou na úrovni programu identifikovaná typem pthread_t. Je to obvykle ukazatel na strukturu, ve které je zachycen stav a atributy vlákna. Tento typ a další uvedené typy, prototypy funkcí a makra jsou deklarovány v hlavičkovém souboru <pthread.h>, který musí být vložen na začátku programu. Podobně jako u dalších norem POSIX je požadováno, aby před vložením kteréhokoli standardního hlavičkového souboru byl definován symbol _POSIX_C_SOURCE. Pro zapnutí překladu dle normy POSIX 1003.1c musí mít tento symbol hodnotu rovnu nebo větší 199500L. Dostupnost podpory vláken lze ověřit testem existence symbolu _POSIX_THREADS z hlavičkového souboru <limits.h>:
	#define _POSIX_C_SOURCE 199500L
	#include <limits.h>
	#ifdef _POSIX_THREADS
#include <pthread.h> #else #error "POSIX threads are not available" #endif
Vlákno končí návratem z počáteční funkce vlákna nebo vyvoláním funkce:
int pthread_exit(void *status);
Vrácený stav může být adresou objektu v programu, podobně jako argument počáteční funkce. Objekt ale nesmí být lokální proměnnou vlákna, protože takový objekt přestává po ukončení vlákna existovat. Pro předání číselného výsledku lze přetypovat parametr na typ int (ovšem za předpokladu, že velikost typu int a void * je stejná) a vracet přímo číselný stav. Stav ukončení vlákna může převzít jiné vlákno uvnitř téhož procesu funkcí:
int pthread_join(pthread_t thread, void **status);
Pokud vlákno thread již skončilo, funkce pouze převezme stav a tím uvolní datové struktury spojené s vláknem. Pokud vlákno ještě běží, pozastaví tato funkce volající vlákno a čeká na ukončení vlákna thread.

Synchronizační prostředky

V normě POSIX 1003.1c jsou definovány pouze dva synchronizační prostředky:

pthread_mutex_t - binární semafor pro vzájemné vyloučení (mutual exclusion) a

pthread_cond_t - podmíněná proměnná (condition variable).

Pro každý synchronizační prostředek jsou definovány funkce pro inicializaci, použití a zrušení. Synchronizační prostředky jsou reprezentovány proměnnou odpovídajícího typu. Tato proměnná musí být před prvním použitím inicializována funkcí pthread_mutex_init(), resp. pthread_cond_init():

int pthread_mutex_init(pthread_mutex_t *mutex, pthread_mutexattr_t *attr);
int pthread_cond_init(pthread_cond_t *cond, pthread_condattr *attr);
Parametr attr umožňuje zadat atributy prostředku, podobně jako při vytváření vláken. Hodnota NULL znamená vytvoření prostředku s implicitními atributy. Proměnná může být také inicializována statickou inicializací:
	pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

Vzájemné vyloučení

Pro vzájemné vyloučení provádění kritické sekce kódu jsou k dispozici funkce:
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);
Dvojice funkcí pthread_mutex_lock() a pthread_mutex_unlock() zahajuje a ukončuje sekci kódu, ve které je vzájemně vyloučeno provádění vláken volajících tyto funkce se stejným argumentem mutex. Funkce pthread_mutex_trylock() zkusí získat výhradní přístup a pokud jej nelze získat, protože výlučný přístup má právě nějaké jiné vlákno, vrátí jako výsledek hodnotu EBUSY. Pokud se ji zdaří získat výlučný přístup, vrátí hodnotu 0. Úsek kódu se vzájemným vyloučením provádění vláken slouží k zajištění výhradního přístupu ke sdíleným objektům. Všechna vlákna, která přistupují k některému sdílenému objektu, musí vždy nejprve získat výhradní přístup, pak provést operaci a po ukončení operace uvolnit přístup jiným vláknům. Každému sdílenému objektu odpovídá jedná proměnná typu pthread_mutex_t a každému přístupu ke sdílenému objektu dvojice zahájení a ukončení přístupu:
	SomeType	var;					/* sdílená proměnná */
	pthread_mutex_t var_mutex = PTHREAD_MUTEX_INITIALIZER;
	...
	pthread_mutex_lock(&var_mutex);	/* získání výhradního přístupu */
	...							/* operace se sdílenou proměnnou */
	pthread_mutex_unlock(&var_mutex);	/* uvolnění přístupu */
Implementace vzájemného vyloučení je různě složitá podle rozsahu kontrol, které se provádí při zahájení a ukončení výhradního přístupu. První variantou implementace je zařazení testu, zda o přístup nežádá znovu vlákno, které již výhradní přístup má. Druhou variantou je povolení či nepovolení takovýchto rekurzivních přístupů. Typ prostředku lze nastavit v atributech prostředku:
int pthread_mutexattr_settype(pthread_mutexattr_t *attr, unsigned type)
Parametr type nastavuje typ prostředku ve struktuře atributů attr. Strukturu atributů s nastaveným typem pak použijeme při inicializaci prostředku. Typ prostředku je zadáván předdefinovanými konstantami:

PTHREAD_MUTEXTYPE_FAST Není detekován pokus o rekurzivní přístup. Pokus o získání přístupu z vlákna, které již přístup k prostředku má, způsobí uváznutí (implicitní hodnota atributu).
PTHREAD_MUTEXTYPE_DEBUG Není povolen rekurzivní přístup, ale je detekován a signalizován chybou EDEADLK.
PTHREAD_MUTEXTYPE_RECURSIVE Je povolen rekurzivní přístup a počítán počet rekurzivních vstupů. Při uvolňování se čítač snižuje a po posledním rekurzivním výstupu je uvolněn výhradní přístup.

Podmíněná proměnná

Zatímco vzájemné vyloučení lze použít pouze pro vytváření sdružených kritických sekcí, podmíněné proměnné jsou obecným synchronizačním prostředkem, který lze použít pro pozastavení vlákna a čekání na splnění libovolné podmínky. Podmínka, na kterou vlákno čeká, nemá přímou spojitost s podmíněnou proměnnou, může to být libovolná podmínka, či stav v programu, který je signalizován pomocí podmíněné proměnné. Asociace mezi podmínkou a podmíněnou proměnnou je dána použitím. Protože je podmínka reprezentována nějakým síleným objektem v programu, musí být zajištěno vzájemné vyloučení operací s objektem. Proto je podmíněná proměnná vždy svázána se vzájemným vyloučením operací nad objektem, který reprezentuje podmínku. Vlákno, které čeká na splnění podmínky, musí nejprve získat výhradní přístup k objektu stráženému vzájemným vyloučením. Pak může otestovat stav objektu, zda je daná podmínka splněna, a pokud není, čekat na splnění podmínky pomocí podmíněné proměnné:
int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);
Funkce uvolní jednou nedělitelnou operací výhradní přístup strážený proměnnou mutex a zařadí vlákno do fronty vláken, čekajících na splnění podmínky cond. Až některé jiné vlákno oznámí, že je podmínka splněna, bude čekání ukončeno a před návratem z funkce pthread_cond_wait() získá původní vlákno opět výhradní přístup strážený proměnnou mutex. Nedělitelnost operace uvolnění a zahájení čekání je podstatnou vlastností tohoto synchronizačního prostředku. Zaručuje, že v době mezi uvolněním výhradního přístupu k objektu a zahájením čekání se nezmění stav objektu a nebude tedy signalizováno splnění podmínky, protože to by se v tomto okamžiku ztratilo.

Příklad:

	pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
	pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
	int condition = 0;		/* podmínkou je zde stav sdílené proměnné */
	...
	pthread_mutex_lock(&mutex);
	while (condition == 0) {	/* čekání na splnění podmínky */
		pthread_cond_wait(&cond, &mutex);
	}
	/* podmínka je nyní splněna, jiné vlákno ji nemůže změnit, protože
	   přístup k podmínce je strážen pomocí mutex */
	...
	condition = 0;
	pthread_mutex_unlock(&mutex);		/* uvolnění přístupu k objektu */
Cyklus testování stavu podmínky a volání pthread_cond_wait() je nezbytný, protože po ukončení čekání může předběhnout odblokované vlákno při získání výhradního přístupu k objektu jiné vlákno a to může stav podmínky změnit. Splnění podmínky může signalizovat kterékoli vlákno funkcemi:
int pthread_cond_signal(pthread_cond_t *cond);
int pthread_cond_broadcast(pthread_cond_t *cond);
Funkce pthread_cond_signal() uvolní z čekání pouze jedno vlákno, zatímco funkce pthread_cond_broadcast() uvolní všechna vlákna čekající na danou podmíněnou proměnnou cond. Není chybou, pokud v daný okamžik nečeká na podmíněnou proměnnou žádné vlákno. Programátor musí zajistit, aby při uvolnění vlákna byla podmínka skutečně splněna. Signalizace splnění podmínky by v uvedeném příkladu mohla mít tvar:
	pthread_mutex_lock(&mutex);
	condition = 1;						/* změna stavu podmínky */
	pthread_cond_signal(&cond);
	pthread_mutex_unlock(&mutex);
Délku čekání na splnění podmínky a uvolnění lze omezit časovým limitem. Čekání s časovým limitem umožňuje funkce pthread_cont_timedwait():
int pthread_cond_timedwait(pthread_cond_t *cond, pthread_mutex_t *mutex, 						const struct timespec *abstime);
Tato funkce má oproti funkci pthread_cond_wait() navíc parametr abstime, který určuje časový limit čekání. Časový limit je zadán absolutním časem, kdy má být čekání ukončeno.

Použití vláken

Vlákna lze použít jednak jako prostředek paralelního programování pro zvýšení výkonu aplikace, jednak jako prostředek pro dosažení přehlednější a logičtější struktury programu. Běžným motivem pro použití vláken v programu může být:

1. Urychlení běhu programu

Využitím více procesorů a paralelním zpracováním lze řadu úloh vyřešit efektivněji. Limitem urychlení je ale počet procesorů v systému. Tento počet nemůže být u architektur se sdílenou pamětí příliš velký, protože rostou komunikační nároky. Běžné systémy (SGI, Sun) mají maximálně 16 až 32 procesorů, přičemž od počtu 6 až 12 je přírůstek výkonu při přidání dalšího procesoru nezanedbatelně menší než 1.

2. Paralelní vstupní a výstupní operace

Běžné systémy podporují pouze blokující čtení a zápis, proces je pozastaven a po dokončení operace opět spuštěn. Podpora asynchronního čtení a zápisu je sice součástí normy POSIX 1003.1b, ale zatím není příliš rozšířená, případně je omezena jen na některé typy zařízení. Paralelními vlákny lze zahájit velký počet paralelně zpracovávaných vstupních a výstupních operací, což je výhodné jak pro běžné aplikace typu klient/server, tak pro speciální aplikace (zálohování, databáze, apod.).

3. Aplikace s grafickým uživatelským rozhraním

U těchto aplikací musí být zaručeno zpracování vstupních událostí, jinak přestává aplikace reagovat na interakci uživatele. Jakékoli dlouhodobější výpočty, práce se soubory, síťové komunikace jsou bez použití vláken problematické, či neřešitelné. Vlákna navíc umožňují přehlednější a čitelnější zápis programu.

4. Systémy reálného času

Vlákna umožňují jemnější paralelismus než na úrovni nezávislých procesů a efektivnější komunikaci mezi vlákny. Systémy reálného času většinou podporu vláken obsahují a poskytují širší repertoár plánovacích algoritmů a synchronizačních prostředků.

Literatura:

[1] Mueller, F.: A Library Implementation of POSIX Threads under UNIX, Winter USENIX, 1993

[2] Provenzano, C. A.: Pthreads, http://www.mit.edu:8001/people/proven/pthreads.html

[3] Tamirisa, C.G.: Porting DCE Threads Programs to AIX 4.1, AIXpert, Aug 1994

[4] Tamirisa, C.G.: Introduction to Multithreaded Programming, AIXpert, Nov 1994

[5] Programming with System Calls and Libraries, Novell, 1994

Resume

Parallel programming using threads is well suited for medium grained concurrency. It is more effective then parallel programming using independent processes, but not as effective as specialized parallel computing facilities, like parallelizing compilers. Synchronization between threads in multiprocessor environment is performed using traditional methods (mutual exclusion with memory locks) which have the complexity at about hundreds of instructions in the blocking case. User level implementation of thread support should be combined with kernel support to enable usage of more processors in SMP systems.