58
Inżynieria
oprogramowania
www.sdjournal.org
Software Developer’s Journal 07/2007
Programowanie aplikacji wielowątkowych
w języku C++ w oparciu o wzorce projektowe
W
ytwarzanie oprogramowania wielowątko-
wego obrosło legendą problemu bardzo
trudnego, co powoduje, że pisanie aplika-
cji wielowątkowych przyprawia o dreszcz grozy wie-
lu programistów. Jest to poniekąd prawda, lecz wyda-
je się, iż odpowiednie rozparcelowanie systemu oraz
usystematyzowanie pewnych pojęć może w znacz-
nym stopniu rozwiać takie obawy. Główna przewaga
programów wielowątkowych, uruchamianych na poje-
dynczym procesorze, nad programami jednowątkowy-
mi skupia się w obszarze optymalizacji dostępu do za-
sobów i nie daje sama w sobie dodatkowych korzyści,
a wręcz powoduje obniżenie wydajności systemu z ra-
cji potrzeby konsumpcji czasu procesora na przełącza-
nie kontekstu wątków, co nie występuje przy przetwa-
rzaniu jednowątkowym [Alex01]. Z drugiej strony opty-
malizowanie dostępu do zasobów, czy wytwarzanie
responsywnego interfejsu użytkownika jest dużo ła-
twiejsze i bardziej eleganckie, gdy dysponujemy moż-
liwością uruchomienia aplikacji w wielu wątkach prze-
twarzania, poniekąd wymuszając na nas taką a nie in-
ną decyzję odnośnie ilości wątków w systemie.
W systemie jednoprocesorowym odpowiedzial-
nością za zarządzanie wątkami obarczone jest jądro
systemu operacyjnego. Ponieważ często architektu-
ry prymitywnych procesorów nie zapewniają takiego
wsparcia dla systemu operacyjnego, wsparcie to za-
pewnia kompilator wstrzykując odpowiednie instruk-
cje w trakcie generowania kodu wykonywalnego. In-
strukcje te oznaczają miejsca, w których jeden wątek
może przełączyć się na inny. Sam proces przełącza-
nia wątków wymaga oczywiście strategii w ramach
której nastąpi decyzja o tym, który wątek powinien
otrzymać sterowanie, co samo w sobie nie jest zada-
niem trywialnym. Omówiona wyżej operacja wstrzyki-
wania instrukcji nazywana jest podziałem czasu (ang.
time slicing) procesora. Pozwala ona na emulację
wsparcia, które już bezpośrednio (czyt. sprzętowo)
zrealizowane jest w bardziej zaawansowanych archi-
tekturach procesorów.
Zarówno standard C jak i C++, są bardzo
oszczędne jeśli chodzi o określenie zachowania
się języka w aplikacji wielowątkowej, wyrażając się
w słowie kluczowym
volatile
i przerzucając niejako
na programistę zagadnienia związane z synchroni-
zacją takich aplikacji.
Daje to początkującemu programiście tak wielki
wachlarz możliwości, że bardzo łatwo może on po-
pełnić podstawowe błędy, które w późniejszym eta-
pie projektu mogą doprowadzić do zakleszczeń, wy-
ścigów, rozsynchonizowań i innych bardzo trudnych
do namierzenia i wyeliminowania błędów. Głównym
sprzymierzeńcem w walce z tym zjawiskiem okazują
się sprawdzone i dogłębnie przeanalizowane rozwią-
zania konstrukcyjne znane jako wielowątkowe wzor-
ce projektowe. Można napisać, że jednym z kluczo-
wych czynników decydujących o sukcesie projektu
programistycznego pisanego z użyciem wielowąt-
kowości jest zastosowanie odpowiednich konstrukcji
podstawowych oraz technik parcelacji systemu.
Pojęcia podstawowe
Słowo kluczowe
volatile
(pol. ulotny), podobnie jak
const
jest modyfikatorem typu, jednak w dziedzinie
jego zastosowania leżą zmienne, które mogą być do-
stępne z poziomu różnych wątków, różnych włókien
czy też mogą zostać zmodyfikowane w czasie obsłu-
gi przerwań. Słowo to zastosowane do zmiennej, in-
struuje kompilator o tym, że zmienna jest ulotna, czy-
li że jej wartość może być zmieniana w sposób in-
ny niż to wynika z drzewa wywołań danej procedu-
ry, a co za tym idzie, kompilator wyłącza na jej rzecz
wszelkiego rodzaju optymalizacje powodujące bufo-
rowanie-cacheowanie tej zmiennej, które to optyma-
lizacje mogłyby spowodować, iż jej wartość byłaby
widziana różnie z różnych kontekstów mających do
niej dostęp. Inaczej mówiąc, dla zmiennej zadeklaro-
wanej z modyfikatorem typu volatile, kompilator po-
winien zawsze zapewnić fizyczny odczyt jej wartości
z pamięci, niezależnie od kontekstu, w którym dany
odczyt ma miejsce. Dla przykładu rozważmy nastę-
pującą pętlę wykonywaną przez wybrany wątek:
flag=false;
while (!flag)
{
Sleep(1000); // sleeps for 1000 milliseconds
}
Inny wątek lub funkcja obsługi przerwania w pew-
nym momencie ustawia tą zmienną na wartość
true
,
co w zamierzeniu ma wymusić na pierwszym wątku
opuszczenie pętli
while
. Kompilator widząc powyż-
szy kod, nie wie jednak jaki jest zamysł programisty
i cel użycia tej zmiennej, więc chcąc zoptymalizować
kod wynikowy ruguje ją, zastępując powyższą pę-
tlę – pętlą nieskończoną, przez co zachowanie takiej
aplikacji jest w oczywisty sposób błędne. Wyjściem
Paweł Kapłański
Autor jest architektem wiodącym w Samsung Electro-
nics Poland R&D Center, gdzie zajmuje się m.in. syste-
mami wbudowanymi (ang. embedded).
Kontakt z autorem: pawel.kaplanski@wp.pl
Programowanie aplikacji wielowątkowych w C++
59
www.sdjournal.org
Software Developer’s Journal 07/2007
z tej sytuacji jest zastosowanie modyfikatora
volatile
do
zmiennej
flag
, co daje kompilatorowi wiedzę o charakterze
tej zmiennej. Bez słowa kluczowego
volatile
pisanie aplika-
cji wielowątkowych byłoby niemożliwe lub też kompilator byłby
pozbawiony możliwości zaawansowanych technik optymaliza-
cji kodu wynikowego.
Warto wspomnieć, że słowo kluczowe
volatile
może być sto-
sowane, podobnie jak słowo kluczowe
const
, zarówno do typów
prostych jak i złożonych. W przypadku typów złożonych podob-
nie jak kwalifikator
const
modyfikuje wszystkie typy składowych
obiektu złożonego. Użycie tego słowa kluczowego w stosunku do
typów złożonych daje zaskakujące wręcz możliwości podniesie-
nia jakości procesu wytwarzania aplikacji wielowątkowych w języ-
ku C++ o czym można przeczytać w pracy [Alex03]. Ignorowanie
natomiast słowa kluczowego
volatile
bez zrozumienia jego prze-
znaczenia może owocować bardzo dziwnymi i ciężkimi w detekcji
błędami, takimi jak różnica w działaniu programu wielowątkowe-
go w wersji developerskiej – z włączonymi mechanizmami debu-
gującymi, w stosunku do wersji finalnej (Release), w której to włą-
cza się całe spektrum optymalizacji kodu.
Wydawać by się mogło, że odpowiednie używanie
vola-
tile
daje nam możliwość zabezpieczenia się przed baga-
żem niebezpieczeństw sponsorowanych przez architekturę
procesora czy optymalizacje kompilatora. Niestety, progra-
mista aplikacji wielowątkowych, mając do czynienia z nowo-
czesnymi architekturami mikroprocesorów, musi zdawać so-
bie sprawę również z możliwością zachodzenia zjawiska, na-
zywanego: nieuporządkowanym dostępem do pamięci (ang.
memory out-of-ordering). Zjawisko to rozwiązywane jest za
pomocą mechanizmu (ang. memory-barier), które nakazu-
je procesorowi/procesorom wymuszenie uporządkowania do-
stępu do pamięci na granicy tejże bariery. Niestety język C jak
i C++ ze względu na brak standardu w kwestii wątków oraz
bardzo pobieżne potraktowanie tematu wielowątkowości nie
daje nam żadnego wsparcia powodując to, że aby użyć barier
pamięci musimy posłużyć się kodem asemblerowym. W prze-
ciwieństwie do słowa
volatile
, wysokopoziomowe koncepcje
Listing 1.
Singleton nie zabezpieczony przed wyścigami
template
<
class
T
>
class
Singleton
{
private
:
static
T
*
impl
;
public
:
static
T
&
instance
()
{
if
(
instance_
==
0
)
{
instance_
=
new
T
;
std
::
atexit
(&
Singleton
<
T
>::
release
);
}
return
*
instance_
;
}
…
Listing 2.
Singleton z sekcją krytyczną
#include
<boost/thread/mutex.hpp>
template
<
class
T
>
class
Singleton
{
private
:
struct
Instance
{
Instance
()
:
instance_
(
0
)
{}
T
*
instance_
;
boost
::
mutex
mutex_
;
}
;
static
Instance
impl
;
public
:
static
T
&
instance
()
{
// poczatek sekcji krytycznej
boost
::
mutex
::
scoped_lock
lock
(
impl
.
mutex_
);
if
(
impl
.
instance_
==
0
)
{
impl
.
instance_
=
new
T
;
std
::
atexit
(&
Singleton
<
T
>::
release
);
}
return
*
impl
.
instance_
;
}
// koniec sekcji krytycznej
}
;
Listing 3.
Wyjście z tej sytuacji opisuje wzorzec
Podwójnie Sprawdzonego Blokowania
Singleton
&
Singleton
::
Instance
()
{
if
(!
instance_
)
// 1
{
// 2
boost
::
mutex
::
scoped_lock
lock
(
mutex_
);
if
(!
instance_
)
// 4
{
instance_
=
new
Singleton
;
}
}
return
*
instance_
;
}
Rysunek 1.
Składowe wzorca Monitor
������
�������
��������������
��������������
���������
������
��������
�����
��������
���������
����
����
����
60
Inżynieria
oprogramowania
www.sdjournal.org
Software Developer’s Journal 07/2007
wielowątkowe takie jak Thread, Mutex, ScopedLocking idiom,
wzorzec Doble-Checked Locking czy obiekt Condition umożli-
wiają parcelację systemu. Jako, że biblioteka standardowa nie
udostępnia mechanizmów synchronizacyjnych, posłużymy się
w tym artykule biblioteką boost (http://www.boost.org). Wyko-
rzystamy obiekty
boost::mutex
,
boost::condition
i
boost::thre-
ad
oraz użyjemy oferowanych przez nią sprytnych wskaźników
z licznikiem referencji (
boost::shared _ ptr
).
Zagadnienia synchronizacyjne
Klasycznym problemem, z którym styka się każdy progra-
mista zajmujący się wytwarzaniem oprogramowania wielo-
wątkowego, jest problem szeregowania dostępu do zaso-
bów współdzielonych pomiędzy różnymi wątkami. Ten pro-
blem rozwiązywany jest zazwyczaj za pomocą koncepcji Sek-
cji Krytycznej (ang. Critical Section). Sekcje krytyczne to wy-
różnione zbiory procedur, takich że tylko jedna z nich może
być wykonywana w danej chwili. Jeśli jedna procedura z tego
zbioru jest właśnie wykonywana i inny wątek próbuje wykonać
dowolną procedurę z tego zbioru, wówczas musi on zaczekać,
aż pierwsza procedura zakończy swoje działanie [Abel96].
Sekcje krytyczne realizowane są zazwyczaj za pomocą
obiektu Mutex (ang. Mutual Exclusion), który to obiekt może być
tylko w dwóch rozłącznych stanach: zajęty (ang. aquire) lub zwol-
niony (ang. release). Pojedynczy wątek może blokować Mutex
dowolną ilość razy, lecz pozostałe wątki chcąc zająć Mutex mu-
Listing 5.
Operatory wyłuskiwania instancji
template
<
class
T
>
class
Singleton
{
...
T
*
operator
->()
{
return
&
instance
();
}
T
&
operator
*()
{
return
instance
();
}
...
}
;
Listing 6.
Element Kolejki Aktywacji
class
ActivationQueueNode
{
public
:
ActivationQueueNode
():
_prev
(
0
)
{}
static
void
*
operator
new
(
size_t
size
,
ActivationQueue
&
queue
);
static
void
operator
delete
(
void
*
,
ActivationQueue
&)
{
assert
(
false
);
}
protected
:
template
<
class
T
>
friend
void
destroy
(
T
*
p
,
ActivationQueue
&
a
);
friend
class
ActivationQueue
;
virtual
~
ActivationQueueNode
()
{}
ActivationQueueNode
*
_prev
;
}
;
Listing 7.
Kolejka Aktywacji
class
ActivationQueue
{
public
:
explicit
ActivationQueue
(
size_t
maxSize
=
0
);
void
setupEventSize
(
size_t
eventSize
);
ActivationQueueNode
*
peek
();
void
push
(
ActivationQueueNode
*
pev
);
private
:
void
*
alloc
(
size_t
size
);
void
free
(
void
*
memNode
);
}
;
template
<
class
T
>
void
destroy
(
T
*
p
,
ActivationQueue
&
queue
)
{
if
(
p
)
{
// explicit destructor call
p
->
~
T
();
queue
.
free
(
p
);
}
}
inline
void
*
ActivationQueueNode
::
operator
new
(
size_t
size
,
ActivationQueue
&
queue
)
{
return
queue
.
alloc
(
size
);
}
Listing 4.
Dobule-Checked Locking Pattern
template
<
class
T
>
class
Singleton
{
...
public
:
static
T
&
instance
()
{
if
(
impl
.
instance_
==
0
)
{
boost
::
mutex
::
scoped_lock
lock
(
impl
.
mutex_
);
if
(
impl
.
instance_
==
0
)
{
impl
.
instance_
=
new
T
;
std
::
atexit
(&
Singleton
<
T
>::
release
);
}
}
return
*
impl
.
instance_
;
}
}
;
62
Inżynieria
oprogramowania
www.sdjournal.org
Software Developer’s Journal 07/2007
szą poczekać aż wątek blokujący zwolni dany Mutex całkowicie
(tyle razy ile sam go zajął). Gdy to nastąpi, inny wątek może zająć
Mutex tym samym zabierając tą możliwość innym wątkom.
Para akcji zajmij-zwolnij, która odbywa się na obiekcie Mu-
teksa zazwyczaj ma zasięg lokalny i jest realizowana za po-
mocą paradygmatu blokowania zakresu (ang. scoped locking)
[Schm98], odbywającego się przy użyciu zmiennych auto-
matycznych, które w konstruktorze blokują, a w destruktorze
zwalniają wybrany Mutex.
Drugim w kolejności problemem, z którym w głównej mie-
rze spotykamy się w zagadnieniach z pogranicza baz danych,
jest problem czytelników i pisarzy. Dotyka on takich zasobów do
których sposoby dostępu możemy podzielić na dwie grupy, do-
stęp tylko do odczytu i dostęp w celu modyfikacji. Wątek chcą-
cy uzyskać dostęp tylko do odczytu nazywany jest czytelnikiem,
natomiast wątek chcący otrzymać dostęp w celu zmodyfikowa-
nia zawartości zasobu – pisarzem. Pisarz jest jednocześnie czy-
telnikiem. Czytelnicy nie powinni się wzajemnie blokować, lecz
pisarz powinien zaczekać aż czytelnicy przestaną czytać i do-
piero wówczas zająć zasób aby coś „napisać”. Po zajęciu zaso-
bu przez pisarza żaden czytelnik ani inny pisarz nie może zająć
tego zasobu jednocześnie, a co za tym idzie, taka sytuacja jest
analogiczna do Sekcji Krytycznej. Dopiero po zwolnieniu zaso-
bu przez pisarza, inny pisarz lub czytelnicy mogą zająć zasób.
Rysunek 2.
Działanie elementów składowych wzorca Monitor
�������
�������
�������
�����
���������
������
����
������
�����������
����������������
�������
������
�����������
�������
�������
�������
�������
����������������
����������������
Rysunek 3.
Składowe wzorca Active Object
�����
����������
����������
���������
����������
��������
����������������
������
�����
�������
������
����������������
���������
�������
����������
����������
���������
���������
���������
���������
������������
���������������
������������
�����������
����������
����������������������
�������������
63
Programowanie aplikacji wielowątkowych w C++
www.sdjournal.org
Software Developer’s Journal 07/2007
Okazuje się, że w problemie czytelników i pisarzy bardzo trudno
jest zapewnić „sprawiedliwość” dostępu pomiędzy czytelnikami
i pisarzami, tak aby nie dochodziło do zagłodzenia jednych
przez drugich. Problem czytelników i pisarzy rozwiązywany jest
zazwyczaj za pomocą obiektu RWMutex (ang. Read Write Mu-
tex), a sprawiedliwe rozwiązanie problemu czytelników i pisarzy,
które nie faworyzuje ani jednych ani drugich zostało opisane do-
piero na końcu lat dziewięćdziesiątych w pracy [Sanl99]. Roz-
wiązanie to jednak, z uwagi na ilość występujących w nim kon-
strukcji synchronizacyjnych, w „standardowych” przypadkach
nie jest implementowane, na rzecz prostszych i szybszych, acz
niesprawiedliwych rozwiązań.
Bezpieczny Singleton
Bardzo często zachodzi potrzeba eliminacji wyścigów w pro-
cesie inicjalizacji obiektu dostępnego przez różne wątki. Naj-
lepszym przykładem jest tutaj implementacja wielowątkowe-
go wzorca Singleton, która w optymalnej wersji jest realizowa-
na z użyciem wzorca Podwójnie Sprawdzanego Blokowania
(ang. Double-Checked Locking Pattern) [Schm96]. Wzorzec
ten rozwiązuje problem atomowej inicjalizacji obiektów, któ-
re istnieją przez cały czas życia systemu od momentu swo-
jego poczęcia. Wzorzec Singleton [Gamm95] opisuje z kolei
obiekt istniejący przez całe życie systemu w dokładnie jed-
nej kopii. Jego stworzenie jest zazwyczaj odraczane do cza-
su pierwszego użycia, jednak w aplikacjach wielowątkowych
może dochodzić, w trakcie takiego pierwszego odwołania, do
wyścigów między rywalizującymi ze sobą wątkami. Na Listin-
gu 1. ukazana jest taka niebezpieczna, ze względu na wielo-
wątkowość, implementacja wzorca Singleton w formie szablo-
nu. Warto tu zwrócić uwagę na użycie funkcji
std::atexit
, któ-
ra rejestruje funkcję niszczącą Singleton w momencie zamy-
kania systemu (kończenia wykonania programu).
Aby wyeliminować wyścigi w pierwszym podejściu zasto-
sujemy Sekcję Krytyczną opartą na Muteksie, wynikiem tego
zabiegu jest kod znajdujący się na Listingu 2.
Każdorazowy dostęp do instancji Singletona używa me-
chanizmów systemowych – w tym przypadku blokowanie
i zwalnianie Muteksa. Może to okazać się bardzo kosztowne
przy częstym dostępie do Singletonu.
Zasięg wyścigów dotyka tutaj tylko sprawdzenia czy obiekt
Singleton został, czy też nie został stworzony. Jeśli obiekt został
wcześniej stworzony to, po prostu, zwracamy ten obiekt. Sam
proces tworzenia jest już jednak chroniony sekcją krytyczną,
a zapewnione to jest poprzez ponowne sprawdzenie czy obiekt
nie został przypadkiem stworzony. Tylko w przypadku gdy bę-
dąc w sekcji krytycznej wątek, który ją zawłaszczył, stwierdza
że Singleton jeszcze nie jest stworzony, tylko wtedy ten sam wą-
tek go tworzy. W czasie gdy wybrany wątek tworzył Singleton,
pozostałe wątki mogły czekać na wpuszczenie do sekcji kry-
Listing 8.
Schemat użycia kolejki
void
push
()
{
boost
::
mutex
::
scoped_lock
lock
(
mutex_
);
ActivationQueueNode
*
node
=
new
(
queue_
)
ActivationQueueNode
;
// wypelnij element przed wstawieniem do kolejki
while
(
node
==
0
)
{
notFull
.
wait
(
lock
);
node
=
new
(
queue_
)
ActivationQueueNode
;
}
queue_
.
push
(
node
);
notEmpty
.
notify_all
();
}
void
pop
()
{
boost
::
mutex
::
scoped_lock
lock
(
mutex_
);
ActivationQueueNode
*
node
=
queue_
.
pop
();
while
(
node
==
0
)
{
notEmpty
.
wait
(
lock
);
node
=
queue_
.
pop
();
}
// przetworz element przed jego zwonieniem
destroy
(
node
,
queue
);
notFull
.
notify_all
();
}
Listing 9.
Abstrakcja Komendy
class
ICommandDelegateProxy
:
public
ActivationQueueNode
{
public
:
virtual
void
operator
()()
=
0
;
}
;
Listing 10.
Generyczna lista argumentów komendy
template
<
typename
Arg1
,
typename
Arg2
, ...
>
struct
ArgList
<
Arg1
,
Arg2
,
Arg3
,
Arg4
>
{
ArgList
(
const
Arg1
&
theArg1
,
const
Arg2
&
theArg2
,
...
)
:
arg1
(
theArg1
)
,
arg2
(
theArg2
)
,
...
{}
Arg1
arg1
;
Arg2
arg2
;
...
}
;
Listing 11.
Wywołanie funktora z listą argumentów
template
<
typename
Caller
,
typename
Arg1
,
typename
Arg2
,...
>
inline
void
CallWithArgs
(
Caller
&
caller
,
ArgList
<
Arg1
,
Arg2
,...
>&
args
)
{
caller
(
args
.
arg1
,
args
.
arg2
,...
);
}
64
Inżynieria
oprogramowania
www.sdjournal.org
Software Developer’s Journal 07/2007
tycznej, gdy tylko wątek-twórca opuści sekcję krytyczną, dowol-
ny wątek oczekujący sprawdzi, że Singleton został stworzony
i opuści natychmiast sekcję krytyczną.
Niestety okazuje się, że w rzeczywistości możemy, pomi-
mo prostoty tego wzorca, natknąć się na problemy związane
z barierami pamięci, o których wspomniałem we wstępie, dlate-
go należy być bardzo ostrożnym i rozważając użycie tego wzor-
ca dokładnie o nim poczytać w [Alex01] lub [Schm00].
Na zakończenie warto wzbogacić implementację Singleto-
na w odpowiednie operatory
->
oraz
*
. Umożliwi to używanie
Singletonów podobnie jak proxy-wskaźników, zaoszczędzając
sporo niepotrzebnej „pisaniny”.
Mutex + Condition = Semaphore
Inną gałęzią zagadnień są problemy komunikacji wielowąt-
kowej. Dotyczą one bardzo szeroko rozumianej wymiany in-
formacji pomiędzy wieloma wątkami działającymi jednocze-
śnie. Podstawowe rozwiązania tego problemu są budowane
na bazie Muteksu, obiektu Condition oraz kolejki komunika-
tów. Wzorce oparte na tych trzech mechanizmach podstawo-
wych mają w zamierzeniu zapewnić maksimum bezpieczeń-
stwa poprzez eliminacje, na ile to tylko możliwe, zakleszczeń
(ang. deadlock) oraz wyścigów (ang. race condition) już na po-
ziomie projektu systemu.
Obiekt Conditio, podobnie jak Mutex, jest współdzielony po-
między wątkami działającymi jednocześnie i umożliwia syn-
chronizację działań tych wątków. Obiekt Condition używany jest
w parze z pewnym obiektem typu Mutex. Mutex służy do wy-
tworzenia sekcji krytycznej w której ramach działa obiekt Condi-
tion i w związku z tym musi być zajęty w momencie, gdy docho-
dzi do oczekiwania na obiekcie Condition, co realizowane jest po-
przez podawanie do funkcji
wait
obiektu Condition odpowiednie-
go obiektu automatycznego typu Lock, realizującego blokowa-
nie zakresu w odniesieniu do obiektu Mutex. Po zawieszeniu się
na obiekcie Condition, wątek czeka aż inny wątek zanotyfiku-
je współdzielony obiekt Condition. Rozpoczynając oczekiwanie
na obiekcie Condition – Mutex zostaje automatycznie zwolnio-
ny, umożliwiając innym wątkom zajęcie sekcji krytycznej. Inny wą-
Rysunek 4.
Współdziałanie składowych wzorca ActiveObject
��������
�������
���������
�����������
������������
�����
�����������
���������
������������
�������������
��������
��������������
����
������
��������������������������������������
������
���
�������
���������������������
Listing 12.
Implementacja komendy
template
<
typename
Arg1
, ...
>
class
CommandDelegateProxyImpl
:
public
ICommandDelegateProxy
{
public
:
explicit
CommandDelegateProxyImpl
(
const
ArgList
<
Arg1
,
Arg2
,...
>&
args
,
boost
::
shared_ptr
<
Event
<
void
,
Arg1
,
Arg2
,...
>
>
eventGo
):
args_
(
args
)
,
eventGo
(
eventGo
)
{}
virtual
void
operator
()()
{
CallWithArgs
(*
eventGo_
,
args_
);
}
private
:
ArgList
<
Arg1
,
Arg2
,
Arg3
,
Arg4
,
Arg5
>
args_
;
boost
::
shared_ptr
<
Event
<
void
,
Arg1
,
Arg2
,
Arg3
,
Arg4
,
Arg5
>
>
eventGo_
;
}
;
Listing 13.
Algorytm „pobierz, przetwórz, zwolnij”
inline
bool
PeekAndProcess
(
ActivationQueue
*
queue
,
boost
::
mutex
::
scoped_lock
&
lock
,
boost
::
condition
&
notFull
)
{
if
(
ICommandDelegateProxy
*
cmd
=
static_cast
<
ICommandDelegateProxy
*>(
queue
->
peek
()))
{
{
AntyLock
antyLock
(
lock
);
(*
cmd
)();
}
destroy
(
cmd
,
*
queue
);
notFull
.
notify_all
();
return
true
;
}
return
false
;
}
65
Programowanie aplikacji wielowątkowych w C++
www.sdjournal.org
Software Developer’s Journal 07/2007
Listing 14.
Genryczne Proxy
tek działając w obrębie tej właśnie sekcji krytycznej ma możliwość
notyfikacji obiektu Conditio, co spowoduje ponowne zajęcie Mu-
teksa przez wątek oczekujący i zakończenie oczekiwania.
Za pomocą obiektu Condition i Mutex możemy zapisać do-
wolne zagadnienie synchronizacyjne, gdyż okazuje się, że są
one równoważne semaforom Dijkstry. Z kolei Condition i Mutex
może być zapisany przy użyciu semaforów, więc oba mechani-
zmy są równoważne. W praktyce realizuje się obiekt Condition
i Mutex jako twory wysokiego poziomu, pozostawiając na niskim
poziomie – bezpośrednio wspieranym przez system operacyjny
– konstrukcje semaforów, jako prymitywy podstawowe.
Wzorzec Monitor
Wzorzec Monitor [Schm00] nazywany również PassiveObject,
jest modelem obiektu, którego właściwością podstawową
jest to, że nie będzie uruchomiona na raz więcej niż jedna je-
go metoda. Można to zrealizować w prosty sposób, blokując
każdą z metod publicznych obiektu Monitor jednym wspólnym
Muteksem. Sam w sobie schemat działania Monitora rozsze-
rza jednak obiekt Condition, dodając do Monitora funkcjonal-
ności mediacyjne. Używając wyłącznie Muteksu nie byłoby
możliwe stworzenie modelu aktywnego oczekiwania na zaj-
ście pewnego zdarzenia. O zajściu tego zdarzenia można by
się dowiedzieć jedynie poprzez aktywne odpytywanie (ang.
pooling), odpowiednich metod. Obiekt Condition daje nam
możliwość optymalnego oczekiwania na zdarzenie.
Monitor jako wzorzec opisuje, w jaki sposób można dzielić po-
jedynczy obiekt pomiędzy wiele wątków chcących uzyskać do
niego dostęp poprzez wywołanie na nim metod lub oczekiwa-
nie na odpowiednie komunikaty płynące z tego obiektu. Z drugiej
strony można patrzeć na wzorzec Monitor jako na wielowątkowe-
go Mediatora, pozwalającego różnym obiektom na komunikację,
a obiekt zrealizowany za jego pomocą można postrzegać jak im-
plementację pewnego protokołu komunikacji miedzyobiektowej.
Wzorzec ActiveObject
Nazwa PassiveObject oddaje naturę wzorca Monitor, gdyż
Monitor nie zarządza wątkami, lecz raczej używa wątków
stworzonych na zewnątrz, w odróżnieniu od wzorca Active-
Object [Schm00], który rozwiązuje problem dostępu do za-
sobów, separując dostęp do swoich funkcjonalności za po-
mocą wielowątkowego interfejsu typu Proxy. Klient uzysku-
je dostęp poprzez wyżej wymienione Proxy, tworząc Sesję
(ang. Session) i poprzez Proxy, które generuje odpowied-
nią Komendę (ang. Command) przekazywaną do Kolejki
(ang. ActivationQueue) w której oczekuje ona na obsługę
przez wątek roboczy Schedulera wzorca ActiveObject. Sesja,
realizując wzorzec Monitor, umożliwia klientowi oczekiwanie
na zakończenie pracy nad wydaną Komendą.
template
<
typename
Arg1
,
typename
Arg2
,...
>
class
CommandProxy
{
public
:
CommandProxy
()
:
eventGo_
(
new
Event
<
void
,
Arg1
,
Arg2
,
Arg3
,
Arg4
,
Arg5
>)
{}
void
operator
+=
(
EventDelegate
<
void
,
Arg1
,
Arg2
,
Arg3
,
Arg4
,
Arg5
>
theMethod
)
{
(*
eventGo_
)+=
theMethod
;
}
void
operator
-=
(
EventDelegate
<
void
,
Arg1
,
Arg2
,
Arg3
,
Arg4
,
Arg5
>
theMethod
)
{
(*
eventGo_
)-=
theMethod
;
}
void
setSync
(
boost
::
shared_ptr
<
ActivationQueue
>
queue
,
boost
::
shared_ptr
<
boost
::
mutex
>
mutex
,
boost
::
shared_ptr
<
boost
::
condition
>
notEmpty
,
boost
::
shared_ptr
<
boost
::
condition
>
notFull
)
{
queue_
=
queue
;
notEmpty_
=
notEmpty
;
notFull_
=
notFull
;
mutex_
=
mutex
;
}
Listing 15.
Interfejs przykładowego ActiveObject
class
ActiveObject
{
public
:
ActiveObject
();
void
start
();
void
stop
();
CommandProxy
<
int
>
doSomething
;
private
:
boost
::
shared_ptr
<
ActiveObjectImpl
>
impl_
;
}
;
private
:
boost
::
shared_ptr
<
Event
<
void
,
Arg1
,
Arg2
,
Arg3
,
Arg4
,
Arg5
>
>
eventGo_
;
boost
::
shared_ptr
<
ActivationQueue
>
queue_
;
boost
::
shared_ptr
<
boost
::
mutex
>
mutex_
;
boost
::
shared_ptr
<
boost
::
condition
>
notEmpty_
;
boost
::
shared_ptr
<
boost
::
condition
>
notFull_
;
public
:
void
operator
()
(
Arg1
arg1
,
Arg2
arg2
, ...
)
{
{
boost
::
mutex
::
scoped_lock
lock
(*
mutex_
);
ActivationQueueNode
*
n
=
0
;
while
(
(
n
=
new
(*
queue_
)
CommandDelegateProxyImpl
<
Arg1
,
Arg2
,
Arg3
,
Arg4
,
Arg5
> (
ArgList
<
Arg1
,
Arg2
,
Arg3
,
Arg4
,
Arg5
>(
arg1
,
arg2
,
arg3
,
arg4
,
arg5
)
,
eventGo_
))==
0
)
notFull_
->
wait
(
lock
);
queue_
->
push
(
n
);
notEmpty_
->
notify_all
();
}
}
}
;
66
Inżynieria
oprogramowania
www.sdjournal.org
Software Developer’s Journal 07/2007
Z życia wziętym przykładem, w jaki możemy sobie wyobra-
żać mechanizmy działające wewnątrz ActiveObject'u jest re-
stauracja. Klient zamawiając posiłek, wydaje komendę kelnero-
wi, który odpowiada Komendzie, kelner zgłasza zamówienie do
kuchni, wpisując je na listę zamówień, do której wgląd ma szef
kuchni. Lista zamówień odpowiada tutaj Kolejce. Szef kuch-
ni realizuje zamówienia dzieląc je na odpowiednie etapy pracy.
Gdy danie jest gotowe kelner zanosi je do klienta. Może się tak
zdarzyć że zamawiając proste danie wybrany klient otrzyma je
szybciej od klienta, który zamówił danie bardziej skomplikowa-
ne, a zamówił je dużo wcześniej. Z punktu widzenia szefa kuch-
ni nie ma to znaczenia, jego praca polega na krojeniu, smaże-
niu, gotowaniu i innych małych czynnościach. Dzięki temu re-
stauracja może obsłużyć wielu klientów optymalizując dostęp
do cennego zasobu jakim jest szef kuchni poprzez zmaksyma-
lizowanie jego efektywnego czasu pracy (np. czekając na usma-
żenie frytek dla jednego klienta kucharz może przyrządzać sa-
łatkę dla innego). Z punktu widzenia klienta kelner przyjmu-
je zamówienie i dostarcza posiłek, nie wiedząc nawet tego co
w tym okresie czasu działo się w kuchni. Wzorce Monitor i Acti-
veObject wyodrębniają wiele podstawowych konstrukcji:
•
Mutex
– postawowy mechanizm umożliwiający tworzenie
Sekcji Krytycznych;
•
Condition
– podstawowy mechanizm komunikacyjny, skła-
dowa wzorca Monitor;
•
ActivationQueue
– kolejka aktywacji serwera;
•
Command
– komenda asynchroniczna zlecana przez wątek
kliencki do innego wątku;
•
CommandProxy
– proxy komendy asynchronicznej.
Dysponując powyższym zbiorem mechanizmów podstawo-
wych w formie generycznej otrzymujemy potężne narzędzie
pozwalające na parcelację systemów wielowątkowych. Mutex
oraz Condition jako konstrukcje podstawowe zapożyczyliśmy
z biblioteki BOOST (www.boost.org). Zajmijmy się kolejką ak-
tywacji. Można użyć dowolnej implementacji kolejki np. kolejki
z biblioteki standardowej, lecz poniższa implementacja będzie
miała taką właściwość, że alokacja pamięci będzie występo-
wała jedynie przy pierwszym użyciu. W tym celu przeciążymy
operatory
new
oraz
delete
obiektu
ActivationQueueNode
.
Kolejka komunikatów będzie jednocześnie alokatorem dla
swoich elementów i jako taka będzie podawana do przecią-
żonych operatorów. Kolejka w konstruktorze ma podawaną
maksymalną ilość elementów, gdy ta liczba jest równa zeru,
wówczas ograniczenie nie występuje. Sama kolejka wyposa-
żona jest w dwie podstawowe metody push i pop, w połącze-
Listing 16.
Szczegóły implementacji
struct
ActiveObjectImpl
{
ActiveObjectImpl
()
:
commandQueue_
(
new
ActivationQueue
(
10
))
,
commandQueueNotEmptyOrSomethingElse_
(
new
boost
::
condition
)
,
commandQueueNotFull_
(
new
boost
::
condition
)
,
mutex_
(
new
boost
::
mutex
)
{}
void
setupIface
(
ActiveObject
*
iface
)
{
iface
->
doSomething
.
setSync
(
commandQueue_
,
mutex_
,
commandQueueNotEmptyOrSomethingElse_
,
commandQueueNotFull_
);
iface
->
doSomething
+=
delegate_cast
<
Event
>(
this
,
&
ActiveObjectImpl
::
onDoSomething
);
}
void
onDoSomething
(
int
x
);
void
onExecute
();
boost
::
shared_ptr
<
ActivationQueue
>
commandQueue_
;
boost
::
shared_ptr
<
boost
::
condition
>
commandQueueNotEmptyOrSomethingElse_
;
boost
::
shared_ptr
<
boost
::
condition
>
commandQueueNotFull_
;
boost
::
shared_ptr
<
boost
::
mutex
>
mutex_
;
boost
::
shared_ptr
<
boost
::
thread
>
thread_
;
volatile
bool
terminate_
;
}
;
Listing 17.
Inicjalizacja, uruchomienie i zatrzymanie
ActiveObject
ActiveObject
::
ActiveObject
()
:
impl_
(
new
ActiveObjectImpl
())
{
impl_
->
setupIface
(
this
);
}
void
ActiveObject
::
start
()
{
impl_
->
thread_
=
boost
::
shared_ptr
<
boost
::
thread
>(
new
boost
::
thread
(
delegate_cast
<
Delegate
>(
impl_
.
get
()
,
&
ActiveObjectImpl
::
onExecute
)));
}
void
ActiveObject
::
stop
()
{
impl_
->
terminate_
=
true
;
impl_
->
commandQueueNotEmptyOrSomethingElse_
->
notify_all
();
if
(
impl_
->
thread_
!=
0
)
impl_
->
thread_
->
join
();
}
Listing 18.
Głowna pętla Schedulera
void
ActiveObjectImpl
::
onExecute
()
{
terminate_
=
false
;
boost
::
mutex
::
scoped_lock
lock
(*
mutex_
);
while
(!
terminate_
)
{
if
(
PeekAndProcess
(
commandQueue_
.
get
()
,
lock
,
*
commandQueueNotFull_
)
)
continue
;
commandQueueNotEmptyOrSomethingElse_
->
wait
(
lock
);
}
}
;
Programowanie aplikacji wielowątkowych w C++
67
www.sdjournal.org
Software Developer’s Journal 07/2007
niu z metodami alokacyjnymi daje komplet par funkcjonalno-
ści:
operator new + push
oraz
pop + operator delete
. Zarów-
no alokacja jak i pobranie kolejnego elementu z kolejki mogą
zwrócić pusty wskaźnik odpowiadający dwóm przypadkom:
gdy operator new zwróci pusty wskaźnik, oznacza to, że roz-
miar kolejki osiągnął rozmiar maksymalny i musimy poczekać
na zwolnienie jakiegoś elementu – występuje to tylko dla ko-
lejek z ograniczoną maksymalną ilością elementów, z drugiej
strony, gdy metoda pop zwróci pusty wskaźnik, oznacza to,
że kolejka jest pusta i musimy zaczekać na włożenie jakiegoś
elementu do tej kolejki.
Oba warunki wyznaczają użycie kolejki w kontekście wielo-
wątkowości. Potrzebujemy dwóch obiektów
Condition: notFull
– oznaczającego sytuację gdy kolejka nie jest „zapchana” oraz
notEmpty
– notyfikowanego gdy kolejka jest niepusta [Schm00].
Kolejka powinna być za to chroniona Sekcją Krytyczną realizo-
waną pojedynczym Muteksem. Scenariusz użycia kolejki mo-
żemy więc opisać w sposób opisany na Listingu 7.
Element Kolejki będzie bazową klasą dla wszystkich ele-
mentów, które w niej mogą być przechowywane – w tym przy-
padku będą to Komendy. Komenda sama w sobie powinna
pozwolić na swoją ewaluację. Aby to zapewnić, abstrakcyjna
komenda zostanie wyposażona w wirtualny operator wywoła-
nia funkcji, który musi być przeciążony w każdej Komendzie.
Ciało tego operatora będzie sercem komendy.
Komenda ewaluuje swoje argumenty, które muszą być do
niej podane z zewnątrz. Potrzebny jest więc generyczny me-
chanizm pozwalający na przechowanie listy argumentów. To
żądanie realizowane jest za pomocą szablonu
ArgList
.
Do ewaluacji listy argumentów w kontekście dowolnego
funktora stworzony zostanie generyczny szablon funkcji
Cal-
lWithArgs
.
Dysponując powyższymi narzędziami można przystąpić
do implementacji generycznej komendy, która będzie prze-
chowywana w Kolejce Aktywacji. Jako narzędzie użyty zosta-
nie również zbiór narzędzi opisany w artykule z kwietnia, doty-
czącego wzorca polimorfizmu zewnętrznego [Kapl07], do któ-
rego odsyłam czytelników.
Schemat pobrania Komendy z Kolejki Aktywacji, przetwo-
rzenia Komendy oraz zwolnienia Komendy realizuje funkcja
PeekAndProcess
.
Sama implementacja Proxy przedstawiona na Listingu
13. wymaga podania explicite podstawowych elementów syn-
chronizacyjnych oraz Kolejki Aktywacji używanej przez Proxy
w metodzie
setSync
. Schemat stworzenia Komendy, wypełnie-
nia jej listą argumentów oraz wstawienia jej do kolejki zaszyty
jest w implementacji wirtualnego operatora wywołania funkcji.
Dodatkowo operator opiekuje się odpowiednią obsługą podsta-
wowych elementów synchronizacyjnych (Mutex i Condition).
ActiveObject typu Hello-World
Otrzymane podstawowe mechanizmy, pomimo że wydają się
skomplikowane, pozwalają na bardzo prostą produkcję roz-
wiązań wielowątkowych. Rozpatrzmy przykładową implemen-
tację ActiveObjectu. W interfejsie publicznym (udostępnianym
Klientowi) użyjemy Proxy Komendy.
Całość implementacji naszego ActiveObjektu ukryjemy
przed Klientami poprzez realizację wzorca PIMPL (ang. Pri-
vate Implementation). ActiveObject musi zostać zainicjalizo-
wany, uruchomiony oraz zatrzymany. Główny wątek Active-
Objectu procesuje Kolejkę Aktywacji. Z punktu widzenia Klien-
ta ActiveObject interfejs analogiczny do zwykłego obiektu co
ukazuje piękno przyjętego rozwiązania.
Omówiono tu pokrótce elementy można pobrać w formie
biblioteki ze strony http://www.digital-advanced.com. Serdecz-
nie namawiam do eksperymentów. n
Listing 19.
Metoda wywoływana w kontekście wątka
ActiveObject
void
ActiveObjectImpl
::
onDoSomething
(
int
x
)
{
std
::
cout
<<
"hello:"
<<
x
<<
std
::
endl
;
}
Listing 20.
Wywołanie metody ActiveObject poprzez
Proxy
Singleton
_IMPL
(
ActiveObject
);
void
main
(
char
*[]
,
int
)
{
Singleton
<
ActiveObject
>
sr
;
sr
->
start
();
sr
->
doSomething
(
123
)
...
}
Bibliografia
• [Abel96] Herald Abelson, Gerald J.Sussman. Structure and In-
terpretation of Computer Programs. Struktura i Interpretacja
Programów Komputerowych. ISBN: 8320427126, Warszawa
2002, Wydawnictwo Naukowo-Techniczne;
• [Abra04] David Abrahams, Aleksey Gurtovoy. C++ Templa-
te Metaprogramming. ISBN: 0321227255: Addison-Wesley,
2004;
• [Alex01] Andrei Alexandrescu. Modern C++ Design: Ge-
neric Programming and Design Patterns Applied. ISBN:
0201704315, Boston, MA: Addison-Wesley, 2001;
• [Alex03] Andrei Alexandrescu. volatile – Multithreaded Pro-
grammer's Best Friend. Dr.Dobb’s. IV 2003. http://www.
ddj.com/dept/cpp/184403766;
• [Gamm95] E. Gamma, R. Helm, R. Johnson, and J. Vlissides.
Design Patterns: Elements of Reusable Object-Oriented So-
ftware; Reading, MA: Addison-Wesley, 1995;
• [Kapl07] Pawel Kaplanski. „Ewolucja wzorca polimorfizmu ze-
wnętrznego w języku C++”. Software Developer’s Jurnal – IV
2007;
• [Lipp96] Stanley B. Lippman. Inside the C++ Object Model.
ISBN: 0201834545: Addison-Wesley, 1996;
• [Sanl99] T. Sanli, V. Kulkarini, Optimal Admission to Reader-
Writer Systems with no Queuing, Operations Research Let-
ters 25:213–218, 1999;
• [Schm00] Douglas Schmidt, Michael Stal, Hans Rohnert,
Frank Buschmann. Pattern-Oriented Software Architecture,
Volume 2, Patterns for Concurrent and Networked Objects
ISBN: 9780471606956: Addison-Wesley, 2000;
• [Schm96] Douglas C. Schmidt. Reality check. C++ Report,
March – http://www.cs.wustl.edu/~schmidt/editorial-3.html;
• [Schm98] Douglas C. Schmidt 1998, 1999. Scoped Locking –
http://www.cs.wustl.edu/~schmidt/PDF/ScopedLocking.pdf.