Uwaga:
Oryginalna wersja tego artykułu została opublikowana w IBM developerWorks i
jest własnością Westtech Information Services. Poniższy dokument jest
poprawioną przez zespół GDP wersją oryginalnego tekstu i nie jest już
aktualizowany.
|
Wszystko o wątkach systemu POSIX, część druga
1.
Urządzenia zwane muteksami
Zamuteksuj mnie!
W poprzedniej części tego
artykułu omawiany był przykładowy program, który w wyniku swojego działania
zwrócił nieoczekiwany wynik. Każdy z dwu wątków inkrementował zmienną globalną
dwadzieścia razy, w wyniku czego wynik powinien był wynieść 40. Tak się jednak
nie stało i otrzymaliśmy 21. Co się stało? Problem tkwił w tym, że obie
inkrementacje wzajemnie nadpisywały swoje wyniki. Spójrzmy na poprawiony kod,
który wykorzystuje muteksy (ang. mutex, od mutual exclusion), aby sobie
z tym poradzić.
Listing 1.1: wątek3.c |
#include <pthread.h>
#include <stdlib.h>
#include <unistd.h>
#include <stdio.h>
int mojazmiennaglobalna;
pthread_mutex_t mojmuteks=PTHREAD_MUTEX_INITIALIZER;
void *MojaFunkcjaDlaWątku(void *arg) {
int i,j;
for ( i=0; i<20; i++ ) {
pthread_mutex_lock(&mojmuteks);
j=mojazmiennaglobalna;
j=j+1;
printf(".");
fflush(stdout);
sleep(1);
mojazmiennaglobalna=j;
pthread_mutex_unlock(&mojmuteks);
}
return NULL;
}
int main(void) {
pthread_t mojwątek;
int i;
if ( pthread_create( &mojwątek, NULL, MojaFunkcjaDlaWątku, NULL) ) {
printf("błąd przy tworzeniu wątka.");
bort();
}
for ( i=0; i<20; i++) {
pthread_mutex_lock(&mojmuteks);
mojazmiennaglobalna=mojazmiennaglobalna+1;
pthread_mutex_unlock(&mojmuteks);
printf("o");
fflush(stdout);
sleep(1);
}
if ( pthread_join ( mojwątek, NULL ) ) {
printf("błąd przy kończeniu wątka.");
abort();
}
printf("\nWartość mojej zmiennej globalnej to %d\n",mojazmiennaglobalna);
exit(0);
}
|
Wyjaśnienie
Gdyby porównać ten kod z tym, który zamieściłem w poprzednim artykule, uważny
czytelnik z pewnością zauważy, że pojawiły się wywołania funkcji
pthread_mutex_lock() oraz pthread_mutex_unlock(). Umożliwiają one wykonywanie
jednoczesnych operacji. Gdy jeden wątek ma odblokowany muteks, to drugi ma
zablokowany.
Tak właśnie działają muteksy. Jeśli wątek A próbuje zablokować muteks, podczas
gdy wątek B już go blokuje, to wówczas A zostaje uśpiony. Jak tylko B zwolni
muteks (dzięki funkcji pthread_mutex_unlock()), A będzie w stanie go zablokować
na swój użytek (innymi słowy, pthread_mutex_lock() zwróci kod sukcesu).
Analogicznie, jeśli wątek C spróbuje zablokować ten muteks, podczas gdy A już
blokuje, to C zostanie uśpiony na jakiś czas. Wszystkie wątki, które zostaną
uśpione, będą kolejkowane do danego muteksu.
Zazwyczaj funkcje pthread_mutex_lock() i pthread_mutex_unlock() stosuje się,
aby chronić swoje współdzielone struktury danych, a dokładniej upewnić się,
że w danym momencie tylko jeden wątek ma dostęp do pewnych danych. Oczywiście,
jeśli jakiś wątek chce zablokować obecnie odblokowany muteks, to biblioteka
wątków mu to umożliwi bez potrzeby usypiania go.
Ilustracja 1.1: Cztery z nurty odgrywają scenkę z wywołaniami pthread_mutex_lock() |
 |
