Panel użytkownika
Nazwa użytkownika:
Hasło:
Nie masz jeszcze konta?
Autorzy: 'NiceDog',
'Złośliwiec'
Biblioteki C++

Wątki

[lekcja] Rozdział 26. Co to jest wątek; jak się tworzy nowe wątki; omówienie współdzielenia danych; sekcje krytyczne oraz synchronizacja pracy wątków i inne tematy związane z wielowątkowością.

Podstawowe informacje

Zacznijmy od wyjaśnienia, czym też właściwie jest wątek, bo nie dla każdego musi to być oczywiste. Zgodnie z definicją Microsoftu, wątek to najmniejsza jednostka, dla której można zarezerwować czas procesora. Ponieważ definicja ta sama w sobie niewiele mówi, dodajmy sobie jeszcze, że w systemie Windows wiele programów może działać jednocześnie. W praktyce realizuje się to następująco: procesor przez jakiś czas wykonuje instrukcje jednego programu, następnie przerywa jego wykonanie, żeby przez następną chwilę wykonywać drugi program, potem przychodzi kolej na trzeci, czwarty itd.

Gdy wszystkie programy zostaną w ten sposób obsłużone przez procesor, znowu przydzielana jest krótka chwila pierwszemu programowi. I tak w kółko, aż wreszcie damy naszemu pecetowi spokój i go wyłączymy ;-). Ponieważ chwila, w której procesor zajęty jest wykonywaniem danego programu jest bardzo, ale to bardzo krótka, odnosimy wrażenie, że wszystkie uruchomione programy działają w tym samym czasie.

Ale wróćmy do wątków. Teraz, gdy rozumiemy już, na czym mniej więcej polega rezerwacja czasu procesora (i, co za tym idzie, rozumiemy podaną wyżej definicję wątku), możemy zająć się związkiem, jaki mają wątki (ang. 'threads') z pisanymi przez nas programami. Otóż najczęściej nasz program składa się tylko z jednego wątku, zwanego też wątkiem głównym (ang. 'primary thread' lub 'main thread'). Jest on tworzony automatycznie. Możemy jednak stworzyć w obrębie naszego programu kilka wątków i wiele aplikacji tak postępuje, co możesz zobaczyć w każdej chwili, uruchamiając systemowy Menedżer Zadań i włączając w nim kolumnę "Wątki":
Menedżer Zadań wyświetlający liczbę wątków w procesach (Windows XP)
Menedżer Zadań wyświetlający liczbę wątków w procesach (Windows XP)
Po co mielibyśmy chcieć wiele wątków w programie? Odpowiedź wcale nie jest tak prosta i oczywista, jak niektórym mogłoby się wydawać. Ponieważ jednak ten kurs nigdy nie miał nikogo zanudzać filozoficznymi wywodami, więc spróbujmy mimo wszystko takiej właśnie odpowiedzi udzielić. Otóż niektóre zadania, jakie mógłby wykonywać nasz program (jak choćby wczytywanie danych z dużego pliku), są z pewnością czasochłonne, zaś nasz użytkownik nie lubi na nic czekać zbyt długo i chciałby w tym czasie robić cokolwiek innego, dopóki procesor nie upora się z owym głównym, "dużym" zadaniem. Zresztą pół biedy z użytkownikiem kąpanym w gorącej wodzie – znacznie gorzej, gdy płynności wymaga już sam nasz program. Jeśli piszemy np. odtwarzacz multimedialny albo grę akcji, gdzie wielkie ilości danych muszą być dostarczane na bieżąco bez przerywania "głównego zadania", to oczywiście nie ma mowy o wczytywaniu ich w tym samym wątku, w którym są wykorzystywane.

W programie wielowątkowym jeden lub więcej wątków może zająć się wczytywaniem danych i innymi czasochłonnymi, acz mniej pilnymi zadaniami, podczas gdy główny wątek może spokojnie skoncentrować się na wyświetlaniu filmu (w odtwarzaczu) czy renderowaniu sceny (w grze) oraz – co ważne – na zarządzaniu owymi pozostałymi wątkami. W efekcie takie wczytywanie i przetwarzanie danych "w tle" nie musi wcale przebiegać szybciej (czasem wręcz przeciwnie), za to nie będzie prawie wcale blokować głównego wątku, oszczędzając włosy na głowie użytkownika i generalnie sprawiając wrażenie płynności i stabilności.

Oczywiście są i sytuacje, kiedy użycie odrębnych wątków daje realne przyspieszenie niektórych operacji. Dzieje się tak zwłaszcza wtedy, gdy dysponujemy procesorem wielordzeniowym (dziś standard), pozwalającym faktycznie wykonywać kilka rzeczy jednocześnie.

Tworzymy drugi wątek

