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ęść pierwsza
1.
Proste i elastyczne narzędzie do współdzielenia pamięci
Wątki są fajne
Każdy szanujący się programista powinien wiedzieć, jak programować z
użyciem wątków. Są one podobne do procesów - tak samo jądro systemu decyduje o
przydzielaniu im kwantu czasu procesora. W systemach jednordzeniowych jądro
zarządza przydzielaniem kwantu czasu procesora, aby stworzyć wrażenie
jednoczesnego obsługiwania wątków dokładnie tak, jak to jest w przypadku
procesów. W przypadku systemów wieloprocesorowych, wątki i procesy mogą się
wykonywać jednocześnie.
Dlaczego więc wielowątkowość (ang. multithreading) jest tak często uznawana
za lepsze rozwiązanie od wielu niezależnych procesów w zadaniach wymagających
wzajemnej współpracy? Głównie z tego powodu, że wątki wykorzystują ten sam
obszar pamięci, toteż wszystkie stworzone przez programistę wątki mogą
zapisywać do lub czytać z globalnie zadeklarowanych zmiennych. Każdy, kto
kiedykolwiek pisał jakąś niebanalną aplikację korzystającą z funkcji fork(),
powinien zdać sobie sprawę z użyteczności tego narzędzia. Dlaczego? Podczas,
gdy fork() tworzy nowe procesy potomne, tworzy także pewien problem.
Mianowicie, jak zmusić wiele procesów potomnych, z których każdy korzysta z
własnego, wydzielonego obszaru w pamięci, aby się komunikowały między sobą.
Niestety nie ma prostego rozwiązania. Mimo wielu różnych rodzajów lokalnych
IPC (ang. inter-process communication, czyli komunikacja między procesami),
są one obciążone pewnymi niepożądanymi konsekwencjami:
-
Zużywają zasoby związane ze specjalnymi (a także nadmiarowymi)
funkcjami, jakie zostały dla nich zaimplementowane w jądrze systemu.
-
W zdecydowanej większości przypadków, IPC nie są naturalną częścią kodu
programu. Nieraz implementacja IPC w wielkim stopniu komplikuje dany
program.
Nie jest dobrze, są dwa poważne problemy, mianowicie nadmiarowość i zbędna
komplikacja. Każdy, kto dotychczas musiał zmagać się z implementacją IPC,
która wiązałą się z masowym zmienianiem źródeł, powinien docenić zalety
współdzielenia pamięci, które wprowadzają wątki. Wątki zaimplementowane w
POSIX nie wymagają wprowadzania wielokrotnych zmian i komplikowania kodu, zaś
ich obsługa całkowicie zamyka się w źródłach danego programu. Zaledwie
niewielkie modyfikacje umożliwią wątkom korzystanie z istniejących struktur
danych. Nie trzeba ich wcale tłoczyć do zbędnych deskryptorów albo umieszczać w
specjalnym obszarze wspólnej pamięci. Właśnie dlatego programiści zachęcani są
do porzucenia modelu wielu procesów korzystających z pojedynczych wątków i
przejścia na system korzystający z jednego procesu rozdzielanego na wiele
wątków.
Wątki są elastyczne
Ale to nie wszystko. Zdarza się, że wątki potrafią być bardzo elastyczne. W
porównaniu z funkcją fork(), wątki potrzebują o wiele mniej zasobów
zarezerwowanych do ich obsługi. Jądro systemu nie musi tworzyć niezależnych
kopii deskryptorów, obszaru pamięci, itp. dla każdego z nich, co pozwala
zaoszczędzić sporo mocy obliczeniowej procesora. Zaletą takiego rozwiązania
jest tworzenie poszczególnych wątków nawet sto razy szybciej niż nowych
procesów potomnych. Dzięki temu można lekką ręką powołać do działania całą
masę nowych wątków i właściwie nie trzeba się przejmować, że zajmą dużą część
zasobów, jak to odbywa się w przypadku tworzenia procesów za pomocą funkcji
fork(). Oznacza to, że można wprowadzać bezkarnie wątki wszędzie tam, gdzie
wydaje się to choć trochę przydatne.
Rzecz jasna wątki, tak jak i procesy, będą obsługiwane szybciej w
środowisku wieloprocesorowym. To dość istotny aspekt jeśli dana aplikacja
będzie uruchamiana w takowym (a generalnie, jeśli ta aplikacja jest typu open
source, to bardzo możliwe, ze będzie w niejednym funkcjonować). Między
osiągami aplikacji wielowątkowych a komputerami działającymi w oparciu o
systemy wieloprocesorowe istnieje zależność liniowa, która szczególnie
uwidacznia się w przypadku aplikacji intensywnie wykorzystujących moc
procesora. W przypadku tych aplikacji często, dość naturalnie nasuwają się na
myśl rozwiązania wielowątkowe. Wprawiony programista aplikacji wielowątkowych
jest w stanie zmierzyć się z dotychczas nieosiągalnymi problemami w innowacyjny
sposób. Ponadto sporo niejasności, jakie niosły ze sobą IPC będzie można
zignorować. Dzięki wszystkim tym zaletom, programowanie aplikacji
wielowątkowych stanie się elastyczne, proste i przyjemne.
Wydaje mi się, że jestem klonem
Funkcja systemowa __clone() jest chyba dość powszechnie znana programistom
tworzącym swoje aplikacje pod Linuksem. Jest podobna do funkcji fork(), jednak
ma w sobie sporo funkcjonalności typowej dla wątków. Na przykład może
selektywnie udostępniać elementy środowiska, w jakim dany program został
uruchomiony (obszar pamięci, deskryptory plików, itp.) przez nowopowołany
proces potomny. Jest to niewątpliwie ważna zaleta, aczkolwiek korzystanie z tej
funkcji wiąże się także z pewnym obciążeniem, mianowicie, cytując stronę z man
__clone():
Listing 1.1: cytat z man __clone() |
"Funkcja systemowa __clone() jest rozwiązaniem stosowanym w systemach typu
Linux więc nie zaleca się jej stosowania w aplikacjach wieloplatformowych.
Lepiej korzystać z bibliotek implementujących API wątków w POSIX 1003.1c,
takich jak wątki linuksowe (ang. pthread; patrz funkcja pthread_create())"
|
Podczas, gdy __clone() niesie ze sobą wiele zalet wątków, nie jest funkcją,
którą można stosować w środowiskach nielinuksowych. Nie oznacza to, że nie
należy z niej korzystać, po prostu należy to brać pod uwagę. Na szczęście, jak
widnieje w man __clone(), istnieje lepsze rozwiązanie - wątki w POSIX. Aby
napisać program działający pod Linuksem, BSD, Solarisem i wielu innych
systemach operacyjnych, trzeba skorzystać z wątków zaproponowanych w POSIX.
Tworzenie wątków
Oto prosty przykładowy program wykorzystujący linuksowe wątki:
Listing 1.2: Przykładowy program wykorzystujący linuksowe wątki |
#include <pthread.h>
#include <stdlib.h>
#include <unistd.h>
void *MojaFunkcjaDlaWątku(void *arg) {
int i;
for ( i=0; i<20; i++ ) {
printf("Wątek mówi cześć!\n");
sleep(1);
}
return NULL;
}
int main(void) {
pthread_t mojwątek;
if ( pthread_create( &mojwątek, NULL, MojaFunkcjaDlaWątku, NULL) ) {
printf("błąd przy tworzeniu wątku\n"); abort();
}
if ( pthread_join ( mythread, NULL ) ) {
printf("błąd w kończeniu wątku\n");
abort();
}
exit(0);
}
|
Aby skompilować ten program, należy zwyczajnie zapisać go jako wątek1.c i
wpisać:
Listing 1.3: Kompilacja przykładowego programu |
$ gcc wątek1.c -o wątek1 -lpthread
|
Uruchomienie następuje przez wydanie komendy:
Listing 1.4: Uruchamianie programu |
$ ./wątek1
|
Zrozumienie przykładu wątek1.c
wątek1.c to bardzo prosty programik typu "hello world",
którego głównym zadaniem jest pomoc w zrozumieniu zasady tworzenia wątków.
Przejrzyjmy krok po kroku jego działanie. W funkcji main() jest zadeklarowana
zmienna typu pthread_t (zdefiniowany w pthread.h) o nazwie "mojwątek". Zawiera
ona identyfikator wątku (ang. thread id, tid) i należy jej używać jako
odniesienia do danego wątku w celu efektywnego nim zarządzania.
Po zadeklarowaniu zmiennej "mojwątek", wywoływana jest funkcja
pthread_create(), która de facto tworzy właściwy wątek. Samo wywołanie znajduje
się w instrukcji warunkowej "if", dzięki czemu programista ma pewność, że
funkcja zwróciła zero w przypadku sukcesu i wątek został powołany do życia. W
przeciwnym wypadku zostanie wyświetlony komunikat o błędzie. Spójrzmy na listę
argumentów, jakie pobiera ta funkcja. Pierwsza z nich to wskaźnik do zmiennej
"mojwątek". Następny (tu ustawiony jako pseudowartość NULL) pozwala wybrać
atrybuty dla tego wątku. W tym przypadku domyślna wartość jest w zupełności
wystarczająca, więc wstawiono NULL.
Trzeci argument to nazwa funkcji, która będzie wykonywana w chwili, gdy
pthread_create() zostanie wywołana i zwróci zero. W tym przypadku jest to
"MojaFunkcjaDlaWątku()" (może zaistnieć potrzeba rzutowania na *void, przyp.
tłum.). Gdy ta funkcja się wykona, wątek, który ją wywołał również zostanie
zakończony. W tym przypadku nie robi ona nic sensownego, po prostu wypisuje
"Wątek mówi cześć!" dwudziestokrotnie, na standardowe wyjście. Warto zwrócić
uwagę, że przyjmuje ona argument typu void* i takiego samego typu wartość jest
zwracana. Oznacza to, że można przekazać jej dodatkową porcję danych w
argumencie i można też takową otrzymać. A jak przekazać te dane naszemu
wątkowi? Zwyczajnie korzystając z czwartego argumentu funkcji pthread_create(),
który w powyższym przypadku został ustawiony na pseudowartość NULL, bowiem ten
kod nie wymaga niczego więcej.
Jak się zapewne cześć użytkowników się domyśla, program ten składa się z dwu
wątków, jeśli pthread_create() wykona się poprawnie. Chwila, dwu wątków?
Przecież wywołanie pthread_create() nastąpiło tylko raz! Tak, to prawda, ale
główny program również jest traktowany jako wątek (ang. "main" thread, wątek
główny). Należy patrzeć na to z tej strony - program napisany bez podziału na
wątki składa się z jednego wątku głównego. Poprzez powołanie nowego wątku mamy
ich w programie dwa.
Domyślam się, że w głowach czytelników pojawiają się teraz przynajmniej dwa
ważne pytania. Po pierwsze, co robi główny wątek, gdy powołany zostanie nowy?
Wykonywanie go jest kontynuowane, linijka po linijce. A co się dzieje z
nowym wątkiem, gdy ten skończy się wykonywać? Czeka on, aż zostanie z powrotem
przyłączony w ramach procesu zbierającego śmieci (ang. garbage collecting).
Parę słów o funkcji pthread_join(). Tak, jak pthread_create() rozdziela
dany program na dwa wątki, funkcja ta scala dwa wątki w jeden. Pierwszym
argumentem tej funkcji jest identyfikator tid, "mojwątek". Kolejny to wskaźnik
do void, który w przypadku, gdy nie jest ustawiony na NULL, będzie obszarem do
umieszczenia tego, co zwróci funkcja wykonywana w danym wątku. W tym przypadku
jest to NULL, ponieważ nasza funkcja nie zwraca niczego istotnego.
Łatwo można wywnioskować, że "MojaFunkcjaDlaWątku()" potrzebuje 20 sekund
na wykonanie się, czyli dużo więcej niż czas, po jakim zostanie wykonana
funkcja pthread_join. Gdy to jednak nastąpi, w wątku głównym jest wywoływana
funkcja sleep(), która zmusza go, aby czekał, aż nasza funkcja skończy się
wykonywać. Dopiero wtedy wątek będzie miał możliwość scalenia się z głównym.
Wówczas ten program znowu będzie składać się z pojedynczego wątku. Zawsze
należy kończyć swoje wątki, ponieważ, gdy będą już niepotrzebne, zaśmiecają
system, a także powodują osiągnięcie limitu stworzonych przez danego
użytkownika wątków. W rezultacie spowoduje to, że pthread_create() zacznie
zwracać informacje o błędach.
Nie ma rodziców, nie ma potomków
Jak wiadomo, gdy korzysta się z modelu rodzic-potomek, proces, w którym
wywoływana jest funkcja fork() jest rodzicem, zaś proces powołany przez tą
funkcję staje się jego potomkiem. Taki model pozwala na hierarchizację
procesów, który może okazać się całkiem przydatny, szczególnie, gdy czekamy na
potomka, aby się zakończył. Przykładowo funkcja waitpid() zmusi wybrany proces,
żeby ten czekał, aż wszystkie jego potomki zostaną zakończone. Jest to typowa
funkcja sprzątająca w rodzicu.
Sprawa ma się ciekawiej w przypadku wątków w POSIX. Jak dotąd umyślnie nie
korzystałem z nazwy wątek-rodzic czy wątek-potomek, a to dlatego, że tu taka
hierarchia nie istnieje. Mimo że program może powoływać wątek, a ten może
powołać następny, wszystkie one będą równorzędne. Takie założenie niesie za
sobą pewną implikację: gdy programista chce czekać na koniec jakiegoś wątku, to
musi podać jego identyfikator. Biblioteka sama tego nie odgadnie.
Dla wielu osób to może nie być dobra wiadomość, ponieważ to może
skomplikować programy, które bazują na więcej niż dwu wątkach. Niech to jednak
nas nie martwi, bowiem standard wątków w POSIX dostarcza wszelkie możliwe
narzędzia, jakie mogłyby się przydać w zarządzaniu wieloma wątkami. Właściwie
to brak hierarchii rodzic-potomek otwiera nową furtkę dla programistów. Na
przykład, jeśli powołamy wątek1, który to podzieli się i stworzy wątek2, to
wątek1 wcale nie musi czekać, aż wątek2 się zakończy. Funkcję pthread_join dla
wątek2 można wywołać w jakimkolwiek innym wątku. Gdy dany program ma bardzo
wiele wątków, można łatwo stworzyć listę do przechowywania "wątków, które
należy zakończyć", a następnie stworzyć wątek, który będzie za nas sprzątał
pozostałe wątki z tej listy. Dzięki temu można zminimalizować śmiecenie, jakie
może wyniknąć z działania naszego programu.
Pływanie synchroniczne
Czas przyjrzeć się przykładowi programu, który w swoim wyniku poda coś
nieoczekiwanego. Oto wątek2.c:
Listing 1.5: wątek2.c |
#include <pthread.h>
#include <stdlib.h>
#include <unistd.h>
#include <stdio.h>
int mojazmiennaglobalna=0;
void *MojaFunkcjaDlaWątku(void *arg) {
int i,j;
for ( i=0; i<20; i++ ) {
j=mojazmiennaglobalna;
j=j+1;
printf(".");
fflush(stdout);
sleep(1);
mojazmiennaglobalna=j;
}
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ątku.");
abort();
}
for ( i=0; i<20; i++) {
mojazmiennaglobalna=mojazmiennaglobalna+1;
printf("o");
fflush(stdout);
sleep(1);
}
if ( pthread_join ( mojwątek, NULL ) ) {
printf("błąd przy kończeniu wątku.");
abort();
}
printf("\nMoja zmienna globalna wynosi %d\n",mojazmiennaglobalna);
exit(0);
}
|
Zrozumienie działania wątek2.c
Ten program, podobnie jak poprzedni, tworzy nowy wątek. Zarówno ten wątek,
jak i wątek główny inkrementują zmienną "mojazmiennaglobalna"
dwudziestokrotnie. Jednak wynik działania tego programu jest dość
nieoczekiwany. Aby go skompilować, należy podać:
Listing 1.6: Kompilacja programu |
$ gcc wątek2.c -o wątek2 -lpthread
|
Następnie można go uruchomić poprzez podanie:
Listing 1.7: Uruchamianie programu |
$ ./wątek2
..o.o.o.o.oo.o.o.o.o.o.o.o.o.o..o.o.o.o.o
Moja zmienna globalna wynosi 21
|
Skoro "mojazmiennaglobalna" zaczyna od zera, a następnie oba wątki zwiększają
jej wartość o 20, to program powinien na koniec wypisać 40. Skoro jednak jest
21, to coś tu chyba jest nie tak. Ale co?
Kluczem do rozwiązania tego problemu jest przypisanie do zmiennej globalnej
wartości ze zmiennej lokalnej "j". Jest ona najpierw inkrementowana, następnie
wywoływana jest funkcja sleep(1) i dopiero wtedy ta wartość jest kopiowana z
powrotem do zmiennej globalnej. O to właśnie chodzi. Jeśli wątek główny zrobi
to samo dokładnie po tym, gdy wartość jest kopiowana, to zostanie ona
nadpisana, a w rezultacie wartości obu inkrementacji nie ulegają zsumowaniu.
Dlatego w wyniku działania programu jest 21 a nie 40.
Reasumując, ważne jest, aby być świadomym zależności podobnych do tej
wymienionej w powyższym przykładzie. Gdy w grę wchodzą wątki i nastąpi taka
pomyłka, to może się z tym wiązać utrata cennego czasu. No chyba, że ktoś pisze
artykuł o wątkach w POSIX rzecz jasna :). A zatem, co można zrobić, aby się
przed nimi zabezpieczyć?
Skoro problem powstaje przy kopiowaniu zmiennej globalnej do "j", po którym
następuje jednosekundowa pauza przed ponownym przypisaniem, można spróbować
uniknąć korzystania ze zmiennej pomocniczej i inkrementować zmienną globalną
bezpośrednio. I chociaż to rozwiązanie rozwiąże nasz problem w tym przypadku,
nie jest ono poprawne. Gdyby zamiast inkrementacji, na zmiennej globalnej była
wykonywana jakaś skomplikowana operacja matematyczna, program prawie na pewno
nie zachowa się poprawnie. Dlaczego?
Aby zrozumieć ten problem, trzeba pamiętać, że wątki obsługiwane są
współbieżnie. Nawet na systemach jednordzeniowych (czy też w tym wypadku
jednoprocesorowych), gdzie jądro zarządza przydziałem kwantu czasu procesora
aby zrealizować jednoczesne działanie, można z perspektywy programisty
wyobrazić sobie oba wątki wykonywane w tym samym czasie. Wątek2.c jest wadliwy,
ponieważ kod zawarty w "MojaFunkcjaDlaWątku()" zależy od tego czy ta zmienna
globalna będzie modyfikowana poza funkcją, podczas tej jednej sekundy, przed
którą następuje inkrementacja. Potrzebny jest zatem jakiś sposób, aby
przekazać drugiemu wątkowi "poczekaj, teraz ja wprowadzam zmiany do zmiennej
globalnej". O tym powiem w następnej części tego artykułu. A zatem do
zobaczenia!
2.
Źródła
-
Daniel o wątkach w POSIX Część druga
oraz część trzecia
-
Oficjalna dokumentacja:
Wątki
w Linuksie, autorstwa Sean'a Walton'a, KB7rfa
-
Wątki w POSIX -
poradnik
autorstwa Mark'a Hays'a z Uniwersytetu w Arizonie
-
Wprowadzenie do linuksowych
wątków w TCL, czyli zmiany, jakie należy wprowadzić w TCLu, aby była
możliwość korzystania z wątków
-
Inny poradnik
Wątki w
POSIX dla początkujących, autorstwa Tom'a Wagner'a i Don'a Towsley'a z
Wydziału Informatyki na Uniwersytecie w Massachusetts w Amherst
- Oczywiście zawsze pomocne są strony w man (man -k pthread)
-
FSU PThreads to
biblioteka języka C, która implementuje wątki POSIX dla systemów SunOS
4.1.x, Solaris 2.x, SCO UNIX, FreeBSD, Linuksa i pod DOS'em
-
Strona domowa dla referencji, pt.
Wątki w POSIX i DCE
pod Linuksem
-
Warto sprawdzić
Biblioteka LinuxThreads
-
Proolix to
prosty system operacyjny w standardzie POSIX dla układów i8086+, stale
ulepszany
-
Książka David'a R. Butenhof'a
Programowanie z obsługą wątków w POSIX, w której omawiane są, między
innymi, możliwe permutacje niekorzystania z muteksów
-
Książka W. Richard'a Stevens'a
UNIX Programowanie sieciowe: Sieciowe API: Gniazda oraz XTI, część
pierwsza
|
|