Konverze multithreadové aplikace z Win32 pod Linux
RNDr. Januš Drózd, CSc

Úvod

Co jsou vlákna (threads) a k čemu slouží?
K využití vláken se programátor dostává v okamžiku, když potřebuje, aby jeho program pracoval na více úkolech současně. Nehraje přitom roli, zda pod slovem "současně" je míněno skutečné paralelní zpracování na více procesorech, nebo využití multiprocesingu na jediném procesoru. Podstatné je, že programátor nechce složitě programovat přecházení od jednoho rozpracovaného úkolu na jiný - čímž by podstatně zkomplikoval svoje dílo - ale chce využít služeb operačního systému, které rozvrhnou práci mezi procesory nebo mezi časové intervaly na jednom procesoru. Takovýto požadavek se běžně vyskytuje při programování serverů, které musí být schopny souběžně pracovat na požadavcích od více klientů.

Tradiční odpovědí operačních systému unixového typu na tuto potřebu byly nástroje dovolující:
•	spouštět z procesu jiný proces (ať už se stejným nebo jiným kódem),
•	komunikovat mezi těmito procesy a synchronizovat běh procesů.

Paralelismus realizovaný pomocí procesů se vyznačuje tím, že od okamžiku spuštění práce probíhá nad oddělenými paměťovými prostory a systémovými zdroji. Každý proces pracuje s vlastními zdroji a pokud je chce sdílet s jiným procesem, musí vynaložit práci navíc. Tento model vyhovuje v případech, kdy míra kooperace mezi paralelně prováděnými úkoly je malá nebo žádná - typickým příkladem je webový server, který může pro vytvoření a odeslání každé stránky použít jiný proces, a tím zajistí, že dlouho trvající vytváření jedné (dynamické) stránky nezablokuje práci s jinými stránkami.

Opačný případ nastává například u SQL serveru. Také on musí paralelně zpracovávat SQL příkazy od různých klientů. Míra kooperace mezi souběžně probíhajícími akcemi je zejména v hlubších vrstvách serveru vysoká - klienti sdílejí nejrůznější cache, informace o zámcích atd. Kdyby se pro kooperaci mezi klienty měly využít prostředky meziprocesové komunikace, bylo by to neefektivní a z pohledu programátora i neobratné.

Tyto úvahy byly motivací pro zavedení alternativního nástroje pro implementaci paralelismu, a to vláken (anglicky threads). Vlákna jsou protikladem procesů v tom smyslu, že standardně vše sdílejí - nejen kód, ale i operační paměť a všechny systémové zdroje - a pokud má vlákno mít něco soukromého, musí vynaložit dodatečné úsilí. Přepínání řízení mezi vlákny na procesoru je podstatně rychlejší, než mezi procesy, protože vlákna mají pouze minimální soukromý kontext (zásobník, obsah registrů) a není přitom nutno přestavovat prostředky ochrany paměti a dalších zdrojů jako při přechodu mezi procesy.

Vlákna jsou nesmírně užitečným nástrojem. V prostředí Unixů však poněkud trpí tím, že byla do nich většinou doplňována dodatečně a občas se střetávala s již zaběhnutými koncepty.

Teoreticky by vlákna mohla být realizována i uvnitř procesu, zcela bez podpory operačního systému - stačilo by pro každé vlákno alokovat zvláštní zásobník a vhodně tyto zásobníky přepínat. Tím bychom však ztratili nejen možnost rozložit práci na více procesorů, ale zejména možnost pracovat v jednom vláknu, když jiné vlákno čeká uvnitř systémové funkce na externí událost, například na přečtení dat z disku nebo příchod dat některým komunikačním kanálem. Proto je pro reálná vlákna podpora systému nezbytná. Existují však rozdíly, jak je tato podpora do systému začleněna - zda se vlákna spíše podobají procesům (obohaceným o sdílení prostředků) nebo zda jde o samostatný koncept realizovaný pod úrovní procesů.