Spróbujmy odpalić własny wątek. Jeden, jak już wiemy, jest uaktywniany zawsze kiedy uruchomimy jakikolwiek program i nie jest to wielka sztuka ;-). Na szczęście wystartowanie drugiego wątku w obrębie naszego programu również nie jest takie znowu skomplikowane (schody zaczną się dopiero później ;-)) i zaraz to sobie uczynimy.

Zanim jednak weźmiemy się do roboty, chciałbym abyś zaopatrzył się w jakąś aplikację, pozwalającą podglądać uruchomione procesy. Jeśli używasz Windows XP lub nowszego, to aplikację taką już masz: zowie się ona Menedżer Zadań i zapewne zdążyłeś się już z nią zaznajomić. Jeśli nie, to naciśnij Ctrl+Alt+Del (tylko, na bogów, nie dwukrotnie ;-)).

Kto zerknął już na dokumentację WinAPI, pewnie znalazł już funkcję CreateThread. Robi ona dokładnie to, co sugeruje nazwa; tworzy wątek. Ponieważ jednak tylko nieliczni ludzie zadają sobie trud przeczytania w dokumentacji ''wszystkiego'', więc od razu wyjaśnijmy, że w praktyce prawie nikt nie używa tej funkcji. Powód jest prosty (i wyjaśniony w dokumentacji, gdzieś na samym końcu...) – może ona powodować problemy, gdy używa się jej z biblioteką CRT. Ponieważ raczej używamy CRT, więc do wątków powinniśmy używać _beginthread lub _beginthreadex. Nazwy są dość dziwne, ale same funkcje są bardzo podobne do CreateThread, jak wkrótce się przekonamy. Nietrudno się domyślić, że funkcja _beginthread jest nieco prostsza:
C/C++
uintptr_t _beginthread( void( * start_address )( void * ), unsigned stack_size, void * arglist );
Pierwszy argument to adres funkcji, która zawiera kod wątku – coś jakby main. Może to być dowolna funkcja z naszego programu o pasującej sygnaturze. Życie wątku zaczyna się w momencie wywołania tej funkcji (w WinAPI zwanej procedurą wątku), a kończy wraz z jej opuszczeniem.

Drugi argument to rozmiar stosu – jeśli nas nie interesuje, to możemy tu dać 0. Wreszcie w ostatnim argumencie możemy coś przekazać do procedury wątku (i zwykle to robimy).

Prosta procedura wątku może wyglądać tak:
C/C++
#include <process.h> // wymagane ze względu na _beginthread


unsigned int g_Counter = 0;


void __cdecl ThreadProc( void * Args )
{
    while( g_Counter < 1000 )
         g_Counter++;
   
    _endthread();
}
Nasz wątek nie robi na razie za wiele – po prostu zwiększa sobie licznik, aż ten osiągnie wartość 1000. Następnie wychodzimy z wątku. Ponieważ nasza procedura wątku nie zwraca żadnej wartości (jest to wymagane dla funkcji _beginthread!), więc nie musimy nigdzie dawać instrukcji return, ale ponieważ jesteśmy w wątku, to grzecznie byłoby z naszej strony wywołać _endthread przed wyjściem i tak też czynimy.

Bardzo ważną rzeczą jest tutaj konwencja wywołania funkcji – dla procedury wątku tworzonego przez _beginthread musi to być __cdecl.

Mając gotową procedurę, możemy sobie teraz utworzyć sam wątek:
C/C++
HANDLE hThread =( HANDLE ) _beginthread( ThreadProc, 0, NULL );
Sleep( 5000 );

char buffer[ 1024 ];
sprintf( buffer, "g_Counter: %d\n", g_Counter );
OutputDebugString( buffer );
Patrząc na ten kod możemy się dość łatwo domyślić, co miałby on robić: tworzy wątek, czeka 5 sekund na jego zakończenie (w tym czasie wątek, jak już wiemy, zwiększa sobie swój licznik), po czym wypisuje na ekran wartość licznika.

Nasz kod najprawdopodobniej zadziała. Popełniliśmy jednak gruby błąd: założyliśmy, że operacja, którą wykonuje drugi wątek, wykona się w czasie mniejszym, niż 5 sekund. Na dzisiejszych komputerach założenie to jest raczej spełnione, jednak w praktyce nigdy nie możemy sobie robić takich założeń. I tutaj dochodzimy do kolejnego zagadnienia, czyli...

Synchronizacja

Funkcje czekające

Przekonaliśmy się przed chwilą, że wątki potrafią być kłopotliwe. Gdy jeden coś robi, a drugi już skończył swoją część roboty, często musi poczekać na ten pierwszy. Wiemy już, że funkcja Sleep nie jest najlepsza w tej roli. Czeka bowiem przez sztywno określoną liczbę milisekund – możemy w ten sposób nie wyrobić się w zadanym czasie, możemy też "przespać" właściwy moment zakończenia czynności, na którą czekamy i w ten sposób zmarnować sporo zawsze cennego czasu.

