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. 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.
Listing 1.1: Kolejka.h |
/* kolejka.h
** Copyright 2000 Daniel Robbins, Gentoo Technologies, Inc.
** Author: Daniel Robbins
** Date: 16 Jun 2000
*/
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
** Copyright 2000 Daniel Robbins, Gentoo Technologies, Inc.
** Author: Daniel Robbins
** Date: 16 Jun 2000
**
** Ten zestaw funkcji do obsługi kolejki był projektowany z myślą
** o wątkach. Kod został przepisany tak, żeby było odwrotnie (po
** prostu typowy zestaw nudnych a szybkich funkcji dla obsługi kolejki)
** Po co? Ponieważ sensowniej jest utrzymywać wielowątkowość jako
** opcjonalny dodatek. Rozpatrzymy sytuację, w której chcemy dodać 5
** węzłów do kolejki. W wersji wielowątkowej każde wywołanie KolDodaj()
** powodowałoby blokowanie muteksu 5 razy. To sporo zbędnych operacji.
** Jednak po usunięciu podziału na wątki, operacja wywołująca blokuje
** muteks tylko na początku, dodaje 5 elementów, a następnie zwalnia.
** Działanie takie pozwala na optymalizację, która nie jest dostępna
** przy innym podejściu. Dzięki temu także, kod jest użyteczny dla
** aplikacji jednowątkowych.
**
** Możemy także łatwo wprowadzić podział na wątki w tej strukturze danych
** poprzez korzystanie z typu data_control, zdefiniowanego w control.c
** oraz control.h
*/
#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
** Copyright 2000 Daniel Robbins, Gentoo Technologies, Inc.
** Author: Daniel Robbins
** Date: 16 Jun 2000
**
** Te funkcje umożliwiają łatwe wprowadzenie podziału na wątki w jakiejkolwiek
** strukturze danych. Należy po prostu skojarzyć strukturę data_control
** ze strukturą danych (na przykład tworząc nową strukturę). Następnie
** można zwyczajnie blokować i zwalniać muteks lub wykonywać
** wait/signal/broadcast na zmiennej warunkowej w strukturze data_control
** zgodnie z zapotrzebowaniem.
**
** struktury data_control zawierają zmienną całkowitoliczbową zwaną "active".
** Służy ona do specyficznego modelu wielowątkowego, gdzie każdy wątek sprawdza
** stan tej zmiennej przy każdym blokowaniu muteksu. Jeśli wynosi ona 0, to
** wątki nie wykonują swoich funkcji, tylko zatrzymują się. Jeśli wynosi 1, to
** działają zwyczajnie. Toteż, dzięki ustawieniu "active" wartości 0, wątek
** zarządzający może łatwo poinformować inne wątki, żeby się zakończyły.
** control_activate() oraz control_deactivate() to funkcje, które również
** rozgłaszają zmienną warunkową z data_control, dzięki czemu wszystkie wątki
** zablokowane przy pthread_cond_wait() zostaną wybudzone, aby zauważyć zmianę
** i w rezultacie się zakończyć.
*/
#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;
}
|
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).
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" /* Kolejka "work_Kolejka" zawiera zadania dla poszczególnych wątków. */ struct work_Kolejka { data_control control; kolejka work; } wq; /* Dodałem numer zadania (ang. job number) do węzła. Zazwyczaj węzeł zawierałby dodatkowe dane do analizy. */ typedef struct work_Wezel { struct Wezel *nast; int jobnum; } wWezel; /* Kolejka "cleanup_Kolejka" zawiera identyfikatory wątków zatrzymanych. Zanim wątek ulegnie przyłączeniu, dodaje się do tej kolejki. Monitoruje ją wątek główny, który, gdy dostrzeże zmiany, zostanie wybudzony i usunie zakończony wątek. */ struct cleanup_Kolejka { data_control control; kolejka cleanup; } cq; /* Dodałem numerowanie wątków (pomocniczo, dla celów instruktażowych) oraz identyfikatory wątków do węzłów w kolejce sprzątania. Zostaje on przekazany nowym wątkom podczas ich tworzenia. Tuż przed zakończeniem wątku, węzeł ten jest przyłączany do kolejki sprzątającej (ang. cleanup queue). Wątek główny odpowiedzialny jest za monitorowanie i operacje czyszczenia po naszej grupie wątków zadaniowych (ang. working threads). */ 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); /* w poniższym kodzie czekamy, aż pojawi się nowy węzeł w kolejce do sprzątania. To pozwala na uniknięcie fałszywych wybudzeń. Nawet, jeśli wyjdziemy z pthread_cond_wait(), to nie zakładamy, że warunek, na który czekamy, jest spełniony. */ while (cq.cleanup.glowa==NULL) { pthread_cond_wait(&cq.control.warunek,&cq.control.muteks); } /* W tym momencie przetrzymujemy muteks, a w kolejce jest element wymagający przetwarzania. Najpierw odłączamy węzeł z kolejki. Następnie wywołujemy pthread_join() na zawartym w nim identyfikatorze wątku "TID". Gdy pthread_join() zwróci wartość, można stwierdzić, że po wątku jest posprzątane. Dopiero wtedy można zwolnić pamięć zaalokowaną dla węzła za pomocą funkcji free() i zdekrementować liczbę działających wątków. Ewentualnie należy czekać i powtórzyć całą operację. */ 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(); } |
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.
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ć.
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()).
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.
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!