Projekt převáděný pod Linux
Zkušenosti, popisované v tomto textu, byly získány při práci na WinBase602 SQL serveru. Tento databázový server dříve existoval pouze na platformách Windows a Novell Netware. Jeho nová verze měla být schopna práce také pod Linuxem a FreeBSD.

O serverovém software se dá obecně říci, že jeho přenos na jinou platformu bývá snazší, než je tomu u klientských programů, protože největší rozdíly mezi platformami bývají v oblasti GUI (grafického uživatelského rozhraní). V případě WinBase602 SQL serveru to platilo beze zbytku, protože samotný server má pouze minimální uživatelské rozhraní - veškeré nastavování a sledování provozu se děje pomocí klientů.

Díky jasným standardům pro jazyk C++ a jeho základní knihovny spočívají hlavní rozdíly mezi platformami v systémových voláních, která Windows nemohla při svém zrodu převzít z CP/M. Mezi ně patří oblast vláken a synchronizace.

Rozhodli jsme se využít vlákna v té podobě, jak jsou definována v normě POSIX 1003.1c, neboť její implementaci lze nalézt na nejširší paletě Unixů, včetně Linuxu.

Účelem tohoto textu není výuka paralelního programování, ale připomenutí jeho konceptů a přehlídka nástrojů, které jsou v Linuxu k dispozici.

Řízení běhu vláken

Spuštění a ukončení běhu vlákna
Spuštění nového vlákna obstará funkce pthread_create. Parametrem spuštění je funkce, kterou má vlákno provést, a ukazatelový parametr, který bude této funkci předán.

Vlákno skončí, jakmile provede celou zadanou funkci, jakmile samo zavolá funkci pthread_exit, nebo poté, co je zrušeno jiným vláknem.

Zrušení vlákna z jiného vlákna
Vlákno může “násilně” ukončit běh jiného vlákna pomocí funkce pthread_cancel. Vlákno, které má být zrušeno, může svoji reakci na tuto situaci předem definovat voláním těchto funkci:
•	pthread_setcancelstate - zakazuje nebo povoluje zrušení,
•	pthread_setcanceltype - určuje, zda ke zrušení dojde ihned, nebo až při dosažení tzv. bodu bezpečného zrušení.
Bodem bezpečného zrušení je místo, kde vlákno zavolá funkci pthread_testcancel nebo některou z třídy funkcí, které jsou definovány normou jako body bezpečného zrušení. Funkce, které patří mezi bezpečné body zrušení, se v různých Unixech liší.

Rušení vlákna z jiného vlákna je velmi problematický čin. Je-li vlákno uvnitř kritické sekce, je pravděpodobné, že sekce zůstane natrvalo zamčena. Zdroje, které vlákno dočasně alokovalo a k nimž má přístup pouze přes své lokální proměnné, mohou zůstat neuvolněny. Programátor musí tyto situace velmi pečlivě (a pracně) ošetřit. 
Funkce pthread_setcanceltype nevyřeší sama o sobě všechny tyto problémy, protože mnohé funkce (např. pthread_cond_wait) jsou definované jako bezpečné body zrušení a přitom je můžete volat i na místech, kde si zrušení nepřejete. Proto je třeba zvolit některou ze tři cest:
•	úplně  se vyhnout násilnému ukončování vláken zvenčí;
•	pracovat s pthread_setcancelstate a povolit zrušení pouze tam, kde je to skutečně bezpečné;
•	kombinovat volání pthread_setcanceltype s definováním handlerů volaných při ukončení vlákna.
 
Funkce pro rušení vláken nejsou k dispozici pod FreeBSD 2.2.8.

Handlery ukončení vlákna
Funkce pthread_cleanup_push a pthread_cleanup_pop dovolují registrovat resp. odregistrovat funkci, která má být provedena při ukončení vlákna, ať už vlákno skončilo samo nebo bylo ukončeno vnějším zásahem. Registrované handlery jsou uložené na zásobníku, takže jako první je odstraněn handler, který byl registrován jako poslední.

Na použití těchto funkcí je kladeno podstatné omezení - smí být použity pouze v párech nacházejících se ve stejné funkci a ve stejné posloupnosti příkazů. 

