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ęść trzecia
1.
Poprawianie efektywności za pomocą zmiennych warunkowych
Wyjaśnienie funkcjonowania zmiennych warunkowych
Poprzednią część tego artykułu
zakończyłem wspominając o pewnym problemie, a mianowicie, jak wątek może
radzić sobie z sytuacją, w której czeka, aż pewien warunek zwróci prawdę.
Mógłby nieustannie blokować i odblokowywać jakiś muteks, za każdym razem
dokonując sprawdzenia we współdzielonej strukturze czy nie pojawiła się
oczekiwana zmiana. Czynność taka jednak powoduje niepotrzebne zużycie zasobów,
zaś regularne sprawdzanie jest bardzo nieefektywne. Najlepszym rozwiązaniem
jest tutaj korzystanie z funkcji pthread_cond_wait().
Zrozumienie zasad działania pthread_cond_wait() jest bardzo ważne. Ta funkcja
jest sercem systemu sygnałów dla wątków w POSIX, bywa też najtwardszym orzechem
do zgryzienia w tej tematyce.
Na początek rozpatrzymy scenariusz, gdzie wątek blokuje muteks, aby sprawdzić,
czy pewna lista jest pusta. Jego zadaniem bowiem jest usunięcie
węzła (ang. node) z listy, chociaż gdy jest ona pusta, to nie może on wykonać
swojego zadania. Oto, co się więc dzieje.
Po zablokowaniu muteksu na swój użytek, nasz wątek wywoła
pthread_cond_wait(&mojwarunek,&mojmuteks), co jest raczej
skomplikowane, więc prześledzimy wszystkie operacje krok po kroku.
Pierwszą czynnością wykonywaną przez pthread_cond_wait() jest jednoczesne
odblokowanie muteksu "mojmuteks" (tak, żeby inne wątki mogły modyfikować
listę) oraz czekanie na warunek "mojwarunek" (dzięki czemu funkcja wybudzi
się, gdy zostanie to zasygnalizowane przez inny wątek). Następnie, przy
odblokowanym muteksie, inne wątki mogą modyfikować listę, na przykład przez
dodawanie do niej elementów.
pthread_cond_wait() jednak nie zwraca wartości w tym momencie. Odblokowanie
muteksu następuje natychmiast, ale oczekiwanie na warunek "mojwarunek" to
zazwyczaj operacja blokująca. Zatem nasz wątek zostanie uśpiony, przy czym nie
będzie zużywać cykli procesora dopóki nie zostanie wybudzony. A na to właśnie
czekamy. Nasz wątek pozostaje uśpiony w oczekiwaniu na pewien warunek i nie
wykonuje żadnego kosztownego sprawdzania. Z perspektywy wątku jest to
oczekiwanie na wartość zwróconą przez pthread_cond_wait().
Kontynuując wyjaśnienie, powiedzmy, że inny wątek, na przykład "wątek 2"
blokuje muteks i dodaje element do naszej listy. Zaraz po odblokowaniu
muteksu, "wątek 2" wywołuje funkcję pthread_cond_broadcast(&mojwarunek).
Dzięki temu wszystkie wątki oczekujące na spełnienie warunku "mojwarunek"
zostaną w tej chwili wybudzone. Oznacza to, że pierwszy wątek, który tkwi w
pthread_cond_wait(), będzie mógł wykonywać swoje operacje.
Zobaczmy teraz co dzieje się z pierwszym wątkiem. Po tym, jak "wątek 2" wywoła
pthread_cond_broadcast(&mojwarunek), czytelnik może stwierdzić, że
pthread_cond_wait() w pierwszym wątku natychmiast zwróci jakąś wartość. A
tak nie jest! Funkcja ta najpierw wykona jeszcze jedną operację, mianowicie
ponownie zablokuje muteks. Dopiero wtedy wątek numer 1 będzie mógł rozpocząć
działanie i sprawdzić czy na liście wystąpiły jakieś interesujące zmiany.
Przykład i wyjaśnienie
Listing 1.1: Kolejka.h |
/* kolejka.h
*/
typedef struct wezel {
struct wezel *nast;
} Wezel;
typedef struct kolejka {
wezel *glowa, *ogon;
} Kolejka;
void KolStworz(kolejka *mojpocz);
void KolDodaj(kolejka *mojpocz, wezel *mojwez);
Wezel *KolSprawdz(kolejka *mojpocz);
|
Listing 1.2: kolejka.c |
/* kolejka.c
*/
#include <stdio.h>
#include "kolejka.h"
void KolStworz(kolejka *mojpocz) {
mojpocz->glowa=NULL;
mojpocz->ogon=NULL;
}
void KolDodaj(kolejka *mojpocz,wezel *mojwezel) {
mojwezel->nast=NULL;
if (mojpocz->ogon!=NULL)
mojpocz->ogon->nast=mojwezel;
mojpocz->ogon=mojwezel;
if (mojpocz->glowa==NULL)
mojpocz->glowa=mojwezel;
}
Wezel *KolSprawdz(kolejka *mojpocz) {
//zacznij od poczatku
wezel *mojwezel;
mojwezel=mojpocz->glowa;
if (mojpocz->glowa!=NULL)
mojpocz->glowa=mojpocz->glowa->nast;
return mojwezel;
}
|
Listing 1.3: control.h |
#include <pthread.h>
typedef struct data_control {
pthread_mutex_t muteks;
pthread_cond_t warunek;
int active;
} data_control;
|
Listing 1.4: control.c |
/* control.c
*/
#include "control.h"
int control_init(data_control *mycontrol) {
int mojstatus;
if (pthread_mutex_init(&(mycontrol->mutex),NULL))
return 1;
if (pthread_cond_init(&(mycontrol->warunek),NULL))
return 1;
mycontrol->active=0;
return 0;
}
int control_destroy(data_control *mycontrol) {
int mojstatus;
if (pthread_cond_destroy(&(mycontrol->warunek)))
return 1;
if (pthread_mutex_destroy(&(mycontrol->warunek)))
return 1;
mycontrol->active=0;
return 0;
}
int control_activate(data_control *mycontrol) {
int mojstatus;
if (pthread_mutex_lock(&(mycontrol->muteks)))
return 0;
mycontrol->active=1;
pthread_mutex_unlock(&(mycontrol->muteks));
pthread_cond_broadcast(&(mycontrol->warunek));
return 1;
}
int control_deactivate(data_control *mycontrol) {
int mojstatus;
if (pthread_mutex_lock(&(mycontrol->muteks)))
return 0;
mycontrol->active=0;
pthread_mutex_unlock(&(mycontrol->muteks));
pthread_cond_broadcast(&(mycontrol->warunek));
return 1;
}
|
Czas na debugowanie
Jeszcze jeden plik, zanim przejdziemy do gwoździa programu. Oto:
dbug.h:
Listing 1.5: dbug.h |
#define dabort() \
{ printf("Rezygnacja przy linii nr %d w pliku %s\n",__LINE__,__FILE__); abort(); }
|
Korzystamy z tego kodu aby obsłużyć nieodwracalne błędy grupy wątków
wykonujących jedno określone zadanie (ang. work crew).
Kod grupy wątków
Oto kod dla grupy wątków:
Listing 1.6: workcrew.c |
#include <stdio.h>
#include <stdlib.h>
#include "control.h"
#include "kolejka.h"
#include "dbug.h"
/*
*/
struct work_Kolejka {
data_control control;
kolejka work;
} wq;
/*
*/
typedef struct work_Wezel {
struct Wezel *nast;
int jobnum;
} wWezel;
/*
*/
struct cleanup_Kolejka {
data_control control;
kolejka cleanup;
} cq;
/*
*/
typedef struct cleanup_Wezel {
struct wezel *nast;
int threadnum;
pthread_t tid;
} cWezel;
void *MojaFunkcjaDlaWatku(void *mojarg) {
wWezel *mywork;
cWezel *mojwezel;
mojwezel=(cWezel *) mojarg;
pthread_mutex_lock(&wq.control.muteks);
while (wq.control.active) {
while (wq.work.glowa==NULL && wq.control.active) {
pthread_cond_wait(&wq.control.warunek, &wq.control.muteks);
}
if (!wq.control.active)
break;
//mamy cos!
mywork=(wWezel *) KolSprawdz(&wq.work);
pthread_mutex_unlock(&wq.control.muteks);
//rozpocznij prace nad zadaniem
printf("Watek nr %d rozpoczyna zadanie nr %d\n",mojwezel->threadnum,mywork->jobnum);
free(mywork);
pthread_mutex_lock(&wq.control.muteks);
}
pthread_mutex_unlock(&wq.control.muteks);
pthread_mutex_lock(&cq.control.muteks);
KolDodaj(&cq.cleanup,(Wezel *) mojwezel);
pthread_mutex_unlock(&cq.control.muteks);
pthread_cond_signal(&cq.control.warunek);
printf("Konczenie watku nr %d...\n",mojwezel->threadnum);
return NULL;
}
#define NUM_WORKERS 4
int numthreads;
void koncz_watek(void) {
cWezel *ob_wezel;
printf("przylaczanie watkow...\n");
while (numthreads) {
pthread_mutex_lock(&cq.control.muteks);
/*
*/
while (cq.cleanup.glowa==NULL) {
pthread_cond_wait(&cq.control.warunek,&cq.control.muteks);
}
/*
*/
ob_wezel = (cWezel *) KolSprawdz(&cq.cleanup);
pthread_mutex_unlock(&cq.control.muteks);
pthread_join(ob_wezel->tid,NULL);
printf("Zakonczono watek nr %d\n",ob_wezel->threadnum);
free(ob_wezel);
numthreads--;
}
}
int stworz_watek(void) {
int x;
cWezel *ob_wezel;
for (x=0; x<NUM_WORKERS; x++) {
ob_wezel=malloc(sizeof(cWezel));
if (!ob_wezel)
return 1;
ob_wezel->threadnum=x;
if (pthread_create(&ob_wezel->tid, NULL, MojaFunkcjaDlaWatku, (void *) ob_wezel))
return 1;
printf("Stworzono watek nr %d\n",x);
numthreads++;
}
return 0;
}
void inicjalizuj_struktury(void) {
numthreads=0;
if (control_init(&wq.control))
dabort();
KolStworz(&wq.work);
if (control_init(&cq.control)) {
control_destroy(&wq.control);
dabort();
}
KolStworz(&wq.work);
control_activate(&wq.control);
}
void usun_struktury(void) {
control_destroy(&cq.control);
control_destroy(&wq.control);
}
int main(void) {
int x;
wWezel *mywork;
inicjalizuj_struktury();
/* TWORZENIE */
if (stworz_watek()) {
printf("Tworzenie nie powiodlo sie, sprzatam.\n");
koncz_watek();
dabort();
}
pthread_mutex_lock(&wq.control.muteks);
for (x=0; x<16000; x++) {
mywork=malloc(sizeof(wWezel));
if (!mywork) {
printf("Nie mozna wykonac funkcji malloc().\n");
break;
}
mywork->jobnum=x;
KolDodaj(&wq.work,(Wezel *) mywork);
}
pthread_mutex_unlock(&wq.control.muteks);
pthread_cond_broadcast(&wq.control.warunek);
printf("Spi...\n");
sleep(2);
printf("Deaktywacja kolejki z zadaniami...\n");
control_deactivate(&wq.control);
/* CLEANUP */
koncz_watek();
usun_struktury();
}
|
Przegląd kodu
Czas na szybki przegląd kodu. Pierwsza zdefiniowana struktura nazywa się
"wq" i zawiera data_control oraz głowę kolejki. Struktura data_control będzie
używana do zarządzania dostępem do całej kolejki, włączając w to jej węzły.
Następny krok to zdefiniowanie właściwych węzłów z zadaniami. Aby kod był
adekwatny do założeń tego przykładu i artykułu, wszystko, co jest tam zawarte,
to numer zadania.
Następnie tworzymy kolejkę sprzątania. Komentarze powinny wyjaśnić zasady jej
funkcjonowania. OK, na razie pominiemy funkcje MojaFunkcjaDlaWatku(),
koncz_watek(), stworz_watek() oraz inicjalizuj_struktury() i przeskoczymy do
main(). Pierwsze, co robimy, to inicjalizacja struktur, jak data_control i
wszystkie kolejki, w tym kolejkę z zadaniami.
Ciekawostka odnośnie sprzątania
Teraz czas na inicjalizację naszych wątków. Po przejrzeniu kodu funkcji
stworz_watek() można stwierdzić, że wygląda on dosyć zwyczajnie, poza jedną
sprawą. Warto zauważyć, że alokujemy węzeł do sprzątania, a także
inicjalizujemy jego komponenty: numer wątku "threadnum" oraz jego identyfikator
"TID". Przekazujemy ten węzeł do każdego, nowo utworzonego wątku jako argument
w funkcji pthread_create(). Dlaczego?
Robimy tak, ponieważ gdy wątek istnieje, przyłączy on swój węzeł do sprzątania
do odpowiedniej kolejki i zakończy swoje działanie. Wówczas wątek główny
wykryje zmianę w kolejce (przy pomocy zmiennej warunkowej) i usunie z niej ten
węzeł. Ponieważ przechowujemy tam "TID", będzie wiadomo dokładnie który wątek
zakończył swoje działanie, a zatem główny wątek może spokojnie wywołać
pthread_join(TID), finalizując zakończenie odpowiedniego wątku. Bez tego,
wszystkie wątki musiałyby być kończone wedle ściśle ustalonej kolejności, na
przykład takiej, w jakiej były tworzone. Takie rozwiązanie mogłoby wymusić
oczekiwanie na zakończenie jednego wątku, podczas gdy dziesięć innych
czekałoby bezczynnie na przyłączenie. Na tym przykładzie ewidentnie widać, jak
bardzo ten sposób przyśpiesza zamykanie setek wątków.
Tworzenie zadań
Gdy już mamy działające wątki z przypisanymi zadaniami (w
MojaFunkcjaDlaWatku(), zaraz do tego dojdziemy), to wątek główny rozpoczyna
dodawanie elementów do kolejki zadań. Najpierw blokuje muteks dla "wq", żeby
następnie zaalokować 16000 pakietów zadaniowych, dodając je do tej kolejki
jeden po drugim. Dalej pthread_cond_broadcast() jest wywoływana w celu
wybudzenia wszystkich wątków, żeby te mogły robić swoje. Następuje dwu
sekundowe zatrzymanie pracy głównego wątku za pomocą funkcji sleep(2), po czym
kolejka z zadaniami jest dezaktywowana. Pociąga to za sobą zakończenie wątków
z przydzielonymi zadaniami. Wówczas główny wątek wywołuje funkcję
koncz_watek(), aby po nich posprzątać.
MojaFunkcjaDlaWatku()
Czas przyjrzeć się MojaFunkcjaDlaWatku(), czyli funkcji przetwarzanej przez
każdy z grupy wątków zadaniowych. Gdy taki wątek zostaje stworzony, to
natychmiast blokuje muteks kolejki z zadaniami, bierze jeden węzeł i zaczyna
go przetwarzać. Jeśli nie ma dostępnych zadań, wywoływana jest funkcja
pthread_cond_wait(). Można dostrzec, że dzieje się to w niewielkiej pętli
while(), a to jest bardzo ważne. Po wybudzeniu za pomocą pthread_cond_wait()
nigdy nie powinno się zakładać, że dany warunek jest spełniony. Jest to bowiem
bardzo prawdopodobne, ale zawsze może się okazać inaczej. Pętla while()
spowoduje ponowne wywoływanie pthread_cond_wait() na wypadek, gdyby się
okazało, że jednak nasza lista jest nadal pusta.
Jeśli istnieje węzeł z zadaniem, to wypisywany jest jego numer, po czym
pamięć jest zwalniana i na tym zadanie się kończy. Prawdziwy program robiłby
coś bardziej zmyślnego. Po pętli while() muteks jest blokowany, żeby można
było sprawdzić stan zmiennej "active", a także upewnić się czy w kolejce
pojawiły się nowe węzły. Jak to wynika z tego kodu, jeśli pętla while()
natrafi na "wq.control.active" równe zeru, to zatrzyma się i kod odpowiedzialny
za sprzątanie zostanie wówczas wywołany.
Część kodu odpowiedzialnego za sprzątanie, znajdująca się poza wątkiem głównym
jest ciekawa. Najpierw odblokowana zostaje kolejka z zadaniami, ponieważ
pthread_cond_wait() zwracając wartość blokuje również muteks. Następnie
blokowana jest kolejka "cleanup", w celu dodania do niej węzła zawierającego
"TID" (który zostanie później wykorzystany przez wątek główny do sprzątania),
po czym muteks ten jest zwalniany. Wątki oczekujące na "cq" (kolejka cleanup,
nasza zmienna) są powiadamiane za pomocą
pthread_cond_signal(&cq.control.warunek), dzięki czemu wątek główny dowie
się, że pojawił się nowy węzeł do przetworzenia. Nie ma potrzeby używania
pthread_cond_broadcast() w tym momencie, ponieważ tylko jeden wątek oczekuje
na nowe elementy w "cq". Wątek zadaniowy wypisuje komunikat o swoim
zakończeniu, po czym ulega przyłączeniu, gdy tylko nastąpi pthread_join()
(czyli po wywołaniu koncz_watek()).
koncz_watek()
Prosty przykład użycia zmiennej warunkowej prezentuje funkcja koncz_watek().
Działa ona w pętli, dopóki istnieją wątki z zadaniami. Czeka na nowe węzły w
kolejce sprzątania. Jeśli pojawi się nowy węzeł, to zostaje on odłączony, a
kolejka jest zwalniana (żeby inne węzły mogły być dalej dodawane).
Identyfikator "TID" zawarty w nim posłuży do przyłączenia tego wątku, po czym
pamięć zostanie zwolniona a liczba wątków zdekrementowana.
Podsumowanie
To koniec serii "Wszystko o wątkach systemu POSIX". Mam nadzieję, że po jej
lekturze czytelnik będzie w stanie implementować obsługę wielu wątków w swoich
programach. Więcej informacji można znaleźć w sekcji
Źródła, gdzie została zawarta, między innymi,
paczka ze wszystkimi kodami źródłowymi (w oryginalnym wydaniu, przyp. tłum.)
programów w tym artykule. Do zobaczenia!
2.
Źródła
-
Paczka z kodami źródłowymi programów z tego artykułu.
-
Część pierwsza oraz
Część druga tego artykułu autorstwa Daniela.
-
Wspaniałym źródłem jest dokumentacja (man -k pthread)
-
Dokładne wyjaśnienie wątków w POSIX znajduje się w tej książce:
Programming
with POSIX Threads, by David R. Butenhof (Addison-Wesley,
1997). Jest to prawdopodobnie najlepsza książka o wątkach w POSIX.
-
Wątki są także omówione tu: UNIX
Network Programming - Networking APIs: Sockets and XTI, autorstwa
W. Richard'a Stevens'a (Prentice Hall, 1997). To klasyka, aczkolwiek nie
pokrywa takiej ilości materiału, co powyższa pozycja.
-
Warto zapoznać się z dokumentacją Sean'a Walton'a (KB7rfa) Linux
threads
-
Krok po kroku -
wątki w POSIX
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
-
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.
|
|