W tym momencie na arenę wkraczają funkcje czekające, a dokładniej najpopularniejsza z nich – WaitForSingleObject. Jej zadanie jest dość proste – ma "uśpić" wątek (ten, z którego została wywołana) do momentu, aż wskazany obiekt (uchwyt) przejdzie w stan "zasygnalizowany". W przypadku wątku oznacza to koniec jego żywota. Tak więc jeśli funkcji WaitForSingleObject podamy uchwyt do wątku, to funkcja ta będzie czekać, aż wątek się zakończy. O co nam właśnie chodziło:
C/C++
HANDLE hThread =( HANDLE ) _beginthread( ThreadProc, 0, NULL );
WaitForSingleObject( hThread, INFINITE );

char buffer[ 1024 ];
sprintf( buffer, "g_Counter: %d\n", g_Counter );
OutputDebugString( buffer );
Teraz nasz kod jest dużo, dużo lepszy niż poprzednio, ponieważ zadbaliśmy o synchronizację wątków. Możemy dzięki temu mieć pewność, że nie będziemy na ekranie wypisywać wartości licznika, zanim drugi wątek nie skończy jego ustawiania.

Flaga INFINITE oznacza, że funkcja WaitForSingleObject ma czekać w nieskończoność na swój obiekt. Brzmi to groźnie. I faktycznie, może to być groźne – łatwo można w ten sposób doprowadzić do zawieszenia naszej aplikacji. Dlatego też nierzadko korzysta się inaczej z tego parametru funkcji WaitForSingleObject: podając maksymalny czas czekania ('timeout'), po upłynięciu którego funkcja kończy swe działanie i pozwala programowi wykonywać się dalej. W takim przypadku zapewne chcemy sprawdzić, co zwróciła funkcja:
C/C++
HANDLE hThread =( HANDLE ) _beginthread( ThreadProc, 0, NULL );
DWORD result = WaitForSingleObject( hThread, 5000 );
if( result == WAIT_OBJECT_0 )
{
}
else if( result == WAIT_TIMEOUT )
{
}
Czekanie może się tu skończyć dwojako: albo funkcja się doczeka, aż obiekt związany z danym uchwytem przejdzie w stan "zasygnalizowany" (tj. wątek się zakończy, funkcja zwróci WAIT_OBJECT_0), albo minie 5 sekund, czyli okaże się, że wątek nie zdążył ze swoim zadaniem w żądanym czasie (funkcja zwróci WAIT_TIMEOUT).

Jeśli wydarzy się to drugie, możemy na to zareagować rozmaicie: albo wyświetlimy użytkownikowi wiadomość, że zadany czas został przekroczony (po czym możemy czekać dalej lub zrezygnować), albo zapytać użytkownika, co dalej czynić. Wszystko zależy od okoliczności.

Kiedy zaś można stosować flagę INFINITE? Dobrze zaprojektowana aplikacja wielowątkowa powinna używać jej jak najrzadziej, najlepiej wcale, a jeśli już, to raczej poza głównym wątkiem, tak aby w przypadku "niedoczekania się" nie zawiesić całej aplikacji.

Oczekiwanie na wiele obiektów

Co zrobić, jeśli chcemy np. zatrzymać większą liczbę wątków? Czekanie na każdy z nich, powiedzmy 10 sekund, mogłoby się okazać wyjątkowo irytujące dla użytkownika. Najlepiej byłoby powiedzieć systemowi, żeby czekał na wszystkie naraz, a on sam zaplanowałby czekanie w optymalny sposób, tak że programista nie musiałby się niczym martwić.

Taką właśnie mniej więcej cechę posiada funkcja WaitForMultipleObjects. Jak sama nazwa wskazuje, potrafi ona czekać na kilka obiektów synchronizacyjnych jednocześnie. Składnia jest dość podobna jak w przypadku WaitForSingleObject. Oczywiście tym razem musimy jakoś przekazać kilka uchwytów naraz, więc domyślamy się, że trzeba użyć tablicy:
C/C++
vector < HANDLE > threads;
for( int i = 0; i < 10; ++i )
{
    threads.push_back( GetThreadHandle( i ) );
}
Tutaj skorzystaliśmy z wektora STL. Jak wiemy (albo i nie), elementy wektora są zawsze ułożone jeden za drugim, więc bez problemu możemy korzystać z niego tak, jak ze zwykłej statycznej tablicy. Użycie funkcji jest bardzo proste:
C/C++
if( threads.size() > 0 )
     WaitForMultipleObjects( threads.size(), & threads[ 0 ], TRUE, 10000 );

W tym przykładzie dajemy 10 sekund wszystkim 10 wątkom, aby zakończyły swe działanie. Oczywiście aby tak się stało, przed wywołaniem WaitForMultipleObjects musimy jakoś powiedzieć tym wszystkim wątkom, aby się zakończyły.