Čekání na ukončení vlákna
Zřetelný rozdíl mezi řízením vláken pod Windows a Linuxem spočívá ve způsobu čekání jednoho vlákna na ukončení jiného vlákna. V Linuxu je k dispozici funkce pthread_join, které předáte handle jiného vlákna a funkce skončí, jakmile doběhne zadané vlákno. Ve Windows lze handle vlákna použít jako synchronizační objekt a použít některý způsob čekání na objekt nebo skupinu objektů. Napohled se zdá, že to, co nabízejí Windows, je silnější, protože Linux neumožňuje zadat timeout nebo reagovat na příchod jiných události během čekání. Ve skutečnosti se ovšem ve Windows skrývá zrada - funkci nelze korektně implementovat, protože zásoba handles pro vlákna není neomezená. Pokud v programu stále vznikají a zanikají vlákna, dříve či později dostane nové vlákno B stejný handle jako některé již ukončené vlákno A. Pokud se vlákno C v tomto okamžiku rozhodne čekat na ukončení vlákna A, bude ve skutečnosti čekat na ukončení vlákna B.

Na funkci pthread_join se lze dívat jako na pomůcku, která v jistých případech zjednoduší program, v obecném případě však může být nahrazena jiným synchronizačním mechanismem, například zvednutím semaforu při doběhnutí vlákna. Výjimkou je případ zrušení vlákna z jiného vlákna.

Uvolnění zdrojů vlákna při jeho ukončení
Parametry zadávané při spouštění vlákna dovolují specifikovat, zda na ukončení vlákna bude možno čekat pomocí funkce pthread_join. Nastavení tohoto parametru má však jeden významný vedlejší efekt. Pokud na vlákno A lze čekat, ale žádné jiné vlákno tak neučiní, pak paměť alokována pro vlákno A nebude po jeho skončení vrácena systému. Tato vlastnost je běžnou pastí číhající na programátory převádějící multithreadové aplikace z jiných prostředí.

Synchronizace vláken
Nejběžnějšími důvody pro synchronizaci vláken jsou:
•	zamezení souběžného přístupu více vláken ke sdíleným datům;
•	potřeba předávat signály k činnosti mezi vlákny.

Hlavním nedostatkem synchronizačních mechanismů v Linuxu je nemožnost zadat čekání na více než jednu podmínku. V tomto ohledu je situace podstatně horší než ve Win32 API. Dalším nedostatkem je, že pouze jedna ze synchronizačních funkcí dovoluje při čekání zadat timeout.

Semafory
Za nejuniverzálnější synchronizační mechanismus jsou obvykle považovány semafory. Na semafor se lze dívat jako na čítač, nad nimž jsou implementovány dvě základní operace:
•	čekání, dokud semafor nemá kladnou hodnotu, a snížení jeho hodnoty o jedničku;
•	zvětšení hodnoty semaforu o jedničku.
Tyto operace jsou implementovány jako atomické, takže nehrozí nebezpečí, že by například dvě vlákna současně nalezla v semaforu hodnotu 1 a pokračovala dál bez čekání.

Semafory pro synchronizací procesů jsou tradičním nástrojem v Unixech. Pro synchronizaci vláken je však třeba využít jinou sadu semaforových funkcí. 

K vytvoření semaforu slouží funkce sem_init, v níž se nastaví počáteční hodnota semaforu. Semafor se ruší funkcí sem_destroy. Atomickou operaci čekání na kladnou hodnotu semaforu a její snížení implementuje funkce sem_wait, zatímco zvětšení hodnoty semaforu provede funkce sem_post.

Možnosti práce se semafory rozšiřují další dvě funkce. Funkce sem_trywait ověří, zda semafor má kladnou hodnotu, a pokud ano, sníží ji a skončí. V opačném případě nečeká, ale také ihned skončí, a pomocí své hodnoty sdělí, který z těchto dvou případů nastal. Funkce sem_getvalue vrací hodnotu semaforu. Obdoba této funkce například ve Win32 API není k dispozici.

