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. |
1. Proste i elastyczne narzędzie do współdzielenia pamięci
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:
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.
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.
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.
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!