Na powyższym obrazku, ten wątek, który blokuje muteks, otrzymuje dostęp do
pełnej struktury danych. Nie musi się martwić, że inny wątek coś namiesza w
międzyczasie. Ta struktura danych jest de facto niedostępna dopóki, dopóty
muteks ten pozostaje zablokowany. To tak, jakby funkcje pthread_mutex_lock()
oraz pthread_mutex_unlock() były znakami informującymi o pracach drogowych na
danym odcinku jezdni, tu na wybranym fragmencie kodu. Są ostrzeżeniem dla
innych wątków i zmuszają je, aby poszły spać. Rzecz jasna, taki przykład
jest prawdziwy tylko wtedy, gdy każdą operację odczytu i zapisu ujmiemy w
wywołanie tych funkcji.
Dlaczego właściwie muteks?
Brzmi interesująco, ale dlaczego mielibyśmy chcieć usypiać nasze wątki?
W końcu, czyż ich główną zaletą nie miało być współbieżne i czasami
jednoczesne działanie? Jest to niewątpliwie prawda, aczkolwiek każdy,
choć trochę bardziej zaawansowany program wielowątkowy będzie wymagał
użycia muteksów. Rzućmy okiem jeszcze raz na nasz przykład, aby
zrozumieć dlaczego.
Warto zwrócić uwagę, że w MojaFunkcjaDlaWątku() muteks jest blokowany przy
początku pętli i zwalniany przy jej końcu. W tym przykładzie, "mojmuteks" jest
używany, aby chronić zmienną "mojazmiennaglobalna". Zgodnie z przykładem,
wartość jest kopiowana do zmiennej lokalnej, a następnie inkrementowana, po
czym wywoływana jest funkcja sleep(1) i dopiero wówczas zawartość zmiennej
lokalnej jest zapisywana do naszej zmiennej globalnej. Bez muteksu nasza
funkcja nadpisze wartość zmiennej globalnej, gdy wyjdzie ze sleep(1).
Korzystanie z muteksu zapewnia nas, że tak się nie stanie. Na wszelki wypadek
dodam, że użycie funkcji sleep() w tym przykładzie służyło tylko spowodowaniu
wadliwego działania. Poza tym, jest tutaj całkowicie zbędna. Program wątek3.c
w wyniku swojego funkcjonowania zwraca już poprawne wartości:
Listing 1.2: Wynik działania programu wątek3 |
$ ./wątek3
o..o..o.o..o..o.o.o.o.o..o..o..o.ooooooo
Wartość mojej zmiennej globalnej to 40
|
Aby w dalszym stopniu zbadać ten przykład, obejrzyjmy kod inkrementacji w
naszym przykładzie.
Listing 1.3: Kod inkrementacji |
Kod inkrementacji w MojaFunkcjaDlaWątku():
j=mojazmiennaglobalna;
j=j+1;
printf(".");
fflush(stdout);
sleep(1);
mojazmiennaglobalna=j;
Kod inkrementacji w głównym wątku:
mojazmiennaglobalna=mojazmiennaglobalna+1;
|
Dla takiego kodu, w przypadku jednowątkowego programu, należałoby oczekiwać,
że MojaFunkcjaDlaWątku() wykona się całkowicie, a następnie zacznie się
wykonywać część kodu w funkcji main(). W programie wielowątkowym bez muteksów
program zazwyczaj będzie się jednak wykonywał w następującym porządku (dzięki
funkcji sleep()):
Listing 1.4: Kolejność wykonywania się |
wątek MojaFunkcjaDlaWątku() wątek główny
j=mojazmiennaglobalna;
j=j+1;
printf(".");
fflush(stdout);
sleep(1); mojazmiennaglobalna=mojazmiennaglobalna+1;
mojazmiennaglobalna=j;
|
Ponieważ program wykona się w takiej kolejności, modyfikacje przeprowadzone w
wątku głównym na zmiennej globalnej zostają nadpisane, w wyniku czego
użytkownik otrzymuje niewłaściwy wynik działania programu. Gdybyśmy tutaj
manipulowali wskaźnikami, najprawdopodobniej otrzymalibyśmy błąd związany z
naruszeniem ochrony pamięci. Należy zwrócić uwagę, że MojaFunkcjaDlaWątku()
wykonuje wszystkie swoje instrukcje w kolejności chronologicznej. Po prostu,
problem związany jest z tym, że operacje wykonują dwa wątki, a nie, że któryś
z nich przetwarza instrukcje w niewłaściwej kolejności.
Wątki od środka, część pierwsza
Zanim wytłumaczę, w jaki sposób znajduję miejsca, gdzie trzeba użyć wątków,
chciałbym zaprezentować ich działanie od wewnątrz. Oto przykład pierwszy:
Załóżmy, że w głównym wątku tworzone są trzy inne wątki: A, B oraz C,
wywoływane w odpowiedniej kolejności:
Listing 1.5: Kolejność tworzenia wątków |
pthread_create( &wątek_a, NULL, MojaFunkcjaDlaWątku, NULL);
pthread_create( &wątek_b, NULL, MojaFunkcjaDlaWątku, NULL);
pthread_create( &wątek_c, NULL, MojaFunkcjaDlaWątku, NULL);
|
Po pierwszym, sukcesywnym wywołaniu pthread_create() można założyć, że albo
wątek A istnieje albo jest obecnie zatrzymany. Po drugim, sukcesywnym
wywołaniu oba wątki, główny i B mogą zakładać, że A istnieje lub jest
wstrzymany.
Jednak gdy tylko drugie wywołanie pthread_create() zwróci wartość, główny wątek
nie może założyć, który wątek, A czy B, rozpocznie swoje działanie w
przyszłości jako pierwszy. Mimo że oba wątki istnieją, to jądro systemu wraz
z biblioteką zadecydują, któremu z nich przydzielić kwant czasu procesora. Nie
ma reguły na to, który z nich wykona się pierwszy. Jest jednak całkiem
prawdopodobne, że to A będzie pierwsze, po prostu nie ma na to gwarancji.
Prawdopodobieństwo jest za to mniejsze na maszynach wieloprocesorowych.
Programiści, którzy z góry zakładają, że A wykona się przed B, będą mieli kod
działający poprawnie w 99% przypadków. A może gorzej - działający w 100% na ich
komputerach, ale w 0% przypadków na cztero-procesorowych maszynach ich klientów.
Kolejną wnioskiem, jaki można wyciągnąć z powyższego przykładu jest fakt, że
biblioteka wątków zachowuje kody wykonywania dla każdego wątku z osobna.
Innymi słowy, te trzy wywołania funkcji pthread_create() wykonają się w takiej
kolejności, w jakiej są wywoływane. Z perspektywy głównego wątku, cały kod
jest wykonywany w odpowiedniej kolejności. Czasami można ten fakt wykorzystać
do optymalizacji niektórych części naszych programów. Na przykład, w powyższym
kodzie, wątek C może założyć, że A i B się wykonują lub są zakończone. Nie
musi się martwić, że A czy B mogły nie powstać, a takie zachowanie może
posłużyć pewnej optymalizacji aplikacji wielowątkowych.
Wątki od środka, część druga
A teraz kolejny, hipotetyczny przykład. Załóżmy, że mamy kilka wątków, które
wykonują następujący kod:
Listing 1.6: Wywoływany kod |
mojazmiennaglobalna=mojazmiennaglobalna+1;
|
Czy trzeba blokować muteks przed każdą inkrementacją i blokować po? Niektórzy
powiedzą, że nie. Jest bardzo prawdopodobne, że kompilator skompiluje ten kod
jako pojedynczą instrukcję języka maszynowego, a jak wiadomo, pojedynczej
instrukcji nie da się przerwać, jeśli jest już wykonywana. Nawet system
przerwań sprzętowych przestrzega tej reguły. Wiedząc to, ma się chęć
zrezygnowania z pthread_mutex_lock() i pthread_mutex_unlock(), jednak takie
wyjście byłoby błędne.
Czy gadam jak mięczak? Właściwie, to nie. Po pierwsze, nie należy zakładać, że
coś zostanie skompilowane jako pojedyncza instrukcja kodu maszynowego, chyba,
że ktoś to sprawdza później w języku niskopoziomowym. Nawet, jeśli programista
zdecyduje się na dołożenie asercji upewniających go o tym, to i tak mogą
pojawić się problemy.
Dlaczego? Korzystanie z pojedynczych asercji prawdopodobnie będzie działać
idealnie na komputerze jednoprocesorowym. Każda inkrementacja nastąpi
automatycznie, a więc jest spora szansa, że wynik będzie zgodny z oczekiwanym.
Jednak na maszynie wieloprocesorowej będzie inaczej. Tam taka instrukcja
mogłaby być wykonywana przez wiele jednostek procesorowych jednocześnie.
Należy też pamiętać, że zmiana w pamięci musi przejść przez pamięć cache L1
oraz L2 i dopiero trafi do pamięci głównej. Maszyna wieloprocesorowa to nie
tylko więcej procesorów. Jest tam także specjalny sprzęt zarządzający dostępem
do pamięci. W rezultacie, programista nie może wiedzieć, który procesor
obsłuży daną instrukcję. Aby nasz kod był przewidywalny i stabilny, powinniśmy
korzystać z muteksów. Wprowadzą one barierę w pamięci, która upewni nas, że
operacje czytania i pisania na zmiennych odbędą się w takiej kolejności, w
jakiej wątki blokują wybrany muteks.
Weźmy pod uwagę maszynę o symetrycznej architekturze wieloprocesorowej, która
odświeża pamięć główną 32-bitowymi blokami. Gdyby inkrementować 64-bitową
zmienną całkowitą bez pomocy muteksów, to cztery najstarsze bajty mogą być od
jednego procesora, a cztery pozostałe od innego. Pech! Co gorsze, taki program
może nawalić kiedyś na maszynie naszego klienta o trzeciej w nocy.
David R. Butenhof omawia możliwe permutacje programowania bez używania
muteksów w swojej książce "Programowanie przy pomocy wątków w POSIX"
(patrz źródła na końcu tego artykułu).
Wiele muteksów
Jeśli upchamy do programu zbyt wiele muteksów, to stracimy na współbieżności,
a w rezultacie może okazać się, że będzie on działał wolniej, niż taki sam
program jednowątkowy. Gdyby muteksów zaś było za mało, to mogą pojawić się
błędy w funkcjonowaniu. Na szczęście istnieje złoty środek. Po pierwsze,
muteksy należy stosować do kolejkowania dostępu do *danych współdzielonych*.
Nie należy używać muteksów do danych prywatnych albo do takich, do których
w danej chwili dostęp może mieć tylko jeden wątek.
Po drugie, w przypadku korzystania ze współdzielonych struktur danych, należy
korzystać z muteksów zarówno dla operacji odczytu jak i zapisu - zwyczajnie
otaczając dane bloki instrukcji wywołaniami funkcji pthread_mutex_lock() oraz
pthread_mutex_unlock(). Można też zastosować je wszędzie tam, gdzie jakieś
niezmienniki zostały naruszone. Trzeba się więc nauczyć przeglądać swój kod
z perspektywy programu jednowątkowego i upewnić się, że każdy inny wątek
ma zwięzły i przyjazny wgląd do pamięci. To może zająć wiele dodatkowych
godzin, ale gdy takie programowanie wejdzie nam w nawyk, to dalsze
działania na muteksach przyjdą nam bez potrzeby dłuższego zastanawiania
się.
Korzystanie z wywołań - inicjalizacja
Czas na przegląd wszystkich możliwych sposobów korzystania z muteksów.
Zaczniemy od inicjalizacji. W przykładzie
wątek3.c, skorzystałem z inicjalizacji statycznej,
czyli zadeklarowania zmiennej typu pthread_mutex_t, a następnie przypisaniu
jej stałej wartości PTHREAD_MUTEX_INITIALIZER:
Listing 1.7: Przykład inicjalizacji |
pthread_mutex_t mojmuteks=PTHREAD_MUTEX_INITIALIZER;
|
To całkiem proste. Można także tworzyć muteksy dynamicznie. Robi się tak, gdy
kod alokuje nowy muteks za pomocą funkcji malloc(). Wówczas metoda statyczna
się nie sprawdzi. Należy skorzystać z funkcji pthread_mutex_init():
Listing 1.8: Sposób na dynamiczne tworzenie muteksów |
int pthread_mutex_init( pthread_mutex_t *mojmuteks, const pthread_mutexattr_t *attr)
|
Jak widać, pthread_mutex_init() przyjmuje wskaźnik do zaalokowaniej uprzednio
w pamięci zmiennej na muteks oraz opcjonalny argument typu wskaźnik do
pthread_mutexattr_t. Struktura ta może być użyta do ustawienia różnorakich
atrybutów dla danego muteksu. Zazwyczaj jednak, atrybuty te nie są potrzebne,
toteż korzysta się w tym miejscu z pseudowartości NULL.
Kiedykolwiek używana jest funkcja pthread_mutex_init() w celu inicjalizacji
muteksu, to należy potem posprzątać za pomocą pthread_mutex_destroy().
Funkcja ta przyjmuje jako argument wskaźnik do zmiennej typu
pthread_mutex_t i zwalnia wszelkie zasoby, jakie są alokowane dla danego
muteksu podczas podczas jego tworzenia. Należy jednak zwrócić uwagę, że nie
zwalnia pamięci zarezerwowanej na samą zmienną pthread_mutex_t, tylko obszar
pamięci z danymi. Warto także nadmienić, iż obie funkcje pthread_mutex_init()
i pthread_mutex_destroy() zwracają zero jako kod sukcesu.
Korzystanie z wywołań - blokowanie
Listing 1.9: Przykład blokowania |
pthread_mutex_lock(pthread_mutex_t *mojmuteks)
|
Funkcja pthread_mutex_lock() przyjmuje pojedynczy argument w postaci wskaźnika
do muteksu. Jeśli zdarzy się, że ten muteks jest już zablokowany, to dana
funkcja pójdzie spać. Jeśli jednak funkcja zwróci kod sukcesu, to funkcja
wywołująca zostanie wybudzona i to ona będzie od teraz blokować ten muteks.
Można więc otrzymać zero jako kod sukcesu albo niezerowy kod błędu.
Listing 1.10: Przykład odblokowywania |
pthread_mutex_unlock(pthread_mutex_t *mojmuteks)
|
Funkcja pthread_mutex_unlock() uzupełnia pthread_mutex_lock() i jest w stanie
odblokować muteks, który został przez jakiś wątek zablokowany. Zawsze należy
odblokowywać swoje muteksy, jeśli tylko będzie to możliwe i za razem
bezpieczne (aby poprawić osiągi). Nie należy zaś odblokowywać muteksów, które
uprzednio nie były zablokowane, bowiem wówczas funkcja zwróci nam niezerowy
kod błędu (EPERM).
Listing 1.11: Przykład na próbę blokowania |
pthread_mutex_trylock(pthread_mutex_t *mojmuteks)
|
Ta funkcja jest przydatna, gdy trzeba zablokować muteks, p'odczas gdy dana
funkcja zajmuje się czymś innym (ponieważ interesujący ją muteks jest obecnie
zablokowany). Po wywołaniu tej funkcji nastąpi próba zablokowania muteksu.
Jeśli nie jest on w danej chwili zablokowany, to tak się stanie i otrzymamy
kod sukcesu. Jeśli muteks jest zablokowany, to funkcja ta nie będzie się
ustawiać w kolejce do niego. Zwróci jednak niezerową wartość EBUSY jako kod
błędu. Można wówczas kontynuować swoją pracę i wrócić do tego miejsca później.
Oczekiwanie na spełnienie warunków
Muteksy są niezbędnym narzędziem dla programów, w których istnieje podział na
wątki, ale nie załatwią one wszystkiego. Co, na przykład, się dzieje, gdy
oczekujemy na spełnienie konkretnego warunku, którym było pojawienie się
czegoś w strukturze danych współdzielonych? Można napisać kod, który
nieustannie to sprawdza, blokując co raz jakiś muteks. Gdy warunek się
spełni, można szybko odblokować muteks i dokonać odpowiednich operacji. To
jest jednak fatalne podejście, gdyż dodatkowe pętle spowolnią działanie
naszego programu.
Można uśpić dany wątek na jakiś moment, powiedzmy na trzy sekundy po każdym
sprawdzeniu, jednak wtedy nasz kod nie będzie reagował w optymalnym czasie.
W tym wypadku potrzebna jest funkcja, która uśpi dany wątek na czas tak długi,
aż dany warunek zostanie spełniony. A gdy tak się stanie, potrzebna jest metoda
na obudzenie wątków, które czekają na spełnienie naszego warunku. Jeśli da się
to jakoś zorganizować, to nasz kod będzie bardzo wydajny i pozbędziemy się
przymusu blokowania muteksów. A zmienne warunkowe w POSIX właśnie to nam
gwarantują.
Zmienne warunkowe w POSIX są tematem następnej części tego artykułu. Powiem w
nim jak dokładnie się nimi posługiwać. Po jego lekturze czytelnik będzie w
stanie napisać w pełni zaawansowaną aplikację wielowątkową. W kolejnej części
zamierzam także zwiększyć tempo, zakładając, że czytelnicy nabrali trochę
wprawy w posługiwaniu się wątkami. Liczę, że pozwoli mi to na omówienie
bardziej złożonych przykładów. A skoro mowa o czekaniu na spełnienie warunków
- do zobaczenia w części trzeciej!
2.
Źródła
|
|