Professional Documents
Culture Documents
Wydawnictwo HELION
ul. Kościuszki 1c, 44-100 GLIWICE
tel. 32 231 22 19, 32 230 98 63
e-mail: helion@helion.pl
WWW: http://helion.pl (księgarnia internetowa, katalog książek)
Drogi Czytelniku!
Jeżeli chcesz ocenić tę książkę, zajrzyj pod adres
http://helion.pl/user/opinie/proch5_ebook
Możesz tam wpisać swoje uwagi, spostrzeżenia, recenzję.
ISBN: 978-83-246-8907-1
Printed in Poland.
Oceń książkę
Spis treści
Wstęp .............................................................................................. 9
Przedmowa .................................................................................... 11
Rozdział 1. Dla niecierpliwych: asynchroniczność i pętla równoległa .................. 13
Programowanie asynchroniczne. Operator await i modyfikator async
(nowość języka C# 5.0 i platformy .NET 4.5) .............................................................. 13
Klasa Parallel z biblioteki TPL (nowość platformy .NET 4.0) ....................................... 19
Równoległa pętla For ............................................................................................... 20
Przerywanie pętli ...................................................................................................... 22
Rozdział 2. Wątki ............................................................................................ 25
Monte Carlo .................................................................................................................... 25
Obliczenia bez użycia dodatkowych wątków ................................................................. 26
Przeniesienie obliczeń do osobnego wątku ..................................................................... 28
Wątki, procesy i domeny aplikacji ................................................................................. 30
Usypianie bieżącego wątku ............................................................................................ 31
Przerywanie działania wątku (Abort) ............................................................................. 32
Wstrzymywanie i wznawiane działania wątku ............................................................... 34
Wątki działające w tle ..................................................................................................... 35
Zmiana priorytetu wątku ................................................................................................ 36
Użycie wielu wątków i problemy z generatorem liczb pseudolosowych ........................ 36
Pamięć lokalna wątku i bezpieczeństwo wątku .............................................................. 39
Czekanie na ukończenie pracy wątku (Join) ................................................................... 40
Sekcje krytyczne (lock) .................................................................................................. 43
Przesyłanie danych do wątku .......................................................................................... 45
Pula wątków ................................................................................................................... 47
Jeszcze raz o sygnalizacji zakończenia pracy wątków .................................................... 50
Operacje atomowe .......................................................................................................... 51
Tworzenie wątków za pomocą System.Threading.
Timer i imitacja timera w wątku z wysokim priorytetem ............................................. 54
Zadania ........................................................................................................................... 57
Rozdział 3. Zmienne w aplikacjach wielowątkowych ......................................... 59
Atrybut ThreadStatic ...................................................................................................... 59
Opóźniona inicjacja i zmienne lokalne wątku ................................................................ 60
Volatile ........................................................................................................................... 64
Zadania ........................................................................................................................... 65
4 Programowanie równoległe i asynchroniczne w C# 5.0
Ostatni rozdział poświęcony jest natomiast bibliotece CUDAfy.NET; jest to jedyna w tej
książce technologia niebędącą dzieckiem firmy z Redmond. Umożliwia stosunkowo
łatwe, w warstwie składniowej, programowanie kart graficznych w języku C#. Warto
ją poznać, choćby po to, aby uświadomić sobie, z jakimi przyspieszeniami możemy mieć
do czynienia, jeżeli wykorzystamy moc kart graficznych. Należy jednak mieć świa-
domość, że w jednym rozdziale może się zmieścić jedynie wprowadzenie do tego te-
matu. W żadnym razie rozdziału tego nie można uznać za wyczerpujące omówienie
tematu. Celem tego rozdziału jest raczej zbudowanie pomostu między technologiami
.NET a CUDA, które czytelnicy mogą poznać po przeczytaniu jednej z wielu książek
poświęconych tej technologii.
Jak już wspomniałem, adresatem naszej książki są programiści .NET pragnący użyć
wielowątkowości w swoich programach. Książkę pisaliśmy również z myślą o stu-
dentach, którzy chcą poszerzyć swoje horyzonty. W tym drugim przypadku platforma
.NET jest naprawdę dobrym miejscem do prób wykorzystania programowania rów-
noległego.
Myślałem, że to kolejna słaba propozycja, ale jak zacząłem ją czytać, okazało się, że
autor ma niesamowite podejście do czytelników. Poczułem się tak, jakby usiadł obok
mnie, tłumaczył i pokazywał na przykładach, na początku banalnych, aby delikatnie
wprowadzić mnie w temat, a na końcu bardzo zaawansowanych, by pokazać możli-
wości technologii. Najważniejsze, że cały czas czułem wsparcie ze strony autora, któ-
remu udało się uniknąć syndromu zostawienie mnie samego z problemem.
Niedawno otrzymałem od niego pytanie, czy nie napisałbym przedmowy do jego nowej
książki o programowaniu równoległym. Na początku trochę się obawiałem, bo temat
ten jest jednym z trudniejszych, na jaki można napisać książkę o programowaniu.
Większość publikacji związanych z tym tematem, jakie znam, jest albo wybiórcza i przed-
stawia wybrane fragmenty całości, albo bardzo zaawansowana, przeznaczona dla
specjalistów z ogromną wiedzą teoretyczną. Jednak pomyślałem, że kto jak kto, ale
Jacek da radę.
które można łatwo przetestować, a dodatkowo nie zostawili czytelnika jedynie z su-
chym kodem, bo omówili szczegółowo każdy aspekt programowania współbieżnego.
Jeśli więc jesteś programistą poszukującym szybkiego, gruntownego, ale przede wszyst-
kim praktycznego poznania zagadnień związanych z współbieżnością, ta książka jest
dla Ciebie.
Programowanie asynchroniczne.
Operator await i modyfikator async
(nowość języka C# 5.0
i platformy .NET 4.5)
Język C# 5.0 wyposażony został w nowy operator await, ułatwiający synchronizację
dodatkowych zadań uruchomionych przez użytkownika. Poniżej zaprezentuję prosty
przykład jego użycia, który powinien wyjaśnić jego działanie. Działanie tego operatora
związane jest ściśle z biblioteką TPL (ang. Task Parallel Library) i jej sztandarową klasą
Task, które zostaną omówione w kolejnych rozdziałach. Jednak podobnie jak w przypad-
ku opisanej niżej pętli równoległej Parallel.For, tak i w przypadku operatora await
dogłębna znajomość biblioteki TPL nie jest konieczna.
14 Programowanie równoległe i asynchroniczne w C# 5.0
msgBox("button1_Click: Początek");
msgBox("Wynik: "+akcja("synchronicznie"));
msgBox("button1_Click: Koniec");
}
1
Alternatywnie moglibyśmy użyć instrukcji await Task.Delay(1000);, ale wówczas musielibyśmy
oznaczyć wyrażenie lambda jako async, a wtedy należałoby referencję do niego zapisać w zmiennej
typu Func<object, Task<long>>.
Rozdział 1. Dla niecierpliwych: asynchroniczność i pętla równoległa 15
{
msgBox("Akcja: Początek, argument: " + argument.ToString());
Thread.Sleep(1000); //opóźnienie
msgBox("Akcja: Koniec");
return DateTime.Now.Ticks;
};
Nie jest konieczne, aby instrukcja odczytania własności Result znajdowała się w tej
samej metodzie, co uruchomienie zadania — należy tylko do miejsca jej odczytania
przekazać referencję do zadania (w naszym przypadku zmienną typu Task<long>). Zwy-
kle referencję tę przekazuje się jako wartość zwracaną przez metodę uruchamiającą
zadanie. Przykład takiej metody widoczny jest na listingu 1.3. Jeżeli używamy angielskich
nazw metod, jest zwyczajem, aby metoda tworząca i uruchamiająca zadanie miały przy-
rostek ..Async.
Operator await zwraca parametr użyty w klasie parametrycznej Task<>. Zatem w przy-
padku zadania typu Task<long> będzie to zmienna typu long. Jeżeli użyta została wersja
nieparametryczna klasy Task, operator zwraca void i służy jedynie do synchronizacji
(nie przekazuje wyniku; nieparametryczna klasa Task nie ma także własności Result).
Ważna rzecz: samo użycie operatora await i modyfikatora async nie powoduje utwo-
rzenia nowych zadań lub wątków! Powoduje jedynie przekazanie na pewien czas ste-
rowania z metody, w której znajduje się operator await i oznaczonej modyfikatorem
async, do metody, która ją wywołała, i powrót w momencie ukończenia zadania, na
jakie czeka await. Koszt jest zatem niewielki i rozwiązanie to może być z powodzeniem
stosowane bez obawy o utratę wydajności. Ponadto, właśnie z uwagi na wydajność,
operator await sprawdza, czy w momencie, w którym dociera do niego sterowanie,
metoda asynchroniczna nie jest już zakończona. Jeżeli tak, praca kontynuowana jest syn-
chronicznie bez zbędnych skoków.
Metoda z modyfikatorem async może zwracać wartość void — tak jak w przedsta-
wionej wyżej metodzie zdarzeniowej button1_Click. Jednak w takim przypadku jej
działanie nie może być żaden sposób synchronizowane. Po uruchomieniu nie mamy
nad nią żadnej kontroli. Szczególnie nie można użyć operatora await ani metody Wait
klasy Task, aby poczekać na jej zakończenie. Żeby to było możliwe, metoda z mody-
fikatorem async musi zwracać referencję Task lub Task<>. Wówczas możliwe jest użycie
operatora await, za którym można zresztą ustawić dowolne wyrażenie o wartości Task
2
Aby taki efekt uzyskać bez operatora await, należałoby użyć konstrukcji opartej na funkcjach
zwrotnych (ang. callback). W efekcie kod stałby się raczej skomplikowany i przez to podatny na
błędy. Warto też zauważyć, że await nie jest prostym odpowiednikiem metody Task.Wait, która po
prostu zatrzymałaby bieżący wątek do momentu zakończenia zadania. W przypadku operatora await
nastąpi przekazanie sterowania do metody wywołującej i powrót w momencie zakończenia zadania.
18 Programowanie równoległe i asynchroniczne w C# 5.0
lub Task<> (zmienne i własności tego typu oraz metody lub wyrażenia lambda zwra-
cające wartość tego typu3). Przekazane zadanie umożliwia synchronizację. Ponadto
użycie wersji parametrycznej pozwala na zwrócenie wartości przekazywanej potem
przez operator await.
3
Prawdę mówiąc, należałoby to stwierdzenie uściślić, bo nie tylko zadania mogą być argumentem
operatora await, a każdy typ, który zwraca metodę GetAwaiter. Więcej informacji dostępnych jest
na stronie FAQ zespołu odpowiedzialnego za implementację mechanizmu async/await w platformie
.NET (http://blogs.msdn.com/b/pfxteam/archive/2012/04/12/10293335.aspx).
4
Warto zwrócić uwagę na przyrostek „Async”. W końcu jest to teraz metoda, która działa asynchronicznie,
choć żadnego zadania nie tworzy.
Rozdział 1. Dla niecierpliwych: asynchroniczność i pętla równoległa 19
Z kolei na listingu 1.10 widoczna jest pętla wykonująca owe obliczenia wraz z przy-
gotowaniem tablicy z wynikami. Wyniki te nie są jednak drukowane — tablica jest
zbyt duża, żeby to miało sens. Poniższy kod zawiera dwie zagnieżdżone pętle For.
Interesuje nas tylko wewnętrzna. Zadaniem zewnętrznej jest wielokrotne powtórzenie
obliczeń, co pozwoli nam bardziej wiarygodnie zmierzyć czas obliczeń. Pomiary te reali-
zujemy na bazie zliczania taktów procesora (System.Environment.TickCount).
//obliczenia sekwencyjne
int iloscPowtorzen = 100;
double[] wyniki = new double[tablica.Length];
int start = System.Environment.TickCount;
for(int powtorzenia = 0; powtorzenia<iloscPowtorzen;++powtorzenia)
for(int i=0;i<tablica.Length; ++i)
wyniki[i] = obliczenia(tablica[i]);
int stop = System.Environment.TickCount;
Console.WriteLine("Obliczenia sekwencyjne trwały "
+ (stop - start).ToString() + " ms.");
/*
//prezentacja wyników
Rozdział 1. Dla niecierpliwych: asynchroniczność i pętla równoległa 21
string s = "Wyniki:\n";
for(int i=0;i<tablica.Length;++i)
s += i + ". " + tablica[i] + " ?= " + wyniki[i] + "\n";
Console.WriteLine(s);
*/
}
Metoda Parallel.For jest dość intuicyjna w użyciu. Jej dwa pierwsze argumenty okre-
ślają zakres zmiany indeksu pętli. W naszym przypadku jest on równy [0,1000). Wo-
bec tego do metody podanej w trzecim argumencie przekazywane są liczby od 0 do 999.
Trzeci argument jest delegatem, do którego można przypisać metodę lub, jak w naszym
przypadku, wyrażenie lambda wywoływane w każdej iteracji pętli. Powinna się tam
zatem znaleźć zawartość oryginalnej pętli.
To, że tworzenie równoległej pętli Parallel.For jest, jak to mówią Anglicy, out of the
box, nie oznacza, że automatycznie unikamy wszystkich problemów, jakie w równole-
głych pętlach mogą powstać. Szczególnie należy zwrócić uwagę na sprawę podsta-
wową: między iteracjami pętli nie może być rekurencyjnej zależności, a więc kolejna
iteracja nie może zależeć od wartości jakieś zmiennej policzonej w poprzedniej iteracji.
Iteracje w równoległej pętli nie są przecież wykonywane w kolejności indeksów. Na-
leży także uważać na ukryte zależności rekurencyjne. Przykładem, w którym kryją się
takie zależności, jest choćby klasa Random.
Nie należy się spodziewać, że dzięki użyciu równoległej pętli nasze obliczenia przy-
spieszą tyle razy, ile rdzeni procesora mamy do dyspozycji. Tworzenie i usuwanie zadań
również zajmuje nieco czasu. Eksperymentując z rozmiarem tablicy i liczbą oblicza-
nych sinusów, można sprawdzić, że zrównoleglanie opłaca się tym bardziej, im dłuższe
są obliczenia wykonywane w ramach jednego zadania. Dla krótkich zadań użycie rów-
noległej pętli może wręcz wydłużyć całkowity czas obliczeń. W moich testach na kom-
puterze z jednym procesorem dwurdzeniowym czas obliczeń zmniejszył się do mniej
więcej ⅔ czasu obliczeń sekwencyjnych. Z kolei przy aż ośmiu rdzeniach czas obli-
czeń równoległych spadł tylko do nieco ponad ⅓.
22 Programowanie równoległe i asynchroniczne w C# 5.0
Przedstawione w tym rozdziale informacje o klasie Parallel i jej metodzie For na-
leży traktować jedynie jako zapowiedź rozdziału 7., w którym klasa ta zostanie
omówiona bardziej wyczerpująco.
Przerywanie pętli
Podobnie jak w klasycznej pętli for, również w jej równoległej wersji możemy w każdej
chwili przerwać działanie pętli. Służy do tego klasa ParallelLoopState, która może
być przekazana w dodatkowym argumencie metody wykonywanej w każdej iteracji.
Klasa ta udostępnia dwie ważne metody: Break i Stop. Różnią się one tym, że pierwsza
pozwala na wcześniejsze zakończenie bieżącej iteracji, a następne nie będą już uru-
chamiane, podczas gdy metoda Stop nie tylko natychmiast kończy bieżące zadanie,
ale również podnosi flagę IsStopped, która może być sprawdzona we wszystkich uru-
chomionych wcześniej iteracjach, co powinno być dla nich sygnałem do zakończenia
działania (jeżeli programista uwzględni to w ich kodzie). Na listingu 1.12 pokazuję
przykład, w którym pętla jest przerywana, jeżeli wylosowana zostanie liczba 0.
Console.WriteLine(
"Wylosowane liczby: " + s + "\n" +
"Liczba pasujących liczb: " + licznik + "\n" +
"Suma: " + suma + "\n" +
"Średnia: " + (suma / (double)licznik).ToString());
}
24 Programowanie równoległe i asynchroniczne w C# 5.0
Rozdział 2.
Wątki
Jacek Matulewski
Monte Carlo
Częstym przykładem wykorzystywanym podczas nauki programowania współbieżne-
go jest obliczanie liczby π metodą Monte Carlo. Jest to jeden z prostszych sposobów,
a przy tym chyba najmniej wydajny.
1
Warto jednak mieć świadomość, że wątki platformy .NET (reprezentowane przez klasę
System.Threading.Thread) nie są tożsame z wątkami systemu Windows — gospodarza platformy .NET.
26 Programowanie równoległe i asynchroniczne w C# 5.0
Wyobraźmy sobie kwadrat i wpisane w niego koło (rysunek 2.1). Promień koła r = 1,
a więc bok kwadratu a = 2. Pole kwadratu to oczywiście a2 = 22 = 4. Natomiast pole
koła to πr2 = π12 = π. Stosunek pola koła do pola kwadratu jest zatem równy π/4. Jeżeli
będziemy losowo umieszczać punkty wewnątrz kwadratu, co dla komputera nie jest
trudne, stosunek ilości punktów, które znalazły się wewnątrz koła, względem wszyst-
kich, które znalazły się w obrębie kwadratu, powinien być równy stosunkowi pól tych
figur. Równy powinien być zatem π/4. Innymi słowy, uzyskany w obliczeniach stosu-
nek ilości punktów wewnątrz koła do wszystkich punktów umieszczonych wewnątrz
kwadratu pomnożony przez 4 jest szukanym przez nas stosunkiem obwodu do średnicy
okręgu.
Rysunek 2.1.
Ilustracja metody
obliczania liczby π
namespace Watki
{
class Program
{
static Random r = new Random();
Sercem metody obliczPi jest pętla for, w której losowane są dwie liczby pseudolosowe
z przedziału od 0 do 1 — współrzędne x i y punktu. Dla uproszczenia ograniczamy się
jedynie do jednej, wyróżnionej na rysunku 2.1, ćwiartki kwadratu i jednej ćwiartki
wpisanego w kwadrat koła — stosunek pola ćwiartki kwadratu do ćwiartki koła nadal
równy jest π/4. Następnie sprawdzamy, czy suma kwadratów x i y jest mniejsza od
1 (x2 + y2 < 1), a więc czy punkt wyznaczony przez te współrzędne mieści się w jed-
nostkowym kole. Jeżeli tak, zwiększamy o 1 lokalną zmienną ilośćTrafień.
Aby przetestować naszą implementację metody Monte Carlo, wystarczy teraz nacisnąć
Ctrl+F5. Pojawi się konsola, a w niej po chwili zobaczymy wyniki obliczeń. Wyniki
powinny być bliskie wartości 3,14, a czas obliczeń rzędu sekundy (1000 milisekund)3.
Przeniesienie obliczeń
do osobnego wątku
W aplikacji konsolowej wykonywanie nawet większych obliczeń w głównym wątku
nie jest niczym złym. W aplikacjach okienkowych prowadzi to jednak do zablokowa-
nia interfejsu aplikacji. Dzieje się tak bo kontrolki są obsługiwane przez pętlę główną
2
Pomiar czasu umieszczony w metodzie Main nie dawałby prawidłowych wyników, gdyż metoda ta kończy
działanie (choć nie kończy go wątek główny) jeszcze przed zakończeniem metody uruchamianieObliczenPi.
Niżej omówię metody pozwalające na wstrzymanie działania wątku głównego do momentu zakończenia
wątku dodatkowego.
3
Czas obliczeń zależy — oczywiście — od konfiguracji komputera. Obliczenia nie powinny jednak trwać
bardzo długo.
Rozdział 2. Wątki 29
pracującą w tym samym wątku. Najprostszą rzeczą, jaką możemy zrobić, jest wobec
tego wykonanie obliczeń w osobnym wątku. Aby można było korzystać z klasy wątku
Thread, należy zadeklarować użycie przestrzeni nazw System.Threading, w której jest
ona zdefiniowana.
Zmodyfikujmy kod programu według wzoru z listingu 2.2. W metodzie Main utwórzmy
instancję klasy Thread. Argument jej konstruktora jest delegatem typu ThreadStart lub
ParametrizedThreadStart. Na razie skupmy się na tym pierwszym. Delegat ten przyj-
muje referencję do metody wykonującej właściwe obliczenia. Metoda ta musi być bez-
argumentowa i nie może zwracać wartości. Nieprzypadkowo nasza metoda uruchamianie
ObliczenPi ma właśnie taką sygnaturę.
using System.Threading;
namespace Watki
{
class Program
{
static Random r = new Random();
Rysunek 2.3. Pierwszy napis wyświetlany jest w głównym wątku, a wyniki obliczeń w dodatkowym.
Utworzenie dodatkowego wątku nie wpłynęło znacząco na czas działania aplikacji na moim względnie
nowym komputerze, ale na starszym wydłużyło go o średnio 100 milisekund
Dość swobodnie posługuję się terminem „wątek”. Warto jednak wyjaśnić, czym on
właściwie jest i czym różni się od procesu. Wątek to część kodu (np. metoda i metody
przez nią wywoływane), która wykonywana jest niezależnie i równocześnie z innymi
4
Możliwe stany wątku wymienione są w typie wyliczeniowym ThreadState. Są to m.in.: Unstarted
(po utworzeniu obiektu typu Thread, ale przed wywołaniem jego metody Start), Running (wykonywany),
Stopped (zatrzymany), Suspended (wstrzymany), Aborted (po wywołaniu metody Abort, ale przed
przejściem w stan Stopped), WaitSleepJoin (stan, w którym wykonywanie wątku jest wstrzymane
ze względu na synchronizację).
5
Autorem tego podrozdziału jest Dawid Borycki.
Rozdział 2. Wątki 31
Domeny aplikacji działają w ramach jednego procesu. Nie należy jednak ich mylić
z wątkami, gdyż wiele wątków może zostać utworzonych w trakcie wykonywania kodu
wywołanego w jednej domenie.
6
Klasa Thread posiada również metodę SpinWait (zob. też struktura SpinWait w przestrzeni nazw
System.Threading). W odróżnieniu od metody Sleep nie usypia ona wątku, a wykonuje podaną
w argumencie ilość iteracji pętli. Procesor jest w efekcie cały czas obciążony (inaczej niż w przypadku
metody Sleep). Dlatego poza testami nie należy tej metody używać.
32 Programowanie równoległe i asynchroniczne w C# 5.0
zatem w metodzie Main tuż po uruchomieniu dodatkowego wątku, ale przed polece-
niem wyświetlającym pytanie na ekranie, umieścimy instrukcję Thread.Sleep(2000);,
główny wątek zostanie uśpiony na dwie sekundy. W tym czasie obliczenia powinny się
skończyć i kolejność komunikatów widocznych w konsoli zmieni się (rysunek 2.4).
Rysunek 2.4.
Uśpienie głównego wątku
powoduje zmianę
kolejności napisów
widocznych w konsoli
{
Console.WriteLine("Wyjątek (" + exc.Message + ")");
}
}
Rysunek 2.5.
Efekt wywołania
metody Abort
przerywającej
działanie wątku
Warto przy tej okazji zwrócić uwagę na bardzo ważną kwestię. Na listingu 2.3 po-
kazuję metodę wykonywaną w ramach wątku, której cały kod umieszczony jest w kon-
strukcji try..catch. To jest właściwy sposób postępowania. Wyjątki powinny być
przechwytywane w obrębie metody wątku. Jeżeli tego nie zrobimy, ewentualnie zgło-
szone wyjątki pozostaną nieobsłużone. Nie pomoże otoczenie konstrukcją try..catch
miejsca uruchomienia wątku (metody Thread.Start). Wyjątki, które zostaną zgło-
szone w wątku, nie są przekazywane do innego wątku, nawet jeżeli to z niego nasz
wątek został uruchomiony.
argumentem przeciwko metodzie Abort jest to, że możliwe jest takie napisanie metody
wątku, aby jej przerwanie w taki sposób nie było możliwe — wystarczy wywołać w niej
metodę Thread.ResetAbort (http://www.albahari.com/threading/part4.aspx#_Aborting_
Threads). Jednak nie wydaje mi się, że są to argumenty, które wskazują, iż używanie
metody Abort jest jednoznacznie złą praktyką. Rzeczywiście jej użycie powinno na-
stępować wówczas, gdy chcemy definitywnie zamknąć wątek bez względu na stan, w ja-
kim się aktualnie znajduje. W przeciwnym wypadku należy użyć flagi (zmienna typu
bool z modyfikatorem volatile), której stan jest sprawdzany w wątku i w razie jej
podniesienia metoda wątku jest kończona. Jeszcze lepszym pomysłem jest użycie omó-
wionej w rozdziale 6. struktury CancellationToken (zadanie 2. na końcu rozdziału).
Zarzut, że możliwość anulowania wątku metodą ResetAbort dyskwalifikuje metodę
Abort, wydaje mi się jednak nie do końca trafiony. Wystarczy przecież powstrzymać
się od używania metody ResetAbort7.
Wstrzymywanie i wznawiane
działania wątku
Czasem konieczne jest chwilowe wstrzymanie wątku. Można do tego użyć metod
Suspend i Resume. Są one jednak oznaczone jako przestarzałe. Poza tym ich użycie jest
sensowne tylko wtedy, gdy wewnątrz wątku nie stosujemy innych metod synchroni-
zacji. W niektórych przypadkach te dwie metody wydają się najwygodniejsze. Pokazuję
to na poniższym listingu:
static void Main(string[] args)
{
Thread t = new Thread(uruchamianieObliczenPi);
t.Start();
Thread.Sleep(500);
t.Abort();
t.Suspend();
Console.WriteLine("Naciśnij Enter, aby kontynuować działanie wątku...");
Console.ReadLine();
t.Resume();
}
7
Więcej argumentów za metodą Thread.Abort i przeciw niej można znaleźć w dyskusji
tego problemu na stronach http://www.interact-sw.co.uk/iangblog/2004/11/12/cancellation
i http://blog.hexad.dk/2010/11/why-threadabort-is-not-evil.html.
Rozdział 2. Wątki 35
Rysunek 2.6.
W wydruku widoczny jest
komunikat informujący
o możliwości wznowienia
działania wątku. Pomiary
czasu w takiej sytuacji
mają niewielki sens
Rysunek 2.7.
Brak wyników;
aplikacja nie czeka na
zakończenie wątku tła
8
Większość metod klasy Thread ma odpowiedniki w funkcjach WinAPI dla wątków niezarządzanych
(http://msdn.microsoft.com/en-us/library/74169f59.aspx). Idea wątków tła jest jednak wprowadzona
dopiero na poziomie platformy .NET.
36 Programowanie równoległe i asynchroniczne w C# 5.0
Priorytet wątku nie ma nic wspólnego z tym, czy działa on w tle. Można zatem wątek
o najwyższym priorytecie oznaczyć jako wątek tła i zostanie on przerwany w razie za-
kończenia wątku głównego.
9
Wynik dzielenia 10000000L przez zmienną ileWatkow może nie być liczbą całkowitą, zatem suma
ilości prób we wszystkich wątkach może nie być równa 10000000L. To nie ma jednak wpływu na
dokładność obliczeń.
Rozdział 2. Wątki 37
Niestety, na razie nie ma sposobu, aby sprawdzić czas obliczeń. W metodzie uruchamianie
ObliczenPi obliczany byłby tylko czas z pojedynczego wątku, a metoda Main kończy
się jeszcze przed zakończeniem obliczeń.
Na pierwszy rzut oka używanie jednego wspólnego generatora liczb losowych przez
wszystkie wątki jest dobre — dzięki temu liczby losowane w poszczególnych wąt-
kach są różne. To się rzeczywiście sprawdza przy niewielkiej liczbie prób w jednej
serii, np. rzędu 104. Niestety, jeżeli tych prób jest więcej (rzędu 108), metoda Random.
NextDouble przestaje zwracać liczby losowe i wynik obliczeń oddala się od prawdziwej
38 Programowanie równoległe i asynchroniczne w C# 5.0
Rysunek 2.9.
Błędy spowodowane
niepoprawnym
działaniem klasy
Random dla wielu
wywołań metody
NextDouble
Rozdział 2. Wątki 39
Listing 2.8. Tworzenie osobnych instancji generatora liczb pseudolosowych w każdym wątku
static double obliczPi(long ilośćPrób)
{
Random r=new Random(Program.r.Next() & DateTime.Now.Millisecond);
double x, y;
long ilośćTrafień = 0;
for (int i = 0; i < ilośćPrób; ++i)
{
x = r.NextDouble();
y = r.NextDouble();
if (x * x + y * y < 1) ++ilośćTrafień;
//Console.WriteLine("x={0}, y={1}", x, y);
}
return 4.0 * ilośćTrafień / ilośćPrób;
}
Rysunek 2.10.
Każdy wątek oblicza
liczbę π niezależnie
od pozostałych
//tworzenie wątków
Rozdział 2. Wątki 41
...
W efekcie zwiększenia liczby prób jakość wyników powinna się poprawić, czyli błąd
obliczonej liczby powinien zmaleć (rysunek 2.11). Nie należy jednak spodziewać
się jakościowej poprawy. Używany przez nas algorytm obliczania liczby jest bardzo
mało wydajny. Używamy jej jedynie jako pretekstu do prezentacji obliczeń wielowąt-
kowych.
42 Programowanie równoległe i asynchroniczne w C# 5.0
Rysunek 2.11.
Wyniki z poszczególnych
wątków są uśredniane
Tabela 2.1. Nie należy porównywać liczb z różnych kolumn, gdyż procesory na różnych komputerach
użyte w testach nie były takie same
Komputer Komputer Komputer
z dwoma rdzeniami z czterema rdzeniami z ośmioma rdzeniami
Ilość wątków
(Intel Core 2 Duo (Intel Core 2 Quad (Intel Core i7 Q720,
E6550, 2.33GHz) Q6600, 2.4GHz) 1.6GHz)
1 50 s 49 s 53 s
2 26 s 25 s 27 s
3 26 s 16 s 20 s
4 26 s 12 s 17 s
5 26 s 15 s 15 s
6 27 s 16 s 14 s
7 29 s 16 s 13 s
8 27 s 13 s 12 s
9 30 s 13 s 13 s
10 27 s 14 s 13 s
Rozdział 2. Wątki 43
...
Często jako obiekt synchronizacji w instrukcji lock stosuje się referencję do zaso-
bu, do którego dostęp ma być synchronizowany. Powoduje to nieporozumienie po-
legające na przekonaniu, że w ten sposób dostęp do tego obiektu jest blokowany.
Nic takiego nie ma miejsca. Obiekt służy jedynie jako identyfikator sekcji krytycz-
nej. Dla przykładu na listingu 2.10, w momencie gdy jeden wątek jest w sekcji kry-
tycznej, którego obiektem synchronizacji jest generator liczb pseudolosowych r,
inny wątek może swobodnie z tego generatora korzystać.
Słowo kluczowe lock to alias do użycia metod Enter i Exit klasy Monitor, a konkretnie
do konstrukcji:
Monitor.Enter(Program.r);
try
{
Console.WriteLine("Synchronizacja: wątek nr {0} osiągnął półmetek",
Thread.CurrentThread.ManagedThreadId);
}
finally
{
Monitor.Exit(Program.r);
}
Z tą różnicą, że w przypadku użycia słowa kluczowego lock wyjątek zgłaszany przez klasę
Monitor jest „ukryty” i nie powoduje jego przechwycenia w metodzie uruchamianie-
ObliczenPi (co w naszym przypadku przedwcześnie kończyłoby działanie wątku).
10
Metody służące do przerywania działania wątków Thread.Abort i Thread.Interrupt mogą przełamać
działanie instrukcji lock i wymusić przyspieszone opuszczenie sekcji krytycznej przez wątek. To może
być potencjalnym źródłem błędów logicznych w aplikacjach wielowątkowych.
11
Zmienną można przyrównać do klucza do pokoju, w którym może przebywać tylko jedna osoba.
Rozdział 2. Wątki 45
Bardzo ważny jest wybór obiektu synchronizacji, czyli obiektu wskazywanego w ar-
gumencie słowa kluczowego lock. Przede wszystkim musi to być obiekt typu referen-
cyjnego, czyli instancja klasy. Najlepiej, aby był to obiekt o jak najmniejszym zakresie,
żeby uniknąć groźby niezamierzonych interferencji. Nie ma niczego dziwnego lub
niepożądanego w tworzeniu „pustych” obiektów tylko na potrzeby konkretnej blokady,
np. object obiektSynchronizacji = new object();. Tylko sekcje krytyczne z tym
samym obiektem synchronizacji wykluczają się nawzajem — tylko jeden wątek może
przebywać tylko w jednej z nich.
Oprócz zwykłych blokad tworzonych przy użyciu słowa kluczowego lock (względnie
bezpośrednio metodami Monitor.Enter i Monitor.Exit), możliwe jest również wy-
korzystanie klasy SpinLock i jej metod Enter i Exit. Klasa ta implementuje tzw.
wirującą blokadę polegającą na tym, że wątek oczekujący na wejście do zajętej
sekcji krytycznej wykonuje pętlę ciągle sprawdzającą możliwość wejścia. W ten
sposób procesor jest stale obciążony, choć wejście do sekcji krytycznej następuje
szybciej. Generalnie nie należy stosować tego typu blokad, chyba że blokady są
gęsto poutykane i przewidujemy, że czas oczekiwania będzie bardzo krótki.
Rysunek 2.12.
Parametrem przesyłanym
do wątku jest indeks
pętli, w której wątki są
tworzone
Pula wątków
Dokładność rzędu 10-5 dla 109 prób nie jest porażająca. Jeżeli nie rozważamy zmiany
algorytmu12, jedyną możliwością zwiększenia precyzji obliczeń jest zwiększenie ilości
prób. I to o wiele rzędów wielkości. Należy to zrobić, ustalając ilość wątków równą
ilości rdzeni procesora i zwiększając pracę wykonywaną przez każdy z wątków. My jed-
nak postąpimy inaczej — zwiększymy, i to znacznie, ilość wątków. Jak już podkre-
ślałem, w obliczeniach wykonywanych na CPU ilość wątków znacznie przekraczająca
ilość rdzeni nie ma większego sensu. Nie zyskamy nic na ich równoległym wykonywa-
niu, a w praktyce możemy tylko stracić na ciągłym przełączaniu wątków. Zresztą ilość
jednocześnie działających wątków jest ograniczona przez limit systemowy, część wąt-
ków będzie zatem czekała na utworzenie, aż zakończą się poprzednie. Zwiększanie
wątków nabierze jednak sensu, jeżeli będziemy nimi odpowiednio zarządzać — za-
miast starać się uruchomić je wszystkie jednocześnie, ustawimy je w kolejce, uru-
chamiając jednorazowo tylko tyle, ile procesory są w stanie wykonać.
12
Do wyboru mamy wiele algorytmów obliczania liczby . Tradycyjne to m.in. metoda Archimedesa
polegająca na ograniczaniu pola koła polami wpisanego i opisanego wielokąta foremnego i przedstawiona
na początku tego rozdziału metoda Wolfa (metoda Monte Carlo). Autorem bardziej nowoczesnych
metody jest ojciec analizy matematycznej Leonhard Euler. Pokazał on, że szereg 1 i
i 1
2
6 .
Stąd łatwo wyprowadzić wzór na , który dość łatwo zastosować nawet w obliczeniach
przeprowadzanych bez komputera. Implementację tego algorytmu przedstawiam na poniższym listingu:
ulong i=0, N=(ulong)1E8;
double S=0;
do
{
i++;
S+=(1.0/(1.0*i*i));
} while (i<N);
double pi=Math.Sqrt(6*S);
Warto zwrócić uwagę, że każda iteracja zależy jedynie od indeksu pętli, więc pętla może być wydajnie
zrównoleglona. W metodzie tej dokładność wyniku zwiększa się o rząd, gdy o rząd zwiększa się ilość
obliczonych wyrazów szeregu. Metoda Eulera ma jednak tylko znaczenie historyczne. Również na
obliczaniu sum szeregów bazują metody Leibniza, Wallisa, Sharpa i wiele innych (http://mathworld.
wolfram.com/PiFormulas.html). Metody używane w XX wieku to przede wszystkim algorytmy
iteracyjne, np. metoda Salamina i Brenta lub algorytmy braci Borwein. Wreszcie najnowszym
osiągnięciem są algorytmy spigot (kurkowe?), które pozwalają na liczenie dowolnych cyfr stałych
matematycznych, w tym liczby , bez znajomości poprzednich (algorytm BBP).
48 Programowanie równoległe i asynchroniczne w C# 5.0
Przy dużej ilości wątków najlepszym rozwiązaniem jest użycie gotowej klasy ThreadPool,
tworzącej pulę wątków, które czekają w kolejce na wykonanie. Ilość rzeczywiście
działających wątków może być przez nas ograniczona, ale i bez interwencji programisty
jest automatycznie dostosowywana do ilości dostępnych rdzeni procesorów. Nie należy
się zatem obawiać, że klasa ta będzie tworzyć setki wątków na komputerze z dwoma
procesorami. Będzie ich co najwyżej kilka, za to będą optymalnie wykorzystywać moc
dostępnych rdzeni. Co więcej, wątki, które zakończyły działanie, nie są usuwane, a przej-
mują kolejne zadania. W ten sposób minimalizowane są koszty obsługi wielu wątków.
Listing 2.12. Zmiany w metodzie Main wprowadzane w celu utworzenia puli wątków
static Random r = new Random();
const int ileWatkow = 100;
static double pi = 0;
//tworzenie wątków
WaitCallback metodaWatku = uruchamianieObliczenPi;
ThreadPool.SetMaxThreads(30, 100);
for (int i = 0; i < ileWatkow; ++i)
{
ThreadPool.QueueUserWorkItem(metodaWatku, i);
}
Jak widać na listingu 2.12, nie tworzymy instancji klasy ThreadPool. W aplikacji może
istnieć tylko jedna pula wątków, którą obsługuje klasa statyczna. Przy użyciu metody
SetMaxThreads ustalamy maksymalną ilość wątków. Użyta przeze mnie liczba 30 jest
o wiele większa niż ilość rdzeni procesorów dostępnych we współczesnych kompute-
rach, oznacza więc, że menedżer wątków ma tworzyć taką ilość wątków, jaka najle-
piej wykorzysta całą moc komputera. Oczywiście, w innym scenariuszu moglibyśmy
sprawdzić ilość rdzeni procesorów i np. wykorzystać tylko połowę z nich.
Rysunek 2.13.
Obliczenia zarządzane
przez pulę wątków
dla dziesięciu wątków
Jeżeli liczba ta spadnie do zera, możemy domyślać się, że wszystkie zadania zostały
wykonane. Wtedy obliczamy wynik i prezentujemy go użytkownikowi. Jest to ważne,
ponieważ wątek główny nie czeka przed zamknięciem aplikacji na wątki tworzone
przez pulę wątków (podobnie jak w przypadku wątków tła).
Jak widzimy na rysunku 2.13, ilość wątków nie jest równa ilości rdzeni. W moim kom-
puterze z czterema rdzeniami CPU menedżer po pewnym czasie utworzył dodatkowy,
piąty wątek. Śledząc numery wątków, można też sprawdzić, że wątki, które zakończyły
działanie, przejmują nowe uruchomienia zakolejkowanych metod.
50 Programowanie równoległe i asynchroniczne w C# 5.0
//tworzenie wątków
ThreadPool.SetMaxThreads(30, 100);
WaitCallback metodaWatku = uruchamianieObliczenPi;
for (int i = 0; i < ileWatkow; ++i)
{
ewht[i] = new EventWaitHandle(false, EventResetMode.AutoReset);
ThreadPool.QueueUserWorkItem(metodaWatku, i);
}
...
13
Idea jest podobna do metody Thread.Join, której używaliśmy wcześniej. Z tym, że zastosowanie
EventWaitHandle nie ogranicza się do powiadamiania o zakończeniu działania wątku — tu możemy
wywołać metodę Set w dowolnym momencie.
Rozdział 2. Wątki 51
Operacje atomowe
Wątki w naszym przykładzie są niezależne poza jednym momentem — dodawa-
niem cząstkowych wyników do zmiennej statycznej Program.pi. Wykonywana jest
ona wprawdzie tylko raz w ciągu dość długiego działania wątku, wobec czego szansa
na to, aby dwa wątki próbowały jednocześnie zmieniać tę zmienną, jest minimalna,
ale a prori istnieje. Naturalnym postępowaniem w takiej sytuacji jest użycie w metodzie
uruchamianieObliczenPi słowa kluczowego lock, co spowoduje, że operacja zwiększania
wartości zmiennej Program.pi w poszczególnych wątkach znajdzie się w sekcji
krytycznej wykonywanej tylko przez jeden wątek jednocześnie:
lock (r) { Program.pi += pi; }
Użyję jednak tego miejsca jako pretekstu do przedstawienia koncepcji operacji ato-
mowych. Operacje atomowe są to operacje, które z punktu widzenia systemu opera-
cyjnego są niepodzielne, wykonywane przez procesor „na raz”, choć niekoniecznie
52 Programowanie równoległe i asynchroniczne w C# 5.0
Listing 2.14. Modyfikacje programu niezbędne, aby możliwe było użycie operacji atomowych
class Program
{
static Random r = new Random();
const int ileWatkow = 100;
static double pi = 0;
const long ilośćPróbWWątku = 10000000L;
static long całkowitaIlośćTrafień = 0L;
//tworzenie wątków
ThreadPool.SetMaxThreads(30, 100);
WaitCallback metodaWatku = uruchamianieObliczenPi;
for (int i = 0; i < ileWatkow; ++i)
{
ewht[i] = new EventWaitHandle(false, EventResetMode.AutoReset);
ThreadPool.QueueUserWorkItem(metodaWatku, i);
}
{
x = r.NextDouble();
y = r.NextDouble();
if (x * x + y * y < 1) ++ilośćTrafień;
//Console.WriteLine("x={0}, y={1}", x, y);
}
return ilośćTrafień;
}
Należy pamiętać, że metody klasy Interlocked nie tworzą sekcji krytycznych, a za-
tem nie współdziałają z operatorem lock z innego wątku.
54 Programowanie równoległe i asynchroniczne w C# 5.0
14
Oprócz tej implementacji timera w platformie .NET jest jeszcze klasa System.Timers.Timer, o której
wspomnę, kontrolka Timer z biblioteki Windows Forms oraz klasa System.Windows.DispatcherTimer.
Rozdział 2. Wątki 55
W części metody Main, w której wyświetlamy końcowe wyniki, można zwolnić timer
poleceniami:
timer.Change(-1, System.Threading.Timeout.Infinite);
timer.Dispose();
Warto zwrócić uwagę, że obiekt timer jest zwykłym obiektem platformy .NET, zatem,
choć do działania korzysta z systemowych obiektów jądra, to aby pozostawał uży-
teczny na poziomie platformy .NET, musimy stale przechowywać referencję do niego.
Inaczej może być usunięty przez odśmiecacz i związana z nim metoda przestanie
być uruchamiana.
Jeżeli teraz uruchomimy projekt, komunikaty o postępie obliczeń zaczną się pokazywać
(rysunek 2.14). Jednak nie będą się pojawiały regularnie co sekundę, a o wiele rzadziej
(chyba że czytelnicy dysponują superkomputerem lub zmniejszyli ilość prób wykonywa-
nych w każdym wątku). Powodem jest to, że w naszej aplikacji pula wątków (ThreadPool)
i bez timera wykorzystuje wszystkie dostępne rdzenie procesorów. Tworzone przez
timer wątki muszą wobec tego czekać na zakończenie tych, które już działają, i uru-
chamiane są poza kolejką dopiero, gdy jakiś rdzeń się zwolni (dlatego metody timera
powinny być z założenia krótkie). Właśnie dlatego komunikaty timera pojawiają się zaw-
sze tuż po informacji o zakończeniu kolejnego wątku, bez względu na długość podanych
w timerze interwałów.
Rysunek 2.14.
Użycie klasy
System.Threading.Timer
Niewiele zmieni także użycie timera z przestrzeni System.Timers (listing 2.17). Również
on korzysta z puli wątków, zatem i w tym przypadku komunikaty będą pojawiały się
dopiero po zakończeniu jednego z działających wątków z puli wątków. To samo dotyczy
klasy BackgroundWorker czy metod BeginInvoke kontrolek z biblioteki Windows Forms
(rozdział 5.). One również korzystają z puli wątków.
=>
{
Console.WriteLine("Ilość prób: " +
Interlocked.Read(ref całkowitaIlośćPrób).ToString() +
"/" + (ileWatkow * ilośćPróbWWątku).ToString());
});
timer.Start();
...
timer.Stop();
timer.Dispose();
Co wobec tego możemy zrobić? Chyba najprostszym wyjściem jest utworzenie nie-
zależnego wątku z wysokim priorytetem, który w nieskończonej pętli będzie wyświe-
tlał komunikat i następnie usypiał na sekundę (listing 2.18). Przerwanie pętli umożliwi
obsługa wyjątku ThreadAbortException, wystarczy zatem po zakończeniu obliczeń wy-
wołać na jego rzecz metodę Abort. Prostszym rozwiązaniem jest wprawdzie oznaczenie
tego wątku jako wątku tła — zostałby automatycznie przerwany po zakończeniu metody
Main — jednak zależało mi, żeby w ostatnim komunikacie pokazać, że wszystkie próby
zostały przeprowadzone.
...
watekAlaTimer.Abort();
Rozdział 2. Wątki 57
Zadania
1. Zmodyfikuj program do obliczania przybliżenia liczby w taki sposób,
aby wprowadzić do niego flagę pozwalającą na wstrzymanie i wznowienie
wszystkich wątków, w których przeprowadzane są obliczenia. Jak zmienić
program, aby możliwe było wstrzymywanie każdego wątku osobno?
2. W programie służącym do obliczania przybliżenia liczby użyj klasy
CancellationTokenSource i struktury CancellationToken do przerwania
działania wszystkich wątków jednocześnie (rozdział 6.). Sprawdź zarówno
zgłaszanie wyjątków typu OperationCanceledException metodą CancellationToken.
ThrowIfCancellationRequested w razie wywołania metody Cancellation
TokenSource.Cancel (rozwiązanie podobne do działania metody Thread.Abort),
jak i instrukcję warunkową sprawdzającą, czy metoda ta została wywołana
(własność IsCancellationRequested).
3. Utwórz instancję poniższej klasy, a następnie uruchom jej metodę w trzech
wątkach. Oczywiście, powinny być wykonane jednocześnie. Następnie wskaż
klasę bazową ContextBoundObject i dodaj atrybut Synchronization. Sprawdź,
że teraz metody, pomimo tworzenia wątków, wykonywane są sekwencyjnie.
public class Klasa
{
public void Metoda()
{
Console.WriteLine("Początek: " + Thread.CurrentThread.ManagedThreadId);
Thread.Sleep(1000);
Console.WriteLine("Koniec: " + Thread.CurrentThread.ManagedThreadId);
}
}
Zmienne zdefiniowane wewnątrz kodu wykonywanego przez wątek są lokalne dla tego
wątku. Jeżeli kod jest wykonywany przez wiele wątków, zmienne takie w każdym z tych
wątków są niezależne od siebie. Natomiast zmienne zdefiniowane poza wątkami są
współdzielone przez wątki. Każdy z wątków może zmienić ich wartość i zmiana ta jest
widoczna w pozostałych wątkach.
Atrybut ThreadStatic
W kodzie widocznym na listingu 3.1 tworzymy statyczną zmienną licznik typu cał-
kowitego int. W poszczególnych wątkach wartość licznika jest zwiększana, a następnie
wyświetlana w konsoli. Wartość licznika w kolejnych wątkach jest coraz większa (co
niekoniecznie odpowiada kolejności wyświetlania wartości). Co jednak należy zrobić
w sytuacji, kiedy chcemy, aby każdy wątek miał własny licznik, który nadal definiujemy
globalnie? Możemy — oczywiście — zdefiniować tablicę liczników indeksowanych
przesłanym do wątków parametrem. Łatwiej jednak użyć atrybutu ThreadStatic, który
działa na statyczne zmienne i powiela je dla każdego wątku. W efekcie w poniższym
przykładzie na wydruku zobaczymy, że wartości licznika w każdym wątku są równe 1
(rysunek 3.1). Mechanizm ten wykorzystamy w kolejnych rozdziałach do rozdzielenia
generatora liczb pseudolosowych, który nie działa dobrze, gdy używany jest przez
wiele wątków.
Rysunek 3.1.
U góry efekt działania
wątku na zmiennej
licznik wspólnej dla
wielu wątków. U dołu
— po rozdzieleniu jej
za pomocą atrybutu
ThreadStatic
Powielenie instancji licznika dla każdego wątku obowiązuje także wątek główny. Jeżeli
po wywołaniu metody Console.ReadLine umieścimy polecenie wyświetlające wartość
zmiennej licznik, zobaczymy, że jest ona równa 0. W wątku głównym wartość licznika
nie została zmodyfikowana. Jeżeli dodatkowo w pierwszej linii metody Main, tj. jeszcze
przed utworzeniem wątków, nadamy tej zmiennej jakąś wartość, będzie ona wyświe-
tlona na końcu, ale nie zostanie przejęta przez wątki, które widzą „świeżą” zmienną
zainicjowaną wartością domyślną, czyli zerem. Nie pomoże nawet podanie wartości
początkowej w linii deklaracji zmiennej. Również ta wartość uwzględniona będzie je-
dynie w wątku głównym.
Opóźniona inicjacja
i zmienne lokalne wątku
W .NET 4.0, w przestrzeni System pojawiła się nowa klasa o nazwie Lazy. Imple-
mentuje ona wzorzec nazywany potocznie leniwą inicjacją, a bardziej formalnie —
inicjacją z opóźnieniem. We wzorcu tym zmienna opakowywana typem Lazy nie jest
rzeczywiście inicjowana, aż do momentu jej pierwszego użycia. Można to wykorzystać,
by uniknąć tworzenia obiektu typu referencyjnego „na zapas”, w przypadku gdy o jego
użyciu decyduje warunek sprawdzany dopiero podczas działania programu. Oszczę-
Rozdział 3. Zmienne w aplikacjach wielowątkowych 61
dzimy wtedy pamięć i czas procesora. Użycie nowego „wrappera” jest bardzo proste.
Pokazuję to na listingu 3.2. Efekt widoczny jest na rysunku 3.2.
Rysunek 3.2.
Opóźniona inicjacja
W pierwszej linii jako argumentu konstruktora klasy Lazy używam funkcji zapisanej
za pomocą wyrażenia lambda i zwracającej wartość 1. Jest to prosta funkcja, która zo-
stanie zastosowana do zainicjowania zmiennej dopiero przy pierwszej próbie odczy-
tania jej wartości. Powyższy przykład jest — oczywiście — bardzo prosty, ale jego
zadaniem jest tylko prezentacja idei „leniwej inicjacji”. Prawdę mówiąc, lepiej byłoby,
gdyby leniwym typem była klasa, a nie struktura. Wówczas argumentem konstruktora
powinna być funkcja-fabryka tworząca instancje owej klasy. Na listingu 3.3 prezentuję
to na przykładzie przycisku w aplikacji Windows Forms.
Rysunek 3.3.
Efekt użycia typu Lazy<>
przy wielu wątkach
Problem pojawi się, jeżeli zechcemy ustawić przed tak zadeklarowaną zmienną atrybut
ThreadStatic, tj. użyć zmiennej zadeklarowanej jako:
[ThreadStatic]
static Lazy<int> li = new Lazy<int>(() => Thread.CurrentThread.ManagedThreadId);
Zmienna li będzie miała w metodzie wątku wartość null. Jej inicjację należałoby
przenieść do wyrażenia lambda wykonywanego w wątku, a to mija się z celem. Sytu-
ację ratuje nowy typ platformy .NET 4.0, czyli ThreadLocal, który łączy możliwość
późnej inicjacji właściwą dla typu Lazy<> z działaniem atrybutu ThreadStatic. W efek-
cie, jeżeli zadeklarujemy zmienną li w następujący sposób:
Rozdział 3. Zmienne w aplikacjach wielowątkowych 63
static ThreadLocal<int> li =
new ThreadLocal<int>(() => Thread.CurrentThread.ManagedThreadId);
każdy wątek będzie miał swoją instancję zmiennej, która będzie inicjowana w tych
wątkach. Świadczą o tym ich wartości widoczne na rysunku 3.4.
Rysunek 3.4.
Efekt użycia typu
ThreadLocal<>
Volatile
Słowo kluczowe volatile jest modyfikatorem zmiennej wyłączającym optymalizację
w poleceniach, które się do niej odnoszą. Nie wszystkich, a tych, które mogłyby być
potencjalnie niebezpieczne w scenariuszu aplikacji wielowątkowej. Odczytywanie tak
oznaczonej zmiennej odbywa się za pomocą metody statycznej System.Threading.
Thread.VolatileRead, a zapis — System.Threading.Thread.VolatileWrite. W efekcie
w skompilowanym kodzie pośrednim i kodzie wykonywanym przez procesor zmienne
są modyfikowane i odczytywane dokładnie w tym miejscu, które wynika z kodu C#
— w ramach optymalizacji nie jest przeprowadzane żadne buforowanie czy przesta-
wianie instrukcji (blokowane są zarówno optymalizacje kompilatora C#, jak i kompi-
latora JIT). Oznacza to też, że po zmianie wartości takiej zmiennej nowa wartość bę-
dzie widoczna we wszystkich wątkach.
Thread.VolatileRead zwraca wartość zmiennej zapisaną jako ostatnią przez dowolny
wątek za pomocą Thread.VolatileWrite (przez dowolny procesor) — żadna modyfi-
kacja nie jest możliwa do momentu zwrócenia wartości przez tę metodę. Użyta przez te
metody bariera pamięci (metoda Thread.MemoryBarrier), która pilnuje, aby wszystkie
buforowane operacje (nie tylko na tej zmiennej) zostały wykonane, może być bardzo
kosztowna — warto rozważyć w zamian użycie lock lub operacji atomowych z klasy
Interlocked. Słowa kluczowego volatile powinniśmy raczej unikać.
Należy podkreślić, że modyfikator volatile nie powoduje, że zmienna staje się bez-
pieczna w operacjach przeprowadzanych w wielu wątkach — do tego konieczne są
operacje atomowe lub synchronizacja za pomocą sekcji krytycznej. Wyjątkiem są
zmienne typu bool, w przypadku których użycie słowa volatile zapewnia atomowość
operacji, przez co zmienne takie świetnie nadają się na flagi sygnalizujące zdarzenia
między wątkami.
Do czego poza tym można słowa volatile używać? Załóżmy, że mamy zmienną, która
może być modyfikowana w jednym wątku, a odczytywana w drugim. Zadeklarowanie
jej jako volatile zapewni, że zmiana w pierwszym wątku realizowana jest bez opóźnień
czy przekłamań, a w efekcie, że nowa wartość widoczna będzie natychmiast w drugim
wątku. Gdybyśmy zmiennej nie oznaczyli jako volatile, kompilator mógłby buforować
i kumulować wielokrotne zmiany zmiennej, co sprawiłoby, że drugi wątek widziałby
zmiany z opóźnieniem i nie po wszystkich operacjach modyfikacji przeprowadzanych
przez pierwszy wątek. Oczywiście, problem ten można by rozwiązać, korzystając z sek-
cji krytycznych utworzonych za pomocą słowa kluczowego lock (jeżeli jeden wątek
tylko modyfikuje, a drugi tylko czyta), ale użycie modyfikatora volatile w tym akurat
przypadku będzie mniej kosztowne.
W niektórych przypadkach użycie słowa kluczowego volatile nie jest możliwe (nie
ustawimy go np. w deklaracji tablic), nie ma go także w języku Visual Basic. Wówczas
możemy jednak użyć bezpośrednio metod klasy System.Threading.Volatile.
Ważne uwagi na temat volatile, jego działania i błędów w MSDN można znaleźć
w materiałach Joego Albahariego dostępnych pod adresem http://www.albahari.com/
threading/part4.aspx#_The_volatile_keyword.
Rozdział 3. Zmienne w aplikacjach wielowątkowych 65
Zadania
1. Przygotuj klasę, której metoda Next będzie zwracać całkowitą liczbę
pseudolosową typu int. Sprawdzaj numery wątków, z których następują
wywołania tej metody, i dla nowych twórz osobną dla tego wątku instancję
klasy Random wykorzystywaną do generowania liczb pseudolosowych.
Następnie użyj do tego atrybutu ThreadStatic.
2. Napisz aplikację Windows Forms, która w zależności od decyzji użytkownika
będzie tworzyła tysiąc przycisków lub tysiąc pól opcji wyświetlanych na formie.
Użyj leniwej inicjacji, aby zminimalizować koszty.
66 Programowanie równoległe i asynchroniczne w C# 5.0
Rozdział 4.
Więcej o synchronizacji
wątków. Blokady i sygnały
Jacek Matulewski
W tabelach 4.1 oraz 4.2 (na stronie 68, 84) zestawione zostały metody dostępne
w platformie .NET, a służące do synchronizacji wątków (por. http://www.albahari.com/
threading/part2.aspx). W tabeli 4.1 znajdziemy klasy służące do realizacji blokad,
a w tabeli 4.2 — klasy do przekazywania sygnałów między wątkami.
68 Programowanie równoległe i asynchroniczne w C# 5.0
1
Źródło: strona Joego Albahariego Threading in C# (http://www.albahari.com/threading/part2.aspx),
zmierzony dla procesora Intel Core i7 860.
Rozdział 4. Więcej o synchronizacji wątków. Blokady i sygnały 69
śniejsze posilenie się, a to jest niemożliwe. Mamy zatem typową sytuację zakleszcze-
nia angażującą większą ilość wątków. Z innym, może mniej wydumanym przykładem
zakleszczenia spotkał się każdy kierowca — jest to sytuacja, w której do skrzyżowania
równorzędnego jednocześnie podjeżdżają z każdej strony cztery samochody. Kodeks
prawa drogowego nie rozstrzyga, kto ma wówczas jechać jako pierwszy i jeżeli żaden
z kierowców nie zbierze się na odwagę, żeby ruszyć, samochody mogą stać w nieskoń-
czoność.
class PoleceniePrzelewu
{
public Konto KontoPłatnika;
public Konto KontoOdbiorcy;
public decimal Kwota;
}
WaitCallback transakcja =
(object parametr) =>
{
PoleceniePrzelewu poleceniePrzelewu = parametr as PoleceniePrzelewu;
if (poleceniePrzelewu == null) throw new
ArgumentNullException("Brak polecenia przelewu");
else Konto.Przelew(poleceniePrzelewu.KontoPłatnika,
poleceniePrzelewu.KontoOdbiorcy, poleceniePrzelewu.Kwota);
};
ThreadPool.QueueUserWorkItem(transakcja, new PoleceniePrzelewu {
KontoPłatnika = konto1, KontoOdbiorcy = konto2, Kwota = 50 });
ThreadPool.QueueUserWorkItem(transakcja, new PoleceniePrzelewu {
KontoPłatnika = konto1, KontoOdbiorcy = konto2, Kwota = 10 });
Rysunek 4.1.
Dwa przelewy
z konta 1 na konto 2
Problem pojawi się, gdy jednocześnie pojawią się operacje przelewu z konta 1 na
konto 2 i z konta 2 na konto 1:
ThreadPool.QueueUserWorkItem(transakcja, new PoleceniePrzelewu { KontoPłatnika =
konto1, KontoOdbiorcy = konto2, Kwota = 50 });
ThreadPool.QueueUserWorkItem(transakcja, new PoleceniePrzelewu { KontoPłatnika =
konto2, KontoOdbiorcy = konto1, Kwota = 10 });
using System.Threading;
namespace CzytelnicyPisarze
{
class Program
{
static Random r = new Random();
const int ileElementow = 10;
static int[] tablica = new int[ileElementow];
ThreadStart akcjaPisarza =
() =>
{
Thread.Sleep(r.Next(maksymalnaPrzerwaMiedzyModyfikacjami));
//opóźnienie
while (true)
{
try
{
Console.WriteLine("Przygotowania do modyfikacji
elementu (wątek nr " + Thread.CurrentThread.
ManagedThreadId + ")");
int indeks = r.Next(ileElementow);
modyfikujElement(indeks);
}
catch (ThreadAbortException)
{
Console.WriteLine("Wątek pisarza " +
Thread.CurrentThread.ManagedThreadId + " kończy pracę");
}
}
};
ThreadStart akcjaCzytelnika =
() =>
{
Thread.Sleep(r.Next(maksymalnaPrzerwaMiedzyOdczytami));
//opóźnienie
while (true)
{
try
{
Console.WriteLine("Przygotowania do odczytania elementu
(watek nr " + Thread.CurrentThread.ManagedThreadId +
")");
int indeks = r.Next(ileElementow);
int wartoscElementu = odczytajElement(indeks);
Console.WriteLine("Odczytany element o indeksie " +
indeks.ToString() + " równy jest " + wartoscElementu
+ " (watek nr " + Thread.CurrentThread.Managed
ThreadId + ")");
76 Programowanie równoległe i asynchroniczne w C# 5.0
Thread.Sleep(maksymalnaPrzerwaMiedzyOdczytami);
}
catch (ThreadAbortException)
{
Console.WriteLine("Wątek czytelnika " +
Thread.CurrentThread.ManagedThreadId + " kończy pracę");
}
}
};
wyswietlZawartoscTablicy();
}
}
}
W metodzie Main zdefiniowane zostały dwie akcje (typu Action) — jedna dla wątków
pisarzy, druga dla wątków czytelników. Następnie w pętlach tworzone są wątki, których
referencje umieszczane są w tablicach (nie korzystam z puli wątków, aby wszystkie
wątki były jednocześnie uruchomione). Po ich utworzeniu główny wątek czeka na na-
ciśnięcie Enter i wyświetla stan tablicy, jeżeli to nastąpi.
Widać, że z łatwością możemy zdefiniować klasę otaczającą dla tablicy lub innych ko-
lekcji, która byłaby bezpieczna w aplikacjach wielowątkowych i realizowała scenariusz
pisarzy i czytelników (pełniejszy przykład takiej klasy można znaleźć w MSDN).
Należy zwrócić uwagę, że zmieniając opóźnienia i priorytety wątków, łatwo doprowa-
dzić do zagłodzenia jednej z grup wątków. Implementacja ReaderWriterLockSlim zawie-
ra jednak mechanizmy, które pomagają tego uniknąć. Mam na myśli szczególnie ustala-
nie maksymalnego czasu, przez jaki blokada jest aktywna.
Przyjrzyjmy się przypadkowi, w którym jest jeden wątek producenta i jeden wątek
konsumenta3. Aby sprawę dodatkowo uprościć, nie będziemy tworzyć rzeczywistego
zasobu. Będziemy go jedynie imitować licznikiem, który producent będzie zwiększał,
a konsument — zmniejszał. W pierwszym podejściu nie użyjemy także żadnego wy-
rafinowanego mechanizmu komunikacji między wątkami, a jedynie „globalnie” dostępne
flagi, które wątki będą mogły podnosić i opuszczać.
Listing 4.4. Kod aplikacji ProducentKonsument. Wyróżnione wyrażenie lambda jest szczegółowo
omówione poniżej
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Threading;
namespace ProducentKonsument
{
class Program
2
Warto w tym kontekście zwrócić również uwagę na interfejs IProducerConsumerCollection<> i klasę
BlockingCollection<>. Omówione zostaną w rozdziale 9. Ich działanie opiera się na nieco innym
podejściu, bo to nie wątki, a magazyn przejmuje kontrolę nad całym procesem. Dla przykładu metoda,
która dodaje nowy elementu do magazynu, zostanie wstrzymana, a przez to i wywołujący ją wątek,
jeżeli magazyn jest pełen. W ten sposób wątek będzie czekał aż do momentu, w którym w magazynie
będzie wolne miejsce. Analogicznie metoda odbierająca element z pustego magazynu może zostać
wstrzymana, aż do momentu pojawienia się elementu.
3
Por. przykład 2 ze strony MSDN http://msdn.microsoft.com/en-us/library/aa645740(v=vs.71).
aspx#vcwlkthreadingtutorialexample2synchronizing.
Rozdział 4. Więcej o synchronizacji wątków. Blokady i sygnały 79
{
static object obiektSynchronizacjiMagazynu = new object();
static Random r = new Random();
ThreadStart akcjaKonsumenta =
() =>
{
Console.WriteLine("Wątek konsumenta jest uruchamiany");
while (true)
{
if(watekKonsumentaAktywny)
Thread.Sleep(r.Next(maksymalnyCzasUruchomieniaKonsumpcji));
while (watekKonsumentaAktywny)
{
lock (obiektSynchronizacjiMagazynu)
{
licznikElementowWMagazynie--;
Console.Write("Element zabrany. ");
}
wyswietlStanMagazynu();
if (licznikElementowWMagazynie <= 0)
{
watekKonsumentaAktywny = false;
Console.WriteLine("Wątek konsumenta został uśpiony");
}
if (!watekProducentaAktywny)
{
Console.WriteLine("Wątek producenta jest wznawiany");
watekProducentaAktywny = true;
}
Thread.Sleep(r.Next(maksymalnyCzasKonsumpcji));
}
}
};
Console.ReadLine();
Console.Write("Koniec. "); wyswietlStanMagazynu();
}
}
}
Na listingu 4.4 przedstawiam klasę Program aplikacji konsolowej, w jakiej pole licznik
ElementowWMagazynie imituje magazyn; wątek producenta dokłada elementy do maga-
zynu, a wątek konsumenta je z niego zabiera. Czas produkcji i konsumpcji są losowe,
choć ograniczone z góry przez stałe parametry (w powyższym listingu ustalone na
jedną sekundę). Poza tym zdefiniowane są dwie flagi watekProducentaAktywny i watek
KonsumentaAktywny, które pozwalają na uśpienie i wznowienie obu wątków bez uży-
wania oznaczonych jako przestarzałe metod Thread.Suspend i Thread.Resume. Oba
wątki uruchamiane są w metodzie Main jako wątki tła, więc kończone są automatycznie
po zakończeniu wątku głównego, który wstrzymany jest poleceniem Console.ReadLine.
Rozdział 4. Więcej o synchronizacji wątków. Blokady i sygnały 81
Na listingu 4.5 prezentuję nową wersję kodu aplikacji. Należy zwrócić uwagę, że nie
ma w niej już flag. Zastąpiły je dwa „puste” obiekty służące do synchronizacji (obok
wcześniej używanego obiektu służącego do synchronizacji operacji na magazynie).
Listing 4.5. Nowa wersja kodu aplikacji bez flag (ponownie wyróżniona jest akcja producenta)
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Threading;
namespace ProducentKonsument
{
class Program
{
static object obiektSynchronizacjiMagazynu = new object();
static object obiektSynchronizacjiProducenta = new object();
static object obiektSynchronizacjiKonsumenta = new object();
static Random r = new Random();
lock (obiektSynchronizacjiProducenta)
Monitor.Wait(obiektSynchronizacjiProducenta);
Console.WriteLine("Wątek producenta zostanie wznowiony");
Thread.Sleep(r.Next(maksymalnyCzasUruchomieniaProdukcji));
Console.WriteLine("Wątek producenta został wznowiony");
}
lock (obiektSynchronizacjiKonsumenta)
Monitor.Pulse(obiektSynchronizacjiKonsumenta);
Thread.Sleep(r.Next(maksymalnyCzasProdukcji));
}
};
ThreadStart akcjaKonsumenta =
() =>
{
Console.WriteLine("Wątek konsumenta jest uruchamiany");
while (true)
{
lock (obiektSynchronizacjiMagazynu)
{
licznikElementowWMagazynie--;
Console.Write("Element zabrany. ");
}
wyswietlStanMagazynu();
if (licznikElementowWMagazynie <= 0)
{
Console.WriteLine("Wątek konsumenta zostanie uśpiony");
lock (obiektSynchronizacjiKonsumenta)
Monitor.Wait(obiektSynchronizacjiKonsumenta);
Console.WriteLine("Wątek konsumenta zostanie wznowiony");
Thread.Sleep(r.Next(maksymalnyCzasUruchomieniaKonsumpcji));
Console.WriteLine("Wątek konsumenta został wznowiony");
}
lock (obiektSynchronizacjiProducenta)
Monitor.Pulse(obiektSynchronizacjiProducenta);
Thread.Sleep(r.Next(maksymalnyCzasKonsumpcji));
}
};
Console.ReadLine();
Console.Write("Koniec. "); wyswietlStanMagazynu();
}
}
}
84 Programowanie równoległe i asynchroniczne w C# 5.0
Kod akcji producenta i konsumenta nieco się uprościł: nie ma już podwójnej pętli
while — pozostała tylko jedna pętla odpowiedzialna za podtrzymywanie działania
wątku. W zamian pojawiły się dwie nowe sekcje krytyczne tworzone słowem kluczo-
wym lock (obok sekcji krytycznej chroniącej modyfikację stanu magazynu). Instrukcja
lock pojawia się w instrukcji warunkowej sprawdzającej, czy bieżący wątek ma zostać
wstrzymany. W przypadku producenta jest to:
lock (obiektSynchronizacjiProducenta) Monitor.Wait(obiektSynchronizacjiProducenta);
Możliwa jest sytuacja, w której kilka wątków zostało uśpionych za pomocą metody
Wait z tym samym obiektem jako argumentem. Metoda Pulse obudzi tylko pierwszy
w kolejce. Jeżeli chcemy obudzić wszystkie, powinniśmy użyć metody PulseAll.
Wywołanie metody Pulse, jeżeli żaden wątek nie jest w stanie uśpienia, pozostaje bez
efektu. W odróżnieniu od np. AutoResetEvent ten impuls nie jest przechowywany.
4
Źródło: strona Joego Albahariego Threading in C# (http://www.albahari.com/threading/part2.aspx),
zmierzony dla procesora Intel Core i7 860.
Rozdział 4. Więcej o synchronizacji wątków. Blokady i sygnały 85
EventWaitHandle i AutoResetEvent
Oprócz bardzo szybkiego mechanizmu sygnalizacji zaimplementowanego w meto-
dach Monitor.Wait i Monitor.Pulse, mamy również do dyspozycji poznaną wcześniej
klasę EventWaitHandle (użyliśmy jej w rozdziale 2. do sygnalizowania zakończenia
dodatkowych wątków) i jej dwie klasy potomne: AutoResetEvent i ManualResetEvent.
Obiekt, który w ten sposób powstaje, jest w pełni równoważny z obiektem EventWait
Handle, z drugim argumentem równym EventResetMode.AutoReset. Z kolei użycie
opcji EventResetMode.ManualReset (lub równoważnie klasy ManualResetEvent) powo-
duje, że wywołanie metody Set nie zmienia stanu obiektu (pozostaje włączony) aż do
momentu, w którym wyłączymy go ręcznie metodą Reset. Odpowiada to zwykłym
drzwiom, które pozostają otwarte aż do momentu, gdy ktoś je zamknie.
W naszym przykładzie, w którym jest tylko jeden wątek producenta i tylko jeden wą-
tek konsumenta, nie zobaczymy różnicy między tymi dwoma trybami. Jeżeli jednak
w stan uśpienia metodą WaitOne wprowadzono więcej wątków, w przypadku AutoReset
wypuszczony zostałby tylko jeden z nich. Korzystając z metafory autostradowej,
można powiedzieć, że po jego wznowieniu szlaban automatycznie zostałby opuszczony
i zablokował jazdę następnych wątków. Natomiast w przypadku ManualReset szlaban
pozostawałby podniesiony do momentu, w którym programista zdecyduje go opuścić,
wywołując metodę Reset. Warto zwrócić uwagę, że w trybie ManualReset, aby odtwo-
rzyć działanie trybu AutoReset, metody Set i Reset musiałyby być wywołane w jednej
atomowej operacji. Nie wystarczy umieścić ich bezpośrednio jedna po drugiej.
5
Źródło tego pomysłu: http://stackoverflow.com/questions/153877/
what-is-the-difference-between-manualresetevent-and-autoresetevent-in-net.
Zob. też. http://www.codeproject.com/Articles/39040/Auto-and-Manual-Reset-Events-Revisited.
86 Programowanie równoległe i asynchroniczne w C# 5.0
Warto jeszcze wspomnieć, że w platformie .NET 4.0 dodana została klasa ManualReset
EventSlim, która odpowiada klasie ManualResetEvent, ale ograniczona jest do działania
wewnątrz jednego procesu. Nie korzysta z mechanizmów systemu Windows i dzięki
temu jest znacznie szybsza (tabela 4.2).
Bariera
W wersji 4.0 platformy .NET, wraz z biblioteką TPL opisaną w kolejnych rozdziałach,
w przestrzeni nazw System.Threading pojawiła się ciekawa klasa Barrier. Pozwala
ona na szybkie i wygodne synchronizowanie kolejnych etapów wykonywania metod
w wątkach.
namespace BarrierDemo
{
class Program
{
const int ileWatkow = 10;
6
Pomysł tego przykładu zapożyczyłem ze strony Joego Albahariego:
http://www.albahari.com/threading/part4.aspx#_The_Barrier_Class.
Rozdział 4. Więcej o synchronizacji wątków. Blokady i sygnały 87
Console.Write(i.ToString());
}
};
Thread[] watki = new Thread[ileWatkow];
for (int i = 0; i < ileWatkow; ++i)
{
watki[i] = new Thread(metodaWatku);
watki[i].Start();
}
Console.ReadLine();
}
}
}
Rysunek 4.2.
Liczby drukowane
w sposób
niezsynchronizowany
Listing 4.7. Użycie bariery do synchronizacji kolejnych etapów wykonywania metody wątku
...
using System.Threading;
namespace BarrierDemo
{
class Program
{
const int ileWatkow = 10;
static Barrier b = new Barrier(ileWatkow);
Console.ReadLine();
}
}
}
Rysunek 4.3.
Efekt zsynchronizowania
drukowania liczb przez
poszczególne wątki
za pomocą barier
Rysunek 4.4.
Efekt użycia metody
wykonywanej
po każdym etapie
synchronizowanym
przez barierę
się jednak tylko do systemu Windows i języka C#, podobnie jak opisane niżej sema-
fory, jej realizacje możemy znaleźć w każdym systemie umożliwiającym jednoczesne
uruchamianie wielu programów i w każdym języku programowania obsługującym wie-
lowątkowość. Oprócz muteksów nazwanych, możliwe jest również tworzenie muteksów
lokalnych, których działanie ograniczone jest do jednego procesu. Są one jednak
mniej wydajne niż mechanizmy udostępniane choćby przez klasę Monitor.
Mutex
Zasadniczym przeznaczeniem klasy Mutex jest jednak utworzenie sekcji krytycznej,
w której może przebywać tylko jeden wątek. Przygotujemy prostą aplikację, którą bę-
dziemy uruchamiać w kilku instancjach, aby przekonać się, że to rzeczywiście działa
(listing 4.9).
Listing 4.9. Użycie obiektu klasy Mutex do utworzenia sekcji krytycznej o zasięgu „międzyaplikacjowym”
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
90 Programowanie równoległe i asynchroniczne w C# 5.0
using System.Threading.Tasks;
using System.Threading;
namespace MutexDemo
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("Aplikacja została uruchomiona");
while (true)
{
m.WaitOne(); //czeka, kiedy będzie można wejść do sekcji krytycznej
Console.Write('[');
if (Console.KeyAvailable)
{
switch(Console.ReadKey(true).Key)
{
case ConsoleKey.Enter:
Console.WriteLine();
Console.WriteLine("\n\nWątek został wstrzymany w sekcji
krytycznej.\nNaciśnij Enter, aby zwolnić muteks...");
Console.ReadLine();
break;
case ConsoleKey.Escape:
koniec = true;
break;
}
}
m.ReleaseMutex(); //zwalnia muteks (opuszcza sekcję krytyczną)
Console.Write("]");
if (koniec)
{
Console.WriteLine("\n\nKoniec.");
return;
}
Thread.Sleep(1000);
Console.Write(" ");
}
}
}
}
Działanie metody Main rozpoczyna się od utworzenia muteksu. Należy zwrócić uwa-
gę, że pierwszym argumentem jego konstruktora jest false — a zatem muteks jest po
utworzeniu nieaktywny. Aktywuje go dopiero metoda WaitOne. Powoduje ona, że bieżący
Rozdział 4. Więcej o synchronizacji wątków. Blokady i sygnały 91
wątek aplikacji wchodzi do sekcji krytycznej. Oczywiście, jeżeli żaden wątek się w niej
nie znajduje. Jeżeli jednak sekcja krytyczna jest zajęta (muteks jest aktywny), metoda
WaitOne czeka na jej zwolnienie, blokując bieżący wątek (stąd nazwa metody). W po-
wyższym przykładzie wejście do sekcji krytycznej zablokuje działanie całej innej in-
stancji aplikacji, bo aplikacja ta jest jednowątkowa. Działanie muteksu jest jednak
ograniczone do wątku, w którym metody są wywoływane. Co więcej, zwolnienie mutek-
stu za pomocą metody ReleaseMutex, a więc wyjście z sekcji krytycznej, może się odbyć
tylko z tego samego wątku — przeznaczeniem muteksu nie jest wobec tego przesyła-
nie sygnałów między wątkami.
Metoda WaitOne może być wywołana wielokrotnie (coś na kształt zagnieżdżania sek-
cji krytycznych). Wówczas metoda ReleaseMutex musi być wywołana tyle samo razy.
Niezwolnienie muteksu może prowadzić do niechcianego blokowania aplikacji.
Semafor
Semafory to kolejny obiekt jądra wykorzystywany do synchronizacji. O ile analogiem
muteksu z życia codziennego może być jedno okienko na poczcie, przy którym może
stać tylko jeden interesant, to semaforowi odpowiada większy urząd pocztowy z kilkoma
okienkami, w którym może być obsługiwanych kilku interesantów jednocześnie. Gdy
jedno z okienek się zwalnia, podchodzi następna osoba ze wspólnej kolejki. W platfor-
mie .NET semafory dostępne są przy użyciu klasy Semaphor. Korzystając z niej, przy-
gotujmy aplikację, która może być uruchomiona tylko w tylu instancjach, ile rdzeni
procesora jest dostępnych (listing 4.10).
Listing 4.10. Wyróżnione są zasadnicze różnice względem wcześniejszej aplikacji opartej o klasę Mutex
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Threading;
namespace SemaphoreDemo
{
class Program
92 Programowanie równoległe i asynchroniczne w C# 5.0
{
static void Main(string[] args)
{
Console.WriteLine("Aplikacja została uruchomiona");
int iloscWatkowWSekcjiKrytycznej = System.Environment.ProcessorCount;
Console.WriteLine("Ile wątków może być jednocześnie w sekcji
krytycznej: " + iloscWatkowWSekcjiKrytycznej.ToString());
while (true)
{
s.WaitOne(); //czeka, kiedy będzie można wejść do sekcji krytycznej
Console.Write('[');
if (Console.KeyAvailable)
{
switch (Console.ReadKey(true).Key)
{
case ConsoleKey.Enter:
Console.WriteLine();
Console.WriteLine("\n\nWątek został wstrzymany w sekcji
krytycznej.\nNaciśnij Enter, aby zwolnić semafor...");
Console.ReadLine();
break;
case ConsoleKey.Escape:
koniec = true;
break;
}
}
int ilePozostaloMiejscNaWatkiWSekcjiKrytycznej = s.Release();
//zwalnia semafor (opuszcza sekcję krytyczną)
Console.Write(ilePozostaloMiejscNaWatkiWSekcjiKrytycznej.
ToString() + "]");
if (koniec)
{
Console.WriteLine("\n\nKoniec.");
return;
}
Thread.Sleep(1000);
Console.Write(" ");
}
}
}
}
Należy zwrócić uwagę na różnice względem analogicznego kodu z listingu 4.9. Two-
rzymy semafor z określoną przez zmienną iloscWatkowWSekcjiKrytycznej ilością miejsc
na wątki w sekcji krytycznej. Przyjmijmy, że jest ona równa 4. Ponieważ w drugim
argumencie konstruktora klasy Semaphor użyliśmy tej samej wartości, wszystkie miejsca
Rozdział 4. Więcej o synchronizacji wątków. Blokady i sygnały 93
Inaczej niż muteksy, semafory nie mają właściciela. Dzięki temu miejsca na wątki zare-
zerwowane w jednym wątku metodą WaitOne mogą być zwalnianie z innych wątków
przy użyciu metody Release. To — oczywiście — może prowadzić do błędów i to
trudnych do wykrycia.
Jeżeli pierwszy argument konstruktora jest mniejszy od drugiego, oznacza to, że wątek,
w którym utworzony został semafor, od razu rezerwuje część miejsc w sekcji krytycznej.
Miejsca te muszą być zwolnione, aby inne wątki (także z innych aplikacji) mogły je
zająć. Jeżeli pierwszym argumentem byłoby 0, bieżący wątek całkowicie blokowałby
dostęp do sekcji krytycznej aż do wywołania przynajmniej raz metody Release.
Od platformy .NET w wersji 4.0 dostępna jest szybsza wersja semafora zaimple-
mentowana w klasie SemaphorSlim. Ograniczona jest jednak tylko do semafora lo-
kalnego — nie może synchronizować wątków z różnych procesów.
Zadania
1. Korzystając z klas Konto i PoleceniePrzelewu (podrozdział „Problem ucztujących
filozofów”), zorganizuj „wianek” pięciu kont, w których — analogicznie jak
w oryginalnym problemie pięciu filozofów — każdy filozof będzie próbował
wykonać przelew na konto prawego sąsiada. To powinno doprowadzić do
zakleszczenia wątków.
2. Przygotuj wersję programu ProducentKonsument, w którym działa wiele wątków
producentów i konsumentów. Użyj klasy SemaphorSlim. Reguluj tempo produkcji
i konsumpcji, zmieniając ilość działających wątków. Rozważ możliwość
zminimalizowania punktów synchronizacji przez wprowadzenie osobnych
magazynów dla każdego wątku i ich synchronizację dopiero w przypadku
całkowitego opróżnienia lub przepełnienia jednego z nich.
3. Przygotuj klasę ogólną (parametryczną) implementującą „inteligentny” magazyn
w scenariuszu producent-konsument, zgodnie z opisem z przypisu 2. z tego
rozdziału.
94 Programowanie równoległe i asynchroniczne w C# 5.0
namespace ImageAnalyzer
{
public partial class Form1 : Form
{
// Szerokość i wysokość obrazu
private const int _width = 800;
private const int _height = 600;
public Form1()
{
InitializeComponent();
KonfigurujWykres();
}
Listing 5.2. Domyślna metoda zdarzeniowa przycisku z etykietą Przygotuj obraz. Jej celem jest
utworzenie mapy bitowej, będącej projekcją sygnału o kształcie funkcji kosinus przesuwanej o czynnik
fazowy proporcjonalny do indeksu wiersza obrazu
private void buttonPrzygotujObraz_Click(object sender, EventArgs e)
{
Bitmap img = new Bitmap(_width, _height);
Rozdział 5. Wątki a interfejs użytkownika 99
pictureBoxPreview.Image = img;
buttonAnalizuj.Enabled = true;
}
Rysunek 5.3. Widok projektowanej aplikacji z przygotowanymi danymi, które wykorzystam w kolejnym
podrozdziale
Generowana przez aplikację mapa bitowa reprezentuje sygnał w postaci funkcji kosinus,
który w każdej kolejnej linii jest przesunięty w fazie względem pierwszej linii o stały
czynnik. W efekcie otrzymuję zestaw prążków obróconych o kąt ok. 45° względem
osi rzędnych obrazu.
Wykorzystanie wątków
w długotrwałych metodach zdarzeniowych
Uzupełnię teraz projekt aplikacji ImageAnalyzer o procedury umożliwiające wykona-
nie cyfrowej analizy poszczególnych linii obrazu, czego efektem będzie prezentacja
przekrojów tych linii. Aktualnie analizowana linia obrazu będzie zaznaczona czarną
poziomą kreską.
100 Programowanie równoległe i asynchroniczne w C# 5.0
DodajPunktyDoWykresu(wykres, 0, lineData);
pictureBoxPreview.Image = tempBitmap;
}
}
}
KonfigurujWykres();
}
Listing 5.6. Zatrzymanie pracy wątku analizującego obraz za pomocą flagi _analizaAktywna
private void buttonPrzerwijAnalize_Click(object sender, EventArgs e)
{
_analizaAktywna = false;
buttonPrzerwijAnalize.Enabled = false;
}
_analizaAktywna = true;
thread.Start();
buttonPrzerwijAnalize.Enabled = true;
}
Listing 5.9. Przed zamknięciem aplikacji należy zadbać o zakończenie funkcji wszystkich wątków roboczych
private void Form1_FormClosing(object sender, FormClosingEventArgs e)
{
_analizaAktywna = false;
}
Rozdział 5. Wątki a interfejs użytkownika 103
Alternatywnie wątek można przerwać za pomocą metody Abort. Jest to jednak sposób nie-
zalecany, gdyż w zasadzie sprowadza się do zgłoszenia wyjątku ThreadAbortException
na rzecz danego obiektu typu Thread (rozdział 2.).
Zgłaszanie tego wyjątku można wyłączyć. Służy do tego statyczna właściwość Con-
trol.CheckForIllegalCrossThreadCalls. W celu zablokowania zgłaszania wyjątku
typu InvalidOperationException podczas dostępu do komponentów wizualnych z wąt-
ków roboczych wystarczy uzupełnić konstruktor klasy Form1 o polecenie wyróżnione
na listingu 5.10.
KonfigurujWykres();
Form1.CheckForIllegalCrossThreadCalls = false;
}
1
http://msdn.microsoft.com/en-us/library/7a2f3ay4%28v=vs.90%29.aspx
104 Programowanie równoległe i asynchroniczne w C# 5.0
Synchronizacja wątków
z interfejsem użytkownika
w aplikacjach Windows Forms
Podczas implementacji wielowątkowych aplikacji desktopowych komponenty Windows
Forms stanowią współdzielone zasoby. Równoczesny dostęp do tych komponentów
realizowany z kilku wątków jednocześnie, co może doprowadzić do niekontrolowanych
sytuacji. Wynika to głównie ze zjawiska wyścigu wątków (ang. race condition), który
w najprostszej definicji oznacza, że działanie aplikacji zależy od tego, który z wątków
wykorzystujących współdzielone zasoby wykona swoją pracę jako pierwszy. Innym
efektem, który może negatywnie wpłynąć na poprawne działanie aplikacji wielowątko-
wych, jest opisane w rozdziale 4. zakleszczenie wątków (ang. deadlock).
Listing 5.11. Definicja klasy ThreadSafeCalls implementującej bezpieczeństwo wątków dla wybranych
metod komponentów PictureBox oraz Chart
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using System.Text;
106 Programowanie równoległe i asynchroniczne w C# 5.0
using System.Threading.Tasks;
using System.Windows.Forms;
using System.Windows.Forms.DataVisualization.Charting;
namespace ImageAnalyzer
{
static class ThreadSafeCalls
{
private delegate void DodajPunktyDoWykresuDelegate(Chart
chart, int seriesIndex, double[] yValues);
public static void DodajPunktyDoWykresu(Chart chart,
int seriesIndex, double[] yValues)
{
if (chart.InvokeRequired)
{
chart.Invoke(new DodajPunktyDoWykresuDelegate(
DodajPunktyDoWykresu),
new object[] { chart, seriesIndex, yValues });
}
else
{
if(seriesIndex < chart.Series.Count)
{
chart.Series[seriesIndex].Points.Clear();
DodajPunktyDoWykresu(wykres, 0, lineData);
ThreadSafeCalls.DodajPunktyDoWykresu(wykres, 0, lineData);
pictureBoxPreview.Image = tempBitmap;
ThreadSafeCalls.UstawObraz(pictureBoxPreview, tempBitmap);
2
Delegat nazywany jest również „przedstawicielem” lub „pełnomocnikiem”. W potocznym języku
programistów używany jest też termin „delegacja”.
Rozdział 5. Wątki a interfejs użytkownika 109
namespace ImageAnalyzer
{
public class PictureBoxThreadSafe : PictureBox
{
public new Image Image
{
get
{
return base.Image;
}
set
{
if (this.InvokeRequired)
{
this.Invoke(new Action(() =>
{ this.Image = value; }));
}
else
{
base.Image = value;
}
}
}
}
}
na
private PictureBoxThreadSafe pictureBoxPreview;
w następujący sposób:
this.pictureBoxPreview = new PictureBoxThreadSafe();
zamiast
ThreadSafeCalls.UstawObraz(pictureBoxPreview, tempBitmap);
BackgroundWorker
Implementację mechanizmu bezpiecznego dostępu do komponentów można zreali-
zować samodzielnie, implementując mechanizm wyzwalania zdarzeń odpowiedzial-
nych za aktualizację interfejsu użytkownika, obsługiwanych tylko i wyłącznie w wątku
UI, lub wykorzystać przygotowany do tego celu komponent BackgroundWorker. Umożli-
wia on asynchroniczne wykonywanie długotrwałych operacji w ramach osobnego
wątku, niezależnego od wątku odpowiedzialnego za działanie GUI aplikacji desktopo-
wej. Działanie klasy BackgroundWorker opiera się na wykorzystaniu omówionej w roz-
dziale 2. puli wątków. W najprostszych scenariuszach, często spotykanych w aplikacjach
Windows Forms, komponent BackgroundWorker pozwala na uniknięcie samodzielnego
tworzenia wątków.
Rysunek 5.6.
Widok formy
projektowanej aplikacji
3
Lista ta stanie się aktywna po kliknięciu ikony błyskawicy w oknie właściwości (rysunek 5.7).
112 Programowanie równoległe i asynchroniczne w C# 5.0
Rysunek 5.7.
Okno właściwości obiektu
BackgroundWorker
if (backgroundWorkerOdczyt.CancellationPending)
{
e.Cancel = true;
break;
}
else
{
backgroundWorkerOdczyt.ReportProgress(100 * i /
liczbaDanychDoOdczytania, r.Next(255));
}
}
}
}
progressBarOdczyt.Value = e.ProgressPercentage;
}
Rysunek 5.8.
Aplikacja
w trakcie pracy
Synchronizacja wątków
z komponentami
Windows Presentation Foundation
W tym podrozdziale pokażę wzorce projektowe wykorzystywane do synchronizacji
wątków odwołujących się do komponentów z biblioteki Windows Presentation Foun-
dation (WPF). Do tego celu wykorzystam projekt z rozdziału 2., którego celem było
Rozdział 5. Wątki a interfejs użytkownika 115
Z góry zastrzegam, że moim celem nie będzie dokładne omówienie technologii WPF,
a jedynie tego jej małego wycinka, który związany jest z problemem synchronizacji in-
terfejsu w aplikacjach wielowątkowych.
Rysunek 5.9. Kreator New Project z zaznaczonym szablonem projektu aplikacji Windows Presentation
Foundation
116 Programowanie równoległe i asynchroniczne w C# 5.0
Wygląd formy aplikacji WPF jest jednoznacznie określony przez kod XAML. Wobec
tego do zaprojektowania interfejsu użytkownika aplikacji, który przedstawiłem na ry-
sunku 5.10, wystarczy w widoku projektowania interfejsu użytkownika kliknąć edytor
kodu XAML i skopiować do niego polecenia z listingu 5.20.
Name="ButtonRozpocznij" Click="ButtonRozpocznij_Click"
Height="25"/>
<Button Content="Przerwij obliczenia"
HorizontalAlignment="Left" Margin="145,10,0,0"
VerticalAlignment="Top" Width="130"
Name="ButtonPrzerwij" Height="25"
Click="ButtonPrzerwij_Click"/>
<ListBox Grid.Column="1" HorizontalAlignment="Left"
Height="400" Margin="10,40,0,0"
VerticalAlignment="Top" Width="400"
Name="ListBoxWyniki" Grid.ColumnSpan="2"/>
<Canvas HorizontalAlignment="Left" Height="400"
Margin="10,40,0,0" VerticalAlignment="Top"
Width="400" Name="CanvasPodglad"/>
</Grid>
</Window>
Listing 5.21. Do poprawnej kompilacji projekt aplikacji MonteCarloPi wymaga pozostałych metod
zdarzeniowych, zadeklarowanych w poprzednim podrozdziale. Z tego powodu utworzyłem puste definicje
tych metod
public partial class MainWindow : Window
{
private double _srednicaOkregu;
private double _promienOkregu;
public MainWindow()
{
InitializeComponent();
_srednicaOkregu = CanvasPodglad.Width;
_promienOkregu = _srednicaOkregu / 2.0;
}
rect.Width = _srednicaOkregu;
rect.Height = _srednicaOkregu;
rect.Stroke = Brushes.Black;
rect.StrokeThickness = 1;
CanvasPodglad.Children.Add(rect);
}
private void RysujUkladWspolrzednych()
{
// Oś odciętych
Line liniaPozioma = new Line();
liniaPozioma.X1 = 0.0;
liniaPozioma.X2 = _srednicaOkregu;
liniaPozioma.Y1 = _promienOkregu;
liniaPozioma.Y2 = _promienOkregu;
// Oś rzędnych
Line liniaPionowa = new Line();
liniaPionowa.X1 = _promienOkregu;
liniaPionowa.X2 = _promienOkregu;
liniaPionowa.Y1 = 0.0;
liniaPionowa.Y2 = _srednicaOkregu;
// Kolor i rozmiar linii
liniaPozioma.Stroke = liniaPionowa.Stroke = Brushes.Black;
liniaPozioma.StrokeThickness = liniaPionowa.StrokeThickness = 1;
// Zmiana stylu linii na przerywany
DoubleCollection dashes = new DoubleCollection();
dashes.Add(10);
liniaPozioma.StrokeDashArray =
liniaPionowa.StrokeDashArray = dashes;
CanvasPodglad.Children.Add(liniaPozioma);
CanvasPodglad.Children.Add(liniaPionowa);
}
private void PrzygotujPodglad()
{
CanvasPodglad.Children.Clear();
RysujKwadrat();
RysujOkrag();
RysujUkladWspolrzednych();
}
Rozdział 5. Wątki a interfejs użytkownika 119
Wróćmy jednak do omówienia zasadniczych elementów metod z listingu 5.21. Ich po-
stać jest podobna i sprowadza się do utworzenia jednego z obiektów zadeklarowanych
w przestrzeni nazw System.Windows.Shapes (Ellipse, Rectangle lub Line). Po utworze-
niu odpowiedniego obiektu konfiguruję jego kolor (własność Stroke), rozmiar (wła-
sności Width i Height) oraz szerokość obramowania (StrokeThickness), a następnie
120 Programowanie równoległe i asynchroniczne w C# 5.0
public ParametryWatku()
{
// Wartości domyślne;
IloscProb = 2000L;
Opoznienie = 5;
}
if (!_watekAktywny)
Rozdział 5. Wątki a interfejs użytkownika 121
{
PrzygotujPodglad();
_watekAktywny = true;
Thread thread = new Thread(ObliczPi);
thread.Start(new ParametryWatku());
}
else
{
MessageBox.Show("Obliczenia są już uruchomione.",
this.Title);
}
}
Listing 5.24. Ilość prób oraz opóźnienie pomiędzy kolejnymi iteracjami przekazuję do funkcji wątku
za pomocą jego parametrów uruchomieniowych
private void ObliczPi(Object parametryWatku)
{
ParametryWatku p = (ParametryWatku)parametryWatku;
long iloscProb = p.IloscProb;
int msSleepTime = p.Opoznienie;
long iloscTrafien = 0;
double odlegloscPunktuOdPoczatkuUkladuWspolrzednych
= Math.Sqrt(xC * xC + yC * yC);
DodajElementDoListy(statystyka);
Thread.Sleep(msSleepTime);
}
}
ellipse.Width = srednicaPunktu;
ellipse.Height = srednicaPunktu;
CanvasPodglad.Children.Add(ellipse);
d ( x, y , x0 , y 0 ) ( x x0 ) 2 ( y y 0 ) 2 .
_watekAktywny = true;
Thread thread = new Thread(ObliczPi);
thread.SetApartmentState(ApartmentState.STA);
thread.Start(new ParametryWatku());
}
else
{
MessageBox.Show("Obliczenia są już uruchomione.",
this.Title);
}
}
Jednakże podczas ponownej próby prezentacji wyników pojawi się kolejny wyjątek
typu InvalidOperationException o treści Wątek wywołujący nie może uzyskać dostępu
do tego obiektu, ponieważ należy on do innego wątku. Jest to problem analogiczny do
tego, z którym spotkaliśmy się podczas implementacji aplikacji ImageAnalyzer. Jego
rozwiązanie polega na zaimplementowaniu bezpiecznego wielowątkowego dostępu
do kontrolek WPF, które zrealizuję i omówię w następnym podrozdziale.
124 Programowanie równoległe i asynchroniczne w C# 5.0
Warto nieco szerzej omówić wyjątek związany z modelem STA (ang. Single-Threaded
Apartment) przetwarzania współbieżnego. Model ten wywodzi się z architektury wielo-
wątkowych aplikacji wykorzystujących obiekty COM (ang. Component Object Model).
COM jest technologią umożliwiającą tworzenie obiektów, które dzięki globalnej reje-
stracji w systemie operacyjnym mogą być wykorzystywane przez inne aplikacje.
Technologia COM jest podstawą innych technologii, takich jak Microsoft OLE, COM+,
DCOM czy ActiveX.
Czytelnicy mogą czuć się w tej chwili skonsternowani, ponieważ podczas omawiania
wielowątkowości w zarządzanym świecie biblioteki .NET pojawiają się informacje
o technologiach niezarządzanych. Wynika to z faktu, że zarządzane obiekty zaimple-
mentowane w bibliotece .NET muszą współpracować z pozostałymi, niezarządzanymi
elementami systemu operacyjnego. Współpraca ta odbywa się właśnie w oparciu o tech-
nologię COM. Obiekty zarządzane „są widoczne” w świecie niezarządzanym jako
obiekty COM. Z tego powodu w powyższym przykładzie zetknęliśmy się z problemem
konfiguracji modelu przetwarzania współbieżnego COM. Model ten związany jest z kon-
tekstem działania wątków (ang. Apartment Threaded Model, w skrócie ATM), wykorzy-
stujących obiekty COM. Istnieją trzy rodzaje modeli ATM: Single-Threaded Apartment
(STA), Multi-Threaded Apartment (MTA) oraz Neutral Apartment (NA).
W modelu MTA kilka wątków może kontrolować stan obiektu COM. W takim przy-
padku, w celu implementacji bezpieczeństwa wątków obiekty COM posiadają wbu-
dowane mechanizmy synchronizacji.
namespace MonteCarloPi
{
class ThreadSafeCallsWpf
{
private delegate void RysujPunktDelegate(Canvas canvas,
double x, double y, bool wewnatrzOkregu);
}
else
{
Ellipse ellipse = new Ellipse();
ellipse.Width = srednicaPunktu;
ellipse.Height = srednicaPunktu;
ellipse.Fill = wewnatrzOkregu ?
Brushes.LightGreen : Brushes.Red;
canvas.Children.Add(ellipse);
long iloscTrafien = 0;
{
// Losowanie współrzędnych punktu z zakresu (0: _srednicaOkregu)
double xComp = _srednicaOkregu * r.NextDouble();
double yComp = _srednicaOkregu * r.NextDouble();
// Przesunięcie wylosowanych współrzędnych do zakresu (-_promien:_promien)
double xC = xComp - _promienOkregu;
double yC = yComp - _promienOkregu;
double odlegloscPunktuOdPoczatkuUkladuWspolrzednych
= Math.Sqrt(xC * xC + yC * yC);
DodajElementDoListy(statystyka);
ThreadSafeCallsWpf.DodajElementDoListy(ListBoxWyniki,
statystyka);
Thread.Sleep(msSleepTime);
}
}
Rysunek 5.12. Wizualizacja kolejnych etapów szacowania wartości liczby metodą probabilistyczną
128 Programowanie równoległe i asynchroniczne w C# 5.0
Kontekst synchronizacji 4
4
Współautorem tego podrozdziału jest Jacek Matulewski.
Rozdział 5. Wątki a interfejs użytkownika 129
Listing 5.29. Przekazanie kontekstu synchronizacji bieżącego wątku (wątku UI) do funkcji wątku roboczego
private void buttonRozpocznijOdczyt_Click(object sender, EventArgs e)
{
backgroundWorkerOdczyt.RunWorkerAsync(
WindowsFormsSynchronizationContext.Current);
KonfigurujStanPrzyciskow(true);
}
Listing 5.30. Prezentacja postępu pracy wątku roboczego oraz aktualnej wartości
private void AktualizujUI(object parametry)
{
Parametry p = parametry as Parametry;
listBoxDane.Items.Add(p.Wartosc);
listBoxDane.SelectedIndex = listBoxDane.Items.Count - 1;
progressBarOdczyt.Value = p.Postep;
}
if (backgroundWorkerOdczyt.CancellationPending)
{
e.Cancel = true;
130 Programowanie równoległe i asynchroniczne w C# 5.0
break;
}
else
{
backgroundWorkerOdczyt.ReportProgress(100 * i /
liczbaDanychDoOdczytania, r.Next(255));
kontekst.Send(AktualizujUI,
new ParametrySynchronizacji(100 * i /
liczbaDanychDoOdczytania, r.Next(255)));
}
}
}
5
Po więcej informacji warto zajrzeć na strony: http://msdn.microsoft.com/en-us/magazine/gg598924.aspx,
http://www.codeproject.com/Articles/31971/Understanding-SynchronizationContext-Part-I.
132 Programowanie równoległe i asynchroniczne w C# 5.0
Wróćmy jednak do metod Post i Send, które różnią się także sposobem obsługi błędów.
W metodzie Send z biblioteki Windows Forms wyjątki zgłaszane w metodzie wykony-
wanej w wątku okna (w naszym przykładzie jest to metoda ustawWartoscPaskaPostepu)
są przekazywane do miejsca wywołania metody Send. Dzięki temu ma sens otoczenie
wywołania tej metody konstrukcją try..catch. To bardzo wygodne rozwiązanie, które
— niestety — nie zadziała w przypadku asynchronicznego wykonywania czynności
zainicjowanych przez metodę Post. Nie działa również dla metody Send w WPF. Jeżeli
nie ma obsługi wyjątków wewnątrz kodu uruchamianego przez te metody, wyjątki
zgłaszane w nim skazane są na brak obsłużenia i w efekcie najprawdopodobniej prze-
rwą działanie wątku okna. W metodach Invoke i BeginInvoke obsługę wyjątków moż-
na zrealizować tylko wewnątrz definicji metod uruchamianych za ich pośrednictwem.
Width="68" Name="buttonWyczysc"
Click="buttonWyczyscListe_Click"/>
</Grid>
</Window>
namespace DataReaderWPF
{
/// <summary>
/// Interaction logic for MainWindow.xaml
/// </summary>
public partial class MainWindow : Window
{
private volatile bool _watekAktywny = false;
public MainWindow()
{
InitializeComponent();
}
_watekAktywny = true;
thread.Start(DispatcherSynchronizationContext.Current);
KonfigurujStanPrzyciskow(true);
}
private void buttonPrzerwijOdczyt_Click(object sender,
EventArgs e)
{
_watekAktywny = false;
KonfigurujStanPrzyciskow(false);
}
listBoxDane.Items.Add(p.Wartosc);
listBoxDane.SelectedIndex = listBoxDane.Items.Count - 1;
progressBarOdczyt.Value = p.Postep;
if (p.Postep == 100)
{
KonfigurujStanPrzyciskow(false);
}
}
public class ParametrySynchronizacji
{
public int Postep { get; set; }
public int Wartosc { get; set; }
public ParametrySynchronizacji(int postep, int wartosc)
{
this.Postep = postep;
this.Wartosc = wartosc;
}
}
}
}
Rozdział 5. Wątki a interfejs użytkownika 135
poleceniem
kontekst.Post(AktualizujUI, new ParametrySynchronizacji(100 * i /
liczbaDanychDoOdczytania, r.Next(255)));
Zadania
1. Uzupełnij formę aplikacji ImageAnalyzer o dodatkowy przycisk, umożliwiający
wstrzymywanie i wznawianie analizy danych.
2. W projekcie aplikacji MonteCarloPi dodaj możliwość konfiguracji parametrów
początkowych wątku (ilość prób oraz opóźnienie) z poziomu GUI. Wykorzystaj
do tego celu klasę Parametry.
3. Uzupełnij projekt aplikacji MonteCarloPi o komponent Chart (z pakietu WPF
Toolkit). Wykorzystaj go w celu graficznej ilustracji zależności wyznaczonej
wartości liczby od ilości prób.
4. Przygotuj „wrapper” do kontrolki ProgressBar, w którym dostęp do własności
Value jest bezpieczny w sensie wielowątkowości.
Rozdział 6.
Zadania
Mateusz Warczak
Tworzenie zadania
W platformie .NET 4.5 podstawową klasą służącą do tworzenia programów korzy-
stających z algorytmów równoległych jest Task (czyli zadanie). Jak wspomniałem we
wstępie, tworzy ona warstwę abstrakcji, pod którą kryje się dobrze już znana klasa
138 Programowanie równoległe i asynchroniczne w C# 5.0
Od wersji Visual Studio 2012 jest ona uwzględniana domyślnie, w Visual Studio 2010
należy umieścić ją samodzielnie. W przestrzeni tej oprócz klasy Task znajdują się
również klasy pomocnicze, takie jak TaskFactory, TaskScheduler, czy wreszcie
ważna, zapowiedziana w rozdziale 1. klasa Parallel pozwalająca na tworzenie pętli
równoległych bez bezpośredniego budowania obiektów typu Task.
Nowo powstałe zadanie, podobnie jak w przypadku klasy Thread, nie będzie wykony-
wało wskazanego w argumencie kodu, zanim nie wywołamy na rzecz reprezentującej
go instancji klasy Task metody Start:
t.Start();
Praca z zadaniami
Klasa zadania Task oferuje wszystkie udogodnienia programowania obiektowego i ję-
zyka C#, od tworzenia list, aż po korzystanie z nowych algorytmów równoległych
zaimplementowanych np. w metodach AsParallel i ForEach klasy List<>. W obu tych
metodach kod wykonywany równolegle często przekazywany jest za pomocą wyrażeń
lambda. Zanim przystąpimy do pracy z zadaniami, warto się z nimi „oswoić”.
Rozdział 6. Zadania 139
Jako przykład na listingu 6.1 przedstawiony jest fragment aplikacji konsolowej poka-
zujący, jak w prosty i wygodny sposób zlecić jednoczesne wykonanie stu zadań (ko-
mentarz poniżej)1.
for(int i=0;i<100;i++)
{
listaZadan.Add(new Task(a));
}
listaZadan.ForEach(t=>t.Start());
listaZadan.ForEach(t=>t.Wait());
Rysunek 6.1.
Wynik działania
programu
z listingu 6.1
1
Wartość identyfikatora zadania przydzielana jest dopiero podczas odwołania do własności
Task.CurrentId w ramach zadania, a nie, jak można by się spodziewać, już podczas tworzenia zadania
(http://msdn.microsoft.com/en-us/library/system.threading.tasks.task.id.aspx). Dlatego, gdybyśmy
usunęli z akcji polecenie drukujące informację o starcie zadania, numery kończonych zadań
drukowane byłyby z kolejnymi liczbami. Gdybyśmy chcieli numerować dużą ilość zadań, powinniśmy
raczej przekazywać kolejne numery przy użyciu parametru (opis w następnym podrozdziale).
140 Programowanie równoległe i asynchroniczne w C# 5.0
We wnętrzu akcji zastosowano statyczną metodę SpinWait klasy Thread, która powo-
duje wykonanie podanej w argumencie tej metody liczby iteracji pętli. Wykorzystując
klasę Thread, należy pamiętać o uwzględnieniu jej przestrzeni nazw System.Threading
w sekcji poleceń using. Metoda Thread.SpinWait, w odróżnieniu od metody Thread.
Sleep (rozdział 2.), nie usypia bieżącego wątku, a w zamian wykonuje rzeczywiste
operacje obciążające procesor. Jak widać na rysunku 6.2, procesor jest w pełni obciążony.
Gdyby ten kod napisano sekwencyjnie, obciążany byłby tylko jeden rdzeń procesora,
co spowodowałoby, że procesor wykorzystywany byłby tylko w 50%.
Rysunek 6.2.
Widoczne w systemowym
menedżerze zadań
obciążenie obu rdzeni
procesora Intel Core 2
Duo w trakcie wykonania
programu korzystającego
z zadań
Aby dane przesyłane do zadania mogły być użyte, wyrażenie lambda będące pierwszym
argumentem konstruktora klasy Task zostało rozszerzone o argument. W praktyce wy-
gląda to następująco:
Task t = new Task((o) => { Console.WriteLine(o.ToString()); }, "Dzień dobry!");
Możliwe jest przekazanie tylko jednego parametru. To oznacza, że jeżeli chcemy przeka-
zać więcej danych, musimy zdefiniować własną strukturę, która będzie wykorzystywana
w charakterze parametru, lub przesłać indeks do danych zapisanych w kolekcjach.
Sposób użycia własności Result pokazany został na listingu 6.2, w którym zadanie
wykonuje kod wyrażenia lambda typu Func<String> zwracającego łańcuch „Dzień
dobry”.
t.Start();
t.Wait();
Console.WriteLine(t.Result);
reszty przez ów indeks. Jeżeli jest taki indeks pętli, dla której reszta równa jest zero
— badana liczba nie jest liczbą pierwszą. Algorytm można by — oczywiście —
usprawnić, ale nam właśnie zależy na tym, żeby czas jego wykonywania był jak naj-
dłuższy. Dzięki temu wyraźniej zobaczymy przyspieszenie wynikające ze zrówno-
leglenia.
Każdy z testów wykonywanych w pętli jest niezależny od innych, pętla może być
więc z powodzeniem zrównoleglona. Poszczególne zadania będą sprawdzać, czy zni-
ka reszta z dzielenia, przy czym ich parametrem będzie indeks pętli, a zwracaną war-
tością — zero w przypadku powodzenia i wartość parametru w przypadku porażki.
O ile zastosowanie typu object jako argumentu daje możliwość przekazywania zada-
niom różnych typów, o tyle korzystanie z niego w ciele zadania nie jest zbyt wygodne.
Zatem warto najpierw utworzyć zmienną pomocniczą o zadeklarowanym typie, a na-
stępnie zrzutować na nią obiekt otrzymany przez głowę metody zadania.
W tego typu algorytmach często przydatna jest możliwość wstrzymania pracy zadań.
W powyższym przykładzie, gdy jedno zadanie odkryje, że nie mamy do czynienia z liczbą
Rozdział 6. Zadania 143
Synchronizacja zadań
Wspomniałem już o metodzie Wait klasy Task, zatrzymującej bieżący wątek, aż do
zakończenia zadania, na rzecz którego została wywołana. Metoda ta jest użyteczna, gdy
mamy do czynienia z pojedynczymi zadaniami, a szczególnie wtedy, gdy pracujemy
tylko z jednym dodatkowym zadaniem. Gdy jednak mamy całą tablicę zadań, wywo-
ływanie metody Wait dla każdej instancji klasy Task z osobna przestaje być praktyczne.
W takiej sytuacji idealnym rozwiązaniem są dwie metody statyczne klasy Task, czyli
WaitAll i WaitAny. Metoda WaitAll każe bieżącemu wątkowi czekać na wykonanie
wszystkich wymienionych w jej argumentach zadań, natomiast metoda WaitAny wznawia
pracę wątku głównego po zakończeniu dowolnego ze wskazanych zadań. Sygnatury
powyższych metod zawierają słowo kluczowe params:
public static void WaitAll( params Task[] tasks )
A zatem ich argumentami może być zarówno tablica zadań, jak i dowolnie duży zbiór
zadań wymienianych po przecinku.
Obie metody są przeciążone. W ich pozostałych wersjach lista zadań określona jest
wprawdzie bez słowa kluczowego params, ale dzięki temu możemy również np. określić
czas (mierzony w milisekundach), przez który wątek główny czeka na zakończenie
wskazanych zadań:
public static bool WaitAll( Task[] tasks, int millisecondsTimeout )
2
public static bool WaitAny( Task[] tasks, TimeSpan timeout )
Zwracana przez powyższe metody wartość typu boolean informuje o tym, czy zadania
przerwane zostały z powodu upłynięcia czasu oczekiwania (wówczas wartość ta rów-
na jest false), czy dlatego, że spełniony został warunek w postaci wykonania zadań.
Z powodu braku modyfikatora params w tych wersjach metod lista zadań musi być
podana jawnie jako tablica.
2
Więcej na http://msdn.microsoft.com/en-us/library/dd235618(v=vs.110).aspx.
144 Programowanie równoległe i asynchroniczne w C# 5.0
t2.ContinueWith((t)=>
{
Console.WriteLine("Zadanie o identyfikatorze {1} zostało wykonane
po zakończeniu zadania t2 o identyfikatorze {0}", t.Id,Task.CurrentId);
});
3
Można wymusić, aby zadanie będące kontynuacją było wykonywane w tym samym wątku,
co zadanie pierwotne (tabela 6.1).
Rozdział 6. Zadania 145
t1 = new Task(a);
t2 = new Task(a);
t3 = t1.ContinueWith(b);
t4 = t2.ContinueWith(b);
t1.Start();
t2.Start();
Przerywanie zadań
Może się zdarzyć, że zadanie podczas wykonywania trzeba będzie przerwać. Odbywa
się to nieco inaczej niż w przypadku wątków (metoda Thread.Abort w rozdziale 2.).
Programista, przygotowując kod wykonywany przez zadanie, ma do dyspozycji klasę
CancellationToken, która pozwala na monitorowanie, czy nastąpiło wywołanie metody
Cancel na rzecz obiektu reprezentującego to zadanie. Jej wywołanie jest — oczywiście
— wezwaniem do przerwania zadania. Zasada działania tego mechanizmu jest bardzo
prosta: jeszcze przed utworzeniem zadania, np. w wątku głównym, tworzymy obiekt
typu CancellationTokenSource. Następnie jego składową, obiekt CancellationToken,
przekazujemy do zadania za pomocą argumentu konstruktora, dzięki czemu referencja
do niego znana jest zarówno wewnątrz zadania, jak i w wątku głównym. Wywołanie
metody CancellationTokenSource.Cancel z wątku głównego powoduje, że własność
IsCancellationRequested zaczyna zwracać wartość true. Jej wartość powinna być
146 Programowanie równoległe i asynchroniczne w C# 5.0
4
Tłumaczenie ze strony http://msdn.microsoft.com/en-us/library/system.threading.tasks.
taskcontinuationoptions(v=vs.110).aspx.
5
Cytat ze strony http://blogs.msdn.com/pfxteam/archive/2009/10/19/9909371.aspx.
Rozdział 6. Zadania 147
Samo zadanie nie może wywołać metody Cancel — kłóciłoby się to z przedstawioną
wyżej ideą przerywania zadania. Nie jest to zresztą potrzebne — do zakończenia za-
dania w dowolnym momencie można przecież użyć polecenia return kończącego wy-
konywany przez zadanie kod.
Należy podkreślić, że klasa Task nie zawiera innych, niż opisane powyżej, metod lub
pól związanych z przerwaniami. Użycie tokena przerwania to jedyny zalecany sposób
na przerywanie zadań.
t.Start();
Thread.Sleep(3000);
cts.Cancel();
t.Wait();
Jak wspomniałem, wywołanie metody Cancel spoza zadania powoduje wewnątrz tego
zadania zgłoszenie wyjątku OperationCanceledException (odpowiedzialna jest za to
metoda CancellationToken.ThrowIfCancellationRequested), który w powyższym ko-
dzie jest przechwycony. Token może być przekazany nie tylko do zadania, ale także
do metod, takich jak ContinueWith oraz Wait, WaitAll czy WaitAny. O ile w przypadku
148 Programowanie równoległe i asynchroniczne w C# 5.0
kontynuowania token przekazany jako drugi argument metody będzie po prostu tokenem
dla zadania pochodnego i mechanizm korzystania z niego będzie identyczny, o tyle
zastosowanie tokena przerwania w metodzie Wait wygląda już trochę inaczej. Ponieważ
metoda Wait wstrzymuje główny wątek, przerwanie musi pochodzić z innego zadania.
W przykładzie widocznym na listingu 6.8 przymknąłem oko na logikę działania pro-
gramu, aby lepiej zaprezentować to, jak wykonać przerwanie metody WaitAll.
t.Start();
Task[] zadania={t};
try
{
Task.WaitAll(zadania, ct);
}
catch (OperationCanceledException)
{
Console.WriteLine("Przerwano oczekiwanie");
}
Stan zadania
Stan każdego zadania, od momentu jego utworzenia aż do zakończenia, może być
opisany przy użyciu elementów typu wyliczeniowego TaskStatus. Można go spraw-
dzić, korzystając z własności Task.Status. Dostępne stany zadania wymienione zo-
stały w tabeli 6.2.
6
Zob. http://msdn.microsoft.com/en-us/library/dd997396.aspx.
7
Przetłumaczone z http://msdn.microsoft.com/en-us/library/system.threading.tasks.taskstatus(v=vs.110).aspx.
150 Programowanie równoległe i asynchroniczne w C# 5.0
Tabela 6.2. Lista stanów, w jakich może znaleźć się zadanie — ciąg dalszy
Stan Opis
RanToCompletion Wykonanie zadania dobiegło końca, tzn. zostało zakończone bez przerwania
metodą Cancel ani bez zgłoszenia jakiegokolwiek wyjątku.
Cancelled Zadanie zostało przerwane przez przypisany egzemplarz Cancelation
TokenSource; status ten jest przypisywany w momencie zgłoszenia wyjątku
OperationCanceledException, a nie w momencie wywołania metody Cancel.
Faulted Zadanie zostało przerwane przez wystąpienie wyjątku.
Thread.Sleep(200);
test.Start();
Task.WaitAll(test, obserwator);
{
ct.ThrowIfCancellationRequested();
}
}, ct, TaskCreationOptions.LongRunning);
Thread.Sleep(200);
test1.Start();
test2.Start();
try
{
Task.WaitAll(test1, test2, obserwator);
}
catch (AggregateException ae)
{
foreach (var exc in ae.InnerExceptions)
{
Console.WriteLine("Przechwycono wyjątek: {0}", exc.Message);
}
}
W tej wersji tworzone i uruchamiane są dwa zadania testowe test1 i test2. Następnie
program czeka, aż znajdą się w stanie Running. Pozwala to na przerwanie wykonywa-
nego zadania z jednoczesnym zgłoszeniem wyjątku oraz uniknięcie przerwania nie-
rozpoczętego zadania, co zaowocowałoby jedynie zmianą jego stanu. Aby oczekiwanie
to nie było zbyt długie, zadania tworzone są z opcją LongRunning, co gwarantuje urucho-
mienie drugiego zadania natychmiast po pierwszym, bez oczekiwania na ewentualne je-
go zakończenie. Przykładowy wynik wykonania programu widoczny jest na rysunku 6.3.
152 Programowanie równoległe i asynchroniczne w C# 5.0
Fabryka zadań
Klasa TaskFactory jest fabryką obiektów typu Task8. Dostarcza narzędzia do urucha-
miania i planowania zadań. Dostęp do domyślnej fabryki uzyskujemy za pomocą sta-
tycznej własności Factory klasy Task. Najważniejszą i najczęściej używaną metodą tego
obiektu jest StartNew, ale poza nią warto również zwrócić uwagę na metody Continue
WhenAll oraz ContinueWhenAny.
W wersji .NET 4.5 dodano w klasie Task nową metodę statyczną Run, będącą synoni-
mem Task.Factory.StartNew. Zamiast odnosić się jawnie do fabryki obiektów, można
zatem utworzyć zadanie, jednocześnie je uruchamiając, w następujący sposób:
Task t = Task.Run(() => { /*…*/ });
8
Fabryka obiektów to obiekt oferujący zestaw narzędzi do tworzenia innych obiektów typu ściśle
związanego z fabryką.
Rozdział 6. Zadania 153
Task<int> t2 = Task<int>.Factory.StartNew(
(o) => { Console.WriteLine(o.ToString()); return 1; },
(object)"Dzień dobry",
ct,
TaskCreationOptions.None,
TaskScheduler.Default
);
t2.Wait();
9
Więcej pod adresem http://msdn.microsoft.com/en-us/library/system.threading.tasks.taskfactory.aspx.
154 Programowanie równoległe i asynchroniczne w C# 5.0
t3 = (t1 = Task.Factory.StartNew(a)).ContinueWith(b);
t4 = (t2 = Task.Factory.StartNew(a)).ContinueWith(b);
Task[] zadania={t3,t4};
Task.Factory.ContinueWhenAny(zadania, (t) =>
{
Console.WriteLine("Zawodnik nr {0} wygrał wyścig!", t.Id);
});
Task.WaitAll(zadania);
Console.WriteLine("Wyścig zakończony");
Jeżeli chcemy utworzyć grupę zadań z takimi samymi ustawieniami, możemy zbudo-
wać własną fabrykę zadań przechowującą opcje wykorzystywane później w każdym
tworzonym przez nią zadaniu. Obejmuje to klasy CancellationToken, TaskCreation
Options, TaskContinuationOptions i TaskScheduler. Ostatnia jest szczególnie ważna,
gdy fabryka korzysta z własnego mechanizmu kolejkowania zadań. Przygotowując
własną instancję klasy TaskFactory, wystarczy przekazać zmodyfikowanego plani-
stę i wówczas zadania utworzone w tej fabryce będą w odpowiedni sposób zarządzane.
To rozwiązanie wykorzystamy przy okazji omawiania klasy TaskScheduler. Nato-
miast w przykładzie z listingu 6.14 przy użyciu konstruktora fabryki przekazano tylko
token umożliwiający jednoczesne przerywanie zadań. Token ten będzie wspólny dla
wszystkich zadań z tej fabryki, dzięki czemu każde zadanie utworzone i uruchomione
w ten sposób zostanie przerwane przy pierwszym wywołaniu metody cts.Cancel.
while (true)
{
Thread.Sleep(500);
ct.ThrowIfCancellationRequested();
}
});
cts.Cancel();
try
{
a.Wait();
}
catch (AggregateException)
{
Console.WriteLine("Koniec!");
}
Planista i zarządzanie
kolejkowaniem zadań
Mechanizm zarządzania równoległym wykonywaniem zadań implementowanych
przez klasę Task realizowany jest przez planistę (klasa TaskScheduler). Klasa ta opie-
ra swoje działanie na znanej z wcześniejszych wydań platformy .NET puli wątków
ThreadPool. Implementuje ona kolejkę wątków do wykonania, uwzględniającą moż-
liwości i obciążenie procesorów. Klasa TaskScheduler idzie o krok do przodu względem
ThreadPool. Jej działanie łączy cechy kolejki FIFO z możliwością ingerencji użyt-
kownika w ustalanie kolejności. Kolejka zadań wewnątrz klasy TaskScheduler jest
kolekcją typu ConcurrentQueue (rozdział 9.), w dokumentacji MSDN opisywaną jako
thread-safe FIFO. Klasa ta implementuje m.in. interfejs IProducerConsumerCollection:
public class ConcurrentQueue<T> : IProducerConsumerCollection<T>, …
10
Dla przypomnienia: rozwiązanie problemu producent-konsument polega na takim zsynchronizowaniu
wątków, aby wątki dostarczające dane nie dodawały ich do pełnego bufora, a wątki pobierające nie
pobierały danych z pustego bufora. Problem ten opisany został szerzej w rozdziale 4. Przedstawiony
zostanie również w kontekście zadań w rozdziale 8.
156 Programowanie równoległe i asynchroniczne w C# 5.0
Tworzenie własnych instancji klasy TaskScheduler nie jest ani konieczne, ani często
stosowane, lecz może czasem pozwolić na zwiększenie wydajności programu. Budo-
wanie własnego mechanizmu zarządzania zadaniami polega na definiowaniu klasy
potomnej z abstrakcyjnej klasy TaskScheduler. Wymaga to nadpisania trzech metod:
GetScheduledTasks — zwracającej aktualną kolejkę zadań do wykonania,
QueueTask — odpowiedzialnej za dodawanie nowego zadania do kolejki,
TryExecuteTaskInline — sprawdzającej, czy zadanie może zostać wykonane
synchronicznie i wykonującej je w ten sposób, jeżeli to możliwe.
Oczywiście, nie jest to klasa nadająca się do praktycznego użycia. Pisząc własną klasę,
należy utworzyć własną kolejkę zadań. Nie jest istotne, jakiego typu struktury danych
użyjemy. Ważne, aby można było ją rzutować na interfejs IEnumerable. Tego wymaga
metoda GetScheduledTasks. Konieczne jest także samodzielne tworzenie wątków mają-
cych wykonywać zadania. Najlepszym miejscem do tego jest konstruktor klasy po-
tomnej, w którym uruchamiamy te wątki w tle. Ponieważ brakuje metod klasy bazowej
odpowiedzialnych za zarządzanie i wykonanie zadań, najlepiej wykorzystać do tego
celu zwykłe wątki (klasa Thread). Pokazuję to na listingu 6.16.
Rozdział 6. Zadania 157
public Planista()
{
watekGlowny = new Thread(() =>
{
Console.WriteLine("Planista utworzony.");
while (true)
{
if (kolejka.Count > 0)
{
int i = new Random().Next(kolejka.Count);
if (TryExecuteTask(kolejka[i])) kolejka.RemoveAt(i);
}
}
});
watekGlowny.IsBackground = true;
watekGlowny.Start();
}
planisty opiera się na nieskończonej pętli sprawdzającej, czy kolejka zadań nie jest
pusta. Jeżeli znajdują się w niej zadania, jedno z nich jest wybierane losowo, uruchamia-
ne i usuwane z kolejki. Dzięki losowemu wyborowi zadania będziemy mogli łatwo spraw-
dzić, czy za kolejkowanie zadań odpowiada nasz, czy domyślny planista (rysunek 6.4).
Rysunek 6.4.
Przykładowy
wydruk programu
wykorzystującego
własnego planistę.
Widać, że zadania
uruchamiane są
po kolei, natomiast
wykonywane losowo
Jak wspominałem wyżej, tworzenie grupy zadań zarządzanych przez własnego plani-
stę można zrealizować, korzystając z własnej fabryki zadań. W jej konstruktorze na-
leży podać referencję do instancji owego planisty, a fabryka użyje go do zarządzania
wszystkimi tworzonymi przez siebie zadaniami (analogicznie do przykładu z tokenem
przerywania zadań z listingu 6.14). Pokazuję to na listingu 6.17.
Ustawienia zadań
Jak wspomniałem wcześniej, argumentami konstruktora klasy Task, poza akcją i da-
nymi, mogą być również opcje określające, w jaki sposób zadanie ma być wykony-
wane i zarządzane. Za zarządzanie kolejnością i priorytetami zadań odpowiada omó-
wiony wyżej mechanizm planisty zadań zaimplementowany w klasie TaskScheduler.
Warto wspomnieć, że do zadania można również przekazać ustawienia, które mogą
mieć wpływ na działanie domyślnego planisty. Możliwe opcje zgromadzone są w typie
wyliczeniowym TaskCreationOptions (tabela 6.3).
***
Opisane powyżej składowe biblioteki Parallel Extensions, a właściwie jej główna klasa
Task wraz z klasami powiązanymi, tworzą idealny interfejs do budowania aplikacji
równoległych. Zrezygnowanie z kreowania dużej ilości wątków pozwala ograniczyć
11
Działanie tej metody jest dokładnie opisane na stronie http://blogs.msdn.com/pfxteam/archive/
2009/10/15/9907713.aspx.
12
Przetłumaczone ze strony http://msdn.microsoft.com/en-us/library/system.threading.tasks.
taskcreationoptions(v=vs.110).aspx.
160 Programowanie równoległe i asynchroniczne w C# 5.0
zużycie pamięci programu i nie tracić wydajności. Na bazie tej klasy zaprojektowane
są omówione w następnych dwóch rozdziałach zrównoleglone pętle For, ForEach,
While i klasa Parallel stojąca za równoległymi wyrażeniami PLINQ.
Zadania
1. Na podstawie programu z listingu 6.16 zaimplementuj własny TaskScheduler,
który przydzielać będzie zadania do więcej niż jednego wątku na zasadzie:
a) kolejki (FIFO),
b) stosu (LIFO).
2. Dodaj do programu z listingu 6.11 nowe zadanie Test3 oraz powiąż je relacją
przodek-potomek z zadaniem Test1 tak, aby Test3 był zadaniem nadrzędnym dla
Test1. Zaobserwuj w oknie konsoli zmianę stanów nowego zadania w zależności
od potomka.
3. Odpowiedz na pytanie, czy istnieje sposób, aby (bez przechowywania
referencji do obiektu typu Task):
a) z zadania-potomka odwołać się do przodka?
b) z zadania-przodka odwołać się do potomka?
4. Korzystając z zadań i funkcji zwrotnych, spróbuj odtworzyć funkcjonalność
operatora await w przykładzie z rozdziału 1.
Rozdział 7.
Klasa Parallel.
Zrównoleglanie pętli
Mateusz Warczak
Najprostsze wywołanie przyjmuje jako argumenty tylko indeks początkowy, ilość ite-
racji oraz akcję (obiekt typu Action<int>), którą można utożsamiać z ciałem trady-
cyjnej pętli for. W tej najprostszej wersji akcja przyjmuje tylko jeden argument typu
int — licznik pętli. Od razu rzuca się w oczy, iż programując pętle równoległe, należy
porzucić przyzwyczajenia do swobodnego definiowania warunków pętli (znanego już
z języka C), a powrócić do typowej dla języka Pascal iteracji według kolejnych liczb
całkowitych — z góry musi być znana ilość zadań, jaka ma być utworzona; nie może
ona zależeć od wartości zmiennej modyfikowanej w kolejnych iteracjach. Z tego samego
powodu niemożliwa jest „ręczna” modyfikacja indeksu wewnątrz ciała zrównoleglo-
nej pętli. Zresztą nie ma klasycznego indeksu rozumianego jako zmienna, do której
dostęp możliwy jest we wszystkich iteracjach pętli. Wartość indeksu przekazywana
jest wprawdzie do akcji wykonywanej w iteracji, ale jest zmienną lokalną, a ściślej
argumentem tej akcji. Przebieg opierać się może tylko na liczbach całkowitych (32- lub
64-bitowych), nie ma możliwości zastosowania zmiennych typu float czy double, o wła-
snych iteratorach nawet nie wspominam. Z punktu widzenia elastyczności języka można
to rozumieć jako krok wstecz, ale jest on wymuszony warunkami współbieżności i przez
to dość typowy dla wszystkich mechanizmów zrównoleglania kodu. Przykładowa pętla
równoległa przedstawiona została na listingu 7.2.
1
Szczegółowy opis metod klasy Parallel: http://msdn.microsoft.com/en-us/library/
system.threading.tasks.parallel(v=vs.110).aspx.
Rozdział 7. Klasa Parallel. Zrównoleglanie pętli 163
Listing 7.3. Sprawdzanie za pomocą For, czy liczba jest liczbą pierwszą
Console.Write("Sprawdź, czy liczba pierwsza: ");
int n = Int32.Parse(Console.ReadLine());
2
Niżej zostanie zaprezentowana wersja tego kodu, który przerywa działanie po znalezieniu pierwszego
dzielnika (listing 2.6).
164 Programowanie równoległe i asynchroniczne w C# 5.0
Aby zaprezentować działanie tego typu pętli, posłużę się zmodyfikowaną wersją po-
przedniego przykładu, podmieniając jedynie metodę realizującą pętlę. Jak widać na
listingu 7.4, wywołanie metody Parallel.ForEach różni się nieznacznie od klasycznej
pętli foreach. Ponieważ nie można tu zastosować składni z wykorzystaniem operatora
in, odpowiednie dane przekazywane są jako argumenty metody. Pierwszym z nich jest
kolekcja. Tworzę ją za pomocą statycznej metody Enumerable.Range, która buduje se-
kwencję liczb naturalnych. Jej argumentami są początek zakresu oraz ilość elementów
zbioru.
Listing 7.4. Sprawdzanie za pomocą ForEach, czy liczba jest liczbą pierwszą
Console.Write("Sprawdź, czy liczba pierwsza: ");
int n = Int32.Parse(Console.ReadLine());
if (pierwsza)
{
Console.WriteLine("Liczba {0} jest liczbą pierwszą", n);
}
Metoda Invoke
Klasa Parallel zawiera również definicję metody Invoke przyjmującej jako argument
dowolną ilość akcji reprezentujących bloki kodu, które będą w miarę możliwości wy-
konywane równolegle. Może się to przydać, gdy potrzebne jest równoczesne wyko-
nanie dwóch lub więcej procedur. Metoda Invoke ma tę zaletę, w porównaniu z uru-
Rozdział 7. Klasa Parallel. Zrównoleglanie pętli 165
chamianiem odrębnych zadań przy użyciu bezpośrednio klasy Task, że blokuje dalsze
wykonanie bieżącego wątku i oczekuje na zakończenie pracy zadań. Dzięki temu nie
trzeba dodatkowo wywoływać metod Wait czy WaitAll.
public static void Invoke( params Action[] actions )
public static void Invoke( ParallelOptions parallelOptions, params Action[] actions
)
DoTree(tree, myAction);
}
3
Przykład jest modyfikacją kodu dostępnego na stronie http://msdn.microsoft.com/en-us/library/
vstudio/dd557750%28v=vs.100%29.aspx.
166 Programowanie równoległe i asynchroniczne w C# 5.0
() => action(tree.Data),
() => DoTree(tree.Left, action),
() => DoTree(tree.Right, action)
);
}
}
Przerywanie pętli
za pomocą CancelationToken
Aby zilustrować użycie klasy CancellationToken w zrównoleglonych pętlach Parallel.For
i Parallel.ForEach, wrócę do przykładu sprawdzania liczby pierwszej (listing 7.3).
Zmodyfikuję go w taki sposób, aby pętla została przerwana w momencie znalezienia
dzielnika. Nowa wersja kodu widoczna jest na listingu 7.6. Kod znacznie się wydłużył;
najważniejsze zmiany w porównaniu z wersją bez możliwości przerwania pętli zostały
wyróżnione. Przerwanie pętli następuje po znalezieniu dowolnego dzielnika liczby n.
4
Własności opisane są na stronie http://msdn.microsoft.com/en-us/library/system.threading.tasks.
paralleloptions.aspx.
Rozdział 7. Klasa Parallel. Zrównoleglanie pętli 167
Oczywiście, nie musi to być najmniejszy dzielnik, jak byłoby w przypadku sekwen-
cyjnego wykonywania pętli. Przerwanie pętli spowoduje, że zadania czekające w ko-
lejce nie zostaną uruchomione. Należy się jednak liczyć z tym, że zadania będące już
w trakcie wykonywania dobiegną do końca — może to spowodować wypisanie więcej
niż jednego dzielnika. Uruchomienie kodu z listingu 7.6 wymaga zadeklarowania użycia
przestrzeni nazw System.Threading, w której zdefiniowana jest klasa CancellationToken.
try
{
Parallel.For(2, (int)Math.Sqrt(n)+1, po, (i) =>
{
if (n % (int)i == 0)
{
cts.Cancel();
}
ct.ThrowIfCancellationRequested();
});
Na listingu 7.7 przedstawiam kod rozwiązujący ten sam problem, ale zaimplementowany
przy użyciu równoległej pętli Parallel.ForEach. Wyróżnione zostały różnice w kodzie
źródłowym w stosunku do listingu 7.6.
try
{
Parallel.ForEach<int>(Enumerable.Range(2, (int)Math.Sqrt(n) - 1), po, (i) =>
{
if (n % (int)i == 0)
{
cts.Cancel();
}
ct.ThrowIfCancellationRequested();
});
Z kolei struktura ParallelLoopResult jest typem zwracanym przez metody For i ForEach.
Pozwala na sprawdzenie stanu pętli po jej zakończeniu. Przykład jej użycia widoczny
jest na listingu 7.8, na którym prezentuję też użycie metody ParallelLoopState.Stop.
Struktura ParallelLoopResult posiada jedynie dwie własności: IsStopped i Lowest
BreakIteration. Ich możliwe wartości są takie same jak analogiczne własności klasy
ParallelLoopState (tabela 7.2). Za ich pomocą jesteśmy w stanie stwierdzić, czy
wszystkie iteracje pętli zostały wykonane.
Rozdział 7. Klasa Parallel. Zrównoleglanie pętli 169
Głównym elementem powyższej metody jest pętla for wykonująca n iteracji. W każdym
przebiegu losowany jest punkt wewnątrz kwadratu i sprawdzane jest, czy leży on we-
wnątrz ćwiartki koła o promieniu 1.
Algorytm można w łatwy sposób zrównoleglić, tworząc osobne zadanie dla każdego
losowania. Ponieważ jednak wersja sekwencyjna opiera się na pętli for, nie warto
samodzielnie tworzyć instancji klasy Task. O wiele prościej będzie, gdy wykorzystamy
metodę Parallel.For (listing 7.10).
Parallel.For(
0,
n,
(i) =>
{
x = r.NextDouble();
y = r.NextDouble();
if (x * x + y * y < 1) k++;
});
return 4.0 * k / n;
}
Niestety, powyższy kod współbieżny jest wadliwy. Nie zastosowano w nim synchro-
nizacji w miejscach, w których jednoczesne wykonywanie fragmentów kodu może po-
wodować przekłamanie wyników. Dotyczy to zmiennej k przechowującej sumę punktów
leżących wewnątrz koła. Inkrementacja tej zmiennej nie powinna być wykonywana
przez więcej niż jeden wątek w danej chwili. Drugim takim miejscem jest generowanie
liczb losowych. Klasa Random nie została przystosowana do wykorzystania w aplika-
cjach wielowątkowych. W obu przypadkach prostą synchronizację można zrealizować
przy użyciu operatora lock. Pokazuję to na listingu 7.11.
Parallel.For(
0,
n,
(i) =>
{
lock(r) {x = r.NextDouble(); y = r.NextDouble();}
if (x * x + y * y < 1) lock(ks){k++;}
});
return 4.0 * k / n;
}
Parallel.For(
0, //fromInclusive
n, //toExclusive
() => 0, //localInit
(i, stanPetli, sumaCzesciowa) => //body
{
lock (r) { x = r.NextDouble(); y = r.NextDouble(); }
if (x * x + y * y < 1) sumaCzesciowa++;
return sumaCzesciowa;
},
(sumaCzesciowa) => { Interlocked.Add(ref k, sumaCzesciowa); } //localFinally
);
return 4.0 * k / n;
}
Kolejny argument określa działanie wykonywane w każdej iteracji pętli. Jest to delegat
typu Func<int,ParallelLoopState,int,int>, tj. odpowiada trójargumentowej metodzie
zwracającej liczbę całkowitą. Pierwszym argumentem jest bieżąca wartość indeksu
pętli dla danej iteracji. Drugim — aktualny stan realizującego tę iterację zadania. Obu
nie będziemy używać w tym przykładzie. Ostatnim argumentem jest lokalna zmienna
sumaCzesciowa, która będzie zmodyfikowana w iteracji, jeżeli wylosowany punkt znaj-
dzie się w obrębie koła. Po jej ewentualnej aktualizacji zwracamy jej wartość jako war-
tość akcji wykonywanej w każdej iteracji pętli.
6
Należy pamiętać, że ogromna ilość zadań (instancji klasy Task) wykorzystywanych w pętli
Parallel.For realizowana jest przez jedynie kilka wątków (instancji klasy Thread). Ich ilość jest
dobierana automatycznie i porównywalna z ilością rdzeni wszystkich procesorów dostępnych
w komputerze.
Rozdział 7. Klasa Parallel. Zrównoleglanie pętli 173
Pozostał jeszcze problem klasy Random. Nadal jej użycie jest synchronizowane za pomocą
zwykłej sekcji krytycznej. Problem ten można rozwiązać, przydzielając osobny generator
do każdego wątku. Pomysł ten zrealizujemy, tworząc klasę RandomThreadSafe7 (listing
7.13). Klasa ta jest w istocie odpowiednikiem klasy Random przystosowanym do wy-
korzystania w aplikacjach wielowątkowych. W odróżnieniu od klasy Random jest klasą
statyczną; zatem nie musimy się troszczyć o tworzenie jej instancji i przekazywanie
jej referencji do różnych części programu. Należy zwrócić uwagę, że w klasie Random
ThreadSafe (poza momentem inicjacji) nie są stosowane sekcje krytyczne. Nie jest też
tworzony chwilowy obiekt typu Random dla każdego wywołania metody NextDouble.
Rozwiązanie jest nieco bardziej finezyjne. Klasa wyposażona jest w globalny obiekt
typu Random (pole _global), który wykorzystywany jest jedynie do tworzenia ziaren
dodatkowych obiektów przechowywanych w polu _local. Oba pola są statyczne (zresztą
cała klasa jest statyczna), jednak to drugie pole dzięki atrybutowi ThreadStatic (roz-
dział 3.) jest rozdzielone na instancje lokalne w każdym wątku. Obecność lokalnej in-
stancji sprawdza metoda NextDouble i jeśli trzeba (tzn. gdy pole _local jest równe
null), tworzy obiekt klasy Random. Taka sytuacja ma miejsce jedynie raz dla każdego
wątku użytego do wykonywania zadań i lokalny generator będzie widoczny tylko w tym
wątku. Podczas tworzenia wątku synchronizowany jest moment, w którym z globalnego
generatora liczb losowych odczytywane jest ziarno dla nowego generatora (w ten spo-
sób liczby zwracane przez poszczególne generatory są różne). Ponieważ to odbywa
się tylko raz w każdym wątku, nie ma wpływu na wydajność całego programu. Przy
kolejnych uruchomieniach metody NextDouble, gdy generatory już istnieją, synchroni-
zacja nie jest potrzebna.
7
Kod klasy RandomThreadSafe oparty jest na pierwowzorze ze strony http://blogs.msdn.com/pfxteam/
archive/2009/02/19/9434171.aspx. Zmodyfikowany został jedynie w taki sposób, że zamiast
losowych liczb całkowitych zwraca liczby rzeczywiste. Na tej stronie znaleźć również można
dokładniejszy opis zagadnienia synchronizacji generowania liczb pseudolosowych.
174 Programowanie równoległe i asynchroniczne w C# 5.0
Listing 7.13. Wielowątkowy generator liczb pseudolosowych oraz korzystająca z niego wersja metody
ObliczPiRownolegle
public static class RandomThreadSafe
{
private static Random _global = new Random();
[ThreadStatic]
private static Random _local;
Parallel.For(
0,
n,
() => 0,
(i, stanPetli, sumaCzesciowa) =>
{
x = RandomThreadSafe.NextDouble();
y = RandomThreadSafe.NextDouble();
if ((x * x + y * y) < 1)
{
sumaCzesciowa++;
}
return sumaCzesciowa;
},
(sumaCzesciowa) =>
{
Interlocked.Add(ref k, sumaCzesciowa);
}
);
return 4.0 * k / n;
}
Rozdział 7. Klasa Parallel. Zrównoleglanie pętli 175
Partycjonowanie danych
Dzięki synchronizacji kod obliczający liczbę jest bezpieczny. Jednak czas jego wy-
konania jest nadal dłuższy niż czas wykonania algorytmu sekwencyjnego (listing 7.9).
Powodem jest to, że krok pętli równoległej wykonuje za mało obliczeń. Przez to więcej
czasu zajmuje zarządzanie zadaniami niż obliczenia. Nie oznacza to jednak, że tego pro-
blemu nie da się tak zrównoleglić, aby obliczenia skrócić. Działanie pętli Parallel.For
opiera się na przypisaniu zadania do każdego kroku pętli. Oczywiście, przydzielenie
tych zadań do wątków wykonywane jest automatycznie i nie zawsze jest to najbardziej
optymalne rozwiązanie dla danego problemu. W rozpatrywanym przykładzie rozwią-
zaniem jest zwiększenie ilości obliczeń w obrębie akcji body. W tym celu zmniejszona
zostanie ilość kroków pętli równoległej, a w każdym jej kroku wykonywana będzie
pętla sekwencyjna (por. implementację metody Monte Carlo z rozdziału 2.). Bibliote-
ka TPL udostępnia odpowiednie narzędzia, które pozwalają na ustalenie optymalnej
ilości kroków pętli nadrzędnej (równoległej) i przydzielenie zakresów pętlom pod-
rzędnym. Odpowiada za to klasa Partitioner, która udostępnia metodę Create służącą
do tworzenia zbioru przedziałów (obiektu typu OrderablePartitioner). Na listingu
7.14 przedstawiam przykład użycia tego sposobu. W tym przykładzie podstawą me-
tody liczącej π jest pętla ForEach, która jednak nie różni się znacząco argumentami
wywołania od wykorzystywanej wcześniej metody For. Jedyną różnicą jest przeka-
zywanie zamiast pary liczb 0,n obiektu tworzonego przez wywołanie Partitioner.
Create(0,n).
Parallel.ForEach(
Partitioner.Create(0, n),
() => 0,
(przedzial, stanPetli, sumaCzesciowa) =>
{
Random r = new Random(Task.CurrentId.Value + System.Environment.TickCount);
return 4.0 * k / n;
}
176 Programowanie równoległe i asynchroniczne w C# 5.0
Tabela 7.3. Czas obliczeń w milisekundach (uśredniony dla 50 prób). Uzyskane przyspieszenie jest na
tym samym poziomie, co w przypadku wyników z tabeli 2.18
Procesor dwurdzeniowy Procesor czterordzeniowy
Równo-
Sekwencyjnie legle Sekwencyjnie Równolegle
Ilość prób Przyspieszenie Przyspieszenie
(listing 7.9) (listing (listing 7.9) (listing 7.14)
7.14)
106 56 31 1,81 28 8 3,50
107 539 293 1,84 280 81 3,46
8
10 5513 3023 1,82 2793 767 3,64
109 53 283 27 631 1,93 27 806 7910 3,52
się wobec tego również czas wykorzystywany na tworzenie zadań i zarządzanie nimi, co
zaowocowało skróceniem obliczeń i uzyskaniem przyspieszenia w porównaniu z wy-
konaniem sekwencyjnym. W tabeli 7.3 przedstawiam wyniki przeprowadzonych testów
oraz uzyskane przyspieszenia — stosunek czasu wykonania sekwencyjnego do rów-
noległego. Przeprowadzone testy wykazały przyspieszenie stanowiące 90% możliwego
maksymalnego przyspieszenia, co jest bardzo zadowalającym wynikiem.
***
Zadania
1. W oparciu o program z listingu 7.5 utwórz program przeszukujący drzewo
o dowolnej ilości potomków każdego wierzchołka.
2. Wprowadź do pętli w programie z listingu 7.14 ograniczenie
MaxDegreeOfParallelism o różnych wartościach i porównaj wyniki czasowe.
3. Utwórz program sprawdzający, czy podana liczba jest liczbą pierwszą,
który będzie oparty na klasie Partitioner i pętli ForEach.
178 Programowanie równoległe i asynchroniczne w C# 5.0
Rozdział 8.
Synchronizacja zadań
Jacek Matulewski
Blokady (lock)
Na listingu 8.1 przedstawiony jest zmodyfikowany kod z listingu 4.1 (jeszcze bez mody-
fikacji chroniących przed zakleszczeniem). Klasy Konto, PoleceniePrzelewu są iden-
tyczne z klasami z oryginału, dlatego ich mniej istotne metody pominąłem na listingu.
Zmieniona jest jedynie metoda Main, w której zamiast metody ThreadPool.QueueUser
WorkItem zadającej pracę wątkowi z puli wątków, użyłem metody Task.Factory.
StartNew, aby utworzyć zadanie.
using System.Threading;
namespace Zakleszczenie
{
class Program
180 Programowanie równoległe i asynchroniczne w C# 5.0
{
class Konto
{
private decimal saldo;
private int id;
...
class PoleceniePrzelewu
{
public Konto KontoPłatnika;
public Konto KontoOdbiorcy;
public decimal Kwota;
}
Action<object> transakcja =
Rozdział 8. Synchronizacja zadań 181
Przekonamy się, że pojawi się zakleszczenie zadań, a raczej kryjących się za nimi
wątków. I znowu najprostszym rozwiązaniem jest uporządkowanie zasobów i rezer-
wowanie kont w kolejności rosnących numerów id, czyli zmodyfikowanie metody
Przelew zgodnie z listingiem 4.2 z rozdziału 4.
Na podobnej zasadzie w zadaniach będą działać również inne typy blokad, np. Reader
WriterLock i ReaderWriterLockSlim, czy sekcje krytyczne tworzone za pomocą klas
Mutex i Semaphore.
1
Można wprawdzie użyć konstrukcji Task.Delay(100).Wait(); tworzącej dodatkowe zadanie odczekujące
1/10 sekundy i wymuszającej czekanie na nie lub wręcz konstrukcji await Task.Delay(1000); (po
oznaczeniu metody, w której umieszczamy te instrukcje modyfikatorem async), ale Thread.Sleep jest
zdecydowanie bardziej eleganckie — usypia po prostu bieżący wątek, czyli wątek, w ramach którego
działa bieżące zadanie.
182 Programowanie równoległe i asynchroniczne w C# 5.0
using System.Threading;
namespace ProducentKonsument
{
class Program
{
static object obiektSynchronizacjiMagazynu = new object();
static object obiektSynchronizacjiProducenta = new object();
static object obiektSynchronizacjiKonsumenta = new object();
static Random r = new Random();
Thread.Sleep(r.Next(maksymalnyCzasUruchomieniaProdukcji));
Console.WriteLine("Zadanie producenta zostało wznowione");
}
lock (obiektSynchronizacjiKonsumenta)
Monitor.Pulse(obiektSynchronizacjiKonsumenta);
Thread.Sleep(r.Next(maksymalnyCzasProdukcji));
}
};
Action akcjaKonsumenta =
() =>
{
Console.WriteLine("Zadanie konsumenta jest uruchamiane");
while (true)
{
lock (obiektSynchronizacjiMagazynu)
{
licznikElementowWMagazynie--;
Console.Write("Element zabrany. ");
}
wyswietlStanMagazynu();
if (licznikElementowWMagazynie <= 0)
{
Console.WriteLine("Zadanie konsumenta zostanie uśpione");
lock (obiektSynchronizacjiKonsumenta)
Monitor.Wait(obiektSynchronizacjiKonsumenta);
Console.WriteLine("Zadanie konsumenta zostanie wznowione");
Thread.Sleep(r.Next(maksymalnyCzasUruchomieniaKonsumpcji));
Console.WriteLine("Zadanie konsumenta zostało wznowione");
}
lock (obiektSynchronizacjiProducenta)
Monitor.Pulse(obiektSynchronizacjiProducenta);
Thread.Sleep(r.Next(maksymalnyCzasKonsumpcji));
}
};
Console.ReadLine();
Console.Write("Koniec. "); wyswietlStanMagazynu();
}
}
}
184 Programowanie równoległe i asynchroniczne w C# 5.0
Bariera
Nie będzie pewnie zaskoczeniem, że także stosując barierę użytą dla zadań (listing 8.3),
otrzymamy efekt podobny do uzyskanego dla wątków (listing 4.7).
using System.Threading;
namespace BarrierDemo
{
class Program
{
const int ileZadan = 10;
static Barrier b = new Barrier(ileZadan, (Barrier _b) => {
Console.WriteLine(); });
Console.ReadLine();
}
}
}
Rozdział 8. Synchronizacja zadań 185
Warto zwrócić uwagę na dziwny efekt pojawiający się w tej wersji programu. Aplika-
cja powinna działać bardzo szybko — jej działanie ogranicza się przecież do wyświe-
tlenia paru cyfr. Szybko pojawia się kilka zer (ich liczba zależy od ilości dostępnych
rdzeni), a kolejne wolniej. Dopiero następne cyfry pojawiają się niemal natychmiast.
Tego efektu nie było w przypadku wątków (listing 4.7), ale pojawiłby się, gdybyśmy
zamiast prostej tablicy samodzielnie tworzonych wątków użyli puli wątków (listing
8.4). Związane to jest z planistą zarządzającym wątkami w puli. Uruchamia on tylko
tyle wątków, ile ma dostępnych rdzeni. Nowe wątki dodaje dopiero wtedy, gdy po za-
kończeniu poprzednich „przekona się”, że są na to zasoby (procesor jest nieobciążony).
Ten efekt pojawia się także w zadaniach, które korzystają z puli wątków.
Console.ReadLine();
}
}
186 Programowanie równoległe i asynchroniczne w C# 5.0
Rozdział 9.
Dane w programach
równoległych
Mateusz Warczak
z odczytem. W tych sytuacjach konieczne jest użycie np. sekcji krytycznych, które
gwarantują, że w danej chwili dostęp do źródła danych ma tylko jeden wątek.
1
Implementacja własnej kolekcji wykorzystująca ten scenariusz przedstawiona jest dalej w tym rozdziale
(w punkcie „Własna kolekcja współbieżna”).
Rozdział 9. Dane w programach równoległych 189
Kolekcja ConcurrentBag
Często używana klasa ConcurrentBag zoptymalizowana jest do pracy z wieloma wąt-
kami bądź zadaniami, wśród których nie można wyróżnić producentów i konsumentów,
a więc wątków, które tylko zapisywałyby dane do kolekcji i tylko z niej czytały. Istotne
jest również to, że klasa ta nie rozróżnia duplikatów elementów. Ta ostatnia własność
pozwala na znaczne zwiększenie wydajności. Zasada działania klasy ConcurrentBag
przypomina nieco zasadę działania opisanej w rozdziale 7. klasy RandomThreadSafe.
Polega ona na tworzeniu osobnych kolejek dla każdego wątku czytającego lub zapi-
sującego dane w kolekcji. Wątki korzystają ze swoich kolejek praktycznie bez żadnej
synchronizacji. Dopiero wtedy, gdy wystąpi opróżnienie kolejki jednego z wątków,
następuje zsynchronizowana wymiana danych pomiędzy wątkami.
//pobieranie elementów
int element;
string s = "Elementy zdjęte z kolejki (" + kolejka.Count + " elementów):\n";
Praca z BlockingCollection
Klasa BlockingCollection<> również należy do przestrzeni nazw System.Collections.
Concurrent. Jednak jej sposób działania różni się od innych kolekcji z tej przestrze-
ni, np. opisanych wyżej klas ConcurrentQueue<> i ConcurrentStack<>. Klasa ta stanowi
jedynie opakowanie (ang. wrapper) dla innych kolekcji implementujących interfejs
Rozdział 9. Dane w programach równoległych 191
Parallel.Invoke(
producent,
konsument
);
Klasą bazową nie musi być jednak kolejka. Proponuję zamienić pierwszą instrukcję na
BlockingCollection<int> kolekcja = new BlockingCollection<int>
(new ConcurrentStack<int>(),3);
2
Ta ostatnia klasa nie implementuje interfejsu IProducerConsumerCollection (ze względu na strukturę
jej elementów). Jest jednak z nim „zgodna ideowo”.
194 Programowanie równoległe i asynchroniczne w C# 5.0
public MojStos()
{
stos = new Stack<T>();
}
3
Klasa ta jest oparta na przykładzie umieszczonym na stronie http://msdn.microsoft.com/en-us/
library/dd287147.aspx.
196 Programowanie równoległe i asynchroniczne w C# 5.0
IEnumerator IEnumerable.GetEnumerator()
{
return ((IEnumerable<T>)this).GetEnumerator();
}
Jak widać na listingu 9.4, klasa MojStos<> jest opakowaniem dla zwykłego stosu.
Obiekt Stack<> jest jej polem prywatnym. Jedynym zadaniem tej klasy jest synchro-
niczny (w obrębie wątku) dostęp do elementów stosu. Uzyskujemy to, korzystając ze
zwykłego operatora lock. Szczegółowo zdefiniowane zostały wymuszone przez inter-
fejs IProducerConsumerCollection<> metody TryAdd i TryTake. W naszej implementa-
cji operacja TryAdd zawsze kończy się sukcesem. Jej działanie opiera się na metodzie
Push stosu. Natomiast metoda TryTake wymaga sprawdzenia, czy zbiór nie jest pusty.
Aby przetestować działanie klasy MojStos, wystarczy w listingu 9.3 podmienić pierwszą
linię kodu na następującą:
BlockingCollection<int> kolekcja = new BlockingCollection<int>(
new MojStos<int>(),
3);
Agregacja
W platformie .NET od wersji 3.5 wśród rozszerzeń zdefiniowanych dla interfejsu
IEnumarable<> pojawiło się bardzo przydatne rozszerzenie o nazwie Aggregate. Jest
ono również obecne w nowych kolekcjach współbieżnych. Działanie tego rozszerzenia
polega na wykonaniu pętli przebiegającej po wszystkich elementach kolekcji, w itera-
cjach której za pomocą tzw. funkcji redukcji obliczana jest wartość pośrednia, czyli
np. suma, minimum czy własna, zdefiniowana operacja. Funkcja redukcji, zdefiniowana
za pomocą wyrażenia lambda będącego jednym z argumentów metody Aggregate, na
podstawie wartości początkowej (bądź wyniku z poprzedniej iteracji) oraz aktualnego
elementu kolekcji wylicza nową wartość pośrednią przekazywaną jako dana wejściowa
do następnego kroku. Po zakończeniu pętli ostateczna wartość wartości pośredniej zwra-
cana jest przez wartość rozszerzenia Aggregate. Najczęściej spotykane funkcje redukcji
to suma, iloczyn, wartość średnia, ilość elementów, minimum czy maksimum. Więk-
szość najczęściej stosowanych funkcji redukcji została zaimplementowana w oddziel-
nych, często stosowanych rozszerzeniach klasy IEnumerable<> (zebrano je w tabeli
9.2). Stanowią one ważny element technologii LINQ stosowany do analizy danych
w kolekcjach, szczególnie wyników zapytań LINQ.
198 Programowanie równoległe i asynchroniczne w C# 5.0
Gdy nasze wymagania wykraczają poza tę listę funkcji, wystarczy zdefiniować własną
funkcję redukcji i przekazać ją jako argument metody Aggregate. Ponadto przekazana
może być wartość początkowa — wykorzystywana przy obliczeniu wartości funkcji
redukcji dla pierwszego elementu w kolekcji. W przeciwnym wypadku obliczenia roz-
poczną się od drugiego elementu. Wówczas w miejsce wartości pośredniej w pierwszej
iteracji zostanie użyty pierwszy element kolekcji. Dodatkowo można przekazać jako
argument funkcję konwertującą ostateczną wartość na typ wyjściowy agregacji. Nagłó-
wek metody rozszerzającej pobierającej wszystkie wspomniane argumenty wygląda na-
stępująco:
public static TResult Aggregate<TSource, TAccumulate, TResult>(
this IEnumerable<TSource> source,
TAccumulate seed,
Func<TAccumulate, TSource, TAccumulate> func,
Func<TAccumulate, TResult> resultSelector
)
lista.Add("Ala ");
lista.Add("ma ");
lista.Add("kota ");
4
Zob. http://msdn.microsoft.com/en-us/library/ckzcawb8.aspx.
Rozdział 9. Dane w programach równoległych 199
Console.WriteLine(wynik);
s x2 y2 z2
Console.WriteLine(odleglosc);
Redukcją pośrednią jest suma kwadratów, natomiast końcową — zwykła suma. Gdyby-
śmy w obu przypadkach zastosowali tę samą funkcję, wyniki pośrednie redukcji byłyby
podnoszone do kwadratu i następnie sumowane. To prowadziłoby do niepoprawnego
wyniku. Funkcję konwersji wykorzystano, aby obliczyć pierwiastek z ostatecznej sumy
i jednocześnie przekształcić typ źródłowy (typ elementu kolekcji, tj. int) na wynikowy
(double).
Tak jak we wszystkich obliczeniach współbieżnych, tak i tutaj istnieją pewne sytu-
acje, które należy przewidzieć, aby uniknąć błędów obliczeń. Wynika to przede wszyst-
kim z nieznanej kolejności zakończenia pracy wątków, a więc i nieznanej kolejności
elementów w redukcji końcowej. Przykład konkatenacji tekstu, który ilustrował agre-
gację sekwencyjną, nie może więc być wprost przeniesiony do agregacji równoległej.
Funkcja redukcji musi spełniać trzy wymienione poniżej wymogi. Dopiero spełnienie
wszystkich trzech gwarantuje poprawne wykonanie agregacji5. Niech P oznacza funkcję
redukcji pośredniej, a K funkcję redukcji końcowej.
Kolejność argumentów funkcji redukcji końcowej — może się zdarzyć, że
funkcja zostanie wywołana z argumentami w odwrotnej kolejności, niż miałoby
to miejsce podczas wykonania sekwencyjnego. Należy więc korzystać z funkcji
przemiennych, takich jak np. dodawanie, a unikać takich, w których zmiana
kolejności argumentów prowadzi do zmiany wartości, jak np. odejmowanie czy
konkatenacja. Mówiąc wprost, funkcja musi spełniać warunek K(x,y) = K(y,x).
Kolejność wywołania funkcji redukcji końcowej — nie zawsze obliczenia dla
kolejnych iteracji będą wykonywane zgodnie z kolejnością elementów w kolekcji.
Warunek ten można zapisać w postaci równania K(K(x,y),z) = K(x,K(y,z)).
Przykładem funkcji, które nie spełniają tej zasady, są operacje na liczbach
zmiennoprzecinkowych. Problemem jest tu błąd dokładności dla bardzo małych
ułamków. Zostanie to przedstawione na przykładzie poniżej.
Wartość początkowa powinna być elementem neutralnym funkcji — ponieważ
operacja redukcji końcowej może być wykonana niewiadomą ilość razy, użycie
elementu innego niż neutralny (np. 0 dla dodawania, 1 dla mnożenia, pusty
łańcuch dla konkatenacji itd.) doprowadzi do niepożądanego wyniku. Zależność
opisuje równość P(x,y) = K(x,P(s,y)), gdzie s oznacza wartość początkową
(ang. seed). Obejściem tego problemu może być zastosowanie takiej wersji
przeciążonej metody Aggregate, w której argument seed jest funkcją.
5
Zob. http://blogs.msdn.com/b/pfxteam/archive/2008/01/22/7211660.aspx.
Rozdział 9. Dane w programach równoległych 201
Wartość liczby π uzyskana za pomocą tego szeregu jest tym dokładniejsza, im dłuższy
będzie ciąg sumowanych elementów. Jednak zawsze przybliżenie uzyskane tą metodą
jest lepsze (dla tej samej liczby operacji), niż to z metody Monte Carlo. Na listingu
9.6 przedstawiam korzystającą z agregacji implementację wzoru Leibniza.
using System.Collections.Concurrent;
using System.Threading.Tasks;
using System.Threading;
namespace aggregation_pi
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("Ścisła wartość Pi: {0}", Math.PI);
IEnumerable<double> zapytanie =
from i in new ConcurrentBag<int>(Enumerable.Range(0, zakres))
select ciag(i);
czas=Environment.TickCount;
var wynik1 = 4 * zapytanie.Sum();
czas=Environment.TickCount-czas;
202 Programowanie równoległe i asynchroniczne w C# 5.0
Na rysunku 9.1 przedstawiam wyniki obliczeń oraz czas ich uzyskania dla każdej z czte-
rech metod.
Rysunek 9.1.
Czas obliczeń
przybliżenia liczby π
metodą Leibniza
wykonanych różnymi
typami agregacji
6
O ile rozszerzenia LINQ zdefiniowane są w klasie Enumerable, definicje rozszerzeń PLINQ
znajdziemy w klasie ParallelEnumerable.
204 Programowanie równoległe i asynchroniczne w C# 5.0
Aby je zrównoleglić, wystarczy do kolekcji pełniącej rolę źródła danych dodać wy-
wołanie AsParallel (listing 9.8). Dzięki tej nowej metodzie rozszerzającej dostęp do
danych odbywać się będzie za pomocą interfejsu, który zadba o odpowiedni podział
danych. Za ten podział odpowiada klasa Partitioner wspomniana na wstępie tego
rozdziału. Zarówno mechanizmy PLINQ, jak i TPL posiadają przypisane domyślnie
warianty tej klasy, przez co jest ona w zasadzie niewidoczna dla programisty. Co
ciekawe, jest również możliwe tworzenie własnej klasy odpowiedzialnej za podział da-
nych, co w szczególnych przypadkach może usprawnić działanie programu.
standardowych rozszerzeń LINQ, ale inny jest zwracany typ. Jest to ParallelQuery<>.
No i — oczywiście — metody te korzystają z zalet współbieżności. Interfejsy wyko-
rzystywane w „klasycznym” LINQ oraz ich równoległe implementacje zostały przed-
stawione w tabeli 9.4.
Bardzo łatwo pogubić się w typach danych, z jakich korzysta się na każdym etapie
zapytania, głównie dlatego, że klasy oferują metody o tych samych nazwach. Dlatego
bardziej dociekliwych czytelników odsyłam do dokumentacji MSDN, gdzie można
przeanalizować transformację typów, w zależności od wywoływania kolejnych operato-
rów. Pozostałe osoby mogą się cieszyć, że klasy te zostały zaprojektowane w taki spo-
sób, żeby zmiana zapytania LINQ na zapytanie współbieżne PLINQ była prosta i ograni-
czała się właściwie do wywołania metody AsParallel na rzecz kolekcji-źródła danych.
Drugim etapem jest równoległe wykonanie zapytania. PLINQ korzysta przy tym z zadań
biblioteki TPL, które wykonywane są przez wątki zarządzane za pomocą puli wątków
ThreadPool. Na tym etapie zadania z przydzielonymi fragmentami danych zostają
rozplanowane do wykonania przez poszczególne wątki z puli. Stopień współbieżności,
a więc ilość wątków wykorzystanych dla danego zapytania, można określić, korzystając
z metody WithDegreeOfParallelism. Ustala ona dokładną ilość wątków i jednocześnie
maksymalną ilość zadań wykonywanych jednocześnie. Jednak bez konkretnego po-
wodu lepiej tych ustawień nie zmieniać i zdać się na wartości ustalane domyślnie.
Metoda WithDegreeOfParallelism daje większą kontrolę nad zrównolegleniem niż ta,
jaką mieliśmy w przypadku zrównoleglania pętli za pomocą klasy Parallel. Wtedy
mieliśmy do dyspozycji jedynie własność MaxDegreeOfParallelism klasy ParallelOptions,
206 Programowanie równoległe i asynchroniczne w C# 5.0
która pozwala określić maksymalną ilość wątków. Subtelna różnica polega na tym, że
pętla może użyć mniejszej ilości wątków (np. pętla wywołana dla jednego zadania
wykorzysta tylko jeden wątek), podczas gdy zapytanie PLINQ zawsze wykorzysta
wskazaną przez nas ilość wątków. Aby ją ustalić w trakcie działania programu, warto
użyć ilości dostępnych rdzeni procesora w komputerze, na jakim uruchamiana jest
aplikacja. Ich liczbę możemy odczytać z własności Environment.ProcessorCount.
W ostatnim etapie, mającym znaczący wpływ na wydajność zrównoleglania zapytań,
następuje scalanie danych (ang. merging). Dane podczas scalania muszą być zsyn-
chronizowane, co ma ogromny wpływ na ostateczną wydajność całego procesu.
Oznacza to, że fragmentaryczne wyniki, które wcześniej są przechowywane w osob-
nych buforach, po zakończeniu pracy przez wszystkie wątki są łączone w jednej sekcji
krytycznej. Mogą być również łączone na bieżąco, co eliminuje konieczność czekania
na dłużej pracujące wątki, ale wymaga większej ilości operacji synchronicznych już
w drugim etapie całego procesu. Decyzję o wyborze metody oddano w ręce programisty.
Sposób scalania można określić za pomocą metody WithMergeOptions. Pobiera ona
jeden argument typu ParallelMergeOptions. Jest to typ wyliczeniowy, którego wartości
przedstawiam w tabeli 9.6.
Zgodnie z dokumentacją MSDN zapytanie PLINQ nie zawsze wykonywane jest współ-
bieżnie. Zapytanie analizowane jest pod względem wykorzystywanych operatorów (po-
dobnie jak przy wyborze algorytmu segmentującego) i w zależności od spodziewanej
efektywności wykonywane jest sekwencyjnie bądź równolegle. Należy pamiętać, że
w trakcie tej oceny nie jest analizowana złożoność wyrażeń użytkownika zastosowanych
w zapytaniu ani wielkość danych wejściowych. Wykonanie równoległe można wymusić
za pomocą metody WithExecutionMode, przyjmującej argument typu wyliczeniowego
ParallelExecutionMode. Jego wartości zostały przedstawione w tabeli 9.7.
7
Zob. http://blogs.msdn.com/b/pfxteam/archive/2009/05/28/9648672.aspx.
8
Nieużywana przeze mnie do tej pory metoda rozszerzająca TakeWhile (zdefiniowana dla IEnumerable<>)
pobiera elementy z kolekcji dopóki, dopóty nie zostanie spełniony warunek podany w jej argumencie.
Analogicznie metoda SkipWhile pomija elementy do momentu spełnienia warunku. Zob. komentarz
na stronie http://weblogs.asp.net/nmarun/archive/2010/04/08/linq-takewhile-and-skipwhile-methods.aspx.
Rozdział 9. Dane w programach równoległych 207
9
Zob. http://msdn.microsoft.com/en-us/library/system.linq.parallelmergeoptions.aspx.
10
Zob. http://msdn.microsoft.com/en-us/library/system.linq.parallelexecutionmode.aspx.
208 Programowanie równoległe i asynchroniczne w C# 5.0
11
W pewnym okresie rozwoju biblioteki PLINQ metoda ta nosiła nazwę AsMerged. Wrócono jednak
do nazwy AsSequential, aby skontrastować ją z antonimicznie działającym rozszerzeniem AsParallel.
Należy jednak nadal pamiętać, że metoda ta jest bezpośrednio związana z procesem scalania danych.
Rozdział 9. Dane w programach równoległych 209
Przerywanie zapytań
Podobnie jak w pętlach równoległych zaimplementowanych w klasie Parallel oraz
w pracy z zadaniami, także w rozszerzeniach PLINQ możemy użyć tokenów przerwań.
Korzystać będziemy z klas CancellationTokenSource i CancellationToken z przestrzeni
nazw System.Threading. Proste przerwanie zapytania jeszcze przed rozpoczęciem jego
wykonania przedstawiam na listingu 9.13. Token przekazujemy jako argument metody
WithCancellation, natomiast metoda Cancel wywołana zostaje przed odwołaniem się
do zapytania, a więc przed wykonaniem użytych w nim metod rozszerzających. Faktycz-
ne wykonanie zapytania, które ma miejsce dopiero w chwili użycia zwracanej przez
nie kolekcji, powinno być otoczone operatorem try, natomiast sekcja catch powinna
obsługiwać wyjątek typu OperationCanceledException (o tym więcej piszę niżej).
Przykładowa definicja funkcji Funkcja przyjmującej jeden argument typu int została
przedstawiona na listingu 9.14.
Oczywiście, zapytanie może zostać przerwane później, tzn. już w trakcie rzeczywi-
stego pobierania danych, gdy np. metoda Cancel wywołana zostanie z równolegle
wykonywanego wątku. Należy jednak pamiętać, że najpierw zostaną zakończone bieżące
obliczenia, a dopiero potem zgłoszony odpowiedni wyjątek. W przypadku PLINQ
może to oznaczać nawet wykonanie całego zapytania. Jeżeli zatem zapytanie zawiera
czasochłonne obliczenia, należy w ich trakcie za pomocą metody ThrowIfCancellation
Requested sprawdzać, czy wywołana została metoda Cancel. Powyższy przykład uży-
cia zapytania będzie w takiej sytuacji wyglądać następująco:
foreach (var i in zapytanie)
{
Console.WriteLine(i);
ct.ThrowIfCancellationRequested();
}
Nic nie stoi na przeszkodzie, aby przerwanie obsłużyć wewnątrz zapytania. Wówczas
w bloku try należy umieścić wyrażenie ct.ThrowIfCancellationRequested(); (listing
9.15). Użycie instrukcji break powoduje, że wyjątek obsłużymy tylko raz. Inaczej kod
bloku catch zostałby wykonany dla wielu elementów zwracanej przez zapytanie kolekcji.
Rozdział 9. Dane w programach równoległych 211
Jeżeli w trakcie wykonywania zapytania w ramach wskazanego tokena nie zostaną zgło-
szone wyjątki inne niż OperationCanceledException, w bloku catch należy obsłużyć
tylko ten typ wyjątku. W przeciwnym przypadku wszystkie wyjątki, łącznie z przerwa-
niem, zebrane zostaną w AggregateException i w takiej formie trzeba je obsłużyć. Osta-
tecznie wzorcowa procedura obsługi przerwania zapytania, uwzględniająca wszystkie
możliwe wyjątki, powinna wyglądać tak, jak przedstawiona na listingu 9.16. Dodatkowo
w przedstawionym na tym listingu kodzie zgłaszany jest wyjątek niezwiązany z prze-
rwaniem, aby pokazać, że przy obecności innych wyjątków OperationCanceledException
faktycznie dodawany jest do AggregateException.
{
//Inny wyjątek
Console.WriteLine("Inny błąd: {0}",e.Message);
}
}
}
Metoda ForAll
W aplikacjach LINQ zwykle najpierw za pomocą zapytania pobieramy kolekcję da-
nych, na której następnie wykonujemy jakieś operacje, korzystając zazwyczaj z pętli
foreach. Należy — oczywiście — pamiętać, że zapytanie zostanie wykonane dopiero
w momencie użycia zwracanego przez nie wyniku. Nie zmienia to jednak faktu, że te
dwie operacje wykonywane będą jedna po drugiej. Wiemy już, jak zrównoleglić obie
czynności osobno: do zapytania możemy użyć PLINQ, a pętle możemy wykonać, korzy-
stając z metody Parallel.ForEach. Jednak obie te czynności, nawet zrównoleglone,
nadal będą wykonywane jedna po drugiej; po zapytaniu musi nastąpić zsynchronizo-
wane scalenie danych. Dopiero wówczas można ich użyć w pętli. A są przecież sytuacje,
w których nie wynik zapytania, a dopiero efekt wykonania pętli jest istotny. Nie chcemy
przechowywać samego wyniku zapytania, a jednocześnie komplet danych z zapytania
nie jest konieczny do rozpoczęcia dalszych obliczeń. Wówczas można pójść o krok
dalej i skorzystać z metody ParallelQuery<>.ForAll. Podane w jej argumencie wyra-
żenie lambda wykonywane jest od razu dla tych elementów, które wybrane zostały
w zapytaniu ze źródła danych, bez czekania na scalenie wyniku. Użycie tej metody
prezentuję na listingu 9.17.
Listing 9.17. Przykład wykorzystania metody ForAll — wypisanie liczb parzystych mniejszych niż 100
Enumerable.Range(0, 100)
.AsParallel()
.Where(i => { return i % 2 == 0; })
.ForAll(i => { Console.WriteLine(i); });
***
Typowe zadania, w których używamy zapytań LINQ, a więc przeszukiwanie lub sor-
towanie tablic czy praca z bazą danych SQL Server, źle poddają się zrównolegleniu.
W tych przypadkach nie warto, a nawet nie należy korzystać z PLINQ. Są jednak takie
szczególne sytuacje, w których w zapytanie LINQ „wplecione” są kosztowne obli-
czeniowo operacje. W tych przypadkach zdecydowanie warto rozważyć możliwość
użycia PLINQ, aby te operacje wykonywać równolegle.
Podejmując decyzję o zrównolegleniu zapytania LINQ, należy wziąć pod uwagę na-
stępujące czynniki 12.
12
Zob. http://msdn.microsoft.com/en-us/library/dd997399.aspx i http://msdn.microsoft.com/en-us/
library/dd997403.aspx.
Rozdział 9. Dane w programach równoległych 213
Zadania
1. Utwórz program obliczający n-ty wyraz ciągu Fibonacciego (w oparciu o wzór
ogólny, a nie definicję rekurencyjną). W tym celu wykorzystaj różnicę dwóch
równoległych agregacji.
2. Zaimplementuj program sprawdzający, czy podana liczba jest liczbą pierwszą
przy użyciu:
a) samego PLINQ,
b) metody ForAll.
214 Programowanie równoległe i asynchroniczne w C# 5.0
Rozdział 10.
Synchronizacja kontrolek
interfejsu z zadaniami
Mateusz Warczak
Rysunek 10.1.
Interfejs aplikacji
wyszukującej dzielniki
liczby całkowitej
int n;
try
{
n = Int32.Parse(tbDana.Text);
if (n < 1) throw new OverflowException();
}
catch
{
MessageBox.Show("Wprowadź poprawną liczbę");
return;
}
bWyszukaj.Enabled = false;
pbPostep.Value = 0;
Task.Factory.StartNew(() =>
{
for (int i = 1; i <= n; i++)
{
Rozdział 10. Synchronizacja kontrolek interfejsu z zadaniami 217
Thread.Sleep(100);
if (n % i == 0)
{
lbDzielniki.Items.Add(i);
lbDzielniki.Update();
}
pbPostep.Value = i * 100 / n;
}
bWyszukaj.Enabled = true;
});
}
Kod z listingu10.1 dzieli się na dwie części. W pierwszej (kończącej się blokiem
try..catch) pobierana jest wartość liczbowa wprowadzona przez użytkownika w polu
tekstowym i sprawdzana jest jej poprawność. Jeżeli zawartość pola tekstowego jest
inna niż liczba całkowita większa od zera, działanie metody zakończy się wyświetleniem
komunikatu o błędzie. Druga część to przede wszystkim kod tworzący nowe zadanie,
w którym wykonywana jest pętla wyszukująca dzielniki. Każda iteracja, natrafiając na
dzielnik, dodaje jego wartość do listy lbDzielniki (konieczne jest jej odświeżenie
metodą Update, aby wprowadzane dane były od razu widoczne) oraz aktualizuje pasek
postępu. Zadanie tworzone jest z użyciem fabryki zadań z biblioteki TPL (rozdział 6.).
Naiwnie wydawać by się mogło, że jest to wszystko, co wystarczy zrobić, aby prze-
nieść obliczenia do odrębnego wątku, jednak już z rozdziału 5. wiemy, że wykonanie ta-
kiej aplikacji zakończy się zgłoszeniem wyjątku widocznego na rysunku 10.2 — nie-
możliwy jest dostęp do kontrolek z innych wątków niż ten, w którym zostały utworzone.
Aby tego uniknąć, można skorzystać m.in. z kontekstu synchronizacji. Jest on „za
darmo” dostępny dla wątku interfejsu aplikacji Windows Forms i WPF (własność
SynchronizationContext.Current). W przypadku zadań sprawa dodatkowo się uprasz-
cza. Aby uruchamiać zadania we wskazanym kontekście synchronizacji, wystarczy
skorzystać z odrębnego planisty zadań (rozdział 6.), któremu przypisany będzie kontekst
z wątku interfejsu. Aby utworzyć taki obiekt, należy skorzystać z metody statycznej
klasy TaskScheduler:
public static TaskScheduler FromCurrentSynchronizationContext()
lbDzielniki.Items.Clear();
int n;
try
{
n = Int32.Parse(tbDana.Text);
if (n < 1) throw new OverflowException();
}
catch
{
MessageBox.Show("Wprowadź poprawną liczbę");
return;
}
bWyszukaj.Enabled = false;
pbPostep.Value = 0;
Task.Factory.StartNew(() =>
{
{
Task.Factory.StartNew((i2) =>
{
lbDzielniki.Items.Add(i2);
lbDzielniki.Update();
}, i, CancellationToken.None, TaskCreationOptions.None,
planistaInterfejsu);
}
Task.Factory.StartNew((i2) =>
{
pbPostep.Value = (int)i2 * 100 / n;
}, i, CancellationToken.None, TaskCreationOptions.None, planistaInterfejsu);
}
}
).ContinueWith(_ =>
{
bWyszukaj.Enabled = true;
}, planistaInterfejsu);
Pierwsza zmiana związana jest z tworzeniem obiektu planisty za pomocą metody Task
Scheduler.FromCurrentSynchronizationContext. Musi ona być uruchomiona w wątku
interfejsu, np. w metodzie zdarzeniowej przycisku. Tylko wtedy z utworzonym w ten
sposób planistą związany będzie właściwy kontekst synchronizacji (inne wątki zwykle
go w ogóle nie posiadają). W kolejnym wyróżnionym miejscu widać, że metody aktuali-
zujące zawartość listy przeniesione zostały do odrębnych zadań, które uzyskają dostęp
do odpowiedniego konktestu za pośrednictwem przesłanego do nich planisty. Oprócz
planisty musimy przekazać także argumenty typu CancellationToken i TaskCreation
Options, bo nie istnieje przeciążenie metody StartNew, które przyjmowałoby jedynie
planistę. Należy zwrócić uwagę, że wartość dzielnika (czyli zmienna i) również prze-
kazywana jest jako argument. Jest to konieczne, ponieważ bezpośrednie odwołanie
się do tej zmiennej w metodzie Items.Add mogłoby spowodować przypisanie niepra-
widłowej wartości ze względu na brak synchronizacji między wątkami. Po zakończe-
niu pracy głównego zadania konieczne jest jeszcze ponowne aktywowanie przycisku
bWyszukaj. W tym przypadku najłatwiej skorzystać z metody ContinueWith, również
dlatego, że przyjmuje mniej argumentów. Dopiero tak przygotowana aplikacja będzie
wolna od blokowania interfejsu, wyjątków oraz problemów z synchronizacją zmiennych.
Rysunek 10.3.
Interfejs aplikacji WPF
(por. z rysunkiem 10.1)
Kod metody zdarzeniowej będzie niemal identyczny z tym na listingu 10.2. Na pre-
zentującym go listingu 10.3 wyróżniono nieliczne zmiany, jakie związane były z uży-
ciem kontrolek WPF zamiast Windows Forms.
lbDzielniki.Items.Clear();
int n;
try
{
n = Int32.Parse(tbDana.Text);
if (n < 1) throw new OverflowException();
}
catch
{
MessageBox.Show("Wprowadź poprawną liczbę");
return;
}
bWyszukaj.IsEnabled = false;
pbPostep.Value = 0;
Task.Factory.StartNew(() =>
{
{
lbDzielniki.Items.Add(i2);
//lbDzielniki.Update();
}, i, CancellationToken.None, TaskCreationOptions.None,
planistaInterfejsu);
}
Task.Factory.StartNew((i2) =>
{
pbPostep.Value = (int)i2 * 100 / n;
}, i, CancellationToken.None, TaskCreationOptions.None,
planistaInterfejsu);
}
}
).ContinueWith(_ =>
{
bWyszukaj.IsEnabled = true;
}, planistaInterfejsu);
}
Jak widać, kod różni się tylko w dwóch sytuacjach. Po pierwsze, dezaktywowanie i ak-
tywowanie przycisku odbywa się z wykorzystaniem własności IsEnabled zamiast
Enabled. Po drugie, nie jest już konieczne odświeżanie listy po dodaniu do niej elemen-
tów. Zmiany te nie dotyczą jednak wykorzystania kontekstu synchronizacji. Nie różni się
ono niczym, niezależnie od zastosowanej technologi tworzenia interfejsu użytkownika.
Aktualizacja interfejsu
z wykorzystaniem operatora await
Użycie metody FromCurrentSynchronizationContext nie jest jedynym sposobem na
poprawną aktualizację zawartości okna aplikacji, w której wykorzystane są zadania
z biblioteki TPL. Drugim, może nawet prostszym w użyciu sposobem jest zastosowanie
operatora await opisanego w rozdziale 1. Ma on tę zaletę, że można go bezpiecznie
używać bez martwienia się o kontekst synchronizacji czy tworzenia odrębnego planisty.
Wadą jest to, że sposób ten użyteczny jest jedynie do wprowadzania zmian w interfej-
sie po zakończeniu zadania; operator await nie umożliwia aktualizowania interfejsu
na bieżąco w trakcie wykonywania długotrwałych obliczeń. Aby zademonstrować
wykorzystanie tego operatora, przygotowana została „okienkowa” wersja programu
obliczającego liczbę z listingu 7.14. Na rysunku 10.4 prezentuję interfejs aplikacji
utworzony z kontrolek WPF.
Rysunek 10.4.
Aplikacja WPF
służąca do obliczania
przybliżenia liczby
222 Programowanie równoległe i asynchroniczne w C# 5.0
try
{
n = Int32.Parse(tbDana.Text);
if (n < 1) throw new OverflowException();
}
catch
{
MessageBox.Show("Wprowadź poprawną liczbę");
return;
}
bOblicz.IsEnabled = false;
Task<double> t = Task<double>.Factory.StartNew(
(n2) => ObliczPiRownolegle((int)n2),
n
);
Zadania
1. Zaimplementuj przykład z listingów 10.2 i 10.3 w środowisku Silverlight
(wskazówka: biblioteka TPL dostępna jest tylko w wersji 5. tego środowiska).
2. Zmodyfikuj program z listingu 10.4, dodając wiązanie (ang. binding) do przycisku
bOblicz w taki sposób, aby był on aktywny w zależności od stanu zadania t.
3. Utwórz okienkową wersję programu przeszukującego drzewo binarne (listing 7.5),
która będzie przedstawiała dane w kontrolce TreeView.
224 Programowanie równoległe i asynchroniczne w C# 5.0
Rozdział 11.
Analiza aplikacji
wielowątkowych.
Debugowanie
i profilowanie
Mateusz Warczak
Obok opisu każdego z narzędzi przedstawione zostaną również przykłady ich użycia do
analizy programów z poprzednich rozdziałów. Pozwoli to zaprezentować, w jaki spo-
sób należy tych narzędzi używać, jakie zjawiska możemy zaobserwować oraz jakie
błędy wykryć z ich pomocą.
utworzyć, wystarczy kliknąć na lewym marginesie edytora kodów lub (po wskazaniu
kursorem miejsca w tekście) wcisnąć klawisz F9. Opisane niżej okna dostępne są w me-
nu Debug, Windows, ale dopiero w momencie wstrzymania debugowanego programu.
Okno wątków widoczne na rysunku 11.1 przedstawia stan programu z listingu 7.14
podczas debugowania. Na tym przykładzie opisane zostaną jego najbardziej użyteczne
funkcje. Aby otworzyć okno wątków, należy ustawić punkt przerwania w kodzie (F9),
uruchomić program i po dotarciu do przerwania nacisnąć Ctrl+Alt+H lub wybrać z menu
Debug/Windows/Threads.
Bardzo przydatne jest polecenie Switch to Thread z menu kontekstowego wątku w tabeli
widocznej w oknie Threads. Zaznacza ono w kodzie źródłowym polecenie aktualnie
wykonywane w zaznaczonym wątku. Możemy w ten sposób łatwo sprawdzić, co w mo-
mencie zatrzymania robiły poszczególne wątki.
Z menu kontekstowego w oknie wątków dostępna jest jeszcze jedna bardzo przydatna
opcja, a mianowicie Show Threads in Source. Powoduje ona dodanie na marginesie
edytora kodu dodatkowych oznaczeń. Poza standardowymi ikonami debuggera, poja-
wiają się tam ikony wskazujące linie kodu, w których „znajdują się” inne wątki.
Na rysunku 11.2 widać oznaczenie dla trzech wątków — pierwsze przy pętli for, po-
zostałe przysłonięte ikoną punktu przerwania i strzałką wskazującą aktywny wątek.
Gdy kilka wątków znajduje się w tym samym miejscu, przy ikonie widnieje znak plusa
(widoczny na rysunku 11.3). Pomiędzy tymi wątkami możemy się przełączać (co spra-
wia, że wątek, na który się przełączymy, staje się aktywny) za pomocą polecenia Switch
To Thread z menu kontekstowego ikony (rysunek 11.3) lub z listy rozwijanej paska
narzędzi Debug Location.
1
„Wątek aktywny” to pojęcie związane wyłącznie z procesem debugowania. W trakcie działania
programu wątek taki nie jest w żaden sposób wyróżniony pośród innych uruchomionych równocześnie
wątków. Wątek aktywny to po prostu ten wątek, którego wykonanie aktualnie śledzimy.
2
Więcej informacji o poszczególnych kategoriach wątków można znaleźć w MSDN:
http://msdn.microsoft.com/en-us/library/ms164740(v=vs.100).aspx.
228 Programowanie równoległe i asynchroniczne w C# 5.0
Rysunek 11.3.
Przełączanie między
wątkami za pomocą
menu kontekstowego
Okno zadań pozwala również grupować zadania według wartości pojawiających się
w wybranej kolumnie. Aby wybrać kryterium grupowania, należy wskazać z menu
kontekstowego pozycję Group By, a następnie wybrać odpowiednią kolumnę. Efekt
tego działania przedstawiam na rysunku 11.5.
Rozdział 11. Analiza aplikacji wielowątkowych. Debugowanie i profilowanie 229
Bardzo przydatną opcją jest możliwość przełączenia widoku metod. Służy do tego
opcja dostępna u góry okna stosów, nazwana Toggle Method View. Dzięki temu wątki
wykonujące tę samą metodę zostaną zgrupowane w jeden blok, co przedstawiam na
rysunku 11.8.
230 Programowanie równoległe i asynchroniczne w C# 5.0
Rysunek 11.7. Okno stosów równoległych z włączoną opcją Show external code
Rysunek 11.9.
Obserwacja zmiennej
„i” podczas wykonania
pętli równoległej
Rysunek 11.10.
Podgląd zakresów
wyznaczanych przez
klasę Partitioner
podczas liczenia
przybliżenia liczby Pi
Dzięki możliwości obserwowania wartości dowolnej zmiennej lub własności ilość za-
stosowań tego narzędzia jest praktycznie nieograniczona. Załóżmy, że podczas two-
rzenia programu opartego na zadaniach chcielibyśmy przeanalizować cykl życia wy-
branego zadania. W tym celu wystarczy jedynie rozpocząć debugowanie i utworzyć
obserwację właściwości Status wybranego zadania. Na rysunku 11.11 przedstawiam
śledzenie stanu zadań w przykładzie z listingu 6.11. Dla przypomnienia: działanie tego
programu polega na tworzeniu testowych zadań i celowym wprowadzaniu ich w każdy
232 Programowanie równoległe i asynchroniczne w C# 5.0
Rysunek 11.11.
Podgląd statusu
zadań test1 i test2
z możliwych stanów, aby ostatecznie ukazać ich zmiany w oknie konsoli. Okno ob-
serwacji równoległych ma tę przewagę, że pozwala na podgląd tych samych danych
bez konieczności wprowadzania dodatkowego kodu odpowiedzialnego za pobieranie
informacji o statusie i wypisywanie informacji w konsoli.
Ponieważ zadania zostały dopiero utworzone, nie są widoczne w oknie zadań. Nic jed-
nak nie stoi na przeszkodzie, aby podejrzeć ich stan poprzez wprowadzenie obserwacji.
Podobnie jak w przykładzie z obserwowaniem iteratora pętli, także w tym przypadku
zmiana wartości zaznaczona zostanie czerwonym kolorem (rysunek 11.12).
Rysunek 11.12.
Zmiana stanu zadań
widoczna w oknie
obserwacji równoległych
Concurrency Visualizer
Kolejnym bardzo użytecznym narzędziem jest Concurrency Visualizer; służy ono do
profilowania aplikacji równoległych. Z jego pomocą programista może analizować
informacje o przebiegu wykonania tworzonej aplikacji. Po zakończeniu procesu ze-
brane dane można podejrzeć w trzech trybach: na diagramie widoku wątków, rdzeni
bądź na wykresie wykorzystania CPU.
Narzędzie do profilowania znajduje się w menu Analyze. Po rozwinięciu pozycji
Concurrency Visualizer uzyskujemy dostęp do kilku opcji, spośród których pierwsza,
o nazwie Start with Current Project, pozwala na uruchomienie profilowania bieżącego
projektu. Wybranie tej pozycji z menu spowoduje uruchomienie programu oraz roz-
poczęcie zbierania informacji na temat jego pracy. Dane, które później podejrzeć bę-
dzie można na wygenerowanych raportach, gromadzone będą do momentu zakończenia
programu bądź do ręcznego zatrzymania w głównym oknie Visual Studio. Mogą zaj-
mować sporo miejsca na dysku. Po dłuższej chwili przetwarzania danych zobaczymy
raport analizy przebiegu wykonania aplikacji.
Służy on przede wszystkim do wstępnej analizy, w ramach której szuka się fragmen-
tów wykonywanych równolegle lub nadających się do zrównoleglenia.
Widok Wątki
Bardziej wnikliwa analiza przebiegu programu możliwa jest przy użyciu widoku Wątki
(rysunek 11.14), który pozwala na analizowanie pracy poszczególnych wątków. Jest
on nieco bardziej rozbudowany niż widok wykorzystania CPU i dostarcza bardziej
wyczerpujących informacji o przebiegu obliczeń.
234 Programowanie równoległe i asynchroniczne w C# 5.0
Na początek rzuca się w oczy znacznie większa szczegółowość raportu. W tym widoku
można odczytać dokładną informację o tym, ile czasu trzeba poświęcić na synchroni-
zację, funkcje wyjścia/wejścia czy inne operacje. Łatwo tu również dostrzec problemy
z nierównomiernym rozłożeniem obciążenia pomiędzy wątkami bądź wyśledzić nie-
pożądane sekcje krytyczne. Górna część raportu zawiera listę wątków utworzonych przez
aplikację. W tym przykładzie tylko część z nich odpowiada za obliczenia, reszta to
wątki pomocnicze. Aby skupić się na analizie interesujących nas wątków, można albo
posortować ich listę według czasu wykonania (ang. Sort by execution), korzystając z listy
rozwijanej u góry okna, albo ukryć niepotrzebne wątki — zaznaczając je i wybierając
z menu kontekstowego polecenie Hide Selection (dostępne również na pasku narzędzi).
Wykres obciążenia, podobnie jak w widoku wykorzystania CPU, pozwala na skalo-
wanie osi czasu. Zwiększenie przybliżenia (suwak Zoom) umożliwia podgląd poszcze-
gólnych operacji. Widać wówczas, że poszczególne operacje w obrębie wątku repre-
zentowane są przez prostokąty, które można zaznaczyć. Wtedy możliwe jest jeszcze
dokładniejsze analizowanie, na którym etapie wykonania programu występuje interesu-
jące nas zjawisko. Rodzaje operacji wraz z przedstawiającymi je kolorami objaśnione
są w lewym dolnym rogu raportu. Dolna część, podzielona na zakładki, pozwala na
podejrzenie danych sumarycznych (zakładka Profile Report) lub stosu wywołań za-
znaczonego na diagramie bloku operacji (zakładka Current). Zawartość pierwszej za-
kładki zależy od zaznaczenia pozycji w „legendzie”. Aktywna na rysunku 11.14 pozy-
cja Per Thread Summary powoduje wyświetlenie histogramu z wyróżnieniem, ile czasu
dany wątek zużył na dany typ operacji. Wybranie konkretnej kategorii operacji powo-
duje wyświetlenie informacji bardziej szczegółowych, co widać na rysunku 11.15.
Jeszcze inaczej sytuacja wygląda dla programu z listingu 7.11, w którym cała syn-
chronizacja przeprowadzana była za pomocą operatora lock. Na rysunku 11.17 widać
wyraźnie, jak dużo czasu utracono wówczas na synchronizację, której skutkiem było
4
Co jest zgodne z oczekiwaniami, bo wiemy, że program testowany był na maszynie z procesorem
dwurdzeniowym.
236 Programowanie równoległe i asynchroniczne w C# 5.0
usypianie wątków5! Pomimo iż obecne są okresy pracy współbieżnej, sporo jest frag-
mentów, w których nie są przeprowadzane żadne obliczenia — wątki są w trakcie
przełączania kontekstu bądź uśpienia.
Widok Rdzenie
Ostatni z widoków przedstawia informacje o tym, jakie wątki wykonywane są przez
konkretne rdzenie. Jak widać na rysunku 11.18, wątki zostały przydzielone grupami
do każdego z rdzeni i w obrębie tych rdzeni są przełączane (rysunek 11.14). Podobnie jak
w poprzednim widoku, także tu przedziały czasowe przedstawione są w postaci ramek.
5
Metody synchronizacji, takie jak wykorzystanie klasy Interlocked, nie powodują usypiania wątków,
przez co znacznie mniej czasu traci się na przełączanie kontekstu.
Rozdział 11. Analiza aplikacji wielowątkowych. Debugowanie i profilowanie 237
Rysunek 11.19.
Lista wyboru
procesu do analizy
A co by było, gdyby nasza aplikacja webowa znajdowała się już na serwerze i chcie-
libyśmy prześledzić jej działanie w środowisku produkcyjnym? Nadal możemy sko-
rzystać z narzędzia Concurrency Visualizer, z tym, że będzie to nieco bardziej skom-
plikowane. Sztuczka polega na uruchomieniu na serwerze samego narzędzia, które
będzie miało za zadanie gromadzić dane i zapisywać je do pliku z rozszerzeniem
.CvTrace. Analiza tego pliku na komputerze programisty pozwoli na przygotowanie
przez Visual Studio raportu. Pierwszym krokiem jest zainstalowanie na serwerze na-
rzędzia Concurrency Visualizer Command Line Utility. Odpowiedni plik .msi znaj-
duje się na płycie instalacyjnej Visual Studio, w katalogu Concurrency Visualizer. Do
zainstalowania narzędzia wymagany jest .NET Framework 4.5. Po pomyślnym za-
kończeniu instalacji narzędzie gotowe jest do pracy. Jak sama nazwa wskazuje, jest to
aplikacja konsolowa, więc uruchamiać ją będziemy z linii poleceń. Aby podłączyć się
do działającego procesu serwera IIS, należy wydać polecenie
start CvCollectionCmd.exe /Attach /Process w3wp
238 Programowanie równoległe i asynchroniczne w C# 5.0
Znaczniki
Analiza diagramu wykonania programu często nie jest łatwa i wymaga od użytkownika
żmudnego identyfikowania fragmentów kodu odpowiedzialnych za poszczególne części
wykresu obciążenia. Pomóc w tym może mechanizm znaczników. Są to obiekty pre-
zentowane na diagramie wątków w postaci symboli graficznych oznaczających okre-
ślone punkty bądź przedziały w czasie działania programu. Znaczniki można tworzyć
samodzielnie z poziomu kodu. W niektórych przypadkach są one także generowane
automatycznie — jest tak w przypadku pętli równoległych (rysunek 11.14). Istnieją
trzy podstawowe rodzaje znaczników. Oto one.
Zakres — znaczniki tego typu określają zakres czasu powiązany z etapem
pracy programu.
Flaga — służy do oznaczenia pewnego punktu osiąganego przez program
w czasie wykonania.
Komunikat — element bardzo podobny do flagi, mający jednak inne znaczenie
semantyczne. Służy głównie do wyświetlania informacji o stanie programu
w danym momencie.
Markers.WriteFlag("Początek obliczeń");
Rozdział 11. Analiza aplikacji wielowątkowych. Debugowanie i profilowanie 239
Parallel.ForEach(
Partitioner.Create(0, n),
() => 0,
(przedzial, stanPetli, sumaCzesciowa) =>
{
Random r = new Random(Task.CurrentId.Value + System.Environment.TickCount);
Markers.WriteFlag("Koniec obliczeń");
return 4.0 * k / n;
}
Zadania
1. Zaobserwuj w oknie wątków działanie programu obliczającego liczbę
(listing 7.14, ustaw breakpoint w miejscu polecenia sumaCzesciowa++)
i odpowiedz na pytania:
a) Ile wątków roboczych zostało utworzonych?
b) Ile spośród tych wątków wykonuje metody programu?
c) Ile wątków jednocześnie może wykonywać wybrane polecenie?
2. Przeanalizuj w oknie ParallelWatch wartości częściowe liczby π (zwracane
przez metodę statyczną) w różnych wątkach w programie z listingu 2.9.
3. Przeanalizuj działanie programu z listingu 2.9 z wykorzystaniem Concurrency
Visualiser.
4. W rozdziale 5. opisany jest prosty program, w którym dodatkowy wątek zmienia
położenie paska postępu (w wersji opartej na Control.Invoke i kontekście
synchronizacji). Dodanie na końcu metody zdarzeniowej tworzącej ten dodatkowy
wątek polecenia Join powoduje „zastygnięcie” programu. Prześledź tę sytuację
za pomocą narzędzi debugowania.
242 Programowanie równoległe i asynchroniczne w C# 5.0
Rozdział 12.
Wstęp do CCR i DSS
Piotr Sybilski, Rafał Pawłaszek
CCR (ang. Concurrency and Coordination Runtime) i DSS (ang. Decentralized So-
ftware Services) to dwie wzajemnie uzupełniające się technologie, które wprowadzają
asynchroniczne i współbieżne programowanie na nowy poziom. Oczywiście, istnieją
inne technologie pozwalające na tworzenie oprogramowania korzystającego z rów-
noległego wykonywania zadań. Dlaczego zatem warto zwrócić uwagę na tandem CCR
i DSS? Czym się wyróżniają? Odpowiedź na to pytanie wymaga syntetycznego spojrze-
nia. Same biblioteki CCR oraz DSS mogą zostać zastąpione innymi mechanizmami.
W przypadku CCR rozsądnym wyborem może być TPL, a zamiennikiem dla DDS
jest chociażby WCF (ang. Windows Communication Foundation). Tym, co sprawia,
że połączony pakiet CCR i DSS to pozycja wyjątkowa, jest ogromna skalowalność.
Należy przez to rozumieć zarówno możliwość wykorzystania wielu procesorów na
jednym komputerze, jak i wielu komputerów. Poza tym należy także wspomnieć
o niewielkim rozmiarze pakietu redystrybucyjnego, wysokiej wydajności oraz archi-
tekturze opartej o serwisy i technologię REST (ang. Representational State Transfer).
Ważną cechą CCR i DSS jest protokół komunikacji między procesami (lokalnymi
i zdalnymi), który warto docenić. Samodzielne przygotowanie takiego protokołu,
włącznie z testowaniem i optymalizacją wydajności, przy jednoczesnej kontroli jako-
ści kodu i zarządzaniem błędami, które pojawiają się w trakcie jego wykonania, jest
ogromnym zadaniem, z jakiego zwalniają nas CCR i DSS. Technologie te zostały wielo-
krotnie przetestowane. Wystarczy wspomnieć, że jeden z pierwszych serwisów spo-
łecznościowych, MySpace, został zbudowany w oparciu o te technologie. Stanowią
one również serce środowiska Microsoft Robotics, będącego od wielu lat popularnym
narzędziem przemysłu automatyki i robotyki, ale również środowiskiem hobbystów
budujących roboty z klocków Lego Mindstorms.
Wykorzystanie CCR i DSS ma jeszcze jedną zaletę, widoczną w momencie, gdy pro-
gram albo nasza usługa muszą zostać szybko wyłączone i pojawia się problem prze-
rwania wielu różnych działających wątków, mogących być w bardzo złożonym stanie.
Problem ten jest rozwiązany z wykorzystaniem filtrowanej kolejki wiadomości oraz
mechanizmów kończących przetwarzane już wiadomości. Ten element synchronizacji
współbieżnego wykonania jest podstawową własnością środowiska CCR i DSS, która
jest automatycznie implementowana w projekcie tworzonym w Visual Studio, i tylko
w przypadku korzystania z niezarządzanych zasobów musimy odpowiednio zmodyfi-
kować metodę DropHandler odpowiedzialną za przerwanie działania serwisu. Jest to
bardzo proste i dobrze przetestowane rozwiązanie.
Programowanie równoległe można zdefiniować jako użycie dwóch lub więcej wątków
obliczeniowych do rozwiązania jednego problemu. W tym kontekście CCR i DSS
znakomicie nadają się do skomplikowanych zadań, także wymagających kilkunastu
lub nawet kilkuset wątków. Wynika to z łatwości implementacji kodu oraz równie
łatwego jego wdrażania w istniejących strukturach sieciowych i sprzętowych. Zestaw
narzędzi wspomagających tworzenie rozwiązań w tej technologii pozwala na nieograni-
czoną, z punktu widzenia technologii, skalowalność. Oznacza to, że trafiając na barie-
rę obliczeniową wynikającą z ograniczeń sprzętu, możemy po prostu dodać kolejny
procesor, kolejny komputer lub klaster, aby sprostać nowym wymaganiom. Nie ma przy
tym konieczności dodawania kolejnych warstw kodu związanych z zarządzaniem,
komunikacją, wdrożeniem i kontrolą nowych zasobów. Z punktu widzenia administrato-
ra, aby dołączyć nowe komputery, wystarczy na nich uruchomić usługę DSS odpo-
wiedzialną za automatyczną dystrybucję i uruchomienie kodu (co obejmuje zaplano-
wanie dystrybucji, wykonania oraz synchronizacji zadań).
Rysunek 12.1.
Model typowej usługi Identyfikator usługi
zaimplementowanej
Identyfikator kontraktu
w technologiach
CCR i DSS
Główny port
Stan usługi usługi
Metody obsługi
zdarzeń
Partnerzy
Powiadomienia
Instalacja środowiska
Microsoft Robotics
Zakładamy, że na komputerze jest już zainstalowane Visual Studio 2010, Visual Studio
2012 lub Visual Studio 2013. Należy jeszcze zainstalować darmowe środowisko Micro-
soft Robotics Developer Studio, które można pobrać ze strony http://www.microsoft.
com/robotics/ (289,6 MB).
246 Programowanie równoległe i asynchroniczne w C# 5.0
Rysunek 12.2.
Przepływ wiadomości
pomiędzy usługami
Transport
Transport
DSS
DSSProtocol
Protocol
(TCP/IP)
(TCP/IP)
Rysunek 12.3.
Ekran początkowy
instalacji Microsoft
Robotics Developer
Studio 4
Jeżeli korzystamy z Visual Studio 2012 i systemu Windows 8, odpowiednie pliki sza-
blonów, służące do tworzenia nowych projektów w środowisku Robotics z użyciem
DSS i CCR, mogą nie zostać zainstalowane. W takiej sytuacji należy je samodzielnie
skopiować do biblioteki dokumentów, do folderu Visual Studio 2012\Templates.
Chodzi o plik DssNewServiceVs2010 (4.0).zip w przypadku ProjectTemplates i pliki
DssHosting (4.0).zip oraz DssNewServiceVs2010 (4.0).zip z katalogu ItemTemplates.
Powinny one pozostać spakowane, Visual Studio 2010 i 2012 korzystają z plików ar-
chiwum ZIP. Odpowiednie pliku wraz ze strukturą katalogów znajdują się w mate-
riałach dołączonych do książki. W przypadku Visual Studio 2013 zmianie ulegają je-
dynie foldery docelowe, którymi w tym wypadku są Visual Studio 2013\Templates
oraz Visual Studio 2013\ItemTemplates.
Możliwe problemy
z uruchomieniem środowiska Robotics
Podczas uruchamiania programu DssHost.exe oraz serwisów możemy napotkać błąd
odmowy dostępu. Jego możliwymi przyczynami są konfiguracja zapory internetowej,
inny program działający w tle na portach używanych przez CCR i DSS lub brak od-
powiednich uprawnień użytkownika. Szczegółowy opis radzenia sobie z tą sytuacją
znajduje się poniżej. Błąd wynika z zasad bezpieczeństwa i rezerwacji portów w ko-
munikacji TCP/IP. Typowym objawem tego problemu będzie okno środowiska DSS
Host zawierające komunikat o odmowie dostępu, tak jak na rysunku 12.4.
Rysunek 12.4.
Okno programu
DssHost.exe,
w sytuacji gdy
ustawienia komputera
blokują dostęp do
adresu 127.0.0.1
i portów 50000
i 50001
Gdy za odmowę dostępu odpowiedzialna jest zapora internetowa, może być konieczne
odblokowanie kombinacji adresów i portów 50000 oraz 50001 lub dodanie programu
DssHost.exe do listy zaufanych. Ostatnia możliwość, najrzadziej spotykana, to sytuacja,
w której jakiś inny program korzysta z tych portów. Wtedy możemy w konfiguracji
środowiska Dss Host wymusić zmianę używanych portów na inne. Wystarczy modyfi-
kacja w pliku konfiguracyjnym DssHost.exe.config lub DssHost32.exe.config (w przy-
padku 32-bitowego środowiska) „odkomentowująca” dwie linijki odpowiedzialne za
domyślne porty i zmieniająca ich wartość (listing 12.1).
Listing 12.1. Modyfikacja pliku DssHost.exe.config, która zmienia domyślne porty aplikacji
<!--Http port to use if none specified on the host commandline or runtime initialization-->
<add key="Microsoft.Dss.Core.HttpPort" value="50000" />
<!--Tcp port to use if none specified on the host commandline or runtime initialization-->
<add key="Microsoft.Dss.Core.TcpPort" value="50001" />
Możemy także podać ścieżkę docelową dla migracji o jeden poziom wyżej, wtedy
wszystkie podfoldery i projekty zostaną zaktualizowane jednym poleceniem.
Rozdział 12. Wstęp do CCR i DSS 249
Aby wykonać powyższe zadanie, przygotujemy dwa proste komponenty dobrze ilu-
strujące jedno z najczęstszych zadań, jakie aktualnie stawiane jest przed bibliotekami
CCR i DSS. Jest to automatyzacja, koordynacja zadań i kontrolowanie robotów. Ta część
jest nierozerwalnie związana z fizycznym środowiskiem, z jakim nasz system oddziałuje
poprzez fizyczne czujniki oraz zautomatyzowane urządzenia (przykładem może być
czujnik temperatury i możliwość sterowania kontrolującą temperaturę klimatyzacją).
To nastawienie powoduje, że w tym podejściu wyróżniamy dwa typy modułów pro-
gramowych, czyli czujniki (ang. sensors) i urządzenia (ang. actuators). Te drugie za-
zwyczaj utożsamiane z akcją i ruchem. Czujniki to moduł programu odpowiadający za
kontakt z czujnikami zapewniającymi informację o otaczającym nas świecie. Może to
być czujnik odległości, natężenia światła, dźwięku czy temperatury. Kamera internetowa
jest także przykładem czujnika. Z drugiej strony, mamy wszystkie urządzenia, które
mogą wykonać dla nas jakąś czynność. Może być to silnik elektryczny, podnośnik
czy wentylator. Należy jednak pamiętać, że w niektórych urządzeniach mogą być także
wbudowane czujniki. W silnik samochodu sterowany komputerem „pokładowym”
wbudowane są przecież czujniki informujące o temperaturze smaru czy prędkości
obrotowej. Taki system także będziemy nazywać urządzeniem.
komponenty, takie jak czujnik temperatury czy kamera. Aby uniezależnić się od czuj-
ników i urządzeń, jakie mamy do dyspozycji, oprzemy się wyłącznie na wirtualnej
implementacji urządzeń dostępnej w Microsoft Robotics. To ułatwi wprowadzenie do
tematu bez konieczności posiadania dość drogiego sprzętu.
Rysunek 12.5. Tworzymy nasz pierwszy projekt wykorzystujący środowisko CCD i DSS na bazie
dostarczonego szablonu
Rysunek 12.6.
Konsola wyświetlająca
najważniejsze komunikaty
z środowiska DSS Host.
Widoczny jest typowy
adres lokalny 127.0.0.1
i port 50000, na którym
udostępniane są usługi
Rysunek 12.7. Ekran domowy interfejsu sieciowego Microsoft Robotics automatycznie generowany
przez działający DSS Host
(Manifest Load Results), które jest przydatne, w momencie gdy korzystamy z manife-
stów i chcemy sprawdzić poprawność ich załadowania. Ostatnie łącze skieruje nas do
listy załadowanych zasobów. Podczas startu usługi wszystkie zasoby wbudowane w plik
biblioteki są ładowane do pamięci i dostępne później pod unikatowymi adresami
URL. Usługa diagnostyki zasobów (Resource Diagnostics) pozwala to sprawdzić.
W panelu kontrolnym do utworzonej przez nas usługi możemy się dostać na kilka
sposobów: wpisując jej adres, np. http://127.0.0.1:50000/temperatureservice/
ec558b3f-2d9a-435c-a8a2-d9a430c5b2ed (ostatnia część to losowo wygenerowany
identyfikator, który przy każdym uruchomieniu usługi będzie inny) w pasku adresu
przeglądarki, klikając dwukrotnie jej ikonę na liście usług w panelu domowym lub wybie-
rając ją z listy w panelu kontrolnym. Skorzystamy z tej ostatniej metody, gdyż losowo wy-
generowany identyfikator w adresie jest niewygodny przy ręcznym wpisywaniu.
Po przejściu do naszej usługi zobaczymy plik XML zawierający kilka obco wyglądających
informacji oraz długi adres w postaci http://127.0.0.1:50000/temperatureservice/
ec558b3f-2d9a-435c-a8a2-d9a430c5b2ed. To jest automatycznie ustalony adres usługi,
Rozdział 12. Wstęp do CCR i DSS 253
Rysunek 12.9. Ekran dodawania nowego elementu, w tym przypadku nowego pliku XSLT, który
pozwoli w łatwy i czytelny sposób transformować stan usługi zserializowany z wykorzystaniem XML-a
na stronę internetową
Rozdział 12. Wstęp do CCR i DSS 255
Domyślna zawartość pliku nie będzie nas interesować. Możemy ją usunąć i wpisać tam
typowy szablon używany w dokumentacji i przykładach dostarczonych razem z Microso-
ft Robotics (listing 12.4). Kolejne pliki XSLT będą bazowały na właśnie wprowadzonym
przez nas kodzie.
Listing 12.4. Plik transformaty XSLT pozwalający na czytelną ilustrację stanu usługi
<?xml version="1.0" encoding="UTF-8" ?>
<!-- Wykorzystywane szablony -->
<xsl:stylesheet version="1.0"
xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
xmlns:tst="http://schemas.tempuri.org/2013/01/temperatureservice.html">
</table>
</xsl:template>
</xsl:stylesheet>
Następnie zaznaczmy, że plik XSLT ma być częścią tworzonej przez nas usługi. Zro-
bimy to, wybierając w oknie własności pliku wartość Embedded Resource (z ang.
wbudowany zasób) z rozwijanej listy przy parametrze Build Action (rysunek 12.10).
Aby usługa korzystała z transformaty do wyświetlania swojego stanu, zmodyfikujmy
plik TemperatureService.cs, wprowadzając zmiany wyróżnione na listingu 12.5.
Rysunek 12.10.
Własności dodanego pliku
XSLT, który będzie
dołączany do biblioteki
naszego serwisu jako
wbudowany zasób
Listing 12.5. Fragment kodu deklarujący zmienną będącą stanem serwisu oraz unikatowość serwisu
using System;
using System.Collections.Generic;
using System.ComponentModel;
using Microsoft.Ccr.Core;
using Microsoft.Dss.Core.Attributes;
using Microsoft.Dss.ServiceModel.Dssp;
using Microsoft.Dss.ServiceModel.DsspServiceBase;
using W3C.Soap;
using submgr = Microsoft.Dss.Services.SubscriptionManager;
namespace TemperatureService
{
[Contract(Contract.Identifier)]
[DisplayName("TemperatureService")]
[Description("TemperatureService service (no description provided)")]
class TemperatureService : DsspServiceBase
{
/// <summary>
/// Service state
/// </summary>
[ServiceState(StateTransform = "TemperatureService.
TemperatureService.xslt")]
TemperatureServiceState _state = new TemperatureServiceState();
/// <summary>
Rozdział 12. Wstęp do CCR i DSS 257
[SubscriptionManagerPartner]
submgr.SubscriptionManagerPort _submgrPort = new
submgr.SubscriptionManagerPort();
/// <summary>
/// Service constructor
/// </summary>
public TemperatureService(DsspServiceCreationPort creationPort)
: base(creationPort)
{
}
/// <summary>
/// Service start
/// </summary>
protected override void Start()
{
//
// Add service specific initialization here
//
base.Start();
}
/// <summary>
/// Handles Subscribe messages
/// </summary>
/// <param name="subscribe">the subscribe request</param>
[ServiceHandler]
public void SubscribeHandler(Subscribe subscribe)
{
SubscribeHelper(_submgrPort, subscribe.Body, subscribe.ResponsePort);
}
}
}
Ważne jest także, aby poinformować DSS Host, iż od tej pory będziemy samodzielnie
obsługiwać żądania wyświetlania strony. W tym celu modyfikujemy również plik
TemperatureServiceTypes.cs. Zmieniamy deklarację klasy TemperatureServiceOperations,
dodając do parametrów klasy bazowej pozycję HttpGet. Deklarujemy także użycie
przestrzeni nazw Microsoft.Dss.Core.DsspHttp. Wszystkie te zmiany pokazujemy na
listingu 12.6.
258 Programowanie równoległe i asynchroniczne w C# 5.0
namespace TemperatureService
{
/// <summary>
/// TemperatureService contract class
/// </summary>
public sealed class Contract
{
/// <summary>
/// DSS contract identifer for TemperatureService
/// </summary>
[DataMember]
public const string Identifier =
"http://schemas.tempuri.org/2013/01/temperatureservice.html";
}
/// <summary>
/// TemperatureService state
/// </summary>
[DataContract]
public class TemperatureServiceState
{
private DateTime time = DateTime.Now;
[DataMember]
public DateTime Time
{
get { return time; }
set { time = value; }
}
/// <summary>
/// TemperatureService main operations port
/// </summary>
[ServicePort]
public class TemperatureServiceOperations : PortSet<DsspDefaultLookup,
DsspDefaultDrop, Get, Subscribe, HttpGet>
{
}
Rozdział 12. Wstęp do CCR i DSS 259
/// <summary>
/// TemperatureService get operation
/// </summary>
public class Get : Get<GetRequestType, PortSet<TemperatureServiceState, Fault>>
{
/// <summary>
/// Creates a new instance of Get
/// </summary>
public Get()
{
}
/// <summary>
/// Creates a new instance of Get
/// </summary>
/// <param name="body">the request message body</param>
public Get(GetRequestType body)
: base(body)
{
}
/// <summary>
/// Creates a new instance of Get
/// </summary>
/// <param name="body">the request message body</param>
/// <param name="responsePort">the response port for the request</param>
public Get(GetRequestType body, PortSet<TemperatureServiceState, Fault>
responsePort)
: base(body, responsePort)
{
}
}
/// <summary>
/// TemperatureService subscribe operation
/// </summary>
public class Subscribe : Subscribe<SubscribeRequestType,
PortSet<SubscribeResponseType, Fault>>
{
/// <summary>
/// Creates a new instance of Subscribe
/// </summary>
public Subscribe()
{
}
/// <summary>
/// Creates a new instance of Subscribe
/// </summary>
/// <param name="body">the request message body</param>
public Subscribe(SubscribeRequestType body)
: base(body)
{
}
/// <summary>
/// Creates a new instance of Subscribe
/// </summary>
/// <param name="body">the request message body</param>
260 Programowanie równoległe i asynchroniczne w C# 5.0
Rysunek 12.11. Okno przeglądarki zawierające stronę internetową działającego serwisu, w którym
z wykorzystaniem transformaty XSLT jest mierzona temperatura i wyświetlany jej odczyt
Zadeklarowano dwie nowe wiadomości, które będą obsługiwane przez usługę. Wymagają
one zdefiniowania kilku dodatkowych klas, które będą dziedziczyły po podstawowych
typach generycznych komunikatów. Jest to Submit<> w przypadku UpdateTemperature
i Replace<> w przypadku Replace (listing 12.8). Wiadomości rozróżniane są na podsta-
wie typów przenoszonych obiektów2. Dlatego nawet dla zwykłej, nieniosącej żadnych
danych aktualizacji konieczne jest utworzenie osobnej klasy, choćby nie posiadała
ona żadnych metod ani własności. Taka jest klasa UpdateRequest.
Listing 12.8. Definicja dwóch nowych wiadomości, które zostaną użyte do odświeżania stanu serwisu
/// < summary >
/// Temperature Service - replace the current state
/// < /summary >
public class Replace : Replace<TemperatureServiceState,
PortSet<DefaultReplaceResponseType, Fault>>
{
public Replace()
{
}
[DataContract]
public class UpdateRequest { }
2
Podobny mechanizm został użyty przy filtrowaniu wyjątków w konstrukcji try..catch.
262 Programowanie równoległe i asynchroniczne w C# 5.0
replace.ResponsePort.Post(DefaultReplaceResponseType.Instance);
yield break;
}
/// <summary>
/// Concurrent Temperature Update handler
/// </summary>
/// <param name="request">Empty parameter</param>
/// <returns></returns>
[ServiceHandler(ServiceHandlerBehavior.Concurrent)]
public IEnumerator<ITask> UpdateTemperatureHandler(UpdateTemperature request)
{
Random randomNumberGenerator = new Random();
TemperatureServiceState newState = new TemperatureServiceState();
newState.Time = DateTime.Now;
newState.Temperature = randomNumberGenerator.NextDouble() * 30.0 + 10.0;
Replace message = new Replace(newState);
_mainPort.Post(message);
SendNotification(_submgrPort, message);
Listing 12.10. Wywołanie fragmentu kodu po upłynięciu 3000 milisekund, czyli 3 sekund
...
// Set the timer for the next tick
Activate(
Arbiter.Receive(
false, TimeoutPort(3000),
delegate(DateTime time)
{
_mainPort.Post(new UpdateTemperature());
}
));
...
264 Programowanie równoległe i asynchroniczne w C# 5.0
Listing 12.11. Metoda startowa, w której rejestrowana jest nasza usługa w środowisku DSS
/// <summary>
/// Service start
/// </summary>
protected override void Start()
{
//
// Add service specific initialization here
//
base.Start();
_mainPort.Post(new UpdateTemperature());
}
Rysunek 12.12. Wygląd serwisu po wprowadzeniu ostatnich poprawek. Temperatura zmienia się co trzy
sekundy, a cały proces jest w pełni asynchroniczny i nie blokuje ani interfejsu, ani żadnego wątku
Serwisy partnerskie
W drugim projekcie zajmiemy się problemem wentylacji i jej sterowaniem w oparciu
o informację uzyskaną od czujników temperatury. Utwórzmy nowy projekt DSS i na-
zwijmy go VentilationService. Usługa TemperatureService będzie w tym przypadku
usługą partnerską dla usługi sterującej VentilationService, wykorzystującą uzyskane
informacje o temperaturze do sterowania wirtualną wentylacją.
Rysunek 12.13.
Wygląd okna
konfiguracyjnego
nowego serwisu
po wprowadzonych
zmianach
266 Programowanie równoległe i asynchroniczne w C# 5.0
Listing 12.12. Automatyczna modyfikacja szablonu serwisu wprowadzona podczas jego tworzenia
using System;
using System.Collections.Generic;
using System.ComponentModel;
using Microsoft.Ccr.Core;
using Microsoft.Dss.Core.Attributes;
using Microsoft.Dss.ServiceModel.Dssp;
using Microsoft.Dss.ServiceModel.DsspServiceBase;
using W3C.Soap;
using submgr = Microsoft.Dss.Services.SubscriptionManager;
using temperatureservice = TemperatureService.Proxy;
namespace VentilationService
{
[Contract(Contract.Identifier)]
[DisplayName("VentilationService")]
[Description("VentilationService service (no description provided)")]
class VentilationService : DsspServiceBase
{
/// <summary>
/// Service state
/// </summary>
[ServiceState]
VentilationServiceState _state = new VentilationServiceState();
/// <summary>
/// Main service port
/// </summary>
[ServicePort("/VentilationService", AllowMultipleInstances = true)]
VentilationServiceOperations _mainPort = new VentilationServiceOperations();
[SubscriptionManagerPartner]
submgr.SubscriptionManagerPort _submgrPort = new
submgr.SubscriptionManagerPort();
/// <summary>
/// TemperatureService partner
/// </summary>
[Partner("TemperatureService", Contract =
temperatureservice.Contract.Identifier, CreationPolicy =
PartnerCreationPolicy.UseExistingOrCreate)]
Rozdział 12. Wstęp do CCR i DSS 267
temperatureservice.TemperatureServiceOperations _temperatureServicePort
= new temperatureservice.TemperatureServiceOperations();
temperatureservice.TemperatureServiceOperations _temperatureServiceNotify =
new temperatureservice.TemperatureServiceOperations();
/// <summary>
/// Service constructor
/// </summary>
public VentilationService(DsspServiceCreationPort creationPort)
: base(creationPort)
{
}
/// <summary>
/// Service start
/// </summary>
protected override void Start()
{
//
// Add service specific initialization here
//
base.Start();
}
/// <summary>
/// Handles Subscribe messages
/// </summary>
/// <param name="subscribe">the subscribe request</param>
[ServiceHandler]
public void SubscribeHandler(Subscribe subscribe)
{
SubscribeHelper(_submgrPort, subscribe.Body, subscribe.ResponsePort);
}
}
}
Użytkownik podczas normalnej pracy z kodem nie odnosi się do niego bezpośrednio
ani nie musi go modyfikować.
Warto zwrócić uwagę na nazwy plików. Wszystkie tworzone przez nas biblioteki
serwisów zawierają informację o miesiącu oraz roku, w którym powstał projekt.
Dzięki temu unikamy problemów związanych z konfliktami wersji podczas używania tej
samej biblioteki w kilku wariantach. Dlatego nazwa pliku biblioteki usługi Temparature
Service to TemperatureService.Y2013.M01.dll, a nie TemperatureService.dll. W efek-
cie nazwy plików, do jakich będziemy się odwoływali dalej w tym rozdziale, mogą
różnić się od tych na komputerach czytelników właśnie przyrostkiem informującym
o czasie utworzenia. Dodatkowo na bazie tej biblioteki tworzone są automatycznie dwie
kolejne, za pomocą programu DssProxy.exe. Pierwsza ma przyrostek „Proxy”, a druga
„Transform”; obie zapewniają ważną warstwę separacji danych i metod.
namespace VentilationService
{
[Contract(Contract.Identifier)]
[DisplayName("VentilationService")]
[Description("VentilationService service (no description provided)")]
class VentilationService : DsspServiceBase
{
/// <summary>
/// Service state
/// </summary>
[ServiceState(StateTransform = "VentilationService.VentilationService.xslt")]
VentilationServiceState _state = new VentilationServiceState();
/// <summary>
/// Main service port
/// </summary>
[ServicePort("/VentilationService", AllowMultipleInstances = false)]
VentilationServiceOperations _mainPort = new VentilationServiceOperations();
Rozdział 12. Wstęp do CCR i DSS 269
[SubscriptionManagerPartner]
submgr.SubscriptionManagerPort _submgrPort = new
submgr.SubscriptionManagerPort();
/// <summary>
/// TemperatureService partner
/// </summary>
[Partner("TemperatureService", Contract =
temperatureservice.Contract.Identifier, CreationPolicy =
PartnerCreationPolicy.UseExistingOrCreate)]
temperatureservice.TemperatureServiceOperations _temperatureServicePort =
new temperatureservice.TemperatureServiceOperations();
temperatureservice.TemperatureServiceOperations _temperatureServiceNotify =
new temperatureservice.TemperatureServiceOperations();
...
Listing 12.14. Nowe własności stanu serwisu wyświetlane przez transformatę XSLT w pliku
VentilationServiceTypes.cs
using System;
using System.Collections.Generic;
using System.ComponentModel;
using Microsoft.Ccr.Core;
using Microsoft.Dss.Core.Attributes;
using Microsoft.Dss.Core.DsspHttp;
using Microsoft.Dss.ServiceModel.Dssp;
using Microsoft.Dss.ServiceModel.DsspServiceBase;
using W3C.Soap;
namespace VentilationService
{
/// <summary>
/// VentilationService contract class
/// </summary>
public sealed class Contract
{
/// <summary>
/// DSS contract identifer for VentilationService
/// </summary>
[DataMember]
public const string Identifier =
"http://schemas.tempuri.org/2013/02/ventilationservice.html";
}
/// <summary>
/// VentilationService state
/// </summary>
[DataContract]
public class VentilationServiceState
{
private DateTime time = DateTime.Now;
[DataMember]
public DateTime Time
{
270 Programowanie równoległe i asynchroniczne w C# 5.0
/// <summary>
/// VentilationService main operations port
/// </summary>
[ServicePort]
public class VentilationServiceOperations : PortSet<DsspDefaultLookup,
DsspDefaultDrop, Get, Subscribe, HttpGet>
{
}
/// <summary>
/// VentilationService get operation
/// </summary>
public class Get : Get<GetRequestType, PortSet<VentilationServiceState, Fault>>
{
/// <summary>
/// Creates a new instance of Get
/// </summary>
public Get()
{
}
/// <summary>
/// Creates a new instance of Get
/// </summary>
/// <param name="body">the request message body</param>
public Get(GetRequestType body)
: base(body)
{
}
/// <summary>
/// Creates a new instance of Get
/// </summary>
/// <param name="body">the request message body</param>
/// <param name="responsePort">the response port for the request</param>
public Get(GetRequestType body, PortSet<VentilationServiceState, Fault>
responsePort)
: base(body, responsePort)
{
}
}
/// <summary>
/// VentilationService subscribe operation
/// </summary>
Rozdział 12. Wstęp do CCR i DSS 271
/// <summary>
/// Creates a new instance of Subscribe
/// </summary>
/// <param name="body">the request message body</param>
public Subscribe(SubscribeRequestType body)
: base(body)
{
}
/// <summary>
/// Creates a new instance of Subscribe
/// </summary>
/// <param name="body">the request message body</param>
/// <param name="responsePort">the response port for the request</param>
public Subscribe(SubscribeRequestType body, PortSet<SubscribeResponseType,
Fault> responsePort)
: base(body, responsePort)
{
}
}
}
xmlns:vst="http://schemas.tempuri.org/2013/02/ventilationservice.html">
<xsl:template match="/vst:VentilationServiceState">
<table>
<tr class="even">
<th colspan="2">Ventilation Service</th>
</tr>
272 Programowanie równoległe i asynchroniczne w C# 5.0
<tr class="odd">
<th>Time:</th>
<td>
<xsl:value-of select="vst:Time"/>
</td>
</tr>
<tr class="even">
<th>Ventilation Efficiency:</th>
<td>
<xsl:value-of select="format-number(vst:VentilationEfficiency, '0.0')"/>
</td>
</tr>
</table>
</xsl:template>
</xsl:stylesheet>
Aby spełnić wymogi tego prostego scenariusza, dodano do stanu serwisu dwie wła-
sności: AmbientTemperature służącą do przechowywania temperatury otoczenia, od-
czytywaną przez czujniki w serwisie TemperatureService, oraz HardwareTemperature
do wyświetlania temperatury sprzętu (listing 12.17). Konieczne będzie także zarejestro-
wanie subskrypcji na nowe dane dostarczane do własności AmbientTemperature przez
usługę TemperatureService oraz dodanie prostej logiki obsługującej nowe dane (li-
sting 12.16). Polega ona na dodaniu usługi kontrolującej wentylację, jako nowego
subskrybenta do serwisu temperatury, oraz metody obsługującej przybycie nowych
danych TemperatureChangedHandler. Ta metoda na podstawie prostego wzoru obliczy
temperaturę sprzętu, bazując na temperaturze otoczenia i wentylacji. Jednocześnie wen-
tylacja zostanie ustawiona w sposób zapewniający optymalną temperaturę. Ze względu
na to, iż będziemy w metodzie obsługującej nowe dane zmieniać stan usługi, oznaczymy
Rozdział 12. Wstęp do CCR i DSS 273
base.Start();
MainPortInterleave.CombineWith(new Interleave(
new TeardownReceiverGroup(),
new ExclusiveReceiverGroup
(
Arbiter.Receive<temperatureservice.Replace>(true,
_temperatureServiceNotify, TemperatureChangedHandler)
),
new ConcurrentReceiverGroup()));
}
...
private void TemperatureChangedHandler(temperatureservice.Replace replaceMessage)
{
_state.Time = DateTime.Now;
_state.AmbientTemperature = replaceMessage.Body.Temperature;
double efficiency = (replaceMessage.Body.Temperature - 20.0)*10.0 + 50.0;
if (efficiency > 100.0)
_state.VentilationEfficiency = 100.0;
else
if(efficiency < 0.0)
_state.VentilationEfficiency = 0.0;
else
_state.VentilationEfficiency = efficiency;
_state.HardwareTemperature = replaceMessage.Body.Temperature -
(_state.VentilationEfficiency - 50.0) / 10.0;
}
274 Programowanie równoległe i asynchroniczne w C# 5.0
W kodzie widocznym na listingu 12.16 użyta została metoda LogError. Jest to bardzo
ważny element środowiska CCR i DSS — zunifikowany system logowania, raporto-
wania oraz przechwytywania błędów. Rejestrowane zdarzenia mogą mieć różne stopnie
ważności. W zależności od tego używamy jednej z metod: LogError, LogWarning,
LogInfo, LogVerbose albo podstawowej metody Log. Dostępna jest również strona
internetowa służąca do wyświetlania, filtrowania i analizy otrzymywanych informa-
cji, do której link jest widoczny w przeglądarce internetowej na liście łączy w lewym
górnym rogu. Jej przykładowy wygląd prezentujemy na rysunku 12.15. Ponadto, tak
jak w każdej usłudze, możemy podpiąć się przy użyciu subskrypcji do serwisu reje-
strującego wszystkie wpisy do systemu logowania i tam analizować, archiwizować oraz
przetwarzać zbierane dane. Wszystkie metody odpowiedzialne za logowanie są bez-
pieczne w kontekście bezpieczeństwa wątków oraz kolejności przetwarzania.
Rozdział 12. Wstęp do CCR i DSS 275
Rysunek 12.15. Widok strony internetowej wyświetlanej przez środowisko DSS, która zapewnia
zintegrowany dostęp do informacji i raportów wszystkich serwisów działających w danej instancji
DssHost.exe
Rysunek 12.16.
Okno serwisu
obsługującego
wentylację po
ostatecznych
modyfikacjach kodu
276 Programowanie równoległe i asynchroniczne w C# 5.0
using Microsoft.Dss.Core.DsspHttp;
namespace CalculateService
{
/// <summary>
/// CalculateService contract class
/// </summary>
public sealed class Contract
{
/// <summary>
/// DSS contract identifer for CalculateService
/// </summary>
[DataMember]
public const string Identifier =
"http://schemas.tempuri.org/2013/02/calculateservice.html";
}
/// <summary>
/// CalculateService state
/// </summary>
[DataContract]
public class CalculateServiceState
{
private double calculationResult = 0.0;
[DataMember]
public double CalculationResult
{
get { return calculationResult; }
set { calculationResult = value; }
}
}
/// <summary>
Rozdział 13. Skalowalne rozwiązanie dla systemów rozproszonych 279
/// <summary>
/// Calculation parameter
/// </summary>
[DataContract]
public class CalculationParameters
{
[DataMember]
public long NumberOfTrials { get; set; }
[DataMember]
public int Seed { get; set; }
}
/// <summary>
/// CalculateService get operation
/// </summary>
public class Get : Get<GetRequestType, PortSet<CalculateServiceState, Fault>>
280 Programowanie równoległe i asynchroniczne w C# 5.0
{
/// <summary>
/// Creates a new instance of Get
/// </summary>
public Get()
{
}
/// <summary>
/// Creates a new instance of Get
/// </summary>
/// <param name="body">the request message body</param>
public Get(GetRequestType body)
: base(body)
{
}
/// <summary>
/// Creates a new instance of Get
/// </summary>
/// <param name="body">the request message body</param>
/// <param name="responsePort">the response port for the request</param>
public Get(GetRequestType body, PortSet<CalculateServiceState, Fault>
responsePort)
: base(body, responsePort)
{
}
}
/// <summary>
/// CalculateService subscribe operation
/// </summary>
public class Subscribe : Subscribe<SubscribeRequestType,
PortSet<SubscribeResponseType, Fault>>
{
/// <summary>
/// Creates a new instance of Subscribe
/// </summary>
public Subscribe()
{
}
/// <summary>
/// Creates a new instance of Subscribe
/// </summary>
/// <param name="body">the request message body</param>
public Subscribe(SubscribeRequestType body)
: base(body)
{
}
/// <summary>
/// Creates a new instance of Subscribe
/// </summary>
/// <param name="body">the request message body</param>
/// <param name="responsePort">the response port for the request</param>
public Subscribe(SubscribeRequestType body, PortSet<SubscribeResponseType,
Fault> responsePort)
Rozdział 13. Skalowalne rozwiązanie dla systemów rozproszonych 281
: base(body, responsePort)
{
}
}
}
Idea stojąca za działaniem metody CalculatePi z listingu 13.2 została opisana w roz-
dziale 2. Nie jest to ani precyzyjny, ani zbyt szybki algorytm obliczania π, ale tu wła-
śnie chodzi o taki „pożeracz” czasu i wydajności procesora, a nie o uzyskanie jak
najlepszego przybliżenia stałej Archimedesa. Aby zapewnić w miarę równomierny
rozkład wyników, funkcja pobiera jako parametr ziarno seed oraz liczbę losowań number
OfTrials, która zapewnia odpowiednią długość wykonania kodu i sensowną precyzję
wyniku. Druga ważna modyfikacja w pliku CalculateService.cs to obsłużenie wiado-
mości Compute, która jest poleceniem wykonania obliczeń z użyciem podanego ziarna oraz
z określoną liczbą prób. Do przekazania tych danych stosujemy wiadomość zawierającą
własności, które chcemy przekazać od nadawcy do odbiorcy (klasa CalculationParameters
zdefiniowana w pliku CalculateServiceTypes.cs, listing 13.2). Podobnie wyglądał kod
związany z wiadomością Replace służącą do zmiany stanu serwisu w projekcie Tempe-
ratureService (listingi 12.7, 12.8 i 12.9). Trzecią różnicą, w porównaniu z poprzed-
nimi projektami, jest zezwolenie na wystąpienie większej liczby serwisów o tym samym
kontrakcie. W tym celu atrybut AllowMultipleInstances pozostawiamy z domyślną
wartością true. Ostatnią zmianą jest dodanie pliku transformaty XSLT. Jego zawar-
tość prezentujemy na listingu 13.3. Jest to typowa transformata, z jakiej korzystaliśmy
we wcześniejszych projektach.
Listing 13.2. Obsługa transformaty XSLT, dwóch typów wiadomości Replace i Calculate
oraz intensywna numerycznie metoda w CalculateService.cs
using System;
using System.Collections.Generic;
using System.ComponentModel;
using Microsoft.Ccr.Core;
using Microsoft.Dss.Core.Attributes;
using Microsoft.Dss.ServiceModel.Dssp;
using Microsoft.Dss.ServiceModel.DsspServiceBase;
using W3C.Soap;
using submgr = Microsoft.Dss.Services.SubscriptionManager;
namespace CalculateService
{
[Contract(Contract.Identifier)]
[DisplayName("CalculateService")]
[Description("CalculateService service (no description provided)")]
class CalculateService : DsspServiceBase
{
/// <summary>
/// Service state
/// </summary>
[ServiceState(StateTransform = "CalculateService.CalculateService.xslt")]
CalculateServiceState _state = new CalculateServiceState();
/// <summary>
/// Main service port
282 Programowanie równoległe i asynchroniczne w C# 5.0
/// </summary>
[ServicePort("/CalculateService", AllowMultipleInstances = true)]
CalculateServiceOperations _mainPort = new CalculateServiceOperations();
[SubscriptionManagerPartner]
submgr.SubscriptionManagerPort _submgrPort = new
submgr.SubscriptionManagerPort();
/// <summary>
/// Service constructor
/// </summary>
public CalculateService(DsspServiceCreationPort creationPort)
: base(creationPort)
{
}
/// <summary>
/// Service start
/// </summary>
protected override void Start()
{
//
// Add service specific initialization here
//
base.Start();
[ServiceHandler(ServiceHandlerBehavior.Exclusive)]
public IEnumerator<ITask> ReplaceHandler(Replace replace)
{
_state = replace.Body;
SendNotification(_submgrPort, replace);
replace.ResponsePort.Post(DefaultReplaceResponseType.Instance);
yield break;
}
[ServiceHandler(ServiceHandlerBehavior.Concurrent)]
public void CalculateHandler(Calculate request)
{
double piValue = CalculatePi(request.Body.NumberOfTrials,
request.Body.Seed);
CalculateServiceState newState = new CalculateServiceState();
newState.CalculationResult = piValue;
Replace replace = new Replace(newState);
_mainPort.Post(replace);
}
x = rnd.NextDouble();
y = rnd.NextDouble();
if (x * x + y * y < 1) ++numberOfHits;
}
return 4.0 * numberOfHits / numberOfTrials;
}
/// <summary>
/// Handles Subscribe messages
/// </summary>
/// <param name="subscribe">the subscribe request</param>
[ServiceHandler]
public void SubscribeHandler(Subscribe subscribe)
{
SubscribeHelper(_submgrPort, subscribe.Body, subscribe.ResponsePort);
}
}
}
Listing 13.3. Standardowy plik transformaty XSLT, dla serwisu CalculateService, wyświetlający wynik
ostatnich obliczeń
<?xml version="1.0" encoding="UTF-8" ?>
<xsl:stylesheet version="1.0"
xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
xmlns:cst="http://schemas.tempuri.org/2013/02/calculateservice.html">
<xsl:template match="/cst:CalculateServiceState">
<table>
<tr class="even">
<th colspan="2">Calculate Service</th>
</tr>
<tr class="odd">
<th>Result:</th>
<td>
<xsl:value-of select="cst:CalculationResult"/>
</td>
</tr>
</table>
</xsl:template>
</xsl:stylesheet>
284 Programowanie równoległe i asynchroniczne w C# 5.0
Rysunek 13.1.
Widok strony internetowej
wyświetlanej dla serwisu
obliczeniowego
CalculateService
using Microsoft.Dss.Core.DsspHttp;
Rozdział 13. Skalowalne rozwiązanie dla systemów rozproszonych 285
namespace SpawnService
{
/// <summary>
/// SpawnService contract class
/// </summary>
public sealed class Contract
{
/// <summary>
/// DSS contract identifer for SpawnService
/// </summary>
[DataMember]
public const string Identifier =
"http://schemas.tempuri.org/2013/02/spawnservice.html";
}
/// <summary>
/// SpawnService state
/// </summary>
[DataContract]
public class SpawnServiceState
{
private List<double> calculationList = new List<double>(10);
[DataMember]
public List<double> CalculationList
{
get { return calculationList; }
set { calculationList = value; }
}
}
/// <summary>
/// SpawnService main operations port
/// </summary>
[ServicePort]
public class SpawnServiceOperations : PortSet<DsspDefaultLookup,
DsspDefaultDrop, Get, Subscribe, HttpGet>
{
}
/// <summary>
/// SpawnService get operation
/// </summary>
public class Get : Get<GetRequestType, PortSet<SpawnServiceState, Fault>>
{
/// <summary>
/// Creates a new instance of Get
/// </summary>
public Get()
{
}
/// <summary>
/// Creates a new instance of Get
/// </summary>
/// <param name="body">the request message body</param>
public Get(GetRequestType body)
: base(body)
{
286 Programowanie równoległe i asynchroniczne w C# 5.0
/// <summary>
/// Creates a new instance of Get
/// </summary>
/// <param name="body">the request message body</param>
/// <param name="responsePort">the response port for the request</param>
public Get(GetRequestType body, PortSet<SpawnServiceState, Fault>
responsePort)
: base(body, responsePort)
{
}
}
/// <summary>
/// SpawnService subscribe operation
/// </summary>
public class Subscribe : Subscribe<SubscribeRequestType, PortSet
<SubscribeResponseType, Fault>>
{
/// <summary>
/// Creates a new instance of Subscribe
/// </summary>
public Subscribe()
{
}
/// <summary>
/// Creates a new instance of Subscribe
/// </summary>
/// <param name="body">the request message body</param>
public Subscribe(SubscribeRequestType body)
: base(body)
{
}
/// <summary>
/// Creates a new instance of Subscribe
/// </summary>
/// <param name="body">the request message body</param>
/// <param name="responsePort">the response port for the request</param>
public Subscribe(SubscribeRequestType body, PortSet<SubscribeResponseType,
Fault> responsePort)
: base(body, responsePort)
{
}
}
}
Rysunek 13.2.
Dodawanie referencji
do serwisu obsługującego
obliczenia
który skróci kod, w jakim będziemy odwoływać się do tej usługi. Jest to praktyka
powszechnie stosowana podczas dołączania referencji do serwisów partnerskich. Za-
pewnimy także wyświetlanie odpowiedniej strony w przeglądarce poprzez modyfika-
cję atrybutu stanu serwisu oraz dodanie wartości false do AllowMultipleInstances,
aby zablokować tworzenie wielu instancji usługi (listing 13.5). Do kodu klasy Spawn
Service dodamy stałe pole numberOfCalculators, które będzie reprezentować ilość
tworzonych usług wykonujących obliczenia, a także tablicę calculatorPorts odpo-
wiednich numerów portów służących do komunikacji i powiadamiania pomiędzy
SpawnService a instancjami obliczeniowymi CalculateService oraz listę nazw dla usług
— calculatorUris (listing 13.5). Modyfikacja metody Start oraz dodanie obsługi pod-
łączenia innych usług w tej metodzie to ConnectToPartners, a rozpoczęcie obliczeń to
SpawnCalculations. W metodzie Start znajduje się także pętla for, która odpowiada
za tworzenie partnerów.
288 Programowanie równoległe i asynchroniczne w C# 5.0
Listing 13.5. Modyfikacje związane z obsługą XSLT, zaznaczenie unikalności serwisu i dodanie
zmiennych służących do tworzenia i komunikacji z partnerami w SpawnService.cs
using System;
using System.Collections.Generic;
using System.ComponentModel;
using Microsoft.Ccr.Core;
using Microsoft.Dss.Core.Attributes;
using Microsoft.Dss.ServiceModel.Dssp;
using Microsoft.Dss.ServiceModel.DsspServiceBase;
using W3C.Soap;
using submgr = Microsoft.Dss.Services.SubscriptionManager;
namespace SpawnService
{
[Contract(Contract.Identifier)]
[DisplayName("SpawnService")]
[Description("SpawnService service (no description provided)")]
class SpawnService : DsspServiceBase
{
/// <summary>
/// Service state
/// </summary>
[ServiceState(StateTransform = "SpawnService.SpawnService.xslt")]
SpawnServiceState _state = new SpawnServiceState();
/// <summary>
/// Main service port
/// </summary>
[ServicePort("/SpawnService", AllowMultipleInstances = false)]
SpawnServiceOperations _mainPort = new SpawnServiceOperations();
[SubscriptionManagerPartner]
submgr.SubscriptionManagerPort _submgrPort = new
submgr.SubscriptionManagerPort();
/// <summary>
/// Service constructor
/// </summary>
public SpawnService(DsspServiceCreationPort creationPort)
: base(creationPort)
{
}
/// <summary>
/// Service start
/// </summary>
protected override void Start()
{
Rozdział 13. Skalowalne rozwiązanie dla systemów rozproszonych 289
base.Start();
Activate(
Arbiter.Receive(false, TimeoutPort(1000),
delegate(DateTime time)
{
ConnectToPartners();
SpawnCalculations(9);
}
));
}
/// <summary>
/// Handles Subscribe messages
/// </summary>
/// <param name="subscribe">the subscribe request</param>
[ServiceHandler]
public void SubscribeHandler(Subscribe subscribe)
{
SubscribeHelper(_submgrPort, subscribe.Body, subscribe.ResponsePort);
}
}
}
Listing 13.6. Inicjacja obliczeń, podłączenie serwisów partnerskich oraz obsługa powiadomień
o zakończeniu w SpawnService.cs
...
private void SpawnCalculations(int calculationNumber)
{
int internalCounter = 0;
290 Programowanie równoległe i asynchroniczne w C# 5.0
Przygotowany kod zawiera kilka interesujących technik. Jedna z nich jest zawarta
w metodzie CalculationFinishedHandler, która jest wywoływana w trybie wyłącznym,
dlatego możemy w niej modyfikować stan usługi i zdać się na automatyczną synchro-
nizację dostępu. Nadajemy jej status metody wyłącznej i podłączamy do głównego prze-
plotu (ang. interleave) metodą CombineWith należącą do obiektu MainPortInterleave
(listing 13.6). Obiekt ten reprezentuje mechanizm odbierania i obsługiwania komunika-
tów przychodzących na główny port naszej usługi. Możemy tworzyć dodatkowe porty,
jednak znajdą się one poza głównym przeplotem i będziemy musieli samodzielnie dbać
o ich synchronizację z resztą kodu.
Opóźnione uruchamianie
Często w trakcie pisania kodu potrzebny jest obiekt pozwalający na wykonanie żądanej
operacji z pewnym opóźnieniem. By uzyskać taki efekt w bibliotekach CCR i DSS,
należy wysłać wiadomość Timeout na port TimeoutPort naszej usługi. Po określonym
czasie uruchomi on wskazany fragment kodu. Ilustrację tego zachowania stanowi listing
13.5. Wyzwalacz jest tam wykorzystany do inicjacji połączenia z usługami-partnerami
i zlecenia im obliczenia liczby π. Ponieważ tworzenie usług partnerskich może zająć
trochę czasu, kolejny krok, a więc połączenie z nimi, opóźniamy o sekundę.
Plik transformaty XSLT (listing 13.7) jest dość podobny do tworzonych we wcze-
śniejszych projektach. Zawiera jednak jedną nowość, a mianowicie pętlę xsl:for-each,
która przechodzi przez wszystkie elementy listy i wyświetla je w odpowiednich wier-
szach i kolumnach tabeli. Jest to kolejna ilustracja łatwości użycia i siły transformaty
XSLT w przypadku interakcji z danymi w formacie XML.
Listing 13.7. Plik transformaty XSLT dla serwisu SpawnService, wyświetlający wyniki ostatnich
obliczeń z wykorzystaniem pętli
<?xml version="1.0" encoding="UTF-8" ?>
<xsl:stylesheet version="1.0"
xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
xmlns:sst="http://schemas.tempuri.org/2013/02/spawnservice.html">
<xsl:import href="/resources/dss/Microsoft.Dss.Runtime.Home.MasterPage.xslt" />
292 Programowanie równoległe i asynchroniczne w C# 5.0
<xsl:template match="/">
<xsl:call-template name="MasterPage">
<xsl:with-param name="serviceName">
Spawn Service
</xsl:with-param>
<xsl:with-param name="description">
Represents the calculation hub in the DSS environment.
</xsl:with-param>
</xsl:call-template>
</xsl:template>
<xsl:template match="/sst:SpawnServiceState">
<table>
<tr class="even">
<th colspan="2">Spawn Service</th>
</tr>
<xsl:for-each select="sst:CalculationList/sst:double">
<tr class="odd">
<th>
Pi =
</th>
<td>
<xsl:value-of select="."/>
</td>
</tr>
</xsl:for-each>
</table>
</xsl:template>
</xsl:stylesheet>
Po dodaniu pliku XSLT usługa realizująca równoległe obliczenia jest już gotowa.
Możemy ją uruchomić, a ona zainicjuje obliczenia i udostępni ich wyniki. Po czasie
zależnym od wydajności sprzętu pojawi się na stronie usługi lista wyników podobna
do tej z rysunku 13.3. Zachęcamy do eksperymentowania z parametrami obliczenio-
wymi i sprawdzenia, jaki mają wpływ na dokładność obliczeń.
Rysunek 13.3.
Widok strony internetowej
wyświetlanej dla serwisu
koordynującego
i inicjującego obliczenia
SpawnService
Rozdział 13. Skalowalne rozwiązanie dla systemów rozproszonych 293
Pierwsze polecenie zmienia katalog na taki, w którym znajduje się program DssDeploy.exe,
a w drugim uruchamiamy go w celu utworzenia pakietu dystrybucyjnego Calculate
Install.exe. W ścieżce dostępu do pliku biblioteki obliczeniowej UserName zastępujemy
odpowiednią nazwą użytkownika. Ten pakiet należy zainstalować na zdalnym kompute-
rze, zaraz po zainstalowaniu pakietu Microsoft CCR and DSS Runtime 4 Redistributa-
ble.exe, wspomnianego już wcześniej. Aby aktywować możliwość komunikacji z innymi
węzłami poprzez sieć, musimy zmienić ustawienia bezpieczeństwa w pliku DssHost.
exe.config znajdującym się w folderze C:\Users\Piotr\Microsoft Robotics Dev Studio
4\bin. Edytując ten plik w notatniku, powinniśmy odnaleźć następującą linijkę:
<add key="Microsoft.Dss.Services.Transports.AllowUnsecuredRemoteAccess" value="false"/>
Rysunek 13.4.
Widok strony internetowej
z wybranym panelem
Security Manager
z bocznej listy łączy
Listing 13.8. Modyfikacje kodu odpowiedzialne za utworzenie usług obliczających na innym węźle
using System;
using System.Collections.Generic;
using System.ComponentModel;
using Microsoft.Ccr.Core;
using Microsoft.Dss.Core.Attributes;
using Microsoft.Dss.ServiceModel.Dssp;
using Microsoft.Dss.ServiceModel.DsspServiceBase;
using W3C.Soap;
using submgr = Microsoft.Dss.Services.SubscriptionManager;
Rysunek 13.5. Widok strony internetowej, na której wyłączamy tymczasowo, na potrzeby przykładu,
zabezpieczenia autoryzacji, klikając przycisk Disable
namespace SpawnService
{
[Contract(Contract.Identifier)]
[DisplayName("SpawnService")]
[Description("SpawnService service (no description provided)")]
class SpawnService : DsspServiceBase
{
/// <summary>
/// Service state
/// </summary>
[ServiceState(StateTransform = "SpawnService.SpawnService.xslt")]
SpawnServiceState _state = new SpawnServiceState();
/// <summary>
/// Main service port
/// </summary>
[ServicePort("/SpawnService", AllowMultipleInstances = false)]
SpawnServiceOperations _mainPort = new SpawnServiceOperations();
[SubscriptionManagerPartner]
submgr.SubscriptionManagerPort _submgrPort = new
submgr.SubscriptionManagerPort();
/// <summary>
/// Service constructor
/// </summary>
public SpawnService(DsspServiceCreationPort creationPort)
: base(creationPort)
{
}
/// <summary>
/// Service start
/// </summary>
protected override void Start()
{
for (int i = 0; i < numberOfCalculators; i++)
{
calculatorUris[i] = @"http://158.75.101.74:50000/calculate" +
i.ToString();
ServiceInfoType serviceInfo = new
ServiceInfoType(calc.Contract.Identifier, calculatorUris[i]);
Microsoft.Dss.Services.Constructor.Proxy.ConstructorPort cp =
ServiceForwarder<Microsoft.Dss.Services.Constructor.
Proxy.ConstructorPort>(@"http://158.75.101.74:50000/constructor");
Microsoft.Dss.Services.Constructor.Proxy.Create create = new
Microsoft.Dss.Services.Constructor.Proxy.Create(serviceInfo);
create.TimeSpan = DsspOperation.DefaultShortTimeSpan;
cp.Post(create);
Activate(Arbiter.Choice(
create.ResponsePort,
delegate(CreateResponse createResponse) { },
delegate(Fault f) { LogError(f); }
));
}
base.Start();
Activate(
Arbiter.Receive(false, TimeoutPort(1000),
delegate(DateTime time)
{
ConnectToPartners();
SpawnCalculations(9);
}
));
}
{
int internalCounter = 0;
for (int i = 0; i < calculationNumber; i++)
{
calculatorPorts[internalCounter].Post(new calc.Calculate(new
calc.CalculationParameters()
{
NumberOfTrials = 10000000L,
Seed = (new Random().Next()) + i
}));
internalCounter++;
if (internalCounter >= numberOfCalculators)
internalCounter = 0;
}
}
Activate(
Arbiter.Choice(calculatorPorts[i].Subscribe(calculatorNotify),
delegate(SubscribeResponseType response)
{
Console.WriteLine("Subscribed.");
},
delegate(Fault fault)
{
Console.WriteLine("Unable to subscribe: " + fault);
}
));
;
LogInfo("Connected to the Service Calculate" + i.ToString());
}
/// <summary>
298 Programowanie równoległe i asynchroniczne w C# 5.0
Rysunek 13.6.
Widok strony internetowej
przedstawiającej wynik
działania rozproszonych
obliczeń
Podsumowanie
Biblioteki CCR i DSS będące częścią środowiska Microsoft Robotics tworzą wygodny,
szybki i sprawdzony pakiet narzędzi używany przede wszystkim w automatyce i ro-
botyce, ale również do tych wszystkich zadań i obliczeń, które wymagają asynchro-
niczności, koordynacji oraz łatwego rozproszenia i skalowania. Z łatwością możemy
sterować armią robotów działających w oparciu o Windows CE lub utworzyć rozpro-
szone środowisko do obliczeń na kartach graficznych czy procesorach CPU, komuni-
kujące się przy użyciu protokołu TCP/IP. Mimo że pierwsze kroki stawiane w tej
dziedzinie są trudne i używanie CCR i DSS wymaga pewnej wprawy, zestaw narzędzi,
dokumentacja oraz producent i środowisko Robotics sprawiają, że jest to bardzo
satysfakcjonująca przygoda. Ciekawym studium wykorzystania bibliotek CCR i DSS
Rozdział 13. Skalowalne rozwiązanie dla systemów rozproszonych 299
Zadania
1. Zmodyfikuj pierwszy i drugi projekt tak, aby obsługiwały także kontrolę
wilgotności w pomieszczeniu. Do usługi monitorującej temperaturę
TemperatureService dodaj zmienną opisującą wilgotność Humidity oraz jej
cykliczną aktualizację razem z temperaturą. W usłudze VentilationService
dodaj własności AmbientHumidity, HardwareHumidity oraz własność
DehumidifierEfficiency reprezentującą wydajność osuszacza powietrza
(w procentach, podobnie jak przy wentylacji).
2. Wykonaj pierwsze zadanie, tworząc zupełnie nowe usługi HumidityService
oraz DehumidifierService, obsługujące tylko wilgotność. W ten sposób
opracujesz dwie podstawowe, połączone usługi zupełnie samodzielnie.
3. Jeżeli dysponujesz kamerą internetową, skompiluj program Blob Tracker
Calibrate dostępny wśród przykładów dostarczonych z Microsoft Robotics
Developer Studio. Znajduje się on w podkatalogu samples\Technologies\Vision\
BlobTrackerCalibrate, w katalogu, w którym zainstalowane zostało środowisko
Robotics. Po uruchomieniu programu można w oknie wpisać nazwę obiektu,
następnie myszą wskazać go na ekranie, tworząc małe kółeczko w jego wnętrzu
i kliknąć przycisk Train. Serwis nauczy się rozpoznawać obiekt i rozpocznie
jego śledzenie na ekranie. Warto wybrać na początek obiekt średniej wielkości
o barwie wyraźnie kontrastującej w stosunku do otoczenia.
4. Zapoznaj się z przykładami, dokumentacją i wprowadzeniem dotyczącym
środowiska DSS udostępnionym na stronach MSDN pod adresem:
http://msdn.microsoft.com/en-us/library/dd145263.aspx. Warto przeczytać
dostępny tam samouczek.
5. Do usług liczących liczbę π dodaj obsługę pomiaru i rejestracji czasu wykonania.
W stanie usługi CalculateService powinna pojawić się nowa własność. Należy
także zmodyfikować plik transformaty XSLT tak, aby zapewnić jej wyświetlanie.
Wartość ta powinna być też przekazana do usługi-zarządcy SpawnService i tam
wyświetlona.
6. Do poprzedniego zadania dodaj jeszcze pomiar czasu od wysłania żądania
z usługi SpawnService do otrzymania przez nią wyniku. Wynik powinien zostać
wyświetlony na stronie internetowej.
1
http://grids.ucs.indiana.edu/ptliupages/publications/CCRApril16open.pdf
300 Programowanie równoległe i asynchroniczne w C# 5.0
Rozdział 14.
Wprowadzenie
do Reactive Extensions.
Zarządzanie sekwencjami
zdarzeń
Rafał Pawłaszek i Piotr Sybilski
Reactive Extensions (w skrócie określana jako Rx) jest biblioteką Microsoftu, która
pozwala komponować zdarzenia oraz operacje asynchroniczne z wykorzystaniem wyra-
żeń LINQ oraz koordynuje przetwarzanie równoległe w sposób parametryczny. W tym
rozdziale wyjaśnimy, co to wszystko znaczy, w jaki sposób jest zaimplementowane oraz
jak można to wykorzystać we własnych aplikacjach.
302 Programowanie równoległe i asynchroniczne w C# 5.0
Czy warto uczyć się Rx? Operacje asynchroniczne zawsze stwarzały problemy pod-
czas programowania. Aby przekazywać informacje o wynikach i trwaniu operacji,
bardzo często należy wykorzystywać flagi oraz zapamiętywać stany i używać funkcji
odpowiedzi (ang. callback functions). W Rx podejście jest zupełnie inne. Zdarzenia nie
są traktowane jednostkowo, lecz jako sekwencje. Do takich sekwencji można utwo-
rzyć zapytania, które w Rx implementowane są za pomocą LINQ, stąd często mówi
się, że Rx to LINQ do zdarzeń (ang. LINQ To Events). Jedną z wielu zalet biblioteki Rx
jest to, że doskonale integruje się nie tylko ze zdarzeniami w .NET, lecz ogólnie z opera-
cjami asynchronicznymi.
Platforma ta staje się coraz bardziej popularna. Rozwijana jest nie tylko dla .NET;
w ramach projektu tworzone są także biblioteki dla języków JavaScript oraz Visual C++.
Rx była dołączona już do Windows Phone 7 jako metoda utrzymywania interfejsu
użytkownika aktywnego i odpowiadającego w trakcie wykonywania czasochłonnych
operacji. Przykładowo oficjalna aplikacja kliencka GitHub dla systemu Windows jest
utworzona na podstawie Rx. Programistom z GitHub tak bardzo spodobała się praca
z Rx, że opracowali własną implementację dla systemu OS X na komputery Apple
Macintosh i oparli na niej bibliotekę interfejsu, którą nazwali ReactiveCocoa.
Programowanie reaktywne
Jeśli z aplikacji zostanie wysłane zapytanie do bazy danych — aplikacja będzie czekać
na zakończenie operacji. Kiedy trzeba będzie wczytać duży plik — aplikacja będzie
czekać na zakończenie operacji. Gdy trzeba będzie skorzystać z serwisu internetowego
— aplikacja będzie czekać na zakończenie operacji. Jest to przykład programowania in-
teraktywnego. W programowaniu interaktywnym przepływ zadań aplikacji jest linio-
wy. Gdy w aplikacji zostanie naciśnięty przycisk i rozpocznie się długotrwała operacja,
użytkownik musi czekać, dopóki się ona nie zakończy. Mówi się, że tego typu program
oparty jest na wyciąganiu danych (ang. pull-based). A przecież i aplikacja, i tym bar-
dziej użytkownik mogą wykonać w tym czasie wiele innych, pożytecznych zadań.
Rysunek 14.1.
Różnica pomiędzy
interaktywnym
a reaktywnym
pobieraniem danych
Rx, korzystając z tej idei, opiera się na dwóch interfejsach wprowadzonych do pod-
stawowej biblioteki klas (ang. BCL — Base Class Library) na platformie .NET w wersji
4.0; są to IObservable<T> oraz IObserver<T>. Reactive Extensions zawdzięcza pierwszy
człon swej nazwy modelowi programowania, który implementuje, czyli modelowi re-
aktywnemu, natomiast drugi człon nazwy wynika z tego, że Rx nie posiada w swoich
bibliotekach typów IObservable<T> oraz IObserver<T>, lecz rozszerza je o funkcjo-
nalności tworzenia zapytań przy wykorzystaniu LINQ1. Przyjrzyjmy się im bliżej.
IObservable<T>
Interfejs IObservable<T> jest pierwszym z dwóch głównych typów Rx. Można rozu-
mieć go jako sekwencję zdarzeń o typie T. Jeśli zatem zdarzeniem nas interesującym
jest przyciśnięcie lewego klawisza Alt, wówczas za każdym razem, gdy naciśnięty zo-
stanie ten klawisz, w przechowywanej sekwencji „pojawi się” kolejny element. Defi-
nicja interfejsu IObservable<T> jest przedstawiona na listingu 14.1.
IObserver<T>
Aby dokonać subskrypcji, należy posłużyć się drugim typem podstawowym, na którym
opiera się Rx, a mianowicie IObserver<T>. Interfejs IObserver<T> jest typem nasłuchują-
cym zmian w sekwencji IObservable<T>. Co zatem może usłyszeć IObserver<T>?
Również i jego implementacja jest prosta, co widać na listingu 14.2.
1
Ciekawe jest, że oba interfejsy zostały wprowadzone do BCL bez jakichkolwiek klas,
które implementowałyby je.
304 Programowanie równoległe i asynchroniczne w C# 5.0
Dualizm interaktywno-reaktywny
Wróćmy na chwilę do dyskusji na temat modeli interaktywnego oraz reaktywnego. Od-
powiednikiem pierwszego na platformie .NET jest para IEnumerable<T> i IEnumerator<T>.
Ich implementacja znajduje się na listingu 14.3.
Wyobraźmy sobie dwa obiekty, które współpracują ze sobą, np. telewizor oraz pilot
do telewizora. Aby uprościć przykład, zajmijmy się sytuacją, gdzie na pilocie są dwa
przyciski, Włącz oraz Wyłącz, służące do włączania i wyłączania telewizora. Można
powiedzieć, że telewizor „obserwuje”, czy na pilocie został naciśnięty przycisk Włącz
albo Wyłącz i reaguje na te zdarzenia, odpowiednio ustawiając swój stan. W termi-
nologii programistycznej telewizor nazwany byłby obserwatorem, natomiast pilot ob-
serwablą3. Przejście do opisu abstrakcyjnego pozwala na wykorzystanie tego wzorca
nie tylko do opisu układu telewizora i pilota czy np. żarówki i kontaktu, ale także do
kompletnie innych zagadnień.
2
Wzorce projektowe. Elementy oprogramowania obiektowego wielokrotnego użytku, E. Gamma, R. Helm,
R. Johnson, J. M. Vlissides, Helion, Gliwice 2010.
3
W klasycznym wzorcu istnieją pojęcia obserwatora oraz przedmiotu, jednak tutaj posłużymy się
pojęciami, jakie zostały przyjęte w implementacji Rx wzorca obserwatora.
306 Programowanie równoległe i asynchroniczne w C# 5.0
Platforma Rx
W trakcie ewolucji Rx ukształtowała się naturalna struktura rozwiązania, wynikająca
z podziału zadań poszczególnych komponentów. Struktura ta, zaprezentowana na ry-
sunku 14.2 i w tabeli 14.1, podzielona jest na trzy warstwy: warstwę LINQ do zdarzeń
(ang. LINQ to Events), warstwę sekwencji zdarzeń i warstwę zarządzania współ-
bieżnością.
Rysunek 14.2.
Architektura
Reactive Extensions
Rozdział 14. Wprowadzenie do Reactive Extensions 307
Biblioteki Rx
Od wersji 2.1 aktualna wersja Rx jest dystrybuowana wyłącznie przy użyciu pakie-
tów NuGet. Dlatego, choć nie tylko, warto zapoznać się z tym narzędziem i przy-
zwyczaić do niego. Jego opis znajduje się w dodatku C.
Rysunek 14.3. Po wpisaniu w menedżerze pakietów NuGet frazy reactive pojawiają się pakiety Rx-,
wśród których interesuje nas Rx-Main
Tabela 14.2. Lista (wraz z opisem) pakietów NuGet, od których zależy pakiet Rx-Main
Nazwa pakietu Instalowane biblioteki Opis
Rx-Core System.Reactive.Core.dll Zawiera podstawowe struktury zarządzające
współbieżnością oraz typy, na których
mechanizm działania Rx jest oparty.
Rx-Interfaces System.Reactive.Interfaces.dll Tu znajdują się dodatkowe interfejsy wymagane
do poprawnego działania silnika Rx, przeładowania
interfejsów IObservable<T> oraz IObserver<T>,
a także podstawowe klasy dla typu Scheduler —
wymagane do zarządzania asynchronicznością.
Rx-Linq System.Reactive.Linq.dll Posiada klasę statyczną Observable, służącą jako
podstawa do tworzenia zapytań w świecie Rx.
Rx-Platform System.Reactive.Platform Służy do optymalizacji typów IScheduler dla
Services Services.dll konkretnych platform, takich jak .NET 4.5,
WinRT, Silverlight, Windows Phone.
witaj.Subscribe(Console.WriteLine);
Rysunek 14.4.
Wynik uruchomienia
pierwszego programu
wykorzystującego
Reactive Extensions
Rozdział 14. Wprowadzenie do Reactive Extensions 309
Gramatyka Rx
Omówiliśmy wstępnie podstawowe struktury Rx: interfejsy IObservable<T> oraz
IObserver<T>. Aby faktycznie rozpocząć pracę z Rx, należy poznać możliwości komuni-
kacji z interfejsami podstawowymi oraz zrozumieć, co można osiągnąć z wykorzysta-
niem LINQ w programowaniu zdarzeń — innymi słowy, należy poznać gramatykę Reac-
tive Extensions.
Jako alternatywę Rx proponuje szereg metod tworzących (ang. factory method). Metody
te, oprócz tego, że pozwalają utrzymać wszelkie optymalizacje jakościowe, są także
bardzo łatwe w użyciu, co powoduje, iż uchwycenie idei Rx staje się o wiele prostsze.
Omawiając metody tworzące, zajmiemy się przypadkiem ciągu dziesięciu liczb na-
turalnych od 0 do 9. Aby przetestować metody tworzące, utworzymy projekt kon-
solowy, w którym zainstalujemy pakiet NuGet Rx-Main.
Observable.Range
W metodzie Main zdefiniujemy sekwencję sekwRange, tak jak na listingu 14.5. Metoda
ta tworzy definicję sekwencji. Aby zobaczyć jej rezultat, dokonajmy subskrypcji. Po
skompilowaniu, zgodnie z oczekiwaniami, na ekranie pojawi się sekwencja dziesięciu
liczb (każda w osobnym wierszu), zgodnie z rysunkiem 14.5.
Rysunek 14.5.
Wynik subskrypcji
sekwencji dziesięciu
kolejnych liczb
całkowitych od 0 do 9
Listing 14.5. Definicja sekwencji przy użyciu metody tworzącej Range wraz z subskrypcją
class Program
{
static void Main(string[] args)
{
IObservable<int> sekwRange = Observable.Range(0, 10);
sekwRange.Subscribe(
onNext: (element) =>
310 Programowanie równoległe i asynchroniczne w C# 5.0
{
Console.WriteLine(element);
});
Observable.Generate
Dodajmy kolejną sekwencję. Tym razem użyjemy metody Observable.Generate, jak
to przedstawiamy na listingu 14.6. Zgodnie z nazwą, Generate generuje sekwencję z wy-
korzystaniem stanu, iteracji oraz warunku iteracji. Zatem można interpretować ten przy-
padek jako pętlę for, która prowadzi do utworzenia sekwencji. Dokonujemy subskrypcji,
podobnie jak wcześniej, co widać na tym samym listingu.
Listing 14.6. Definicja sekwencji przy użyciu metody tworzącej Generate oraz subskrypcja
do zdefiniowanej sekwencji
class Program
{
static void Main(string[] args)
{
IObservable<int> sekwGenerate = Observable.Generate<int, int>(
initialState: 0,
condition: i => i < 10,
iterate: i => i + 1,
resultSelector: i => i);
sekwGenerate.Subscribe(
onNext: (element) =>
{
Console.WriteLine(element);
});
Observable.Create
Kolejna metoda służąca do tworzenia sekwencji to metoda Create. Utworzymy zatem
jeszcze jedną obserwablę — sekwCreate (listing 14.7). Metoda tworząca nie jest już
tak oczywista jak poprzednie. Observable.Create buduje nową sekwencję poprzez
określenie, w jaki sposób ewentualny obserwator będzie informowany o kolejnych zda-
rzeniach, czyli w tym przypadku o pojawiających się elementach ciągu liczb naturalnych.
Wpierw tworzona jest zmienna całkowita i o wartości początkowej równej zeru. Do
Rozdział 14. Wprowadzenie do Reactive Extensions 311
Listing 14.7. Definicja sekwencji przy użyciu metody tworzącej Create wraz z subskrypcją
class Program
{
static void Main(string[] args)
{
IObservable<int> sekwCreate = Observable.Create<int>(
subscribe: obserwator =>
{
int i = 0;
while (i < 10)
{
obserwator.OnNext(i);
i++;
}
obserwator.OnCompleted();
return () => { };
});
sekwCreate.Subscribe(
onNext: (element) =>
{
Console.WriteLine(element);
});
Listing 14.8. Uwzględnienie możliwych błędów przy tworzeniu obserwabli z listingu 14.7
class Program
{
static void Main(string[] args)
{
IObservable<int> sekwCreate = Observable.Create<int>(
subscribe: obserwator =>
{
try
{
int i = 0;
312 Programowanie równoległe i asynchroniczne w C# 5.0
obserwator.OnCompleted();
}
catch (Exception error)
{
obserwator.OnError(error);
}
return () => { };
});
sekwCreate.Subscribe(
onNext: (element) =>
{
Console.WriteLine(element);
});
Tym razem, jeśli nawet pojawi się błąd, będzie przechwycony, a obserwator zostanie
o nim powiadomiony poprzez wywołanie metody OnError.
Subskrypcje
Subskrypcja jest jedynym sposobem komunikacji obserwatorów z obserwablami. Warto
więc wiedzieć, w jaki sposób można tę komunikację nawiązać. Interfejs IObservable<T>
(listing 14.1.) wskazuje, że jedyną możliwością subskrypcji jest utworzenie klasy
implementującej interfejs IObserver<T> oraz podanie jej jako parametru w metodzie
Subscribe. Natomiast w poprzednich przykładach zupełnie nie korzystaliśmy z IObse-
rver<T>. Czy na pewno? Otóż w Rx utworzone są przeładowania do metody Subscribe,
które — korzystając ze zdefiniowanych metod — „pod spodem” implementują ten
interfejs.
Ten zapis należy odczytać następująco: „Dla każdego elementu, który pojawi się
w sekwencji, wyświetl na ekranie «Wywołano metodę OnNext(wartość aktualnego
elementu)»”. Istnieje też przeciążona wersja metody Subscribe, która przyjmuje zarówno
akcje, które należy wykonać w momencie przyjścia nowego elementu lub zdarzenia,
jak i w momencie zakończenia działania sekwencji (listing 14.9). Jest również wersja
przyjmująca oprócz dwóch wymienionych także akcję wykonywaną w razie wystąpienia
błędu. Oczywiście, w przypadku z listingu 14.9 metoda OnError nie zostanie wywoła-
na, dlatego zmodyfikujmy samą sekwencję, zgodnie ze wzorem na listingu 14.10.
W przykładzie tym zamiast ostatniego elementu ciągu na ekranie pojawi się metoda
wskazująca błąd dzielenia, co widać na rysunku 14.6. Co ważne, nie zostanie wywołana
metoda OnCompleted.
obserwator.OnCompleted();
}
catch (Exception error)
{
obserwator.OnError(error);
}
return () => { };
});
sekwCreate.Subscribe(
onNext: (element) =>
{
Console.WriteLine(element);
},
onCompleted: () =>
{
Console.WriteLine("Koniec przetwarzania");
});
Console.WriteLine("Naciśnij ENTER, aby zakończyć...");
Console.ReadLine();
}
}
314 Programowanie równoległe i asynchroniczne w C# 5.0
Listing 14.10. Subskrypcja wykorzystująca metody OnNext, OnCompleted i OnError oraz redefinicja
obserwabli tak, że zakończy się niepowodzeniem w związku z dzieleniem przez zero
class Program
{
static void Main(string[] args)
{
IObservable<int> sekwCreate = Observable.Create<int>(
subscribe: obserwator =>
{
try
{
int i = -10;
Rysunek 14.6.
Wynik działania sekwencji,
która zakończy się
niepowodzeniem w wyniku
dzielenia przez zero
Rozdział 14. Wprowadzenie do Reactive Extensions 315
LINQ do zdarzeń
Interfejsy IObservable<T> oraz IObserver<T> są fundamentami technologii Rx. Jednak
siłą, która stanowi o tej platformie, są zapytania LINQ, za pomocą których można
przeszukiwać, łączyć, filtrować dane sekwencje, a wreszcie subskrybować powiada-
mianie o przechowywanych w nich zdarzeniach. Do opisu sekwencji zdarzeń opartej
na interfejsie IObservable<T>, do której można dokonać subskrypcji przy użyciu in-
terfejsu IObserver<T>, utworzono notację graficzną, zdecydowanie ułatwiającą zro-
zumienie, jak działają operatory LINQ. Nosi ona nazwę diagramów koralikowych (ang.
marble diagrams).
Diagramy koralikowe
Spójrzmy raz jeszcze na wygląd interfejsu IObserver<T>. Posiada on trzy metody
wymienione w tabeli 14.3.
Tabela 14.3. Opis wykonania metod interfejsu IObserver<T>, gdy jest on obserwatorem sekwencji
zdarzeń IObservable<T>
Metoda Opis
OnNext Wykonywana, gdy tylko w sekwencji pojawi się kolejny element. Funkcja ta może być
wykonana dowolną ilość razy, tzn., że w specjalnym przypadku może nie zostać
wykonana ani razu.
OnCompleted Wykonana wówczas, gdy sekwencja osiągnie ostatni element. W przypadku sekwencji
nieskończonych może to nigdy nie nastąpić. Natomiast istotne tutaj jest to, że
OnCompleted zostanie wywołana nie więcej niż jeden raz. Zależy to od tego, czy
sekwencja się nie kończy, bądź od tego, czy w trakcie wykonywania sekwencji
nie wystąpi wyjątek i zostanie wywołana metoda OnError.
OnError Zostanie do niej przekazany wyjątek, jeśli sekwencja nieoczekiwanie błędnie
zakończy swoje działanie.
Wniosek stąd wynikający jest bardzo ważny dla zrozumienia, jak można posługiwać
się sekwencjami zdarzeń: w trakcie życia sekwencji metoda OnNext obserwatora zo-
stanie wywołana dowolną ilość razy, dopóki sekwencja nie zakończy poprawnie swojego
działania, wywołując OnCompleted, albo dopóki sekwencja nie zakończy działania
niepoprawnie, wywołując OnError. Przedstawiamy to na rysunku 14.7.
Rysunek 14.7.
Sekwencja może posiadać
dowolną ilość elementów,
które będą obserwowane
do czasu pojawienia się
OnCompleted albo OnError
316 Programowanie równoległe i asynchroniczne w C# 5.0
Rysunek 14.8.
Diagramy dwóch
przykładowych sekwencji,
z których górna kończy
działanie, wywołując
metodę OnCompleted,
natomiast dolna w wyniku
błędu przetwarzania kończy
działanie, wywołując
metodę OnError
Podstawowe obserwable
Klasą, która będzie bardzo często nam towarzyszyć, jest wspomniana wcześniej sta-
tyczna klasa Observable, znajdująca się w przestrzeni nazw System.Reactive.Linq.
Zdefiniowane są w niej metody rozszerzające (ang. extension methods), operujące na
interfejsach IObservable<T>. Metody te są implementacją LINQ w Rx, czyli LINQ do
zdarzeń. Klasa Observable oferuje również cztery metody tworzące, które można
utożsamiać z podstawowymi elementami diagramów koralikowych (tabela 14.4).
Tabela 14.4. Opis wykonania metod interfejsu IObserver<T>, gdy jest on obserwatorem sekwencji
zdarzeń IObservable<T>
Metoda Element diagramu Opis
Observable.Never Sekwencja ta nie posiada żadnych elementów, nie osiąga
końca, ani nie kończy działania błędem. Zatem na diagramie
koralikowym będzie zaznaczona tak samo jak linia życia.
Można utożsamić ją z pojęciem upływu czasu.
Observable.Return Sekwencja posiada tylko jeden element, po czym kończy.
Element ten jest parametrem metody Return.
Observable.Empty Sekwencja nie posiada żadnych elementów i od razu po
wywołaniu osiąga koniec.
Observable.Throw I ta sekwencja nie posiada elementów, lecz kończy swoje
działanie wyjątkiem, w odróżnieniu od Observable.Empty
<TResult>. Wyjątek ten jest podawany jako parametr metody.
Obserwable czasu
Rx wśród metod tworzących zawiera także i takie, które generują sekwencje zdarzeń
oparte na czasie.
Rozdział 14. Wprowadzenie do Reactive Extensions 317
Observable.Interval
Metoda Observable.Interval(TimeSpan period) tworzy nieskończoną sekwencję, która
generuje liczbę typu long co określony przedział czasu — podany w argumencie inter-
wał. Aby się o tym przekonać, utworzymy nowy projekt i dołączymy pakiet Rx-Main.
W metodzie Main tworzymy sekwencję interval, która będzie generowała kolejne
liczby pojawiające się co 2 sekundy (listing 14.11). Aby to udowodnić, dokonamy sub-
skrypcji, wzorując się na listingu 14.11. Po skompilowaniu i uruchomieniu programu
widać, że elementy pojawiają się w dłuższych odstępach czasu.
interval.Subscribe(
onNext: (element) =>
{
Console.WriteLine("Element: {0}", element);
},
onCompleted: () =>
{
Console.WriteLine("Sekwencja zakończyła działanie.");
});
Aby przekonać się, czy faktycznie są to dwie sekundy, wypiszemy na ekranie aktualny
czas w UTC, a następnie każdy z elementów opiszemy czasem nadejścia (listing 14.12).
Po ponownym skompilowaniu i uruchomieniu widać, że każdy, nawet pierwszy element
jest przekazany z różnicą dwóch sekund.
Listing 14.12. Definicja obserwabli interval oraz subskrypcji, w której podawany jest aktualny czas
przyjścia w UTC
class Program
{
static void Main(string[] args)
{
IObservable<long> interval = Observable.Interval(TimeSpan.FromSeconds(2));
},
onCompleted: () =>
{
Console.WriteLine("Sekwencja zakończyła działanie.");
});
Listing 14.13. Dodanie do sekwencji interval atrybutu Timestamp poprzez wywołanie metody
Timestamp oraz uwzględnienie zmian w subskrypcji
class Program
{
static void Main(string[] args)
{
IObservable<Timestamped<long>> interval =
Observable.Interval(TimeSpan.FromSeconds(2))
.Timestamp();
Jeśli raz jeszcze skompilujemy i uruchomimy program, okaże się, że faktycznie wynik
otrzymany jest taki sam jak wynik z listingu 14.12.
Rozdział 14. Wprowadzenie do Reactive Extensions 319
Observable.Timer
Observable.Timer jest wielokrotnie przeciążoną metodą tworzącą. Kilka z jej sygnatur to:
public static IObservable<long> Timer(DateTimeOffset dueTime);
public static IObservable<long> Timer(TimeSpan dueTime);
public static IObservable<long> Timer(DateTimeOffset dueTime,
TimeSpan period);
public static IObservable<long> Timer(TimeSpan dueTime, TimeSpan period);
Widać, że czas rozpoczęcia dueTime5 można podać, posługując się dwoma typami.
Można albo określić globalny czas rozpoczęcia, podając typ DateTimeOffset, albo
z wykorzystaniem TimeSpan określić czas od rozpoczęcia subskrypcji. Czas rozpoczęcia
jest momentem, w którym zostanie podany pierwszy element. Jeśli zastosujemy
metody wykorzystujące tylko jeden parametr, sekwencja będzie posiadała tylko je-
den element, który pojawi się w wyznaczonym czasie, po czym zakończy swoje dzia-
łanie. Gdy wybierzemy przeładowanie z dwoma parametrami, sekwencja będzie nie-
skończona, a drugi parametr wyznaczy okres pomiędzy pojawianiem się kolejnych
elementów sekwencji. Zatem drugi parametr działa analogicznie do parametru interwału
w metodzie Observable.Interval. Aby przekonać się o działaniu metody, utwórzmy
nowy projekt aplikacji konsolowej i dodajmy pakiet Rx-Main. W metodzie Main
umieścimy kod, zgodnie ze wzorem z listingu 14.14. Po skompilowaniu i urucho-
mieniu wydruk z programu powinien wyglądać bardzo podobnie do poprzedniego, z tym
że pierwszy element pojawi się po 3 sekundach, a każdy następny w odstępie dwóch
sekund.
Listing 14.14. Definicja obserwabli opartej o metodę wytwórczą Observable.Timer oraz subskrypcja
do tej obserwabli
class Program
{
static void Main(string[] args)
{
IObservable<Timestamped<long>> timer =
Observable.Timer(TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(2))
.Timestamp();
timer.Subscribe(
onNext: (element) =>
{
Console.WriteLine("Element: {0}; Przekazany: {1}",
element.Value, element.Timestamp);
},
onCompleted: () =>
{
Console.WriteLine("Sekwencja zakończyła działanie.");
});
5
Dokładniejszym terminem jest „czas dostarczenia”, choć bardziej obrazowym i intuicyjnym
określeniem jest właśnie czas rozpoczęcia.
320 Programowanie równoległe i asynchroniczne w C# 5.0
Modyfikowanie sekwencji
Praca z Rx polega na tworzeniu sekwencji zdarzeń, a następnie na manipulacji tymi
sekwencjami, a więc na filtrowaniu, tworzeniu określonych projekcji i budowaniu za-
pytań między nimi. Wachlarz służących do tego metod jest bardzo bogaty, dlatego w tym
rozdziale przedstawimy tylko pewien podzbiór, który pozwoli czytelnikom na zrozumienie
mechanizmu działania metod operujących na sekwencjach zdarzeń. Każdy przykład
opatrzony będzie także diagramem koralikowym obrazującym jego działanie.
Skip
Metoda Skip (rysunek 14.9) przyjmuje tylko jeden parametr — liczbę elementów,
które pominie przy przekazywaniu do subskrypcji. Aby przedstawić jej działanie,
utworzymy projekt konsolowy i dodamy pakiet Rx-Main. Następnie zbudujemy nową
sekwencję, umieszczając w metodzie Main kod wzorowany na listingu 14.15. Ten prosty
przykład w zupełności wystarczy, by zrozumieć ideę działania metody Skip. Sekwencja
liczb od 0 do 9 włącznie po przetworzeniu Skip(2) przekaże wartości, pomijając dwa
pierwsze elementy. W związku z tym na ekranie konsoli powinien pojawić się ciąg
liczb od 2 do 9.
Rysunek 14.9.
Diagram koralikowy
obrazujący działanie
operatora Skip
Listing 14.15. Kod programu z wykorzystaniem metody wytwórczej Range, w której w przekazywaniu
obserwatorom pomijane są dwa pierwsze elementy dzięki wykorzystaniu operatora Skip
class Program
{
static void Main(string[] args)
{
IObservable<int> sekwencja = Observable.Range(0, 10)
.Skip(2);
sekwencja.Subscribe(
onNext: (element) =>
{
Console.WriteLine("Element: {0}", element);
},
onCompleted: () =>
{
Console.WriteLine("Zakończono przetwarzać sekwencję.");
Rozdział 14. Wprowadzenie do Reactive Extensions 321
});
Zip
Metoda Zip (rysunek 14.10) działa na zasadzie zamka błyskawicznego: łączy elementy
dwóch sekwencji, które pojawiają się w tej samej kolejności. Oznacza to, że pierwszy
element z pierwszej sekwencji połączony będzie z pierwszym elementem z drugiej
sekwencji, drugi z drugim, trzeci z trzecim itd. Metoda posiada też przeładowania dla
większej ilości sekwencji wejściowych, niemniej jednak idea działania jest taka sama.
Otrzymana sekwencja kończy się w momencie zakończenia pierwszej z sekwencji,
które łączono, bądź w momencie wystąpienia błędu w przetwarzaniu, co powoduje
wywołanie metody OnError. Aby zobrazować to przykładem utworzymy nowy projekt
konsolowy z dołączonym pakietem Rx-Main. W metodzie Main umieścimy polecenie
var sekw1 = Observable.Range(0, 10); tworzące sekwencję 10 kolejnych liczb, po-
cząwszy od zera (listing 14.16). Po uruchomieniu programu z powyższą instrukcją
wszystkie elementy pojawiały się niemal natychmiast. Korzystając z metody Zip ,
będziemy w stanie „zahamować” wyświetlanie elementów. Utworzymy drugą sekwencję,
korzystając z metody Observable.Interval, a następnie, aby połączyć obie sekwencje
w jedną, zastosujemy metodę Zip. Metodę Zip wywołujemy na rzecz pierwszej se-
kwencji, w przykładzie sekw1, a druga sekwencja jest jej pierwszym argumentem.
Drugim argumentem jest natomiast referencja do funkcji generującej nowy element
(w powyższym przykładzie użyto wyrażenia lambda). Utworzony w ten sposób element
będzie podawany subskrybentom sekwencji wynik (listing 14.16). Po pomyślnym
skompilowaniu i uruchomieniu przykładu na ekranie powinien pojawić się wynik, taki
jak na rysunku 14.11.
Rysunek 14.10.
Diagram koralikowy
obrazujący działanie
operatora Zip
Listing 14.16. Definicja sekwencji wynik wykorzystującej metodę Zip oraz subskrypcja
class Program
{
static void Main(string[] args)
{
322 Programowanie równoległe i asynchroniczne w C# 5.0
wynik.Subscribe(
onNext: (element) =>
{
Console.WriteLine("Elem1 = {0}; Elem2 = {1}",
element.Elem1, element.Elem2);
},
onCompleted: () =>
{
Console.WriteLine("Zakończono przetwarzać sekwencję.");
});
Rysunek 14.11.
Wynik operatora Zip
dla sekwencji liczb
całkowitych oraz interwału
Widać, że elementy pierwszy i drugi mają te same wartości. Dlatego dla przejrzystości
i wygody w dalszych przykładach zmodyfikujmy sekwencję wynik oraz subskrypcję
tak, by korzystały ze znacznika czasu przy użyciu metody Timestamp (listing 14.17). Tak
zmodyfikowany przykład powinien dawać po ponownej kompilacji i uruchomieniu
pożądany rezultat.
wynik.Subscribe(
onNext: (element) =>
{
Console.WriteLine("Element : {0}; Przekazany: {1}",
element.Value , element.Timestamp);
Rozdział 14. Wprowadzenie do Reactive Extensions 323
},
onCompleted: () =>
{
Console.WriteLine("Zakończono przetwarzać sekwencję.");
});
CombineLatest
Sposób użycia metody CombineLatest (rysunek 14.12) jest bardzo podobny do metody
Zip: łączy dwie lub więcej sekwencji zdarzeń w jedną. Różnica polega na tym, że
przy wykorzystaniu CombineLatest w momencie przyjścia nowego zdarzenia od któ-
rejkolwiek z sekwencji generowany jest kolejny element sekwencji wynikowej zbu-
dowany z ostatnich elementów sekwencji łączonych. Aby to pokazać, utworzymy
kolejny projekt konsolowy i dodamy pakiet Rx-Main. Następnie w metodzie Main
utworzymy sekwencję sekwencja, zgodnie z listingiem 14.18. Jak widać, do utworze-
nia sekwencji wykorzystaliśmy metodę tworzącą Observable.Range, dzięki której po-
wstanie sekwencja dziesięciu liczb naturalnych od 0 do 9. Następnie połączyliśmy ją
z sekwencją, w której zdarzenia rozdzielone są sekundowymi interwałami. W rezultacie
wartością zwracaną przez operator Zip jest sekwencja z wartościami sekwencji Range,
lecz pojawiającymi się z interwałem równym jednej sekundzie. Powoduje to, że
otrzymujemy skończoną sekwencję dziesięciu elementów. My jednak chcemy sko-
rzystać z operatora CombineLatest, zatem połączymy sekwencję ze sobą poleceniem,
tak jak na listingu 14.18, by sekwencja wynik była kombinacją ostatnich elementów
sekwencji sekwencja oraz jeszcze jednej sekwencji, utworzonej przy wykorzystaniu
operatora Skip(1). Znaczy to, że w pierwszej pojawią się liczby od 0 do 9, natomiast
w drugiej od 1 do 9. Pierwsza sekwencja rozpocznie podawanie wyników po sekun-
dzie, natomiast druga po dwóch. W rezultacie otrzymamy obiekt z własnościami Lewy
oraz Prawy, gdzie będą przetrzymywane wartości elementów sekwencji sekwencja
oraz sekwencja.Skip(1). Aby to sprawdzić, wykonajmy subskrypcję zgodnie z listingiem
14.18. Po udanej kompilacji i uruchomieniu programu powinniśmy otrzymać rezultat,
taki jak na rysunku 14.13.
Rysunek 14.12.
Diagram koralikowy
demonstrujący
działanie operatora
CombineLatest
324 Programowanie równoległe i asynchroniczne w C# 5.0
.Zip(Observable.Interval(TimeSpan.FromSeconds(1)),
(lewy, prawy) => lewy);
wynik.Subscribe(
onNext: (element) =>
{
Console.WriteLine("Para ({0},{1})", element.Lewy, element.Prawy);
},
onCompleted: () =>
{
Console.WriteLine("Zakończono przetwarzać sekwencję.");
});
Rysunek 14.13.
Wynik działania operatora
CombineLatest na sekwencji
wraz z nią samą, ale
„przesuniętą” za pomocą
operatora Skip(1)
Buffer
Metoda Buffer (rysunek 14.14) posiada kilka przeciążonych wersji:
public static IObservable<IList<TSource>> Buffer<TSource>(
this IObservable<TSource> source, int count);
public static IObservable<IList<TSource>> Buffer<TSource>(
this IObservable<TSource> source, TimeSpan timeSpan);
public static IObservable<IList<TSource>> Buffer<TSource>(
this IObservable<TSource> source, int count, int skip);
public static IObservable<IList<TSource>> Buffer<TSource>(
this IObservable<TSource> source, TimeSpan timeSpan, int count);
Rozdział 14. Wprowadzenie do Reactive Extensions 325
Rysunek 14.14.
Prezentacja działania
operatora Buffer
Pierwsza jej wersja pobiera jako parametr ilość elementów, którą może pomieścić bu-
for (parametr count). Gdy w buforze pojawi się określona liczba elementów albo gdy
sekwencja się zakończy, bufor zostanie przekazany jako element sekwencji wyniko-
wej. Druga wersja, również z jednym parametrem, określa czas, po jakim bufor zo-
stanie przekazany w elemencie sekwencji wynikowej. W przeładowaniu, gdzie można
podać dwa parametry, czyli ilość elementów w buforze oraz ilość elementów, po któ-
rej pojawi się nowy bufor (parametr skip), metoda będzie tworzyła nowy bufor nie po
osiągnięciu wartości count, a skip. Ostatnie przeładowanie generuje bufor zależny od
czasu bądź ilości elementów, co pierwsze zostanie wysycone. Aby sprawdzić działa-
nie metody, utworzymy nowy projekt konsolowy oraz dodamy do niego pakiet Rx-
Main. W metodzie Main powstałego programu zbudujemy sekwencję sekwencja, zgodnie
z listingiem 14.19. Analogicznie do poprzedniego przykładu tworzymy sekwencję
liczb od 0 do 9 pojawiających się w odstępach jednosekundowych. Tym razem jednak
dodamy znacznik czasu oraz na końcu opracujemy sekwencję buforowaną dla trzech
elementów. Aby trochę wygodniej było analizować wydruk pojawiający się na konsoli,
rozbudujemy subskrypcję zgodnie z listingiem 14.19.
Listing 14.19. Kod programu pokazujący działanie sekwencji opartej na buforze. Wykorzystanie znaku
tabulatora pozwoli na bardziej czytelne przedstawienie wyniku na ekranie
class Program
{
static void Main(string[] args)
{
var sekwencja = Observable.Range(0, 10)
.Zip(Observable.Interval(TimeSpan.FromSeconds(1)),
(lewy, prawy) => lewy)
.Timestamp()
.Buffer(3);
int numerBufora = 0;
sekwencja.Subscribe(
onNext: bufor =>
{
int numerTegoBufora = numerBufora++;
Console.WriteLine("{0}Bufor {1}",
new string('\t', numerTegoBufora),
numerTegoBufora);
Console.WriteLine("{0}{1}({2})",
new string('\t', numerTegoBufora),
element.Value,
element.Timestamp);
}
},
onCompleted: () =>
{
Console.WriteLine("Zakończono przetwarzać sekwencję.");
});
Rysunek 14.15.
Rezultat działania
operatora Buffer
z przykładu
Aby zrozumieć, jak działają inne wersje metody Buffer, warto zmodyfikować listing
14.19 i sprawdzić, jak wówczas będzie wyglądał wydruk.
Window
Metoda Window (rysunek 14.16) jest bardzo podobna do wcześniej omawianej metody
Buffer. Różni je to, że Window nie tworzy obiektów implementujących interfejs IList,
a nowe sekwencje obserwowalne. Spójrzmy na niektóre z przeładowań operatora Window:
public static IObservable<IObservable<TSource>> Window<TSource>(
this IObservable<TSource> source, int count);
public static IObservable<IObservable<TSource>> Window<TSource>(
this IObservable<TSource> source, TimeSpan timeSpan);
public static IObservable<IObservable<TSource>> Window<TSource>(
Rozdział 14. Wprowadzenie do Reactive Extensions 327
Rysunek 14.16.
Prezentacja działania
operatora Window
Rzeczywiście, powyższe wersje metody mają takie same parametry wejściowe jak
metoda Buffer. Różni je jednak zwracany typ, którym teraz jest sekwencja obserwo-
walna IObservable. Aby przekonać się, jak faktycznie działa operator Window, utwo-
rzymy nowy projekt konsolowy i dodamy do niego pakiet NuGet Rx-Main. Następnie
w metodzie Main programu zbudujemy nową sekwencję obserwowalną sekwencja (li-
sting 14.20). Analogicznie do poprzednich przykładów tworzymy dziesięcioele-
mentową listę wartości od 0 do 9, pojawiających się w sekundowych odstępach, opa-
trzoną znacznikami czasowymi. Tym razem jednak korzystamy z operatora Window,
by utworzyć trzyelementowe okna oraz — dzięki wykorzystaniu drugiego parametru —
określić tworzenie nowych okien po osiągnięciu 2. elementu w poprzednim oknie. Aby zo-
brazować ten przypadek, dokonamy subskrypcji, tak jak pokazane jest to na listingu
14.20. Po skompilowaniu projektu i uruchomieniu powinien pojawić się wynik po-
dobny do tego z rysunku 14.17.
Listing 14.20. Subskrypcja do obserwabli sekwencja. Wykorzystanie znaku tabulatora pozwoli na bardziej
czytelne przedstawienie wizualne wyników subskrypcji
class Program
{
static void Main(string[] args)
{
var sekwencja = Observable.Range(0, 10)
.Zip(Observable.Interval(TimeSpan.FromSeconds(1)),
(lewy, prawy) => lewy)
.Timestamp()
.Window(3, 2);
int numerOkna = 0;
328 Programowanie równoległe i asynchroniczne w C# 5.0
sekwencja.Subscribe(
onNext: (okno) =>
{
int numerTegoOkna = numerOkna++;
Console.WriteLine("{0}Okno {1}",
new string('\t', numerTegoOkna),
numerTegoOkna);
okno.Subscribe(
onNext: (element) =>
{
Console.WriteLine("{0}{1}({2})",
new string('\t', numerTegoOkna),
element.Value,
element.Timestamp.Second);
});
},
onCompleted: () =>
{
Console.WriteLine("Zakończono przetwarzać sekwencję.");
});
Console.WriteLine("Naciśnij ENTER, aby zakończyć...");
Console.ReadLine();
}
}
Rysunek 14.17.
Wynik działania operatora
Window(3,2) z przykładu
Rx wyposażono w wiele więcej operatorów, jednak nie będziemy ich tutaj szczegó-
łowo opisywać. Celem tej części było jedynie zaznajomienie czytelników z podsta-
wowymi operatorami oraz pokazanie, w jaki sposób można je opisywać za pomocą
diagramów koralikowych, które bardzo ułatwiają zrozumienie działania operatorów.
Oprócz Rx istnieje jeszcze bardzo przydatny projekt, który rozszerza jego funkcjo-
nalność o operatory niezaimplementowane w samym Rx. Projekt ten nazywa się
„Rozszerzenia rozszerzeń reaktywnych” (ang. Extensions for Reactive Extensions,
w skrócie Rxx). Warto odwiedzić stronę tego projektu, gdyż może są tam zaimple-
mentowane operatory, których będzie brakowało w Rx.
Rozdział 14. Wprowadzenie do Reactive Extensions 329
Listing 14.21. Dwie subskrypcje do tej samej obserwabli z wyraźną — czterosekundową — różnicą
w czasie podłączenia
class Program
{
static void Main(string[] args)
{
var sekwencja = Observable.Range(0, 10)
.Zip(Observable.Interval(TimeSpan.FromSeconds(1)),
(lewy, prawy) => lewy);
sekwencja.Subscribe(
onNext: (element) =>
{
Console.WriteLine("Subskrypcja pierwsza otrzymała element {0}",
element);
});
Thread.Sleep(4000);
sekwencja.Subscribe(
onNext: (element) =>
{
Console.WriteLine("Subskrypcja druga otrzymała element {0}",
element);
});
Rysunek 14.18.
Wynik działania zimnej
obserwabli z przykładu
330 Programowanie równoległe i asynchroniczne w C# 5.0
Okazuje się, że mimo iż dwie subskrypcje były przypisane do tej samej sekwencji, to
zarówno pierwsza (co wydaje się zrozumiałe), jak i druga (co powinno być zaskaku-
jące) otrzymują elementy od wartości 0. Dlaczego tak się dzieje? Otóż mechanizm Rx
działa tak, że jeśli zdefiniujemy sekwencję zdarzeń wewnątrz programu, niezależnie
od zewnętrznych czynników, będzie ona ponownie odtwarzana przy każdym podłą-
czeniu subskrypcji. Znaczy to tyle, że każdy obserwator będzie widział oddzielne wy-
konanie konkretnej subskrypcji. Tego typu obserwable nazywa się zimnymi obserwa-
blami (ang. cold observables).
.Zip(Observable.Interval(TimeSpan.FromSeconds(1)),
(lewy, prawy) => lewy)
.Publish();
sekwencja.Subscribe(
onNext: (element) =>
{
Console.WriteLine("Subskrypcja pierwsza otrzymała element {0}",
element);
});
Thread.Sleep(4000);
sekwencja.Subscribe(
onNext: (element) =>
{
Console.WriteLine("Subskrypcja druga otrzymała element {0}",
element);
});
Thread.Sleep(3000);
lacznik.Dispose();
Zarządzanie równoległością
W poprzednim rozdziale opisaliśmy korzystanie z interfejsów IObservable<T> oraz
IObserver<T> oraz wiele ich metod rozszerzających. Jak pamiętamy ze wstępu do po-
przedniego rozdziału, Rx daje możliwość zarządzania współbieżnością w sposób pa-
rametryczny, lecz jeszcze ani razu nie skorzystaliśmy z parametrów tego typu.
Interfejs IScheduler
Interfejs IScheduler (listing 15.1) w Rx umożliwia, jak wskazuje nazwa, planowanie
zadań. Posiada wielokrotnie przeciążoną metodę Schedule służącą do dodawania zadań
oraz własność tylko do odczytu Now zwracającą aktualny czas.
Jak widać w deklaracji interfejsu IScheduler (listing 15.1), metoda Schedule jest trzy-
krotnie przeciążona. Jej pierwsza wersja nie posiada żadnego odniesienia do czasu,
druga — przyjmuje parametr typu DateTimeOffset, trzecia natomiast — parametr typu
TimeSpan. Jeżeli zatem zadanie ma być wykonane jak najszybciej, korzystamy z pierw-
szej wersji. Gdy chcemy wskazać absolutny moment uruchomienia — stosujemy me-
todę z parametrem typu DateTimeOffset, kiedy natomiast chcemy ustalić opóźnienie
uruchomienia — używamy wersji z argumentem typu TimeSpan. Wówczas wykorzy-
stywana jest własność Now.
czas na komputerze może zmieniać się skokowo, a wtedy w sekwencji mogą zostać
pominięte niektóre zdarzenia bądź nagle pojawi się ich bardzo dużo. Skoki takie mogą
wynikać z synchronizacji zegara z serwerem czasu czy choćby z uśpienia systemu.
Planiści
W Reactive Extensions planistą nazywana jest klasa, która implementuje interfejs
IScheduler. Rx oferuje zbiór gotowych planistów opartych o wspomniane wcześniej
metody asynchronicznego uruchamiania kodu, a więc korzystających z wątków, zadań
itd. Planiści zdefiniowani są w przestrzeni nazw System.Reactive.Concurrency. Pod-
stawowych wymieniamy w tabeli 15.1. Jak widać, nie wszyscy planiści oferują możli-
wość wykorzystania współbieżności, co nie zawsze jest wskazane.
Tabela 15.1. Podstawowa lista planistów wykorzystywanych w Reactive Extensions — ciąg dalszy
Planista Opis
TaskPoolScheduler Każde nowe zadanie zostanie wykonane z wykorzystaniem nowej instancji
zadania TPL z puli zadań (klasa TaskPool). TaskPoolScheduler posiada
własność Default służącą do korzystania z zadań TPL przy użyciu domyślnej
fabryki abstrakcyjnej zadań TPL (klasa TaskFactory). Z racji tego, że w platformie
.NET można tworzyć własne fabryki zadań, i tutaj można budować instancje.
ThreadPoolScheduler Każde nowe zadanie zostanie zrealizowane za pomocą wątku uzyskanego
z puli wątków (klasa ThreadPool). Klasa posiada statyczną własność Instance.
if (i <= 3)
{
Console.WriteLine("{0}Przed {1}.Schedule(akcja), Wątek: {2}",
new string('\t', wewnI),
nazwa,
Thread.CurrentThread.ManagedThreadId);
planista.Schedule(akcja);
Console.WriteLine("{0}Po {1}.Schedule(akcja), Wątek: {2}",
new string('\t', wewnI),
nazwa,
Thread.CurrentThread.ManagedThreadId);
}
};
planista.Schedule(akcja);
}
TestujSchedule(planista.Key, planista.Value);
}
Wydruk widoczny na rysunku 15.1. pokazuje wynik testu dla obu planistów. Zgodnie
z wcześniejszym opisem, obaj planiści faktycznie korzystają z tego samego wątku,
czyli wątku programu o identyfikatorze równym 1. Jednak na rysunku widać też, że
wykonanie tego samego zadania dla obu planistów przebiegło inaczej. Głębokość wcięć,
która zależy od głębokości rekurencji, na jakiej dana akcja została zaplanowana, po-
kazuje, że ImmediateScheduler w momencie dojścia do każdego kolejnego wywołania
metody Schedule rozpoczyna wykonywanie od razu, nawet jeśli akcja jest zaplano-
wana przy użyciu metody Schedule w trakcie wykonywania innej akcji (w tym przy-
padku akcji, która ją wywołała). Dlatego mówi się, że ImmediateScheduler jest planistą
synchronicznym.
Rysunek 15.1.
Wynik porównania
dwóch planistów,
którzy wykorzystują
główny wątek
Listing 15.4. Lista planistów, dla których będzie przeprowadzony test metody Schedule
var planisci = new Dictionary<string, IScheduler>() {
{ "DefaultScheduler", DefaultScheduler.Instance },
{ "ImmediateScheduler", ImmediateScheduler.Instance },
{ "CurrentThreadScheduler", CurrentThreadScheduler.Instance },
{ "NewThreadScheduler", NewThreadScheduler.Default },
{ "ThreadPoolScheduler", ThreadPoolScheduler.Instance },
{ "TaskPoolScheduler", TaskPoolScheduler.Default },
{ "EventLoopScheduler", new EventLoopScheduler() }
Rysunek 15.2.
Test metod Schedule
dla planistów
nieoperujących
na głównym wątku
Rozdział 15. Współbieżność w Rx 339
Jak wynika z wydruku widocznego na rysunku 15.2, wszyscy planiści oprócz Immediate
Scheduler wykonują zaplanowane zadania sekwencyjnie w kolejności ich planowania.
Przetworzenie zdarzeń w sekwencjach w takiej kolejności, w jakiej są generowane,
jest podstawą Rx.
1
Sekwencyjność, o której tu mowa, jest założeniem projektowym Rx i dotyczy przetwarzania zdarzeń
w sekwencji w kolejności pojawiania się. Ich przetwarzanie może być natomiast współbieżne.
2
W nomenklaturze .NET mówi się, że aplikacje tego typu działają w modelu STA (ang. Single
Threaded Apartment).
340 Programowanie równoległe i asynchroniczne w C# 5.0
Listing 15.5. Definicja metody ObservableRange, która służyć będzie do testowania planistów
static IObservable<int> ObservableRange(int start, int count)
{
return Observable.Create<int>(observer =>
{
try
{
int i = start;
while (i < count)
{
Console.WriteLine("{0}Subskrypcja: OnNext({1}); Wątek: {2}",
new string('\t', 2), i, Environment.CurrentManagedThreadId);
observer.OnNext(i++);
}
return Disposable.Empty;
});
}
Listing 15.6. Zapis metody Main do testu planistów pod kątem subskrypcji oraz obserwacji
class Program
{
static void Main(string[] args)
{
var planisci = new Dictionary<string, IScheduler>() {
{ "DefaultScheduler", DefaultScheduler.Instance },
{ "ImmediateScheduler", ImmediateScheduler.Instance },
{ "CurrentThreadScheduler", CurrentThreadScheduler.Instance },
{ "NewThreadScheduler", NewThreadScheduler.Default },
{ "ThreadPoolScheduler", ThreadPoolScheduler.Instance },
{ "TaskPoolScheduler", TaskPoolScheduler.Default },
{ "EventLoopScheduler", new EventLoopScheduler() }
};
Rysunek 15.3.
Wynik testu planistów dla
planisty ImmediateScheduler.
Widać, że faktycznie
wątek główny programu
wykorzystywany jest
odpowiednio do obserwacji
oraz do subskrypcji
Rysunek 15.4.
Wynik testu planistów dla
planisty NewThreadScheduler.
W pierwszym przypadku tylko
obserwacja zachodzi na wątku
innym niż wątek programu,
natomiast w przypadku
drugim, gdy wykorzystana
jest tylko metoda SubscribeOn,
obserwacja zachodzi na tym
samym wątku, co subskrypcja
Rysunek 15.5.
Przedstawienie działania
przykładowego programu,
w którym na głównym wątku
odbywa się subskrypcja
oraz obserwacja sekwencji
Rysunek 15.6.
Przedstawienie działania
przykładowego programu,
w którym została wykorzystana
metoda SubscribeOn, dzięki
czemu subskrypcja sekwencji
odbywa się w oddzielnym wątku
Rysunek 15.7.
Przedstawienie działania
przykładowego programu,
w którym wykorzystane
są metody SubscribeOn
oraz ObserveOn, zatem
do każdego zadania
przydzielony
jest oddzielny wątek
Rozdział 15. Współbieżność w Rx 343
Słowo o unifikacji
Wcześniej bardzo ogólnie stwierdziliśmy, że ThreadPoolScheduler wykorzystuje
ThreadPool do planowania zadań. I choć nazwa wydaje się znajoma, to jednak, gdy spoj-
rzymy na platformy, na których Rx został zaimplementowany, sprawa nie jest taka
prosta. Klasa ThreadPool, wykorzystywana w .NET 4.5, WinRT, Silverlight oraz Win-
dows Phone 7 i 8, odnosi się do tej samej funkcjonalności, ale we wszystkich wymie-
nionych implementacjach jest zupełnie inna (dodatek A). Dlatego wśród pakietów Rx
pojawia się Rx-PlatformServices.
1
Rx-WPF nie posiada żadnych dodatkowych bibliotek i jest tylko wskaźnikiem Rx-Xaml. Podobnie
jest w przypadku pakietu Rx-Silverlight, który również jest tylko wskaźnikiem na Rx-Xaml.
Rozróżnienie to może być spowodowane przyszłymi potencjalnymi różnicami funkcjonalnymi
między technologiami bazującymi na XAML.
346 Programowanie równoległe i asynchroniczne w C# 5.0
Rysowanie z użyciem Rx
Zastanówmy się nad stwierdzeniem, że kursor myszy jest bazą danych punktów2.
2
Porównanie takie zasugerował Bart de Smet, jeden z twórców Rx.
Rozdział 16. Przykłady użycia technologii Rx w aplikacjach WPF 347
Rysunek 16.1. Instalacja pakietu Rx-WPF przy wykorzystaniu menedżera pakietów NuGet
Listing 16.2. Definicja etykiety lblPozycja i dodanie jej do elementów obiektu canvas
public partial class MainWindow : Window
{
private Label lblPozycja;
public MainWindow()
{
InitializeComponent();
}
this.canvas.Children.Add(lblPozycja);
}
}
Aby zacząć pracę ze zdarzeniami .NET w ramach Rx, należy poznać metodę rozsze-
rzającą:
Observable.FromEventPattern<TEventArgs>(object target, string eventName);
Jak wynika z definicji, jej parametrem jest typ argumentu zdarzenia (EventArgs). Na-
tomiast jej argumenty to obiekt, który generuje zdarzenie, i nazwa zdarzenia. Rx za
pomocą mechanizmów refleksji odnajdzie odpowiednie zdarzenie i dokona subskrypcji.
W metodzie Window_Initialized_1 zdefiniujemy obserwablę mouseMoveDb, zgodnie
z listingiem 16.3. Ponieważ interesuje nas obserwacja ruchu kursora myszy, „podpina-
my” się do zdarzenia MouseMove okna głównego (dlatego jako obiekt zgłaszający zda-
rzenie wskazujemy referencję this). Metoda Observable.FromEventPattern korzysta
z typowego wzorca zdarzeń na platformie .NET, w którym wysyłane są argumenty zda-
rzenia (parametr EventArgs) oraz nadawca (parametr sender). Informacja ta nas nie
interesuje, potrzebna jest jedynie aktualna pozycja kursora względem obiektu canvas.
Wobec tego wykorzystujemy metodę projekcji Observable.Select, która działa ana-
logicznie do metody o tej samej nazwie, znanej z innych wersji LINQ. Ostatecznie
otrzymujemy sekwencję punktów kursora myszy. Następnie, aby wykorzystać tę se-
kwencję, określamy subskrypcję, zgodnie z poleceniem na listingu 16.3.
Listing 16.3. Dodanie do metody okna głównego sekwencji mouseMoveDb oraz subskrypcja do tej
sekwencji
private void Window_Initialized_1(object sender, EventArgs e)
{
lblPozycja = new Label()
{
Content = string.Empty,
Margin = new Thickness(0)
};
this.canvas.Children.Add(lblPozycja);
IObservable<Point> mouseMoveDb =
Observable.FromEventPattern<MouseEventArgs>(this, "MouseMove")
.Select(pattern =>
pattern.EventArgs.GetPosition(this.canvas));
mouseMoveDb.ObserveOnDispatcher()
.Subscribe(
onNext: (punkt) =>
{
lblPozycja.Margin = new Thickness(punkt.X + 5,
punkt.Y - 30,
0, 0);
lblPozycja.Content = string.Format("{0}:{1}",
punkt.X, punkt.Y);
});
}
Rysunek 16.2.
Wynik uruchomienia
programu „Rysowanie
Rx”, który tylko śledzi
ruchy kursora myszy
Zmiana pozycji kursora nie będzie jednak skutkować zostawieniem śladu. Aby zacząć
rysowanie na płótnie okna, należy śledzić zdarzenia przyciśnięcia i zwolnienia lewego
przycisku myszy. Te dwa zdarzenia wskażą początek i koniec rysowania. Utworzymy
zatem jeszcze dwie sekwencje: pierwszą, która obserwuje naciśnięcie lewego przycisku
myszy, i drugą, obserwującą jego zwolnienie (listing 16.4).
Listing 16.4. Kod prezentujący wykorzystanie do rysowania elips dwóch sekwencji, pierwszej opartej
na naciśnięciu lewego przycisku myszy, drugiej opartej na puszczeniu lewego przycisku myszy
private void Window_Initialized_1(object sender, EventArgs e)
{
lblPozycja = new Label()
{
Content = string.Empty,
Margin = new Thickness(0)
};
this.canvas.Children.Add(lblPozycja);
IObservable<Point> mouseMoveDb =
Observable.FromEventPattern<MouseEventArgs>(this, "MouseMove")
.Select(pattern => pattern.EventArgs.GetPosition(this.canvas));
mouseMoveDb.ObserveOnDispatcher()
.Subscribe(
onNext: (punkt) =>
{
lblPozycja.Margin = new Thickness(punkt.X + 5, punkt.Y - 30, 0, 0);
lblPozycja.Content = string.Format("{0}:{1}", punkt.X, punkt.Y);
});
var leftMouseButtonDownDb =
Observable.FromEventPattern<MouseButtonEventArgs>
(this, "MouseLeftButtonDown");
var leftMouseButtonUpDb =
Observable.FromEventPattern<MouseButtonEventArgs>
(this, "MouseLeftButtonUp");
var mouseMoveWhileLeftButtonDownDb =
from lmd in leftMouseButtonDownDb
350 Programowanie równoległe i asynchroniczne w C# 5.0
from mm in mouseMoveDb.TakeUntil(leftMouseButtonUpDb)
select mm;
mouseMoveWhileLeftButtonDownDb.Subscribe(
onNext: (punkt) =>
{
var elipsa = new Ellipse()
{
Width = 2,
Height = 2,
Fill = Brushes.Blue
};
Canvas.SetLeft(elipsa, punkt.X);
Canvas.SetTop(elipsa, punkt.Y);
canvas.Children.Add(elipsa);
});
}
W tych dwóch nowych „bazach danych” nie interesuje nas położenie kursora myszy,
a jedynie sam fakt naciśnięcia bądź zwolnienia jej lewego przycisku. Dlatego nie wy-
korzystamy projekcji przy użyciu metody Observable.Select.
Rysunek 16.3.
Wynik rysowania
punktami w projekcie
Rysowanie Rx
Rozdział 16. Przykłady użycia technologii Rx w aplikacjach WPF 351
Możliwe jest rysowanie serii punktów, jednak to podejście uniemożliwia na razie ry-
sowanie ciągłych linii. Dodajmy zatem „bazy danych” przyciśnięcia prawego przyci-
sku myszy i zbudować nowe zapytanie tak, żeby zamiast punktów (dokładniej elips)
można było rysować linie. Z racji tego, że już wykorzystaliśmy naciskanie i zwalnia-
nie lewego przycisku myszy do rysowania elipsami, do rysowania liniami wykorzy-
stamy naciskanie i zwalnianie prawego przycisku myszy. Sekwencje te, odpowiednio
rightMouseButtonDownDb oraz rightMouseButtonUpDb, przedstawione są na listingu 16.5.
Następnie, aby śledzić nie poszczególne położenia kursora myszy, lecz pary kolejnych
punktów, utworzymy sekwencję mouseMoveDiffsDb. Wykorzystaliśmy tutaj dwie me-
tody rozszerzające poznane w rozdziale 14., czyli Zip oraz Skip. Jak widać na listingu
16.5, metoda Zip pobiera elementy lewy i prawy, a następnie tworzy dla nich obiekt
posiadający własności X1, X2, Y1, Y2. Można je utożsamiać z różnicą pomiędzy poło-
żeniami myszy przy dwóch kolejnych zdarzeniach MouseMove. Liczby te wykorzystamy
podczas dokonywania subskrypcji. Wpierw dla nowych sekwencji utworzymy zapytanie
mouseMoveWhileRightButtonDownDb, podobne do tego dla operacji na lewym przycisku
myszy i sekwencji ruchów myszy. Zapytanie to można odczytać, podobnie jak po-
przednio, a więc: „od momentu, w którym w sekwencji rightMouseButtonDownDb po-
jawi się zdarzenie wciśnięcia prawego przycisku myszy, pobieraj pojawiające się zda-
rzenia różnicy położeń w sekwencji mouseMoveDiffsDb do czasu, aż nie pojawi się
zdarzenie puszczenia prawego przycisku myszy w sekwencji rightMouseButtonUpDb”.
Aby sprawdzić, czy tak jest w istocie, utworzymy subskrypcję do wynikowej sekwencji
(listing 16.5).
Listing 16.5. Wykorzystanie sekwencji naciśnięcia i zwolnienia prawego przycisku myszy i utworzenie
subskrypcji do zmian położeń kursora myszy
private void Window_Initialized_1(object sender, EventArgs e)
{
lblPozycja = new Label()
{
Content = string.Empty,
Margin = new Thickness(0)
};
this.canvas.Children.Add(lblPozycja);
IObservable<Point> mouseMoveDb =
Observable.FromEventPattern<MouseEventArgs>(this, "MouseMove")
.Select(pattern => pattern.EventArgs.GetPosition(this.canvas));
mouseMoveDb.ObserveOnDispatcher()
.Subscribe(
onNext: (punkt) =>
{
lblPozycja.Margin = new Thickness(punkt.X + 5, punkt.Y - 30, 0, 0);
lblPozycja.Content = string.Format("{0}:{1}", punkt.X, punkt.Y);
});
var leftMouseButtonDownDb =
Observable.FromEventPattern<MouseButtonEventArgs>
(this, "MouseLeftButtonDown");
352 Programowanie równoległe i asynchroniczne w C# 5.0
var leftMouseButtonUpDb =
Observable.FromEventPattern<MouseButtonEventArgs>
(this, "MouseLeftButtonUp");
var mouseMoveWhileLeftButtonDownDb =
from lmd in leftMouseButtonDownDb
from mm in mouseMoveDb.TakeUntil(leftMouseButtonUpDb)
select mm;
mouseMoveWhileLeftButtonDownDb.Subscribe(
onNext: (punkt) =>
{
var elipsa = new Ellipse()
{
Width = 2,
Height = 2,
Fill = Brushes.Blue
};
Canvas.SetLeft(elipsa, punkt.X);
Canvas.SetTop(elipsa, punkt.Y);
canvas.Children.Add(elipsa);
});
var rightMouseButtonDownDb =
Observable.FromEventPattern<MouseButtonEventArgs>
(this, "MouseRightButtonDown");
var rightMouseButtonUpDb =
Observable.FromEventPattern<MouseButtonEventArgs>
(this, "MouseRightButtonUp");
var mouseMoveWhileRightButtonDownDb =
from rmd in rightMouseButtonDownDb
from mm in mouseMoveDiffsDb.TakeUntil(rightMouseButtonUpDb)
select mm;
mouseMoveWhileRightButtonDownDb.Subscribe(
onNext: (roznica) =>
{
var linia = new Line()
{
Fill = Brushes.Red,
Rozdział 16. Przykłady użycia technologii Rx w aplikacjach WPF 353
Stroke = Brushes.Red,
X1 = roznica.X1,
X2 = roznica.X2,
Y1 = roznica.Y1,
Y2 = roznica.Y2,
StrokeThickness = 2
};
this.canvas.Children.Add(linia);
});
}
Tym razem, bazując na różnicach położeń, rysujemy czerwoną linię, którą następnie
dodajemy do elementów rysowanych przez obiekt canvas. Tak jak poprzednio, sub-
skrypcji dokonujemy na wątku interfejsu, co umożliwia metoda ObserveOnDispatcher.
Po udanej kompilacji możemy wypróbować działanie aplikacji. Tym razem nie tylko
będzie wyświetlana pozycja kursora myszy, lecz także, korzystając z lewego przyci-
sku myszy, możemy rysować za pomocą niebieskich punktów, natomiast przy użyciu
prawego kursora myszy — czerwoną linią. Wynik widać na rysunku 16.4.
Rysunek 16.4.
Wynik rysowania punktami
(na niebiesko) oraz za pomocą
linii (na czerwono)
w przykładzie Rysowanie Rx
Wyszukiwarka
Coraz więcej firm oferuje usługi internetowe pozwalające na przeszukiwanie stron
internetowych, tłumaczenia tekstu czy udostępnianie map. Ich używanie jest prze-
ważnie odpłatne. Ważne jest zatem, aby program komunikujący się z usługą interne-
tową nie wysyłał zbyt dużo zapytań do serwera. W przypadku wyszukiwarek oznacza to,
że należy poczekać, by użytkownik zdążył napisać kilka liter, a nawet całe słowo, zanim
poszukiwane hasło zostanie wysłane do usługi.
Rysunek 16.5.
Ilość transakcji wyszukiwania
za pomocą silnika Bing,
która jest nieodpłatna
W następnym kroku pojawi się treść warunków użytkowania, którą należy szczegó-
łowo przeczytać przed wyrażeniem zgody. Akceptacja tych wymogów jest jednak wa-
runkiem korzystania z oferty Azure, a tym samym ukończenia przez czytelników tego
przykładu. Po akceptacji możemy skorzystać z wybranego limitu transferowego dla
5000 wyszukiwań w Bing miesięcznie. W tym celu należy wcisnąć przycisk Sign Up
znajdujący się przy opcji za 0,00 zł. W następnym oknie trzeba wybrać opcję EXPLORE
THIS DATASET. Na stronie, która zostanie otworzona, można sprawdzać aktualny stan
wykorzystania usługi. Nas na razie nie będzie to interesowało. W celu zachowania
bezpieczeństwa (i to w każdym przypadku korzystania z Windows Azure Marketplace)
zaleca się utworzenie odpowiednich kluczy, które są wykorzystywane przez aplikacje,
aby określać subskrybentów konkretnych usług. Przejdźmy zatem do zakładki My Acco-
unt, a dalej do ACCOUNT KEYS. Wyświetlony jest tam klucz domyślny użytkownika.
Rozdział 16. Przykłady użycia technologii Rx w aplikacjach WPF 355
Nie należy z niego korzystać, lecz do każdej aplikacji utworzyć nowy. Tak też zrobimy,
wciskając przycisk Add (rysunek 16.7). Po podaniu nazwy „RxBingApp” i kliknięciu
Save klucz zostanie wygenerowany.
Rysunek 16.7. Aby dodać nowy klucz użytkownika, należy wpierw wejść na zakładkę My Account,
potem wybrać ACCOUNT KEYS, a na końcu dodać klucz, naciskając Add
Sposób pobierania bibliotek Bing Search API do użycia w języku C# jest co najmniej
dziwny — o wiele wygodniej byłoby, gdyby biblioteki te były dostępne przez NuGet.
Aby rozpocząć pracę w C#, trzeba pobrać plik z definicją klasy w C#. Plik ten znajduje
się na stronie https://datamarket.azure.com/dataset/bing/search. W tej samej kolumnie,
w której widoczny jest cennik, na samym dole znajduje się nasza aktywna subskrypcja.
Tuż pod nią powinien znajdować się link .NET C# Class Library prowadzący do pliku
BingSearchContainer.cs, który należy pobrać i dołączyć do rozwijanej aplikacji.
Po tych przygotowaniach utworzymy nowy projekt WPF w Visual Studio 2012 i na-
zwiemy go „Wyszukiwarka Rx”. Dodamy do niego pakiet NuGet Rx-WPF, a następnie
plik BingSearchContainer.cs.
Następnie w edytorze XAML dodamy pole tekstowe oraz listę, zgodnie z listingiem 16.6.
Listing 16.6. Definicja wyglądu interfejsu użytkownika w prostej aplikacji korzystającej z silnika
wyszukiwania Bing
<Window x:Class="Przeglądarka_Rx.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="MainWindow" Height="350" Width="525" Initialized="Window_Initialized_1">
<Grid>
<TextBox Height="23" Margin="117,12,0,0" Name="txtFraza"
VerticalAlignment="Top" HorizontalAlignment="Left" Width="263" />
<ListBox Margin="0,41,0,0" Name="lboRezultaty" ItemsSource="{Binding}">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel>
356 Programowanie równoległe i asynchroniczne w C# 5.0
W pliku tym, nad konstruktorem utworzymy pole typu string o nazwie accountKey,
którego wartością będzie klucz RxBingApp wygenerowany na stronie (wystarczy go
skopiować). Zdefiniujemy też kontener wyszukiwania bing (pole typu BingSearch
Container zdefiniowanego w pobranym pliku BingSearchContainer.cs), tak jak na li-
stingu 16.7.
Listing 16.7. Kod pierwszego programu wykorzystującego Rx oraz silnik wyszukiwania Bing
public partial class MainWindow : Window
{
private string accountKey = "KLUCZ_RxBingApp_Z_WINDOWS_AZURE_MARKETPLACE";
private BingSearchContainer bing;
public MainWindow()
{
InitializeComponent();
}
IObservable<string> frazy =
Observable.FromEventPattern<TextChangedEventArgs>(txtFraza, "TextChanged")
.Select(pattern => ((TextBox)pattern.Sender).Text);
IObservable<IEnumerable<WebResult>> wynik =
from fraza in frazy
Rozdział 16. Przykłady użycia technologii Rx w aplikacjach WPF 357
wynik.SubscribeOn(ThreadPoolScheduler.Instance)
.ObserveOnDispatcher()
.Subscribe(lista =>
{
this.lboRezultaty.ItemsSource = lista;
});
}
Aby pracować z silnikiem wyszukiwania Bing, należy dodać jeszcze referencję do bi-
blioteki systemowej System.Data.Services.Client.dll. Kolejne instrukcje będą wpisy-
wane do metody Window_Initialized_1. W niej utworzymy nową instancję kontenera
wyszukiwania Bing (listing 16.7). Następnie przechwycimy zdarzenie zmiany tekstu
w polu tekstowym txtSearch. Zrobimy to analogicznie do przykładu z rysowaniem
(listing 16.3). Frazę będziemy pobierać już jako sekwencję obserwowalną. Następnym
krokiem jest wykorzystanie tego wyniku do rozpoczęcia wyszukiwania. Aby utwo-
rzyć zapytanie, należy wykorzystać zainicjowany kontener bing i wywołać na jego
rzecz metodę Web (listing 16.7).
Oczywiście, jest to bardzo proste zapytanie. W jaki sposób je wywołać? Obiekt Data
ServiceQuery posiada dwie służące do tego metody — Execute oraz BeginExecute.
Pierwsza jest metodą blokującą, czyli odpowiada wzorcowi interaktywnemu. Druga
jest zgodna z asynchronicznym wzorcem APM (ang. Asynchronous Programming
Model). Jej działanie kończone jest metodą EndExecute. W jaki sposób z nich korzystać?
Rx posiada bardzo bogaty zbiór możliwości integracji z wszelkimi wzorcami progra-
mowania asynchronicznego. Dostępna jest m.in. metoda Observable.FromAsyncPattern,
z której można skorzystać, aby przejść z wzorca APM do obserwabli. Mając zatem
zapytanie kwerenda, zdefiniowane tak jak na listingu 16.7, utworzymy nową metodę
PobierzWyniki klasy MainWindow, pobierającą wyniki z silnika wyszukiwania Bing i kon-
wertującą je do sekwencji obserwowalnej Rx. Metoda ta widoczna jest na listingu 16.7.
Pierwszy problem można rozwiązać przy użyciu metody Throttle, która w jedynym
parametrze przyjmuje przedział czasu (TimeSpan) określający, jak długo dana sekwencja
ma oczekiwać, zanim zacznie zgłaszać powstałe zdarzenia. Rozwiązanie drugiego pro-
blemu już poznaliśmy przy okazji poprzedniego przykładu. Jest nim operator TakeUntil,
który, działając na metodę PobierzWyniki, przestaje ją wykonywać w momencie, gdy
frazy są modyfikowane. Zatem zmodyfikujemy sekwencję-obserwablę frazy, tak jak
na listingu 16.8.
Listing 16.8. Dodanie operatora Throttle do sekwencji zmieniających się fraz do wyszukiwania oraz
TakeUntil, aby zahamować wysyłanie zapytań do serwera
private void Window_Initialized_1(object sender, EventArgs e)
{
bing = new BingSearchContainer(
new Uri("https://api.datamarket.azure.com/Bing/Search/"))
{
Credentials = new NetworkCredential(accountKey, accountKey)
};
IObservable<string> frazy =
Observable.FromEventPattern<TextChangedEventArgs>(txtFraza, "TextChanged")
.Select(pattern => ((TextBox)pattern.Sender).Text)
.Throttle(TimeSpan.FromSeconds(0.5));
IObservable<IEnumerable<WebResult>> wynik =
from fraza in frazy
from rezultat in
PobierzWyniki(fraza).TakeUntil(frazy)
select rezultat;
wynik.SubscribeOn(ThreadPoolScheduler.Instance)
.ObserveOnDispatcher()
.Subscribe(lista =>
{
Rozdział 16. Przykłady użycia technologii Rx w aplikacjach WPF 359
this.lboRezultaty.ItemsSource = lista;
});
}
Alternatywą dla TakeUntil jest metoda Switch, która działa analogicznie do TakeUntil,
z tym że w momencie przyjścia nowego zdarzenia do obserwabli, dla której jest zasto-
sowana, hamuje ewentualne przetwarzanie poprzedniego zdarzenia i przełącza kontekst
automatycznie do nowego zadania. Korzystając z niej, można zapisać wynik tak, jak
pokazujemy na listingu 16.9.
IObservable<IEnumerable<WebResult>> wynik =
(from fraza in frazy
from rezultat in PobierzWyniki(fraza))
.Switch();
wynik.SubscribeOn(ThreadPoolScheduler.Instance)
.ObserveOnDispatcher()
.Subscribe(lista =>
{
this.lboRezultaty.ItemsSource = lista;
});
}
Jest jeszcze jeden, ukryty problem. Jest nim sposób, w jaki generowane jest zdarzenie
TextChanged. Jeśli w polu będzie wpisany tekst „a”, ten tekst skopiujemy, zaznaczyw-
szy cały tekst w polu tekstowym, i będziemy wklejać, to — mimo iż tekst nie będzie
się różnił — pole txtFraza będzie cały czas generowało zdarzenie TextChanged.
Wówczas niepotrzebnie korzystalibyśmy z ograniczonej liczby zapytań, jakie możemy
wysłać do wyszukiwarki. I w tym przypadku Rx oferuje rozwiązanie. Jest nim metoda
DistinctUntilChanged, która zastosowana dla obserwabli frazy wstrzymuje ogłaszanie
nowego zdarzenia do momentu faktycznej zmiany elementu, czyli w tym przypadku
do zmiany tekstu wyszukiwania. Po jej użyciu definicja obserwabli frazy powinna
mieć postać przedstawioną na listingu 16.10.
{
Credentials = new NetworkCredential(accountKey, accountKey)
};
IObservable<string> frazy =
Observable.FromEventPattern<TextChangedEventArgs>(txtFraza, "TextChanged")
.Select(pattern => ((TextBox)pattern.Sender).Text)
.DistinctUntilChanged()
.Throttle(TimeSpan.FromSeconds(0.5));
IObservable<IEnumerable<WebResult>> wynik =
(from fraza in frazy
from rezultat in PobierzWyniki(fraza))
.Switch();
wynik.SubscribeOn(ThreadPoolScheduler.Instance)
.ObserveOnDispatcher()
.Subscribe(lista =>
{
this.lboRezultaty.ItemsSource = lista;
});
}
Observable.FromEventPattern<TextChangedEventArgs>(txtFraza, "TextChanged")
.Select(pattern => ((TextBox)pattern.Sender).Text)
.Throttle(TimeSpan.FromSeconds(0.5))
.Select(fraza =>
{
DataServiceQuery<WebResult> kwerenda =
bing.Web(fraza, null, null, null, null, null, null, null);
Func<IObservable<IEnumerable<WebResult>>> wynik =
Observable.FromAsyncPattern<IEnumerable<WebResult>>(
kwerenda.BeginExecute,
kwerenda.EndExecute);
return wynik();
})
.Switch()
.SubscribeOn(ThreadPoolScheduler.Instance)
Rozdział 16. Przykłady użycia technologii Rx w aplikacjach WPF 361
.ObserveOnDispatcher()
.Subscribe(lista =>
{
this.lboRezultaty.ItemsSource = lista;
});
}
Pozbyliśmy się więc nie tylko dwóch stanów, czyli obiektów odnoszących się do se-
kwencji obserwowalnych, lecz także metody PobierzWyniki. Dzięki temu cały proces
jest zapisany w postaci czytelnego kodu, który mieści się w mniej niż 25 linijkach i reali-
zuje scenariusz, w jakim aplikacja utrzymuje aktywny i odpowiadający interfejs z wyko-
rzystaniem współbieżności, zapewnia optymalne wykorzystanie łącza oraz limitów po
stronie serwera i do tego reaguje na zmianę hasła wyszukiwania. Jest to naprawdę dużo
jak na niecałe 25 linijek kodu. Ale czy to wszystko? Podejrzewam, że czytelników irytuje
zielony wężyk w Visual Studio podkreślający instrukcję Observable.FromAsyncPattern,
czyli korzystające ze wzorca APM wywołanie zapytania.
Rysunek 16.8.
Komunikat Rx mówiący o tym, że
metoda transformująca ze wzorca
APM do obserwabli jest przestarzała.
Komunikat zawiera także informację,
jak wykorzystać nowe metody
transformacji
W jaki zatem sposób najlepiej transformować wzorzec APM do Rx? Korzystając z pod-
powiedzi widocznej na rysunku 16.8, należy najpierw wykonać konwersję z APM do
TAP, czyli wykorzystując TPL, a dopiero wtedy przejść do obserwowalnej sekwencji za
pomocą metody rozszerzającej zadania TPL TaskObservableExtensions.ToObservable,
która zdefiniowana jest w przestrzeni nazw System.Reactive.Threading.Tasks. Po tych
zmianach część odpowiedzialna za wykonanie zapytania do serwera będzie wyglądać
tak, jak na listingu 16.12. Natomiast po uruchomieniu aplikacji i wpisaniu frazy „reactive
extensions” powinniśmy zobaczyć widok podobny do tego widoku z rysunku 16.9.
{
Credentials = new NetworkCredential(accountKey, accountKey)
};
Observable.FromEventPattern<TextChangedEventArgs>(txtFraza, "TextChanged")
.Select(pattern => ((TextBox)pattern.Sender).Text)
.Throttle(TimeSpan.FromSeconds(0.5))
.Select(fraza =>
{
DataServiceQuery<WebResult> kwerenda =
bing.Web(fraza, null, null, null, null, null, null, null);
return Task<IEnumerable<WebResult>>.Factory
.FromAsync(kwerenda.BeginExecute,
kwerenda.EndExecute,
null)
.ToObservable();
})
.Switch()
.SubscribeOn(ThreadPoolScheduler.Instance)
.ObserveOnDispatcher()
.Subscribe(lista =>
{
this.lboRezultaty.ItemsSource = lista;
});
}
Rysunek 16.9.
Wynik działania
przeglądarki
z przykładu
wykorzystującej
Reactive Extensions
***
3
Typy oparte o IEnumerable<T> zwane są potocznie danymi spoczywającymi, natomiast typy oparte
o IObservable<T> — danymi w ruchu. Generalnie wszystkie dane, których powstawanie oparte jest
o model reaktywny, nazywane są danymi w ruchu.
364 Programowanie równoległe i asynchroniczne w C# 5.0
Rozdział 17.
CUDA w .NET
Tomasz Dziubak
W latach 90. ubiegłego wieku wyłącznym zadaniem kart graficznych było wyświetla-
nie grafiki na monitorach komputerów. Jednak z końcem minionego tysiąclecia roz-
począł się przewrót — ich moc obliczeniową zaczęto wykorzystywać nie tylko do
przetwarzania graficznego, ale także do obliczeń ogólnych problemów numerycznych.
Narodziła się idea GPGPU (ang. General-Purpose computing on Graphics Processor
Units), czyli pomysł wykonywania obliczeń niezwiązanych z grafiką na układach
GPU. W krótkim czasie powstały dwie technologie realizujące ideę GPGPU: CUDA
(ang. Compute Unified Device Architecture), stworzona przez firmę NVidia, oraz ATI
Stream1 firmy ATI. Większą popularność zdobyła jednak ta pierwsza. Stało się tak
z powodu znacznie prostszego interfejsu programowania.
Od czasu pojawienia się technologii CUDA na rynku minęło już sporo czasu. CUDA
dość mocno się zmieniła, lecz dzięki pełnej kompatybilności wstecz programy napisane
w tamtych czasach można uruchomić również na obecnych modelach kart graficznych.
CUDA wykorzystuje język C for CUDA oparty na języku C, ale wzbogacony o nowe
słowa kluczowe i konstrukcje umożliwiające tworzenie aplikacji wykonujących obli-
czenia z użyciem GPU. Należy zaznaczyć, że technologia CUDA obsługiwana jest tylko
przez karty graficzne firmy NVidia2.
Rozwiązanie, które chciałbym opisać w tym rozdziale, to jednak coś więcej niż tylko
wrapper. CUDAfy.NET, bo o nim tu mowa, jest zbiorem bibliotek oraz nieoficjalnym
rozszerzeniem języka C# umożliwiającym pisanie funkcji wykonywanych przez karty
graficzne, tzw. kerneli6, bezpośrednio w języku C#. Kernele piszemy zatem w języku C#,
bezpośrednio w kodzie projektu .NET. Cały projekt kompilujemy, używając standardo-
wego kompilatora języka C#. Tłumaczeniem kerneli z C# na C for CUDA i kompilo-
waniem ich za pomocą kompilatora NVCC zajmują się biblioteki CUDAfy.NET7.
Konfiguracja środowiska
dla CUDAfy.NET
Zestaw bibliotek CUDAfy.NET powstał w firmie Hybrid DSP. Można go pobrać ze
strony internetowej http://www.hybriddsp.com/Downloads.aspx. CUDAfy.NET jest
dostępny na licencji LGPL oraz w wersji, która może być wykorzystywana do celów
komercyjnych. W czasie pisania tego rozdziału dostępna była wersja stabilna 1.12.
Jeśli obecne sterowniki nie obsługują wymaganej przez CUDAfy.NET wersji CUDA,
należy je pobrać i zainstalować. Sterowniki dostępne są na stronach NVidii pod adresem
https://developer.nvidia.com/cuda-downloads. Podczas instalacji możemy wybrać skład-
niki, jakie mają zostać zainstalowane. Zalecam zainstalowanie całego pakietu, zwłaszcza
że pozostałe składniki również są wymagane przez CUDAfy.NET.
6
W polskiej literaturze używane jest czasem określenie „jądro”.
7
Biblioteka CUDAfy.NET korzysta z translatora (piszę o tym niżej), który wpierw za pomocą
dekompilatora ILSpy firmy SharpDevelop dekompiluje plik uruchomieniowy, a następnie tłumaczy
go na C for CUDA.
Rozdział 17. CUDA w .NET 367
Rysunek 17.1. Informacje o wersji CUDA obsługiwanej przez sterowniki karty graficznej
zainstalowane w systemie
Rysunek 17.2.
Ustawianie ścieżki dostępu
do kompilatora cl.exe
8
Wszystkie projekty z tego rozdziału można uruchomić w Visual Studio 2013 jednak ścieżka do
kompilatora C/C++ musi wskazywać na kompilator z wersji Visual Studio 2008, 2010 lub 2012.
W przeciwnym razie skompilowanie projektów nie będzie możliwe gdyż kompilator nvcc z obecnych
wersji CUDA 5 nie wspiera kompilatora C/C++ z Visual Studio 2013. Autorzy biblioteki CUDAfy.NET
zalecają używanie Visual Studio 2010.
368 Programowanie równoległe i asynchroniczne w C# 5.0
Pierwsze kroki
Po pobraniu i rozpakowaniu paczki z CUDAfy.NET widzimy, że składa się ona z kil-
ku podkatalogów. W dwóch z nich, a mianowicie CudafyByExample oraz Cudafy-
Examples, znajdują się przykłady użycia biblioteki. Pierwszy podkatalog zawiera skon-
wertowane do CUDAfy.NET przykłady ze wspomnianej już książki CUDA w przykładach.
Wprowadzenie do ogólnego programowania procesorów GPU. Wszystkie projekty
w tych katalogach są utworzone w Visual Studio 2010. W podkatalogu CudafyExamples
znajdziemy natomiast przykłady użycia typów zespolonych czy wielowymiarowych
tablic. W paczce mamy także katalog bin, który zawiera kilka plików. Są to m.in. bi-
blioteka Cudafy.NET.dll oraz translator cudafycl.exe języka C# do C for CUDA.
Rysunek 17.3. Typowy problem, który może się pojawić podczas próby uruchomienia przykładowych
programów, spowodowany niewłaściwą wersją potencjału obliczeniowego karty graficznej,
która w bibliotece CUDAfy.NET w wersji 1.12 jest domyślnie ustawiona na 1.3
Rozdział 17. CUDA w .NET 369
Problem można łatwo rozwiązać, ale wymaga to ingerencji w kody źródłowe przykła-
dów. Spróbujmy więc zmodyfikować kod, nie wnikając na razie głębiej w jego za-
wartość. Zlokalizujmy miejsce wystąpienia problemu. W tym celu otwieramy plik
Program.cs i ustawiamy punkt przerwania programu (klawisz F9) tuż za nagłówkiem
funkcji Main. Uruchamiamy program i wykonujemy go linia po linii, wciskając klawisz
F10. Linią stwarzającą problem okazuje się ta, która zawiera wywołanie metody simple_
kernel.Execute. Uruchamiamy więc program raz jeszcze i tym razem wchodzimy
do tej metody, wciskając F11 (w momencie jej wykonywania). W ten oto sposób do-
tarliśmy do kodu źródłowego metody widocznej na listingu 17.1. To ona powoduje po-
jawienie się komunikatu widocznego na rysunku 17.3.
Listing 17.1. Kod źródłowy metody simple_kernel.Execute, która powoduje problemy podczas
uruchomienia programu, jeśli karta graficzna ma potencjał obliczeniowy niższy niż 1.3
public static void Execute()
{
CudafyModule km = CudafyTranslator.Cudafy();
Przyjrzyjmy się pierwszej linii kodu umieszczonej wewnątrz tej metody. Tworzy ona
obiekt km klasy CudafyModule za pomocą statycznej metody Cudafy klasy Cudafy
Translator. Ta klasa jest wrapperem programu ILSpy. Umożliwia ona tłumaczenie
kodu napisanego w .NET na C for CUDA oraz jego opakowanie w informacje wykorzy-
stywane przez mechanizm refleksji. Wszystkie te dane przechowuje klasa CudafyModule.
Metoda Cudafy klasy CudafyTranslator jest metodą przeciążoną. Jedna z jej wersji
przyjmuje tylko jeden argument typu wyliczeniowego eArchitecture. Umożliwia on
ustawienie odpowiedniej wersji potencjału obliczeniowego karty graficznej. Najniższą
wersją obsługiwaną przez CUDAfy.NET jest 1.1. Ustawimy więc taką wartość (listing
17.2) i spróbujemy skompilować rozwiązanie oraz uruchomić program.
Listing 17.2. Ustawienie wersji potencjału obliczeniowego karty graficznej na 1.1 w przykładowym
programie
public static void Execute()
{
CudafyModule km = CudafyTranslator.Cudafy(eArchitecture.sm_11);
Rysunek 17.4.
Prawidłowe wykonanie
pierwszego programu
przykładowego
używającego karty
graficznej. Pojawiły
się kolejne błędy przy
próbie uruchomienia
pozostałych programów
przykładowych
Rysunek 17.5. Metoda na szybką zmianę domyślnej wersji potencjału obliczeniowego w przykładowych
programach. Narzędzie można wywołać, wciskając kombinację klawiszy Ctrl+H. W pole „Find what”
wpisujemy CudafyModule km = CudafyTranslator.Cudafy(); a w pole „Replace with” CudafyModule
km = CudafyTranslator.Cudafy();. Po wypełnieniu odpowiednich pól klikamy „Replace All”
Hello World,
czyli pierwszy program CUDAfy.NET
W większości kursów programowania pierwszym programem jest odmiana „Hello
World” — program drukujący na ekranie (konsoli) napis „Hello World!”. My również
spróbujemy napisać taki program. Zaczniemy od uruchomienia Visual Studio i utworze-
nia projektu aplikacji konsolowej o nazwie hello_world (rysunek 17.6).
Rozdział 17. CUDA w .NET 371
using Cudafy;
using Cudafy.Host;
372 Programowanie równoległe i asynchroniczne w C# 5.0
using Cudafy.Translator;
namespace hello_world
{
class Program
{
static void Main(string[] args)
{
}
}
}
Listing 17.4. Utworzenie instancji klasy CudafyModule, pobranie uchwytu reprezentującego kartę
graficzną oraz załadowanie modułu modułCuda
static void Main(string[] args)
{
CudafyModule modułCuda = CudafyTranslator.Cudafy(eArchitecture.sm_20);
GPGPU uchwytGPU = CudafyHost.GetDevice();
uchwytGPU.LoadModule(modułCuda);
}
9
Zob. http://docs.nvidia.com/cuda/parallel-thread-execution/index.html
Rozdział 17. CUDA w .NET 373
Jak widać, funkcja, która ma być wykonywana przez GPU, posiada atrybut Cudafy.
Oczywiście, w programie może być zdefiniowanych więcej kerneli. Wówczas wszyst-
kie muszą posiadać taki atrybut. Dzięki niemu klasa CudafyTranslator „wie”, jakie
funkcje należy skonwertować do języka C for CUDA. Ponadto kernele muszą być
metodami statycznymi. Jeżeli funkcja nie zwraca żadnej wartości, tak jak w naszym
przypadku, jest ona traktowana jak funkcja wywoływana przez host i wykonująca się
na GPU (funkcja z kwalifikatorem __global__). Jeśli funkcja zwracałaby wartość,
byłaby traktowana jak funkcja wywoływana przez GPU i wykonującą się na GPU
(kwalifikator __ device__). W kernelu użyłem dobrze znanej programistom C# metody
Console.WriteLine. Wyświetla ona ciąg znaków na monitorze komputera. W przy-
padku kernela wykonuje taką samą czynność. Różnica polega tylko na tym, że tekst
jest wyświetlany przez wątek GPU, który „wykonuje” tę metodę. A dokładnie nie
metodę, a efekt jej tłumaczenia na język C for CUDA. Podczas tłumaczenia (konwersji)
instrukcja Console.WriteLine jest zamieniana na funkcję printf. Wszystkie skonwerto-
wane kernele są zapisywane do pliku o rozszerzeniu cu, który znajduje się w katalogu
uruchomieniowym. Na listingu 17.6 przedstawiam zawartość tego pliku dla metody
z listingu 17.5.
Listing 17.6. Kernel z listingu 17.5 skonwertowany do języka C for CUDA. Poniższy kod znajduje się
w pliku CUDAFYSOURCETEMP.cu
#include <stdio.h>
// hello_world.Program
extern "C" __global__ void witajSwiecie();
// hello_world.Program
extern "C" __global__ void witajSwiecie()
{
printf("Witaj swiecie!\n");
}
Prosty kernel jest już gotowy. Wywołajmy go zatem z metody Main w taki sposób,
aby został wykonany przez kartę graficzną. Niezbędne modyfikacje przedstawiam na
listingu 17.7. Jak widać, dodane zostały trzy linie kodu, z czego ważne są dwie pierwsze.
Pierwsza z nich tworzy dynamiczny obiekt startKernel. Obiekt ten zawiera metody,
które są efektem wykorzystania w CUDAfy.NET mechanizmu DLR (ang. Dynamic
Language Runtime10) dodanego do platformy.NET w wersji 4.0. W drugiej linii wywo-
łujemy przygotowany wcześniej kernel witajSwiecie.
10
O typach dynamicznych i programowaniu z wykorzystaniem możliwości dynamicznych języków
programowania można przeczytać m.in. na stronach http://msdn.microsoft.com/pl-pl/library/
csharp-4-0--typy-dynamiczne.aspx oraz http://msdn.microsoft.com/pl-pl/library/
programowanie-z-wykorzystaniem-mozliwosci-dynamicznych-jezykow-programowania.aspx.
374 Programowanie równoległe i asynchroniczne w C# 5.0
Listing 17.7. Wywołanie kernela witajSwiecie w głównej funkcji programu z wykorzystaniem mechanizmu
DLR. Metoda Console.ReadKey zatrzymuje program, co umożliwia obejrzenie wyników działania kernela
static void Main(string[] args)
{
CudafyModule modułCuda = CudafyTranslator.Cudafy(eArchitecture.sm_20);
GPGPU uchwytGPU = CudafyHost.GetDevice();
uchwytGPU.LoadModule(modułCuda);
Console.ReadKey();
}
Ponieważ startKernel jest typu dynamic, kompilator nie sprawdza, czy kernel witajSwiecie
jest zdefiniowany w tworzonym programie. W architekturze DLR znaczenie wywoły-
wanej metody (w naszym przypadku kernela) jest analizowane dopiero podczas wy-
konania programu, a sam kod skompiluje się dla dowolnej nazwy kernela. Jeśli ktoś nie
lubi korzystania z architektury DLR, może użyć klasycznej metody, tak jak pokazuję
na listingu 17.8.
Listing 17.8. Alternatywny sposób wywołania kernela witajSwiecie w głównej funkcji programu
bez wykorzystywania mechanizmu DLR
static void Main(string[] args)
{
CudafyModule modułCuda = CudafyTranslator.Cudafy(eArchitecture.sm_20);
GPGPU uchwytGPU = CudafyHost.GetDevice();
uchwytGPU.LoadModule(modułCuda);
uchwytGPU.Launch(1, 1, "witajSwiecie");
Console.ReadKey();
}
Dostępny jest także trzeci sposób uruchamiania kerneli. Oprócz dwóch poznanych
(standardowej oraz z wykorzystaniem DLR) możliwe jest uruchamianie z mocnym
typowaniem.
mogą się cieszyć tylko posiadacze karty graficznej, która obsługuje potencjał oblicze-
niowy 2.0 i wyższy. W kolejnym podrozdziale zaprezentuję narzędzie, które pozwoli na
podziwianie efektów swojej pracy również posiadaczom starszych kart graficznych.
Zachęcam jednak wszystkich czytelników do zapoznania się z informacjami zawartymi
w tym podrozdziale, ponieważ będą one pomocne każdemu programiście, który za-
mierza wykorzystywać potencjał kart graficznych w swojej pracy.
Emulator GPU
Co zrobić, jeśli nie posiadamy karty graficznej obsługującej technologię CUDA z poten-
cjałem obliczeniowym większym lub równym 2.0? W takim przypadku do testów
programu możemy użyć emulatora GPU dostarczonego przez firmę NVidia i obsłu-
giwanego przez CUDAfy.NET. W tym celu musimy nieco zmodyfikować program.
Wróćmy do metody CudafyHost.GetDevice. Metoda może przyjąć dwa opcjonalne pa-
rametry. Pierwszy z tych parametrów jest typem wyliczeniowym eGPUType, który do-
myślnie przyjmuje wartość eGPUType.Cuda. Wartość ta oznacza, że nasz program ma
się wykonywać na karcie graficznej. Wartość tego parametru możemy jednak zmienić
na eGPUType.Emulator (listing 17.9). Wówczas do uruchomienia programu wykorzy-
stywany jest emulator GPU.
uchwytGPU.LoadModule(modułCuda);
Console.ReadKey();
}
Spróbujmy teraz uruchomić program, wciskając klawisz F5. Program powinien skompi-
lować się i po chwili uruchomić. Tym razem w wykonanie programu nie jest zaanga-
żowany procesor GPU, lecz jedynie CPU emulujący GPU. Dzięki temu możemy pro-
gram uruchomić również na komputerach z kartami graficznymi nieobsługującymi
technologii CUDA lub o zbyt niskim potencjale obliczeniowym. Efekt jego działania
jest taki sam jak na rysunku 17.7.
376 Programowanie równoległe i asynchroniczne w C# 5.0
Własności GPU
Gdy chcemy pisać programy wykorzystujące moc obliczeniową procesora graficzne-
go, musimy poznać podstawowe parametry karty graficznej. Biblioteka CUDAfy.Net
umożliwia pobranie wszystkich niezbędnych parametrów w bardzo prosty sposób.
Klasa przechowująca te informacje to GPGPUProperties. Do pobierania tych informa-
cji dla wszystkich kart graficznych znajdujących się w systemie przeznaczona jest
statyczna metoda GetDeviceProperties z klasy CudafyHost. Metoda zwraca wartość
typu IEnumerable<GPGPUProperties> co oznacza, że możemy użyć pętli foreach w celu
wylistowania informacji dla wszystkich kart graficznych. Metoda GetDeviceProperties
posiada dwa argumenty wywołania. Pierwszy z nich jest typem wyliczeniowym eGPUType,
który poznaliśmy już wcześniej. Jego wartość ustawiamy na eGPUType.Cuda, jeśli
chcemy poznać własności karty graficznej. Nic jednak nie stoi na przeszkodzie, aby
jako pierwszy parametr wywołania ustawić eGPUType.Emulator, co umożliwi poznanie
podstawowych parametrów emulatora GPU. Drugi z argumentów przyjmuje wartość
typu bool. Umożliwia pobranie dodatkowych informacji o GPU za pomocą biblioteki
cudart.dll. Domyślna wartość tego parametru jest ustawiona na true.
Zmodyfikujmy wcześniej opisany pierwszy program w taki sposób, aby oprócz zwykłe-
go Witaj swiecie! wyświetlał także informacje o kartach graficznych. Na listingu 17.10
przedstawiam wymagane do tego modyfikacje metody Main. Obecnie coraz częściej
pojawiają się systemy z wieloma kartami graficznymi. Również karty graficzne z wielo-
ma procesorami nie są niczym nowym. Znajomość podstawowych parametrów tych
układów pozwala na uruchomienie programu na układzie, który najlepiej spełnia jego
wymogi. Dzięki temu wydajność programu może znacznie wzrosnąć. Wśród udostęp-
nianych parametrów można znaleźć informacje o obsługiwanym potencjale obliczenio-
wym, pamięci karty graficznej czy dostępnej liczbie wątków. Wynik działania programu
przedstawiam na rysunku 17.8.
Listing 17.10. Modyfikacja programu hello_world, która wyświetla podstawowe informacje o kartach
graficznych zainstalowanych w komputerze
static void Main(string[] args)
{
CudafyModule modułCuda = CudafyTranslator.Cudafy(eArchitecture.sm_20);
GPGPU uchwytGPU = CudafyHost.GetDevice(eGPUType.Emulator);
uchwytGPU.LoadModule(modułCuda);
Console.WriteLine();
Console.WriteLine("Informacje o kartach graficznych zainstalowanych w systemie");
Console.WriteLine();
foreach (GPGPUProperties parametryGPU in
CudafyHost.GetDeviceProperties(eGPUType.Cuda))
{
Console.WriteLine("Nazwa urządzenia: " + parametryGPU.Name);
Console.WriteLine("Numer identyfikacyjny urządzenia: " +
parametryGPU.DeviceId);
Console.WriteLine("Potencjał obliczeniowy: " + parametryGPU.Capability);
Console.WriteLine("Częstotliwość taktowania zegara: " +
parametryGPU.ClockRate);
Console.WriteLine("Status ECC: " + (parametryGPU.ECCEnabled ? "włączone" :
"wyłączone"));
Console.WriteLine("Limit czasu działania kernela: " +
(parametryGPU.KernelExecTimeoutEnabled ? "włączone" : "wyłączone"));
Console.WriteLine("Liczba multiprocesorów: " +
parametryGPU.MultiProcessorCount);
Console.WriteLine("Ilość pamięci globalnej: "
+ parametryGPU.TotalGlobalMem + "B");
Console.WriteLine("Ilość pamięci stałej: " +
parametryGPU.TotalConstantMemory + "B");
Console.WriteLine("Ilość pamięci współdzielonej przypadającej na jeden
blok: " + parametryGPU.SharedMemoryPerBlock + "B");
Console.WriteLine("Maksymalna liczba wątków: " +
parametryGPU.MaxThreadsSize.x + "x" + parametryGPU.MaxThreadsSize.y + "x"
+ parametryGPU.MaxThreadsSize.z);
Console.WriteLine("Maksymalna liczba wątków na blok: " +
parametryGPU.MaxThreadsPerBlock);
Console.WriteLine("Maksymalna liczba wątków na multiprocesor: " +
parametryGPU.MaxThreadsPerMultiProcessor);
Console.WriteLine("Liczba wątków w osnowie: " + parametryGPU.WarpSize);
Console.WriteLine("Maksymalny wymiar sieci: " + parametryGPU.MaxGridSize.x
+ "x" + parametryGPU.MaxGridSize.y + "x" + parametryGPU.MaxGridSize.z);
Console.WriteLine("Maksymalna liczba kerneli wykonywanych równocześnie: " +
parametryGPU.ConcurrentKernels);
Console.WriteLine("Zintegrowana karta graficzna: " +
(parametryGPU.Integrated?"TAK":"NIE"));
Console.WriteLine("Liczba rejestrów na blok:" +
parametryGPU.RegistersPerBlock);
Console.WriteLine();
}
Console.ReadKey();
}
378 Programowanie równoległe i asynchroniczne w C# 5.0
Rysunek 17.8. Wynik działania programu z listingu 17.10, który wyświetla podstawowe informacje
o karcie graficznej
Obliczenie wartości elementu macierzy wynik jest już bardzo prostą czynnością. Używa-
jąc wyznaczonych wartości indeksów, mnożymy element macierzy A przez odpowiedni
element macierzy B, a wyniki zapisujemy do zmiennej wynik (listing 17.13).
Listing 17.13. Wyznaczenie iloczynu Schura dla elementu macierzy o indeksach xIndex i yIndex
[Cudafy]
public static void iloczynSchura(GThread thread, float[,] macierzA, float[,]
macierzB, float[,] wynik)
{
int xIndex = thread.blockIdx.x * thread.blockDim.x + thread.threadIdx.x;
int yIndex = thread.blockIdx.y * thread.blockDim.y + thread.threadIdx.y;
W efekcie mamy już zaimplementowany, najprościej jak tylko to możliwe, kernel obli-
czający iloczyn Schura dwóch macierzy o elementach typu float. Musimy go teraz
wywołać w funkcji Main. W tym celu najpierw należy utworzyć odpowiednie obiekty
reprezentujące moduł CUDA oraz uchwyt do karty graficznej, tak jak to zrobiliśmy
w programie hello_world. Na listingu 17.14 przedstawiam realizującą to zadanie
funkcję Main.
380 Programowanie równoległe i asynchroniczne w C# 5.0
Console.ReadKey();
}
Operacje na pamięci
globalnej karty graficznej
Zanim jednak pierwszy raz wykonamy nasz kernel, musimy przygotować przykłado-
we dane, a następnie skopiować je do pamięci karty graficznej. W kartach graficznych
mamy do czynienia z kilkoma rodzajami pamięci. Można tu wymienić m.in. pamięć
globalną, pamięć stałą czy współdzieloną. O typach pamięci dostępnej na kartach gra-
ficznych więcej można przeczytać w dokumencie dostarczonym przez firmę NVidia
w formie pliku PDF o nazwie CUDA_C_Programming_Guide.pdf. W naszym programie
na początek do przechowywania macierzy, na których będziemy wykonywać opera-
cje, wykorzystamy pamięć globalną, potem skorzystamy z pamięci współdzielonej
w celu optymalizacji dostępu do danych.
Mając już przygotowane dane, musimy przydzielić pamięć globalną karty graficznej,
w której te dane będziemy przechowywać. W tym celu użyjemy metody Allocate —
składowej obiektu uchwytGPU. Metoda dla typu float[,] pobiera jako argument ma-
cierz, którą chcemy skopiować do pamięci globalnej układu graficznego. Metoda nie
kopiuje jednak danych do pamięci karty. Na jej podstawie ustala ona tylko rozmiar
pamięci, jaki należy dla niej zaalokować. Metoda zwraca tablicę jednoelementową,
która przechowywana jest w pamięci globalnej karty. Nie należy jednak używać jej
w kodzie tak, jak zwykłej tablicy znanej z języka C#. Należy ją traktować raczej jak
wskaźnik do pamięci globalnej karty.
Listing 17.16. Inicjacja tablic w pamięci globalnej układu graficznego oraz wykonanie kernela
iloczynSchura
static void Main(string[] args)
{
CudafyModule modułCuda = CudafyTranslator.Cudafy(eArchitecture.sm_11);
GPGPU uchwytGPU = CudafyHost.GetDevice(eGPUType.Cuda);
uchwytGPU.LoadModule(modułCuda);
//w tym miejscu dodamy kod wykonujący kernel iloczynSchura na przykładowych danych
float[,] A = new float[,] { { 1, 2 }, { 3, 4 } };
float[,] B = new float[,] { { 5, 6 }, { 7, 8 } };
float[,] Wynik = new float[2, 2];
uchwytGPU.CopyToDevice<float>(A, gpu_A);
uchwytGPU.CopyToDevice<float>(B, gpu_B);
Console.ReadKey();
}
Listing 17.17. Kopiowanie danych z pamięci karty graficznej i wyświetlenie ich na monitorze komputera
static void Main(string[] args)
{
CudafyModule modułCuda = CudafyTranslator.Cudafy(eArchitecture.sm_11);
GPGPU uchwytGPU = CudafyHost.GetDevice(eGPUType.Cuda);
uchwytGPU.LoadModule(modułCuda);
//w tym miejscu dodamy kod wykonujący kernel iloczynSchura na przykładowych danych
float[,] A = new float[,] { { 1, 2 }, { 3, 4 } };
float[,] B = new float[,] { { 5, 6 }, { 7, 8 } };
float[,] Wynik = new float[2, 2];
uchwytGPU.CopyToDevice<float>(A, gpu_A);
uchwytGPU.CopyToDevice<float>(B, gpu_B);
uchwytGPU.CopyFromDevice(gpu_Wynik, Wynik);
Console.WriteLine(Wynik[0, 0] + " " + Wynik[0, 1]);
Console.WriteLine(Wynik[1, 0] + " " + Wynik[1, 1]);
uchwytGPU.Free(gpu_A);
uchwytGPU.Free(gpu_B);
uchwytGPU.Free(gpu_Wynik);
Console.ReadKey();
}
Możemy teraz ponownie uruchomić program i sprawdzić, czy otrzymane wyniki (ry-
sunek 17.9) zgadzają się z tymi z podrozdziału „Przekazywanie parametrów do kerneli”.
Wyniki powinny być dokładnie takie same, co oznacza, że prawidłowo zaimplemen-
towaliśmy i użyliśmy kernela iloczynSchura.
Rysunek 17.9.
Program Iloczyn_Schura
w działaniu
Rozdział 17. CUDA w .NET 383
Listing 17.18. Modyfikacje mające na celu wykonywanie obliczeń na macierzach o dużych rozmiarach
static void Main(string[] args)
{
CudafyModule modułCuda = CudafyTranslator.Cudafy(eArchitecture.sm_11);
GPGPU uchwytGPU = CudafyHost.GetDevice(eGPUType.Cuda);
uchwytGPU.LoadModule(modułCuda);
//w tym miejscu dodamy kod wykonujący kernel iloczynSchura na przykładowych danych
float[,] A = new float[liczbaDanychWjednymWymiarze, liczbaDanychWjednymWymiarze];
float[,] B = new float[liczbaDanychWjednymWymiarze, liczbaDanychWjednymWymiarze];
float[,] Wynik = new float[liczbaDanychWjednymWymiarze, liczbaDanychWjednymWymiarze];
InicjacjaTablic(A, B);
uchwytGPU.CopyToDevice<float>(A, gpu_A);
uchwytGPU.CopyToDevice<float>(B, gpu_B);
uchwytGPU.CopyFromDevice(gpu_Wynik, Wynik);
Console.WriteLine("Macierz A:");
WyświetlElementy(A);
Console.WriteLine("Macierz B:");
WyświetlElementy(B);
Console.WriteLine("Wynik obliczeń:");
WyświetlElementy(Wynik);
uchwytGPU.Free(gpu_A);
uchwytGPU.Free(gpu_B);
uchwytGPU.Free(gpu_Wynik);
Console.ReadKey();
}
private static void WyświetlElementy(float[,] macierz)
{
Console.WriteLine(macierz[0, 0] + " " + macierz[0, 1] + " " + macierz[0, 2]);
Console.WriteLine(macierz[1, 0] + " " + macierz[1, 1] + " " + macierz[1, 2]);
Console.WriteLine(macierz[2, 0] + " " + macierz[2, 1] + " " + macierz[2, 2]);
}
static public void InicjacjaTablic(float[,] A, float[,] B)
{
for (int i=0; i<A.GetLongLength(0); i++)
for (int j = 0; j < A.GetLongLength(1); j++)
{
A[i, j] = B[i, j] = i + j;
}
}
Następnie dodałem do funkcji Main instrukcje mające na celu wyznaczenie czasu wy-
konywania kernela iloczynSchura. Biblioteka CUDAfy.NET umożliwia pomiar tego
czasu w bardzo łatwy sposób. Dostarcza metody StartTimer i StopTimer składowych
klasy GPGPU. Pierwsza z nich uruchamia pomiar czasu, a druga zwraca czas, jaki upłynął
pomiędzy wywołaniami tych dwóch metod. Aby jednak czas wykonania został po-
prawnie zmierzony, należy zsynchronizować wszystkie wątki, których używamy do
obliczeń tuż przed zatrzymaniem pomiaru czasu. Służy do tego metoda Synchronize,
również składowa klasy GPGPU. Instrukcje służące do pomiaru czasu wykonania ker-
nela zostały wyróżnione na listingu 17.19.
Listing 17.19. Modyfikacje programu z listingu 17.18 umożliwiające pomiar czasu wykonania kernela
oraz jego porównanie z czasem wykonywania takich samych obliczeń, ale na CPU
static void Main(string[] args)
{
CudafyModule modułCuda = CudafyTranslator.Cudafy(eArchitecture.sm_11);
GPGPU uchwytGPU = CudafyHost.GetDevice(eGPUType.Cuda);
uchwytGPU.LoadModule(modułCuda);
Rozdział 17. CUDA w .NET 385
//w tym miejscu dodamy kod wykonujący kernel iloczynSchura na przykładowych danych
float[,] A = new float[liczbaDanychWjednymWymiarze, liczbaDanychWjednymWymiarze];
float[,] B = new float[liczbaDanychWjednymWymiarze, liczbaDanychWjednymWymiarze];
float[,] Wynik = new float[liczbaDanychWjednymWymiarze, liczbaDanychWjednymWymiarze];
InicjacjaTablic(A, B);
uchwytGPU.CopyToDevice<float>(A, gpu_A);
uchwytGPU.CopyToDevice<float>(B, gpu_B);
uchwytGPU.StartTimer();
startKernel.iloczynSchura(gpu_A, gpu_B, gpu_Wynik);
uchwytGPU.Synchronize();
float upłynęłoCzasu = uchwytGPU.StopTimer();
uchwytGPU.CopyFromDevice(gpu_Wynik, Wynik);
Console.WriteLine("Macierz A:");
WyświetlElementy(A);
Console.WriteLine("Macierz B:");
WyświetlElementy(B);
Console.WriteLine("Wynik obliczeń na GPU:");
WyświetlElementy(Wynik);
Console.WriteLine();
Console.WriteLine("Czas wykonania kernela: {0} ms", upłynęłoCzasu);
uchwytGPU.Free(gpu_A);
uchwytGPU.Free(gpu_B);
uchwytGPU.Free(gpu_Wynik);
Console.WriteLine();
Console.WriteLine("Przyśpieszenie obliczeń w stosunku do CPU: {0} ",
ts.TotalMilliseconds / upłynęłoCzasu);
Console.ReadKey();
}
386 Programowanie równoległe i asynchroniczne w C# 5.0
Wyniki działania programu na moim systemie, który zawiera układ graficzny GeForce
GT 130M z 32 rdzeniami oraz dwurdzeniowy procesor Intel Core 2 Duo P8600 2.40GHz,
przedstawione są na rysunku 17.10. Jak widać, przyśpieszenie obliczeń wykonywanych
na GPU w stosunku do obliczeń wykonywanych na CPU jest około trzykrotne. Wyko-
nywane tu obliczenia nie są skomplikowane, dlatego przyśpieszenie nie jest zbyt duże.
Poza tym karta graficzna, którą posiadam, jest układem przeznaczonym do laptopów.
Przyśpieszenie obliczeń, jakie można z jej pomocą uzyskać, jest — siłą rzeczy —
ograniczone. Z drugiej strony, obliczenia na CPU wykonywane są tylko na jednym
rdzeniu. Czas, jaki został wyznaczony dla jednego rdzenia, można ekstrapolować na
wiele rdzeni, dzieląc go przez ich ilość. Wartość, jaką w ten sposób otrzymamy, bę-
dzie ograniczeniem z dołu czasu wykonywania obliczeń. Czas ten jest — oczywiście
— tylko wartością teoretyczną i w praktyce bardzo rzadko osiąganą. Warto zauważyć,
że szacując czas wykonywania obliczeń dla np. czterech rdzeni CPU, nie uzyskujemy
żadnego przyśpieszenia. Wręcz przeciwnie — program będzie wykonywał się wolniej na
GPU niż na CPU!
Rysunek 17.10.
Wyniki działania programu
z listingu 17.19. Zależą one
mocno od posiadanego
sprzętu, a więc zapewne
większość czytelników
będzie miała zupełnie inne
wyniki niż przedstawione
na tym rysunku
Listing 17.20. Kernel z listingu 17.13 skonwertowany do języka C for CUDA. Poniższy kod znajduje
się w pliku CUDAFYSOURCETEMP.cu
// Iloczyn_Schura.Program
extern "C" __global__ void iloczynSchura(float* macierzA, int macierzALen0, int
macierzALen1, float* macierzB, int macierzBLen0, int macierzBLen1, float* wynik,
int wynikLen0, int wynikLen1);
// Iloczyn_Schura.Program
extern "C" __global__ void iloczynSchura(float* macierzA, int macierzALen0, int
macierzALen1, float* macierzB, int macierzBLen0, int macierzBLen1, float* wynik,
int wynikLen0, int wynikLen1)
{
int num = blockIdx.x * blockDim.x + threadIdx.x;
int num2 = blockIdx.y * blockDim.y + threadIdx.y;
wynik[(num) * wynikLen1 + ( num2)] = macierzA[(num) * macierzALen1 + ( num2)] *
macierzB[(num) * macierzBLen1 + ( num2)];
}
11
O dostępie zwartym do pamięci oraz innych optymalizacjach, które znacznie przyśpieszają obliczenia,
można przeczytać w dokumencie CUDA C BEST PRACTICES GUIDE dostarczonym przez firmę
NVidia razem z SDK.
388 Programowanie równoległe i asynchroniczne w C# 5.0
Z początku wszystko może wydawać się dość trudne, ale zapoznanie się z kodem
kernela iloczynSchuraPamiecWspoldzielona przedstawionym na listingu 17.21 po-
winno ułatwić zrozumienie problemu. Do alokacji pamięci współdzielonej używam
metody AllocateShared, składowej klasy GThread (listing 17.21). Przyjmuje ona trzy
argumenty: nazwę bufora (w naszym przypadku macierzy) oraz jego rozmiary (dwie
wartości, ponieważ bufor jest macierzą). Rozmiar pamięci współdzielonej musi być
znany już w momencie kompilacji programu. Z tego powodu zmieniłem definicję
zmiennej liczbaWątkówWjednymWymiarze na const int liczbaWątkówWjednymWymiarze
= 16;. Przeniosłem ją również poza funkcję Main tak, aby była dostępna także w kernelu.
Warto zauważyć, że zamieniłem miejscami indeksy yIndex oraz xIndex. Teraz yIndex
indeksuje wiersze, a xIndex kolumny. Dzięki temu wątki o kolejnych numerach identyfi-
kowanych przez threadIdx.x i tym samym threadIdx.y będą wyznaczały wiersze ma-
cierzy wynik. Wszystkie powyższe modyfikacje powodują, że kernel iloczynSchuraPamiec
Wspoldzielona wykorzystuje dostęp zwarty do pamięci globalnej komputera. Przeko-
namy się o tym, wywołując nowy kernel i sprawdzając, o ile szybciej wykonuje obliczenia
(listing 17.22).
Listing 17.22. Modyfikacje, jakie należy wykonać w programie, aby uruchomić kernel
iloczynSchuraPamiecWspoldzielona i sprawdzić czas jego wykonania. Na listingu przedstawione
zostały tylko fragmenty programu, które uległy zmianie
class Program
{
const int liczbaWątkówWjednymWymiarze = 16;
uchwytGPU.StartTimer();
startKernel.iloczynSchuraPamiecWspoldzielona(gpu_A, gpu_B, gpu_Wynik);
uchwytGPU.Synchronize();
upłynęłoCzasu = uchwytGPU.StopTimer();
WyświetlElementy(Wynik);
Console.WriteLine();
Console.WriteLine("Czas wykonania kernela (wyk. pamięci współdzielonej):
{0} ms", upłynęłoCzasu);
uchwytGPU.Free(gpu_A);
...
}
}
Rysunek 17.11.
Wyniki działania
programu z listingu 17.22.
Czerwonymi kwadratami
oznaczyłem najważniejsze
wyniki
Powyższy przykład pokazuje, jak ważne jest dbanie o prawidłowy dostęp do pamięci
globalnej karty graficznej. W porównaniu ze standardowymi obliczeniami wykony-
wanymi za pomocą CPU, tego typu problemy wymuszają na programiście znacznie
lepszą znajomość sprzętu, na którym wykonywane są obliczenia.
390 Programowanie równoległe i asynchroniczne w C# 5.0
Kolejnym krokiem jest ustawienie ziarna. Ziarno to wartość określająca stan począt-
kowy generatora. Dla tej samej wartości ziarna wygenerowane liczby pseudolosowe są
takie same. Ziarno możemy ustawić za pomocą metody generujLiczby.SetPseudo
RandomGeneratorSeed. Ustawiłem je na 30000.
Następnym krokiem jest utworzenie tablic typu float, w których będziemy przecho-
wywać wygenerowane liczby. Do nich zapiszemy liczby wygenerowane metodą
generujLiczby.GenerateUniform. Generuje ona liczby z przedziału [0, 1]. Oprócz niej
dostępne są także inne metody, które umożliwiają generowanie liczb pseudolosowych
np. o typie całkowitym (metoda Generate), czy też posiadające rozkład normalny
(metoda GenerateNormal). Na koniec pozostaje już tylko skopiować dane z karty graficz-
nej do pamięci komputera i wyświetlić je na monitorze komputera (rysunek 17.12).
Cały kod programu cudafy_rand przedstawiony jest na listingu 17.23. Warto wspo-
mnieć, że jeśli w metodzie Create ostatniemu parametrowi nadamy wartość true, a więc
chcemy, aby dane były automatycznie skopiowane do pamięci komputera, do metody
generujLiczby.GenerateUniform w pierwszym argumencie należy przekazać tablicę,
która zaalokowana jest w pamięci RAM komputera.
Listing 17.23. Program cudafy_rand generujący dziesięć liczb pseudolosowych dla ziarna równego
30000 i wyświetlający je na monitorze komputera
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Cudafy;
using Cudafy.Host;
using Cudafy.Translator;
using Cudafy.Maths.RAND;
namespace cudafy_rand
{
class Program
{
const ulong ziarno = 30000;
const int ilośćElementów = 10;
uchwytGPU.Free(tablicaLiczbGPU);
Console.ReadKey();
}
FFT na GPU
Oprócz biblioteki cuRAND NVidia dostarcza także bibliotekę cuFFT, która umożli-
wia wykonywanie szybkiej transformaty Fouriera (FFT) z wykorzystaniem GPU. FFT
jest operacją bardzo często używaną w nauce, inżynierii czy obróbce grafiki. Biblioteka
CUDAfy.NET także umożliwia wykorzystanie cuFFT w programach pisanych w C#.
W tym podrozdziale pokażę, jak można to wykonać, modyfikując program cudafy_rand.
Listing 17.24. Obliczenie szybkiej transformaty Fouriera na danych wygenerowanych przy użyciu
generatora liczb pseudolosowych. Zmieniona została również liczba generowanych danych na 32
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
12
Zob. http://docs.nvidia.com/cuda/cufft/index.html#topic_4_8.
Rozdział 17. CUDA w .NET 393
using Cudafy;
using Cudafy.Host;
using Cudafy.Translator;
using Cudafy.Maths.RAND;
using Cudafy.Maths.FFT;
using Cudafy.Types;
namespace cudafy_rand
{
class Program
{
const ulong ziarno = 30000;
const int ilośćElementów = 32;
uchwytGPU.Free(tablicaLiczbGPU);
Console.ReadKey();
}
BLAS
BLAS to skrót od Basic Linear Algebra Subprograms. BLAS jest biblioteką służącą
do wykonywania operacji algebraicznych na macierzach i wektorach. Powstało wiele
bibliotek implementujących funkcjonalność BLAS w przeróżnych językach progra-
mowania. NVidia dostarcza bibliotekę cuBLAS13, która również implementuje funk-
cjonalność swojego pierwowzoru. Nie będę opisywał wszystkich operacji, jakie moż-
na wykonać za pomocą tej biblioteki, pokażę tylko, w jaki sposób można ją wykorzystać
w programie C# za pomocą biblioteki CUDAfy.NET. Zmodyfikujemy w tym celu
program z listingu 17.24.
Listing 17.25. Wyznaczanie iloczynu skalarnego dwóch wektorów za pomocą metody DOT z biblioteki
cuBLAS przy użyciu wrappera z biblioteki CUDAfy.NET
static void Main(string[] args)
{
...
uchwytGPU.CopyFromDevice(fftGPU, fftCPU);
WyświetlWartości(fftCPU);
uchwytGPU.Free(tablicaLiczbGPU);
...
}
13
Dokumentacja biblioteki jest dostępna pod adresem http://docs.nvidia.com/cuda/cublas/index.html.
Rozdział 17. CUDA w .NET 395
Zadania
1. Napisz kernel, który na dwóch wektorach o rozmiarze 1000 przechowywanych
w postaci tablicy wykonuje operację o następującej formule Wi=Xi+a*Yi.
Parametr a jest liczbą, przez którą mnożone są składowe wektora Y. Elementy
wektorów są typu float.
2. Wygeneruj tablicę 100 liczb pseudolosowych wszystkimi dostępnymi
metodami. Sprawdź, jaki jest czas generowania liczb dla każdej z nich.
3. W jednym z poprzednich rozdziałów tworzyliśmy aplikację, która wyznaczała
wartość liczby π metodą Monte Carlo. Utwórz aplikację, która tą samą metodą
wyznaczy wartość liczby π, wykonując obliczenia na karcie graficznej.
4. Utwórz tablicę dwuwymiarową składającą się z liczb pseudolosowych
wygenerowanych metodą CURAND_RNG_QUASI_SOBOL32. Następnie oblicz
dla nich szybką transformatę Fouriera 2D.
5. Wykorzystując metody z przestrzeni Cudafy.Maths.BLAS, oblicz normę
euklidesową wektora W z zadania 1.
396 Programowanie równoległe i asynchroniczne w C# 5.0
Dodatek A
Biblioteka TPL w WinRT
Mateusz Warczak
W roku 2012 miało miejsce oficjalne wydanie 8. wersji systemu Windows. W syste-
mie tym pojawiła się kolejna platforma uruchomieniowa z rodziny .NET — WinRT.
Związana jest z nowym ekranem Start, który zastąpił znane z poprzednich wersji
systemu menu Start oraz z aplikacjami z interfejsem Modern UI. Ponieważ aplikacje
te mogą być dystrybuowane jedynie poprzez sklep Windows Store, w Visual Studio
nazywane są po prostu aplikacjami Windows Store. Warto wspomnieć, że równocześnie
z Windows 8 wydane zostało Windows RT. Jest to wersja tego systemu przeznaczona
przede wszystkim na tablety z ekranem dotykowym, w której w ogóle nie ma możliwości
uruchamiania zwykłych aplikacji „na pulpit”, a pracować można jedynie z aplikacjami
dla nowej platformy WinRT.
Aplikacje tworzone w środowisku WinRT (skrót od Windows Runtime) stanowią od-
rębną rodzinę oprogramowania, w porównaniu z aplikacjami .NET. Rdzeniem tej
platformy jest jednak pakiet .NET for Windows Store, którego zawartość pokrywa się
w znacznym stopniu z podstawową funkcjonalnością .NET. Obie platformy mają wiele
wspólnego. Tworzenie aplikacji dla WinRT jest bardzo podobne do tworzenia aplikacji
WPF. W obu przypadkach budowanie interfejsu aplikacji opiera się na definiowaniu
jej wyglądu w języku XAML, podobne są również służący do tego zestawy kontrolek
oraz biblioteki klas. W obu platformach dostępna jest także biblioteka TPL. Celem
tego dodatku jest przedstawienie różnic w procesie tworzenia aplikacji równoległych
z wykorzystaniem biblioteki TPL pomiędzy aplikacjami WinRT a aplikacjami .NET.
Mówiąc o aplikacjach WinRT, mamy na myśli — oczywiście — aplikacje z interfejsem
graficznym. A ponieważ większość programów w książce to aplikacje konsolowe, na
potrzeby tego dodatku zostały one zmodyfikowane: w głównym oknie aplikacji (czy
raczej na stronie — w aplikacjach WinRT głównym elementem interfejsu jest strona
— Page) umieszczono pole tekstowe wyświetlające informacje, które we wcześniej
wersji wyświetlane były w konsoli. Jest to rozwiązanie najprostsze, ale pozwala unik-
nąć zbyt daleko idących zmian w kodzie. Dzięki temu porównanie użycia TPL będzie
łatwiejsze. Aby komunikaty wyświetlane były poprawnie, zastosowano synchroni-
zację z wykorzystaniem klasy TaskScheduler opisaną w rozdziale 101. Do prezentowania
1
Zagadnienia dotyczące synchronizacji interfejsu opisane w rozdziałach 5. i 10. mają zastosowanie
również w aplikacji WinRT.
398 Programowanie równoległe i asynchroniczne w C# 5.0
Zadania
Task to podstawowa klasa biblioteki TPL. Przykład prezentujący tworzenie zadań zo-
stanie wobec tego zaprezentowany jako pierwszy. Na listingu A.2 przedstawiono program
z rozdziału 6. (listing 6.1) przeniesiony do środowiska WinRT.
Jak widać, powyższy kod nie różni się niczym od pierwowzoru w kwestii pracy z za-
daniami (czyli obiektami klasy Task). Pewnie różnice w kodzie jednak się pojawiły —
zostały wyróżnione na listingu. Pierwsza z nich dotyczy wywołania metody Task.Delay
zamiast Thread.SpinWait. Wykorzystanie tej ostatniej nie jest możliwe, ponieważ w śro-
dowisku WinRT w ogóle nie ma klasy Thread. Usunięcie bezpośredniego dostępu do
wątków ma wymusić na programistach korzystanie z zadań. Mechanizmy poboczne,
jak choćby metody SpinWait czy Sleep, zastąpione zostały innymi narzędziami, które
zostały opisane w tym dodatku.
Druga różnica wyróżniona na listingu A.2 dotyczy nie tyle biblioteki TPL, co klasy
List<>. W WinRT usunięto z niej metodę ForEach. W związku z tym, aby uzyskać
dostęp do elementów listy, należy skorzystać ze zwykłej pętli2.
W dokumentacji MSDN znaleźć można informację o tym, które elementy klas i struk-
tur dostępne są na poszczególnych platformach (.NET, XNA i w środowisku WinRT).
Na rysunku A.1 przedstawiono fragment strony MSDN z metodami klasy List<T>.
Interesujące nas infromacje znajdują się w pierwszej kolumnie. Obok oznaczenia
metody (fioletowa ikona) kolejne ikony oznaczają: środowisko XNA, Portable Class
Library (czyli podzbiór składników .NET obecnych we wszystkich środowiskach mobil-
nych Microsoft: Silverlight, Windows Phone i Xbox 360) oraz WinRT (opisane jako
.NET for Windows Store apps). Podobne adnotacje znajdują się przy klasach, kon-
struktorach itd. (rysunek A.1).
2
Można by też skorzystać z PLINQ i metody ForAll (przykład zastosowania tej metody przedstawiono
na listingu 9.17).
400 Programowanie równoległe i asynchroniczne w C# 5.0
Struktura SpinWait
Jak wspomniano, w WinRT nie jest dostępna klasa Thread, a tym samym jej metoda
SpinWait, która wykonywała puste obliczenia dla określonej liczby iteracji. W .NET
4.5 wprowadzono jednak strukturę SpinWait, która ma spełniać to samo zadanie. Oferuje
ona kilka metod, pośród których znajduje się SpinOnce. Metoda wykonuje jeden cykl
obliczeń, co jest równoważne wywołaniu metody z klasy Thread z argumentem równym
1. Jej działanie jednak jest nieco bardziej złożone. Instancja SpinWait przechowuje
licznik wywołań metody SpinOnce, który ma wpływ na jej działanie. Dla większych
wartości licznika wątek może być usypiany. Gdybyśmy chcieli zaimplementować
własny odpowiednik metody SpinWait z klasy Thread, należy ten licznik resetować,
aby uniknąć usypiania. Taką metodę zaprezentowano na listingu A.3.
Usypianie wątków
Drugą metodą statyczną klasy Thread, która pozwala na wstrzymanie pracy wątku, jest
metoda Sleep. Różnica w jej działaniu względem SpinWait polega na tym, że wątek fak-
tycznie jest wstrzymywany i przechodzi w stan uśpienia na określoną ilość milisekund.
Ze względu na brak klasy Thread w WinRT, do klasy Task dodano metodę Delay, która,
podobnie jak pierwowzór, przyjmuje jako argument ilość czasu, na jaką wątek ma zostać
uśpiony. Jej działanie polega jednak nie na uśpieniu bieżącego wątku, a na utworzeniu
nowego zadania, które zostanie zakończone po upływie określonego czasu. Aby za-
tem odtworzyć funkcjonalność metody Sleep z klasy Thread, należy za pomocą metody
Wait wstrzymać bieżący wątek, aż do zakończenia tego nowego zadania (listing A.4).
Pula wątków
Skoro w WinRT nie mamy dostępu do klasy Thread, nasuwa się pytanie, co z klasą
ThreadPool? Wiadomo, że zadania, także dostępne w WinRT, swoje działanie opierają
na puli wątków. Nie oznacza to jednak, że jest to ta sama klasa, co w zwykłej plat-
formie .NET. Klasa ThreadPool jest dostępna w WinRT, ale jest to zupełnie inna klasa,
znajdująca się w zupełnie innej przestrzeni nazw Windows.System.Threading3. Jednak
przewrotnie służy do tego samego.
//tworzenie wątków
WorkItemHandler metodaWatku = uruchamianieObliczenPi;
do
{}
while (ileDzialajacychWatkowPuli > 0);
pi /= ileWatkow;
msg(String.Format("Wszystkie wątki zakończyły działanie.\nUśrednione Pi={0},
błąd={1}", pi, Math.Abs(Math.PI - pi)));
3
Podobnie jak cała przestrzeń Windows, jest ona dostępna jedynie w aplikacjach WinRT. Przestrzeni
Windows nie należy mylić z System.Windows dostępnej np. w WPF.
402 Programowanie równoległe i asynchroniczne w C# 5.0
ThreadPoolTimer
Kolejne klasy, których nie znajdziemy w bibliotekach WinRT, to opisywana w roz-
dziale 2. klasa Timer oraz klasa DispatcherTimer, które służą do definiowania cyklicz-
nie wykonywanych czynności. Zastąpić je ma klasa ThreadPoolTimer znajdująca się, po-
dobnie jak ThreadPool, w przestrzeni Windows.System.Threading. Na listingu A.6
przedstawiono kod programu z listingu 2.17 przeniesiony do środowiska WinRT. Pomi-
nięta została najważniejsza jego część, czyli obliczenia wykonywane w wątkach. Moim
celem było jednak zaprezentowanie nowego sposobu definiowania licznika, a nie pełne
przeniesienie funkcjonalności.
timer = ThreadPoolTimer.CreatePeriodicTimer(
(source) =>
{
msg("Ilość prób: " +
Interlocked.Read(ref całkowitaIlośćPrób).ToString() +
"/" + (ileWatkow * ilośćPróbWWątku).ToString());
},
TimeSpan.FromMilliseconds(1000)
);
Jak widać na listingu A.6, obiekt nie powstaje za pomocą konstruktora, jak miało to
miejsce w przypadku klasy Timer, a przy użyciu statycznej metody ThreadPoolTimer.
CreatePeriodicTimer, do której przekazujemy dwa argumenty: cyklicznie wywoływa-
ną akcję oraz okres rozdzielający kolejne jej uruchomienia (wartość typu TimeSpan).
Tak utworzony licznik jest automatycznie uruchamiany w momencie utworzenia. Nie
trzeba więc wywoływać metody Start. Metodzie Close z klasy Timer, kończącej pra-
cę licznika, odpowiada w tym przypadku metoda Cancel.
Dodatek A Biblioteka TPL w WinRT 403
Podobieństwa
Na koniec warto zaznaczyć dwa ważne podobieństwa między platformą .NET a plat-
formą WinRT. Po pierwsze, w WinRT mamy do dyspozycji klasę Parallel, a szcze-
gólnie jej metodę For. Programy z rozdziału 7. mogą być zatem przenoszone do WinRT
bez większych modyfikacji. I po drugie, w WinRT obecny jest również mechanizm
async/await.
Rysunek A.2.
Elementy interfejsu
aplikacji Windows
Store obliczającej
przybliżenie liczby π
tbError.Text = "";
try
{
n = Int32.Parse(tbDana.Text);
if (n < 1) throw new OverflowException();
}
catch
{
tbError.Text = "Wprowadź poprawną liczbę!";
return;
}
bOblicz.IsEnabled = false;
Task<double> t = Task<double>.Factory.StartNew(
(n2) => ObliczPiRownolegle((int)n2),
n
404 Programowanie równoległe i asynchroniczne w C# 5.0
);
Przenośna biblioteka
„Zwykła” platforma .NET i platforma WinRT mają wiele elementów wspólnych. Bi-
blioteka TPL jest najlepszym tego przykładem. Platformy te różnią się jednak na tyle,
że ich skompilowane pliki wykonywalne i biblioteki DLL nie mogą być między nimi
przenoszone. W zamian w Visual Studio od wersji 2012 dostępny jest projekt typu
Portable Class Library, czyli przenaszalna biblioteka DLL. Obejmuje ona nie tylko
dwie wymienione platformy, ale również Silverlight, Windows Phone czy platformę
.NET na konsoli Xbox 360. Możliwość zbudowania takiej biblioteki pozwala na two-
rzenie kodu współdzielonego przez aplikacje dla różnych platform. Może to być także
kod korzystający z równoległości oferowanej przez TPL. Biblioteka TPL nie jest jed-
nak dostępna dla wszystkich składników tego ekosystemu. Obok aplikacji .NET Fra-
mework w wersji od 4.0 i Windows Store, TPL dostępna jest również w platformach
Silverlight, ale tylko w wersji 5., oraz Windows Phone od wersji 8. Na rysunku A.3
pokazano okno wyboru platform docelowych wyświetlane w momencie tworzenia bi-
blioteki typu Portable Class Library.
Rysunek A.3.
Wybór platform podczas
tworzenia biblioteki typu
Portable Class Library
korzystającej z TPL
Tworzona przez nas przykładowa biblioteka składać się będzie tylko z jednej klasy
statycznej o nazwie Zadania, która posiadać będzie metodę statyczną ObliczPi. Działanie
tej metody oparte jest na metodzie z listingu 9.6.
namespace Biblioteka
{
public static class Zadania
{
private static double Ciag(int i)
{
if(i%2==1)
return -1.0 / ((double)i * 2 + 1);
return 1.0/((double)i * 2 + 1);
}
public static Task<double> ObliczPi(int zakres)
{
return Task<double>.Factory.StartNew((_zakres) =>
{
double wynik = 4 * new ConcurrentBag<int>(Enumerable.Range(0,
(int)_zakres)).AsParallel().Aggregate(
0.0,
(suma, i) => suma + Ciag(i),
(suma1, suma2) => suma1 + suma2,
(suma) => suma
);
return wynik;
},
zakres);
}
}
}
Jak widać na listingu A.8, oferowana w bibliotece metoda zwraca zadanie, co pozwoli
na jej asynchroniczne wykonanie. Niezależnie od tego, czy zostanie ona wykorzysta-
na w aplikacji WPF, czy WinRT, efektem uruchomienia kodu przedstawionego na li-
stingu A.9 będzie prawidłowe obliczenie liczby oraz zapisanie wyniku do kontrolki
typu TextBox.
***
406 Programowanie równoległe i asynchroniczne w C# 5.0
Zadania
1. Przeanalizuj pracę wybranego programu z tego dodatku z wykorzystaniem
narzędzia ConcurrencyVisualiser oraz dokonaj obserwacji w oknie wątków
(rozdział 11.).
2. Zaimplementuj poniższe przykłady jako aplikacje Windows Store:
a) Program z listingu 8.1 korzystający z operatora synchronizacji.
b) Program z listingu 9.2 korzystający z kolekcji współbieżnych.
4
Usunięta została jedynie klasa ReaderWriterLock na rzecz klasy ReaderWriterLockSlim.
Dodatek B
Dobre praktyki
programowania aplikacji
wielowątkowych
Dawid Borycki
Wprowadzenie
Celem tego dodatku jest wykorzystanie informacji zebranych w poprzednich roz-
działach, by przedstawić dobre praktyki, które powinny być stosowane podczas im-
plementacji aplikacji wielowątkowych. Lista tych praktyk, oprócz informacji opisanych
poprzednio (np. dotyczących metody Abort obiektu Thread), poparta przykładowymi
aplikacjami, powinna ułatwić czytelnikom implementację aplikacji równoległych oraz
uniknąć typowych usterek pojawiających się podczas ich tworzenia.
namespace DobrePraktyki
{
public partial class Form1 : Form
{
string _wspoldzielonyZasob = "Współdzielony zasób";
const string _watekSekcjeKrytyczneNieJestAktywny =
"Wątek SekcjeKrytyczne nie jest aktywny";
const string _watekSekcjeKrytyczneAktywny =
"Wątek SekcjeKrytyczne jest aktywny";
public Form1()
{
InitializeComponent();
KonfigurujWatekSekcjeKrytyczne();
toolStripWatekSekcjeKrytyczne.Text =
_watekSekcjeKrytyczneNieJestAktywny;
}
{
Monitor.Enter(_wspoldzielonyZasob);
while (!backgroundWorkerSekcjeKrytyczne.CancellationPending)
{
// Blokuj dostęp do pola _wspoldzielonyZasob
}
Monitor.Exit(_wspoldzielonyZasob);
}
Monitor.Exit(_wspoldzielonyZasob);
}
}
}
ście — nie nastąpi. Z tego powodu aplikacja stwarza wrażenie zawieszonej, a jedynym
ratunkiem jest przerwanie jej działania.
if (Monitor.IsEntered(_wspoldzielonyZasob))
{
Monitor.Exit(_wspoldzielonyZasob);
}
}
else
{
MessageBox.Show("Przekroczono limit czasu oczekiwania na
zwolnienie współdzielonego zasobu");
}
}
Wyścig
Zjawisko wyścigu w aplikacjach wielowątkowych jest definiowane jako zależność
wyniku aplikacji od tego, który z wątków uzyskujących dostęp do współdzielonego
zasobu wykona swoją funkcję jako pierwszy. Innymi słowy, wynik aplikacji zależy od
tego, który z wątków uzyska czas procesora jako pierwszy. W takiej sytuacji wynik
aplikacji jest nieprzewidywalny i z tego powodu wyścig uznawany jest za błąd.
Listing B.3. Zmiana wartości zapisanej w polu _wspolnyZasob z poziomu wielu wątków
private int _wspolnyZasob;
private void buttonInkrementujWspolnyZasob_Click(object sender, EventArgs e)
{
_wspolnyZasob = 0;
listBoxWyniki.Items.Add(_wspolnyZasob.ToString());
}
Rysunek B.1.
Wynik działania aplikacji
tylko w dwóch przypadkach
był prawidłowy
Zasada działania powyższej aplikacji znowu nie jest skomplikowana — jej celem jest
utworzenie czterystu wątków, z których każdy inkrementuje wartość zapisaną w polu
_wspolnyZasob. Wynikiem działania aplikacji powinno być wyświetlenie liczby 400.
Jednakże, ze względu na efekt wyścigu, tylko w ok. 20% uzyskaliśmy poprawny wynik.
Pole _wspolnyZasob jest współdzielone pomiędzy wątkami i jak wynika z powyższe-
go przykładu dostęp do niego powinien być atomowy.
Listing B.4. Operacja inkrementacji wartości zapisanej w polu _wspolnyZasob realizowana jest
w sposób atomowy
private void AktualizujWspoldzielonePole()
{
_wspolnyZasob++;
Interlocked.Increment(ref _wspolnyZasob);
}
Rysunek B.2.
Wynik działania aplikacji
jest teraz poprawny we
wszystkich przypadkach
Listing B.5. Założenie, że wątki robocze uzyskują czas procesora według kolejności ich uruchomienia
nie jest poprawne
private int _wspolnyZasob;
ManualResetEventSlim _sygnalStartowy = new ManualResetEventSlim(false);
private void buttonInkrementujWspolnyZasob_Click(object sender, EventArgs e)
{
_wspolnyZasob = 0;
listBoxWyniki.Items.Add(_wspolnyZasob.ToString());
}
_wspolnyZasob = numerWatku;
}
Rysunek B.3.
Na skutek wyścigu wątków
w celu uzyskania czasu
procesora wynik aplikacji
jest prawidłowy jedynie
w dwóch przypadkach
_sygnalStartowy.Reset();
{
watki[i].Join(msTimeOut);
}
listBoxWyniki.Items.Add(_wspolnyZasob.ToString());
}
if (_sygnaly[numerWatku].WaitOne(msTimeOut))
{
_wspolnyZasob = numerWatku;
}
else
{
Debug.WriteLine("Przekroczono limit oczekiwania
na sygnalizację zdarzenia");
}
Rysunek B.4.
Synchronizacja pracy
wątków pozwala
uniknąć negatywnych
skutków wyścigu
{
Thread thread = new Thread(ThreadFunc);
_watekAktywny = true;
thread.IsBackground = true;
thread.Start();
Thread.Sleep(1000);
_watekAktywny = false;
while (_watekAktywny)
{
licznik++;
}
AddItemToListBoxTS(listBoxWyniki, licznik);
}
odczekaniu jednej sekundy wartość tego pola zostaje zmieniona na false w ramach
metody buttonUruchomWatek_Click. W takiej sytuacji działanie wątku roboczego po-
winno zostać przerwane, a w komponencie typu ListBox powinna być wyświetlona
aktualna wartość zmiennej licznik.
Jednakże po wykonaniu powyższych kroków czytelnicy przekonają się, że tak się nie
stanie. Wątek będzie wykonywany w nieskończoność. Wynika to z faktu, że kompi-
lator wykonał czynności optymalizacyjne mające na celu usunięcie sprawdzania wa-
runku przerwania pętli while, które wprowadzałoby opóźnienia w realizacji funkcji
wątku roboczego. Kompilator uznał, że skoro w ramach funkcji wątku roboczego war-
tość pola _watekAktywny nie jest modyfikowana, to można pominąć sprawdzanie wa-
runku przerwania pętli while. Spowodowało to — oczywiście — błędne działanie
aplikacji.
Aby poinformować kompilator o tym, że wartość zapisana w polu _watekAktywny mo-
że być zmieniana z poziomu innych wątków i że nie powinien dla niego stosować
procedur optymalizacyjnych, należy deklarację pola _watekAktywny uzupełnić o słowo
kluczowe volatile:
private volatile bool _watekAktywny = false;
Bezpieczeństwo wątków
a konstruktory i pola statyczne
Dyskusję zawartą w tym dodatku uzupełnię o dygresję dotyczącą statycznych ele-
mentów klas. W bibliotece .NET jednymi z obiektów, dla których zaimplementowano
bezpieczeństwo wątków, są statyczne konstruktory klas. Są to obiekty wykorzysty-
wane do inicjacji pól statycznych. Statyczne konstruktory są wywoływane bezpośred-
nio przed utworzeniem instancji klasy lub przed uzyskaniem dostępu do statycznych
właściwości (pól lub metod) klasy. Dzięki temu mogą być również wykorzystywane
do wykonania wszystkich procedur, które wymagają jednorazowego uruchomienia.
Przykładem takiej sytuacji może być projekt klasy, która zapisuje do pliku tekstowego
informacje o przebiegu pracy aplikacji, tzw. logu. W statycznym konstruktorze można
wówczas umieścić procedury otwierające plik logu, co jest operacją jednorazową.
Kolejne instancje klasy będą odwoływały się do tego samego pliku, otwartego w sta-
tycznym konstruktorze. Oczywiście, w takiej sytuacji należy również zadbać o bezpie-
czeństwo wątków, ponieważ plik pełni rolę współdzielonego zasobu.
namespace DobrePraktyki
{
class SampleClass
{
static SampleClass()
{
Debug.WriteLine("Konstruktor został uruchomiony: "
+ DateTime.Now);
Thread.Sleep(1000);
Rysunek B.5.
Funkcje wątków roboczych
uruchamianych z poziomu
statycznych konstruktorów
są blokowane do momentu
zakończenia tworzenia
obiektu
w następujący sposób:
public SampleClass()
Efekt działania tak zmodyfikowanej aplikacji ilustruje rysunek B.6, z którego jasno
wynika, że wykonywanie funkcji wątku roboczego rozpoczęło się w momencie in-
stancjonowania obiektu.
Rysunek B.6.
Funkcje wątków roboczych
uruchamianych z poziomu
konstruktorów instancyjnych
nie są blokowane do
momentu zakończenia
instancjonowania klasy
422 Programowanie równoległe i asynchroniczne w C# 5.0
Dodatek C
Menadżer pakietów
NuGet
Rafał Pawłaszek, Piotr Sybilski
Instalacja NuGet
W Visual Studio 2012, a także w Visual Studio 2010, NuGet instalowany jest do-
myślnie. Najprostszym sposobem sprawdzenia, czy rzeczywiście został zainstalowany,
jest wybranie w menu Visual Studio zakładki Tools. Jeśli w tym menu zobaczymy pozy-
cję Library Package Manager (rysunek C.1), znaczy to, że NuGet znajduje się na liście
zainstalowanych rozszerzeń.
Rysunek C.1. Jeśli w zakładce Tools istnieje wpis Library Package Manager,
znaczy to, że wśród rozszerzeń Visual Studio NuGet jest zainstalowany
Rysunek C.2. Po wpisaniu w polu wyszukiwania frazy „nuget package manager” na liście pakietów
do pobrania pojawi się menedżer NuGet
Dodatek C Menadżer pakietów NuGet 425
Korzystanie z NuGet
Po udanej instalacji i ponownym uruchomieniu Visual Studio można zacząć korzystać
z menedżera pakietów NuGet. Wystarczy na dowolnie utworzonym projekcie nacisnąć
prawy przycisk myszy i z listy wyboru wybrać Manage NuGet Packages... (rysunek C.3).
Rysunek C.3.
Po udanej instalacji pakietu NuGet
wśród opcji do wyboru w menu
kontekstowym rozwiązania pojawia się
Manage NuGet Packages...
426 Programowanie równoległe i asynchroniczne w C# 5.0
Skorowidz
A B
ActiveX, 124 BackgroundWorker, 110, 114
adres bariera, 86, 104, 184
http, 256 Bart de Smet, 346
URL, 256 Base Class Library, Patrz: BCL
agregacja kolekcji równoległych, 199 Basic Linear Algebra Subprograms, Patrz: BLAS
Albahari Joe, 64 bazą danych SQL, 212
algorytm BCL, 303
braci Borwein, 47 bezpieczeństwo, 40, 77, 104, 124, 419
spigot, 47 biblioteka
Apartment Threaded Model, Patrz: ATM Bing Search API, 355
aplikacja BLAS, Patrz: BLAS
desktopowa, 95, 124, 215, 251 CCR, Patrz: CCR
domena, Patrz: domena aplikacji cuBLAS, 394
GitHub, 302 CUDAfy.Net, 376
instancja, 89 CUDAfy.NET, 366
kliencka, 302 Cudafy.NET.dll, 371
konsolowa, 26, 28, 80, 215, 237, 307, 339, 397 cuFFT, 392
przebieg pracy, 419 cuRAND, 390, 392
równoległa, 225 DLL, 404
profiler, 225, 232 DSS, Patrz: DSS
rysująca, 350 Kinect for Windows, 246
sieciowa, 215 klas podstawowa, Patrz: BCL
webowa, 237 kontrolek WPF, 345
wielowątkowa, 40, 187, 189, 407, 411 licencja, 423
Windows Forms, 96, 104, 105 Microsoft Silverlight, 246
Windows Store, 397 Portable Class Library, 307
WinRT, 397 ReactiveCocoa, Patrz: ReactiveCocoa
WPF, 116, 219 Rx, Patrz: Rx
z interfejsem graficznym, 397 System.Data.Services.Client.dll, 357
async method, 16 TPL, Patrz: TPL
ATI Stream, 365 Windows Forms, 96
ATM, 124 Bing, 345, 353, 357
428 Programowanie równoległe i asynchroniczne w C# 5.0