V Linuxu nelze vytvořit semafor, který by mohl současně sloužit k synchronizování vláken a procesů, přestože norma POSIX s tím počítá.

Přestože jsou semafory nejúčinnějším nástrojem pro složitější případy synchronizace vláken, jsou definovány ve zvláštní části normy POSIX a zřejmě v souvislosti s tím jsme je na FreeBSD 2.2.8 nenalezli. V takových případech nezbývá, než jejich funkci složitě napodobovat pomocí jiných synchronizačních objektů.

Mutexy
Nejběžnější situací, která vyžaduje synchronizaci procesů, je zajištění exkluzivního přístupu k datům během operací, které dočasně poruší jejich konzistenci. Oblast, v niž smí být v jednom okamžiku nejvýše jedno vlákno, je nazývá kritickou sekcí. 

Pokud například dvě vlákna pracují se společnou celočíselnou proměnnou a obě chtějí zvětšit její hodnotu o jedničku, pak existuje pravděpodobnost, že by tato operace mohla proběhnout v čase takto:
1.	Vlákno A čte hodnotu proměnné,
2.	Vlákno B čte hodnotu proměnné,
3.	Vlákno B zapisuje hodnotu zvětšenou o 1,
4.	Vlákno A zapisuje hodnotu zvětšenou o 1.
Přestože obě vlákna odvedla svoji práci, bude proměnná zvětšena pouze o jedničku, nikoli o dvojku.

Kritické sekce se zajišťují buď pomocí semaforů, nebo s využitím mutexů, což jsou v podstatě jednoúčelově zapouzdřené semafory. Mutex se vytvoří pomocí funkce pthread_mutex_init a zruší pomocí pthread_mutex_destroy. Při vstupu do kritické sekce vlákno volá pthread_mutex_lock. Tato funkce skončí ihned, pokud v kritické sekci není žádné jiné vlákno, jinak čeká, dokud jiné vlákno sekci neopustí, a pak vpustí dovnitř pouze jedno z čekajících vláken. Na výstupu z kritické sekce se volá funkce pthread_mutex_unlock.

Doplňující funkce pthread_mutex_trylock se pokusí vstoupit do kritické sekce, a pokud se jí to nezdaří, nečeká a informuje o tom prostřednictvím své hodnoty.

Obvyklé chápání pojmu mutex připouští, aby stejné vlákno volalo funkci pthread_mutex_lock pro stejný mutex i vícekrát. Kritická sekce je v takovém případě otevřena až poté, co je stejný počet krát zavolána funkce pthread_mutex_unlock. Linux implementuje i alternativní druh mutexu, pro nějž pokus o opakovaný vstup do kritické sekce způsobí deadlock vlákna a trvalé uzamčení sekce. Poněkud nebezpečné je, že tento zjednodušený druh mutexu se v Linuxu vytváří implicitně.

Vytvoření mutexu, který slouží k ochraně rekurzivně přístupné kritické sekce, může v Linuxu vypadat například takto:
typedef pthread_mutex_t CRITICAL_SECTION;
void InitializeCriticalSection(CRITICAL_SECTION * cs)
{ pthread_mutexattr_t attr;
  pthread_mutexattr_init(&attr);
  pthread_mutexattr_setkind_np(&attr, PTHREAD_MUTEX_RECURSIVE_NP);
  pthread_mutex_init(cs, &attr);
  pthread_mutexattr_destroy(&attr); 
}
Zápis lze zjednodušit využitím předinicializovaných hodnot atributů pthread_mutexattr_t.

Čekání na událost
Čekání na událost je poměrně komplexním synchronizačním mechanismem, který dovoluje spojit čekání na určitou situaci se vstupem do kritické sekce. K využití tohoto mechanismu je třeba vytvořit mutex (viz výše) a dále vytvořit událost pomocí funkce pthread_cond_init. Událost se ruší pomocí pthread_cond_destroy. 