Spore znaczenie ma trzeci parametr (typu BOOL). Jeśli jest ustawiony na TRUE, funkcja będzie czekała na wszystkie obiekty podane w tablicy. Jeśli na FALSE, pierwszy lepszy z podanych wątków spowoduje przerwanie czekania i powrót z funkcji. W tym drugim przypadku wartość zwracana będzie zawierała informację o tym, który z obiektów pierwszy przeszedł w stan zasygnalizowany (tutaj: który wątek się zakończył). Jego indeks zostanie po prostu dodany do "normalnych" wartości zwracanych (WAIT_OBJECT_0 lub WAIT_ABANDONED_0). W przypadku, gdy wyznaczony czas minie, a żaden z obiektów nie zmieni stanu, dostaniemy po prostu WAIT_TIMEOUT.

Zdarzenia

Czekanie na zakończenie wątków jest z pewnością pomocne, ale nie rozwiązuje wszystkich problemów z synchronizacją. Trudno sobie przecież wyobrazić program, który w imię synchronizacji morduje każdy wątek, jak tylko ten wykona swoje zadanie. Ale istnieją też obiekty, które umożliwiają przesłanie sygnału do funkcji czekającej bez kończenia wątku.

Jednym z takich obiektów jest zdarzenie (ang. 'event'). Tak jak inne obiekty synchronizacyjne, zdarzenie musi być najpierw utworzone:
C/C++
HANDLE hEvent = CreateEvent( NULL, TRUE, FALSE, NULL );
Podobnie jak w przypadku wątku (oraz innych obiektów związanych z synchronizacją), zdarzenie posiada swój uchwyt typu HANDLE, który może mieć jeden z dwóch stanów: zasygnalizowany i niezasygnalizowany. I podobnie jak z wątkiem, gdy zdarzenie przejdzie w stan zasygnalizowany, wątek czekający np. na funkcji WaitForSingleObject ruszy dalej.

Trzeci parametr funkcji CreateEvent oznacza właśnie początkowy stan obiektu; FALSE to stan niezasygnalizowany. Z kolei drugi parametr, również typu BOOL, określa, czy zdarzenie powinno automatycznie przechodzić w stan niezasygnalizowany po tym, jak czekający wątek zostanie odblokowany. Wartość TRUE w naszym przykładzie oznacza konieczność "ręcznego" przestawiania stanu.

Parametry: pierwszy i czwarty w tym momencie nas nie interesują. Jest to, odpowiednio: wskaźnik do struktury opisującej atrybuty bezpieczeństwa oraz nazwa zdarzenia (jeśli chcemy mu jakąś nadać).

W momencie, gdy chcemy, aby inny wątek (wywołujący np. WaitForSingleObject) przestał czekać na bieżący wątek, to w tym bieżącym wątku powinniśmy wywołać SetEvent. W tym momencie podane zdarzenie zmieni stan na zasygnalizowany, a czekający wątek zacznie znów działać:
C/C++
SetEvent( hEvent );
Aby ponownie zmienić stan na niezasygnalizowany i tym samym móc dalej używać zdarzenia utworzonego w trybie "ręcznym", musimy wywołać ResetEvent:
C/C++
ResetEvent( hEvent );
Jeśli zaś zdarzenie utworzyliśmy w trybie automatycznym, to jak już wspomnieliśmy, przejście w stan niezasygnalizowany wykona się samo, gdy czekający wątek przestanie czekać.

Dysponując poręcznym narzędziem, jakim są zdarzenia, możemy wreszcie naprawdę kontrolować wykonywanie aplikacji wielowątkowej – możemy w dowolnym momencie poczekać, aż inny wątek coś zrobi bez konieczności jego zakończenia.

Współdzielenie zasobów i sekcje krytyczne

Wątki zazwyczaj współdzielą ze sobą pewne fragmenty pamięci. Najprostszym przykładem może tu być nasza zmienna g_Counter, której używaliśmy powyżej. Pojedyncze zmienne zwykle nie są problemem w aplikacjach wielowątkowych (zwłaszcza, gdy są modyfikowane w jednym wątku, a odczytywane w innym, tak jak u nas). Jednakże często używamy bardziej złożonych struktur danych, ot, choćby list. Typowym zadaniem dla wątku jest pobieranie jakichś danych odłożonych na kolejkę, wykonywanie na nich jakiejś operacji i ewentualne odkładanie ich z powrotem na kolejkę. Rzadko udaje się takie dane upchnąć w prostej zmiennej, zwykle używamy jakiejś większej struktury, np.:
C/C++
struct STask
{
    int taskType;
    int userID;
    unsigned int taskTime;
    string taskMsg;
   