Vlákno čeká na událost tak, že napřed zamkne mutex a pak zavolá funkci pthread_cond_wait. V této funkci zůstane, dokud událost nenastane. Podstatné je, že po dobu čekání je mutex odemčen a teprve před výstupem z funkce pthread_cond_wait je opětovně zamčen. Událost se vyvolává voláním funkce pthread_cond_signal.

Mutex zde slouží nejen k ochraně datové struktury tvořící událost před souběžnou manipulací z více vláken, ale dá se využít také k zajištění exkluzivního přístupu k dalším datům, jejichž využití souvisí s událostí. V tomto směru jsou události v Linuxu silnějším mechanismem než události ve Win32 API.

Doplňující funkce pthread_cond_timedwait dovoluje čekat na událost pouze po stanovenou dobu. Lze ji využít k implementování funkce Sleep, která pozastaví vlákno na stanovenou dobu. Níže uvedená funkce by měla být přenositelná mezi platformami, na nichž jsou k dispozici vlákna dle POSIXu.

void Sleep(unsigned long miliseconds)
{ timeval  tv;  
  timespec ts;  
  pthread_condattr_t sleepcondattr;      
  pthread_cond_t sleepcond;              
  pthread_mutexattr_t sleepmutexattr;    
  pthread_mutex_t sleepmutex;            
  gettimeofday(&tv, NULL);                   
  tv.tv_usec+=(miliseconds*1000);                   
  if (tv.tv_usec>=1000000)                
  {  tv.tv_sec+=tv.tv_usec / 1000000;      
     tv.tv_usec=tv.tv_usec % 1000000;    
  }                                          
  TIMEVAL_TO_TIMESPEC(&tv, &ts);                   
  pthread_condattr_init(&sleepcondattr);            
  pthread_cond_init(&sleepcond, &sleepcondattr);       
  pthread_mutexattr_init(&sleepmutexattr);           
  pthread_mutex_init(&sleepmutex, &sleepmutexattr);    
  pthread_mutex_lock(&sleepmutex);    
  pthread_cond_timedwait(&sleepcond, &sleepmutex, &ts);  
  pthread_mutex_unlock(&sleepmutex);    
  pthread_mutex_destroy(&sleepmutex);                
  pthread_mutexattr_destroy(&sleepmutexattr);        
  pthread_cond_destroy(&sleepcond);                  
  pthread_condattr_destroy(&sleepcondattr);          
}

I tento kód lze zjednodušit na základě informací o atributech mutexů a událostí z manuálových stránek.

Soukromá data vláken
Z podstaty vláken vyplývá, že vlákna sdílejí globální proměnné, zatímco “automatické” proměnné deklarované jako lokální ve funkcích patří pouze tomu vláknu, které do funkce vstoupilo. Vlákno může předat ukazatel na svoji (automatickou nebo dynamicky alokovanou) proměnnou jinému vláknu a operační systém nebude tomu klást žádné překážky (v podobě oddělených adresových prostorů apod.)

V některých aplikacích vzniká potřeba datových struktur, k nimž budou všechna vlákna přistupovat zcela uniformně, obsah struktur však bude pro každé vlákno jiný. Takový požadavek lze řešit deklarováním těchto struktur ve všech funkcích, které jsou spouštěny jako samostatná vlákna, a jejich předáváním do všech funkcí, které s nimi potřebují pracovat. 

Elegantnější řešení nabízí čtveřice specializovaných funkcí z knihovny pthread. Pomocí funkce pthread_key_create lze vytvořit klíč, který bude společný pro všechna vlákna a s jehož pomocí bude každé vlákno pracovat s vlastní instancí datové struktury. Každé vlákno si vytvoří vlastní instanci dat, přiřadí ji ke klíči voláním pthread_setspecific a na libovolném místě se k ní pak může dostat pomocí funkce pthread_getspecfic. Klíč lze zrušit funkcí pthread_key_delete, čímž zanikne uniformní způsob přístupu k soukromým strukturám.

Problémy s vlákny a komunikací po TCP/IP
Většina problémů s použitím vláken na Linuxu má společný základ - mnohé široce používané koncepty pocházejí z doby, kdy vlákna neexistovala.

Typickým příkladem jsou signály. POSIX říká, že asynchronní signál se má doručit kterémukoli vláknu, které ho nezablokovalo. To je poměrně nešťastné řešení. To nejrozumnější, co programátor může udělat, je zakázat příjem signálů ve všech vláknech a vytvořit zvláštní vlákno přijímající signály - ani tak není snadné zjistit, kterého vlákna se signál týká.

Nejpoužívanější implementace vláken v Linuxu pocházející od Xaviera Leroye se však od normy odchýlila a signál (s výjimkou signálů ukončujících proces) dostane to vlákno, které jej vyvolalo.
 
Stav FIN_WAIT2
Při ukončování komunikace se může TCP/IP spojení dostat do stavu FIN_WAIT2, což je stav, kdy jedna strana provedla uzavření socketu, ale druhá zavření ještě nepotvrdila. 

Například jestliže se ukončí proces na Linuxu, ke kterému byla připojena přes TCP/IP aplikace běžící například na Windows, a proces na Linuxu po sobě socket neuzavře, nelze proces na Linuxu opět správně spustit. Důvodem je tedy nekorektně ukončené TCP/IP spojení a procesu na Linuxu se nezdaří opětovný bind socketu na daný port, protože ten je ve stavu FIN_WAIT2. Podle původních RFC specifikujících TCP/IP by tento stav vůbec neměl nastat a nepočítá se s ním. V praxi tento stav ale nastává (v některých případech velmi často) a řeší se timeoutem. Timeout se počítá od doby, kdy jedna strana již socket uzavřela a čeká se na dokončení uzavírání socketu druhou stranou. Timeout bývá větší než 60 sekund, v Linuxu bývá nastavena hodnota 3 minuty. Předpokládá se, že za tuto dobu vyhynou všechny pakety z nekorektně ukončeného spojení. Po dobu Timeoutu je socket ve stavu FIN_WAIT2 a nelze provést úspěšný bind jiného socketu na stejný port. Toto může některým aplikacím způsobovat problémy, které lze řešit použitím option SO_REUSEADDR v setsockopt:
setsockopt ( sock, SOL_SOCKET, SO_REUSEADDR, &i, sizeof(i) );
kde i musí mít nenulovou hodnotu (TRUE). Použije se na vytvořený socket ještě před jeho připojením na port (bind). Toto řešení právě umožňuje, jestliže se socket dostal do stavu FIN_WAIT2, na tento obsazený port provést úspěšný bind. V jednoduché aplikaci toto funguje spolehlivě, při použití ve větší multithreadové aplikaci však setsockopt na některé options (včetně SO_REUSEADDR) nefungoval vždy spolehlivě. Tímto problémem se zabývala i diskuse na linux@linux.cz, kde jiné řešení než s SO_REUSEADDR bohužel taky nebylo.

Ukončování TCP/IP spojení v multithreadových aplikacích
Ve Win32 API lze používat socket současně ve více vláknech jednoho procesu, například jestliže jedno vlákno visí na socketu ve funkci listen, lze z jiného vlákna provést na tento socket close. Funkce listen pak skončí s chybou indikující, že jí byl uzavřen socket. Na Linuxu ale dochází ke skutečnému uzamykání socketů mezi vlákny, protože operace se sockety jsou chráněny kritickou sekcí. Na Linuxu tedy nelze použít model z Win32 API. Pokud požadujeme kompatibilitu mezi různými platformami, lze použít model, kdy si při běžné komunikaci aplikace oznámí ukončení některé činnosti, zašlou si žádost o uzavření socketu a socket navzájem uzavřou. Tento model jako obousměrný lze úspěšně aplikovat na další platformy jako třeba FreeBSD nebo Novell.

Literatura
Vladimír Michl: Linux a vlákna, Linuxové noviny 08-09/98. Dostupné na http://www.linux.cz/noviny, obsahově se částečně překrývá v tímto textem.
Manuálové stránky Linuxu
http://paiullac.inria.fr/~xleroy/linuxthreads Dokumentace pocházející od autora implementace POSIXových vláken na Linuxu