    void Process( void )
    {
        Sleep( 500 ); // z braku lepszego zajęcia... ;-)
    }
};
Mamy tu cztery pola, w dodatku jedno z nich (string) nie jest typem podstawowym, tylko dość skomplikowaną klasą. Zapisanie takiego obiektu w kontenerze std::list nie jest operacją atomową, tzn. nie da się jej wykonać w najmniejszej możliwej jednostce czasu procesora. Zajmuje więc kilka takich jednostek, a to oznacza, że w międzyczasie inny wątek może próbować się dobrać do naszej kolejki. Oczywiście nie chcielibyśmy, aby np. próbował odczytać z niej element typu STask, zanim jeszcze pierwszy wątek zdążył zapisać do kolejki wszystkie jego bajty. Tym bardziej nie chcielibyśmy, żeby spróbował sam w jakiś sposób zmodyfikować kolejkę!

Niestety, opisane wyżej zagrożenie jest całkiem realne, gdy w grę wchodzą wątki i możemy się o tym łatwo przekonać.
C/C++
list < STask > g_TaskQueue;

void __cdecl ThreadProc( void * Args )
{
    while( !g_TaskQueue.empty() )
    {
        STask task = g_TaskQueue.back();
        g_TaskQueue.pop_back();
        task.Process();
    }
   
    _endthread();
}
Stworzyliśmy sobie kolejkę zadań i jak widać, zadaniem wątku jest pobieranie zadań z tej kolejki oraz wykonywanie ich (tutaj akurat polega to na "przespaniu" 500 milisekund). Po wykonaniu zadanie jest usuwane z kolejki i tak w kółko, aż kolejka się opróżni. Wówczas wątek się kończy. Zobaczmy teraz, co dzieje się w głównym wątku:
C/C++
STask task;
task.taskType = 0;
task.taskTime = 0;
task.userID = 0;
task.taskMsg = "Some text";

for( int i = 0; i < 10; ++i )
     g_TaskQueue.push_back( task );

HANDLE hThread =( HANDLE ) _beginthread( ThreadProc, 0, NULL );

while( !g_TaskQueue.empty() )
{
    STask task = g_TaskQueue.back();
    g_TaskQueue.pop_back();
    task.Process();
}

WaitForSingleObject( hThread, INFINITE );

MessageBox( hwnd, "Wątek zakończył działanie.", "", MB_ICONINFORMATION );
Dodaliśmy 10 zadań do kolejki i odpaliliśmy wątek, który próbuje wykonać te zadania. Zaraz potem (czyli de facto równocześnie) to samo próbuje na własną rękę zrobić główny wątek. Takie ryzykowne zagranie może się to skończyć tylko w jeden sposób: coś się tutaj wywali. Popełniliśmy błąd, usiłując pozwolić dwóm wątkom na równoczesny zapis i odczyt w tej samej strukturze danych.

Niektóre implementacje STL mogą być 'thread-safe', a i sam przypadek jest jeszcze stosunkowo prosty, więc możemy liczyć na to, że nasz błąd nie spowoduje akurat tutaj katastrofy. Generalnie jednak próba uzyskania równoczesnego dostępu do tego samego zasobu zwykle kończy się bardzo źle i musimy takich sytuacji unikać jak ognia.

Jednym ze sposobów zaradzenia problemowi zasobów współdzielonych między kilkoma wątkami są sekcje krytyczne. Jest to wydzielony kawałek kodu, którego wykonywania system nie może nigdy przerwać w celu przekazania "pałeczki" innemu wątkowi. Dzięki temu mamy gwarancję, że inny wątek nie będzie się dobierał do danego miejsca w pamięci, zanim nie skończymy na nim operować. Innymi słowy: tylko jeden wątek może w danym momencie być wewnątrz danej sekcji krytycznej.

Pierwszym krokiem we wprowadzeniu sekcji krytycznych do naszej aplikacji jest zadeklarowanie reprezentującego taką sekcję obiektu:
C/C++
CRITICAL_SECTION g_Section;
Następnie sekcję krytyczną musimy zainicjować:
C/C++
InitializeCriticalSection( & g_Section );
Teraz jest już gotowa do użycia. Musimy się zastanowić, które miejsca w naszym kodzie mają być sekcjami krytycznymi. To akurat dość proste – będą to miejsca, gdzie odwołujemy się do kolejki zadań. Nasza procedura wątku będzie po tej zmianie wyglądać tak:
C/C++
void __cdecl ThreadProc( void * Args )
{
    EnterCriticalSection( & g_Section ); // początek sekcji krytycznej
   
    while( !g_TaskQueue.empty() )
    {
        STask task = g_TaskQueue.back();
        g_TaskQueue.pop_back();
        task.Process();
    }
   
    LeaveCriticalSection( & g_Section ); // ...i jej koniec
   
    _endthread();
}
Widzimy, że wejście do sekcji krytycznej wyznacza wywołanie funkcji EnterCriticalSection (podajemy jej adres wcześniej zadeklarowanego obiektu typu CRITICAL_SECTION), zaś koniec – wywołanie LeaveCriticalSection. Teraz nie musimy już się obawiać, że inny wątek spróbuje coś zrobić z naszą kolejką w tym samym czasie, gdy ten wątek coś z niej wyjmuje lub wrzuca. Będzie mógł uzyskać dostęp do kolejki dopiero wtedy, gdy już opuścimy sekcję krytyczną, czyli po wywołaniu LeaveCriticalSection. A wówczas bieżący wątek nie będzie już nic robił z kolejką, więc nie dojdzie do kolizji.

Dobrze byłoby to samo zrobić z drugim miejscem w programie, gdzie odwołujemy się do kolejki zadań:
C/C++
HANDLE hThread =( HANDLE ) _beginthread( ThreadProc, 0, NULL );

EnterCriticalSection( & g_Section );

while( !g_TaskQueue.empty() )
{
    STask task = g_TaskQueue.back();
    g_TaskQueue.pop_back();
    task.Process();
}

LeaveCriticalSection( & g_Section );

WaitForSingleObject( hThread, INFINITE );

MessageBox( hwnd, "Wątek zakończył działanie.", "", MB_ICONINFORMATION );

DeleteCriticalSection( & g_Section );
W ten sposób zabezpieczyliśmy się stuprocentowo: jeden wątek nie będzie drugiemu wchodził w paradę podczas odczytywania czy zapisywania kolejki. Nie ma już znaczenia, czy użyjemy kontenera STL, czy jakiegokolwiek innego ani jaki rozmiar będą miały zapisywane struktury – sekcje krytyczne zapewnią nam bezpieczeństwo operacji na tych strukturach.

Gdy wiemy, że sekcja krytyczna nie będzie nam już potrzebna, musimy usunąć ją poprzez DeleteCriticalSection. Najlepiej inicjalizację sekcji wykonywać przy uruchomieniu programu, a usuwanie – przy jego zakończeniu.

Warto jeszcze zauważyć, że w powyższym przykładzie trochę przedobrzyliśmy i przez tę dbałość o bezpieczeństwo straciliśmy na wydajności. Oto bowiem pozbawiliśmy nasz program szansy na równoczesne wykonywanie kodu przez dwa wątki. Sekcje krytyczne sprawiają, że tylko jeden wątek może wykonywać zadania, a gdy skończy, drugi wątek nie będzie miał już co robić, bo kolejka będzie pusta. Dlatego pokusimy się o drobną optymalizację:
C/C++
void __cdecl ThreadProc( void * Args )
{
    while( !g_TaskQueue.empty() )
    {
        EnterCriticalSection( & g_Section );
        STask task = g_TaskQueue.back();
        g_TaskQueue.pop_back();
        LeaveCriticalSection( & g_Section );
       
        task.Process();
    }
   
    _endthread();
}
Teraz sekcja krytyczna obejmuje mniej instrukcji. Generalnie ilość kodu między początkiem a końcem sekcji powinna być jak najmniejsza i właśnie według tej zasady tutaj postąpiliśmy. Podobną zmianę powinniśmy teraz wykonać w głównym wątku. Jeśli to zrobimy, oba wątki znowu będą mogły działać równolegle, ale tym razem nie będziemy ryzykować kolizją, ponieważ sekcje krytyczne pilnują, aby wątki nie wykonywały tego samego zadania równocześnie.

Debugowanie wątków w Visual Studio

Wątki z reguły znacznie komplikują nasz program, więc rola debuggera znacznie wzrasta, gdy używamy wątków. Dlatego też im lepsze wsparcie debugowania daje nam IDE, tym lepiej.

W Visual Studio 2005 to wsparcie jest dość ubogie; wszystko, co dostajemy, to małe okienko "Threads", gdzie wyświetlone są wszystkie aktualnie działające wątki debugowanej aplikacji. Wygląda tak:
Okno wątków w debuggerze (Visual Studio 2005)
Okno wątków w debuggerze (Visual Studio 2005)
Jak widać, wątki wyświetlane są według nazwy funkcji, od których zaczyna się ich działanie. W przypadku wątku głównego jest to funkcja __tmainCRTStartup, która z kolei odpala naszą funkcję main. Dla pozostałych wątków widzimy nazwę procedury wątku. Oprócz tego wyświetlana jest nazwa funkcji, w której aktualnie dany wątek siedzi (na obrazku jest to funkcja WinMain). Możemy też odczytać priorytet wątku i czy jest on zatrzymany. Klikając dwukrotnie wątek na liście bądź wybierając z menu kontekstowego "Switch To Thread" zostaniemy przeniesieni do miejsca w kodzie, gdzie aktualnie ten wątek przebywa. Oczywiście spowoduje to również aktualizację zawartości okienka "Call Stack" (które teraz będzie pokazywało stos wywołań wybranego wątku) i innych okien debuggera. Z okna "Threads" możemy też zamrozić ('freeze') wątek, czyli zatrzymać go (np. żeby nie utrudniał debugowania głównego wątku).

Dużo lepsze wsparcie wielowątkowości oferuje Visual Studio 2008. Tutaj mamy już do dyspozycji dwa comboboxy na pasku narzędzi, pokazujące listę debugowanych procesów i wątków i pozwalające przełączać się między nimi:
ComboBox wątków w debuggerze (Visual Studio 2008)
ComboBox wątków w debuggerze (Visual Studio 2008)
W ten sposób okienko "Threads" nie musi być cały czas widoczne i mamy więcej miejsca na inne ważne okienka. Ponadto Visual Studio 2008 wyświetla na marginesie linii kodu specjalną ikonkę, która pozwala nam zorientować się, czy zmienił się wątek od czasu poprzedniego kroku. Dzięki temu nie musimy cały czas sprawdzać, w którym wątku aktualnie jesteśmy.
Okno wątków w debuggerze (Visual Studio 2008)
Okno wątków w debuggerze (Visual Studio 2008)

Pułapki

Programowanie wielowątkowe, jako się rzekło, nie jest proste. Oto kilka "najpopularniejszych" pułapek, na jakie możemy się natknąć.

Zakleszczenie wątków

Zakleszczenie (ang. 'deadlock') polega na tym, że jeden wątek czeka, aż drugi coś zrobi, a ten drugi... czeka na pierwszy. Oczywiście w ten sposób żaden nigdy się nie doczeka i aplikacja przestanie reagować na działania użytkownika.

Jak zapobiegać zakleszczeniu? Najczęściej wynika ono z błędu projektowego, więc jeśli dojdzie do takiej sytuacji, to najprawdopodobniej nasza koncepcja komunikacji między wątkami jest zła i należy ją przemyśleć jeszcze raz. Jeśli nie da się inaczej, pozostaje przerwać czekanie i ogłosić błąd – innymi słowy może to być znak, że nie powinniśmy w danym miejscu używać flagi INFINITE.

Zawieszenie na sekcji krytycznej

Przy sekcjach krytycznych wspomnieliśmy, że należy nimi obejmować jak najkrótszy fragment kodu. Chodzi nie tylko o wydajność, ale też o to, by programista nie miał okazji popełnić grubej pomyłki prowadzącej do zawieszania się aplikacji, a o pomyłkę łatwo, gdy używamy zbyt długich sekcji krytycznych. Żeby było wiadomo, o czym mowa, popatrzmy na lekko zmodyfikowany przykład z przetwarzaniem kolejki zadań:
C/C++
void __cdecl ThreadProc( void * Args )
{
    while( !g_TaskQueue.empty() )
    {
        EnterCriticalSection( & g_Section );
       
        STask task = g_TaskQueue.back();
        g_TaskQueue.pop_back();
       
        if( !task.IsValid() ) continue;
       
        task.Process();
       
        LeaveCriticalSection( & g_Section );
    }
   
    _endthread();
}
Jeśli przypadkiem coś będzie nie tak z zadaniem pobranym z kolejki i metoda IsValid zwróci false, to spróbujemy zignorować nieprawidłowe zadanie i przejść do następnego. Wszystko super, tyle że próba przejścia do następnego kroku pętli spowoduje... zawieszenie się wątku. Przyczyna jest prosta: w każdej kolejnej iteracji wchodzimy do sekcji krytycznej. Jeśli pominiemy LeaveCriticalSection i zaczniemy nową iterację, czyli wejdziemy dwa razy pod rząd do tej samej sekcji krytycznej, co oznacza, że wątek zacznie czekać, aż poprzedni wątek opuści tę sekcję. To oczywiście nigdy nie nastąpi, ponieważ w sekcji jest aktualnie ten sam wątek, który niestety nie wie, że sam siebie przyblokował... Rozwiązaniem problemu jest osobne wyjście z sekcji krytycznej za każdym razem, gdy pomijamy "właściwe" wyjście, czyli:
C/C++
void __cdecl ThreadProc( void * Args )
{
    while( !g_TaskQueue.empty() )
    {
        EnterCriticalSection( & g_Section );
       
        STask task = g_TaskQueue.back();
        g_TaskQueue.pop_back();
       
        if( !task.IsValid() )
        {
            LeaveCriticalSection( & g_Section );
            continue;
        }
       
        task.Process();
       
        LeaveCriticalSection( & g_Section );
    }
   
    _endthread();
}
Oczywiście, można to samo napisać prościej:
C/C++
if( task.IsValid() )
     task.Process();

Ten przykład był akurat bardzo prosty, a błąd – widoczny jak na dłoni. Wiadomo jednak, że w "prawdziwym życiu" sytuacje bywają o wiele bardziej złożone, z wielokrotnie zagnieżdżonymi pętlami, licznymi instrukcjami warunkowymi i funkcjami ciągnącymi się przez kilka ekranów, a wtedy sekcje krytyczne mogą stać się przysłowiowym wrzodem na tyłku.

Wątki i GUI

Jedną z największych niedogodności związanych z wątkami w WinAPI (i zarazem jednym z najczęściej popełnianych błędów) są odwołania do GUI. Jeśli na przykład spróbujemy z wątku zrobić cokolwiek z GUI, a główny wątek czeka na zakończenie tej operacji w funkcji WaitForSingleObject, to nasza aplikacja się zawiesi. Zobaczmy:
C/C++
void __cdecl ThreadProc( void * Args )
{
    MessageBox( hwnd, "Test", 0, 0 );
   
    _endthread();
}
Jeśli główny wątek nie poczeka na zakończenie ThreadProc, to wszystko będzie w porządku, ale wystarczy wywołać WaitForSingleObject, a MessageBox nawet się nie wyświetli. Oczywiście synchronizacja jest nam raczej niezbędna, więc z niej nie zrezygnujemy.

Tak więc konkluzja jest dość smutna: jesteś w wątku, zapomnij o GUI. Jeśli musisz np. coś wyświetlać z innego wątku, to możesz kolejkować teksty do wyświetlenia (jak już wiemy, nie ma problemu z wstawianiem danych do kolejki i odczytywaniem jej w innym wątku, zwłaszcza gdy zabezpieczymy się sekcją krytyczną).

Marnowanie czasu procesora

System przydziela każdemu wątkowi określoną liczbę cykli procesora. Gdy wątek nic nie robi, cykle te marnują się, a wydajność systemu spada. Dlatego musimy zadbać, aby wątki albo zawsze były zajęte, albo przynajmniej potrafiły powiadomić system, że nic nie robią.

Nawet jeśli wszystkie nasze wątki są czymś zajęte, to jeden z nich może w pewnych okolicznościach zdominować pozostałe i system wykona najpierw ten wątek, podczas gdy pozostałe będą na niego czekać – to nam psuje cały efekt, jaki miały nam dać wątki.

Oba te problemy można rozwiązać w prosty sposób. Weźmy taki wątek:
C/C++
void __cdecl ThreadProc( void * Args )
{
    int counter = 0;
    int p = *( int * ) Args;
   
    while( counter < 10 )
    {
        counter++;
        if( counter % 2 == p )
             continue;
       
        char buffer[ 128 ];
        sprintf( buffer, "%i (thread %i)\n", counter, p );
        OutputDebugString( buffer );
    }
   
    _endthread();
}
Jeżeli jako argument podamy temu wątkowi liczbę 0, to wyświetli nam on po kolei liczby parzyste mniejsze lub równe 10; jeśli podamy 1, to wyświetli liczby nieparzyste mniejsze od 10. Możemy też na bazie tej procedury utworzyć dwa wątki i wykonać oba zadania jednocześnie:
C/C++
HANDLE hThreads[ 2 ];
int params[] = { 0, 1 };
hThreads[ 0 ] =( HANDLE ) _beginthread( ThreadProc, 0, params + 0 );
hThreads[ 1 ] =( HANDLE ) _beginthread( ThreadProc, 0, params + 1 );

WaitForMultipleObjects( 2, hThreads, TRUE, INFINITE );
Ponieważ na razie nie zsynchronizowaliśmy tych dwóch wątków (mamy tylko czekanie w głównym wątku, aż te dwa wątki robocze całkowicie zakończą swą pracę), więc kolejność ich wykonywania nie jest ściśle określona. U mnie najpierw pierwszy wykonuje swoją całą pracę, potem dopiero przychodzi kolej na drugi. Zróbmy teraz małą modyfikację:
C/C++
void __cdecl ThreadProc( void * Args )
{
    int counter = 0;
    int p = *( int * ) Args;
   
    while( counter < 10 )
    {
        counter++;
        if( counter % 2 == p )
             continue;
       
        char buffer[ 128 ];
        sprintf( buffer, "%i (thread %i)\n", counter, p );
        OutputDebugString( buffer );
       
        Sleep( 0 ); // reszta czasu dla drugiego wątku
    }
   
    _endthread();
}
Dodaliśmy instrukcję Sleep(0). Funkcję Sleep już znamy, ale nie w tym znaczeniu. Jeśli wywołamy Sleep(0), to niewykorzystane cykle zostaną "oddane" następnemu wątkowi. W ten sposób osiągnęliśmy to, co chcieliśmy: wątki wykonują swoje zadania równocześnie (lub na przemian, jeśli mamy procesor jednordzeniowy bądź pozostałe rdzenie są już czymś zajęte). Na wyjściu widzimy, że liczby wypisywane są tym razem po kolei.
Poprzedni dokument Następny dokument
Unicode w WinAPI Drukowanie