You are on page 1of 436

Wszelkie prawa zastrzeżone.

Nieautoryzowane rozpowszechnianie całości


lub fragmentu niniejszej publikacji w jakiejkolwiek postaci jest zabronione.
Wykonywanie kopii metodą kserograficzną, fotograficzną, a także kopiowanie
książki na nośniku filmowym, magnetycznym lub innym powoduje naruszenie
praw autorskich niniejszej publikacji.

Wszystkie znaki występujące w tekście są zastrzeżonymi znakami firmowymi


bądź towarowymi ich właścicieli.

Autor oraz Wydawnictwo HELION dołożyli wszelkich starań, by zawarte


w tej książce informacje były kompletne i rzetelne. Nie biorą jednak żadnej
odpowiedzialności ani za ich wykorzystanie, ani za związane z tym ewentualne
naruszenie praw patentowych lub autorskich. Autor oraz Wydawnictwo HELION
nie ponoszą również żadnej odpowiedzialności za ewentualne szkody wynikłe
z wykorzystania informacji zawartych w książce.

Redaktor prowadzący: Ewelina Burska


Projekt okładki: Studio Gravite/Olsztyn
Obarek, Pokoński, Pazdrijowski, Zaprucki

Materiały graficzne na okładce zostały wykorzystane za zgodą Shutterstock.

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

Copyright © Helion 2014

Printed in Poland.

 Poleć książkę na Facebook.com  Księgarnia internetowa

 Kup w wersji papierowej  Lubię to! » Nasza społeczność

 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

Rozdział 4. Więcej o synchronizacji wątków. Blokady i sygnały ......................... 67


Problem ucztujących filozofów ...................................................................................... 68
Problem czytelników i pisarzy ........................................................................................ 73
Komunikacja między wątkami. Problem producenta i konsumenta ............................... 78
Sygnalizacja za pomocą metod Monitor.Pulse i Monitor.Wait ....................................... 81
EventWaitHandle i AutoResetEvent .............................................................................. 85
Bariera ............................................................................................................................ 86
Synchronizacja wątków z różnych procesów. Muteksy i semafory nazwane ................. 88
Kontrola ilości instancji aplikacji ............................................................................. 89
Mutex ....................................................................................................................... 89
Semafor .................................................................................................................... 91
Zadania ........................................................................................................................... 93
Rozdział 5. Wątki a interfejs użytkownika ......................................................... 95
Wątki robocze w aplikacjach desktopowych .................................................................. 95
Przygotowanie projektu aplikacji oraz danych wejściowych ................................... 96
Wykorzystanie wątków w długotrwałych metodach zdarzeniowych ....................... 99
Synchronizacja wątków z interfejsem użytkownika w aplikacjach Windows Forms ... 104
BackgroundWorker ...................................................................................................... 110
Synchronizacja wątków z komponentami Windows Presentation Foundation ............. 114
Projekt graficznego interfejsu użytkownika ........................................................... 115
Implementacja metod zdarzeniowych .................................................................... 117
Bezpieczny dostęp do kontrolek WPF .................................................................... 125
Kontekst synchronizacji ............................................................................................... 128
Groźba zagłodzenia wątku interfejsu i asynchroniczna zmiana stanu
współdzielonych zasobów .......................................................................................... 135
Zadania ......................................................................................................................... 136
Rozdział 6. Zadania ....................................................................................... 137
Tworzenie zadania ........................................................................................................ 137
Praca z zadaniami ......................................................................................................... 138
Dane przekazywane do zadań ....................................................................................... 140
Dane zwracane przez zadania ....................................................................................... 141
Przykład: test liczby pierwszej ..................................................................................... 141
Synchronizacja zadań ................................................................................................... 143
Przykład: sztafeta zadań ............................................................................................... 144
Przerywanie zadań ........................................................................................................ 145
Stan zadania .................................................................................................................. 149
Fabryka zadań ............................................................................................................... 152
Planista i zarządzanie kolejkowaniem zadań ................................................................ 155
Ustawienia zadań .......................................................................................................... 159
Zadania ......................................................................................................................... 160
Rozdział 7. Klasa Parallel. Zrównoleglanie pętli .............................................. 161
Równoległa pętla for .................................................................................................... 162
Równoległa pętla foreach ............................................................................................. 163
Metoda Invoke .............................................................................................................. 164
Ustawienia pętli równoległych. Klasa ParallelOptions ................................................. 166
Przerywanie pętli za pomocą CancelationToken .......................................................... 166
Kontrola wykonywania pętli ......................................................................................... 168
Synchronizacja pętli równoległych. Obliczanie π metodą Monte Carlo ....................... 169
Partycjonowanie danych ............................................................................................... 175
Zadania ......................................................................................................................... 177
Spis treści 5

Rozdział 8. Synchronizacja zadań ................................................................... 179


Blokady (lock) .............................................................................................................. 179
Sygnały (Monitor.Pulse i Monitor.Wait) ...................................................................... 182
Bariera .......................................................................................................................... 184
Rozdział 9. Dane w programach równoległych ................................................. 187
Praca ze zbiorami danych w programowaniu równoległym ......................................... 187
Współbieżne struktury danych ............................................................................... 187
Kolekcja ConcurrentBag ........................................................................................ 189
Współbieżne kolejka i stos ..................................................................................... 189
Praca z BlockingCollection .................................................................................... 190
Własna kolekcja współbieżna ................................................................................. 193
Agregacja ............................................................................................................... 197
Agregacje dla kolekcji równoległych ..................................................................... 199
PLINQ — zrównoleglone zapytania LINQ .................................................................. 203
Przykład zapytania PLINQ ..................................................................................... 204
Jak działa PLINQ? ................................................................................................. 205
Kiedy PLINQ jest wydajne? ................................................................................... 207
Metody przekształcające dane wynikowe .............................................................. 208
Przerywanie zapytań .............................................................................................. 209
Metoda ForAll ........................................................................................................ 212
Zadania ......................................................................................................................... 213
Rozdział 10. Synchronizacja kontrolek interfejsu z zadaniami ............................ 215
Zadania w aplikacjach Windows Forms ....................................................................... 215
Zadania w aplikacjach WPF ......................................................................................... 219
Aktualizacja interfejsu z wykorzystaniem operatora await ........................................... 221
Zadania ......................................................................................................................... 223
Rozdział 11. Analiza aplikacji wielowątkowych. Debugowanie i profilowanie ...... 225
Okno wątków (Threads) ............................................................................................... 226
Okno zadań równoległych (Parallel Tasks) .................................................................. 228
Okno stosów równoległych (Parallel Stacks) ............................................................... 229
Okno równoległego śledzenia zmiennych (Parallel Watch) ......................................... 230
Concurrency Visualizer ................................................................................................ 232
Widok Wykorzystanie CPU ................................................................................... 232
Widok Wątki .......................................................................................................... 233
Widok Rdzenie ....................................................................................................... 236
Profilowanie aplikacji zewnętrznych ...................................................................... 237
Znaczniki ................................................................................................................ 238
Zadania ......................................................................................................................... 241
Rozdział 12. Wstęp do CCR i DSS .................................................................... 243
Instalacja środowiska Microsoft Robotics .................................................................... 245
Możliwe problemy z uruchomieniem środowiska Robotics ................................... 247
Kompilacja i uruchamianie projektów dołączonych do książki ............................. 248
CCR i DSS w pigułce ................................................................................................... 249
Czujniki i urządzenia — tworzenie pierwszej usługi ............................................. 249
Serwisy partnerskie ................................................................................................ 265
Rozdział 13. Skalowalne rozwiązanie dla systemów rozproszonych
na bazie technologii CCR i DSS .................................................. 277
Opóźnione uruchamianie .............................................................................................. 291
Uruchamianie obliczeń na klastrze ............................................................................... 293
Podsumowanie .............................................................................................................. 298
Zadania ......................................................................................................................... 299
6 Programowanie równoległe i asynchroniczne w C# 5.0

Rozdział 14. Wprowadzenie do Reactive Extensions.


Zarządzanie sekwencjami zdarzeń .............................................. 301
Programowanie reaktywne ........................................................................................... 302
IObservable<T> ..................................................................................................... 303
IObserver<T> ......................................................................................................... 303
Dualizm interaktywno-reaktywny .......................................................................... 304
Obserwator — wzorzec projektowy ....................................................................... 305
Platforma Rx ................................................................................................................. 306
Biblioteki Rx .......................................................................................................... 307
Gramatyka Rx ............................................................................................................... 309
Jak korzystać z interfejsów w Rx? ......................................................................... 309
Subskrypcje ............................................................................................................ 312
LINQ do zdarzeń .................................................................................................... 315
Zimne i gorące obserwable ........................................................................................... 329
Rozdział 15. Współbieżność w Rx ..................................................................... 333
Zarządzanie równoległością ......................................................................................... 333
Interfejs IScheduler ................................................................................................ 334
Planiści ................................................................................................................... 335
Metody SubscribeOn i ObserveOn ......................................................................... 339
Słowo o unifikacji .................................................................................................. 343
Rozdział 16. Przykłady użycia technologii Rx w aplikacjach WPF ....................... 345
Rysowanie z użyciem Rx ............................................................................................. 346
Wyszukiwarka .............................................................................................................. 353
Rozdział 17. CUDA w .NET ............................................................................... 365
Konfiguracja środowiska dla CUDAfy.NET ................................................................ 366
Pierwsze kroki .............................................................................................................. 368
Hello World, czyli pierwszy program CUDAfy.NET ................................................... 370
Emulator GPU .............................................................................................................. 375
Własności GPU ............................................................................................................ 376
Przekazywanie parametrów do kerneli ......................................................................... 378
Operacje na pamięci globalnej karty graficznej ............................................................ 380
Pomiar czasu wykonania .............................................................................................. 383
Dostęp zwarty do pamięci globalnej i pamięć współdzielona ...................................... 386
Generator liczb pseudolosowych .................................................................................. 390
FFT na GPU ................................................................................................................. 392
BLAS ............................................................................................................................ 394
Zadania ......................................................................................................................... 395
Dodatek A Biblioteka TPL w WinRT ............................................................... 397
Zadania ......................................................................................................................... 398
Struktura SpinWait ....................................................................................................... 400
Usypianie wątków ........................................................................................................ 400
Pula wątków ................................................................................................................. 401
ThreadPoolTimer .......................................................................................................... 402
Podobieństwa ................................................................................................................ 403
Przenośna biblioteka ..................................................................................................... 404
Zadania ......................................................................................................................... 406
Spis treści 7

Dodatek B Dobre praktyki programowania aplikacji wielowątkowych .............. 407


Wprowadzenie .............................................................................................................. 407
Sekcje krytyczne i zakleszczenia .................................................................................. 407
Wyścig .......................................................................................................................... 411
Słowo kluczowe volatile i kontrola pętli wykonywanej w ramach funkcji wątku ........ 417
Bezpieczeństwo wątków a konstruktory i pola statyczne ............................................. 419
Dodatek C Menadżer pakietów NuGet .............................................................. 423
Instalacja NuGet ........................................................................................................... 423
Korzystanie z NuGet .................................................................................................... 425
Skorowidz ................................................................................... 427
8 Programowanie równoległe i asynchroniczne w C# 5.0
Wstęp
W ostatnich latach większą moc komputerów uzyskiwano, zwiększając liczbę proceso-
rów i liczbę ich rdzeni. Aby w pełni wykorzystać tę „podzieloną” moc obliczeniową,
konieczne jest opanowanie umiejętności programowania współbieżnego (równoległego).
To stwierdzenie jest w ostatnich latach powtarzane tak często, że niemal się zbanali-
zowało. A jednak nadal stosunkowo niewielu programistów posiadło tę umiejętność.
Dotyczy to także programistów C#, czy w ogóle osób piszących aplikacje dla plat-
formy .NET.

Celem tej książki jest opisanie narzędzi i technologii pozwalających na programowa-


nie współbieżne dla platformy .NET. Z oczywistych względów książka skupia się ra-
czej na przedstawieniu samych technologii, a nie na szczegółowym omówieniu do-
brych praktyk ich używania. Zaczynamy od wątków, które same w sobie nie są może
tą technologią, którą dziś warto wybrać, rozpoczynając nowy projekt, ale ponieważ to na
nich opierają się wszystkie pozostałe — należy je znać. Dotyczy to szczególnie puli
wątków. Wątkom, ale również klasycznym pułapkom programowania równoległego,
poświęcone są rozdziały od 2. do 4. W kolejnych pięciu rozdziałach omawiamy nato-
miast zadania, bibliotekę TPL i biblioteki na niej oparte. Biblioteka TPL wprowadzona
w wersji 4.0 platformy .NET stała się już nowym standardem programowania współ-
bieżnego. To na TPL opiera się asynchroniczność w aplikacjach Modern UI dla Win-
dows 8 czy PLINQ. Zadania kryją się także za metodą Parallel.For, która jest obecnie
najczęściej używanym sposobem zrównoleglania aplikacji .NET (rozdział 1.).

W dalszych rozdziałach omawiamy biblioteki DSS i CCR — bardziej wyspecjalizowane,


a przez to mniej znane w społeczności programistów .NET. To biblioteki słusznie
kojarzone z Microsoft Robotics Studio, ale mogące także działać poza nim. Oferują one
gotowe i sprawdzone rozwiązania w sytuacjach, w których samodzielne programowanie
sieciowe oparte na TPL zajęłoby wiele miesięcy. W kolejnych rozdziałach omówiona
została technologia Reactive Extensions — nowe rozwiązanie proponowane przez
Microsoft. Należy je poznać, bo reprezentuje nowe podejście do programowania —
programowanie zdarzeniowe, w którym współbieżność jest immanentnym elementem.
Obie technologie znacznie wykraczają poza sam temat współbieżności, ale są z nim
silnie związane.
10 Wstęp

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.

Poza ostatnim rozdziałem o CUDAfy.NET, kody zaprezentowane w książce mogą


być uruchamiane w obu popularnych obecnie wersjach środowiska Visual Studio tj.
2010 i 2012. Sprawdziliśmy także, że kody są zgodne z najnowszą wersją Visual Studio
2013 (tę ostatnią sprawdziliśmy w edycji Release Candidate z października 2013 roku).

Jacek Matulewski, Toruń, 6 maja 2013 rok


Przedmowa
Jacka Matulewskiego poznałem na półce. Stał obok innych książek o programowaniu.
To znaczy nie on sam osobiście, ale jego książka o ASP.NET. Były to dzikie czasy,
kiedy wydawane w Polsce książki były słabymi tłumaczeniami dzieł sprzed lat lub
słabymi publikacjami w stylu „zrób to sam”. Poszukując wartościowych publikacji,
które mógłbym rekomendować moim studentom, natrafiłem na książkę Jacka. Wyróż-
niała ją niesamowita szybkość wydania. Niedawno przedstawiono ASP.NET w wersji 3.5,
a tutaj już książka czeka na mnie na półce.

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.

Szukając dla Microsoftu autora kolejnej książki opracowywanej w ramach programu


IT Academy Lokalna, pomyślałem o Jacku. Udało się przekonać go do napisania
świetnej publikacji, z której każdego roku korzystają setki studentów w całej Polsce.
Wreszcie udało mi się również poznać Jacka osobiście. Miałem również okazję wziąć
udział w jego szkoleniu i faktycznie czułem się tak, jakbym czytał jego książkę, tylko
tym razem faktycznie był przy mnie i tłumaczył to sam. Dużo wiedzy przekazanej w pro-
sty, praktyczny sposób.

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ę.

Po przeczytaniu rękopisu mogę z całą stanowczością powiedzieć, że sobie poradził.


W książce, którą czytałem, autorzy powoli, ale skutecznie opisali praktycznie najważ-
niejsze i najbardziej przydatne w pracy programistów zagadnienia związane z pro-
gramowaniem równoległym. Niezbędną teorię zaprezentowali z użyciem przykładów,
12 Przedmowa

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.

dr inż. Piotr Bubacz


Academic Program Manager Microsoft
Rozdział 1.
Dla niecierpliwych:
asynchroniczność
i pętla równoległa
Jacek Matulewski

Zgodnie z zasadą Pareto, w większości przypadków czytelnicy będą potrzebowali tylko


znikomej części wiedzy przedstawionej w tej książce. Postanowiłem wobec tego w roz-
dziale 1. opisać dwie nowości platformy .NET 4.0 i 4.5, które wydają mi się najważ-
niejsze i które prawdopodobnie będą najczęściej używane w programach czytelników.

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

Spójrzmy na przykład widoczny na listingu 1.1, w którym przedstawiam metodę zdarze-


niową przycisku. Zdefiniowana jest w niej przykładowa akcja pobierająca obiekt typu
object, a zwracająca liczbę całkowitą long. Referencję do niej zapisuję w zmiennej
akcja i uruchamiam ją (synchronicznie). Czynność owa wprowadza jednosekundowe
opóźnienie za pomocą metody Thread.Sleep (należy zadeklarować użycie przestrzeni
nazw System.Threading1), które — oczywiście — opóźnia wykonywanie całej metody
zdarzeniowej po kliknięciu przycisku. W efekcie na jedną sekundę aplikacja zamiera.

Listing 1.1. Synchroniczne wykonywanie kodu zawartego w akcji


private void button1_Click(object sender, EventArgs e)
{
Func<object, long> akcja =
(object argument) =>
{
msgBox("Akcja: Początek, argument: " + argument.ToString());
Thread.Sleep(1000); //opóźnienie
msgBox("Akcja: Koniec");
return DateTime.Now.Ticks;
};

msgBox("button1_Click: Początek");
msgBox("Wynik: "+akcja("synchronicznie"));
msgBox("button1_Click: Koniec");
}

void msgBox(string komunikat)


{
string taskID = Task.CurrentId.HasValue ? Task.CurrentId.ToString() : "UI";
MessageBox.Show("! " + komunikat + " (" + taskID + ")");
}

W metodzie przedstawionej na listingu 1.2 ta sama akcja wykonywana jest asynchro-


nicznie w osobnym wątku utworzonym przez platformę .NET na potrzeby zdefinio-
wanego tu zadania (instancja klasy Task z TPL). Synchronizacja następuje w momencie
odczytania wartości zadanie.Result, czyli wartości zwracanej przez czynność akcja.
Jej sekcja get czeka ze zwróceniem wartości aż do zakończenia akcji wykonywanej
przez zadanie, wstrzymując do tego czasu wątek, w którym wykonywana jest metoda
button1_Click. Jest to zatem typowy punkt synchronizacji, choć trochę ukryty. Warto
zwrócić uwagę, że po instrukcji zadanie.Start(), a przed odczytaniem własności zadanie.
Result mogą być wykonywane dowolne czynności, o ile są niezależne od wartości
zwróconej przez zadanie.

Listing 1.2. Użycie zadania do asynchronicznego wykonania kodu


private void button1_Click(object sender, EventArgs e)
{
Func<object, long> akcja =
(object argument) =>

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;
};

Task<long> zadanie = new Task<long>(akcja, "zadanie");


zadanie.Start();
msgBox("Akcja została uruchomiona");
if (zadanie.Status != TaskStatus.Running &&
zadanie.Status!=TaskStatus.RanToCompletion)
msgBox("Zadanie nie zostało uruchomione");
else msgBox("Wynik: "+zadanie.Result);
msgBox("button1_Click: Koniec");
}

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.

Listing 1.3. Wzór metody wykonującej jakąś czynność asynchronicznie


Task<long> DoSomethingAsync(object argument)
{
Func<object, long> akcja =
(object _argument) =>
{
msgBox("Akcja: Początek, argument: " + _argument.ToString());
Thread.Sleep(1000); //opóźnienie
msgBox("Akcja: Koniec");
return DateTime.Now.Ticks;
};

Task<long> zadanie = new Task<long>(akcja, argument);


zadanie.Start();
return zadanie;
}

protected void button1_Click(object sender, EventArgs e)


{
msgBox("button1_Click: Początek");
Task<long> zadanie = DoSomethingAsync("zadanie-metoda");
msgBox("Akcja została uruchomiona");
if (zadanie.Status != TaskStatus.Running &&
zadanie.Status!=TaskStatus.RanToCompletion)
msgBox("Zadanie nie zostało uruchomione");
else msgBox("Wynik: " + zadanie.Result);
msgBox("button1_Click: Koniec");
}
16 Programowanie równoległe i asynchroniczne w C# 5.0

Po tym wprowadzeniu możemy przejść do omówienia zasadniczego tematu. Wraz z wer-


sjami 4.0 i 4.5 w platformie .NET (oraz w platformie Windows Runtime) pojawiło się
wiele metod podobnych do przedstawionej powyżej metody DoSomethingAsync (ale —
oczywiście — w odróżnieniu od niej robiących coś pożytecznego). Metody te wykonują
asynchronicznie różnego typu długotrwałe czynności. Znajdziemy je w klasie HttpClient,
w klasach odpowiedzialnych za obsługę plików (StorageFile, StremWriter, Stream
Reader, XmlReader), w klasach odpowiedzialnych za kodowanie i dekodowanie ob-
razów czy w klasach WCF. Asynchroniczność jest wręcz standardem w aplikacjach
Windows 8 z interfejsem Modern UI. I właśnie po to, aby ich użycie było (prawie) tak
proste jak metod synchronicznych, wprowadzony został w C# 5.0 (co odpowiada plat-
formie .NET 4.5) operator await. Ułatwia on synchronizację dodatkowego zadania two-
rzonego przez te metody. Należy jednak pamiętać, że metodę, w której chcemy użyć ope-
ratora await, musimy oznaczyć modyfikatorem async. Prezentuję to na listingu 1.4.

Listing 1.4. Przykład użycia modyfikatora async i modyfikatora await


protected async void button1_Click(object sender, EventArgs e)
{
msgBox("button1_Click: Początek");
Task<long> zadanie = DoSomethingAsync("async/await");
msgBox("Akcja została uruchomiona");
long wynik = await zadanie;
msgBox("Wynik: " + wynik);
msgBox("button1_Click: Koniec");
}

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).

Metody oznaczone modyfikatorem async nazywane są w angielskiej dokumentacji MSDN


async method. Może to jednak wprowadzać pewne zamieszanie. Z powodu tej nazwy
metody z modyfikatorem async (w naszym przypadku metoda Button1_Click) utoż-
samiane są z metodami wykonującymi asynchronicznie jakieś czynności (a taką w na-
szym przypadku jest DoSomethingAsync). Osobom poznającym dopiero temat często
wydaje się, że aby metoda wykonywana była asynchronicznie, wystarczy dodać do jej
sygnatury modyfikator async. To nie jest prawda!

Możemy wywołać metodę DoSomethingAsync w taki sposób, że umieścimy ją bezpo-


średnio za operatorem await, np. long wynik = await DoSomethingAsync("async/
await");. Jaki to ma sens? Wykonywanie metody button1_Click, w której znajduje
się to wywołanie, zostanie wstrzymane aż do momentu zakończenia metody DoSomething
Async, więc efekt, jaki zobaczymy na ekranie, będzie identyczny z wynikiem w przy-
padku synchronicznym (listing 1.1). Różnica jest jednak wyraźna i to jest zasadnicza
nowość, bo instrukcja zawierająca operator await nie blokuje wątku, w którym wywołana
została metoda button1_Click. Kompilator zawiesza wywołanie metody button1_Click,
przechodząc do kolejnych czynności w miejscu jej wywołania aż do momentu zakoń-
czenia uruchomionego zadania. W momencie, gdy to nastąpi, wątek wraca do metody
Rozdział 1.  Dla niecierpliwych: asynchroniczność i pętla równoległa 17

button1_Click i kontynuuje jej działanie2. Jednak w programie, na którym w tej


chwili testujemy operator await, efektów tego nie zobaczymy. Efekt będzie widoczny
dopiero wtedy, gdy metodę button1_Click wywołamy z innej metody — niech będzie
to metoda zdarzeniowa button2_Click związana z drugim przyciskiem. Należy za-
uważyć, że w serii instrukcji wywołanie metody oznaczonej modyfikatorem async nie
musi się zakończyć przed wykonaniem następnej instrukcji — i w tym sensie jest ona
asynchroniczna. Aby tak się stało, musi w niej jednak zadziałać operator await czekający
na wykonanie jakiegoś zadania (w naszym przykładzie metody DoSomethingAsync).
W efekcie, w scenariuszu przedstawionym na listingu 1.5 metoda button2_Click zakoń-
czy się przed zakończeniem button1_Click.

Listing 1.5. Działanie modyfikatora async


private async void button1_Click(object sender, EventArgs e)
{
msgBox("button1_Click: Początek");
long wynik = await DoSomethingAsync("async/await");
msgBox("Wynik: " + wynik.ToString());
msgBox("button1_Click: Koniec");
}

private void button2_Click(object sender, EventArgs e)


{
msgBox("button2_Click: Początek");
button1_Click(null, null);
msgBox("button2_Click: Koniec");
}

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.

Sprawdźmy to, tworząc odpowiednik metody button1_Click ze zmienioną sygnaturą (nie


możemy tego zrobić z oryginałem, bo jest związany ze zdarzeniem button1.Click).
Nowa metoda o nazwie DoSomethingMoreAsync widoczna jest na listingu 1.64. Usunąłem
argumenty, których i tak nie używaliśmy, i zmieniłem zwracaną wartość z void na
Task. Dzięki temu metoda ta nie jest już typu „wystrzel i zapomnij”, a może być kon-
trolowana z miejsca uruchomienia (zob. widoczna również na listingu 1.6 metoda
button2_Click). Zdziwienie może budzić jednak fakt, że za słowem kluczowym return
w metodzie DoSomethingMoreAsync wcale nie ma instrukcji tworzącej zwracane przez
tą metodę zadanie (instrukcji return mogłoby wcale nie być). W metodach z modyfi-
katorem async i zwracających wartość Task zadanie jest przypisywane przez kompi-
lator. W ten sposób ułatwiona jest wielostopniowa obsługa metod asynchronicznych.
Należy jednak pamiętać, że te metody nie tworzą nowych zadań, a jedynie je przekazują.

Listing 1.6. Metoda async zwracająca zadanie


private async Task DoSomethingMoreAsync()
{
msgBox("DoSomethingMoreAsync: Początek");
long wynik = await DoSomethingAsync("async/await");
msgBox("DoSomethingMoreAsync: Wynik: " + wynik.ToString());
msgBox("DoSomethingMoreAsync: Koniec");
return;
}

private async void button2_Click(object sender, EventArgs e)


{
msgBox("button2_Click: Początek");
await DoSomethingMoreAsync();
msgBox("button2_Click: Koniec");
}

A co w przypadku metod async, które miałyby zwracać wartość? Załóżmy, że metoda


DoSomethingMore miałaby zwracać wartość typu long (np. wartość zmiennej wynik).
Wtedy należy zmienić typ tej metody na Task<long>, a za słowem kluczowym return
wstawić wartość typu long. Pokazuję to na listingu 1.7. Warto zapamiętać, choć to
uproszczone stwierdzenie, że w metodach async operator await wyłuskuje z typu Task<>
parametr, a słowo kluczowe return w metodach async zwracające wartość typu Task<>
działa odwrotnie — otacza dowolne obiekty typem Task<>.

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

Listing 1.7. Metoda async zwracająca wartość long


private async Task<long> DoSomethingMoreAsync()
{
msgBox("DoSomethingMoreAsync: Początek");
long wynik = await DoSomethingAsync("async/await");
msgBox("DoSomethingMoreAsync: Wynik: " + wynik.ToString());
msgBox("DoSomethingMoreAsync: Koniec");
return wynik;
}

private async void button2_Click(object sender, EventArgs e)


{
msgBox("button2_Click: Początek");
msgBox("button2_Click: Wynik: " + await DoSomethingMoreAsync());
msgBox("button2_Click: Koniec");
}

I kolejna sprawa. Co w metodach async dzieje się w przypadku błędów? Nieobsłużo-


ne wyjątki zgłoszone w metodzie z modyfikatorem async i zwracające zadania (Task
lub Task<>) są za pośrednictwem tych zadań przekazywane do metody wywołującej.
Można zatem użyć normalnej konstrukcji try..catch, jak na listingu 1.8. Gorzej jest
w przypadku metod async zwracających void (typu „wystrzel i zapomnij”, jak button1_
Click z naszego przykładu). Wówczas wyjątek przekazywany jest do puli wątków
kryjącej się za mechanizmem zadań i przechwytywanie wyjątków nic nie da.

Listing 1.8. Obsługa wyjątków zgłaszanych przez metody async


private async void button2_Click(object sender, EventArgs e)
{
msgBox("button2_Click: Początek");
try
{
msgBox("button2_Click: Wynik: " + await DoSomethingMoreAsync());
}
catch(Exception exc)
{
msgBox("button2_Click: Błąd!\n" + exc.Message);
}
msgBox("button2_Click: Koniec");
}

Klasa Parallel z biblioteki TPL


(nowość platformy .NET 4.0)
Do platformy .NET w wersji 4.0 dodana została biblioteka TPL (ang. Task Parallel
Library), która wraz ze zrównoleglonym PLINQ i kolekcjami przystosowanymi do
konkurencyjnej obsługi składa się na tzw. Parallel Extensions. Biblioteka TPL nad-
budowuje klasyczne wątki, korzystając z poznanej już przed chwilą klasy Task (z ang.
20 Programowanie równoległe i asynchroniczne w C# 5.0

zadanie). Biblioteka ta zostanie dokładnie opisana w następnych rozdziałach. Tu chciał-


bym skupić się tylko na najczęściej używanym jej elemencie — implementacji współ-
bieżnej pętli For.

Równoległa pętla For


Załóżmy, że mamy zbiór stu liczb rzeczywistych, dla których musimy wykonać jakieś
stosunkowo czasochłonne czynności. W naszym przykładzie będzie to obliczanie
wartości funkcji f(x) = arcsin(sin(x)). Funkcja ta powinna z dokładnością numeryczną
zwrócić wartość argumentu x. Zrobi to, ale nieźle się przy tym namęczy — funkcje
trygonometryczne są dość wymagające numerycznie. Dodatkowo powtórzymy te obli-
czenia kilkakrotnie, aby jeszcze bardziej wydłużyć czas obliczeń. Kod odpowiedniej
metody z projektu aplikacji konsolowej widoczny jest na listingu 1.9.

Listing 1.9. Metoda zajmująca procesor


private static double obliczenia(double argument)
{
for (int i = 0; i < 10; ++i) argument = Math.Asin(Math.Sin(argument));
return argument;
}

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).

Listing 1.10. Obliczenia sekwencyjne


static void Main(string[] args)
{
//przygotowania
int rozmiar = 10000;
Random r = new Random();
double[] tablica = new double[rozmiar];
for(int i=0;i<tablica.Length;++i) tablica[i] = r.NextDouble();

//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);
*/
}

Przy użyciu klasy Parallel z przestrzeni nazw System.Threading.Tasks można bez


większego wysiłku zrównoleglić pętlę for z metody Main (tę z indeksem i). Pokazuje
to kod z listingu 1.11. Należy go dodać do metody z listingu 1.10.

Listing 1.11. Przykład zrównoleglonej pętli for


//obliczenia równoległe
start = System.Environment.TickCount;
for(int powtorzenia = 0; powtorzenia < iloscPowtorzen; ++powtorzenia)
Parallel.For(0, tablica.Length, i=>{ wyniki[i] = obliczenia(tablica[i]); });
stop = System.Environment.TickCount;
Console.WriteLine("Obliczenia równoległe trwały " + (stop - start).ToString() + " ms.");

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.

Metoda Parallel.For automatycznie synchronizuje używane przez nią zadania przed


zakończeniem, dlatego nie ma zagrożenia zamazania danych w ramach kolejnych powtó-
rzeń (zewnętrzna pętla for).

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.

Listing 1.12. Przerywanie pętli równoległej


private static void przerywaniePetli()
{
Random r = new Random();
long suma = 0;
long licznik = 0;
string s = "";

//iteracje zostaną wykonane tylko dla liczb parzystych


//pętla zostanie przerwana wcześniej, jeżeli wylosowana liczba jest większa od 90
Parallel.For(
0,
10000,
(int i, ParallelLoopState stanPetli) =>
{
int liczba = r.Next(7); //losowanie liczby oczek na kostce
if(liczba == 0)
{
s += "0 (Stop);";
stanPetli.Stop();
}
if(stanPetli.IsStopped) return;
if(liczba % 2 == 0)
{
s += liczba.ToString() + "; ";
obliczenia(liczba);
suma += liczba;
licznik += 1;
}
else
{
s += liczba.ToString() + "; ";
}
});
Rozdział 1.  Dla niecierpliwych: asynchroniczność i pętla równoległa 23

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

Bez względu na to, z jakiej technologii programowania równoległego będziemy korzy-


stać, oznaczać to będzie korzystanie z wątków. Dotyczy to szczególnie technologii .NET1.
Także programując aplikacje „okienkowe”, zarówno oparte na bibliotece Windows Forms,
jak i Windows Presentation Foundation, często korzystamy z wątków, jednak nie-
koniecznie zdajemy sobie z tego sprawę. Łatwo domyślić się, że takie kontrolki jak
BackgroundWorker i Timer tworzą dodatkowe wątki, ale to, że niezależny wątek związany
jest z każdym oknem, nie jest już takie oczywiste. A przecież także inne kontrolki je
tworzą. I dlatego, zanim przejdziemy do nowszych rozwiązań, wyjaśnię pojęcia pod-
stawowe, a więc wątki, pulę wątków i metody ich synchronizacji.

Zacznę od próby określenia, czym w ogóle są wątki i co to jest wielowątkowość? Wie-


lowątkowość (ang. multithreading) to cecha systemu operacyjnego, dzięki której w ra-
mach jednego procesu (czyli w praktyce w ramach jednego uruchomionego programu)
można wykonywać kilka wątków (ang. thread). Wątki to niezależnie wykonywane ciągi
instrukcji. Wszystkie wątki tego samego procesu mają dostęp do danych tego procesu.
Z punktu widzenia C# oznacza to, że wątek może wykonywać jakąś metodę niezależnie
od pozostałej części aplikacji. Oznacza to również, że wątek ten będzie miał wyłączny
dostęp do zmiennych lokalnych tej metody oraz współdzielony dostęp do zmiennych
zadeklarowanych poza metodą.

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 π

Dokładność oszacowania liczby π w tej metodzie jest proporcjonalna do odwrotności


pierwiastka liczby wylosowanych punktów. Nie jest to zatem algorytm zbyt wydajny.
Istnieje wiele bardziej efektywnych (przypis 12 na str. 47). Jednak nie chodzi tu o do-
kładność wyniku, a o wielowątkową implementację obliczeń.

Utwórzmy projekt aplikacji konsolowej (Console Application, rysunek 2.2). Dlaczego


konsolowej? W trakcie nauki programowania wielowątkowego aplikacje konsolowe
mają tę przewagę nad aplikacjami „okienkowymi”, że można w nich śledzić działanie
wątków i wyświetlać komunikaty, które nie zatrzymują działania wątku (drukowanie
na konsoli). Natomiast komunikaty wyświetlane w aplikacjach „okienkowych” metodą
MessageBox.Show czekają na naciśnięcie przez użytkownika przycisku OK i wstrzymują
tym samym działanie wątku. Również wyświetlanie komunikatów, np. w różnego typu
kontrolkach, wiąże się z problemami. Kontrolki działające w wątku okna nie pozwa-
lają na dostęp z innych wątków. Nie jest to wprawdzie problemem nie do rozwiązania
(rozdział 5.), ale łatwiej będzie zacząć od aplikacji konsolowej.

Obliczenia bez użycia


dodatkowych wątków
Zacznijmy od przygotowania wersji, w której liczba π będzie obliczana tylko w głów-
nym wątku aplikacji. Przede wszystkim zdefiniujmy metodę statyczną obliczPi, która
będzie realizowała obliczenia (listing 2.1). Metoda ta wymaga obecności kilku pól,
m.in. instancji generatora liczb pseudolosowych (klasa Random).
Rozdział 2.  Wątki 27

Rysunek 2.2. Tworzenie projektu aplikacji konsolowej

Listing 2.1. Kod niezrównoleglony


using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace Watki
{
class Program
{
static Random r = new Random();

static void Main(string[] args)


{
uruchamianieObliczenPi();
}

static double obliczPi(long ilośćPrób)


{
double x, y;
long ilośćTrafień = 0;
for (long 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;
}
28 Programowanie równoległe i asynchroniczne w C# 5.0

static void uruchamianieObliczenPi()


{
int czasPoczatkowy = Environment.TickCount;

long ilośćPrób = 10000000L;


double pi = obliczPi(ilośćPrób: ilośćPrób);
Console.WriteLine("Pi={0}, błąd={1}", pi, Math.Abs(Math.PI - pi));

int czasKoncowy = Environment.TickCount;


int roznica = czasKoncowy - czasPoczatkowy;
Console.WriteLine("Czas obliczeń: " + (roznica).ToString());
}
}
}

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ń.

Ze względu na dalszy rozwój programu przygotujemy dodatkowo bezparametrową


metodę uruchamianieObliczenPi, która uruchamia obliczenia z ilością prób równą 107
(liczbę tę można dostosować do możliwości obliczeniowych komputera, na którym
pracujemy) i wyświetla wynik oraz czas, jaki zajęły obliczenia (ilość milisekund)2.
Również ta metoda widoczna jest na listingu 2.1. Należy ją wywołać z metody Main.

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ę.

Listing 2.2. Tworzenie i uruchamianie wątku


using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

using System.Threading;

namespace Watki
{
class Program
{
static Random r = new Random();

static void Main(string[] args)


{
uruchamianieObliczenPi();

Thread t = new Thread(uruchamianieObliczenPi);


t.Start();
Console.WriteLine("Czy ten napis pojawi się przed otrzymaniem wyniku?");
}

static double obliczPi(long ilośćPrób)


{
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;
}

static void uruchamianieObliczenPi()


{
int czasPoczatkowy = Environment.TickCount;
Console.WriteLine("Uruchamianie obliczeń, wątek nr {0}...",
Thread.CurrentThread.ManagedThreadId);
30 Programowanie równoległe i asynchroniczne w C# 5.0

long ilośćPrób = 10000000L;


double pi = obliczPi(ilośćPrób: ilośćPrób);
Console.WriteLine("Pi={0}, błąd={1}, wątek nr {2}",
pi, Math.Abs(Math.PI - pi),
Thread.CurrentThread.ManagedThreadId);

int czasKoncowy = Environment.TickCount;


int roznica = czasKoncowy - czasPoczatkowy;
Console.WriteLine("Czas obliczeń: " + (roznica).ToString());
}
}
}

Na listingu 2.2 wyróżniona jest drobna modyfikacja metody uruchamianieObliczenPi,


której celem jest wyświetlenie numeru wątku, w jakim wykonane zostały obliczenia
(por. rysunek 2.3). Jak podkreśla nawet nazwa użytej do jego odczytania własności,
numer ten identyfikuje wątek w platformie .NET i nie ma nic wspólnego z ewentualnym
identyfikatorem wątku systemu Windows, który „za nim stoi”.

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

Utworzenie obiektu klasy Thread nie jest równoznaczne z uruchomieniem nowego


wątku. Do tego służy metoda Start. Jak się łatwo domyślić, klasa ta udostępnia również
metody i własności pozwalające na zarządzanie wątkiem. Są to m.in. metody Abort
(przerywanie działania wątku), Suspend i Resume (wstrzymanie i wznawianie wątku),
własność ThreadState informująca o stanie wątku4, własność IsBackground pozwala-
jąca określić, czy wątek działa w tle, i sporo innych. Poniżej krótko opiszę kilka wybra-
nych składowych klasy Thread.

Wątki, procesy i domeny aplikacji 5

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

wątkami. Ta definicja sprawia, iż wątki są podobne do procesów, z tym że procesy, czyli


instancje (uruchomione egzemplarze) programu, mają swoją, osobną przestrzeń adreso-
wą pamięci, podczas gdy wątki uruchamiane w ramach jednego procesu współdzielą
pamięć. Zarządzaniem procesami i wątkami zajmuje się planista systemu operacyjnego
(także w przypadku wątków tworzonych w ramach platformy .NET). Planista przydziela
wątkom czas na poszczególnych rdzeniach procesora. Jeżeli nawet rdzeni jest kilka
lub kilkanaście, wątki będą na nich wykonywane na przemian — jest ich w całym syste-
mie zbyt dużo, aby wątek mógł mieć przydzielony swój rdzeń na wyłączność.

W platformie uruchomieniowej CLR (ang. Common Language Runtime), czyli wirtu-


alnej maszynie platformy .NET, procesy systemowe dzielone są na zarządzane pod-
procesy, nazywane domenami aplikacji (ang. Application Domain). Wątki zarządzane,
reprezentowane przez obiekty typu Thread, tworzone są w ramach zarządzanych pro-
cesów. Podczas uruchamiania domeny aplikacji tworzony jest jeden wątek zarządzany.
Kolejne wątki można budować poprzez tworzenie dodatkowych obiektów typu Thread.
W platformie CLR wątki zarządzane mogą być swobodnie przemieszczane pomiędzy
domenami aplikacji.

Domeny aplikacji, które w technologii .NET reprezentowane są przez obiekty typu


AppDomain, ułatwiają izolację, usuwanie z pamięci i zabezpieczenie kodu zarządzanego.
Izolacja polega na odosobnieniu pewnej części kodu; zapewnia w ten sposób, że wy-
korzystywane przez nią dane nie będą współdzielone z pozostałą częścią aplikacji.
Usuwanie domeny aplikacji z pamięci ułatwia zarządzanie długotrwałymi procesami.
Jeśli kod zawarty w domenie stanie się niestabilny, można zatrzymać jego wykonywanie
bez konieczności zatrzymywania całego procesu. Zabezpieczenie kodu pozwala ogra-
niczać uprawnienia, z jakimi uruchamiane są dane fragmenty aplikacji (w tym wątki
zarządzane).

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.

Usypianie bieżącego wątku


Po wywołaniu metody Start uruchamiającej wątek związany z obiektem t umieści-
łem instrukcję wyświetlającą pytanie: „Czy ten napis pojawi się przed otrzymaniem
wyniku?” (listing 2.2, rysunek 2.3). Odpowiedź na to pytanie jest oczywista: oblicze-
nia nie są tak szybkie, żeby zakończyły się przed wyświetleniem tego pytania, za któ-
re odpowiedzialny jest główny wątek aplikacji. Co innego, gdybyśmy trochę wyświe-
tlenie tego pytania opóźnili. Nadaje się do tego metoda statyczna Thread.Sleep,
usypiająca wątek, w którym została uruchomiona, na zadaną ilość milisekund6. Jeżeli

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

Przerywanie działania wątku (Abort)


Wywołanie metody Thread.Abort na rzecz instancji obiektu reprezentującego wątek
spowoduje jego zakończenie. Nie jest ono jednak tak bezwzględne, jak mogłoby się
wydawać. Wywołanie metody Thread.Abort powoduje wystąpienie w tym osobnym
wątku wyjątku, który daje mu szansę na reakcję. Aby sprawdzić działanie tego mechani-
zmu, wykonaj następujące polecenia.
1. Wydłuż działanie obliczeń wykonywanych w osobnym wątku, zwiększając
w metodzie uruchamianieObliczenPi ilość prób (zmienna ilośćPrób — argument
wywołania metody obliczPi).
2. Metodę uruchamianieObliczenPi zmodyfikuj również w taki sposób, żeby
monitorowała wystąpienie wyjątków (listing 2.3).

Listing 2.3. Użycie metody Abort spowoduje wywołanie wyjątku w wątku


static void uruchamianieObliczenPi()
{
try
{
int czasPoczatkowy = Environment.TickCount;
Console.WriteLine("Uruchamianie obliczeń, wątek nr {0}...",
Thread.CurrentThread.ManagedThreadId);

long ilośćPrób = 10000000L;


double pi = obliczPi(ilośćPrób: ilośćPrób);
Console.WriteLine("Pi={0}, błąd={1}, wątek nr {2}",
pi, Math.Abs(Math.PI - pi),
Thread.CurrentThread.ManagedThreadId);

int czasKoncowy = Environment.TickCount;


int roznica = czasKoncowy - czasPoczatkowy;
Console.WriteLine("Czas obliczeń: " + (roznica).ToString());
}
catch (ThreadAbortException exc)
{
Console.WriteLine("Działanie wątku zostało przerwane (" + exc.Message + ")");
}
catch (Exception exc)
Rozdział 2.  Wątki 33

{
Console.WriteLine("Wyjątek (" + exc.Message + ")");
}
}

3. Następnie w metodzie Main po uruchomieniu wątku metodą t.Start i po


półsekundowej przerwie spowodowanej wywołaniem metody Thread.Sleep,
która da czas drugiemu wątkowi na rzeczywiste uruchomienie, wywołaj metodę
t.Abort (listing 2.4). Spowoduje to przerwanie działania wątku przed jego
zakończeniem (rysunek 2.5). Ilość prób, którą wcześniej ustaliłem na 107, musi
być tak dobrana, żeby obliczenia trwały dłużej niż pół sekundy, bo tylko tyle
główny wątek czeka przed uruchomieniem metody Abort. Właściciele szybkich
komputerów mogą być zmuszeni do zwiększenia ilości prób.

Listing 2.4. Wywołanie metody Abort pół sekundy po uruchomieniu obliczeń


static void Main(string[] args)
{
Thread t = new Thread(uruchamianieObliczenPi);
t.Start();
Thread.Sleep(500);
t.Abort();
Console.WriteLine("Czy ten napis pojawi się przed otrzymaniem wyniku?");
}

Rysunek 2.5.
Efekt wywołania
metody Abort
przerywającej
działanie wątku

Jeżeli w metodzie uruchamianieObliczenPi nie byłoby instrukcji try..catch obsłu-


gującej wyjątki, dodatkowy wątek zostałby i tak przerwany, tyle że nie mielibyśmy
szansy zareagować na to w jakikolwiek sposób.

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.

Wielu autorów, w tym autorzy dokumentacji MSDN (http://msdn.microsoft.com/en-us/


library/1c9txz50.aspx), odradza użycie metody Abort, ponieważ jej działanie polega
na zgłoszeniu wyjątku w metodzie wątku, co może powodować trudne do przewidze-
nia skutki. Mechanizm ten nie dba o to, jaki fragment kodu wykonywany jest przez
wątek i czy jest to właściwe miejsce, aby nagle go przerwać. Nie dba o dokończenie
„atomowych” operacji, które mogą być ważne dla stabilności całego programu. Drugim
34 Programowanie równoległe i asynchroniczne w C# 5.0

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();
}

Po kompilacji i uruchomieniu aplikacji (Ctrl+F5) jej działanie zostanie po połowie


sekundy wstrzymane: dodatkowy wątek zostanie wstrzymany przy użyciu metody
t.Suspend, a główny — za pomocą oczekiwania na naciśnięcie klawisza Enter (polecenie
Console.ReadLine). Dopiero jego naciśnięcie pozwoli na dokończenie obu wątków
(rysunek 2.6).
Jeżeli wewnątrz wątku uruchamiana jest pętla, a tak jest w większości długo działają-
cych wątków, łatwo go wstrzymać i wznowić, definiując globalną flagę lub obiekt
przekazywany do wątku, którym możemy kontrolować jego działanie (zadanie 1. na
końcu rozdziału).

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

Wątki działające w tle


Kolejną opcją klasy Thread, na którą chciałbym zwrócić uwagę czytelników, jest możli-
wość zmiany priorytetu wątku oraz oznaczenie go jako wątku tła8. Zacznijmy od drugiej
możliwości. Czym w ogóle różnią się wątki tła od zwykłych wątków (ang. background
i foreground)? Tylko jednym. Aplikacja, której wątek główny zakończył swoje dzia-
łanie, czeka na zakończenie dodatkowego wątku zwykłego, a po cichu przerywa wątek
tła. W naszej aplikacji obliczenia w osobnym wątku trwają o wiele dłużej niż działa-
nie metody Main, ale aplikacja czeka na ich zakończenie i kończy działanie dopiero po
wyświetleniu wyników. Jeżeli jednak dodatkowy wątek oznaczylibyśmy jako wątek tła
(listing 2.5), zostanie on przerwany bez względu na postęp obliczeń.

Listing 2.5. Wątki tła nie blokują zamknięcia procesu


static void Main(string[] args)
{
Thread t = new Thread(uruchamianieObliczenPi);
t.IsBackground = true;
t.Start();
Thread.Sleep(500);
}

Po uruchomieniu tak zmodyfikowanej aplikacji obliczenia nie mają szans na zakoń-


czenie (rysunek 2.7). Półsekundowa pauza w wątku głównym dodana została, aby
dodatkowy wątek miał w ogóle szanse na start.

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

Zmiana priorytetu wątku


Można również zmienić priorytet wątku. Mamy do dyspozycji pięć wartości priorytetu
zebranych w typie wyliczeniowym ThreadPriority: najniższy, obniżony, normalny,
podwyższony i najwyższy. Jeżeli np. chcemy nadać naszym obliczeniom najwyższy
priorytet, najlepiej jeszcze przed uruchomieniem wątku zmienić jego własność Priority
zgodnie ze wzorem z listingu 2.6.

Listing 2.6. Zmiana priorytetu wątku


static void Main(string[] args)
{
Thread t = new Thread(uruchamianieObliczenPi);
t.Priority = ThreadPriority.Highest;
t.Start();
}

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.

Użycie wielu wątków i problemy


z generatorem liczb pseudolosowych
Załóżmy, że chcemy zwiększyć precyzję obliczeń, wykonując wielokrotnie metodę
uruchamianieObliczenPi i uśredniając uzyskane w ten sposób obliczenia. Lepiej byłoby
sumować trafienia uzyskane w poszczególnych seriach, ale skoro ilość prób w każdej
serii jest taka sama, także uśrednianie wyników obliczeń z poszczególnych serii jest
do zaakceptowania. Najprostsze, co moglibyśmy zrobić, to wykorzystanie pętli for,
która zadaną ilość razy wywoła metodę uruchamianieObliczenPi. Wówczas poszczegól-
ne serie wykonane byłyby w osobnym wątku, ale sekwencyjnie. A przecież doskonale
nadają się do zrównoleglenia — poszczególne serie w żaden sposób od siebie nie zależą.
Każdą z nich możemy więc umieścić w osobnym wątku. Jeden dodatkowy wątek za-
stąpmy zatem grupą wątków, których referencje zbierać będziemy w zwykłej tablicy
Thread[]. Pokazuję to na listingu 2.7. Tam też można zobaczyć, że ilość prób wyko-
nywaną w jednym wątku zmniejszamy tyle razy, ile jest wszystkich wątków wykorzy-
stywanych do obliczeń9. W ten sposób liczba  jest nadal liczona z tą samą dokładnością.

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

Listing 2.7. Dziesięć wątków z najniższym priorytetem


static Random r = new Random();
const int ileWatkow = 10;

static void Main(string[] args)


{
Thread[] tt = new Thread[ileWatkow];
for (int i = 0; i < ileWatkow; ++i)
{
tt[i] = new Thread(uruchamianieObliczenPi);
tt[i].Priority = ThreadPriority.Lowest;
tt[i].Start();
}
}

static void uruchamianieObliczenPi()


{
try
{
Console.WriteLine("Uruchamianie obliczeń, wątek nr {0}...",
Thread.CurrentThread.ManagedThreadId);
long ilośćPrób = 10000000L / ileWatkow;
double pi = obliczPi(ilośćPrób: ilośćPrób);
Console.WriteLine("Pi={0}, błąd={1}, wątek nr {2}",
pi, Math.Abs(Math.PI - pi),
Thread.CurrentThread.ManagedThreadId);
}
catch (ThreadAbortException exc)
{
Console.WriteLine("Działanie wątku zostało przerwane (" + exc.Message + ")");
}
catch (Exception exc)
{
Console.WriteLine("Wyjątek (" + exc.Message + ")");
}
}

W efekcie, po uruchomieniu aplikacja tworzy dziesięć dodatkowych wątków. Skutki


tego możemy obserwować choćby w Menedżerze zadań (rysunek 2.8), gdzie widać, że
proces związany z aplikacją wykorzystuje kilkanaście wątków systemowych. W praktyce
optymalne jest utworzenie tylu wątków, ile dostępnych jest wszystkich rdzeni procesorów.
Ich ilość możemy sprawdzić za pomocą własności System.Environment.ProcessorCount.

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.8. Śledzenie aplikacji za pomocą Menedżera zadań

wartości i zbliża się do 4 (rysunek 2.9). Co więcej, błędnemu działaniu generatora


towarzyszy znaczny wzrost czasu obliczeń. Najprostszym sposobem ominięcia tego
problemu jest tworzenie lokalnych kopii generatora liczb losowych w każdym wątku.
Do inicjacji poszczególnych wątków będziemy używać ziarna utworzonego za pomocą
liczby pobranej ze wspólnego generatora oraz dodatkowo liczby milisekund aktualnego
czasu (liczba od 0 do 999). Konieczne zmiany w metodzie obliczPi pokazuję na li-
stingu 2.8. Po tej zmianie działanie wydaje się poprawne (rysunek 2.10).

W rozdziale 7. przedstawiona zostanie klasa generatora, której będzie można


bezpiecznie używać w aplikacjach wielowątkowych. Jej działanie opiera się na tej
samej idei tworzenia osobnych generatorów liczb losowych dla każdego nowego
wątku.

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

Pamięć lokalna wątku


i bezpieczeństwo wątku
Wszystkie wątki utworzone w obrębie jednego procesu współdzielą jedną przestrzeń
adresową. Nie oznacza to jednak, że zmienne x i y utworzone w metodzie obliczPi są
wspólne dla wszystkich wątków. Są to zmienne lokalne wątku przechowywane w pa-
mięci lokalnej wątku. Każdy wątek ma zatem swoje własne zmienne x oraz y i ich warto-
ści od siebie w żaden sposób nie zależą. Inaczej jest ze zmiennymi zdefiniowanymi
poza metodami uruchomionymi w ramach wątku. Dla przykładu stała ileWatkow, za-
deklarowana w klasie Program, jest dostępna ze wszystkich wątków — jej wartość jest
niezależna od tego, z którego wątku jest odczytywana. Ponieważ jest to stała, nie mo-
żemy zmienić jej wartości, ale gdyby stałą nie byłą, to zmiana jej wartości w jednym
wątku byłaby widoczna w pozostałych.
40 Programowanie równoległe i asynchroniczne w C# 5.0

Obecność danych współdzielonych przez wątki umożliwia projektowanie aplikacji


współbieżnych, ale jest też zasadniczą trudnością. Zwróćmy uwagę, że zmiana wartości
zmiennej współdzielonej może powodować zmianę zachowania wątku, które od tej
wartości zależy. W efekcie dwa wątki, które wykonują ten sam kod, mogą działać w inny
sposób. Taka sytuacja może być — oczywiście — zamierzona, ale jeżeli nie jest, zwykle
dość trudno ją wytropić i wyeliminować. Najważniejszym zadaniem programisty pro-
jektującego aplikacje wielowątkowe jest właśnie zadbanie o bezpieczeństwo wątków
(ang. thread safety), a więc o to, żeby poszczególne wątki nie interferowały ze sobą
(za pośrednictwem współdzielonych danych) w sposób niezamierzony. Podstawowym
sposobem do osiągnięcia tego celu jest zapewnienie, że dostęp do współdzielonych da-
nych ma tylko jeden wątek w danej chwili. O tym jednak niżej.

Czekanie na ukończenie pracy wątku


(Join)
W naszej przykładowej aplikacji nadal pozostaje nierozwiązany problem uśrednienia
wyników. Na rysunku 2.10 widzimy, że wątki dobrze wykonują swoją pracę i wy-
świetlają częściowe wyniki; niestety, z poziomu metody Main nie mamy do tych wyni-
ków dostępu. Najprostszym rozwiązaniem jest zdefiniowanie zmiennej globalnej, a wła-
ściwie statycznego pola klasy Program, do którego poszczególne wątki będą dodawać
swoje wyniki częściowe. Ostateczny wynik może być jednak wyświetlony dopiero po
zakończeniu wszystkich wątków, o czym wątek główny musi być powiadomiony. Z po-
mocą przyjdzie teraz kolejna metoda klasy Thread, a mianowicie metoda Join. Wy-
woływana jest na rzecz obiektu reprezentującego dodatkowy wątek i wstrzymuje dzia-
łanie wątku, w jakim została wywołana, blokując go do momentu, w którym dodatkowy
wątek zostanie zakończony. W ten sposób bieżący wątek czeka na zakończenie do-
datkowego. A ponieważ my czekamy na zakończenie wszystkich wątków, wywołamy
metodę Join na rzecz wszystkich dodatkowych wątków (wszystkich obiektów z tablicy
wątków). Dopiero po ich zakończeniu wyświetlimy wyniki zebrane w statycznym polu
pi klasy Program. Odpowiednie zmiany pokazuję na listingu 2.9, w którym wyróżnione
zostały modyfikacje w metodach Main i uruchamianieObliczenPi. Ponadto z metody
uruchamianieObliczenPi do metody Main przeniosłem polecenia mierzące i wyświetla-
jące czas obliczeń.

Listing 2.9. Użycie metody Join


class Program
{
static Random r = new Random();
const int ileWatkow = 10;
static double pi = 0; //zmienna współdzielona

static void Main(string[] args)


{
int czasPoczatkowy = Environment.TickCount;

//tworzenie wątków
Rozdział 2.  Wątki 41

Thread[] tt = new Thread[ileWatkow];


for (int i = 0; i < ileWatkow; ++i)
{
tt[i] = new Thread(uruchamianieObliczenPi);
tt[i].Priority = ThreadPriority.Lowest;
tt[i].Start();
}

//czekanie na zakończenie wątków


foreach (Thread t in tt)
{
t.Join();
Console.WriteLine("Zakończył działanie wątek nr {0}", t.ManagedThreadId);
}
pi /= ileWatkow;
Console.WriteLine("Wszystkie wątki zakończyły działanie.\nUśrednione Pi={0},
błąd={1}", pi, Math.Abs(Math.PI - pi));

int czasKoncowy = Environment.TickCount;


int roznica = czasKoncowy - czasPoczatkowy;
Console.WriteLine("Czas obliczeń: " + (roznica).ToString());
}

...

static void uruchamianieObliczenPi()


{
try
{
Console.WriteLine("Uruchamianie obliczeń, wątek nr {0}...",
Thread.CurrentThread.ManagedThreadId);

long ilośćPrób = 1000000000L / ileWatkow;


double pi = obliczPi(ilośćPrób: ilośćPrób);
Program.pi += pi;
Console.WriteLine("Pi={0}, błąd={1}, wątek nr {2}",
pi, Math.Abs(Math.PI - pi),
Thread.CurrentThread.ManagedThreadId);
}
catch (ThreadAbortException exc)
{
Console.WriteLine("Działanie wątku zostało przerwane (" + exc.Message + ")");
}
catch (Exception exc)
{
Console.WriteLine("Wyjątek (" + exc.Message + ")");
}
}
}

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

Oczekiwanie przez wątek główny na zakończenia wątków, w których prowadzone są


obliczenia, ponownie umożliwia mierzenie czasu działania aplikacji. W tabeli 2.1 przed-
stawiam porównanie czasu obliczeń (bez uśrednień) na komputerze z jednym, dwoma
i czterema procesorami dwurdzeniowymi. Ilość prób zwiększyłem do 109, priorytet
wątku zwiększyłem do najwyższego, a projekt skompilowałem w trybie Release. Czas
obliczeń powinien maleć wraz ze wzrostem ilości wątków do momentu, w którym ilość
wątków równa jest ilości dostępnych rdzeni. Dalszy wzrost ilości wątków nie powinien
już skracać obliczeń. Może je wręcz wydłużyć, szczególnie wtedy, kiedy obliczenia
wykonywane przez poszczególny wątek są krótkie i istotny staje się czas zużyty na
tworzenie wątku lub koszty zarządzania planisty systemowego.

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

Sekcje krytyczne (lock)


Wszystkie wątki działają jednocześnie. Czasem jednak trzeba, aby pewne czynności
wykonywane przez wątki nie odbywały się równolegle. Załóżmy dla przykładu, że wątki
zapisują dane do współdzielonej zmiennej lub do wspólnego pliku. Aby uniknąć utraty
danych z wątku, który zdążył już otworzyć plik, ale jeszcze danych nie zmodyfikował,
w trakcie, gdy inny plik już je zapisuje, konieczne jest zapewnienie, że pewna grupa
instrukcji wykonywana jest tylko przez jeden wątek jednocześnie.

W naszych obliczeniach miejscem, w którym powinna być użyta synchronizacja, jest


przede wszystkim zapis do współdzielonej zmiennej Program.pi, choć w praktyce praw-
dopodobieństwo, że dwa wątki będą chciały ją zmienić dokładnie w tej samej chwili,
jest niewielkie. Zatem, abyśmy mogli zaobserwować działanie blokady, przeprowadzimy
dodatkową synchronizację w połowie obliczeń. Jej celem, tj. czynnością wykonywaną
tylko przez jeden wątek na raz, tzw. sekcją krytyczną, będzie wyświetlenie komuni-
katu na ekranie. Pokazuję to na listingu 2.10.

Listing 2.10. Synchronizacja wątków w połowie obliczeń


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)
{
if (i == ilośćPrób / 2)
{
lock ((object)Program.pi) //pudełkowanie
{
Console.WriteLine("Synchronizacja: wątek nr {0} osiągnął półmetek",
Thread.CurrentThread.ManagedThreadId);
}
}
x = r.NextDouble();
y = r.NextDouble();
if (x * x + y * y < 1) ++ilośćTrafień;
}
return 4.0 * ilośćTrafień / ilośćPrób;
}

...

static void uruchamianieObliczenPi()


{
try
{
Console.WriteLine("Uruchamianie obliczeń, wątek nr {0}...",
Thread.CurrentThread.ManagedThreadId);

long ilośćPrób = 1000000000L / ileWatkow;


double pi = obliczPi(ilośćPrób: ilośćPrób);
lock(r) Program.pi += pi;
44 Programowanie równoległe i asynchroniczne w C# 5.0

Console.WriteLine("Pi={0}, błąd={1}, wątek nr {2}",


pi, Math.Abs(Math.PI - pi),
Thread.CurrentThread.ManagedThreadId);
}
catch (ThreadAbortException exc)
{
Console.WriteLine("Działanie wątku zostało przerwane (" + exc.Message + ")");
}
catch (Exception exc)
{
Console.WriteLine("Wyjątek (" + exc.Message + ")");
}
}
}

Po wejściu przez jeden z wątków do sekcji krytycznej znajdującej się w nawiasach


klamrowych instrukcji lock inne wątki, które również napotkają instrukcje lock z tym
samym obiektem synchronizacji, muszą czekać. Dopiero po kompletnym wykonaniu
instrukcji z sekcji krytycznej pierwszego wątku10 kolejny może „wejść do środka”.
Zmienna stosowana do synchronizacji powinna być typu referencyjnego. Użyłem do
tego referencji do generatora liczb pseudolosowych11.

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.

Przesyłanie danych do wątku


Wątki, a konkretnie metody, które są w nich wykonywane, mają dostęp do obiektów
„globalnych” aplikacji (w naszym przypadku są to statyczne pola klasy Program).
Mogą je odczytywać lub wywoływać ich metody (jak w przypadku obiektu Program.r
— instancji generatora liczb pseudolosowych), mogą je też modyfikować (casus zmien-
nej Program.pi). Dane do wątku można zatem umieszczać w „globalnej” tablicy, w któ-
rej każdy wątek ma przeznaczoną dla siebie jedną komórkę (rozróżnienia między
wątkami i wyboru komórki można dokonać za pomocą unikalnego identyfikatora wąt-
ku). Oznacza to jednak, że nie mamy wpływu na to, który wątek sięgnie po dane z jakiejś
komórki tablicy — identyfikatory wątków przydziela sama platforma .NET. Dlatego
byłoby wygodniej, gdyby można było przesłać jakieś dane bezpośrednio do wątku,
choćby indeks tablicy, w której zebrane zostały obiekty reprezentujące dane wejściowe
obliczeń lub parametry tych obliczeń. Jak to zrobić?

Wspomniałem wyżej, że konstruktor klasy Thread jest przeciążony i może pobierać


zarówno delegata typu ThreadStart, jak i delegata ParameterizedThreadStart. W tym
drugim przypadku metoda wykonywana w wątku pobiera jeden parametr typu object.
Obiekt ten będzie wskazywany w argumencie metody Start. Na listingu 2.11 poka-
zuję zmiany w metodzie Main i w metodzie uruchamianieObliczenPi, które prowadzą
do przesłania obiektu (w naszym przykładzie indeksu wątku) z wątku głównego do
dodatkowych wątków. Na rysunku 2.12 można zobaczyć potwierdzenie, że obiekty te
są poprawnie odbierane.

Listing 2.11. Przekazywanie parametru do metody wykonywanej w dodatkowych wątkach


static void Main(string[] args)
{
int czasPoczatkowy = Environment.TickCount;
//tworzenie wątków
46 Programowanie równoległe i asynchroniczne w C# 5.0

Thread[] tt = new Thread[ileWatkow];


for (int i = 0; i < ileWatkow; ++i)
{
tt[i] = new Thread(uruchamianieObliczenPi);
tt[i].Priority = ThreadPriority.Lowest;
tt[i].Start(i);
}

//czekanie na zakończenie wątków


foreach (Thread t in tt)
{
t.Join();
Console.WriteLine("Zakończył działanie wątek nr {0}", t.ManagedThreadId);
}
pi /= ileWatkow;
Console.WriteLine("Wszystkie wątki zakończyły działanie.\nUśrednione Pi={0},
błąd={1}", pi, Math.Abs(Math.PI - pi));

int czasKoncowy = Environment.TickCount;


int roznica = czasKoncowy - czasPoczatkowy;
Console.WriteLine("Czas obliczeń: " + (roznica).ToString());
}
...
static void uruchamianieObliczenPi(object parametr)
{
try
{
int? indeks = parametr as int?;
Console.WriteLine("Uruchamianie obliczeń, wątek nr {0}, indeks {1}...",
Thread.CurrentThread.ManagedThreadId,
indeks.HasValue?indeks.Value.ToString():"---");

long ilośćPrób = 10000000L / ileWatkow;


double pi = obliczPi(ilośćPrób: ilośćPrób);
Program.pi += pi;
Console.WriteLine("Pi={0}, błąd={1}, wątek nr {2}", pi,
Math.Abs(Math.PI - pi),
Thread.CurrentThread.ManagedThreadId);
}
catch (ThreadAbortException exc)
{
Console.WriteLine("Działanie wątku zostało przerwane (" + exc.Message + ")");
}
catch (Exception exc)
{
Console.WriteLine("Wyjątek (" + exc.Message + ")");
}
}

Oczywiście, możliwość przesyłania jednego parametru do wątku może być niewystar-


czająca. Zwróćmy jednak uwagę, że przesyłamy tak naprawdę referencję do obiektu.
O tym, czym będzie ten obiekt, decyduje programista. Może to być zatem instancja
zdefiniowanej przez nas klasy, w której umieścimy potrzebne dane, kolekcja lub po
prostu indeks do tablic, w których parametry takie są zgromadzone.
Rozdział 2.  Wątki 47

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.

Aby przetestować to rozwiązanie, zastąpmy dotychczasową prostą tablicę wątków pro-


fesjonalnym menedżerem. To on przejmie obowiązki związane z tworzeniem wątków.
Naszym zadaniem będzie jedynie określenie ilości działających wątków i wskazanie
metody, która ma być w nich uruchamiana (listing 2.12).

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;

static void Main(string[] args)


{
int czasPoczatkowy = Environment.TickCount;

//tworzenie wątków
WaitCallback metodaWatku = uruchamianieObliczenPi;
ThreadPool.SetMaxThreads(30, 100);
for (int i = 0; i < ileWatkow; ++i)
{
ThreadPool.QueueUserWorkItem(metodaWatku, i);
}

//czekanie na zakończenie wątków


int ileDostepnychWatkowWPuli = 0; //nieużywane wątki puli
int ileWszystkichWatkowWPuli = 0; //wszystkie wątki puli
int ileDzialajacychWatkowPuli = 0; //używane wątki puli
int tmp = 0;
do
{
ThreadPool.GetAvailableThreads(out ileDostepnychWatkowWPuli, out tmp);
ThreadPool.GetMaxThreads(out ileWszystkichWatkowWPuli, out tmp);
ileDzialajacychWatkowPuli = ileWszystkichWatkowWPuli - ileDostepnychWatkowWPuli;
Console.WriteLine("Ilość aktywnych wątków puli: {0}", ileDzialajacychWatkowPuli);
Thread.Sleep(1000);
}
while (ileDzialajacychWatkowPuli > 0);
pi /= ileWatkow;
Console.WriteLine("Wszystkie wątki zakończyły działanie.\nUśrednione Pi={0},
błąd={1}", pi, Math.Abs(Math.PI - pi));

int czasKoncowy = Environment.TickCount;


int roznica = czasKoncowy - czasPoczatkowy;
Console.WriteLine("Czas obliczeń: " + (roznica).ToString());
}
Rozdział 2.  Wątki 49

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.

Metoda ThreadPool.QueueUserWorkItem pozwala na dodawanie zadań do wykonania


przez wątki z puli wątków. Formują one kolejkę, w której przechowywane są referen-
cje do metod o sygnaturze określonej przez delegata WaitCallback. Ponieważ chcemy
uruchomić sto zupełnie jednakowych zadań, w kolejce umieszczamy sto razy referencję
do tej samej metody uruchamianieObliczenPi. Z każdą z nich wiążemy jednak inną
wartość indeksu, co ułatwia ich odróżnianie w trakcie wykonywania.

Po uruchomieniu programu wątek główny co sekundę monitoruje ilość rzeczywiście


działających wątków, prezentując je na ekranie (listing 2.12, rysunek 2.13). Ilość tę
możemy obliczyć, korzystając z metod ThreadPool.GetAvailableThreads i ThreadPool.
GetMaxThreads zwracających liczbę dostępnych do użycia wątków w puli i zadekla-
rowaną przez nas liczbę wszystkich wątków.

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

Jeszcze raz o sygnalizacji


zakończenia pracy wątków
Zamiast sprawdzać, czy ilość wątków rzeczywiście działających w puli spadła do zera,
co należy traktować jedynie jako rozwiązanie prowizoryczne, możemy zmusić wątki
do sygnalizowania zakończenia pracy. Jedną z możliwości jest użycie klasy Monitor,
a dokładnie jej metod Pulse i WaitOne. O nich więcej informacji podaję w rozdziale 4.,
a teraz proponuję użyć innego mechanizmu — klasy EventWaitHandle. Utworzymy
tablicę takich obiektów o rozmiarze równym ilości zadań. Dodatkowy wątek będzie
po zakończeniu obliczeń wywoływał metodę EventWaitHandle.Set, o czym dowiemy się
w głównym wątku, uruchamiając metodę WaitOne na rzecz wszystkich obiektów z tabli-
cy13. Nowe wersje metod Main i uruchamianieObliczenPi widoczne są na listingu 2.13.

Listing 2.13. Sygnalizacja zakończenia wątku za pomocą klasy EventWaitHandle


class Program
{
static Random r = new Random();
const int ileWatkow = 100;
static double pi = 0;

static EventWaitHandle[] ewht = new EventWaitHandle[ileWatkow];

static void Main(string[] args)


{
int czasPoczatkowy = Environment.TickCount;

//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);
}

//czekanie na zakończenie wątków


for (int i = 0; i < ileWatkow; ++i) ewht[i].WaitOne();
pi /= ileWatkow;
Console.WriteLine("Wszystkie wątki zakończyły działanie.\nUśrednione Pi={0},
błąd={1}", pi, Math.Abs(Math.PI - pi));

int czasKoncowy = Environment.TickCount;


int roznica = czasKoncowy - czasPoczatkowy;
Console.WriteLine("Czas obliczeń: " + (roznica).ToString());
}

...

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

static void uruchamianieObliczenPi(object parametr)


{
try
{
int? indeks = parametr as int?;
Console.WriteLine("Uruchamianie obliczeń, wątek nr {0}, indeks {1}...",
Thread.CurrentThread.ManagedThreadId,
indeks.HasValue?indeks.Value.ToString():"---");
double pi = obliczPi(ilośćPrób: 10000000L);
Program.pi += pi;
Console.WriteLine("Pi={0}, błąd={1}, wątek nr {2}",
pi, Math.Abs(Math.PI - pi),
Thread.CurrentThread.ManagedThreadId);
ewht[indeks.Value].Set();
}
catch (ThreadAbortException exc)
{
Console.WriteLine("Działanie wątku zostało przerwane (" + exc.Message + ")");
}
catch (Exception exc)
{
Console.WriteLine("Wyjątek (" + exc.Message + ")");
}
}
}

Innym sposobem, odpowiednim w sytuacji, w której czekamy na zakończenie wielu


tak samo działających wątków, jest użycie klasy CountdownEvent. Pojawiła się ona
jednak dopiero w platformie .NET w wersji 4.0.

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; }

Nie wiąże się to z dużym obciążeniem numerycznym, bo operacja ta wykonywana


jest tylko raz w każdym wątku, a ponadto niewielkie jest prawdopodobieństwo, że dwa
wątki będą chciały wykonywać ją jednocześnie.

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

w jednym cyklu procesora. Z definicji są zatem w pełni bezpieczne w aplikacjach ko-


rzystających z wielu wątków, bo nie ma możliwości, aby jakaś inna operacja mogła je
zakłócić. Co więcej, operacje takie nie wymagają żadnych dodatkowych mechani-
zmów synchronizacji, zatem ich koszt obliczeniowy jest niewielki.

W platformie .NET operacje atomowe umożliwia klasa System.Threading.Interlocked.


W szczególności na atomowe zwiększanie wartości zmiennej pozwala metoda Inter-
locked.Add. Korzysta do tego z serii funkcji WinAPI InterlockedAdd.. i umożliwia
operacje na zmiennych całkowitych typu int i long. Nie ma wśród nich typu double,
który wymagałby większej ilości operacji. A to oznacza, że nie możemy metody Inter-
locked.Add użyć na zmiennej Program.pi — możemy jednak zmodyfikować pro-
gram tak, aby sumować ilość trafień, którą zapisujemy w zmiennej typu long. Poka-
zuję to na listingu 2.14.

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;

static EventWaitHandle[] ewht = new EventWaitHandle[ileWatkow];

static void Main(string[] args)


{
int czasPoczatkowy = Environment.TickCount;

//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);
}

for (int i = 0; i < ileWatkow; ++i) ewht[i].WaitOne();


double pi = 4.0 * całkowitaIlośćTrafień / (ilośćPróbWWątku*ileWatkow);
Console.WriteLine("Wszystkie wątki zakończyły działanie.\nUśrednione
Pi={0}, błąd={1}", pi, Math.Abs(Math.PI - pi));

int czasKoncowy = Environment.TickCount;


int roznica = czasKoncowy - czasPoczatkowy;
Console.WriteLine("Czas obliczeń: " + (roznica).ToString());
}

static long obliczPi(long ilośćPrób)


{
Random r = new Random(Program.r.Next() & DateTime.Now.Millisecond);
double x, y;
long ilośćTrafień = 0;
for (long i = 0; i < ilośćPrób; ++i)
Rozdział 2.  Wątki 53

{
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ń;
}

static void uruchamianieObliczenPi(object parametr)


{
try
{
int? indeks = parametr as int?;
Console.WriteLine("Uruchamianie obliczeń, watek nr {0}, indeks {1}
...", Thread.CurrentThread.ManagedThreadId,
indeks.HasValue?indeks.Value.ToString():"---");

long ilośćPrób = 1000000000L / ileWatkow;


long ilośćTrafień = obliczPi(ilośćPrób: ilośćPróbWWątku);
Program.pi += pi;
Interlocked.Add(ref całkowitaIlośćTrafień, ilośćTrafień);
Console.WriteLine("Pi={0}, błąd={1}, wątek nr {2}", pi,
Math.Abs(Math.PI - pi), Thread.CurrentThread.ManagedThreadId);
ewht[indeks.Value].Set();
}
catch (ThreadAbortException exc)
{
Console.WriteLine("Działanie wątku zostało przerwane ("+exc.Message+")");
}
catch (Exception exc)
{
Console.WriteLine("Wyjątek (" + exc.Message + ")");
}
}
}

Oprócz metody Add umożliwiającej operację dodawania do istniejącej zmiennej wska-


zanej w argumencie wartości, klasa Interlocked posiada również metody pozwalają-
ce na zamianę wartości dwóch zmiennych (w tym również wartości typu double) oraz
inkrementację i dekrementację zmiennych całkowitych.

Wykonałem testy, w których zdefiniowałem „globalną” zmienną ilośćTrafień i in-


krementowałem ją bezpośrednio w metodzie obliczPi w każdej iteracji. W takiej sytuacji
próba jednoczesnego zmieniania tej zmiennej była bardziej prawdopodobna i powo-
dowała wyraźne przekłamanie wyniku. Synchronizacja była wobec tego ewidentnie
potrzebna. Sprawdziłem zarówno Interlocked.Increment, jak i operator lock obejmujący
jedynie polecenie inkrementacji. W obu przypadkach obliczenia wydłużyły się mniej
więcej trzykrotnie. Zaskoczył mnie fakt, że synchronizacja dla operacji atomowych nie
okazała się wyraźnie krótsza od synchronizacji opartych na sekcjach krytycznych.

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

Tworzenie wątków za pomocą


System.Threading.Timer i imitacja
timera w wątku z wysokim priorytetem
Do puli wątków można dodawać wątki w jeszcze inny sposób. Z puli wątków (przypo-
minam, że w aplikacji jest tylko jedna!) korzysta także klasa System.Threading.Timer14.
Jest ona przeznaczona do czynności, które są wykonywane okresowo. Przygotujmy
w ten sposób wątek „sprawozdawczy”, który będzie informował użytkownika o po-
stępie obliczeń. Niech informacja o ilości przeprowadzonych prób pojawia się co se-
kundę bez opóźnienia. W tym celu trzeba skonfigurować obiekt typu Timer w sposób
pokazany na listingu 2.15. Polecenie to należy dodać do metody Main.
Listing 2.15. Użycie klasy System.Threading.Timer
Timer timer = new Timer(
(object state) =>
{
Console.WriteLine("Ilość prób: " +
Interlocked.Read(ref całkowitaIlośćPrób).ToString() + "/" +
(ileWatkow * ilośćPróbWWątku).ToString());
}, //metoda typu TimerCallback (void (object))
null, //obiekt przesyłany do metody jako argument (state)
0, //opóźnienie (czas do pierwszego uruchomienia metody)
1000); //co ile milisekund uruchamiana będzie metoda

Metoda timera odczytuje zmienną całkowitaIlośćPrób, którą należy zdefiniować w kla-


sie Program i zwiększać jej wartość o 1 w metodzie obliczPi przy każdym losowaniu
liczb (listing 2.16). Korzystamy do tego z metody Interlocked.Increment.
Listing 2.16. Deklaracja i modyfikowanie licznika prób
static long całkowitaIlośćPrób = 0L;
...
static long obliczPi(long ilośćPrób)
{
Random r = new Random(Program.r.Next() & DateTime.Now.Millisecond);
double x, y;
long ilośćTrafień = 0;
for (long i = 0; i < ilośćPrób; ++i)
{
x = r.NextDouble();
y = r.NextDouble();
if (x * x + y * y < 1) ++ilośćTrafień;
Interlocked.Increment(ref całkowitaIlośćPrób);
//Console.WriteLine("x={0}, y={1}", x, y);
}
return ilośćTrafień;
}

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

Proszę zwrócić uwagę, że operacje atomowe zabezpieczają przed sytuacją, w której


jednocześnie następowałaby próba odczytu i zmiany zmiennej całkowitaIlośćPrób. Nie
musimy dzięki nim tworzyć sekcji krytycznych, które byłyby bardziej kosztowne.

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();

Pierwsze polecenie wyłącza timer, co zapobiega próbie uruchomienia jego metody po


wywołaniu metody Dispose zwalniającej używane przez niego zasoby systemowe.

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.

Listing 2.17. Użycie klasy System.Timers.Timer


System.Timers.Timer timer = new System.Timers.Timer(1000);
timer.Elapsed += new System.Timers.ElapsedEventHandler(
(object sender, System.Timers.ElapsedEventArgs e)
56 Programowanie równoległe i asynchroniczne w C# 5.0

=>
{
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.

Listing 2.18. Użycie wątku do okresowego wykonywania czynności


Thread watekAlaTimer = new Thread(
()=>
{
Console.WriteLine("Uruchamiam wątek sprawozdawczy");
try
{
while (true)
{
Thread.Sleep(1000);
Console.WriteLine("Ilość prób: " + Interlocked.Read(ref
całkowitaIlośćPrób).ToString() + "/"
+ (ileWatkow * ilośćPróbWWątku).ToString());
}
}
catch (ThreadAbortException exc)
{
Console.WriteLine("Przerywanie działania wątku
sprawozdawczego.\nKońcowa ilość prób: " + Interlocked.Read(ref
całkowitaIlośćPrób).ToString() + "/" +
(ileWatkow * ilośćPróbWWątku).ToString());
}
});
watekAlaTimer.Priority = ThreadPriority.Highest;
watekAlaTimer.IsBackground = true;
watekAlaTimer.Start();

...

watekAlaTimer.Abort();
Rozdział 2.  Wątki 57

W kontekście naszych dotychczasowych kłopotów, w powyższym kodzie kluczowe


jest nadanie wątkowi wysokiego priorytetu. Wątek imitujący timer ma wówczas pierw-
szeństwo w dostępie do procesora przed wątkami z puli wątków. Na szczęście, wyko-
nywane w nim polecenia nie są kosztowne, raportowanie o postępach w obliczeniach
nie zakłóci samych obliczeń.

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);
}
}

4. Utwórz aplikację z dwoma wątkami. Korzystając z obiektów klasy


EventWaitHandle, skonfiguruj je tak, żeby każdy z nich przez sekundę
drukował w konsoli swój numer wątku. Uruchomienie jednego z wątków
powinno powodować zatrzymanie drugiego. I odwrotnie.
5. Przygotuj klasę parametryczną, która, otaczając prostą zmienną zdefiniowaną
jako pole tej klasy (typu wskazanego przez parametr), przejmie obowiązek
zabezpieczenia odczytu lub przypisania. Należy w tym celu zdefiniować
własność BezpiecznaWartość, która w sekcjach get i set będzie korzystała z sekcji
krytycznych tworzonych poleceniem lock. Dlaczego takie rozwiązanie nie ma
sensu?
58 Programowanie równoległe i asynchroniczne w C# 5.0

6. Napisz program przeszukujący tysiącelementową tablicę w poszukiwaniu


minimalnej i maksymalnej wartości. Przyspiesz działanie owego programu,
korzystając z wielu wątków w najprostszy sposób, tj. dzieląc zakres
przeszukiwanych komórek równo pomiędzy wątki. Wykorzystaj tylko tyle
wątków, ile jest dostępnych rdzeni procesora.
Rozdział 3.
Zmienne w aplikacjach
wielowątkowych
Jacek Matulewski

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.

Listing 3.1. Prosty przykład użycia licznika w aplikacji wielowątkowej


class Program
{
//po usunięciu znaków komentarza każdy wątek będzie miał własną kopię tej zmiennej
//[ThreadStatic]
60 Programowanie równoległe i asynchroniczne w C# 5.0

static int licznik = 0;

static void Main(string[] args)


{
WaitCallback metodaWatku =
(object parametr) =>
{
Interlocked.Increment(ref licznik); //licznik++;
Console.WriteLine("Wątek: " + Thread.CurrentThread.
ManagedThreadId + ", licznik=" + licznik.ToString());
};
for (int i = 0; i < 4; ++i) ThreadPool.QueueUserWorkItem(metodaWatku, i);

Console.ReadLine(); //wątek główny czeka na dodatkowe wątki


}
}

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.

Listing 3.2. Ciało metody Main aplikacji konsolowej


Lazy<int> li = new Lazy<int>(() => 1); //deklaracja zmiennej i wskazanie funkcji
Console.WriteLine("Czy utworzona?: " + li.IsValueCreated.ToString()); //niezainicjowana
Console.WriteLine("Odwołanie do zmiennej, li=" + li.Value); //leniwa inicjacja
Console.WriteLine("Czy utworzona?: " + li.IsValueCreated.ToString()); //już zainicjowana
Console.ReadLine(); //wątek główny czeka na dodatkowe wątki

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.

Listing 3.3. Użycie „wrappera” Lazy<> w aplikacji Windows Forms


Lazy<Button> lb = new Lazy<Button>(() =>
{
Button b = new Button();
b.Parent = this;
b.Top = 100;
b.Left = 100;
b.Text = "Leniwy przycisk";
return b;
});
MessageBox.Show("Czy utworzona?: " + lb.IsValueCreated.ToString());
MessageBox.Show("Odwołanie do zmiennej, etykieta przycisku: \"" + lb.Value.Text +
"\"");
MessageBox.Show("Czy utworzona?: " + lb.IsValueCreated.ToString());

W przypadku wątków sprawa wygląda zasadniczo podobnie. Możliwe jest zdefinio-


wanie zmiennej z opóźnionym inicjowaniem, która jest dzielona między wątkami (listing
3.4). Wówczas pierwszy wątek, który się do niej odwołuje, powoduje jej rzeczywiste
zainicjowanie, nadając jej wartość równą identyfikatorowi tego wątku, a następne wątki
„widzą” już zmienną gotową do użycia (rysunek 3.3).

Listing 3.4. Opóźnione inicjowanie zmiennej w aplikacji wielowątkowej


class Program
{
static Lazy<int> li = new Lazy<int>(() => Thread.CurrentThread.ManagedThreadId);
62 Programowanie równoległe i asynchroniczne w C# 5.0

static void Main(string[] args)


{
WaitCallback metodaWatku =
(object parametr) =>
{
lock (li)
{
Console.WriteLine("Wątek: " + Thread.CurrentThread.
ManagedThreadId.ToString() + ", Czy utworzona?: " +
li.IsValueCreated.ToString()); //jeszcze niezainicjowana
Console.WriteLine("Wątek: " + Thread.CurrentThread.
ManagedThreadId.ToString() + ", li= " + li.Value.ToString());
//leniwa inicjacja
Console.WriteLine("Wątek: " + Thread.CurrentThread.
ManagedThreadId.ToString() + ", Czy utworzona?: " +
li.IsValueCreated.ToString()); //już zainicjowana
}
};
for (int i = 0; i < 4; ++i)
ThreadPool.QueueUserWorkItem(metodaWatku, i);

Console.ReadLine(); //wątek główny czeka na dodatkowe wątki


}
}

Rysunek 3.3.
Efekt użycia typu Lazy<>
przy wielu wątkach

Warto zwrócić uwagę na dodatkowe argumenty konstruktora klasy Lazy<>. Może to


być wartość logiczna isThreadSafe. Jeżeli ma wartość true, obiekt może być jedno-
cześnie używany przez wiele wątków, w przeciwnym razie dostęp do przechowywanej
wartości może mieć w danej chwili tylko jeden wątek. Drugim możliwym argumentem
jest obiekt typu wyliczeniowego LazyThreadSafetyMode, który określa sposób syn-
chronizacji wątków w momencie inicjacji zmiennej.

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<>

W kontekście opóźnionej inicjacji warto wspomnieć o jeszcze jednej nowej klasie


platformy .NET 4.0 System.Threading.LazyInitializer. Zawiera ona wielokrotnie
przeciążoną metodę EnsureInitialized<>, która sprawdza, czy instancja klasy (typ
referencyjny) jest zainicjowana. Jeżeli nie, a więc referencja równa jest null, używa
funkcji-fabryki do jego inicjacji. Pokazuję to na listingu 3.5. Podobnie jak w poprzednich
przykładach, należy jednak pamiętać, że przykład z listingu prezentuje jedynie ideę
opóźnionej inicjacji, a nie najlepsze praktyki jej użycia. Opóźnioną inicjację opłaca
się stosować wyłącznie wówczas, gdy koszt utworzenia instancji obiektu jest duży,
a potrzeba jego powstania nie jest pewna, przykładowo zależy od ścieżki przepływu
kontroli aplikacji, która może być ustalona dopiero w momencie działania aplikacji (np.
wewnątrz instrukcji if, w której spełnienie warunku zależy od decyzji użytkownika).

Listing 3.5. Użycie klasy LazyInitializer do zapewnienia inicjacji obiektu


private void button2_Click(object sender, EventArgs e)
{
//Button przycisk = new Button();
Button przycisk = null;
LazyInitializer.EnsureInitialized<Button>(
ref przycisk,
() =>
{
Button b = new Button();
b.Parent = this;
b.Top = 100;
b.Left = 200;
b.Text = "Leniwy przycisk";
return b;
}
);
MessageBox.Show("Odwołanie do zmiennej, etykieta przycisku: \"" +
przycisk.Text + "\"");
}
64 Programowanie równoległe i asynchroniczne w C# 5.0

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 rozdziale 2. być może niewystarczająco podkreśliłem wagę zagadnień związanych


z synchronizacją wątków. A są one naprawdę kluczowym elementem programowania
wielowątkowego. W prostych obliczeniach przedstawionych w tamtym rozdziale dodat-
kowe wątki są wprawdzie wzajemnie niezależne, jednak wszystkie są zależne od wątku
związanego z formą, na której prezentują wyniki. Możliwość uruchomienia niezależnych
wątków to jednak rzadka sytuacja. W większości przypadków konieczna jest synchroni-
zacja wątków i to czasem kilkakrotnie w trakcie ich wykonywania. Rozważmy jako
przykład program, w którym przeprowadzamy symulację wielu oddziałujących ze sobą
ciał fizycznych. W każdym kroku symulacji siły działające na te ciała zależą od poło-
żenia pozostałych ciał. Po każdym kroku konieczne jest zatem obliczenie siły. Należy
przy tym przypilnować, aby żaden z wątków obliczających położenia i prędkości po-
szczególnych ciał nie zaczął kolejnego kroku symulacji lub nie spóźnił się z dostar-
czeniem wyników z poprzedniego kroku. Innym typowym przykładem jest dostęp do
pliku przez wiele wątków. Jeżeli zawartość pliku ma być uporządkowana, wątki nie mo-
gą zapisywać w nim danych w dowolnej kolejności — zapis wymaga zatem synchro-
nizacji. Podobnych sytuacji można znaleźć znacznie więcej. Rozpoznanie ich i odpo-
wiednie zabezpieczenie to zasadnicze zadanie programisty aplikacji wielowątkowych.

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

Tabela 4.1. Porównanie klas służących do synchronizacji wątków za pomocą blokad


Nakład
Klasa Opis Strona Uwagi
czasu1
lock (Monitor. Tworzy sekcję krytyczną 43 20 ns
Enter/ Monitor. (dostęp tylko dla jednego
Exit) wątku jednocześnie)
Mutex Jak wyżej, ale dotyczy także 89 Synchronizuje także 1000 ns
wątków w różnych aplikacjach wątki w różnych
procesach (aplikacjach)
Semaphore Do sekcji krytycznej 91 Synchronizuje także 1000 ns
wpuszczana jest określona wątki w różnych
ilość wątków (dotyczy to także procesach (aplikacjach)
wątków w różnych aplikacjach)
SemaphoreSlim Jak wyżej, ale tylko w obrębie 93 Od wersji 4.0 200 ns
jednego procesu platformy .NET
ReaderWriterLock Realizuje scenariusz 73 Użycie odradzane. 100 ns
czytelników i pisarzy Należy użyć wersji
..Slim
ReaderWriter Jak wyżej, ale lepiej 73 Od wersji 3.5 40 ns
LockSlim zaimplementowane platformy .NET

Problem ucztujących filozofów


Aby uzmysłowić sobie możliwe problemy, rozważmy choćby przykład dwóch wątków,
które jednocześnie próbują uzyskać wyłączny dostęp do np. dwóch tablic. Oba wątki
potrzebują wyłącznego dostępu do obu tablic, aby przejść sekcję krytyczną, w której
są przykładowo kopiowane dane między tymi tablicami. Utworzenie takich sekcji kry-
tycznych umożliwia instrukcja lock omówiona w 2. rozdziale. Dostęp do obu tablic
nie jest jednak rezerwowany w jednej „transakcji”. Rezerwacje mogą być przeprowa-
dzane niezależnie. Wówczas może się zdarzyć sytuacja, w której jeden z wątków zare-
zerwuje jedną z tablic, a drugi — drugą. Każdy z wątków musi uzyskać dostęp do dru-
giej tablicy, aby przejść sekcję krytyczną, ale nie może tego zrobić (listing 4.1, w którym
ów przykład ubrany jest w terminologię bankową). Taka sytuacja będzie trwała w nie-
skończoność, co nazywane jest zakleszczeniem (ang. deadlock). Problem ten został
ogólniej sformułowany jako problem pięciu ucztujących filozofów. Przed każdym z nich
stoi pięć misek ryżu, ale mają do dyspozycji nie dziesięć, ale tylko pięć pałeczek. Każda
z pałeczek leży między dwoma sąsiadującymi przy stole filozofami i jest przez nich
współdzielona. A ponieważ do jedzenia ryżu potrzebne są dwie pałeczki, filozof je-
dzący w danej chwili uniemożliwia jedzenie obu swoim sąsiadom. Wyobraźmy sobie
teraz sytuację, w której każdy z filozofów podniósł jedną pałeczkę. Żaden nie może
jeszcze jeść, bo ma tylko jedną pałeczkę. Każdy z nich zatem czeka, aż sąsiad zwolni
drugą pałeczkę. To jednak nie nastąpi, bo warunkiem odłożenia pałeczki jest wcze-

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ść.

Listing 4.1. Klasyczny przykład operacji bankowych


(por. http://stackoverflow.com/questions/1385843/simple-deadlock-examples)
class Program
{
class Konto
{
private decimal saldo;
private int id;

public Konto(decimal saldoPoczatkowe, int id)


{
saldo = saldoPoczatkowe;
this.id = id;
}

public void Wypłata(decimal kwota)


{
saldo -= kwota;
//Console.WriteLine("Nastąpiła wypłata z konta {0} kwoty {1}. Saldo po
operacji {2}.", id, kwota, saldo);
}

public void Wpłata(decimal kwota)


{
saldo += kwota;
//Console.WriteLine("Nastąpiła wpłata na konto {0} kwoty {1}. Saldo po
operacji {2}.", id, kwota, saldo);
}

public static void Przelew(Konto kontoPłatnika, Konto kontoOdbiorcy,


decimal kwota)
{
if (kontoOdbiorcy == kontoPłatnika) throw new
ArgumentException("Niemożliwe jest wykonanie przelewu na to samo konto");

Console.WriteLine("Przygotowanie do przelewu z konta {0} na konto {1}


kwoty {2}.", kontoPłatnika.id, kontoOdbiorcy.id, kwota);
Console.WriteLine("Salda przed przelewem: konto {0} - saldo {1},
konto {2} - saldo {3}", kontoPłatnika.id, kontoPłatnika.saldo,
kontoOdbiorcy.id, kontoOdbiorcy.saldo);
lock (kontoPłatnika)
{
Console.WriteLine("Dostęp do konta płatnika {0} zarezerwowany",
kontoPłatnika.id);
Thread.Sleep(100);
lock (kontoOdbiorcy)
{
70 Programowanie równoległe i asynchroniczne w C# 5.0

Console.WriteLine("Dostęp do konta odbiorcy {0} zarezerwowany",


kontoOdbiorcy.id);
kontoPłatnika.Wypłata(kwota);
kontoOdbiorcy.Wpłata(kwota);
}
Console.WriteLine("Dostęp do konta odbiorcy {0} zwolniony",
kontoOdbiorcy.id);
}
Console.WriteLine("Dostęp do konta płatnika {0} zwolniony",
kontoPłatnika.id);
Console.WriteLine("Wykonany został przelew z konta {0} na konto {1}
kwoty {2}.", kontoPłatnika.id, kontoOdbiorcy.id, kwota);
Console.WriteLine("Salda po przelewie: konto {0} - saldo {1},
konto {2} - saldo {3}", kontoPłatnika.id, kontoPłatnika.saldo,
kontoOdbiorcy.id, kontoOdbiorcy.saldo);
}
}

class PoleceniePrzelewu
{
public Konto KontoPłatnika;
public Konto KontoOdbiorcy;
public decimal Kwota;
}

static void Main(string[] args)


{
Konto konto1 = new Konto(100, 1);
Konto konto2 = new Konto(150, 2);

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 });

Console.ReadLine(); //wątek główny czeka na dodatkowe wątki


}
}

Na listingu 4.1 przedstawiłem klasę będącą prostą implementacją konta bankowego.


Klasa ta posiada metody Wpłata i Wypłata, które umożliwiają wpłatę i wypłatę okre-
ślonej w argumencie kwoty pieniędzy. Konta są rozpoznawane na podstawie identyfi-
katora, który wskazywany jest w momencie tworzenia konta, tj. w konstruktorze klasy
Konto. Oprócz tych dwóch operacji możliwe jest również przekazanie pewnej sumy
z jednego konta na drugie. Implementuje to statyczna metoda Przelew klasy Konto, która
korzysta z wcześniej wspomnianych metod pozwalających na pobranie i wpłacenie
Rozdział 4.  Więcej o synchronizacji wątków. Blokady i sygnały 71

pieniędzy na konto. Aby zabezpieczyć tę operację przed niepożądanymi efektami wy-


nikającymi z jednoczesnego dostępu do konta, operacja przelewu rezerwuje wyłączny
dostęp do obu kont — najpierw do konta, z którego wypłacane są pieniądze, a następnie
(z opóźnieniem 1/10 sekundy symulującym np. czas na połączenia między systemami
bankowymi) do konta, na które pieniądze będą wpłacane. Przygotowana jest również
klasa PoleceniePrzelewu (por. wzorzec projektowy command) przechowująca dane
niezbędne do dokonania przelewu, a więc referencje do kont płatnika i odbiorcy oraz
kwotę przelewu. W metodzie Main tworzone są dwa konta (o identyfikatorach 1 i 2).
Następnie w puli wątków umieszczane są dwie operacje przelewu, obie z konta 1 na
konto 2. Ten wątek, który rozpocznie się jako pierwszy, zarezerwuje konto 1, a po 1/10
sekundy konto 2. Drugi wątek nie będzie mógł zarezerwować konta 1, zatem będzie
wstrzymany do momentu zakończenia operacji przeprowadzanej przez pierwszy wątek
(rysunek 4.1). Potem druga operacja zostanie wykonana i program zakończy działanie
bez błędów.

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 });

W realizujących je wątkach nastąpi rezerwacja konta płatnika (odpowiednio konta 1 i 2).


Następnie wątki podejmą próbę rezerwacji konta odbiorcy. Konta te, pełniące jedno-
cześnie role kont płatników w drugim wątku, będą już zarezerwowane. W efekcie nastąpi
wzajemne zablokowanie wątków, czyli ich zakleszczenie, i działanie obu wątków zostanie
trwale wstrzymane. Ponieważ żaden z wątków nie będzie mógł kontynuować działania,
mówi się, że zostaną zagłodzone (ang. starvation, metafora pochodząca z przykładu
ucztujących filozofów, którzy przez zakleszczenie nie mogą się posilić). Zauważmy,
że podobna sytuacja nie musi dotyczyć tylko dwóch kont. Jeżeli utworzymy pięć kont
i chcemy wykonać jednocześnie przelewy z konta 1 do konta 2, z konta 2 do konta 3 itd.
aż do konta 5, z którego chcemy przelać pewną kwotę na konto 1 (analogicznie do pier-
ścienia z przykładu o ucztujących filozofach), również może nastąpić zakleszczenie.

Jak rozwiązać ten problem? Można — oczywiście — zsynchronizować wszystkie ope-


racje, umożliwiając w całym systemie realizację tylko jednego przelewu na raz. To
sensowne rozwiązanie, jeżeli mamy dwa konta, ale zupełnie niepraktyczne, gdy kont
72 Programowanie równoległe i asynchroniczne w C# 5.0

są tysiące. Przelewy dotyczą wówczas w ogromnej większości różnych kont i pełna


synchronizacja niepotrzebnie blokowałaby możliwe do równoległego przeprowadze-
nia operacje. Innym sposobem jest wprowadzenie nadzorcy, który „świadomy” jest,
jakie konta są w danej chwili zarezerwowane, i wstrzymuje operacje angażujące konta
wcześniej zarezerwowane. Implementacja nadzorcy (w przypadku ucztujących filozofów
nazywa się go kelnerem) jest jednak dość kłopotliwa. Istnieje prostsze rozwiązanie.
Należy zauważyć, że w przypadku dowolnej ilości kont do zakleszczenia nie doszło-
by, jeżeli bez względu na kierunek przelewu zawsze rezerwowalibyśmy konto o niż-
szym numerze. Wówczas przy dwóch opisanych wyżej przelewach wpierw rezerwo-
wany byłby dostęp do konta 1 i drugi przelew musiałby czekać, nie blokując konta 2.
Pokazuję to na listingu 4.2. Zwróćmy uwagę, że rozwiązanie to nie wymaga żadnej
scentralizowanej kontroli i jest pewne (całkowicie zapobiega zakleszczeniom).

Listing 4.2. Rozwiązanie problemu zakleszczenia dzięki wprowadzeniu kolejności zasobów


public static void Przelew(Konto kontoPłatnika, Konto kontoOdbiorcy, decimal kwota)
{
if (kontoOdbiorcy == kontoPłatnika) throw new ArgumentException("Niemożliwe
jest wykonanie przelewu na to samo konto");
Konto kontoA, kontoB;
if (kontoPłatnika.id < kontoOdbiorcy.id)
{
kontoA = kontoPłatnika;
kontoB = kontoOdbiorcy;
}
else
{
kontoA = kontoOdbiorcy;
kontoB = kontoPłatnika;
}
Console.WriteLine("Przygotowanie do przelewu z konta {0} na konto {1} kwoty {2}.",
kontoPłatnika.id, kontoOdbiorcy.id, kwota);
Console.WriteLine("Salda przed przelewem: konto {0} - saldo {1}, konto {2} -
saldo {3}", kontoPłatnika.id, kontoPłatnika.saldo, kontoOdbiorcy.id,
kontoOdbiorcy.saldo);
lock (kontoA)
{
Console.WriteLine("Dostęp do konta {0} zarezerwowany", kontoA.id);
Thread.Sleep(100);
lock (kontoB)
{
Console.WriteLine("Dostęp do konta {0} zarezerwowany", kontoB.id);
kontoPłatnika.Wypłata(kwota);
kontoOdbiorcy.Wpłata(kwota);
}
Console.WriteLine("Dostęp do konta {0} zwolniony", kontoB.id);
}
Console.WriteLine("Dostęp do konta {0} zwolniony", kontoA.id);
Console.WriteLine("Wykonany został przelew z konta {0} na konto {1} kwoty
{2}.", kontoPłatnika.id, kontoOdbiorcy.id, kwota);
Console.WriteLine("Salda po przelewie: konto {0} - saldo {1}, konto {2} - saldo
{3}", kontoPłatnika.id, kontoPłatnika.saldo, kontoOdbiorcy.id,
kontoOdbiorcy.saldo);
}
Rozdział 4.  Więcej o synchronizacji wątków. Blokady i sygnały 73

Wprowadzenie uporządkowania zasobów (w naszym przypadku oparte po prostu na


kolejności numerów kont) zapobiega zakleszczeniom i sprawdza się doskonale w przy-
padku, gdy wątki realizujące procesy zawsze korzystają z określonej liczby zasobów
(w przypadku przelewów — z dwóch). Jeżeli jednak wątki wybierają zasoby już po
uruchomieniu, a ponadto zasoby (a nawet ich liczba) mogą się zmieniać w trakcie dzia-
łania wątku, rozwiązanie takie może nie być optymalne. Wówczas można wprowadzić
dodatkowy stan zasobu, który pilnuje, aby wątek nie przetrzymywał zasobu i oddawał go
innym wątkom po użyciu (rozwiązanie Chandy i Misra).

Problem czytelników i pisarzy


Innym typowym problemem w programowaniu współbieżnym jest problem czytelników
i pisarzy. Wyobraźmy sobie sytuację, w której do zasobu (np. listy, pliku lub urządze-
nia zewnętrznego) mają dostęp wątki, które mogą go modyfikować (pisarze), i wątki,
które jego stan mogą jedynie odczytywać (czytelnicy). Dostęp czytelników do listy
może być wobec tego całkowicie równoległy. Inaczej jest w przypadku pisarzy, których
dostęp do listy powinien być w pełni zsynchronizowany. Zatem pisarze, przed dopi-
saniem elementu do listy, rezerwują do niej wyłączny dostęp. Ale dostęp ten nie może
być zarezerwowany, jeżeli zasób jest akurat odczytywany. Innymi słowy: czytelnicy
rezerwują dostęp do zasobu wspólnie, a pisarze — indywidualnie. Problem polega na
takim zarządzaniu dostępem do zasobu, aby powyższe reguły były spełnione w sposób
optymalny.

Rozwiązaniem tego problemu w platformie .NET jest klasa ReaderWriterLockSlim.


Istnieje także starsza, mniej wydajna i z mniejszymi możliwościami wersja tej klasy
o nazwie ReaderWriterLock, ale jej użycie jest obecnie odradzane. Poniżej przedstawię
przykład użycia blokady ReaderWriterLockSlim (listing 4.3), choć ograniczony do pod-
stawowej użyteczności (pominę np. możliwość rozszerzenia blokady czytelników do
blokady pisarzy).

Listing 4.3. Przykład użycia klasy ReaderWriterLockSlim w scenariuszu czytelników i pisarzy


using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

using System.Threading;

namespace CzytelnicyPisarze
{
class Program
{
static Random r = new Random();
const int ileElementow = 10;
static int[] tablica = new int[ileElementow];

const int ileWatkowPisarzy = 2;


const int ileWatkowCzytelnikow = 10;
74 Programowanie równoległe i asynchroniczne w C# 5.0

const int maksymalnaPrzerwaMiedzyOdczytami = 1000; //10s


const int maksymalnaPrzerwaMiedzyModyfikacjami = 10000; //10s
const int maksymalnaDlugoscOdczytu = 1000; //1s
const int maksymalnaDlugoscModyfikacji = 100; //0.1s
static ReaderWriterLockSlim rwls = new ReaderWriterLockSlim();

static void modyfikujElement(int indeks, int? wartosc = null)


{
rwls.EnterWriteLock();
Console.WriteLine("Wątki czekajace na zapis: {0}, wątki czekajace na
odczyt: {1}", rwls.WaitingWriteCount, rwls.WaitingReadCount);
try
{
if (wartosc.HasValue) tablica[indeks] = wartosc.Value;
else tablica[indeks]++;
Console.WriteLine("Element " + indeks.ToString() + " został
zmieniony w wątku nr " + Thread.CurrentThread.ManagedThreadId);
Thread.Sleep(r.Next(maksymalnaDlugoscModyfikacji));
}
catch (Exception exc)
{
Console.WriteLine("Modyfikacja elementu " + indeks.ToString() + "
w wątku " + Thread.CurrentThread.ManagedThreadId + " nie jest
możliwa (" + exc.Message + ")");
}
finally
{
rwls.ExitWriteLock();
}
}

static int odczytajElement(int indeks)


{
int wynik = -1;
rwls.EnterReadLock();
Console.WriteLine("Wątki równocześnie odczytujące: {0}, Wątki czekające
na zapis: {1}", rwls.CurrentReadCount, rwls.WaitingWriteCount);
try
{
wynik = tablica[indeks];
Console.WriteLine("Element " + indeks.ToString() + " równy jest \""
+ wynik.ToString() + "\"");
Thread.Sleep(r.Next(maksymalnaDlugoscOdczytu));
return wynik;
}
catch (Exception exc)
{
Console.WriteLine("Odczyt elementu " + indeks.ToString() + " nie
jest możliwy (" + exc.Message + ")");
return wynik;
}
finally
{
rwls.ExitReadLock();
}
}
Rozdział 4.  Więcej o synchronizacji wątków. Blokady i sygnały 75

private static void wyswietlZawartoscTablicy()


{
Console.WriteLine("Zawartość tablicy:");
foreach (int element in tablica) Console.Write(element.ToString() + "\n");
Console.WriteLine("[Koniec]");
}

static void Main(string[] args)


{
//for (int i = 0; i < ileElementow; ++i) tablica[i] = 0;
wyswietlZawartoscTablicy();
Console.WriteLine("Naciśnij Enter...");
Console.WriteLine("Następnie naciśnij Enter, jeżeli będziesz chciał
zakończyć program.");
Console.ReadLine();

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ę");
}
}
};

Thread[] pisarze = new Thread[ileWatkowPisarzy];


for (int i = 0; i < ileWatkowPisarzy; ++i)
{
pisarze[i] = new Thread(akcjaPisarza);
//pisarze[i].Priority = ThreadPriority.AboveNormal;
pisarze[i].IsBackground = true;
pisarze[i].Start();
}

Thread[] czytelnicy = new Thread[ileWatkowCzytelnikow];


for (int i = 0; i < ileWatkowCzytelnikow; ++i)
{
czytelnicy[i] = new Thread(akcjaCzytelnika);
czytelnicy[i].IsBackground = true;
czytelnicy[i].Start();
}

//Console.WriteLine("Naciśnij Enter, jeżeli będziesz chciał zakończyć


program...");
Console.ReadLine();
Console.WriteLine("\nKończenie pracy programu...");
for (int i = 0; i < ileWatkowPisarzy; ++i) pisarze[i].Abort();
for (int i = 0; i < ileWatkowCzytelnikow; ++i) czytelnicy[i].Abort();

wyswietlZawartoscTablicy();
}
}
}

Na listingu 4.3 widoczna jest klasa CzytelnicyPisarze.Program z aplikacji konsolowej.


Zdefiniowana w niej tablica o nazwie tablica z elementami typu int będzie pełniła
rolę chronionego zasobu. Zdefiniowane są także metody dostępowe modyfikujElement
i odczytajElement (o nich więcej piszę niżej). W przypadku pierwszej z metod można
nie podać nowej wartości elementu, wówczas bieżąca wartość zostanie zwiększona o 1.
Jest również metoda wyswietlZawartoscTablicy prezentująca wszystkie elementy tablicy.
Oprócz tego mamy kilka parametrów decydujących o ilości wątków pisarzy i czytelni-
ków oraz różnego rodzaju opóźnieniach wprowadzonych do programu. Bez tych opóź-
nień sytuacja, w której dwa wątki próbują jednocześnie odczytać lub modyfikować
chroniony zasób, byłaby znacznie mniej prawdopodobna. Opóźnienia są dopasowane
do ilości wątków i szybkości mojego komputera. Warto trochę „pobawić się” tymi
parametrami i zobaczyć, jak ich wartości wpływają na działanie aplikacji. Zdefiniowane
jest również pole będące instancją klasy ReaderWriterLockSlim.
Rozdział 4.  Więcej o synchronizacji wątków. Blokady i sygnały 77

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.

Klasa ReaderWriterLockSlim umożliwia założenie dwóch typów blokad — można w ten


sposób utworzyć sekcję krytyczną dla czytelników (służą do tego metody EnterRead
Lock i ExitReadLock) oraz sekcję krytyczną dla pisarzy (metody EnterWriteLock i Exit
WriteLock). W obu parach metod należy zabezpieczyć się przed możliwością wy-
stąpienia wyjątku w obrębie sekcji krytycznej, dlatego metody powinny być użyte zgod-
nie z poniższym wzorcem:
rwls.EnterWriteLock(); //stworzenie blokady
try
{
//polecenia modyfikacji
}
finally
{
rwls.ExitWriteLock(); //zwolnienie blokady
}

Oba typy blokad znajdują się w metodach przeznaczonych do modyfikacji elementu


listy (modyfikujElement) i odczytania go (odczytajElement). Aż prosi się o zdefinio-
wanie własności, która połączyłaby obie metody dostępowe. Najlepiej do tego nadaje się
indeksator (ang. indexer):
private int this[int indeks]
{
get
{
return odczytajElement(indeks);
}
set
{
modyfikujElement(indeks, value);
}
}

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.

Pisząc aplikację wielowątkową, która korzysta z danych, musimy sporo wysiłku


poświęcić na zapewnienie bezpieczeństwa dostępu do tych danych. Jeżeli danych
jest dużo, a bezpieczeństwo jest priorytetem, warto rozważyć użycie bazy danych,
która przejmie od nas obowiązek strzeżenia bezpieczeństwa, zapewniając przy tym
trwałość przechowywania danych i wydajność dostępu do nich.
78 Programowanie równoległe i asynchroniczne w C# 5.0

Komunikacja między wątkami.


Problem producenta i konsumenta
Trzecim klasycznym problemem w aplikacjach współbieżnych jest tzw. problem kon-
sumentów i producentów. I w tym przypadku występują dwa typy wątków — produ-
cenci, którzy wstawiają obiekty do zasobu (np. kolejki lub stosu) i konsumenci, którzy
je odbierają. Problem polega na takim kontrolowaniu produkcji i konsumpcji, aby bu-
for się nie przepełnił, ale jednocześnie nie był pusty. Najprostszym rozwiązaniem jest
uśpienie producenta, gdy bufor jest zapełniony, lub konsumenta, gdyby został całkowicie
opróżniony. O ile wątek sam może się uśpić, poznawszy stan zasobu, do jego obudzenia
potrzebny jest impuls z zewnątrz. Może to robić — oczywiście — sam zasób, świa-
domy swojego stanu. W najprostszym przypadku, w którym jest tylko jeden produ-
cent i jeden konsument, może to zrobić wątek z drugiej grupy, który odbiera lub tworzy
element wkładany do bufora (w tym przypadku do komunikacji między wątkami wy-
godnie jest użyć metody Monitor.Pulse). Natomiast w przypadku wielu producentów
i konsumentów wygodniej skorzystać z dwóch semaforów implementowanych przez
klasę Semaphor, względnie ze wspomnianej w rozdziale 2. klasy CoundtownEvent2.

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();

static volatile bool watekProducentaAktywny = true;


static volatile bool watekKonsumentaAktywny = true;
static Thread watekProducenta = null;
static Thread watekKonsumenta = null;

const int maksymalnyCzasProdukcji = 1000;


const int maksymalnyCzasKonsumpcji = 1000;
const int maksymalnyCzasUruchomieniaProdukcji = 5000;
const int maksymalnyCzasUruchomieniaKonsumpcji = 5000;

static int pojemnoscMagazynu = 20;


static int licznikElementowWMagazynie = 1;

static void wyswietlStanMagazynu()


{
Console.WriteLine("Liczba elementów w magazynie: " +
licznikElementowWMagazynie.ToString());
}

static void Main(string[] args)


{
ThreadStart akcjaProducenta =
() =>
{
Console.WriteLine("Wątek producenta jest uruchamiany");
while (true)
{
if (watekProducentaAktywny)
Thread.Sleep(r.Next(maksymalnyCzasUruchomienia
Produkcji));
while (watekProducentaAktywny)
{
lock (obiektSynchronizacjiMagazynu)
{
licznikElementowWMagazynie++;
Console.Write("Element dodany. ");
}
wyswietlStanMagazynu();
if (licznikElementowWMagazynie >= pojemnoscMagazynu)
{
watekProducentaAktywny = false;
Console.WriteLine("Wątek producenta został uśpiony");
}
if (!watekKonsumentaAktywny)
{
Console.WriteLine("Wątek konsumenta jest wznawiany");
watekKonsumentaAktywny = true;
}
Thread.Sleep(r.Next(maksymalnyCzasProdukcji));
}
}
};
80 Programowanie równoległe i asynchroniczne w C# 5.0

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));
}
}
};

watekProducenta = new Thread(akcjaProducenta);


watekProducenta.IsBackground = true;
watekProducenta.Start();

watekKonsumenta = new Thread(akcjaKonsumenta);


watekKonsumenta.IsBackground = true;
watekKonsumenta.Start();

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

Oczywiście, najważniejsze, z punktu rozważanego problemu, są dwie akcje zdefinio-


wane w metodzie Main. Ich budowa jest analogiczna — omówię je zatem na przykła-
dzie akcji wątku producenta (wyróżnionej w listingu 4.4). Cały kod akcji zawarty jest
w nieskończonej pętli while (tu można by użyć flagi kończącej działanie wątku).
Wewnątrz niej jest polecenie uśpienia wątku imitujące czas niezbędny na rozpoczęcie
produkcji oraz pętla while z warunkiem ustanowionym przez flagę watekProducenta
Aktywny pozwalającą na wstrzymanie i wznowienie działania wątku. Wreszcie we-
wnątrz tej pętli znajduje się polecenie lock, które obejmuje wszystkie czynności związa-
ne z modyfikacją stanu chronionego zasobu, a więc w przypadku producenta zwiększe-
nie liczby elementów w magazynie (co w naszym przykładzie odpowiada przekazaniu
do magazynu produktu wykonanego przez wątek). Następnie, już za sekcją krytyczną,
sprawdzane jest, czy magazyn został całkowicie zapełniony. Jeżeli tak, wątek produ-
centa jest usypiany (flaga watekProducentaAktywny zostaje opuszczona). Następnie,
jeżeli konsument został wcześniej uśpiony, po dodaniu elementu do magazynu może
zostać ponownie włączony (należy jednak pamiętać, że nastąpi to z losowym opóź-
nieniem). Później wątek jest usypiany na losowy ustalony czas ograniczony przez pa-
rametr maksymalnyCzasProdukcji, co symuluje czas wytwarzania nowego elementu.

Aby obserwować mechanizm usypiania i wznawiania wątków, warto zaburzyć symetrię


obu wątków, modyfikując czasy produkcji.

Oczywiście, można bardziej szczegółowo określić poziomy, przy których wątki są


usypiane i wznawiane. W tej chwili producent jest usypiany, gdy magazyn jest pełny,
a wznawiany po zabraniu przez konsumenta choćby jednego elementu (bez względu
na to, w jakim stopniu magazyn jest zapełniony). Zamiast tego można by go wznawiać
tylko wtedy, gdy magazyn jest opróżniony do — powiedzmy — 75% pojemności.
Uniknęlibyśmy w ten sposób sytuacji, w której wątek producenta jest w kolejnych
krokach usypiany i wznawiany (wznowienie produkcji wiąże się z pewnym kosztem
czasowym). W scenariuszu, w którym za produkcję i konsumpcję odpowiada wiele
wątków, możliwe jest bardziej płynne kontrolowania tempa produkcji i konsumpcji
poprzez liczbę wątków. W naszym przykładzie moglibyśmy również kontrolować je,
modyfikując czas produkcji i konsumpcji — trudno jednak znaleźć odpowiednik w bar-
dziej realnej sytuacji.

Sygnalizacja za pomocą metod


Monitor.Pulse i Monitor.Wait
Zastąpmy teraz mechanizm oparty na „globalnie” dostępnych flagach (zmienne sta-
tyczne watekProducentaAktywny i watekKonsumentaAktywny) przez komunikację mię-
dzy wątkami bazującą na użyciu metod Monitor.Wait i Monitor.Pulse. Klasa Monitor
jest już znana, razem z jej metodami Enter i Exit. To one kryją się za stale używanym
przez nas słowem kluczowym lock.
82 Programowanie równoległe i asynchroniczne w C# 5.0

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();

static Thread watekProducenta = null;


static Thread watekKonsumenta = null;

const int maksymalnyCzasProdukcji = 1000;


const int maksymalnyCzasKonsumpcji = 1000;
const int maksymalnyCzasUruchomieniaProdukcji = 5000;
const int maksymalnyCzasUruchomieniaKonsumpcji = 5000;

static int pojemnoscMagazynu = 20;


static int licznikElementowWMagazynie = 1;

static void wyswietlStanMagazynu()


{
Console.WriteLine("Liczba elementów w magazynie: " +
licznikElementowWMagazynie.ToString());
}

static void Main(string[] args)


{
ThreadStart akcjaProducenta =
() =>
{
Console.WriteLine("Wątek producenta jest uruchamiany");
while (true)
{
lock (obiektSynchronizacjiMagazynu)
{
licznikElementowWMagazynie++;
Console.Write("Element dodany. ");
}
wyswietlStanMagazynu();
if (licznikElementowWMagazynie >= pojemnoscMagazynu)
{
Console.WriteLine("Wątek producenta zostanie uśpiony");
Rozdział 4.  Więcej o synchronizacji wątków. Blokady i sygnały 83

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));
}
};

watekProducenta = new Thread(akcjaProducenta);


watekProducenta.IsBackground = true;
watekProducenta.Start();

watekKonsumenta = new Thread(akcjaKonsumenta);


watekKonsumenta.IsBackground = true;
watekKonsumenta.Start();

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);

Metoda Monitor.Wait wstrzymuje działanie bieżącego wątku aż do momentu, w którym


inny wątek wywoła metodę Monitor.Pulse z tym samym obiektem wskazanym w argu-
mencie. Jednak zanim wątek zostanie wstrzymany, opuszcza blokadę, w której metoda
Wait jest wywoływana. Dzięki temu w ogóle możliwe jest uruchomienie metody Pulse,
które przecież również znajduje się w sekcji krytycznej. Próba wywołania tych metod
spoza sekcji krytycznej spowoduje wystąpienie wyjątku SynchronizationLockException.

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.

Tabela 4.2. Porównanie klas służących do synchronizacji wątków za pomocą sygnałów


Nakład
Klasa Opis Strona Uwagi
czasu4
Monitor.Wait/ Metoda Wait wstrzymuje działanie 81 Najszybszy 120 ns
Monitor.Pulse wątku aż do otrzymania sygnału sposób (dla Pulse)
z drugiego wątku wysłanego sygnalizacji
za pomocą metody Pulse między wątkami
w obrębie jednej
aplikacji
EventWaitHandle Metoda sygnalizacji działająca także 50, 85 1000 ns
między wątkami z różnych aplikacji
AutoResetEvent Jak EventWaitHandle w wersji 85 1000 ns
z automatycznym resetowaniem stanu
ManualResetEvent Jak EventWaitHandle w wersji 85 1000 ns
z ręcznym resetowaniem stanu
ManualReset Wyłącznie lokalna wersja klasy 86 Dopiero od .NET 40 ns
EventSlim ManualResetEvent w wersji 4.0
CountdownEvent Umożliwia zliczanie sygnałów 51 Dopiero od .NET 40 ns
i odblokowanie wątku po uzyskaniu w wersji 4.0
określonej wcześniej ich liczby
Barrier Bariera synchronizująca kolejne 86 Dopiero od .NET 80 ns
etapy wątków w wersji 4.0

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.

W rozdziale 2. tworzyłem obiekty EventWaitHandle, używając dwuargumentowego


konstruktora, w którym pierwszym argumentem był początkowy stan, a drugim tryb
działania:
EventWaitHandle ewh = new EventWaitHandle(false, EventResetMode.AutoReset).

Opcja EventResetMode.AutoReset oznacza, że stan tego obiektu (wartość pierwszego


argumentu wskazuje, że w momencie utworzenia stan ten jest wyłączony), który jest
włączany w momencie wywołania metody WaitOne, zostanie automatycznie wyłączony
po wywołaniu metody Set. Można porównać to do drzwi ze sprężyną, które same zamy-
kają się po przejściu przez nie osoby, lub do bramki przy autostradzie, w której szla-
ban opuszcza się automatycznie po przejechaniu samochodu5. Z dokładnie tym samym
rezultatem można użyć metody AutoResetEvent:
AutoResetEvent are = new AutoResetEvent(false);

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.

W kodach dołączonych do książki znajdują się dwie wersje aplikacji Producent


Konsument, w których do sygnalizacji wykorzystywana jest klasa EventWaitHandle
z opcją EventResetMode.AutoReset oraz klasa AutoResetEvent.

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

W odróżnieniu od metod Monitor.Wait i Monitor.Pulse, klasy te pozwalają na syn-


chronizację wątków działających w różnych aplikacjach. Należy wówczas użyć trój-
argumentowego konstruktora i jako trzeci argument podać łańcuch identyfikujący
(nazwę), czego w rozdziale 2. nie robiłem. Możliwość synchronizacji wątków między
aplikacjami wiąże się jednak z dodatkowymi kosztami ponoszonymi nawet w przypad-
ku synchronizacji w obrębie jednej aplikacji — klasy te są w istocie opakowaniami
dla mechanizmów systemowych wykorzystujących obiekty jądra (struktury zapisane
w pamięci i wykorzystywane przez system Windows), narzut czasowy związany z ich
użyciem jest wobec tego znacznie większy (tabela 4.2). Z tego powodu zwykle lepiej
używać metod Monitor.Wait i Monitor.Pulse. Są znacznie szybsze.

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.

Zilustruję to prostym przykładem kilku wątków drukujących w konsoli kolejne liczby


naturalne6. W wersji widocznej na listingu 4.6 liczby te będą drukowane w sposób
dość chaotyczny — ich kolejność zależeć będzie od tego, któremu z wątków udało się
szybciej uzyskać dostęp do procesora (rysunek 4.2).

Listing 4.6. Niezsynchronizowane drukowanie liczb


...
using System.Threading;

namespace BarrierDemo
{
class Program
{
const int ileWatkow = 10;

static void Main(string[] args)


{
ThreadStart metodaWatku =
() =>
{
for (int i = 0; i < 10; ++i)
{

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

Gdybym chciał zsynchronizować wyświetlanie kolejnych liczb za pomocą mechani-


zmów opisanych do tej pory, musiałbym użyć kombinacji sygnałów przesyłanych mię-
dzy wątkami (np. za pomocą metod Monitor.Wait i Monitor.Pulse). Prościej można to
zrobić, korzystając z obiektu typu Barrier. Pokazuję to na listingu 4.7, w którym widoczny
jest ten sam kod z dodaną barierą. Na rysunku 4.3 można zobaczyć efekt jej użycia.

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);

static void Main(string[] args)


{
ThreadStart metodaWatku =
() =>
{
for (int i = 0; i < 10; ++i)
{
Console.Write(i.ToString());
b.SignalAndWait();
}
};
Thread[] watki = new Thread[ileWatkow];
for (int i = 0; i < ileWatkow; ++i)
{
watki[i] = new Thread(metodaWatku);
watki[i].Start();
}
88 Programowanie równoległe i asynchroniczne w C# 5.0

Console.ReadLine();
}
}
}

Rysunek 4.3.
Efekt zsynchronizowania
drukowania liczb przez
poszczególne wątki
za pomocą barier

Wygodną funkcjonalnością klasy Barrier jest możliwość określenia działania, jakie


ma być zrobione po każdym etapie wykonywania wątku. W naszym przykładzie mo-
glibyśmy np. wstawiać między serie drukowanych liczb znak końca linii. Działanie to
należy określić w konstruktorze bariery (rysunek 4.4):
static Barrier b = new Barrier(ileWatkow, (Barrier _b) => { Console.WriteLine(); });

Rysunek 4.4.
Efekt użycia metody
wykonywanej
po każdym etapie
synchronizowanym
przez barierę

Podsumowując część dotyczącą synchronizacji wątków uruchamianych w ramach


jednego procesu, należy wyraźnie podkreślić, że wszystkie mechanizmy synchroni-
zacji wątków omówione w tym rozdziale znajdują zastosowanie także w zadaniach
z biblioteki TPL. Zadania są „nakładką” na wątki, ułatwiającą i optymalizującą ich
użycie. W rozdziale 8. tezę tę zilustruję przykładami.

Synchronizacja wątków z różnych


procesów. Muteksy i semafory nazwane
Do synchronizacji wątków w obrębie aplikacji wielokrotnie korzystaliśmy z klasy Monitor,
a konkretnie z jej metod Enter i Exit kryjących się za słowem kluczowym lock. Ich
działanie ogranicza się jednak tylko do jednego procesu (jednej działającej instancji
aplikacji). A czego użyć, gdybyśmy chcieli zsynchronizować za pomocą bloku two-
rzącego sekcję krytyczną dwa wątki z różnych instancji tej samej lub różnych aplikacji?
W takiej sytuacji należy użyć klasy Mutex (ang. mutual exclusion, wzajemne wyklu-
czanie) — „ogólnosystemowego” odpowiednika metod Enter i Exit klasy Monitor.

Muteksy nazwane są obiektami jądra systemu Windows identyfikowanymi na pod-


stawie unikalnego w systemie łańcucha znaków. Jeżeli dwie aplikacje użyją tego samego
łańcucha, ich wątki będą mogły być synchronizowane. Idea muteksów nie ogranicza
Rozdział 4.  Więcej o synchronizacji wątków. Blokady i sygnały 89

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.

Kontrola ilości instancji aplikacji


Jednak zanim do tego przejdziemy, chciałbym pokazać inne, częste zastosowanie
muteksu — kontrolę ilości uruchamianych instancji aplikacji. Pozwala na to w zasadzie
każdy obiekt jądra — ważna jest jego obecność, a nie funkcja, ale klasa Mutex jest
chyba najbardziej poręczna. Jej użycie do zablokowania kolejnych uruchomień apli-
kacji prezentuję na listingu 4.8.

Listing 4.8. Użycie klasy Mutex do ograniczenia ilości instancji aplikacji


static void Main(string[] args)
{
//kontrola ilości instancji aplikacji
bool pierwszaInstancja;
Mutex m = new Mutex(true, "BardzoUnikalnaNazwaMuteksu", out pierwszaInstancja);
if (pierwszaInstancja)
{
Console.WriteLine("Pierwsza instancja");
Console.ReadLine();
}
else
{
Console.WriteLine("Instancja tego programu jest już uruchomiona. Program
zostanie zamknięty!");
Console.ReadLine();
return;
}
//dalsza część programu
...
}

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");

//kontrola ilości instancji aplikacji


Mutex m = new Mutex(false, "BardzoUnikalnaNazwaMuteksu");
Console.WriteLine("Muteks został utworzony");
Console.WriteLine();

bool koniec = false;

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.

Przykładowa aplikacja z listingu 4.9 umożliwia zatrzymanie wewnątrz sekcji krytycznej


(należy nacisnąć Enter). Ułatwia to sprawdzenie działania muteksu przy dwóch uru-
chomionych instancjach. W takiej sytuacji druga aplikacja powinna zatrzymać się na
metodzie WaitOne, bo nie może wejść do sekcji krytycznej.

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.

Utworzenie muteksu z pierwszym argumentem konstruktora równym true odpowiada


utworzeniu muteksu i próbie natychmiastowego wejścia do sekcji krytycznej. Jeśli
w kodzie z listingu 4.9 zmienilibyśmy ten argument konstruktora, pierwsza instancja
aplikacji działałaby normalnie. Jednak druga nie zdołałaby przejąć mutekstu, a tym
samym wejść do sekcji krytycznej. W efekcie zatrzymałaby się trwale na pierwszym
wywołaniu metody WaitOne.

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());

//kontrola ilości instancji aplikacji


Semaphore s = new Semaphore(iloscWatkowWSekcjiKrytycznej,
iloscWatkowWSekcjiKrytycznej, "BardzoUnikalnaNazwaSemafora");
Console.WriteLine("Semafor został utworzony");
Console.WriteLine();

bool koniec = false;

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

na wątki są wolne. Będą stopniowo rezerwowane poprzez wywołania metody WaitOne.


Każdy wątek w każdej aplikacji może ją wywołać dowolną ilość razy, ale w naszym
przykładzie wywoływana jest tylko raz. Po wyświetleniu komunikatu w konsoli miejsce
to jest zwalniane za pomocą metody Release. W efekcie tylko cztery aplikacje (każda
z jednym wątkiem) mogą jednocześnie wejść do sekcji krytycznej. Następne muszą
czekać, aż zwolni się miejsce.

Aby przetestować powyższy kod, należy kilkakrotnie uruchomić program z listingu


4.10 w trybie bez debugowania (Ctrl+F5), a następnie, naciskając Enter w kolejnych
instancjach, zatrzymywać ich wykonywanie wewnątrz sekcji krytycznych. Jeżeli zatrzy-
mamy więcej aplikacji niż mamy rdzeni procesora, kolejne aplikacje „utkną” na me-
todzie WaitOne. Możemy w ten sposób ograniczyć ilość aplikacji wykonujących rów-
nocześnie obliczenia i zoptymalizować wykorzystanie procesora.

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

4. W aplikacji z zadania 2. zastąp własnoręcznie przygotowaną klasę przez


BlockingCollection<> (opis w rozdziale 9.).
5. Odtwórz przykład aplikacji drukującej kolejne liczby naturalne, nie korzystając
z klasy Barrier, a używając metod Monitor.Wait i Monitor.Pulse.
6. Korzystając z ManualResetEventSlim, przygotuj program, w którym dwa wątki
przekazują sobie „pałeczkę” uprawniającą je do działania. Przy uruchomieniu
aplikacji włączany jest pierwszy wątek; drugi czeka. Po sekundzie pierwszy
wątek wstrzymuje działanie i daje sygnał do działania drugiemu wątkowi. Po
kolejnej sekundzie drugi wątek wstrzymuje działanie i daje sygnał do działania
pierwszemu wątkowi. I tak aż do momentu, w którym użytkownik zakończy
program.
Rozdział 5.
Wątki a interfejs
użytkownika
Dawid Borycki

Podczas tworzenia interaktywnych aplikacji desktopowych istotne znaczenie ma po-


prawne działanie interfejsu użytkownika (GUI, ang. Graphical User Interface). Szcze-
gólnie chodzi tu o jego szybkie reakcje na działania użytkownika. Jest to możliwe, jeżeli
wątek obsługujący okno, czyli tzw. wątek interfejsu użytkownika (wątek UI), zarzą-
dzający stanem komponentów wizualnych umieszczonych na formie, nie zajmuje się
żadnymi długotrwałymi operacjami. Te ostatnie powinny być przeniesione do osobnych
wątków, nazywanych wątkami roboczymi, które nie wymagają bezpośredniej interak-
cji z użytkownikiem. Dobrym przykładem takiego podejścia jest choćby sprawdzanie
pisowni lub drukowanie w tle w edytorze Microsoft Word czy kompilacja kodu w Visual
Studio, która nie blokuje interfejsu, ale informacje o niej są na bieżąco wyświetlane.
Wykorzystanie dodatkowych wątków roboczych, oprócz oczywistych zalet, jest rów-
nież źródłem dodatkowych problemów, niespotykanych w aplikacjach jednowątkowych.
Najbardziej dotkliwe jest ograniczenie uniemożliwiające modyfikacje stanu kompo-
nentów wizualnych z innego wątku niż ten, w którym zostały utworzone. Synchroni-
zacja wątków roboczych i interfejsu użytkownika wymaga zastosowania dodatkowych
mechanizmów, których przedstawieniu poświęcony jest ten rozdział.

Wątki robocze w aplikacjach


desktopowych
Aby przekonać czytelników, że korzystanie z dodatkowych wątków podczas projek-
towania oraz implementacji aplikacji desktopowych jest potrzebne, posłużę się przy-
kładem aplikacji wyświetlającej przekroje kolejnych linii obrazu. Przekrój linii obrazu
to graficzna reprezentacja wartości jej pikseli w postaci dwuwymiarowego wykresu.
96 Programowanie równoległe i asynchroniczne w C# 5.0

Prędkość działania tej aplikacji będzie krytycznie zależeć od rozmiarów analizowanego


obrazu, dlatego dobrze zilustruje problemy, które pojawią się podczas obsługi długich
zadań z poziomu metod zdarzeniowych komponentów Windows Forms oraz Windows
Presentation Foundation. Przykład ten umożliwi również prezentację problemów i za-
gadnień, związanych z synchronizacją wątków z komponentami GUI.

Przygotowanie projektu aplikacji


oraz danych wejściowych
Implementację aplikacji rozpocznę od utworzenia graficznego interfejsu użytkownika
oraz metody generującej obraz, którego przekroje aplikacja ta będzie prezentować z wy-
korzystaniem komponentu Chart z biblioteki Windows Forms.
1. Utwórz nowy projekt Windows Forms Application o nazwie ImageAnalyzer.
W tym celu:
a) W menu File kliknij New, a następnie Project….
b) W kreatorze New Project wybierz zakładkę Templates, a następnie Visual C#
i Windows.
c) Z listy dostępnych szablonów wybierz pozycję Windows Forms Application.
d) W polu Name wpisz ImageAnalyzer (rysunek 5.1).
e) Kliknij przycisk z etykietą OK.

Rysunek 5.1. Kreator New Project w Visual Studio 2012


Rozdział 5.  Wątki a interfejs użytkownika 97

2. Na formie aplikacji umieść dwa przyciski: komponent PictureBox oraz Chart


z palety Data.
3. Zmień nazwę komponentu PictureBox z pictureBox1 na pictureBoxPreview.
W tym celu:
a) Kliknij prawym przyciskiem myszy komponent PictureBox i z menu
kontekstowego wybierz opcję Properties. Uaktywni to okno właściwości
(Properties) wybranego komponentu.
b) W oknie Properties odszukaj pole (Name) i zmień jego wartość na
pictureBoxPreview.
c) Dodatkowo odszukaj pole SizeMode i z listy rozwijanej wybierz opcję
StretchImage.
4. W podobny sposób zmień nazwy pozostałych komponentów:
a) Nazwę przycisku z etykietą Przygotuj obraz zmień na buttonPrzygotujObraz.
b) Własność (Name) przycisku z etykietą Analizuj ustaw na buttonAnalizuj.
c) Nazwę komponentu Chart zmień na wykres.
5. Komponenty wizualne rozmieść na formie według wzoru z rysunku 5.2.

Rysunek 5.2. Projekt aplikacji ImageAnalyzer

6. Przejdź do edycji kodu źródłowego formy. W tym celu w widoku projektowania


interfejsu aplikacji (rysunek 5.2) kliknij menu View i z listy wybierz pozycję Code.
7. Zawartość pliku Form1.cs zmodyfikuj według wzoru z listingu 5.1.
98 Programowanie równoległe i asynchroniczne w C# 5.0

Listing 5.1. Kod źródłowy klasy Form1


using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Threading;
using System.Windows.Forms;
using System.Windows.Forms.DataVisualization.Charting;

namespace ImageAnalyzer
{
public partial class Form1 : Form
{
// Szerokość i wysokość obrazu
private const int _width = 800;
private const int _height = 600;

public Form1()
{
InitializeComponent();

// Zablokowanie przycisku Analizuj do momentu


// utworzenia obrazu
buttonAnalizuj.Enabled = false;

KonfigurujWykres();
}

private void KonfigurujWykres()


{
wykres.ChartAreas[0].AxisX.Minimum = 0;
wykres.ChartAreas[0].AxisX.Maximum = _width;
wykres.Series[0].ChartType = System.Windows.Forms.
DataVisualization.Charting.
SeriesChartType.FastLine;
wykres.Legends.Clear();
}
}
}

8. W widoku projektowania aplikacji (rysunek 5.2) dwukrotnie kliknij przycisk


z etykietą Przygotuj obraz. Spowoduje to utworzenie domyślnej metody
zdarzeniowej przycisku.
9. Uzupełnij ją o polecenia z listingu 5.2.

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

for (int i = 0; i < _width; i++)


{
for (int j = 0; j < _height; j++)
{
double cos = Math.Cos((i + j) * Math.PI / 180.0);
byte val = Convert.ToByte(255.0 * Math.Abs(cos));
img.SetPixel(i, j, Color.FromArgb(val, val, val));
}
}

pictureBoxPreview.Image = img;
buttonAnalizuj.Enabled = true;
}

1. Skompiluj i uruchom aplikację (menu Debug/Start debugging).


11. Po kliknięciu przycisku z etykietą Przygotuj obraz powinieneś uzyskać efekt
analogiczny do przedstawionego na rysunku 5.3.

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

1. Kliknij dwukrotnie przycisk z etykietą Analizuj.


2. W tak utworzonej domyślnej metodzie zdarzeniowej przycisku wstaw polecenia
z listingu 5.3.

Listing 5.3. Prezentacja przekrojów poszczególnych linii obrazu


private void buttonAnalizuj_Click(object sender, EventArgs e)
{
AnalizujObraz();
}

private void AnalizujObraz()


{
Bitmap orgBitmap = new Bitmap(pictureBoxPreview.Image);

const int msDelay = 50;

double[] lineData = new double[orgBitmap.Width];

for (int i = 0; i < pictureBoxPreview.Image.Height; i++)


{
Bitmap tempBitmap = new Bitmap(orgBitmap);

for (int j = 0; j < pictureBoxPreview.Image.Width; j++)


{
lineData[j] = tempBitmap.GetPixel(j, i).R;
}

DodajPunktyDoWykresu(wykres, 0, lineData);

// Rysowanie czarnej poziomej linii na obrazie


Graphics g = Graphics.FromImage(tempBitmap);
g.DrawLine(Pens.Black, 0, i, pictureBoxPreview.Image.Width, i);

pictureBoxPreview.Image = tempBitmap;

// Opóźnienie pomiędzy poszczególnymi liniami


Thread.Sleep(msDelay);
}
}

3. Definicję metody DodajPunktyWykresu, wykorzystanej w listingu 5.3,


przedstawiłem na listingu 5.4.

Listing 5.4. Aktualizacja wybranych serii danych wykresu


private void DodajPunktyDoWykresu(Chart chart, int seriesIndex, double[] yValues)
{
if (seriesIndex < chart.Series.Count && seriesIndex >= 0 && chart != null)
{
chart.Series[seriesIndex].Points.Clear();

for (int i = 0; i < yValues.Length; i++)


{
chart.Series[seriesIndex].Points.AddXY(i, yValues[i]);
Rozdział 5.  Wątki a interfejs użytkownika 101

}
}
}

Aby sprawdzić, czy projekt działa poprawnie, należy go skompilować i uruchomić.


Po przygotowaniu danych wejściowych, czyli kliknięciu przycisku z etykietą Przy-
gotuj obraz, trzeba uruchomić analizę obrazu za pomocą przycisku z etykietą Analizuj.
Działanie aplikacji okaże się jednak dalekie od oczekiwanego. Przede wszystkim można
zauważyć, że:
 kliknięcie przycisku z etykietą Analizuj powoduje zablokowanie interfejsu
użytkownika, ponieważ analiza obrazu wykonywana jest w wątku UI;
 poszczególne przekroje obrazu wcale nie są prezentowane. Na wykresie
pojawia się jedynie przekrój ostatniej linii i to dopiero po pewnym czasie,
zależnym od rozmiaru obrazu.
Powyższe fakty jednoznacznie wskazują na to, że obliczenia związane z analizą obrazu
powinny być uruchomione w osobnym wątku. Dzięki temu interfejs aplikacji nie będzie
zablokowany, ponieważ po uruchomieniu analizy obrazu w dodatkowym wątku stero-
wanie zostanie natychmiast zwrócone do wątku okna. W efekcie wszystkie przekroje
zostaną po kolei wyświetlone.
Realizacja tego zadania wymaga wykonania w projekcie aplikacji ImageAnalyzer na-
stępujących zmian:
1. Na formie aplikacji umieść dodatkowy przycisk.
2. Zmień jego etykietę na Przerwij analizę, a nazwę [własność (Name)]
na buttonPrzerwijAnalize.
3. W klasie Form1 zdefiniuj prywatne pole:
private volatile bool _analizaAktywna = false;

4. Domyślny konstruktor klasy Form1 uzupełnij o polecenie wyróżnione na


listingu 5.5.

Listing 5.5. Zablokowanie przycisku z etykietą Przerwij analizę


public Form1()
{
InitializeComponent();

// Zablokowanie przycisku Analizuj do momentu utworzenia obrazu


buttonAnalizuj.Enabled = false;

// Zablokowanie przycisku Przerwij analizę


// do momentu uruchomienia analizy obrazu
buttonPrzerwijAnalize.Enabled = false;

KonfigurujWykres();
}

5. Kliknij dwukrotnie przycisk z etykietą Przerwij analizę i w tak utworzonej


domyślnej metodzie zdarzeniowej umieść polecenia z listingu 5.6.
102 Programowanie równoległe i asynchroniczne w C# 5.0

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;
}

6. Przenieś obliczenia do wątku roboczego. W tym celu zmodyfikuj metodę


zdarzeniową przycisku buttonAnalizuj według wzoru z listingu 5.7, a warunek
przerwania pętli for w metodzie AnalizujObraz (listing 5.3) uzupełnij o polecenie
wyróżnione na listingu 5.8.

Listing 5.7. Przeniesienie obliczeń do wątku roboczego


private void buttonAnalizuj_Click(object sender, EventArgs e)
{
AnalizujObraz();
Thread thread = new Thread(AnalizujObraz);

_analizaAktywna = true;
thread.Start();

buttonPrzerwijAnalize.Enabled = true;
}

Listing 5.8. Fragment metody AnalizujObraz



for (int i = 0; i < pictureBoxPreview.Image.Height && _analizaAktywna; i++)

1. Na koniec utwórz metodę obsługującą zdarzenie FormClosing:


a) W widoku projektowania aplikacji kliknij prawym przyciskiem myszy jej
formę.
b) Z menu podręcznego wybierz opcję Properties.

c) W górnym panelu odszukaj i kliknij ikonę błyskawicy . Uaktywni to


listę zdarzeń formy aplikacji.
d) Odszukaj zdarzenie FormClosing i dwukrotnie kliknij puste pole w sąsiedniej
kolumnie.
e) Spowoduje to utworzenie metody zdarzeniowej Form1_FormClosing. W jej
definicji wstaw polecenie z listingu 5.9.

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

Długotrwałe obliczenia uruchamiane w ramach metod zdarzeniowych kontrolek po-


wodują, że interfejs aplikacji przestaje reagować na działania użytkownika. Receptą na
ten problem jest przeniesienie obliczeń do osobnego wątku. W powyższym przykładzie
do wątku roboczego przeniesiona została analiza obrazu. W tym celu zmodyfikowałem
metodę zdarzeniową przycisku buttonAnalizuj. Jej działanie sprowadza się do utwo-
rzenia i uruchomienia wątku roboczego oraz ustawienia flagi _analizaAktywna na
wartość true. Wartość tej zmiennej wykorzystuję do zatrzymania funkcji wątku (li-
sting 5.6). Z tego powodu pierwotną metodę analizującą obraz (listing 5.3) uzupełniłem
o sprawdzenie wartości zapisanej w polu _analizaAktywna przed wykonaniem każdej
iteracji (listing 5.8). Zmienna _analizaAktywna została oznaczona słowem kluczowym
volatile, ponieważ dostęp do niej może być uzyskiwany z różnych wątków. Wyko-
rzystanie słowa kluczowego volatile informuje kompilator, aby nie stosował żadnych
procedur optymalizacyjnych względem pola _analizaAktywna (rozdział 3.). Ponadto
użycie słowa kluczowego volatile w przypadku zmiennej typu bool automatycznie
zapewnia atomowy dostęp do danej zmiennej z poziomu metod różnych wątków1.

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.).

Po uruchomieniu aplikacji przekonamy się, że podczas próby dodania danych do wykresu


(wywołanie funkcji DodajPunktyDoWykresu z listingu 5.7) zgłoszony zostanie wyjątek typu
InvalidOperationException o treści: Nieprawidłowa operacja między wątkami: do for-
mantu 'wykres' uzyskiwany jest dostęp z wątku innego niż wątek, w którym został utwo-
rzony. Wyjątek ten pojawia się, ponieważ komponenty Windows Forms nie imple-
mentują domyślnie mechanizmu bezpieczeństwa wątków (ang. thread safety).

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.

Listing 5.10. Przykład wykorzystania własności CheckForIllegalCrossThreadCalls


public Form1()
{
InitializeComponent();

//Zablokowanie przycisku Analizuj do momentu utworzenia obrazu


buttonAnalizuj.Enabled = false;

//Zablokowanie przycisku Przerwij analizę do momentu uruchomienia analizy obrazu


buttonPrzerwijAnalize.Enabled = false;

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

Wprowadzona zmiana nie zapewni bezpiecznego dostępu do komponentów wizualnych


z poziomu funkcji wątków roboczych, czyli nie spowoduje, że projektowana aplikacja
będzie działać poprawnie. Do tego celu konieczne będzie zaimplementowanie syn-
chronizacji wątków z interfejsem użytkownika, którą omówię w kolejnym podrozdziale.
Zablokowanie zgłaszania wyjątku sprawi jednak, że próba zmiany stanu komponentu
wizualnego nie przerwie działania aplikacji, o ile jest ona uruchamiana spoza środo-
wiska Visual Studio.

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).

Kontrola dostępu do współdzielonych zasobów w aplikacjach wielowątkowych reali-


zowana jest w oparciu o szereg obiektów synchronizujących ich pracę, takich jak sekcje
krytyczne, semafory, muteksy, bariery (rozdział 4.). Obiekty te niezaprzeczalnie istnieją,
można zatem zadać pytanie, dlaczego twórcy platformy .NET domyślnie nie zapew-
nili mechanizmu bezpieczeństwa wątków dla komponentów Windows Forms? Wynika
to z tego, że niepotrzebne wykorzystanie obiektów synchronizacyjnych, których użycie
wymaga dodatkowego czasu procesora (rozdział 4.), zmniejsza wydajność aplikacji.
W takim przypadku aplikacje jednowątkowe miałyby niepotrzebnie ograniczoną wy-
dajność.

Komponenty Windows Forms wyposażono w specjalną własność Control.InvokeRequired,


przechowującą informacje o tym, czy dostęp do danego komponentu uzyskiwany jest
w sposób niezapewniający bezpieczeństwa wątków. Innymi słowy, własność ta infor-
muje o tym, czy próba zmiany stanu komponentu następuje z wątku, w którym nie
został on utworzony. Chodzi więc dokładnie o sytuację, która wystąpiła w poprzed-
nim podrozdziale.

W platformie .NET bezpieczny dostęp (w sensie wielowątkowości) do komponentów


Windows Forms realizuje się za pomocą kilku mechanizmów. Pierwszy polega na
przekazaniu żądania uruchomienia metody, zmieniającej stan współdzielonego kom-
ponentu, do wątku, w którym ten komponent został utworzony. Przekazanie tego żą-
dania realizuje się za pomocą metody Control.Invoke po wcześniejszym sprawdzeniu
Rozdział 5.  Wątki a interfejs użytkownika 105

wartości pola Control.InvokeRequired. Przykładowe wykorzystanie tego mechanizmu


zademonstruję w tym podrozdziale. W kolejnym omówię inny, oparty na zdarzeniach,
mechanizm bezpiecznego dostępu do komponentów Windows Forms.

W aplikacji ImageAnalyzer występują dwa komponenty pełniące funkcję współdzie-


lonych zasobów. Są to komponenty PictureBox oraz Chart. Dostęp do nich powinien
zachowywać bezpieczeństwo wątków (ang. thread-safety). Ponieważ procedura uzy-
skiwania bezpiecznego dostępu do komponentów w aplikacjach wielowątkowych jest
realizowana w ramach konkretnego schematu, najwygodniej utworzyć statyczną kla-
sę, z której można wielokrotnie korzystać, także w innych projektach.

W celu poprawienia projektu aplikacji ImageAnalyzer wykonaj następujące czynności:


1. W menu Project kliknij pozycję Add class….
2. W polu Name kreatora Add New Item wpisz ThreadSafeCalls.cs i kliknij
przycisk Add (rysunek 5.4).

Rysunek 5.4. Kreator Add New Item w Visual Studio 2012

3. Przejdź do edycji pliku ThreadSafeCalls.cs i umieść w nim polecenia


z listingu 5.11.

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();

for (int i = 0; i < yValues.Length; i++)


{
chart.Series[seriesIndex].Points.
AddXY(i, yValues[i]);
}
}
}
}
private delegate void UstawObrazDelegate(PictureBox pictureBox,
Bitmap obraz);
public static void UstawObraz(PictureBox pictureBox,
Bitmap obraz)
{
if (pictureBox.InvokeRequired)
{
pictureBox.Invoke(new UstawObrazDelegate(UstawObraz),
new object[] { pictureBox, obraz });
}
else
{
if (pictureBox != null && obraz != null)
{
pictureBox.Image = obraz;
}
}
}
}
}

4. W pliku Form1.cs przejdź do edycji funkcji AnalizujObraz (wszystkie zmiany


przedstawiłem dodatkowo na listingu 5.12):
Rozdział 5.  Wątki a interfejs użytkownika 107

a) Zastąp wywołanie metody DodajPunktyDoWykresu jej bezpieczną wersją


z klasy ThreadSafeCalls.DodajPunktyDoWykresu.
b) Polecenie pictureBoxPreview.Image = tempBitmap; zastąp komendą
ThreadSafeCalls.UstawObraz(pictureBoxPreview, tempBitmap);.

Listing 5.12. Dostęp do współdzielonych komponentów jest teraz bezpieczny


private void AnalizujObraz()
{
Bitmap orgBitmap = new Bitmap(pictureBoxPreview.Image);

const int msDelay = 50;

double[] lineData = new double[orgBitmap.Width];

for (int i = 0; i < pictureBoxPreview.Image.Height


&& _analizaAktywna; i++)
{
Bitmap tempBitmap = new Bitmap(orgBitmap);

for (int j = 0; j < pictureBoxPreview.Image.Width; j++)


{
lineData[j] = tempBitmap.GetPixel(j, i).R;
}

DodajPunktyDoWykresu(wykres, 0, lineData);
ThreadSafeCalls.DodajPunktyDoWykresu(wykres, 0, lineData);

// Rysowanie czarnej poziomej linii na obrazie


Graphics g = Graphics.FromImage(tempBitmap);
g.DrawLine(Pens.Black, 0, i, pictureBoxPreview.Image.Width, i);

pictureBoxPreview.Image = tempBitmap;
ThreadSafeCalls.UstawObraz(pictureBoxPreview, tempBitmap);

// Opóźnienie pomiędzy poszczególnymi liniami


Thread.Sleep(msDelay);
}
}

Po skompilowaniu i uruchomieniu aplikacji ImageAnalyzer przekonamy się, że po-


wyższe zmiany powodują, iż wyjątek InvalidOperationException już się nie pojawia
i aplikacja działa poprawnie, a komponent Chart prezentuje prawidłowo poszczególne
przekroje linii obrazu (rysunek 5.5).

Omówię teraz zasadnicze aspekty dwóch metod zdefiniowanych w klasie ThreadSafeCalls.


Zasada działania obu jest taka sama. W pierwszym kroku odczytuję wartość własności
Control.InvokeRequired. Wartość true informuje o tym, że dostęp do danego kompo-
nentu uzyskiwany jest z wątku innego niż wątek, w którym ten komponent został utwo-
rzony. W takiej sytuacji operacje na danym komponencie należy wykonać synchro-
nicznie w oparciu o metodę Control.Invoke lub asynchronicznie za pomocą metody
Control.BeginInvoke. W przeciwnym przypadku nie jest to konieczne i wówczas odpo-
wiednie polecenia wykonywane są bezpośrednio na rzecz wybranego komponentu.
108 Programowanie równoległe i asynchroniczne w C# 5.0

Rysunek 5.5. Aplikacja ImageAnalyzer w trakcie działania

Metoda Control.Invoke synchronicznie uruchamia funkcję (zmieniającą stan kom-


ponentu), wskazaną za pomocą delegata2 (ang. delegate) w wątku, w którym został utwo-
rzony dany komponent. Mechanizm tego przedstawicielstwa (pełnomocnictwa) jest
w pewnym sensie analogiczny do wskaźników do funkcji, znanych z języków C/C++.

Deklarację przedstawiciela zawierającą typ zwracany przez wskazywaną funkcję oraz


listę jej parametrów formalnych realizuje się za pomocą słowa kluczowego delegate
(listing 5.11). W argumencie metody Control.Invoke należy przekazać obiekt typu
delegate, przechowujący referencję do wskazanej metody klasy. W moim przykładzie
są to metody DodajPunktyDoWykresu oraz UstawObraz klasy ThreadSafeCalls.

Metodę zmieniającą stan kontrolki można również przekazać asynchronicznie do wątku


kontrolującego jej stan za pomocą metody Control.BeginInvoke. Lista jej parametrów
formalnych oraz sposób użycia są analogiczne do metody Control.Invoke. Z tego
powodu nie poświęcam temu zagadnieniu więcej uwagi.

Alternatywą do zastosowanego tu mechanizmu bezpiecznego dostępu do komponen-


tów wizualnych jest utworzenie własnych, „przeciążonych” wersji komponentów typu
PictureBox oraz Chart, które udostępniają dodatkowe, bezpieczne (w sensie wielo-
wątkowości) metody umożliwiające zmianę prezentowanego obrazu (PictureBox) oraz
serii danych (Chart). Przedstawię tę możliwość na przykładzie komponentu PictureBox.
W tym celu uzupełnię projekt aplikacji ImageAnalyzer o komponent PictureBoxThread-
Safe. Realizacja tego zadania wymaga wykonania przez czytelników następujących
czynności:

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

1. W Visual Studio z menu Project wybierz opcję Add Class….


2. W polu Name kreatora Add New Item wpisz PictureBoxThreadSafe.cs i kliknij
przycisk Add.
3. Przejdź do edycji pliku PictureBoxThreadSafe.cs i wstaw w nim polecenia
z listingu 5.13.

Listing 5.13. Definicja komponentu PictureBoxThreadSafe. Jest to zmodyfikowana wersja komponentu


PictureBox implementująca bezpieczny dostęp do właściwości Image z poziomu funkcji wątków roboczych
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;

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;
}
}
}
}
}

1. W pliku Form1.Designer.cs wykonaj następujące zmiany:


a) Deklarację pola pictureBoxPreview zmień z
private System.Windows.Forms.PictureBox pictureBoxPreview;

na
private PictureBoxThreadSafe pictureBoxPreview;

b) W funkcji InitializeComponent zmodyfikuj polecenie inicjujące pole


pictureBoxPreview:
this.pictureBoxPreview = new System.Windows.Forms.PictureBox();
110 Programowanie równoległe i asynchroniczne w C# 5.0

w następujący sposób:
this.pictureBoxPreview = new PictureBoxThreadSafe();

Po wykonaniu powyższych zmian w projekcie aplikacji ImageAnalyzer, w funkcji


wątku roboczego (AnalizujObraz, listing 5.12) można użyć polecenia
pictureBoxPreview.Image = tempBitmap

zamiast
ThreadSafeCalls.UstawObraz(pictureBoxPreview, tempBitmap);

Staje się to możliwe, dzięki temu że komponent PictureBoxThreadSafe pozwala na


zmianę właściwości Image, czyli wyświetlanego obrazu, w bezpieczny sposób (w sen-
sie wielowątkowości). Oczywiście, sposób implementacji jest analogiczny do wyko-
rzystanego w klasie ThreadSafeCalls. Jedyną różnicą jest fakt, że do wskazania metody,
która ma zostać uruchomiona na rzecz komponentu kontrolowanego przez wątek in-
terfejsu użytkownika, wykorzystałem delegata Action. W ten sposób nie musiałem sa-
modzielnie deklarować własnego delegata, jak to zrobiłem na listingu 5.11.

Całkowicie inny mechanizm bezpiecznego dostępu do współdzielonych kontrolek oparty


jest o zdarzenia, które wyzwalane są po zakończeniu obliczeń i pozwalają wykonać
aktualizację interfejsu użytkownika. Obsługa tych zdarzeń następuje w ramach wątku
UI, co zwalnia programistę z konieczności implementacji metod analogicznych do
przedstawionych na listingach 5.11 i 5.13.

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.

Wykorzystanie komponentu BackgroundWorker sprowadza się do przygotowania funkcji


wątku w ramach metody zdarzeniowej BackgroundWorker.DoWork oraz jej uruchomie-
nia za pomocą metody BackgroundWorker.RunWorkerAsync. Dodatkowe metody i zda-
rzenia udostępniane przez komponent BackgroundWorker umożliwiają przerywanie
działania metody wątku (BackgroundWorker.CancelAsync), raportowanie postępu jego
pracy (BackgroundWorker.ProgressChanged) oraz obsługę jego zakończenia (Backgroun-
dWorker.RunWorkerCompleted).
Rozdział 5.  Wątki a interfejs użytkownika 111

Aby zaprezentować sposób korzystania z klasy BackgroundWorker, przygotujemy aplika-


cję symulującą długotrwały odczyt danych z urządzenia pomiarowego. Symulacja ta
polegać będzie na generowaniu losowej jednobajtowej wartości z zakresu od 0 do 255.
W tym celu:
1. Utwórz nowy projekt aplikacji Windows Forms Application o nazwie DataReader.
2. Formę aplikacji uzupełnij o trzy przyciski oraz komponenty typu ListBox,
ProgressBar i BackgroundWorker. Rozmieść je według wzoru z rysunku 5.6.

Rysunek 5.6.
Widok formy
projektowanej aplikacji

3. Domyślne nazwy komponentów zmodyfikuj według poniższego opisu:


a) Nazwę przycisku z etykietą Rozpocznij odczyt zmień
na buttonRozpocznijOdczyt.
b) Własność (Name) przycisku z etykietą Przerwij zmień
na buttonPrzerwijOdczyt.
c) Użyj nazwy buttonWyczyscListe dla przycisku z etykietą Wyczyść.
d) Nazwę komponentu typu BackgroundWorker zmień
na backgroundWorkerOdczyt.
e) Własność (Name) komponentu typu ListBox zmień na listBoxDane,
a komponentu typu ProgressBar na progressBarOdczyt.
4. Własność Enabled przycisku z etykietą Przerwij zmień na false.
5. W oknie właściwości obiektu BackgroundWorker ustaw opcje WorkerReportProgress
oraz WorkerSupportsCancellation na true (rysunek 5.7).
3
6. Na liście dostępnych zdarzeń obiektu BackgroundWorker odszukaj pozycję
DoWork. Następnie kliknij dwukrotnie lewym przyciskiem myszy puste pole
w sąsiedniej kolumnie. Spowoduje to utworzenie metody zdarzeniowej
backgroundWorkerOdczyt_DoWork. W jej definicji wstaw polecenia
z listingu 5.14.

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

Listing 5.14. Funkcja wątku roboczego


private void backgroundWorkerOdczyt_DoWork(object sender, DoWorkEventArgs e)
{
const int liczbaDanychDoOdczytania = 100;
const int msDelayTime = 50;

Random r = new Random();


for(int i = 0; i < liczbaDanychDoOdczytania; i++)
{
Thread.Sleep(msDelayTime);

if (backgroundWorkerOdczyt.CancellationPending)
{
e.Cancel = true;
break;
}
else
{
backgroundWorkerOdczyt.ReportProgress(100 * i /
liczbaDanychDoOdczytania, r.Next(255));
}
}
}

1. W podobny sposób utwórz metody obsługujące zdarzenia ProgressChanged oraz


RunWorkerCompleted i zdefiniuj je odpowiednio według listingów 5.15 i 5.16.

Listing 5.15. Prezentacja postępu pracy funkcji wątku roboczego


private void backgroundWorkerOdczyt_ProgressChanged(object sender,
ProgressChangedEventArgs e)
{
if (e.UserState != null)
{
listBoxDane.Items.Add(e.UserState);
listBoxDane.SelectedIndex = listBoxDane.Items.Count - 1;
Rozdział 5.  Wątki a interfejs użytkownika 113

}
progressBarOdczyt.Value = e.ProgressPercentage;
}

Listing 5.16. Po zakończeniu funkcji wątku konfiguracja stanu przycisków uruchamiających


(wstrzymujących) jego pracę
private void KonfigurujStanPrzyciskow(bool watekAktywny)
{
buttonPrzerwijOdczyt.Enabled = watekAktywny;
buttonRozpocznijOdczyt.Enabled = !watekAktywny;
}
private void backgroundWorkerOdczyt_RunWorkerCompleted(object sender,
RunWorkerCompletedEventArgs e)
{
KonfigurujStanPrzyciskow(false);
}

8. W widoku projektowania formy aplikacji kliknij dwukrotnie przycisk z etykietą


Rozpocznij odczyt i w tak utworzonej domyślnej metodzie zdarzeniowej wstaw
polecenia z listingu 5.17.

Listing 5.17. Uruchomienie funkcji wątku


private void buttonRozpocznijOdczyt_Click(object sender, EventArgs e)
{
backgroundWorkerOdczyt.RunWorkerAsync();
KonfigurujStanPrzyciskow(true);
}

9. Domyślne metody zdarzeniowe przycisków z etykietami Przerwij oraz Wyczyść


zdefiniuj odpowiednio według listingów 5.18 i 5.19.

Listing 5.18. Przerwanie pracy wątku


private void buttonPrzerwijOdczyt_Click(object sender, EventArgs e)
{
// Zatrzymaj wątek, jeśli jest aktywny
if (backgroundWorkerOdczyt.IsBusy)
{
backgroundWorkerOdczyt.CancelAsync();
}
KonfigurujStanPrzyciskow(false);
}

Listing 5.19. Czyszczenie listy


private void buttonWyczyscListe_Click(object sender, EventArgs e)
{
listBoxDane.Items.Clear();
}
114 Programowanie równoległe i asynchroniczne w C# 5.0

10. Po skompilowaniu i uruchomieniu aplikacji powinieneś uzyskać efekt


analogiczny do przedstawionego na rysunku 5.8.

Rysunek 5.8.
Aplikacja
w trakcie pracy

W powyższym przykładzie pokazałem, w jaki sposób za pomocą komponentu


BackgroundWorker uruchomić długotrwałą funkcję realizowaną w osobnym wątku. Pole-
cenia, które mają być wykonywane w tle, należy umieścić w metodzie zdarzeniowej
BackgroundWorker.DoWork. W moim przykładzie w metodzie tej iteracyjnie (w pętli for)
najpierw losuję liczby z zakresu od 0 do 255 (listing 5.14), a następnie przekazuję je (pa-
rametr UserState) wraz z informacją o aktualnym postępie (parametr percentProgress)
do wątku głównego za pomocą zdarzenia BackgroundWorker.ReportProgress. Obsługa
tego zdarzenia, polegająca na dodaniu wylosowanej liczby do listy oraz na zmianie
wartości komponentu typu ProgressBar, realizowana jest z poziomu głównego wątku
aplikacji. W takiej sytuacji wykorzystanie mechanizmu bezpiecznego dostępu do kom-
ponentów, który opisałem w poprzednim podrozdziale, nie jest konieczne. Dostęp do
komponentów wizualnych jest uzyskiwany wyłącznie z wątku, w ramach którego zo-
stały utworzone. Oczywiście, gdybym uzyskiwał dostęp do komponentów wizualnych
z poziomu metody obsługującej zdarzenie BackgroundWorker.DoWork, napotkałbym na
problemy opisane w poprzednim podrozdziale.
W każdej iteracji sprawdzam, czy na rzecz komponentu BackgroundWorker wykona-
no metodę CancelAsync. Wykorzystuję do tego celu własność BackgroundWor-
ker.CancellationPending. Przerywam wykonywanie pętli, gdy jej wartość jest równa true.

Oprócz ułatwionego uruchamiania dodatkowego wątku, BackgroundWorker uwalnia nas od


obowiązku zwalniania zasobów wątku lub wymuszania jego zakończenia. Zgodnie z na-
zwą klasy, wątek tworzony przez BackgroundWorker jest wątkiem tła (rozdział 2.). Za-
mknięcie aplikacji powoduje wobec tego automatyczne zakończenie tego wątku.

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

obliczanie liczby  metodą Monte Carlo. Komponenty graficzne umożliwią mi zilu-


strowanie procesu losowania poszczególnych punktów oraz prezentację aktualnego
przybliżenia liczby  i błędu jej wyznaczenia.

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.

Projekt graficznego interfejsu użytkownika


W celu wizualizacji algorytmu obliczania liczby  posłużę się dwoma komponentami
WPF, Canvas oraz ListBox. Pierwszy z nich wykorzystam do utworzenia układu współ-
rzędnych, narysowania kwadratu oraz wpisanego w niego okręgu. Natomiast kompo-
nentu ListBox użyję do prezentacji ilości zrealizowanych prób, aktualnego przybliżenia
liczby , błędu jej wyznaczenia oraz liczby wylosowanych punktów, znajdujących się
wewnątrz okręgu.

W celu utworzenia aplikacji WPF wykonaj poniższe polecenia:


1. W Visual Studio kliknij File/New/Project…. Uaktywni się kreator New Project.
2. W kreatorze New Project kliknij zakładkę Installed, a następnie Templates
i Visual C#.
3. Z listy dostępnych szablonów wybierz WPF Application.
4. W polu Name wpisz MonteCarloPi. Podsumowanie powyższych czynności
stanowi rysunek 5.9.

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

Po utworzeniu projektu WPF Application aktywnym widokiem w Visual Studio stanie


się widok projektowania graficznego interfejsu aplikacji. Jego przykładową postać
przedstawiłem na rysunku 5.10. Domyślnie jest on podzielony na dwie części. W górnej
prezentowany jest podgląd formy aplikacji. Formularz ten można projektować w sposób
analogiczny do technologii Windows Forms, czyli umieszczając poszczególne kontrolki
za pomocą myszy. Można go również zdefiniować z poziomu kodu XAML (ang.
eXtensible Application Markup Language). Definicja interfejsu użytkownika zaimple-
mentowana za pomocą języka XAML widoczna jest w dolnej części okna z rysunku 5.10.

Rysunek 5.10. Widok projektowania formy aplikacji WPF

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.

Listing 5.20. Definicja interfejsu użytkownika aplikacji MonteCarloPi


<Window x:Class="MonteCarloPi.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Obliczanie liczby Pi metodą Monte Carlo" Height="481"
Width="845" Closing="Window_Closing">
<Grid Margin="0,0,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="410*"/>
<ColumnDefinition Width="410*"/>
</Grid.ColumnDefinitions>
<Button Content="Rozpocznij obliczenia"
HorizontalAlignment="Left" Margin="10,10,0,0"
VerticalAlignment="Top" Width="130"
Rozdział 5.  Wątki a interfejs użytkownika 117

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>

Implementacja metod zdarzeniowych


Obsługę logiki aplikacji rozpocznę od zdefiniowania metody zdarzeniowej przycisku
z etykietą Rozpocznij obliczenia. Na razie skoncentruję się na narysowaniu w obrębie
komponentu Canvas kwadratu, okręgu oraz układu współrzędnych wykorzystywanych
podczas obliczeń liczby . W tym celu wykonaj poniższe kroki:
1. W widoku projektowania interfejsu aplikacji kliknij prawy przycisk myszy.
2. Z menu podręcznego wybierz opcję View code.
3. Klasę MainWindow zdefiniuj według wzoru z listingu 5.21.

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;
}

private void RysujOkrag()


{
Ellipse ellipse = new Ellipse();
ellipse.Width = _srednicaOkregu;
ellipse.Height = _srednicaOkregu;
ellipse.Stroke = Brushes.Blue;
ellipse.StrokeThickness = 1;
CanvasPodglad.Children.Add(ellipse);
118 Programowanie równoległe i asynchroniczne w C# 5.0

private void RysujKwadrat()


{
Rectangle rect = new Rectangle();

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

private void ButtonRozpocznij_Click(object sender,


RoutedEventArgs e)
{
PrzygotujPodglad();
}

private void ButtonPrzerwij_Click(object sender,


RoutedEventArgs e)
{

private void Window_Closing(object sender,


System.ComponentModel.CancelEventArgs e)
{
ButtonPrzerwij_Click(sender, null);
}
}

4. Po uruchomieniu aplikacji i kliknięciu przycisku z etykietą Rozpocznij obliczenia


aplikacja powinna mieć postać analogiczną do przedstawionej na rysunku 5.11.

Rysunek 5.11. Wstępna postać aplikacji MonteCarloPi

Głównym zadaniem metod przedstawionych na listingu 5.19 jest rysowanie losowa-


nych punktów. Na bieżąco będą one pokazywały, jak przebiega obliczanie liczby .
Punkty, które znajdą się wewnątrz okręgu, oznaczę kolorem zielonym, a pozostałe
punkty — czerwonym. Stosunek liczby zielonych punktów do całkowitej liczby punktów
wylosowanych, po pomnożeniu przez 4, będzie przybliżeniem wartości liczby .

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

dodaję je do kolekcji Children komponentu CanvasPodglad. Dodatkowo, w przypadku


obiektów typu Line zmieniam styl rysowania na przerywany. Służy do tego własność
Line.StrokeDashArray.

Po przygotowaniu układu współrzędnych możemy przystąpić do implementacji zasadni-


czej części aplikacji, czyli obliczeń liczby  (wykorzystamy do tego celu algorytm
omówiony w rozdziale 2.):
1. Przejdź do edycji kodu źródłowego aplikacji MonteCarloPi.
2. Nagłówek pliku MainWindow.xaml.cs uzupełnij o polecenie:
using System.Threading;

3. Do klasy MainWindow dodaj prywatne pole


private volatile bool _watekAktywny = false;

4. W menu Project kliknij opcję Add Class….


5. W polu Name kreatora Add New Item wpisz ParametryWatku.cs.
6. Utworzoną klasę ParametryWatku zdefiniuj według wzoru z listingu 5.22.

Listing 5.22. Definicja klasy, służąca konfiguracji parametrów uruchomieniowych wątku


public class ParametryWatku
{
public long IloscProb { get; set; }
public int Opoznienie { get; set; }

public ParametryWatku()
{
// Wartości domyślne;
IloscProb = 2000L;
Opoznienie = 5;
}

public ParametryWatku(long IloscProb, int Opoznienie)


{
this.IloscProb = IloscProb;
this.Opoznienie = Opoznienie;
}
}

7. Domyślną metodę zdarzeniową przycisku z etykietą Rozpocznij obliczenia


(metoda ButtonRozpocznij_Click z listingu 5.21) uzupełnij o polecenia
wyróżnione na listingu 5.23. Definicję wykorzystanej tam funkcji wątku
o nazwie ObliczPi przedstawiłem na listingu 5.24.

Listing 5.23. Uruchomienie obliczeń i przekazanie parametrów początkowych


private void ButtonRozpocznij_Click(object sender, RoutedEventArgs e)
{
PrzygotujPodglad();

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;

Random r = new Random();

for (long i = 1; i <= iloscProb && _watekAktywny; i++)


{
// 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);

bool punktZnajdujeSieWewnatrzOkregu = false;


if (odlegloscPunktuOdPoczatkuUkladuWspolrzednych
< _promienOkregu)
{
iloscTrafien++;
punktZnajdujeSieWewnatrzOkregu = true;
}

RysujPunkt(xComp, yComp, punktZnajdujeSieWewnatrzOkregu);

double wynikPi = 4.0 * iloscTrafien / i;

string statystyka = "Próba nr: " + i + ": Ilość trafień


= " + iloscTrafien + ", Pi = "
+ wynikPi.ToString("f4") + ", Błąd = "
+ Math.Abs(Math.PI - wynikPi).ToString("f4");
122 Programowanie równoległe i asynchroniczne w C# 5.0

DodajElementDoListy(statystyka);

Thread.Sleep(msSleepTime);
}
}

private void RysujPunkt(double x, double y, bool wewnatrzOkregu)


{
Ellipse ellipse = new Ellipse();

const int srednicaPunktu = 5;

ellipse.Width = srednicaPunktu;
ellipse.Height = srednicaPunktu;

ellipse.Fill = wewnatrzOkregu ? Brushes.LightGreen : Brushes.Red;

CanvasPodglad.Children.Add(ellipse);

Canvas.SetLeft(ellipse, x - srednicaPunktu / 2.0);


Canvas.SetTop(ellipse, y - srednicaPunktu / 2.0);
}

private void DodajElementDoListy(object element)


{
ListBoxWyniki.Items.Add(element);
ListBoxWyniki.SelectedItem = element;
ListBoxWyniki.ScrollIntoView(element);
}

8. W metodzie zdarzeniowej przycisku ButtonPrzerwij_Click (listing 5.22)


wstaw polecenie wyszczególnione na listingu 5.25.

Listing 5.25. Przerwanie pracy wątku


private void ButtonPrzerwij_Click(object sender, RoutedEventArgs e)
{
_watekAktywny = false;
}

9. Skompiluj i uruchom aplikację.

W powyższym rozwiązaniu dłuższego komentarza wymagają aspekty dotyczące in-


terfejsu użytkownika i komponentów WPF. Przede wszystkim elementem wymagają-
cym wyjaśnienia jest procedura wyznaczania odległości d(x, y, x0, y0) punktu o wylo-
sowanych współrzędnych (x, y) od początku układu współrzędnych (x0, y0). Wzór
umożliwiający wyznaczenie tej wartości ma postać:

d ( x, y , x0 , y 0 )  ( x  x0 ) 2  ( y  y 0 ) 2 .

Jednakże układ współrzędnych wykorzystywany w grafice komputerowej (w tym do


lokalizacji punktów w obiekcie typu Canvas) to lewoskrętny układ kartezjański z po-
czątkiem w lewym, górnym rogu ekranu. Lewoskrętność oznacza, że wartości osi
rzędnych (Oy) rosną w kierunku dolnej krawędzi ekranu monitora, czyli przeciwnie
Rozdział 5.  Wątki a interfejs użytkownika 123

niż to ma miejsce w prawoskrętnym układzie, w którym wartości te rosną do góry


w płaszczyźnie układu i do którego przywykliśmy podczas szkolnych lekcji matematyki.
Gdyby ten „szkolny” układ współrzędnych był wykorzystywany do lokalizacji punktów
w grafice komputerowej, jego początek znajdowałby się dokładnie w środku płasz-
czyzny ekranu monitora.

W powyższym przykładzie do wizualizacji pozycji wylosowanych punktów w kompo-


nencie typu Canvas wykorzystałem „szkolny” układ współrzędnych z początkiem
w środku okręgu. Z tego powodu podczas obliczania odległości wylosowanych punktów
od początku układu współrzędnych założyłem x0 = y0 = promień okręgu. Wartość
promienia okręgu przechowuję w polu _promienOkregu klasy MainWindow.

Na podstawie odległości wylosowanego punktu od początku układu współrzędnych


dobieram jego kolor w zależności od tego, czy punkt leży wewnątrz (kolor zielony),
czy na zewnątrz okręgu (kolor czerwony).

Jednak po uruchomieniu powyższej aplikacji nietrudno stwierdzić, że jeszcze nie


działa poprawnie. Po utworzeniu i uruchomieniu obliczeń w ramach funkcji wątku
szybko okaże się, że aplikacja nie implementuje jeszcze bezpieczeństwa wątków.
Pierwszy wyjątek typu InvalidOperationException, o treści Wątkiem wywołującym
musi być STA, ponieważ wiele składników interfejsów użytkownika go wymaga, poja-
wi się podczas wykonywania metody RysujPunkt. W celu rozwiązania tego problemu
wystarczy uzupełnić metodę zdarzeniową ButtonRozpocznij_Click o polecenie wyróż-
nione na listingu 5.26.

Listing 5.26. Konfiguracja modelu przetwarzania współbieżnego na model typu Single-Threaded


Apartment (STA)
private void ButtonRozpocznij_Click(object sender, RoutedEventArgs e)
{
if (!_watekAktywny)
{
PrzygotujPodglad();

_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).

Modele te realizują odmienny współdzielony dostęp do obiektów COM z poziomu


różnych wątków. W modelu STA stan wybranego obiektu COM może być kontrolowany
wyłącznie przez jeden wątek. Funkcje pozostałych wątków, wymagające dostępu do
danego obiektu COM, informują wątek kontrolujący jego stan o potrzebie wykonania
operacji na danym obiekcie. Wszystkie żądania są kolejkowane i wykonywane synchro-
nicznie. Dzięki temu zjawiska zakleszczenia i wyścigu nie mają miejsca.

Mechanizm realizowany przez model STA jest analogiczny do spotykanego podczas


projektowania desktopowych aplikacji wielowątkowych. W tych aplikacjach dostęp
do komponentów wizualnych może następować z poziomu kilku wątków. Takie ope-
racje są jednak niedozwolone i — jak przekonaliśmy się w poprzednich podrozdziałach
— powodują zgłoszenie wyjątku typu InvalidOperationException. Platforma uru-
chomieniowa pilnuje, aby dostęp do współdzielonych komponentów był realizowany
synchronicznie. Z tego powodu zachodzi konieczność wykorzystania metody Con-
trol.Invoke (w Windows Forms) w celu zsynchronizowania żądań i uniknięcia
efektów zakleszczenia i wyścigu.

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.

Neutralny model współbieżnego wykorzystania obiektów COM charakteryzuje się


tym, że nie wyróżnia jednego lub kilku wątków kontrolujących stan danego obiektu.
Jedynym ograniczeniem jest to, że wywołania metod kontrolujących stan obiektów
COM muszą następować synchronicznie.

W platformie .NET wątki domyślnie wykorzystują model MTA przetwarzania współ-


bieżnego podczas dostępu do niezarządzanych obiektów COM. Jednak dzieje się to
niejawnie podczas pierwszego dostępu do obiektu COM. Z konfiguracją modelu
przetwarzania współbieżnego spotkaliśmy się dopiero podczas dostępu do kompo-
Rozdział 5.  Wątki a interfejs użytkownika 125

nentów Windows Presentation Foundation, ponieważ do przetwarzania grafiki wyko-


rzystują one niezarządzaną technologię DirectX.

Model przetwarzania współbieżnego obiektu Thread może zostać skonfigurowany


jednorazowo tylko i wyłącznie wtedy, gdy jego własność ThreadState ma wartość
Unstarted lub Running. Jak pokazałem w powyższym przykładzie, służy do tego me-
toda Thread.SetApartmentState. Dozwolonymi parametrami tej metody są elementy typu
wyliczeniowego ApartmentState: MTA, STA oraz Unknown. Ten ostatni jest zwracany
przez metodę Thread.GetApartmentState w sytuacji, gdy nie skonfigurowano wcześniej
kontekstu działania wątku.

Bezpieczny dostęp do kontrolek WPF


W tym punkcie, na przykładzie aplikacji MonteCarloPi pokażę wzorce projektowe
wykorzystywane podczas implementacji bezpiecznego dostępu do komponentów
WPF. Procedura ta jest zbliżona do zastosowanej w przypadku kontrolek Windows
Forms i polega na wykonaniu poniższych czynności:
1. Uzupełnij projekt aplikacji MonteCarloPi o klasę ThreadSafeCallsWpf. W tym celu:
a) Kliknij menu Project, a następnie Add Class….
b) W polu Name kreatora Add New Item wpisz ThreadSafeCallsWpf.
c) Kliknij przycisk z etykietą Add.
2. W utworzonym pliku ThreadSafeCallsWpf.cs umieść polecenia z listingu 5.27.

Listing 5.27. Zawartość pliku ThreadSafeCallsWpf.cs


using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Shapes;

namespace MonteCarloPi
{
class ThreadSafeCallsWpf
{
private delegate void RysujPunktDelegate(Canvas canvas,
double x, double y, bool wewnatrzOkregu);

public static void RysujPunkt(Canvas canvas, double x,


double y, bool wewnatrzOkregu)
{
if (!canvas.Dispatcher.CheckAccess())
{
canvas.Dispatcher.Invoke(new
RysujPunktDelegate(RysujPunkt), new
object[] { canvas, x, y, wewnatrzOkregu });
126 Programowanie równoległe i asynchroniczne w C# 5.0

}
else
{
Ellipse ellipse = new Ellipse();

const int srednicaPunktu = 5;

ellipse.Width = srednicaPunktu;
ellipse.Height = srednicaPunktu;

ellipse.Fill = wewnatrzOkregu ?
Brushes.LightGreen : Brushes.Red;

canvas.Children.Add(ellipse);

Canvas.SetLeft(ellipse, x - srednicaPunktu / 2.0);


Canvas.SetTop(ellipse, y - srednicaPunktu / 2.0);
}
}

private delegate void DodajElementDoListyDelegate(ListBox


listBox, object item);

public static void DodajElementDoListy(ListBox listBox,


object item)
{
if (!listBox.Dispatcher.CheckAccess())
{
listBox.Dispatcher.Invoke(new
DodajElementDoListyDelegate(Dodaj
ElementDoListy), new object[] { listBox, item });
}
else
{
listBox.Items.Add(item);
listBox.SelectedItem = item;
listBox.ScrollIntoView(item);
}
}
}
}

1. Przejdź do edycji metody ObliczPi i zmodyfikuj ją według wzoru z listingu 5.28.

Listing 5.28. Bezpieczny dostęp do komponentów WPF


private void ObliczPi(Object parametryWatku)
{
ParametryWatku p = (ParametryWatku)parametryWatku;
long iloscProb = p.IloscProb;
int msSleepTime = p.Opoznienie;

long iloscTrafien = 0;

Random r = new Random();

for (long i = 1; i <= iloscProb && _watekAktywny; i++)


Rozdział 5.  Wątki a interfejs użytkownika 127

{
// 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);

bool punktZnajdujeSieWewnatrzOkregu = false;


if (odlegloscPunktuOdPoczatkuUkladuWspolrzednych
< _promienOkregu)
{
iloscTrafien++;
punktZnajdujeSieWewnatrzOkregu = true;
}

RysujPunkt(xComp, yComp, punktZnajdujeSieWewnatrzOkregu);


ThreadSafeCallsWpf.RysujPunkt(CanvasPodglad, xComp, yComp,
punktZnajdujeSieWewnatrzOkregu);

double wynikPi = 4.0 * iloscTrafien / i;

string statystyka = "Próba nr: " + i


+ ": Ilość trafień = "
+ iloscTrafien + ", Pi = "
+ wynikPi.ToString("f4") + ", Błąd = "
+ Math.Abs(Math.PI - wynikPi).ToString("f4");

DodajElementDoListy(statystyka);
ThreadSafeCallsWpf.DodajElementDoListy(ListBoxWyniki,
statystyka);

Thread.Sleep(msSleepTime);
}
}

4. Skompiluj i uruchom aplikację. Wynik jej działania powinien być analogiczny


do przedstawionego na rysunku 5.12.

Rysunek 5.12. Wizualizacja kolejnych etapów szacowania wartości liczby  metodą probabilistyczną
128 Programowanie równoległe i asynchroniczne w C# 5.0

Jak zaznaczyłem we wstępie, implementacja bezpiecznego dostępu do komponentów


WPF w wielowątkowych aplikacjach desktopowych jest analogiczna do przedstawio-
nej w podrozdziałach dotyczących kontrolek Windows Forms. Stan komponentów
wizualnych jest kontrolowany przez wątek okna aplikacji. Wszelkie żądania zmiany
ich stanu wysyłane z innych wątków muszą być zgłoszone do wątku kontrolującego
za pośrednictwem obiektu Dispatcher, po wcześniejszym sprawdzeniu, czy jest to
wymagane (metoda Control.Dispatcher.CheckAccess). Z każdym współdzielonym
komponentem skojarzony jest jeden obiekt Dispatcher, który zarządza kolejką żądań.
W technologii WPF istnieje możliwość konfiguracji priorytetów wykonania metod
zmieniających stan danego komponentu. Służą do tego celu przeciążone wersje metody
Control.Dispatcher.Invoke. Dostępne wartości priorytetów przechowuje typ wyli-
czeniowy DispatcherPriority. Oto przykład wykorzystania omawianych priorytetów:
listBox.Dispatcher.Invoke(new
DodajElementDoListyDelegate(DodajElementDoListy),
System.Windows.Threading.DispatcherPriority.Send,
new object[] { listBox, item });

Powyższe polecenie spowoduje bezpieczne wywołanie metody DodajElementDoListy


na rzecz komponentu listBox z najwyższym priorytetem.

Podobnie jak w przypadku komponentów Windows Forms, zgłoszenie żądania wyko-


nania zmiany stanu komponentu wizualnego można zrealizować asynchronicznie. Służy
do tego metoda Control.Dispatcher.BeginInvoke.

Kontekst synchronizacji 4

Wątek w platformie .NET może posiadać kontekst synchronizacji reprezentowany przez


instancję klasy SynchronizationContext lub jej klasy potomnej. Jeżeli wątek posiada taki
kontekst (co można sprawdzić, odczytując statyczną własność SynchronizationCon-
text.Current), możliwe jest przekazanie referencji do kontekstu do funkcji innego
wątku. Wówczas za pomocą metod Send lub Post obiektu SynchronizationContext
możliwe jest uruchomienie wskazanej w ich argumencie metody lub wyrażenia lambda
w wątku, z którym kontekst synchronizacji jest związany. Można też wykorzystać ten
mechanizm do przesłania żądania zmiany stanu kontrolki z funkcji wątku roboczego
do wątku UI.
Wykorzystanie mechanizmu opartego na kontekście synchronizacji nie jest bardziej
skomplikowane niż użycie własności Control.InvokeRequired oraz metody Con-
trol.Invoke. Zilustruję to przykładem opartym na projekcie aplikacji DataReader (podroz-
dział tego rozdziału „BackgroundWorker”), w którym aktualizację interfejsu użytkownika
przeniosę do funkcji wątku roboczego, czyli metody backgroundWorkerOdczyt_DoWork.
W tym celu w projekcie DataReader należy wykonać następujące czynności:
1. W metodzie zdarzeniowej buttonRozpocznijOdczyt_Click (listing 5.17)
uzupełnij wywołanie funkcji RunWorkerAsync o parametr
WindowsFormsSynchronizationContext.Current (listing 5.29).

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);
}

2. W pliku Form1.cs wstaw polecenia z listingu 5.30.

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;
}

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;
}
}

3. Metodę zdarzeniową backgroundWorkerOdczyt_DoWork (listing 5.14) zmodyfikuj


według wzoru z listingu 5.31.

Listing 5.31. Przykład wykorzystania kontekstu synchronizacji.


private void backgroundWorkerOdczyt_DoWork(object sender, DoWorkEventArgs e)
{
const int liczbaDanychDoOdczytania = 100;
const int msDelayTime = 50;

Random r = new Random();


WindowsFormsSynchronizationContext kontekst =
e.Argument as WindowsFormsSynchronizationContext;
for(int i = 0; i < liczbaDanychDoOdczytania; i++)
{
Thread.Sleep(msDelayTime);

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)));
}
}
}

W aplikacji desktopowej kontekst synchronizacji jest automatycznie tworzony dla wąt-


ków interfejsu użytkownika. Jest to obiekt typu WindowsFormsSynchronizationContext,
zdefiniowany w przestrzeni System.Windows.Forms, ale będący potomkiem klasy
SynchronizationContext z przestrzeni nazw System.Threading.

W powyższym przykładzie kontekst synchronizacji, czyli instancja klasy


WindowsFormsSynchronizationContext odczytana ze statycznej własności Synchroni-
zationContext.Current, został przekazany do funkcji wątku roboczego. Jego zadaniem
jest symulacja odczytu danych z urządzenia pomiarowego i prezentacja odczytanych
wartości w komponencie typu ListBox. Postęp tego procesu prezentowany jest za po-
mocą komponentu ProgressBar.

Zmiana stanu komponentów wizualnych realizowana jest w ramach metody AktualizujUI.


Bezpośrednie wywołanie tej metody, z pominięciem wykorzystania kontekstu synchro-
nizacji lub metody Control.Invoke, spowodowałoby zgłoszenie wyjątku typu Invali-
dOperationException.

W tym przykładzie do zaimplementowania bezpiecznego dostępu do współdzielonych


komponentów wizualnych wykorzystaliśmy kontekst synchronizacji, w którym w opar-
ciu o metodę Send uruchamiamy funkcję AktualizujUI. Definiowanie osobnej metody
AktualizujUI nie jest wcale konieczne. Równie dobrze możliwe byłoby wykorzystanie
wyrażenia lambda, którego sygnatura zgodna jest z delegatem SendOrPostCallback,
a więc przyjmuje jeden argument typu object i nie zwraca wartości. W tym przykładzie
zależało mi jednak na wyraźnym odseparowaniu tej części kodu, która — choć wy-
woływana w dodatkowym wątku — wykonywana będzie w wątku wskazanym przez
kontekst synchronizacji.

Wykorzystanie kontekstu synchronizacji można porównać z metodą Control.Invoke


(klasa ThreadSafeCalls, listing 5.10). Warto zwrócić uwagę na podobieństwa obu
podejść. W obu przypadkach wątek dodatkowy musi mieć referencję do obiektu
związanego z wątkiem interfejsu. Jest to obiekt kontekstu synchronizacji w pierw-
szym przypadku, a referencja do dowolnej kontrolki w drugim. Teoretycznie rzecz
ujmując, mechanizm oparty na kontekście synchronizacji mógłby być użyty dla do-
wolnych dwóch wątków, z których żaden nie musi być wątkiem okna. Można w ten
sposób np. rozdzielić dwa moduły aplikacji z dwoma niezależnymi wątkami, zachowując
ich pełną niezależność względem siebie. W praktyce samodzielne utworzenie kontekstu
synchronizacji nie jest jednak łatwe — zwykle wykorzystywane są tylko gotowe konteksty
dostarczone wraz z bibliotekami kontrolek, a więc WindowsFormsSynchronizationContext
Rozdział 5.  Wątki a interfejs użytkownika 131

w przypadku Windows Forms, DispatcherSynchronizationContext w przypadku WPF


i AspNetSynchronizationContext w przypadku ASP.NET. Konteksty synchronizacji
dostarczone wraz z platformą .NET pozwalają na wyraźne rozdzielenie warstwy inter-
fejsu (widoku), ze zdefiniowanymi w niej synchronicznymi metodami modyfikują-
cymi interfejs, oraz warstwy logiki, która nie musi zawierać żadnych bezpośrednich
odniesień do interfejsu i działających w nim kontrolek. Wystarczy jej wiedza o kontek-
ście synchronizacji wątku interfejsu. Zaletą użycia kontekstu synchronizacji jest pro-
stota kodu, w którym ten kontekst jest wykorzystywany5. Z drugiej strony, zastosowa-
nie statycznej klasy, implementującej bezpieczny dostęp do kontrolek wizualnych (np.
klasy ThreadSafeCalls), umożliwia jej łatwą przenaszalność pomiędzy projektami.

Warto też zaznaczyć, że obok metod blokujących, czyli SynchronizationContext.Send


i Control.Invoke, w obu poznanych mechanizmach mamy także do dyspozycji metody
wykonywane asynchronicznie: SynchronizationContext.Post i Control.BeginInvoke
(oraz Control.EndInvoke). One również uruchamiają zadania w wątku interfejsu, ale
nie blokują wątku dodatkowego. Aby zobaczyć różnicę w przypadku kontekstu syn-
chronizacji, usuńmy z metody AktualizujUI (listing 5.31) metodę Thread.Sleep wpro-
wadzającą sztuczne opóźnienie i zmieńmy metodę Send na Post. Spowoduje to szybkie
przebiegnięcie pętli for i tym samym zakolejkowanie do wykonania w wątku interfejsu
stu zadań, które są wykonywane po kolei.

Porównując metody SynchronizationContext.Post oraz Control.BeginInvoke, warto


jeszcze wyróżnić dodatkową możliwość drugiej z nich. Metody Control.BeginInvoke
(Windows Forms) albo Control.Dispatcher.BeginInvoke (WPF) zwracają informację
o stanie asynchronicznego wykonywania wskazanej w ich argumencie metody. Dzięki
temu programista może sprawdzić stan ich wykonania oraz zsynchronizować ich działa-
nie z pozostałymi elementami aplikacji. Obie te czynności można zrealizować za pomocą
metody Control.EndInvoke. W przypadku komponentów Windows Forms przykład
wykorzystania metod Control.BeginInvoke i Control.EndInvoke (oparty o metodę Dodaj
PunktyDoWykresu klasy ThreadSafeCalls, listing 5.11) może być następujący:
IAsyncResult iAsyncResult = chart.BeginInvoke(new
DodajPunktyDoWykresuDelegate(DodajPunktyDoWykresu),
new object[] { chart, seriesIndex, yValues });

// Wykonaj inne operacje i poczekaj na zakończenie metody DodajPunktyDoWykresu


// wywołanej na rzecz danego komponentu chart
chart.EndInvoke(iAsyncResult);

Metoda Control.EndInvoke pozwala na sprawdzenie stanu asynchronicznego wyko-


nania metody wskazanej w argumencie metody Control.BeginInvoke w oparciu o in-
terfejs IAsyncResult. W zmiennej tego typu zapisywane są informacje, m.in. o tym, czy
metoda została zakończona synchronicznie (właściwość IsCompleted), oraz uchwyt Asyn-
cWaitHandle, umożliwiający synchronizację (rozdział 4.). Metoda Control.Invoke
blokuje wątek wywołujący do momentu zakończenia funkcji uruchomionej za pomocą
metody Control.BeginInvoke.

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

Analogicznie sprawa wygląda w komponentach WPF. Jednakże obiekt Control.


Dispatcher nie udostępnia metody EndInvoke, ponieważ metoda Control.Dispatcher.
BeginInvoke jest oznaczona jako awaitable, a synchronizację wskazywanej przez
nią metody z pozostałymi elementami aplikacji realizuje się z wykorzystaniem ope-
ratora await.

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.

Miłą zaletą mechanizmu synchronizacji wątku interfejsu opartego na kontekście syn-


chronizacji jest to, że w zasadzie bez żadnych zmian można kod z listingów 5.29 – 5.31
zastosować także w projektach WPF, tak jak w przypadku wykorzystania metody
Control.Invoke. Jedyną różnicą jest to, że kontekst synchronizacji jest instancją klasy
System.Windows.Threading.DispatcherSynchronizationContext. W celu ilustracji
tego mechanizmu utworzę projekt aplikacji DataReaderWPF, będący wersją projektu
DataReader, opartą o komponenty WPF. Kod XAML aplikacji DataReaderWPF przed-
stawiłem na listingu 5.32, a kod obsługujący interakcje, czyli zawartość pliku Main
WindowXaml.cs, na listingu 5.33.

Listing 5.32. Definicja interfejsu użytkownika aplikacji DataReaderWPF


<Window x:Class="DataReaderWPF.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="DataReaderWPF" Height="345" Width="280">
<Grid>
<Button Content="Rozpocznij odczyt"
HorizontalAlignment="Left" Margin="10,10,0,0"
VerticalAlignment="Top" Width="120"
Name="buttonRozpocznijOdczyt"
Click="buttonRozpocznijOdczyt_Click"/>
<Button Content="Przerwij" HorizontalAlignment="Left"
Margin="135,10,0,0"
VerticalAlignment="Top" Width="120"
Name="buttonPrzerwijOdczyt"
Click="buttonPrzerwijOdczyt_Click" />
<ListBox HorizontalAlignment="Left" Height="237"
Margin="10,37,0,0" VerticalAlignment="Top"
Width="245" Name="listBoxDane"/>
<ProgressBar HorizontalAlignment="Left"
Height="21" Margin="10,279,0,0"
VerticalAlignment="Top" Width="170"
Name="progressBarOdczyt"/>
<Button Content="Wyczyść" HorizontalAlignment="Left"
Margin="187,279,0,0" VerticalAlignment="Top"
Rozdział 5.  Wątki a interfejs użytkownika 133

Width="68" Name="buttonWyczysc"
Click="buttonWyczyscListe_Click"/>
</Grid>
</Window>

Listing 5.33. Przykład wykorzystania kontekstu synchronizacji w aplikacji WPF


using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Navigation;
using System.Windows.Shapes;
using System.Windows.Threading;

namespace DataReaderWPF
{
/// <summary>
/// Interaction logic for MainWindow.xaml
/// </summary>
public partial class MainWindow : Window
{
private volatile bool _watekAktywny = false;

public MainWindow()
{
InitializeComponent();
}

private void ThreadFunc(object parametr)


{
const int liczbaDanychDoOdczytania = 100;
const int msDelayTime = 50;

Random r = new Random();


DispatcherSynchronizationContext kontekst = parametr as
DispatcherSynchronizationContext;

for(int i = 1; i <= liczbaDanychDoOdczytania


&& _watekAktywny; i++)
{
kontekst.Send(AktualizujUI,
new ParametrySynchronizacji(100 * i /
liczbaDanychDoOdczytania, r.Next(255)));
Thread.Sleep(msDelayTime);
}
}
134 Programowanie równoległe i asynchroniczne w C# 5.0

private void KonfigurujStanPrzyciskow(bool watekAktywny)


{
buttonPrzerwijOdczyt.IsEnabled = watekAktywny;
buttonRozpocznijOdczyt.IsEnabled = !watekAktywny;
}
private void buttonRozpocznijOdczyt_Click(object sender,
EventArgs e)
{
Thread thread = new Thread(ThreadFunc);

_watekAktywny = true;
thread.Start(DispatcherSynchronizationContext.Current);
KonfigurujStanPrzyciskow(true);
}
private void buttonPrzerwijOdczyt_Click(object sender,
EventArgs e)
{
_watekAktywny = false;
KonfigurujStanPrzyciskow(false);
}

private void buttonWyczyscListe_Click(object sender,


EventArgs e)
{
listBoxDane.Items.Clear();
}

private void AktualizujUI(object parametry)


{
ParametrySynchronizacji p = parametry as
ParametrySynchronizacji;

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

Kilka różnic można zauważyć w przypadku aplikacji dla Windows 8 z interfejsem


Modern UI. Mamy tam do czynienia z platformą WinRT, a nie zwykłą platformą
.NET. Platforma ta nie obsługuje tworzenia wątków ani za pomocą klasy Thread, ani
za pomocą puli wątków. Możemy jednak odtworzyć aplikację, używając zadań (roz-
dział 6.). Wówczas przekonamy się, że z wątkiem interfejsu związany jest kontekst
synchronizacji zaimplementowany w klasie WinRTSynchronizationContext z przestrzeni
nazw System.Threading. Sposób jego przekazania i użycia jest w zasadzie taki sam jak
w powyższych przykładach, z tą istotną różnicą, że nie ma obsługi metody Send, a jedy-
nie obsługiwana jest asynchroniczna metoda Post.

Groźba zagłodzenia wątku interfejsu


i asynchroniczna zmiana stanu
współdzielonych zasobów
Pracując z aplikacjami z interfejsem graficznym, w których dodatkowe wątki modyfikują
wygląd interfejsu, należy pamiętać o wszystkich niebezpieczeństwach, jakie czyhają na
nas w aplikacjach wielowątkowych. Szczególnie bardzo łatwo o zakleszczenie, w efekcie
którego może dojść do zagłodzenia wątku interfejsu. Aby to pokazać, wystarczy na koń-
cu metody zdarzeniowej buttonRozpocznijOdczyt_Click (listing 5.33), uruchamianej
po kliknięciu przycisku z etykietą Rozpocznij odczyt, umieścić wywołanie metody Join
na rzecz tworzonego w niej wątku (instrukcja thread.Join()). Efektem będzie trwałe
„zastygnięcie” aplikacji tuż po kliknięciu przycisku. Dlaczego? Użycie metody Con-
trol.Invoke lub metody SynchronizeContext.Send, a więc metod, które wstrzymują
bieżący wątek do czasu, gdy zakończy się wykonywanie działań w wątku interfejsu, na-
leży traktować jak punkt synchronizacji. Prześledźmy działanie obu wątków na przy-
kładzie kodu z listingu 5.32. Po kliknięciu przycisku przez użytkownika w wątku in-
terfejsu uruchamiana jest metoda buttonRozpocznijOdczyt_Click. W niej tworzony
jest dodatkowy wątek, w którym zaczyna działać pętla for. W jej pierwszej iteracji
wywoływana jest metoda kontekst.Send, która stara się zakolejkować czynność do
wykonania przez wątek interfejsu i czeka na jej zakończenie. Jednak wątek interfejsu
nadal wykonuje metodę buttonRozpocznijOdczyt_Click, bo nie może jej opuścić ze
względu na polecenie thread.Join, które z kolei czeka na zakończenie dodatkowego
wątku. A skoro tak, nie może wykonać czynności zleconych w metodzie Send. W konse-
kwencji oba wątki „stoją”, nawzajem na siebie czekając. Zatrzymanie wątku interfejsu
powoduje, że interfejs aplikacji nie odpowiada na czynności użytkownika.

Oczywiście, oczekiwanie na zakończenie funkcji wątku wewnątrz metody, w ramach


której został utworzony, nie ma większego sensu, gdyż sprowadza się do jej sekwen-
cyjnego wykonania. Jednak dzięki takiej konstrukcji powyższego przykładu udało się
zilustrować jeszcze dwa bardzo istotne aspekty programowania równoległego. Po
pierwsze, zgodnie z dobrymi praktykami programowania wielowątkowego (dodatek A)
oczekiwanie na zakończenie funkcji wątku powinno być skończone. Oznacza to, że
wywołanie metody thread.Join należy uzupełnić o dodatkowy parametr timeOut, np.
thread.Join(500). Co prawda, nie zapewni to poprawnego działania aplikacji, ale za-
pobiegnie zagłodzeniu wątku UI. Po drugie, zakleszczeniu wątków w omawianej sy-
136 Programowanie równoległe i asynchroniczne w C# 5.0

tuacji zapobiegnie asynchroniczne wysyłanie żądań zmiany stanu komponentów wizual-


nych do wątku, który je kontroluje (wątku UI). W tym celu w listingu 5.33 wystarczy
zastąpić wywołanie metody
kontekst.Send(AktualizujUI, new ParametrySynchronizacji(100 * i /
liczbaDanychDoOdczytania, r.Next(255)));

poleceniem
kontekst.Post(AktualizujUI, new ParametrySynchronizacji(100 * i /
liczbaDanychDoOdczytania, r.Next(255)));

Wówczas wywołanie metody thread.Join bez parametru timeOut spowoduje, że wszyst-


kie żądania zmiany stanu interfejsu użytkownika, które w tym przypadku sprowadzają
się do wyświetlenia wylosowanych danych i zmiany wartości komponentu typu
ProgressBar, zostaną zakolejkowane do wykonania przez wątek UI. Realizacja tych
żądań odbędzie się po wykonaniu metody zdarzeniowej buttonRozpocznijOd-
czyt_Click. Przy czym interfejs aplikacji będzie zablokowany dopóki, dopóty trwa wy-
konywanie pętli for w funkcji ThreadFunc. Funkcja ta kończy się z ostatnią iteracją
pętli for, po upłynięciu około pięciu sekund. Po zakończeniu tej funkcji wątek UI od-
świeża interfejs użytkownika i wszystkie wylosowane liczby dodawane są do listy
praktycznie w tym samym momencie, bez widocznego opóźnienia.

Ponownie działanie aplikacji nie jest zgodne z oczekiwaniami, jednak nieskończone


zakleszczenie wątków już nie występuje.
***

W powyższym rozdziale przedstawiłem typowe problemy związane z używaniem


wątków w aplikacjach desktopowych korzystających z bibliotek kontrolek Windows
Forms i Windows Presentation Foundation oraz wzorce projektowe pozwalające na
ich rozwiązanie.

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

Po omówieniu „tradycyjnego” podejścia do programowania współbieżnego opierają-


cego się na wątkach oraz zagadnień związanych z synchronizacją w programowaniu
współbieżnym możemy przejść do biblioteki TPL (ang. Task Parallel Library). Została
ona wprowadzona do platformy .NET w wersji 4.0 i dość szybko stała się standardem
w programowaniu współbieżnym dla tej platformy. Należy jednak wiedzieć, że biblio-
teka ta nie jest zbudowana od „zera”. Jej działanie opiera się na poznanych w poprzed-
nich rozdziałach wątkach, a konkretnie wykorzystuje pulę wątków aplikacji, aby w naj-
bardziej optymalny sposób wykorzystać dostępne rdzenie procesora. W konsekwencji
wiedza o wątkach, a szczególnie o ich synchronizacji, będzie nadal potrzebna.

Bibliotekę TPL należy traktować jak warstwę abstrakcji ułatwiającą programowanie


współbieżne. Jej kluczowym pojęciem są zadania (klasa Task), które zostaną omówione
w tym rozdziale. W praktyce zapewne częściej używane będą równoległe pętle zaim-
plementowane w klasie Parallel. Zostały one zapowiedziane w rozdziale 1., a bardziej
szczegółowo opisane zostaną w rozdziale 7. W rozdziale 8. powrócimy do zagadnień
synchronizacji, aby pokazać, że mechanizmy omówione w rozdziale 4. mają również
zastosowanie w przypadku zadań. Wreszcie w rozdziale 9. przedstawimy kolekcje przy-
stosowane do współpracy z wieloma działającymi równocześnie zadaniami oraz zrów-
nolegloną bibliotekę LINQ (PLINQ). Wszystkie te nowe elementy wspomagające pro-
gramowanie współbieżne nazywane są wspólnie Parallel Extensions.

Nowym bibliotekom towarzyszą nowe narzędzia Visual Studio wspierające progra-


mowanie równoległe dla platformy .NET, które pomagają w tworzeniu aplikacji ko-
rzystających z wielu rdzeni. Poświęcony jest im rozdział 10.

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

Thread (czyli wątek). W założeniu twórców klasa Task ma wyprzeć wykorzystywanie


klasy Thread w procesie tworzenia oprogramowania.

Nowa klasa zdefiniowana jest w przestrzeni nazw System.Threading.Tasks. W „na-


główku” pliku .cs powinno zatem znaleźć się polecenie:
using System.Threading.Tasks;

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.

Instancję klasy Task możemy utworzyć, korzystając z ośmiokrotnie przeciążonego


konstruktora, którego najważniejszym parametrem jest Action, tj. delegat do metody
nieprzyjmującej argumentów i niezwracającej wartości. Przechowuje ona referencję do
metody, która ma być wykonywana przez zadanie. Jako argumentu konstruktora można
użyć wyrażenia lambda. W przypadku najprostszego jednoargumentowego konstruktora
wygląda to następująco:
Task t = new Task( () => {/*kod do wykonania*/} );

Natomiast kod wypisujący komunikat powitalny wygląda tak:


Task t = new Task( () => { Console.WriteLine("Witaj!"); } );

Pozostałe konstruktory oferują dodatkowo możliwość definiowania opcji związanych


z tworzeniem wątków, przerwaniami itp. Zostaną one omówione dalej w tym rozdziale.

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();

Z kolei wymuszenie wstrzymania bieżącego wątku do momentu zakończenia zadania


można uzyskać, wywołując w nim metodę Task.Wait:
t.Wait();

To pokazuje, że w podstawowych czynnościach praca z zadaniami niewiele różni się


od pracy z wątkami.

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.

Listing 6.1. Równoległe wykonanie dużej ilości zadań


Action a = () =>
{
Console.WriteLine("Start zadania nr " + Task.CurrentId);
Thread.SpinWait(new Random().Next(100000000));
Console.WriteLine("Koniec zadania nr " + Task.CurrentId);
};

//kod tworzący wątki, uruchamiający je i czekający na ich zakończenie


List<Task> listaZadan=new List<Task>();

for(int i=0;i<100;i++)
{
listaZadan.Add(new Task(a));
}

listaZadan.ForEach(t=>t.Start());
listaZadan.ForEach(t=>t.Wait());

Dla wygody i przejrzystości kod wykonywany przez zadanie umieszczony został


w zdefiniowanej osobno akcji a. Jej działanie polega na wypisaniu w konsoli informacji
o rozpoczęciu pracy zadania, symulacji losowej ilości obliczeń oraz wypisaniu informacji
o zakończeniu zadania. Fragment wyświetlonych w oknie konsoli informacji przed-
stawiam na rysunku 6.1.

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ń

Referencje do zadań przechowujemy w zwykłej liście List parametryzowanej klasą


Task:
List<Task> listaZadan=new List<Task>();

Jej użycie pozwala na swobodne zwiększanie liczby przechowywanych w niej zadań,


ale również na użycie pętli foreach, w której mogą być uruchamiane wszystkie zadania.
Jednak zamiast tej standardowej pętli użyliśmy metody List.ForEach.

Dane przekazywane do zadań


Rzeczą, bez której trudno byłoby się obejść w programowaniu współbieżnym, jest moż-
liwość przekazywania do zadań danych. Bez tego trudno myśleć o różnicowaniu za-
dań realizowanych przez poszczególne wątki. Nie po to przecież tworzy się wiele wąt-
ków, aby robiły dokładnie to samo. Do zadania może być np. przesyłany fragment
obrazu (bitmapy), który jest przez nie modyfikowany. Może to być również zakres re-
kordów bazy danych, który ma być przeszukany; mogą być to także parametry dla obli-
czeń przeprowadzanych przez zadania. W takiej sytuacji należy użyć dwuargumento-
wego konstruktora klasy Task, który oprócz delegata przyjmuje również przesyłane do
klasy dane:
Task(Action<object> action, object state) //sygnatura konstruktora zadania
Rozdział 6.  Zadania 141

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.

Dane zwracane przez zadania


Efektem wykonania zadania typu Task<> (klasa Task z parametrem) są dane, które po-
winny być zwrócone do głównej części programu. Służy do tego własność Result za-
dania, której typ określony jest przez parametr klasy Task. Własność ta przechowuje
wartość, która zwracana jest przez funkcję wykonywaną przez zadania. Jeżeli w mo-
mencie próby odczytu własności Result akcja zadania nie została jeszcze zakończona,
wątek z którego następuje odczyt, jest wstrzymywany do czasu jej zakończenia. Jest
to zatem ukryty punkt synchronizacji (rozdział 1.).

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”.

Listing 6.2. Zadanie zwracające wartość


Task<String> t = new Task<String>(() => { return "Dzień dobry!"; });

t.Start();
t.Wait();

Console.WriteLine(t.Result);

Przykład: test liczby pierwszej


Jednym z najprostszych algorytmów, które mogą być zrównoleglone, jest naiwne
sprawdzanie, czy wskazana liczba naturalna jest liczbą pierwszą. Należy jednak za-
znaczyć, że zrównoleglenie tak prostego algorytmu, w którym operacje wykonywane
w jednej iteracji pętli zajmują bardzo niewiele czasu, nie spowoduje, że program będzie
działał szybciej. Wręcz przeciwnie — narzut związany z tworzeniem i przełączeniem
zadań spowoduje, że wersja równoległa będzie znacznie wolniejsza od sekwencyjnej.
Przykład ten należy zatem traktować wyłącznie jako ćwiczenie w zrównolegleniu kodu.

Na listingu 6.3 przedstawiam implementację algorytmu, który w pętli indeksowanej od


dwójki (a ta, jak wiadomo, jest najmniejszą liczbą pierwszą) do pierwiastka kwadrato-
wego liczby przekazanej w parametrze sprawdza, czy ta liczba może być podzielona bez
142 Programowanie równoległe i asynchroniczne w C# 5.0

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.

Listing 6.3. Weryfikacja liczby pierwszej


List<Task<int>> lista = new List<Task<int>>();
Console.Write("Sprawdź, czy liczba pierwsza: ");
int n = Int32.Parse(Console.ReadLine());

for (int i = 2; i <= (int)Math.Sqrt(n); i++)


{
lista.Add(new Task<int>((j) =>
{
if (n % (int)j == 0)
{
return (int)j;
}
else
{
return 0;
}
}, i));
}

foreach (Task<int> t in lista) { t.Start(); }

foreach (Task<int> t in lista) { t.Wait(); }

bool pierwsza = true;


foreach (Task<int> t in lista)
{
if(t.Result!=0)
{
Console.WriteLine("Liczba {0} dzieli się przez {1}.",n,t.Result);
pierwsza = false;
}
}
if (pierwsza) Console.WriteLine("Liczba {0} jest liczbą pierwszą.",n);

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

pierwszą, nie ma sensu, aby kontynuować pracę pozostałych zadań. Pozwalający na to


mechanizm przerywania zadań zostanie omówiony dalej w tym rozdziale, w podroz-
dziale „Przerywanie zadań”.

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 )

public static void WaitAny( 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 )

public static bool WaitAny( Task[] tasks, int millisecondsTimeout )

public static bool WaitAll( Task[] tasks, TimeSpan timeout )

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.

Podobnie do metody Wait działa również metoda Task.ContinueWith. Czeka na zakoń-


czenie zadania, jednak dodatkowo zaraz po jego skończeniu wykonuje podany w ar-
gumencie kod (listing 6.4).

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

Listing 6.4. Przykład użycia metody Task.ContinueWith


Task t1, t2, t3;

t1 = new Task(() => { Thread.Sleep(1000); Console.WriteLine("zadanie t1


o identyfikatorze {0} zakończone", Task.CurrentId); });
t2 = new Task(() => { Thread.Sleep(2000); Console.WriteLine("zadanie t2
o identyfikatorze {0} zakończone", Task.CurrentId); });
t3 = new Task(() => { Thread.Sleep(3000); Console.WriteLine("zadanie t3
o identyfikatorze {0} zakończone", Task.CurrentId); });

Task[] zadania = { t1, t2, t3 };

t2.ContinueWith((t)=>
{
Console.WriteLine("Zadanie o identyfikatorze {1} zostało wykonane
po zakończeniu zadania t2 o identyfikatorze {0}", t.Id,Task.CurrentId);
});

foreach (Task t in zadania) t.Start();


foreach (Task t in zadania) t.Wait();

Jednak w odróżnieniu od poleceń umieszczonych za wywołaniem metody Continue


With, które są wykonywane w bieżącym wątku, akcja przekazana przez jej argument
wykonywana jest w osobnym wątku jako kolejne zadanie3. Kod widoczny na listingu
6.4 wypisuje numery identyfikacyjne zadań, co pozwala stwierdzić, że zadanie utwo-
rzone przez ContinueWith ma inny identyfikator niż zadanie, którego zakończenie było
warunkiem jego rozpoczęcia. Sama składnia metody ContinueWith wymaga przekazania
akcji z jednym parametrem. Parametrem tym jest obiekt zadania, które zakończyło się
i spowodowało wywołanie zadania bieżącego. Dzięki temu można np. sprawdzić iden-
tyfikator tego „poprzednika”.

Przykład: sztafeta zadań


Rozważmy przykład wyścigu zadań w formie sztafety (listing 6.5). Na starcie znajdują
się zadania t1 i t2, po których wyruszają kolejno zadania t3 i t4. Metoda WaitAll
pełni tu rolę sędziego, który ogłasza zakończenie wyścigu. Aby ogłosić zwycięzcę, na-
leżałoby odwołać się do metody statycznej ContinueWhenAny, ponieważ jednak nie jest ona
elementem klasy Task, a opisanej niżej klasy TaskFactory, nie będzie na razie omawiana.

Listing 6.5. Kontynuowanie zadań na przykładzie sztafety


Task t1, t2, t3, t4;
Action a = ()=>
{
Console.WriteLine("Zawodnik nr {0} wystartował",Task.CurrentId);
Thread.Sleep(new Random().Next(1000,1500));

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

Console.WriteLine("Zawodnik nr {0} zakończył bieg", Task.CurrentId);


};

Action<Task> b = (t) =>


{
Console.WriteLine("Zawodnik nr {0} wystartował po zawodniku nr {1}",
Task.CurrentId, t.Id);
Thread.Sleep(new Random().Next(1000, 2500));
Console.WriteLine("Zawodnik nr {0} zakończył bieg", Task.CurrentId);
};

t1 = new Task(a);
t2 = new Task(a);
t3 = t1.ContinueWith(b);
t4 = t2.ContinueWith(b);

t1.Start();
t2.Start();

Task.WaitAll(t1, t2, t3, t4);


Console.WriteLine("Wyścig zakończony");

W programie przedstawionym na listingu 6.5 występują dwa typy zadań: uruchamia-


ne na początku oraz tworzone za pomocą metody ContinueWith. Wykonywany przez
nie kod opisują odpowiednio akcje a i b. Warto zauważyć, że aby czekać na zakoń-
czenie wszystkich czterech zadań, należy przekazać referencje do wszystkich związa-
nych z nimi obiektów w argumentach metody Task.WaitAll. Jest to możliwe, dlatego
że metoda Task.ContinueWith zwraca referencję do nowego zadania, które tworzy.

Metoda ContinueWith występuje w różnych wariantach, m.in. takich, którym można


przekazać argument TaskContinuationOptions. Jest to typ wyliczeniowy, w którym
znajdują się wszystkie możliwe opcje dotyczące zachowania metody ContinueWith.
Zawiera on wszystkie elementy występujące w wyliczeniu TaskCreationOptions, któ-
re będą opisane później, oraz dodatkowo wymienione w tabeli 6.1.

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

Tabela 6.1. Wybrane elementy wyliczenia TaskContinuationOptions4

Kontynuowanie nie powinno nastąpić, jeżeli zadanie zakończy się powodzeniem,


NotOnRanToCompletion
tj. nie zostanie przerwane, ani nie wystąpi nieobsługiwany wyjątek.
NotOnFaulted Kontynuowanie nie nastąpi, jeżeli zadanie zakończyło się nieobsługiwanym
wyjątkiem.
NotOnCanceled Kontynuowanie nie nastąpi, jeżeli zadanie zostało przerwane metodą Cancel.
OnlyOnRanToCompletion Kontynuowanie nastąpi tylko wtedy, gdy zadanie dojdzie do końca.

OnlyOnFaulted Kontynuowanie nastąpi, gdy zadanie zostanie przerwane — wówczas za


pomocą własności Exception można sprawdzić, czemu zgłoszony został
wyjątek. Jeżeli nie zostanie on w tym momencie obsłużony, kontynuowanie
zakończy się również wyjątkiem. W tej sytuacji, tj. gdy zadanie zakończyło
się wyjątkiem, każda próba odczytania własności Result również zakończy
zadanie ContinueWith wyjątkiem.
OnlyOnCanceled Kontynuowanie nastąpi tylko wtedy, gdy zadanie zostanie przerwane
za pomocą metody Cancel.
ExecuteSynchronously Wymusza kontynuowanie w obrębie wątku, który wykonywał zadanie.
Jest to rozwiązanie dla krótkotrwałych zadań — następników.

sprawdzana wewnątrz kodu wykonywanego przez zadanie i jeżeli zmieni wartość na


true — zadanie powinno być kończone. Alternatywnie możemy wewnątrz kodu zadania
jak najczęściej wywoływać metodę CancellationToken.ThrowIfCancellationRequested,
która zgłosi wyjątek OperationCanceledException w przypadku wywołania metody
Cancel. Wyjątek taki może być obsłużony wewnątrz zadania, co powinno prowadzić
do jego zakończenia. Obiekt CancellationToken jest składową klasy CancellationToken
Source (listing 6.6).

Listing 6.6. Struktura klas CancellationToken i CancellationTokenSource


namespace System.Threading
{
public sealed class CancellationTokenSource
{
public void Cancel();
public CancellationToken Token { get; }

}

public struct CancellationToken


{
public Boolean IsCancellationRequested { get; }
public void ThrowIfCancellationRequested();

}
5
}

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ń.

Oto zalety takiego rozwiązania.


 Nie każdy, kto ma dostęp do referencji danego zadania, może je przerwać
— można w ten sposób selekcjonować obiekty, które mogą przerywać zadanie.
 Łatwy sposób na przerwanie większej grupy zadań — wystarczy przypisać im
ten sam token.
 Korzystanie z tokena usprawnia przerwania w przypadku pętli równoległych
(rozdział 7.) oraz PLINQ (rozdział 9.).

Na listingu 6.7 pokazuję przykład przerwania pojedynczego zadania.

Listing 6.7. Przykład zatrzymania zadania


CancellationTokenSource cts = new CancellationTokenSource();
CancellationToken ct = cts.Token;

Task t = new Task(() =>


{
try
{
Console.WriteLine("Zadanie zostało uruchomione");
for (; ; )
{
ct.ThrowIfCancellationRequested();
}
}
catch (OperationCanceledException)
{
Console.WriteLine("Zadanie zostało przerwane");
}
}, ct);

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.

Listing 6.8. Przerwanie oczekiwania na zakończenie zadania


CancellationTokenSource cts = new CancellationTokenSource();
CancellationToken ct = cts.Token;

Task t = new Task(() =>


{
Console.WriteLine("Zadanie zostało uruchomione");
for (;;)
{
Thread.Sleep(3000);
cts.Cancel();
}
});

t.Start();
Task[] zadania={t};

try
{
Task.WaitAll(zadania, ct);
}
catch (OperationCanceledException)
{
Console.WriteLine("Przerwano oczekiwanie");
}

Gdy mówimy o przechwytywaniu wyjątków w kontekście zadań, trzeba zwrócić uwagę


na kilka kwestii.
 Obsłużenie wyjątku wewnątrz zadania (jak w pierwszym przykładzie z listingu
6.7) spowoduje zakończenie zadania ze statusem RanToCompletion, tj. takim
samym, jakby zadanie zakończyło się bez przerwania (informacje o możliwych
stanach zadania podaję w następnym podrozdziale).
 Brak obsługi wyjątku wewnątrz delegata spowoduje zgłoszenie nieobsłużonego
wyjątku podczas uruchamiania programu w trybie debugowania. Debugger
zareaguje przerwaniem programu ze względu na nieobsłużony wyjątek typu
OperationCancelledException, pomimo iż kod jest poprawny, a wyjątek będzie
obsłużony później. W tej sytuacji wystarczy jedynie wcisnąć F5, żeby kontynuować
pracę programu. Aby jednak uniknąć tego typu sytuacji, należy w ustawieniach
Visual Studio wyłączyć ustawienie debuggera Just My Code (menu Tools\
Options\Debugging).
 Wywołanie metody Cancel na różnych etapach realizacji zadania przynosi
odmienne efekty. Jeżeli zadanie nie zostało jeszcze w pełni uruchomione, ściślej
Rozdział 6.  Zadania 149

mówiąc, nie jest jeszcze wykonywany kod metody wskazanej w argumencie,


zostanie przerwane bez zgłaszania wyjątku — w końcu nie ma jeszcze
konieczności ani możliwości jego obsłużenia.
 Zgodnie z sugestiami dokumentacji MSDN, wyjątki powinny być
przechwytywane w metodzie Wait6.
 Przechwytywanie wyjątków przy wywołaniu metody Wait polega na obsłużeniu
wyjątku typu AggregateException. Stanowi on zbiór wyjątków, do których
dostać się można przez własność InnerExceptions (listing 6.9).

Listing 6.9. Obsługa zbioru wyjątków typu AggregateException


try
{
Task.WaitAll(zadania);
}
catch (AggregateException ae)
{
foreach (var exc in ae.InnerExceptions)
{
Console.WriteLine("Przechwycony wyjątek: {0}", exc.Message);
}
}

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.

Tabela 6.2. Lista stanów, w jakich może znaleźć się zadanie7


Stan Opis
Created Zadanie zostało utworzone, ale jeszcze nie jest uruchomione — zadanie jest
w tym stanie po wywołaniu konstruktora.
WaitingForActivation Zadanie zostało zakolejkowane (np. poprzez metodę Task.Start bądź
TaskFactory.StartNew), ale nie nastąpiło jeszcze jego uruchomienie — zadanie
oczekuje, aż jego planista (TaskScheduler) zadecyduje o uruchomieniu.
WaitingToRun Zadanie w tym stanie zostało zaplanowane do wykonania przez jeden
z wariantów Continue; status ten oznacza, że zadanie czeka na zakończenie
poprzednika.
Running Zadanie jest w trakcie wykonywania.

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.

Typowy cykl życia zadania przebiega w następujący sposób: Created (→ Waiting


ForActivation) → Running → RanToCompletion. Na listingu 6.10 prezentuję kod,
w którym stan obiektu jest wyświetlany na ekranie.

Listing 6.10. Wyświetlenie kolejnych stanów pojedynczego zadania


Task test = new Task(() =>
{
Console.WriteLine("Zadanie rozpoczęte");
Thread.Sleep(200);
Console.WriteLine("Zadanie zakończone");
});

//zadanie sprawdzające co chwila, w jakim stanie jest zadanie testowe


Task obserwator = Task.Factory.StartNew(() =>
{
int i =10;
while(i-- > 0)
{
Thread.Sleep(100);
Console.WriteLine(test.Status.ToString());
}
});

Thread.Sleep(200);
test.Start();

Task.WaitAll(test, obserwator);

W czasie działania programu zadanie obserwator co 0.1 sekundy (100 milisekund)


sprawdza i wypisuje stan zadania testowego — pozwala to obserwować zmiany jego sta-
nu. Warto również zwrócić uwagę, jak stan zadania zmienia się w przypadku przerwania.
W tym celu w programie należy uwzględnić zmiany zaznaczone na listingu 6.11.

Listing 6.11. Przerwanie dwóch zadań z uwzględnieniem zmian stanu


CancellationTokenSource cts = new CancellationTokenSource();
CancellationToken ct = cts.Token;

Task test1 = new Task(() =>


{
Console.WriteLine("\nStart zadania (id:{0})\n", Task.CurrentId);
while (true)
Rozdział 6.  Zadania 151

{
ct.ThrowIfCancellationRequested();
}
}, ct, TaskCreationOptions.LongRunning);

Task test2 = new Task(() =>


{
Console.WriteLine("\nStart zadania (id:{0})\n", Task.CurrentId);
while (true)
{
ct.ThrowIfCancellationRequested();
}
}, TaskCreationOptions.LongRunning);

//zadanie sprawdzające co chwila, w jakim stanie jest zadanie testowe


Task obserwator = Task.Factory.StartNew(() =>
{
while (!(test1.IsCompleted && test2.IsCompleted))
{
Thread.Sleep(100);
Console.WriteLine("Test1: {0}\t\tTest2: {1}", test1.Status, test2.Status);
}
});

Thread.Sleep(200);
test1.Start();
test2.Start();

while (test1.Status != TaskStatus.Running || test2.Status != TaskStatus.Running) ;


Thread.Sleep(200);
Console.WriteLine("\nPrzerwanie\n");
cts.Cancel();

try
{
Task.WaitAll(test1, test2, obserwator);
}
catch (AggregateException ae)
{
foreach (var exc in ae.InnerExceptions)
{
Console.WriteLine("Przechwycono wyjątek: {0}", exc.Message);
}
}

Console.WriteLine("Naciśnij dowolny klawisz…");


Console.ReadKey();

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

Rysunek 6.3. Przykład obrazujący zmiany stanu zadań

W momencie przerwania zadanie pierwsze przechodzi w stan Canceled (przerwane),


natomiast drugie — w stan Faulted (zakończone niepowodzeniem). Wynika to z faktu,
iż drugie zadanie nie ma przypisanego tokena ct. W takiej sytuacji wyjątek Operation
CanceledException (zgłaszany przez metodę ThrowIfCancelationRequested) inter-
pretowany jest tak samo jak każdy inny wyjątek niezwiązany z tokenem. Zatem zadanie
powinno znaleźć się w stanie Faulted i tak też się stało. Widać tu także przedział czasu,
w którym wykonywane jest tylko pierwsze zadanie, a drugie czeka na rozpoczęcie. Czas
ten byłby znacznie dłuższy, gdyby zadania nie były tworzone z opcją LongRunning.

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.

Metoda StartNew klasy TaskFactory służy do prostego i szybkiego tworzenia zadania


i jego jednoczesnego uruchamiania. O ile nie jest konieczne odseparowanie tych dwóch
czynności — tworzenie w ten sposób zadań jest jak najbardziej zalecane. Najprostsze
wywołanie metody StartNew wygląda następująco:
Task t = Task.Factory.StartNew(() => { /*…*/ });

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

Korzystanie z fabryki obiektów pozwala uniknąć tworzenia zadania za pomocą kon-


struktorów klasy Task, ale nie pozbawia nas możliwości, jakie one dają. Chodzi
przede wszystkim o możliwość przekazania elementów, takich jak CancellationToken,
TaskCreationOptions, własnych argumentów czy zwracania wartości przez zadanie
(w przypadku parametrycznej wersji klasy Task). W kodzie widocznym na listingu 6.11
prezentuję wszystkie te możliwości — tworzone jest w nim zadanie przyjmujące łań-
cuch, opcje i token przerwania oraz zwracające wynik.

Wersja metody TaskFactory.StartNew użyta w kodzie z listingu 6.12 przyjmuje jako


argumenty kolejno: akcję (kod wykonywany przez zadanie), dane wejściowe (typu
object), token pozwalający na przerwanie zadania (CancellationToken), opcję (Task
CreationOptions) i na końcu referencję do planisty (TaskScheduler). Ponieważ nie
chcemy wprowadzać własnego planisty, wskazujemy planistę domyślnego, którego
referencja dostępna jest przy użyciu właściwości Default klasy abstrakcyjnej Task
Scheduler. Jest to konieczne, bo nie ma takiej wersji metody, która pobiera tylko
cztery interesujące nas argumenty.

Listing 6.12. Tworzenie zadania przez fabrykę obiektów


CancellationTokenSource cts = new CancellationTokenSource();
CancellationToken ct = cts.Token;

Task<int> t2 = Task<int>.Factory.StartNew(
(o) => { Console.WriteLine(o.ToString()); return 1; },
(object)"Dzień dobry",
ct,
TaskCreationOptions.None,
TaskScheduler.Default
);
t2.Wait();

Klasa TaskFactory posiada również dwie metody ContinueWhenAll i ContinueWhenAny9.


Mają się one do poznanych wcześniej metod WaitAll i WaitAny, tak jak ContinueWith
do Wait. Sposób użycia tych metod nie różni się od WaitAll i WaitAny, ale wśród prze-
ciążonych wersji tych metod nie ma takiej, w której do określenia listy zadań użyto mo-
dyfikatora params. Wynika to z faktu, że oprócz tablicy zadań zawsze musi być prze-
kazana jeszcze akcja wykonywana po zakończeniu wszystkich lub jednego z zadań
(w zależności od metody). Ponadto metody te umożliwiają przekazanie tokena prze-
rwania i (lub) ustawień (obiektu TaskContinuationOptions).

Aby zilustrować użycie metody TaskFactory.ContinueWhenAny, wróćmy do przykładu


ze sztafetą. Użyjemy metody do wskazania zwycięzców (listing 6.13).

Listing 6.13. Przykład ze sztafetą rozszerzony o ogłoszenie zwycięzcy


Task t1, t2, t3, t4;
Action a = () =>
{
Console.WriteLine("Zawodnik nr {0} wystartował", Task.CurrentId);

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

Thread.Sleep(new Random().Next(1000, 1500));


Console.WriteLine("Zawodnik nr {0} zakończył bieg", Task.CurrentId);
};

Action<Task> b = (t) =>


{
Console.WriteLine("Zawodnik nr {0} wystartował po zawodniku nr {1}",
Task.CurrentId, t.Id);
Thread.Sleep(new Random().Next(1000, 2500));
Console.WriteLine("Zawodnik nr {0} zakończył bieg", Task.CurrentId);
};

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");

W programie zmieniony został sposób tworzenia zadań — teraz korzystamy z metody


Task.Factory.StartNew. To pozwala umieścić w jednej linii, choć może za cenę czy-
telności kodu, polecenie tworzące i uruchamiające zadanie oraz określanie następcy,
przy jednoczesnym zapamiętaniu referencji do wszystkich tych zadań. Druga zmiana,
bardziej istotna, to dodanie wywołania metody ContinueWhenAny, która odpowiada za
wywołanie akcji (w tym przykładzie ogłoszenia zwycięzcy biegu) po zakończeniu tego
z zadań, które zakończyło się wcześniej.

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.

Listing 6.14. Tworzenie własnej fabryki zadań


CancellationTokenSource cts = new CancellationTokenSource();
CancellationToken ct = cts.Token;

TaskFactory tf = new TaskFactory(ct);

Task a = tf.StartNew(() =>


{
Rozdział 6.  Zadania 155

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>, …

Jak podpowiada jego nazwa, zabezpiecza on kolekcję przed problemem producent-


konsument10.

Opisany wcześniej domyślny planista TaskScheduler dostępny jest jako statyczna


właściwość domyślnej fabryki zadań, a więc poprzez własność:
Task.Factory.Scheduler

Użytkownik może modyfikować jego sposoby zarządzania zadaniami. Służy do tego


opisana wcześniej klasa TaskCreationOptions (tabela 6.3). Na działanie obiektu Task
Scheduler szczególny wpływ mają opcje PreferFairness i LongRunning. Ta pierwsza

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

wymusza, jeśli to możliwe, przestrzeganie kolejności uruchamiania („reguły FIFO”),


tzn. że zadanie najwcześniej umieszczone w kolejce powinno być uruchomione jako
pierwsze. Należy przy tym pamiętać, że zadania w kolejce głównej rozdzielane są na
kolejki lokalne każdego wątku, a później mogą być między wątkami przenoszone (ang.
work stealing). Zastosowanie ustawienia PreferFairness w rozsądny sposób ogranicza
to zachowanie.

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.

Minimalny kod implementujący własną klasę typu TaskScheduler przedstawiam na li-


stingu 6.15.

Listing 6.15. Metody wymagane przy tworzeniu obiektu TaskScheduler


class Planista : TaskScheduler
{
protected override IEnumerable<Task> GetScheduledTasks()
{
throw new NotImplementedException();
}
protected override bool TryExecuteTaskInline(Task task, bool
taskWasPreviouslyQueued)
{
throw new NotImplementedException();
}
protected override void QueueTask(Task task)
{
throw new NotImplementedException();
}
}

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

Listing 6.16. Tworzenie własnego planisty


class Planista : TaskScheduler
{
private List<Task> kolejka = new List<Task>();
private Thread watekGlowny;

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();
}

public override int MaximumConcurrencyLevel


{
get
{
return 1;
}
}

protected override void QueueTask(Task task)


{
kolejka.Add(task);
}

protected override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued)


{
Console.WriteLine("żądanie wykonania sekwencyjnego");
if (Thread.CurrentThread != watekGlowny) return false;
return TryExecuteTask(task);
}

protected override IEnumerable<Task> GetScheduledTasks()


{
return kolejka.ToArray();
}
}

Klasa widoczna na listingu 6.16 poza zapowiedzianymi definicjami metod zawiera


także definicję własności tylko do odczytu MaximumConcurrencyLevel, zwracającej ilość
wątków. W powyższym przykładzie wykorzystywany jest tylko jeden wątek, w związku
z czym odczyt wartości MaximumConcurrencyLevel zwraca zawsze wartość 1. Działanie
158 Programowanie równoległe i asynchroniczne w C# 5.0

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.

Listing 6.17. Wykorzystanie własnego planisty w zarządzaniu zadaniami


class Program
{
static void Main(string[] args)
{
Planista ts = new Planista();
TaskFactory tf = new TaskFactory(ts);

Task[] zadania = new Task[10];


for (int i = 0; i < 10; i++)
{
Console.WriteLine("zadanie {0} rozpoczęte", i + 1);
zadania[i] = tf.StartNew((numer) =>
{
Console.WriteLine("Zadanie {0} zakończone", numer);
}, i + 1
);
}
Task.WaitAll(zadania, 20000);

Console.WriteLine("Naciśnij dowolny klawisz");


Console.ReadKey();
}
}

Należy zauważyć, że w listingu 6.17 w wywołaniu metody WaitAll określony został


czas oczekiwania. Gdyby zastosować wersję bez oczekiwania, wiele zadań wykonanych
zostałoby synchronicznie. Można to łatwo zaobserwować na rysunku 6.4, ponieważ
Rozdział 6.  Zadania 159

każde wywołanie metody TryExecuteTaskInline powoduje wypisanie odpowiedniego


komunikatu. Zachowanie takie wynika z natury metody WaitAll, która poza blokowa-
niem wykonania i oczekiwaniem na zakończenie zadań dba dodatkowo o jak najszybszy
przebieg i najefektywniejsze wykorzystanie zasobów. Dlatego w niektórych przypad-
kach próbuje wykonać zadania synchronicznie11.

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).

Przykładem użycia ustawień niech będzie tworzenie zadania długotrwałego:


Task t = new Task(() => { /* … */ }, TaskCreationOptions.LongRunning);

Tabela 6.3. Ustawienia zadań12


Ustawienia Opis
None Zadanie utworzone zostanie z domyślnymi ustawieniami.
PreferFairness Wskazówka dla planisty, aby traktował zadania w sposób jak najbardziej
uczciwy, tj. zgodnie z zasadą FIFO; zadanie, które zostało najwcześniej
zaplanowane, powinno być również najwcześniej wykonane. Nie jest to domyślne
ustawienie — domyślnie funkcjonuje mechanizm wykradania zadań pomiędzy
wątkami (podrozdział „Planista i zarządzanie kolejkowaniem zadań”).
LongRunning Zapowiada, że zadanie będzie długotrwałe.
AttachedToParent Łączy zadanie z zadaniem rodzica; należy zwrócić uwagę, że zadania
domyślnie są odłączone (ang. detached).
DenyChildAttach Zabrania podłączania do zadania zadań potomnych. Próba utworzenia takiego
powiązania zakończy się zgłoszeniem wyjątku.
HideScheduler Ukrywa planistę w tworzonym zadaniu. Powoduje to, że operacje na nim
wykonywane (np. ContinueWith) wykorzystywać będą domyślnego planistę.

***

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

Omówiona w poprzednim rozdziale biblioteka TPL stanowi podstawę dla wygodnych


narzędzi w większym stopniu automatyzujących programowanie współbieżne. Chodzi
m.in. o technologię zrównoleglonych zapytań zintegrowanych z językiem programowa-
nia, a więc PLINQ (rozdział 9.) oraz klasę Parallel, która przedstawiona zostanie
w tym rozdziale. Aby można było swobodnie ich używać, konieczna jest znajomość
omówionych w poprzednim rozdziale klas pomocniczych, szczególnie CancellationToken
i TaskScheduler.

Klasa Parallel służy do zrównoleglenia wykonywania kodu. Zazwyczaj są to pętle


(zapowiedź z rozdziału 1.), ale zrównoleglić możemy również „zwykłe” bloki kodu.
Klasa ta umieszczona jest w tej samej przestrzeni nazw, co klasa Task, czyli System.
Threading.Tasks. W Visual Studio 2010 należy pamiętać o uwzględnieniu jej w sekcji
poleceń using na początku pliku; od wersji Visual Studio 2012 jest tam umieszczona
domyślnie.

Wspomniane pętle równoległe zostały zaimplementowane jako statyczne metody klasy


Parallel. Są to wielokrotnie przeciążone metody For, ForEach oraz Invoke. Metody
For oraz ForEach to równoległe wersje znanych pętli for i foreach, natomiast Invoke
(z ang. wywołanie) to metoda wywołująca równolegle bloki kodu podane w postaci
tablicy akcji (tj. elementów typu Action). Jej nazwa nawiązuje do dobrze znanych
metod Control.Invoke i Control.BeginInvoke umożliwiających odpowiednio synchro-
niczne i asynchroniczne wywoływanie metod kontrolek Windows Forms z dodatkowych
wątków.
162 Programowanie równoległe i asynchroniczne w C# 5.0

Równoległa pętla for


W poprzednim rozdziale wspomniałem, że mechanizm zadań ma zastosowanie pod-
czas współbieżnego wykonywania dużej ilości poleceń. Najbardziej typową sytuacją te-
go rodzaju jest pętla for. Za jej równoległą implementację odpowiada metoda Parallel.
For. Metoda ta występuje w kilku wariantach, niektóre z nich przedstawiam na li-
stingu 7.11.

Listing 7.1. Definicje różnych wariantów metody For


public static ParallelLoopResult For(
int fromInclusive,
int toExclusive,
Action<int> body)

public static ParallelLoopResult For(


int fromInclusive,
int toExclusive,
Action<int, ParallelLoopState> body)

public static ParallelLoopResult For(


int fromInclusive,
int toExclusive,
ParallelOptions parallelOptions,
Action<int> body)

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.2. Przykład wywołania równoległej pętli For


Parallel.For(
0,
10,
(i) => {Console.WriteLine("Iteracja nr {0}, zadanie nr {1}", i, Task.CurrentId);}
);

Pozostałe przedstawione w listingu 7.1 warianty metody Parallel.For pozwalają ko-


lejno na: sprawdzanie stanu pętli (argument akcji ParallelLoopState) oraz przekazy-
wanie wybranych opcji dotyczących działania pętli (argument ParallelOptions). Klasy
te zostaną opisane dalej w tym rozdziale.
Omawiając pętlę for, warto wrócić do przykładu z liczbami pierwszymi omówionego
w poprzednim rozdziale (listing 6.3). Jak widać na listingu 7.3, kod korzystający ze
zrównoleglonej pętli zaimplementowanej w metodzie Parallel.For znacznie się skrócił.
Nie trzeba tworzyć osobnych zadań, uruchamiać ich i czekać na ich zakończenie. Pętla
trwa, aż sprawdzi wszystkie potencjalne dzielniki liczby n2.

Listing 7.3. Sprawdzanie za pomocą For, czy liczba jest liczbą pierwszą
Console.Write("Sprawdź, czy liczba pierwsza: ");
int n = Int32.Parse(Console.ReadLine());

bool pierwsza = true;


Parallel.For(2, (int)Math.Sqrt(n)+1, (i) =>
{
if (n % (int)i == 0)
{
Console.WriteLine("{0} dzieli się przez {1}", n, i);
pierwsza = false;
}
});
if (pierwsza)
{
Console.WriteLine("Liczba {0} jest liczbą pierwszą", n);
}

Równoległa pętla foreach


Gdy stosujemy kolekcje w C#, bardzo często korzystamy z pętli foreach, która prze-
biega wszystkie elementy wskazanego zbioru danych. Pętla ta również została zaim-
plementowana w klasie Parallel w wielokrotnie przeciążonych metodach Parallel.
ForEach:
public static ParallelLoopResult ForEach<TSource>(
IEnumerable<TSource> source,
Action<TSource> body)

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

public static ParallelLoopResult ForEach<TSource>(


IEnumerable<TSource> source,
Action<TSource, ParallelLoopState> body)

public static ParallelLoopResult ForEach<TSource>(


IEnumerable<TSource> source,
ParallelOptions parallelOptions,
Action<TSource> body)

Argumentami metody ForEach są zawsze kolekcja oraz akcja wykonywana na każdym


jej elemencie. W pokazanych powyżej definicjach kolekcja reprezentowana jest przez
interfejs IEnumerable z przestrzeni nazw System.Collections.Generic, ale może to
być również klasa Partitioner lub OrderablePartitioner (z przestrzeni nazw System.
Collections.Concurrent).

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());

bool pierwsza = true;


Parallel.ForEach<int>(Enumerable.Range(2, (int)Math.Sqrt(n) - 1), (i) =>
{
if (n % (int)i == 0)
{
Console.WriteLine("{0} dzieli się przez {1}", n, i);
pierwsza = false;
}
});

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
)

Jak widać w definicjach, przyjmowane są tylko akcje bez argumentów, co poważnie


ogranicza użyteczność omawianej metody. Dodatkowo w drugim przypadku możliwe
jest przekazanie jako argumentu omówionego niżej obiektu typu ParallelOptions.

Na poniższym przykładzie przedstawiam wywołanie równoległe dwóch operacji prze-


szukiwania lewej i prawej gałęzi drzewa binarnego. Jak wiadomo, drzewo binarne
przeszukuje się na zasadzie rekurencyjnego wywoływania operacji przeszukiwania
dla każdej z jego gałęzi. Dodatkowo dla każdego z elementów drzewa wykonywana
jest jakaś akcja. W tym przypadku jest to po prostu wypisanie w konsoli zawartości
elementów drzewa, które są typu String. Operację przeszukiwania drzewa można by —
oczywiście — zrównoleglić z wykorzystaniem zadań. Jednak sposób ich tworzenia
wymagałby dodania operacji Wait, co niepotrzebnie zmniejszyłoby przejrzystość kodu.

Listing 7.5. Przeszukiwanie drzewa binarnego z użyciem Parallel.Invoke3


class TreeWalk
{

static void Main()


{
Tree<String> tree = new Tree<String>();

tree.Data = "Darth Vader";


(tree.Left = new Tree<string>()).Data = "Luke Skywalker";
(tree.Right = new Tree<string>()).Data = "Princess Leia";

Action<String> myAction = x => Console.WriteLine("{0} ({1})", x,


Task.CurrentId);

DoTree(tree, myAction);
}

public class Tree<T>


{
public Tree<T> Left;
public Tree<T> Right;
public T Data;
}

public static void DoTree<T>(Tree<T> tree, Action<T> action)


{
if (tree == null) return;
Parallel.Invoke(

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)
);
}
}

Ustawienia pętli równoległych.


Klasa ParallelOptions
Wszystkie trzy omówione powyżej metody klasy Parallel posiadają warianty pozwa-
lające na przekazanie jako argumentu instancji klasy ParallelOptions. Umożliwia ona
przekazanie ustawień regulujących działanie pętli (podobnie do opisanych w poprzed-
nim rozdziale klas TaskCreationOptions i TaskContinuationOptions). Ustawienia udo-
stępniane przez właściwości tej klasy przedstawione zostały w tabeli 7.1.

Tabela 7.1. Własności klasy ParallelOptions4


Ustawienia Opis
CancellationToken Ustawienie to znamy już z poprzedniego rozdziału. Podobnie jak
w przypadku zadań tworzonych bezpośrednio za pomocą klasy Task,
także i tu umożliwia przerwanie wszystkich zadań utworzonych w ramach
równoległej pętli przez zewnętrzne wywołanie metody Cancel.
MaxDegreeOfParallelism W dosłownym tłumaczeniu „maksymalny stopień współbieżności”
— pozwala ustawić lub odczytać maksymalną ilość zadań wykonywanych
w jednym czasie.
TaskScheduler Instancja planisty (zob. również informacje na jego temat w poprzednim
rozdziale) pozwalająca określić, w jaki sposób zarządzane mają być zadania
tworzone w trakcie wykonania metody klasy Parallel.

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.

Listing 7.6. Wyszukiwanie liczby pierwszej przerywane po odnalezieniu pierwszego dzielnika


Console.Write("Sprawdź, czy liczba pierwsza: ");
int n = Int32.Parse(Console.ReadLine());

CancellationTokenSource cts = new CancellationTokenSource();


CancellationToken ct = cts.Token;

ParallelOptions po=new ParallelOptions();


po.CancellationToken=ct;

Console.WriteLine("sprawdzam od 2 do {0}", (int)Math.Sqrt(n));

try
{
Parallel.For(2, (int)Math.Sqrt(n)+1, po, (i) =>
{
if (n % (int)i == 0)
{
cts.Cancel();
}
ct.ThrowIfCancellationRequested();
});

Console.WriteLine("Liczba {0} jest liczbą pierwszą", n);


}
catch (OperationCanceledException)
{
Console.WriteLine("Liczba {0} nie jest liczbą pierwszą", n);
}

Mechanizm przerywania zadań korzystający z klasy CancellationToken znany jest już


z poprzedniego rozdziału. Wobec tego powyższy przykład nie wymaga zbyt rozbu-
dowanego komentarza. Należy jedynie podkreślić, że w metodach klasy Parallel token
przekazywany jest w ramach obiektu ParralelOptions.

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.

Listing 7.7. Sprawdzanie liczby pierwszej przy użyciu Parallel.ForEach


Console.Write("Sprawdź, czy liczba pierwsza: ");
int n = Int32.Parse(Console.ReadLine());

CancellationTokenSource cts = new CancellationTokenSource();


CancellationToken ct = cts.Token;
168 Programowanie równoległe i asynchroniczne w C# 5.0

ParallelOptions po = new ParallelOptions();


po.CancellationToken = ct;

Console.WriteLine("sprawdzam od 2 do {0}", (int)Math.Sqrt(n));

try
{
Parallel.ForEach<int>(Enumerable.Range(2, (int)Math.Sqrt(n) - 1), po, (i) =>
{
if (n % (int)i == 0)
{
cts.Cancel();
}
ct.ThrowIfCancellationRequested();
});

Console.WriteLine("Liczba {0} jest liczbą pierwszą", n);


}
catch (OperationCanceledException)
{
Console.WriteLine("Liczba {0} nie jest liczbą pierwszą", n);
}

Kontrola wykonywania pętli


Przerwanie pętli przy użyciu metody Cancel nie jest jednak najlepszym rozwiąza-
niem. Nie tylko ze względu na sporą ilość dodatkowego kodu, ale również dlatego, że
metodę Cancel stosować się powinno dla przerwań zewnętrznych, czyli spoza pętli.
Natomiast w celu przerwania działania pętli w którejś z jej iteracji przygotowano spe-
cjalne narzędzia zaimplementowane w klasach ParallelLoopState i ParallelLoopResult.

Klasa ParallelLoopState może być przekazywana jako argument akcji wykonywanej


w pętli zaraz po indeksie pętli (listing 7.1). Dzięki temu jest dostępna tylko wewnątrz
pętli. Dwie z jej metod — Break i Stop — pozwalają na przerwanie pętli. Różnią się
tym, że Stop działa tak jak metoda CancellationTokenSource.Cancel w powyższym
przykładzie, tzn. przerywa dalsze wykonanie wszystkich nierozpoczętych do tej pory
zadań, natomiast Break pozwala na rozpoczęcie i wykonanie tych iteracji pętli, które
występują przed bieżącym zadaniem. Do dyspozycji mamy również własności tej klasy
pozwalające na sprawdzenie stanu pętli. Zebrano je w tabeli 7.2.

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

Tabela 7.2. Własności klasy ParallelLoopState5


Ustawienia Opis
IsExceptional Informuje, czy któraś z iteracji pętli zgłosiła wyjątek.
IsStopped Informuje, czy pętla została przerwana metodą Stop. Nie ma wpływu na jej
wartość wywołanie metody Break.
LowestBreakIteration Zawiera najniższy numer iteracji przerwanej za pomocą metody Break
(wszystkie powyżej nie zostaną wykonane) lub null, w przypadku gdy
Break nie zostanie wywołane.
ShouldExitCurrent Pozwala na sprawdzenie, czy aktualnie wykonywana iteracja powinna być
Iteration przerwana. Pole przydatne, jeżeli inna iteracja wywołuje metodę Stop, która
— jak wiadomo — pozwala na dokończenie wykonania już rozpoczętych
zadań, a konieczne jest ich zatrzymanie.

Listing 7.8. Przerwanie zadania za pomocą ParallelLoopState.Stop


Console.Write("Sprawdź, czy liczba pierwsza: ");
int n = Int32.Parse(Console.ReadLine());

if (Parallel.For(2, (int)Math.Sqrt(n), (i, stanPetli) =>


{
if (n % (int)i == 0)
{
Console.WriteLine("{0} dzieli się przez {1}", n, i);
stanPetli.Stop();
}
}).IsCompleted)
{
Console.WriteLine("Liczba {0} jest liczbą pierwszą", n);
}

W przykładzie z listingu 7.8 skorzystałem z drugiej przeciążonej wersji metody


Parallel.For widocznej na listingu 7.1, tj. wersji o następującej sygnaturze:
public static ParallelLoopResult For(
int fromInclusive,
int toExclusive,
Action<int, ParallelLoopState> body)

Synchronizacja pętli równoległych.


Obliczanie π metodą Monte Carlo
Najtrudniejszym zadaniem w procesie tworzenia programu współbieżnego jest odpo-
wiednia synchronizacja wątków. Problem ten pojawia się np. w kontekście równocze-
snego dostępu do danych. Równoczesny zapis danych do tego samego źródła może
5
Właściwości te szerzej opisane są na stronie MSDN dostępnej pod adresem
http://msdn.microsoft.com/en-us/library/system.threading.tasks.parallelloopstate.aspx.
170 Programowanie równoległe i asynchroniczne w C# 5.0

zakończyć się pominięciem jednej ze zmian. Podobnie odczyt danych w momencie


ich modyfikowania przez inny proces lub wątek może zakończyć się odczytem niepo-
prawnej wartości. Dlatego instrukcje modyfikujące i odczytujące dane umieszcza się
w tzw. sekcjach krytycznych, które zapewniają, że dostęp do danych ma w danej chwili
tylko jeden wątek.

Aby zaprezentować, w jaki sposób synchronizowane są wątki w najnowszej wersji


platformy .NET, ponownie wykorzystamy przykład obliczania liczby π metodą Monte
Carlo (rozdział 2., gdzie można znaleźć opis tej metody i jej implementację z użyciem
wątków). Tutaj zamieszczam jedynie krótkie przypomnienie. Metoda Monte Carlo po-
lega na generowaniu losowych punktów wewnątrz kwadratu o boku 2 i sprawdzeniu, ile
z nich leży wewnątrz koła wpisanego w ten kwadrat, a raczej wewnątrz jego ćwiartki.
Dysponując zbiorem takich „strzałów”, możemy przybliżyć wartość liczby π ze wzoru:
  4 * k n , gdzie k to ilość punktów wewnątrz koła, a n — ilość wszystkich wylo-
sowanych punktów. Przybliżenie liczby π jest tym dokładniejsze, im większy jest
zbiór losowanych punktów. Najprostszą, sekwencyjną implementację powyższego al-
gorytmu przedstawiam na listingu 7.9 (por. listing 2.1).

Listing 7.9. Wersja sekwencyjna algorytmu


static double ObliczPiSekwencyjnie(long n)
{
Random r = new Random();
double x, y;
long k = 0;

for (long i = 0; i < n; i++)


{
x = r.NextDouble();
y = r.NextDouble();
if (x * x + y * y < 1) k++;
}
return 4.0 * k / n;
}

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).

Listing 7.10. Wersja równoległa algorytmu obliczającego liczbę π


static double ObliczPiRownolegle(long n)
{
Random r = new Random();
double x, y;
long k = 0;
Rozdział 7.  Klasa Parallel. Zrównoleglanie pętli 171

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.

Listing 7.11. Wprowadzenie synchronizacji z użyciem operatora lock


static double ObliczPiRownolegle(long n)
{
Random r = new Random();
double x, y;
long k = 0;
object ks = new object();

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;
}

Niestety, umieszczenie w sekcjach krytycznych niemal całego kodu wykonywanego


w każdej iteracji pętli powoduje, że wykonanie takiego programu jest bardzo czaso-
chłonne. Każde zadanie musi dwukrotnie czekać na uzyskanie indywidualnego dostępu
do zmiennych r i k. W konsekwencji wykonanie takiego programu trwa nawet dwu-
krotnie dłużej niż wersji sekwencyjnej. Jest tak dlatego, że synchronizacja wykony-
wana jest dla każdego zadania (których jest bardzo dużo), a nie dla wątków (których jest
zaledwie kilka). Aby, pracując z zadaniami, zrealizować synchronizację na poziomie
wątków, skorzystamy z innej, pięcioargumentowej wersji pętli Parallel.For (listing 7.12).
172 Programowanie równoległe i asynchroniczne w C# 5.0

Listing 7.12. Synchronizacja zmiennej k powinna dotyczyć wątków, a nie zadań


static double ObliczPiRownolegle(long n)
{
Random r = new Random();
double x, y;
long k = 0;

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;
}

Pięcioargumentowa wersja metody Parallel.For umożliwia użycie lokalnej zmiennej,


którą w naszym przypadku jest zmienna sumaCzesciowa typu int. Zakres tej zmiennej
nie jest ograniczony jedynie do kodu wykonywanego w obrębie każdego zadania, a jest
ona wspólna dla kolejnych zadań (iteracji pętli) wykonywanych w obrębie jednego
wątku6. Podobnie jak we wcześniej używanej wersji pętli Parallel.For, dwa pierwsze
argumenty określają zakres indeksu pętli, który przebiega liczby naturalne od 0 do n-1
włącznie. Trzeci parametr jest typu Func<int> (delegat do bezargumentowej metody
zwracającej wartość typu int) i służy do określenia działania wykonywanego podczas
uruchamiania każdego wątku wykorzystywanego w pętli. Zwracana wartość zostanie
użyta do inicjacji lokalnych zmiennych wątku. Przekazane jako argument w przykładzie
z listingu 7.12 wyrażenie lambda po prostu zwraca wartość 0, która jest zastosowana
jako wartość początkowa zmiennej sumaCzesciowa w każdym wątku.

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

Ostatni argument to delegat typu Action<int>do metody przyjmującej argument typu


int i niezwracającej wartości. Metoda powinna być wykorzystana do wykonania ope-
racji na zmiennych lokalnych wątków (w naszym przypadku na zmiennej sumaCzesciowa)
podczas kończenia pracy poszczególnych wątków. Użyjemy jej do zsumowania wszyst-
kich sum częściowych z poszczególnych wątków w jednej zmiennej k zawierającej
całkowitą ilość trafień.

Dodawanie sumy częściowej obliczanej w poszczególnych wątkach do całkowitej sumy


przechowywanej w zmiennej k może nastąpić w tym samym czasie. W tym miejscu
należy zatem użyć mechanizmu synchronizacji. Trzeba jednak podkreślić, że problem
synchronizacji dotyczy tym razem wątków, a nie zadań. A ponieważ klasa Thread nie
jest w platformie .NET 4.5 czymś nowym, doskonale sprawdzą się tu tradycyjne spo-
soby korzystające z operatora lock, klasy Interlocked czy rozwiązania, takie jak se-
mafory i muteksy (rozdziały 2. i 4.). Jak widać na listingu 7.12, użyłem do tego klasy
Interlocked. Zawiera ona statyczne metody pozwalające na wykonywanie w sposób
zsynchronizowany prostych operacji arytmetycznych na liczbach całkowitych.

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;

public static double NextDouble()


{
if (_local == null)
{
int seed;
lock (_global) seed = _global.Next();
_local = new Random(seed);
}
return _local.NextDouble();
}
}

public static double ObliczPiRownolegle(long n)


{
double x, y;
long k = 0;

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).

Listing 7.14. Obliczanie  z wykorzystaniem klasy Partitioner


static double ObliczPiRownolegle(long n)
{
long k = 0;

Parallel.ForEach(
Partitioner.Create(0, n),
() => 0,
(przedzial, stanPetli, sumaCzesciowa) =>
{
Random r = new Random(Task.CurrentId.Value + System.Environment.TickCount);

for (long i = przedzial.Item1; i < przedzial.Item2; i++)


{
double x, y;
x = r.NextDouble();
y = r.NextDouble();
if (x * x + y * y < 1)
sumaCzesciowa++;
}
return sumaCzesciowa;
},
(sumaCzesciowa) => { Interlocked.Add(ref k, sumaCzesciowa); }
);

return 4.0 * k / n;
}
176 Programowanie równoległe i asynchroniczne w C# 5.0

Pierwsza duża zmiana w stosunku do poprzednich przykładów to — oczywiście —


zastąpienie pętli For przez ForEach. W miejsce argumentów wyznaczających zakres
kroków pętli pojawił się obiekt typu OrderablePartitioner odpowiedzialny za two-
rzenie zakresów indeksów dla pętli podrzędnych. Dlatego do ciała pętli równoległej
nie jest przekazywany argument będący liczbą, a obiekt typu Tuple (z ang. krotka).
Argument ten zawiera informacje o krańcach zakresu danych dla pętli for wykonywanej
wewnątrz akcji body. Korzystanie z klasy Partitioner powoduje znaczne zmniejszenie
ilości kroków równoległej pętli nadrzędnej. Teoretycznie można by więc zrezygno-
wać z wariantu metody ForEach wykorzystującego dodatkowe akcje synchronizujące
(czyli argumenty localInit i localFinally). Ponieważ jednak nie wiadomo, ile kroków
zostanie wykonanych, więc lepiej z synchronizacji nie rezygnować, zwłaszcza że nie mają
one znacznego wpływu na czas obliczeń. Ponadto wielkość zakresów (a tym samym ich
ilość) można określić samemu. Wówczas także należy użyć tego wariantu metody ForEach.

Ponieważ zmniejszyła się liczba iteracji pętli równoległej na rzecz sekwencyjnych


pętli podrzędnych, można zrezygnować z klasy RandomThreadSafe i tworzyć zwykły
obiekt typu Random w każdym kroku pętli. Aby jednak wyniki były odpowiednio losowe,
inicjujemy generator aktualną wartością zegara systemowego i dodatkowo identyfi-
katorem zadania, na wypadek gdyby dwa obiekty tworzone były w tym samym mo-
mencie. Dalsze odwołania mają miejsce w pętli sekwencyjnej, więc żadna synchroni-
zacja nie jest już potrzebna.

Gdy korzystamy z tak zmienionej metody, w kodzie algorytmu obliczającego π w ogóle


nie potrzebujemy operatora lock. Unikając blokujących sekcji krytycznych, znacznie
skróciliśmy czas obliczeń, a przecież otrzymujemy prawidłowy wynik. Czas obliczeń
jest wreszcie krótszy od czasu obliczeń sekwencyjnych (tabela 7.3). Mało tego, uzy-
skane przyspieszenie jest prawie równe maksymalnemu możliwemu przyspieszeniu
(które równe jest ilości rdzeni procesorów).

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

Komentarza wymaga wpływ podziału obliczeń za pomocą klasy Partitioner. Dzięki


niemu nie tylko rozwiązaliśmy problem klasy Random, ale również drastycznie zmniej-
szyliśmy ilość tworzonych zadań i ograniczyliśmy liczbę synchronizacji. Zmniejszył
8
W przypadku tabeli 2.1 przyspieszenie oznacza stosunek czasu wykonania programu z wykorzystaniem
jednego wątku do czasu wykonania z wykorzystaniem ilości wątków równej ilości dostępnych rdzeni.
Rozdział 7.  Klasa Parallel. Zrównoleglanie pętli 177

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.
***

Przedstawione w tym rozdziale pętle równoległe są jednymi z najprzydatniejszych


składników biblioteki TPL. Ze względu na podobieństwo do pętli języka C# oraz na
brak jawnego tworzenia i synchronizowania zadań stanowią bardzo wygodne narzędzie
zrównoleglenia kodu. Oczywiście, nadal po stronie programisty leży odpowiedzialność
za zabezpieczanie sekcji krytycznych i (w pewnym stopniu) podział problemu pomię-
dzy jednostki obliczeniowe.

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

W rozdziałach 2. i 4. przedstawiłem podstawowe zagadnienia związane z synchronizacją


wątków. W tym rozdziale chcę pokazać, że pokazane tam mechanizmy można z po-
wodzeniem stosować także w przypadku zadań. Zadania wykonywane są „w obrębie”
wątków. W danej chwili każde działające zadanie wykonywane jest w osobnym wątku.
Nie ma wobec tego możliwości, aby dwa jednocześnie działające zadania wykonywane
były w jednym wątku. Dzięki temu niepotrzebne są nowe mechanizmy synchronizacji,
w przypadku zadań działają mechanizmy omówione w rozdziałach 2. i 4. Aby prze-
konać o tym czytelników, przedstawię poniżej wybrane z tych rozdziałów przykłady,
w których wątki zastąpione będą zadaniami, a mimo to programy będą działały pra-
widłowo.

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.

Listing 8.1. Sekcje krytyczne


using System;
...
using System.Threading.Tasks;

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;

...

public static void Przelew(Konto kontoPłatnika, Konto kontoOdbiorcy,


decimal kwota)
{
if (kontoOdbiorcy == kontoPłatnika) throw new ArgumentException
("Niemożliwe jest wykonanie przelewu na to samo konto");

Console.WriteLine("Przygotowanie do przelewu z konta {0} na konto


{1} kwoty {2}.", kontoPłatnika.id, kontoOdbiorcy.id, kwota);
Console.WriteLine("Salda przed przelewem: konto {0} - saldo {1},
konto {2} - saldo {3}", kontoPłatnika.id, kontoPłatnika.saldo,
kontoOdbiorcy.id, kontoOdbiorcy.saldo);
lock(kontoPłatnika)
{
Console.WriteLine("Dostęp do konta płatnika {0} zarezerwowany",
kontoPłatnika.id);
Thread.Sleep(100);
lock (kontoOdbiorcy)
{
Console.WriteLine("Dostęp do konta odbiorcy {0}
zarezerwowany", kontoOdbiorcy.id);
kontoPłatnika.Wypłata(kwota);
kontoOdbiorcy.Wpłata(kwota);
}
Console.WriteLine("Dostęp do konta odbiorcy {0} zwolniony",
kontoOdbiorcy.id);
}
Console.WriteLine("Dostęp do konta płatnika {0} zwolniony",
kontoPłatnika.id);
Console.WriteLine("Wykonany został przelew z konta {0} na konto {1}
kwoty {2}.", kontoPłatnika.id, kontoOdbiorcy.id, kwota);
Console.WriteLine("Salda po przelewie: konto {0} - saldo {1},
konto {2} - saldo {3}", kontoPłatnika.id, kontoPłatnika.saldo,
kontoOdbiorcy.id, kontoOdbiorcy.saldo);
}
}

class PoleceniePrzelewu
{
public Konto KontoPłatnika;
public Konto KontoOdbiorcy;
public decimal Kwota;
}

static void Main(string[] args)


{
Konto konto1 = new Konto(100, 1);
Konto konto2 = new Konto(150, 2);

Action<object> transakcja =
Rozdział 8.  Synchronizacja zadań 181

(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);
};
Task.Factory.StartNew(transakcja, new PoleceniePrzelewu { KontoPłatnika
= konto1, KontoOdbiorcy = konto2, Kwota = 50 });
Task.Factory.StartNew(transakcja, new PoleceniePrzelewu { KontoPłatnika
= konto2, KontoOdbiorcy = konto1, Kwota = 10 });

Console.ReadLine(); //wątek główny czeka na dodatkowe wątki


}
}
}

W kodzie zostawiłem odwołania do metody statycznej Thread.Sleep, konieczne jest


zatem dodanie referencji do przestrzeni nazw System.Threading. Nie ma prostszego
sposobu na wstrzymanie zadania1. W metodzie Main zastąpiłem delegację WaitCallback
przez zgodną z nią akcję Action<object>. Metoda Task.Factory.StartNew przyjmuje
tego typu akcję jako argument. Po uruchomieniu kodu zobaczymy, że oba przelewy
zostały zrealizowane. Wydruk powinien być identyczny z wydrukiem na rysunku 4.1.

Przykład ten ma pokazać, że mechanizm synchronizacji użyty w metodzie Przelew,


a więc słowo kluczowe lock, którego używaliśmy wcześniej w aplikacjach wielowąt-
kowych, zadziała równie dobrze, gdy synchronizujemy ze sobą zadania. I niesie ze sobą
te same zagrożenia. Sprawdźmy to, zmieniając polecenia przelewu na:
Task.Factory.StartNew(transakcja, new PoleceniePrzelewu { KontoPłatnika = konto1,
KontoOdbiorcy = konto2, Kwota = 50 });
Task.Factory.StartNew(transakcja, new PoleceniePrzelewu { KontoPłatnika = konto2,
KontoOdbiorcy = konto1, Kwota = 10 });

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

Sygnały (Monitor.Pulse i Monitor.Wait)


Również podczas przesyłania sygnałów między wątkami korzystanie z rozwiązań przed-
stawionych w rozdziale 4. daje prawidłowe rezultaty. Na listingu 8.2 widoczny jest
zmodyfikowany kod z listingu 4.5, w którym wątki zostały zastąpione przez zadania
(zmiany wyróżniłem). Po uruchomieniu okazuje się, że przesyłanie metod za pomocą
Monitor.Pulse i ich odbieranie przez Monitor.Wait działa prawidłowo.

Listing 8.2. Komunikacja między zadaniami


using System;
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();

static Task zadanieProducenta = null;


static Task zadanieKonsumenta = null;

const int maksymalnyCzasProdukcji = 1000;


const int maksymalnyCzasKonsumpcji = 1000;
const int maksymalnyCzasUruchomieniaProdukcji = 5000;
const int maksymalnyCzasUruchomieniaKonsumpcji = 5000;

static int pojemnoscMagazynu = 20;


static int licznikElementowWMagazynie = 10;

static void wyswietlStanMagazynu()


{
Console.WriteLine("Liczba elementów w magazynie: " +
licznikElementowWMagazynie.ToString());
}

static void Main(string[] args)


{
Action akcjaProducenta =
() =>
{
Console.WriteLine("Zadanie producenta jest uruchamiane");
while (true)
{
lock (obiektSynchronizacjiMagazynu)
{
licznikElementowWMagazynie++;
Rozdział 8.  Synchronizacja zadań 183

Console.Write("Element dodany. ");


}
wyswietlStanMagazynu();
if (licznikElementowWMagazynie >= pojemnoscMagazynu)
{
Console.WriteLine("Zadanie producenta zostanie uśpione");
lock (obiektSynchronizacjiProducenta)
Monitor.Wait(obiektSynchronizacjiProducenta);
Console.WriteLine("Zadanie producenta zostanie wznowione");

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));
}
};

zadanieProducenta = new Task(akcjaProducenta);


zadanieProducenta.Start();

zadanieKonsumenta = new Task(akcjaKonsumenta);


zadanieKonsumenta.Start();

Console.ReadLine();
Console.Write("Koniec. "); wyswietlStanMagazynu();
}
}
}
184 Programowanie równoległe i asynchroniczne w C# 5.0

Nie inaczej jest w klasie EventWaitHandle i jej klasach potomnych AutoResetEvent


i ManualResetEvent, a także w ManualResetEventSlim.

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).

Listing 8.3. Użycie bariery


using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

using System.Threading;

namespace BarrierDemo
{
class Program
{
const int ileZadan = 10;
static Barrier b = new Barrier(ileZadan, (Barrier _b) => {
Console.WriteLine(); });

static void Main(string[] args)


{
Action metodaWatku =
() =>
{
for (int i = 0; i < 10; ++i)
{
Console.Write(i.ToString());
b.SignalAndWait();
}
};
Task[] zadania = new Task[ileZadan];
for (int i = 0; i < ileZadan; ++i)
{
zadania[i] = new Task(metodaWatku);
zadania[i].Start();
}

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.

Listing 8.4. Bariera w przypadku puli wątków


class Program
{
const int ileWatkow = 10;
static Barrier b = new Barrier(ileWatkow, (Barrier _b) => {
Console.WriteLine(); });

static void Main(string[] args)


{
Action<object> metodaWatku =
(object parametr) =>
{
for (int i = 0; i < 10; ++i)
{
Console.Write(i.ToString());
b.SignalAndWait();
}
};
for (int i = 0; i < ileWatkow; ++i)
ThreadPool.QueueUserWorkItem(new WaitCallback(metodaWatku));

Console.ReadLine();
}
}
186 Programowanie równoległe i asynchroniczne w C# 5.0
Rozdział 9.
Dane w programach
równoległych
Mateusz Warczak

Praca ze zbiorami danych


w programowaniu równoległym
Uzupełnieniem biblioteki Parallel Extensions są klasy i interfejsy umieszczone w prze-
strzeni nazw System.Collections.Concurrent. Cała ta przestrzeń to kolejna nowość
wprowadzona w wersji 4.0 platformy .NET. Klasy i interfejsy z tej grupy implementują
kolekcje ściśle związane z programowaniem równoległym. Dwa jej kluczowe elementy
to interfejs IProducerConsumerCollection oraz klasa Partitioner. Interfejs Iproducer
ConsumerCollection implementowany jest przez kolekcje, w których dostęp do danych
jest bezpieczny w pracy równoległej. Na nich skupimy się w pierwszej części tego
rozdziału. Natomiast klasa Partitioner pozwala na ustalenie podziału zbioru podczas
przydzielania danych do poszczególnych wątków. Jej zastosowanie zostało już zapre-
zentowane w rozdziale 7. (listing 7.14).

Choć współbieżne struktury danych wprowadzone zostały do platformy .NET równo-


cześnie z TPL, ich użyteczność nie ogranicza się do współpracy z zadaniami. Równie
dobrze można z nich korzystać w aplikacjach współbieżnych opartych na wątkach.

Współbieżne struktury danych


W programowaniu równoległym pojawia się problem równoczesnego dostępu do źródła
danych przez wiele wątków. Z reguły źródła danych umożliwiają bezpieczny jednoczesny
odczyt danych, jednak rzadko możliwy jest jednoczesny zapis lub zapis równoczesny
188 Programowanie równoległe i asynchroniczne w C# 5.0

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.

Najczęściej kontrolę dostępu (tj. synchronizację) pozostawia się programiście. Twórcy


biblioteki Parallel Extensions postanowili jednak wyręczyć programistów w pełnym
zakresie, udostępniając zbiór kolekcji wewnętrznie zabezpieczonych przed nieodpo-
wiednim dostępem do danych. Nie jest to rozwiązanie uniwersalne — nadal na pro-
gramiście spoczywa decyzja dotycząca tego, jaka technika synchronizacji jest najlepsza
w konkretnym przypadku. Jednak najważniejszą cechą tych klas jest to, że nie ma tu
prawie wcale blokady wykluczającej lock. Powoduje ona synchronizację wszystkich
wątków, która nie zawsze jest konieczna, a może być bardzo czasochłonna. Operator
lock używany jest tylko w ostateczności. Natomiast w pozostałych przypadkach sto-
sowane są mniej kosztowne metody synchronizacji, takie jak SpinLock, SpinWait,
SemaphoreSlim, CountDownEvent czy metody klasy Interlocked (rozdziały 2. i 4.). Warto
w tym kontekście zwrócić uwagę na fakt, że proste oczekiwanie zrealizowane metodami
SpinLock lub SpinWait (ang. spinning), choć obciążające procesor, szybciej reaguje na
sygnał do wznowienia działania wątku niż w przypadku zamrożenia wątku (ang. wa-
iting), tj. jego przejścia w stan uśpienia, co ma miejsce, gdy używana jest metoda Wait.

Mechanizmy zapewniające bezpieczny dostęp do danych w aplikacji wielowątkowej


w scenariuszu producent-konsument zawarte zostały w interfejsie IproducerConsumer
Collection<>. Scenariusz ten został opisany w rozdziale 4., w podrozdziale zatytuło-
wanym „Komunikacja między wątkami. Problem producenta i konsumenta”. W inter-
fejsie IProducerConsumerCollection<> zdefiniowane zostały takie metody jak TryAdd
czy TryTake. Metody te nie powodują blokowania wątku, z którego są wywoływane.
Oznacza to, że w sytuacji, gdy dostęp do zasobów będzie niemożliwy, metody nie będą
oczekiwać do momentu zwolnienia zasobu. Obie metody zwracają wartość typu bool,
która informuje, czy próba dodania bądź pobrania elementu zakończyła się powodze-
niem, czy nie. Metody uzupełniają zasadnicze funkcjonalności kolekcji określone w in-
terfejsie IEnumerable<> i przypisanych jej rozszerzeniach. W przypadku implementacji
interfejsu IProducerConsumerCollection<> we własnej kolekcji zalecany schemat po-
stępowania jest taki, że dane przechowywane są w tej kolekcji, jednak dostęp do jej
elementów nie jest realizowany bezpośrednio, a za pomocą instancji klasy Blocking
1
Collection<> . W przestrzeni nazw System.Collection.Concurrent zdefiniowane są
również gotowe kolekcje implementujące oba interfejsy, a więc IproducerConsumer
Collection<> i IEnumerable<>. Kolekcje te realizują różne scenariusze synchronizacji
dostępu do danych i nie wymagają użycia żadnych dodatkowych obiektów pośredni-
czących. Do tej grupy należą m.in. ConcurrentBag<>, ConcurrentStack<> oraz Concurrent
Queue<> implementujące kolejno nieuporządkowaną kolekcję, stos i kolejkę. Dodat-
kowo w przestrzeni System.Collections.Concurrent znajdziemy klasę Concurrent
Dictionary<TKey, TValue> implementującą słownik. Dobrą wiadomością dla pro-
gramistów, którzy chcieliby używać tych kolekcji w swoich programach współbież-
nych, jest to, że zostały one tak zaprojektowane, aby ich użycie było jak najbardziej
podobne do niewspółbieżnych odpowiedników, tj. Stack<>, Queue<> i Dictionary<>.

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.

W przykładowym kodzie na listingu 9.1 widoczne są dwie kolekcje: kolekcja Concurrent


Bag oraz tradycyjna lista List z przestrzeni nazw System.Collections. W pętli
Parallel.For do obu kolekcji dodawany jest indeks. Na koniec prezentowana jest
ilość elementów każdej z kolekcji. Tylko klasa ConcurrentBag gwarantuje, że będzie
ona zawsze równa ilości wykonanych operacji Add (w tym przykładzie 10000). Nato-
miast ilość elementów listy może się różnić ze względu na brak synchronizacji. Mało
tego, dla większej ilości iteracji wykonanie poniższego kodu może zakończyć się zgło-
szeniem wyjątku IndexOutOfRangeException.

Listing 9.1. Podstawy korzystania z kolekcji współbieżnych


ConcurrentBag<int> cb = new ConcurrentBag<int>();
List<int> l = new List<int>();

Parallel.For(0, 10000, (i) =>


{
cb.Add(i);
l.Add(i);
});

Console.WriteLine("Ilość elementów (ConcurrentBag): {0}", cb.Count);


Console.WriteLine("Ilość elementów (List): {0}", l.Count);

Współbieżne kolejka i stos


Jak wspomniałem wcześniej, klasy Stack i Queue implementujące stos i kolejkę mają
swoje współbieżne odpowiedniki w przestrzeni nazw System.Collections.Concurrent.
Przypomnę, że w przypadku stosu dozwolone operacje to kładzenie elementu na wierzch
stosu (ang. push) oraz zdejmowanie elementu z wierzchu stosu (ang. pop). Do ele-
mentów innych niż wierzchni nie ma dostępu, co oznacza, że zdejmujemy tylko ele-
ment położony jako ostatni (LIFO, ang. last in, first out). Natomiast w przypadku ko-
lejki dodajemy elementy „z jednej strony” (ang. enqueue), a pobieramy z drugiej (ang.
dequeue). Tym samym pobierany jest element, który był „zakolejkowany” jako pierwszy
(FIFO, ang. first in, first out). Należy jednak pamiętać, że w programie wielowątkowym
ścisła kolejność elementów może nie zostać zachowana, bo, mimo zwracania elementów
w odpowiedniej kolejności, może mieć miejsce sytuacja, gdy jeden z sąsiadujących
190 Programowanie równoległe i asynchroniczne w C# 5.0

elementów zostanie szybciej przetworzony, przez co kolejność ulegnie zmianie. Syn-


chronizacja gwarantuje prawidłowe dodawanie i usuwanie elementów, co oznacza, że nie
nastąpi np. dwukrotne zwrócenie tego samego elementu. Jak wspomniałem, współ-
bieżne przetwarzanie może się jednak zakończyć w różnym czasie, co może prowadzić
do zmiany kolejności. Nie jest to niedoskonałość klasy Queue, a naturalne zjawisko
w programowaniu równoległym, którego należy się spodziewać.

Na listingu 9.2 przedstawiam prosty, nawet zbyt prosty, bo jednowątkowy, przykład


użycia klas ConcurrentStack i ConcurrentQueue. W porównaniu z użyciem zwykłych
kolekcji Stack i Queue najważniejsza zmiana, oczywiście poza zmianą nazw klas, do-
tyczy sposobu pobierania elementów; metody Pop i Dequeue zostały przemianowane na
TryPop i TryDequeue. Warto zwrócić uwagę, iż inny jest typ zwracany przez te metody
— jest to wartość typu bool. Prawda zwracana jest w przypadku, gdy dodanie lub
zdjęcie elementu powiedzie się, a fałsz w przypadku niepowodzenia. Pobierany lub
zwracany element przekazywany jest natomiast przez argument obu metod, co zmusza
do zdefiniowania zmiennej pomocniczej (w poniższym kodzie jest to zmienna element).

Listing 9.2. Praca z klasami ConcurrentStack i ConcurrentQueue


//zapełnianie kolekcji
int rozmiar = 10;
ConcurrentQueue<int> kolejka = new ConcurrentQueue<int>();
ConcurrentStack<int> stos = new ConcurrentStack<int>();
for (int i = 0; i < rozmiar; ++i)
{
kolejka.Enqueue(i);
stos.Push(i);
}

//pobieranie elementów
int element;
string s = "Elementy zdjęte z kolejki (" + kolejka.Count + " elementów):\n";

for (int i = 0; i < rozmiar; ++i)


{
kolejka.TryDequeue(out element);
s += element.ToString() + " ";
}
s += "\n\nElementy zdjęte ze stosu (" + stos.Count + " elementów):\n";
for (int i = 0; i < rozmiar; ++i)
{
stos.TryPop(out element);
s += element.ToString() + " ";
}

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

IProducerConsumerCollection<> i uzupełnia je o dodatkowe funkcjonalności w oparciu


o obecne w tym interfejsie metody, a przede wszystkim TryAdd i TryTake. Chodzi tu
głównie o dodanie metod blokujących Add i Take (metody te powodują wstrzymanie
działania głównego wątku w sytuacji, gdy konieczne jest oczekiwanie na dostęp do
zasobów) oraz możliwości określenia górnego ograniczenia ilości elementów kolek-
cji. Kolekcja „bazowa” przekazywana jest jako argument konstruktora podczas two-
rzenia obiektu BlockingCollection<>. Jeżeli argument taki nie zostanie przekazany,
klasa korzysta z typu domyślnego, którym jest ConcurrentQueue<>.

Klasa BlockingCollection<> pozwala na blokujący dostęp do danych oparty na po-


pularnym w programowaniu współbieżnym schemacie producent-konsument (opis
w rozdziale 4.). Jego podstawowym założeniem jest podział wątków (czy też procesów
bądź zadań) korzystających ze wspólnego źródła danych na dwie grupy. Pierwszą z nich
tworzą producenci, czyli wątki, których zadaniem jest dodawanie danych do zbioru.
Do drugiej należą konsumenci, którzy jedynie pobierają dane z kolekcji. Gdy bufor
jest pusty, wątek konsumenta nie może pobierać danych i jest blokowany. Natomiast
w przypadku przepełnienia bufora (dla kolekcji BlockingCollection<> maksymalna ilość
elementów wskazywana jest w argumencie jego konstruktora) praca wątku-producenta
jest wstrzymywana, aż do zwolnienia miejsca na nowe dane. Te zasady realizują metody
Add i Take klasy BlockingCollection<>:
public void Add(T item)
public void Add(T item, CancellationToken cancellationToken)
public T Take()
public T Take(CancellationToken cancellationToken)

Jest to para metod blokujących wątek w przypadku oczekiwania na dostęp do zasobów,


pozwalających na dodawanie i pobieranie elementów ze zbioru. Jak widać, metody te
posiadają także warianty pozwalające na przekazanie stosowanego powszechnie w zada-
niach tokena CancellationToken, w tym przypadku umożliwiającego przerwanie ocze-
kiwania (rozdział 6.). Nazewnictwo metod jest konsekwentne w ramach wszystkich
kolekcji współbieżnych: w metodach Add i Take zawsze realizowane jest blokujące
dodawanie i pobieranie danych. Dla zbiorów uporządkowanych stosowane są nazwy
odpowiednie dla struktur danych. Dla kolejek, tj. struktur danych typu FIFO, są to
„Enqueue” (dodaj do kolejki) i „Dequeue” (usuń z kolejki). Natomiast w przypadku
stosów, tj. struktur LIFO, stosowane są nazwy „Push” (połóż na stos) i „Pop” (zdejmij
ze stosu). Dodatkowo dla wszystkich operacji, których rezultat nie jest oczywisty (np.
pobieranie elementu może się nie powieść w przypadku pustej kolekcji), do nazwy
metody dodawany jest przedrostek „Try”. W BlockingCollection<> metody Add i Take
są blokującym odpowiednikiem metod TryAdd i TryTake. Ich nagłówki wyglądają na-
stępująco:
public bool TryAdd( T item )
public bool TryAdd( T item, int timeout )
public bool TryAdd( T item, int timeout, CancellationToken cancellationToken )

public bool TryTake( out T item )


public bool TryTake( out T item, int timeout )
public bool TryTake( out T item, int timeout, CancellationToken cancellationToken )
192 Programowanie równoległe i asynchroniczne w C# 5.0

Dodatkowym argumentem tych metod jest timeout (wyrażony w milisekundach, choć


istnieje również możliwość zastosowania typu TimeSpan), dzięki któremu możliwe jest
określenie maksymalnego czasu oczekiwania w przypadku wystąpienia blokady. Rów-
nież i to oczekiwanie może zostać przerwane wywołaniem metody Cancel na rzecz
odpowiedniego tokena.

Producent może poinformować konsumenta o zakończeniu dodawania elementów


poprzez wywołanie metody CompleteAdding. Z kolei konsument, korzystając z wła-
sności kolekcji IsCompleted, może sprawdzić, czy któryś z producentów zadeklarował
zakończenie dodawania.

Klasa BlockingCollection<> posiada również bardzo ciekawą metodę GetConsuming


Enumerable, zwracającą wartość typu IEnumerable<>. Wykorzystanie tej wartości
jako zbioru, po którym przebiega pętla foreach, powoduje automatyczne usunięcie z ko-
lekcji tych elementów, dla których iteracja pętli już się zakończyła. Krótko mówiąc,
wyrażenie:
foreach(var i in kolekcja.GetConsumingEnumerable())

powoduje pobieranie i następnie usuwanie kolejnych elementów z kolekcji, aż do jej


opróżnienia.

Przedstawiony zestaw funkcji pozwala na wygodne i efektywne korzystanie z kolek-


cji bez potrzeby samodzielnego dbania o synchronizację, pod warunkiem że kolejność
elementów nie jest istotna. Na listingu 9.3 przedstawiam proste operacje na kolekcji
BlockingCollection. W akcji konsumenta dodałem na początku opóźnienie, aby zaob-
serwować, że metoda Add faktycznie powoduje wstrzymanie bieżącego wątku po osią-
gnięciu maksymalnej ilości elementów przez kolekcję, aż do momentu ich usunięcia.
Z kolei opóźnienie przed poinformowaniem o zakończeniu dodawania elementów
przez zadanie producenta pozwala zaobserwować, że metoda GetConsumingEnumerable
powoduje blokowanie wątku konsumenta. Dzieje się tak dlatego, że wywołuje ona
blokującą metodę Take, jeśli nie została ustawiona flaga IsCompleted. Dzięki temu
w wątku konsumenta możliwe jest wywołanie pętli foreach dla zmieniającej się kolek-
cji w tym samym czasie, co pętli for w wątku producenta; wątek konsumenta będzie
czekał, aż do momentu dodania kolejnych elementów przez wątek producenta.

Listing 9.3. Przykładowe operacje klasy BlockingCollection


BlockingCollection<int> kolekcja = new BlockingCollection<int>(3);

Action producent = () =>


{
for (int i = 0; i < 5; i++)
{
kolekcja.Add(i);
Console.WriteLine("Dodano element {0}", i);
}
Thread.Sleep(5000);
kolekcja.CompleteAdding();
};
Action konsument = () =>
{
Thread.Sleep(5000);
Rozdział 9.  Dane w programach równoległych 193

foreach (int i in kolekcja.GetConsumingEnumerable())


{
Console.WriteLine("Pobrano element {0}", i);
}
};

Parallel.Invoke(
producent,
konsument
);

Warto zwrócić uwagę, że tworzymy obiekt BlockingCollection<> bez wskazania ko-


lekcji implementującej interfejs IProducerConsumerCollecition<> (w konstruktorze
określiliśmy jedynie maksymalną ilość elementów zbioru). W związku z tym, powyższy
kod wygląda tak, jakby to instancja klasy BlockingCollection<> była odpowiedzialna za
przechowywanie danych. Musimy jednak pamiętać, że wówczas klasa ta sama tworzy
pomocniczą kolekcję bazową. Domyślnie jest nią kolekcja typu ConcurrentQueue<>,
co można potwierdzić, obserwując kolejność pobieranych elementów. Użyty w listingu
9.3 konstruktor jest wobec tego równoważny instrukcji
BlockingCollection<int> kolekcja = new BlockingCollection<int>(new
ConcurrentQueue<int>(),3);

Klasą bazową nie musi być jednak kolejka. Proponuję zamienić pierwszą instrukcję na
BlockingCollection<int> kolekcja = new BlockingCollection<int>
(new ConcurrentStack<int>(),3);

i jeszcze raz sprawdzić, jaka jest kolejność pobieranych elementów.

Własna kolekcja współbieżna


Jak już wspominałem, interfejs IProducerConsumerCollection<> definiuje metody i wła-
sności kolekcji współbieżnej. Zbiór tych operacji jest niezbędnym minimum, w które
musi być wyposażona każda kolekcja współbieżna. Oczywiście, wymogi te spełniają
wszystkie klasy współbieżne dostarczone z biblioteką Parallel Extensions, a więc:
ConcurrentBag, ConcurrentQueue, ConcurrentStack i ConcurrentDictionary2. Istnieje
również możliwość zaimplementowania interfejsu IProducerConsumerCollection<>
we własnej klasie kolekcji. Zbiór metod predefiniowanych w interfejsie Iproducer
ConsumerCollection<> nie ogranicza się tylko do operacji dodawania i pobierania
pojedynczych elementów z kolekcji. Poniżej, w tabeli 9.1 znajduje się pełne zestawienie
wymaganych przez ten interfejs metod i własności wraz z ich opisem. Zakładam przy
tym, że klasa potomna zdefiniowana została następująco:
class Kolekcja<T> : IProducerConsumerCollection<T>

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

Tabela 9.1. Metody i własności wymagające zaimplementowania przy dziedziczeniu interfejsu


IProducerConsumerCollection<>
Sygnatura Opis
void ICollection.CopyTo( Metoda kopiująca dane do tablicy przekazanej
Array array, w argumencie, począwszy od elementu tablicy
int index określonego przez index. Jest to metoda
) zdefiniowana w interfejsie ICollection i należy
to wskazać, ponieważ istnieje również jej
odpowiednik w IProducerConsumerCollection.
public void CopyTo( Podobnie jak powyższa metoda CopyTo, pozwala
TValue[] destination, na kopiowanie kolekcji do tablicy, począwszy
int index od elementu określonego przez argument index.
)
IEnumerator IEnumerable.GetEnumerator() Zwraca kolekcję typu IEnumerable. Jest to metoda
wykorzystywana np. przez pętlę foreach, metodę
Parallel.ForEach czy zapytania LINQ i PLINQ.
Obecność tej metody jest wymuszana przez
interfejs IEnumerable, co należy zaznaczyć
przy jej implementacji, ponieważ interfejs
IProducerConsumerCollection posiada również
metodę o tej samej nazwie, ale zwracającą
wartość innego typu.
public IEnumerator<TValue> GetEnumerator() Zwraca kolekcję typu IEnumerator<TValue>.
Podobnie jak poprzednia metoda, jest
wykorzystywana przez pętle foreach itp.
public TValue ToArray() Udostępnia dane z kolekcji w postaci tablicy.
public bool TryAdd( Dodaje element do kolekcji. Metoda zwraca
TValue item prawdę w przypadku pomyślnego dodania danych,
) a fałsz, gdy zakończyło się to niepowodzeniem
(np. w sytuacji, gdy kolekcja posiada limit ilości
elementów i jest już zapełniona).
public bool TryTake( Usuwa element z kolekcji. Zwracana jest prawda
out TValue item w przypadku pomyślnego zakończenia lub fałsz,
) gdy kolekcja jest już pusta.
public int Count Własność tylko do odczytu zwracająca ilość
{ elementów w kolekcji.
get;
}
public bool IsSynchronized Własność informująca o tym, czy dostęp
{ do kolekcji odbywa się w sposób bezpieczny, tj.
get; zsynchronizowany.
}
public Object SyncRoot Własność zwracająca obiekt, który powinien
{ być wykorzystany podczas przeprowadzania
get; synchronizacji poza klasą.
}
Rozdział 9.  Dane w programach równoległych 195

Tworzenie własnej kolekcji współbieżnej wymaga ostrożności i uważnego przemy-


ślenia scenariusza, jaki ma ona realizować. Konieczne jest przede wszystkim określe-
nie czynności wykonywanych w sekcjach krytycznych. Programista powinien zade-
cydować, które operacje muszą być zaimplementowane jako blokujące, a które jako
nieblokujące oraz które nie wymagają synchronizacji w ogóle. Przy tym warto się za tę
pracę zabierać tylko wtedy, gdy żadna z gotowych kolekcji nie pasuje do naszego sce-
nariusza dostępu do danych. Możemy wówczas uzyskać zwiększenie wydajności aplika-
cji, np. przez pominięcie synchronizacji tam, gdzie nie jest wymagana.

Na listingu 9.4 widoczny jest przykład klasy implementującej interfejs Iproducer


ConsumerCollection<>. Jest to współbieżna implementacja stosu (odpowiednik klasy
Stack<>). Należy ją jednak traktować jedynie jako demonstrację tego, w jaki sposób
implementować własne kolekcje współbieżne; w przestrzeni nazw istnieje już klasa
ConcurrentStack<> implementująca współbieżny stos i to z niej należy korzystać.

Listing 9.4. Prosta implementacja interfejsu IProducerConsumerCollection<>3


public class MojStos<T> : IProducerConsumerCollection<T>
{
private object obiekt = new object();

private Stack<T> stos = null;

public MojStos()
{
stos = new Stack<T>();
}

public MojStos(IEnumerable<T> kolekcja)


{
stos = new Stack<T>(kolekcja);
}

public void Push(T element)


{
lock (obiekt) stos.Push(element);
}

public bool TryPop(out T element)


{
bool wynik = true;
lock (obiekt)
{
if (stos.Count == 0) { element = default(T); wynik = false; }
else element = stos.Pop();
}
return wynik;
}

public bool TryTake(out T element)


{

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

return TryPop(out element);


}

public bool TryAdd(T element)


{
Push(element);
return true;
}

public T[] ToArray()


{
T[] wynik = null;
lock (obiekt) wynik = stos.ToArray();
return wynik;
}

public void CopyTo(T[] tablica, int indeks)


{
lock (obiekt) stos.CopyTo(tablica, indeks);
}

public IEnumerator<T> GetEnumerator()


{
Stack<T> stos_kopia = null;
lock (obiekt) stos_kopia = new Stack<T>(stos);
return stos_kopia.GetEnumerator();
}

IEnumerator IEnumerable.GetEnumerator()
{
return ((IEnumerable<T>)this).GetEnumerator();
}

public bool IsSynchronized


{
get { return true; }
}

public object SyncRoot


{
get { return obiekt; }
}

public int Count


{
get { return stos.Count; }
}

public void CopyTo(Array tablica, int indeks)


{
lock (obiekt) ((ICollection)stos).CopyTo(tablica, indeks);
}
}
Rozdział 9.  Dane w programach równoległych 197

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);

Wówczas kolejka ConcurrentQueue<> domyślnie używana przez BlockingCollection<>


zostanie zamieniona na instancję klasy MojStos<>. Warto zauważyć, iż blokowanie
przebiega w prawidłowy sposób, mimo że nie zostało zaimplementowane w klasie Moj-
Stos<>. Wynika to — oczywiście — z konstrukcji wrappera BlockingCollection<>.
Dzięki takiemu rozwiązaniu wystarczy więc zaimplementować zaledwie dwie „elemen-
tarne” metody TryAdd i TryTake (pozostałe metody wymagane przez interfejs IProducer
ConsumerCollection<> mogą choćby zgłaszać wyjątki), aby mieć dostęp do takich
funkcjonalności jak blokujące operacje dodawania i usuwania, ograniczenie ilości ele-
mentów czy przetwarzanie elementów z jednoczesnym usuwaniem metodą GetConsuming
Enumerable.

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

Tabela 9.2. Lista operacji redukcji dostępnych w ramach interfejsu IEnumerable<>4


Metoda Opis
Average Średnia arytmetyczna. Argumentem musi być funkcja zwracająca wartość liczbową
(Func<TSource, Decimal>), np. długość napisu dla kolekcji elementów typu string.
Dla liczb może to być po prostu identyczność: (i)=>i.
Count Występuje w dwóch wariantach — normalnego zliczania oraz zliczania warunkowego.
W drugim przypadku jako argument przekazuje się funkcję typu Func<TSource, Boolean>
zwracającą dla danego elementu, w zależności od podanego warunku, wartość logiczną
określającą, czy element ma być zliczony (true), czy nie (false).
Max Funkcja wyszukująca element maksymalny w kolekcji. Występuje również w wariancie
z funkcją przypisującą wartość liczbową (jak w przypadku Average) dla kolekcji, której
elementy nie są liczbami.
Min Analogicznie do powyższego wyszukuje minimum w kolekcji.
Sum Funkcja obliczająca sumę elementów kolekcji. Przyjmuje argument — funkcję
konwertującą na wartość liczbową Func<TSource, Decimal>. Dla kolekcji liczb
argument nie jest wymagany.

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
)

Pierwszy argument (z modyfikatorem this) jest instancją kolekcji, z którą rozszerze-


nie będzie związane. Ponieważ powyższa definicja może być mało zrozumiała, poni-
żej przedstawiam prosty przykład użycia agregacji służący do łączenia (konkatenacji)
napisów z listy lista. Wartością początkową (pierwszym argumentem) jest pusty napis,
funkcją redukcji (drugim argumentem) jest połączenie dwóch łańcuchów za pomocą
statycznej metody Concat klasy string, natomiast funkcja konwertująca (trzeci argu-
ment) zwraca połączony napis, nie zmieniając go w żaden sposób:
List<string> lista=new List<string>();

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

string wynik = lista.Aggregate(


"",
(suma, element) => string.Concat(suma, element),
(suma) => suma
);

Console.WriteLine(wynik);

Agregacje dla kolekcji równoległych


Po tym wstępie przejdę do omówienia agregacji wykonywanych równolegle. Zaimple-
mentowane są one jako rozszerzenie klasy ParallelEnumerable (oraz ParallelQuery<>),
co oznacza, że w praktyce mogą być wywołane dla każdej kolekcji. Wystarczy na
rzecz kolekcji wywołać metodę AsParallel (lub użyć zapytania PLINQ — o czym piszę
w drugiej części tego rozdziału), aby uzyskać współbieżną kolekcję, na rzecz której
można wywołać nową metodę rozszerzającą Aggregate. Równoległe wykonanie agrega-
cji oznacza, że kolekcja zostanie podzielona na kilka części. Na każdej z nich w osob-
nych wątkach wykonana zostanie równocześnie częściowa agregacja z użyciem tej samej
funkcji redukcji — jest to tzw. redukcja pośrednia. Natomiast końcowy rezultat uzy-
skamy z obliczonych wyników pośrednich za pomocą tzw. funkcji redukcji końcowej
(zsynchronizowanej). Należy pamiętać, że funkcje redukcji pośredniej i końcowej re-
alizują zupełnie inne zadania, więc nie mogą być utożsamiane. Nagłówek równoległej
wersji metody Aggregate wygląda następująco:
public static TResult Aggregate<TSource, TAccumulate, TResult>(
this ParallelQuery<TSource> source,
TAccumulate seed,
Func<TAccumulate, TSource, TAccumulate> updateAccumulatorFunc,
Func<TAccumulate, TAccumulate, TAccumulate> combineAccumulatorsFunc,
Func<TAccumulate, TResult> resultSelector
)

Prostym przykładem zastosowania równoległej agregacji może być obliczanie odle-


głości w przestrzeni euklidesowej (pierwiastek sumy kwadratów każdej ze współ-
rzędnych). Dla przykładu w przestrzeni trójwymiarowej odległość wyraża się wzorem:

s  x2  y2  z2

Wzór ten można łatwo uogólnić dla dowolnej liczby wymiarów N:


N 1
s x
i 0
2
i

W przykładzie widocznym na listingu 9.5 o ilości wymiarów decyduje po prostu ilość


elementów w tablicy.
200 Programowanie równoległe i asynchroniczne w C# 5.0

Listing 9.5. Równoległe obliczenie odległości w przestrzeni euklidesowej


int []wspolrzedne={3,4};

double odleglosc = wspolrzedne.AsParallel().Aggregate(


0, //wartość początkowa
(suma, i) => suma + i * i, //redukcja pośrednia
(suma1, suma2) => suma1 + suma2, //redukcja końcowa
(wynik) => Math.Sqrt((double)wynik) //konwersja
);

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

Użycie równoległej agregacji przedstawię ponownie na przykładzie, w którym rów-


nolegle obliczana będzie wartość liczby π. Jednak w odróżnieniu od algorytmu Monte
Carlo, przedstawionego w rozdziałach 2. i 6., tym razem użyjemy wzoru Leibniza,
który wspomniany był w rozdziale 2., w przypisie 4.:
 1 1 1 1 1
      ...
4 1 3 5 7 9

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.

Listing 9.6. Obliczenie liczby π przy użyciu agregacji


using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

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);

const int zakres = 10000000;


int czas;

Func<int, double> znak = (i) =>


{
if (i % 2 == 1)
return -1;
return 1;
};
Func<int, double> nparz = (i) =>
{
return (double)i * 2 + 1;
};
Func<int, double> ciag = (i) =>
{
return znak(i) / nparz(i);
};

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

Console.WriteLine("Wynik sekw. Sum : {0} (czas:{1}ms)", wynik1, czas);


czas=Environment.TickCount;
var wynik2 = 4 * zapytanie.AsParallel().Sum();
czas=Environment.TickCount-czas;
Console.WriteLine("Wynik równ. Sum : {0} (czas:{1}ms)", wynik2, czas);
czas=Environment.TickCount;
var wynik3 = 4 * new ConcurrentBag<int>(Enumerable.Range(0, zakres)).
Aggregate<int, double, double>(
0.0,
(suma, i) => suma + ciag(i),
(suma) => suma
);
czas=Environment.TickCount-czas;
Console.WriteLine("Wynik sekw. Aggregate: {0} (czas:{1}ms)", wynik3, czas);
czas=Environment.TickCount;
var wynik4 = 4 * new ConcurrentBag<int>(Enumerable.Range(0, zakres)).
AsParallel().Aggregate(
0.0,
(suma, i) => suma + ciag(i),
(suma1, suma2) => suma1 + suma2,
(suma) => suma
);
czas=Environment.TickCount-czas;
Console.WriteLine("Wynik równ. Aggregate: {0} (czas:{1}ms)", wynik4, czas);
}
}
}

W kodzie z listingu 9.6 liczba π obliczana jest czterokrotnie. W dwóch pierwszych


przypadkach sumowane ciągi tworzone są za pomocą zapytań LINQ odnoszących się
do kolekcji ConcurrentBag<> wypełnionej liczbami naturalnymi. Samo sumowanie re-
alizowane jest za pomocą wywołania metody Sum (raz sekwencyjnie i raz równolegle
po wywołaniu AsParallel). W dwóch kolejnych wykonywana jest własna agregacja
tworząca ciąg w trakcie obliczeń. Każdy element ciągu (każdy składnik sumy we
wzorze Leibniza) obliczany jest przy użyciu funkcji pomocniczych znak i nparz. Pierw-
sza zwraca znak i-tego elementu wzoru; przy czym elementy zerowy i parzyste mają
znak dodatni, a elementy nieparzyste — ujemny. Druga funkcja przekształca argu-
ment na odpowiednią liczbę nieparzystą. Przykładowe wartości uzyskane za pomocą
tych funkcji przedstawiam w tabeli 9.3. Po utworzeniu w ten sposób kolekcji zawie-
rającej kolejne wyrazy ciągu przeprowadzana jest na niej agregacja zwracająca przy-
bliżenie π/4.

Tabela 9.3. Obliczanie elementów wzoru z wykorzystaniem funkcji pomocniczych


iteracja i 0 1 2 3 4
znak(i) 1 –1 1 –1 1
nparz(i) 1 3 5 7 9
1 1 1 1
ciag(i) 1  
3 5 7 9
Rozdział 9.  Dane w programach równoległych 203

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

Należy pamiętać, że w przypadku dwóch pierwszych metod do czasu sumowania do-


liczony jest czas wykonania zapytania LINQ. Należy również zaznaczyć, że w drugim
przypadku równolegle wykonywane jest tylko sumowanie. Nie zostało użyte zapyta-
nie PLINQ, które omówię dopiero w drugiej części tego rozdziału. Porównując wyni-
ki, możemy stwierdzić, że jednoczesne obliczanie elementu ciągu i sumy pośredniej
pozwala uzyskać wyniki szybciej, niż gdybyśmy tworzyli wpierw ciąg i dopiero później
przeprowadzali sumowanie. Co do współbieżności, o ile w przypadku metody Sum
czasy wykonania są porównywalne, o tyle widać, że agregacja zaprojektowana samo-
dzielnie, lepiej dostosowana do naszych potrzeb, powoduje, iż wyniki w wykonaniu
równoległym są lepsze niż w sekwencyjnym.

Wszystkie metody doprowadziły do uzyskania przyzwoitego przybliżenia, jednak


może się zdarzyć, że pojawią się pewne różnice w wartościach uzyskanych za pomo-
cą obliczeń współbieżnych. Powodem tych błędów jest zastosowanie typu double, co
— jak wiadomo — wiąże się z możliwym przybliżaniem wartości i związaną z tym
utratą precyzji. Jeżeli zależy nam na przybliżeniu kilkunastocyfrowym, błąd ten może
być zaniedbany Należy natomiast pamiętać, że błędy precyzji dla liczb zmiennoprze-
cinkowych dotyczą bardzo małych ułamków i różnica między wynikiem uzyskanym
sekwencyjnie a uzyskanym równolegle jest proporcjonalna do ilości elementów sze-
regu, ponieważ kolejne jego elementy są coraz mniejsze.

PLINQ — zrównoleglone zapytania LINQ


Wprowadzanie mechanizmów programowania współbieżnego do języka C# i platformy
.NET nie ominęło również technologii LINQ, czyli zapytań zintegrowanych z językiem
(ang. Language INtegrated Query).

Technologia LINQ bazuje na metodach rozszerzających (rozszerzeniach) zdefiniowa-


nych dla interfejsu IEnumerable<>. W PLINQ z tym interfejsem związane jest nowe
rozszerzenie AsParallel zwracające obiekt typu ParallelQuery<>. Z kolei dla tego inter-
fejsu zdefiniowany jest zbiór rozszerzeń analogiczny do związanego z IEnumerable<>6.
Bardzo wygodne jest to, że zrównoleglone zapytania tworzymy, używając tych samych
operatorów LINQ.

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

Przykład zapytania PLINQ


Jak można zrównoleglić zapytanie LINQ? Weźmy np. zapytanie widoczne na listingu
9.7. Przeszukuje ono tablicę łańcuchów w poszukiwaniu słów zaczynających się od
wielkiej litery „W”.

Listing 9.7. Zapytanie LINQ


string[] lista = { "Toruń", "Olsztyn", "Katowice", "Kraków", "Warszawa", "Bydgoszcz",
"Gdańsk", "Szczecin", "Wrocław", "Poznań", "Włocławek", "Inowrocław", "Olecko" };
var zapytanie = from m in lista
where m.StartsWith("W")
orderby m.ToUpper()
select m;

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.

Listing 9.8. Zapytanie PLINQ


var zapytanie = from m in lista.AsParallel()
where m.StartsWith("W")
orderby m.ToUpper()
select m;

Zapytanie PLINQ z listingu 9.8 można przedstawić w postaci jawnego wywołania


odpowiednich metod rozszerzających klasy ParallelQuery<> (współbieżnych odpo-
wiedników rozszerzeń zdefiniowanych dla klasy IEnumerable<>). Prezentuję to na li-
stingu 9.9.

Listing 9.9. Zapytanie PLINQ zrealizowane przez jawne wywoływanie metod


var zapytanie = lista.AsParallel()
.Select ( m => { return m; } )
.Where ( m => { return m.StartsWith("W"); } )
.OrderBy( m => { return m.ToUpper(); } );

Wszystkie zdefiniowane w klasie Enumerable metody rozszerzające klasy IEnumerable<>,


z którymi związane są operatory LINQ, a więc m.in. Where, OrderBy, Select czy Join,
zwracają wartość typu IEnumerable<>, co pozwala na sukcesywne wywoływanie ko-
lejnych rozszerzeń na zwracanych przez nie kolekcjach. Sytuacja zmienia się, gdy
jako pierwszej metody w ciągu użyjemy AsParallel. Zdefiniowana jest ona w klasie
Enumerable, ale zwraca wartość typu ParallelQuery<>. Z tą klasą związana jest grupa
rozszerzeń zdefiniowanych w klasie ParallelEnumerable. Ich nazwy są takie same jak
Rozdział 9.  Dane w programach równoległych 205

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.

Tabela 9.4. Klasy wykorzystywane przez LINQ oraz PLINQ


Interfejsy wykorzystywane w LINQ Odpowiedniki wykorzystywane w PLINQ
System.Collections.Generic.IEnumerable<> System.Linq.ParallelQuery<>
System.Collections.IEnumerable System.Linq.ParallelQuery
System.Linq.IOrderedEnumerable<> System.Linq.OrderedParallelQuery<>

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.

Jak działa PLINQ?


Współbieżne wykonanie programu opiera się na podziale i jednoczesnym wykonaniu
pewnej pracy w osobnych wątkach. O ile podczas pracy z TPL pomiędzy wątki dzia-
łające na różnych jednostkach obliczeniowych (procesorach i ich rdzeniach) dzielone
były zadania do wykonania, tak w przypadku PLINQ możemy powiedzieć, że rozdziela-
ne są podzbiory danych. Ogólnie rzecz ujmując, wykonanie takiego kodu dzieli się na
trzy etapy: podział danych, równoległe wykonanie zapytania na fragmentach danych
oraz scalanie podzbiorów i zwrócenie wyniku.

W pierwszym etapie konieczne jest zsynchronizowane (sekwencyjne) podzielenie zbioru


danych wejściowych oraz rozdzielenie ich pomiędzy wątki. Od odpowiedniego po-
działu zależy wydajność dalszej współbieżności. Nie jest to jednak proste, zwłaszcza że
przecież nie jest z góry znany rodzaj danych oraz ich wielkość. W zależności od wielu
czynników, wykorzystywany jest jeden z algorytmów przedstawionych w tabeli 9.5.

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

Tabela 9.5. Algorytmy podziału danych stosowane przez mechanizm PLINQ7


Algorytm Opis
Podział zakresu Podział całego zbioru na części o równej wielkości. Z oczywistych
(ang. range partitioning) względów wykorzystany może być tylko dla danych
indeksowanych, takich jak kolekcje typu List oraz tablice.
Segmentacja Dane dzielone są stopniowo na segmenty różnych wielkości,
(ang. chunk partitioning) w zależności od żądań wątków. Plusem jest równomierny rozkład
pracy na jednostki obliczeniowe w zależności od ich obciążenia.
Podział na „paski” Podział na minimalne podzbiory bądź na pojedyncze elementy,
(ang. strip partitioning) wykorzystywane przez metody SkipWhile i TakeWhile8.
Wykorzystanie funkcji haszującej Dane przydzielane są do konkretnego wątku, jeśli mają tę samą
(ang. hash partitioning) wartość funkcji haszującej. Pozwala to na dalsze operowanie na
danych bez synchronizacji. Podejście to wykorzystywane jest np.
przy operatorach odpowiadających metodom Join, GroupBy
czy Union.

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

Tabela 9.6. Elementy typu wyliczeniowego ParallelMergeOptions9


Opcja Opis
NotBuffered Dane łączone są na bieżąco, bez przechowywania w żadnym
(z ang. bez buforowania) buforze. Ponadto jeżeli wywołana została metoda AsOrdered
(o czym później), dane będą dołączane zgodnie z kolejnością.
FullyBuffered Wszystkie dane wyjściowe zapytania zostaną zgromadzone
(z ang. pełne buforowanie) w buforze przed ostatecznym scaleniem.
AutoBuffered Jest to kompromis między dwoma powyższymi opcjami.
(z ang. buforowanie automatyczne) Dane są buforowane i wysyłane partiami. Rozmiar fragmentów
określany jest automatycznie.
Default Domyślnym trybem jest AutoBuffered.
(z ang. tryb domyślny)

Tabela 9.7. Elementy typu wyliczeniowego ParallelExecutionMode10


Tryb Opis
Default Wybór automatyczny ustalony na podstawie złożoności zapytania.
(z ang. domyślny) Zrównoleglenie jest używane, gdy zwiększy to szybkość
wykonania zapytania.
ForceParallelism Możliwość wymuszenia pełnej współbieżności zapytania.
(z ang. wymuś współbieżność) W takim przypadku pomijana jest analiza zapytania.

Kiedy PLINQ jest wydajne?


Zrównoleglenie zapytania wcale nie musi korzystnie wpłynąć na szybkość programu.
W wielu przypadkach zrównoleglenie ją obniża. Dobrym przykładem są zapytania
operujące na niewielkich źródłach danych. Efektywność zrównoleglenia zapytania
zależy również od użytych operatorów. Aby to zilustrować, posłużę się przykładem
dwóch zapytań. Oba korzystają z tego samego warunku operatora where, jednak w pierw-
szym przypadku jest on wstawiony „inline”, natomiast w drugim dodatkowo wywołuję
omówioną wcześniej w tym rozdziale metodę Thread.SpinWait generującą szereg pu-
stych operacji.

W przypadku zapytania z listingu 9.10 wykonanie sekwencyjne (bez wywołania me-


tody AsParallel) zabrało 2 ms, natomiast równoległe 40 ms. Z kolei zapytanie, które
wiązało się z większą ilością obliczeń (listing 9.11), zostało wykonane w przypadku
sekwencyjnym w ciągu około 800 ms, a w wersji równoległej — w ciągu około 500 ms
(na procesorze dwurdzeniowym). Widać tu wyraźnie, że możemy liczyć na korzyści
ze zrównoleglenia zapytań, jeżeli wiążą się one z wykonywaniem dużej ilości nieza-
leżnych obliczeń, podobnie jak było w przypadku zrównoleglania pętli (rozdział 7.).

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

Listing 9.10. Zapytanie, którego nie da się efektywnie zrównoleglić


string[] lista = { "Katowice", "Kraków", "Warszawa", "Bydgoszcz", "Gdańsk",
"Szczecin", "Wrocław", "Poznań", "Włocławek", "Toruń", "Inowrocław", "Olecko" };
var zapytanie = from m in lista.AsParallel()
where (m.ToUpper().StartsWith("W") ==true)
orderby m.ToUpper()
select m;

Listing 9.11. Zapytanie podatne na zrównoleglenie


var zapytanie = from m in lista.AsParallel()
where Funkcja(m)
orderby m.ToUpper()
select m;
//Funkcja porównująca, dodatkowo „obciążona”
static bool Funkcja(string s)
{
Thread.SpinWait(10000000);
if (s.ToUpper().StartsWith("W") ==true)
{
return true;
}
return false;
}

Metody przekształcające dane wynikowe


Podstawową różnicą między zapytaniem wykonywanym sekwencyjnie a równolegle
jest to, że w tym drugim przypadku kolejność uzyskanych w wyniku zapytania da-
nych nie jest możliwa do przewidzenia i może być różna dla takich samych zapytań.
Wynika to w oczywisty sposób z natury współbieżności (wyścig wątków). Kolejność,
taka jak przy wykonaniu sekwencyjnym, może być jednak wymuszona przy użyciu
wywołania metody AsOrdered i nie oznacza to wcale konieczności całkowitej rezy-
gnacji ze współbieżności. Od momentu wywołania tej metody dalsze rozszerzenia będą
zwracały wyniki uporządkowane, aż do najbliższego wywołania metody AsUnordered
bądź do użycia rozszerzenia OrderBy lub jakiegokolwiek innego operatora zwracającego
typ OrderedParallelQuery, tj. zwracającego dane uporządkowane zgodnie z inną re-
gułą. Należy pamiętać, że korzystanie ze zbiorów uporządkowanych powoduje spo-
wolnienie wykonania zapytania, gdyż każdorazowe sortowanie wymaga synchronizacji.
I tak, aby zwrócić listę kolejnych elementów parzystych, wystarczy wykonać zapytanie:
var zapytanie = from i in lista.AsParallel().AsOrdered()
where i%2==0
select i;

W niektórych sytuacjach samo ustalenie kolejności nie wystarczy. Gdy przykładowo


w którymś z operatorów korzystamy z funkcji niegwarantującej bezpiecznego wyko-
nania współbieżnego, wówczas można wymusić sekwencyjne wykonanie dalszej czę-
ści zapytania, wywołując metodę AsSequential11. Ilustruje to następujący przykład:

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

var zapytanie = lista


.AsParallel()
.Select ( m => { return m; } )
.AsSequential()
.Where ( m => { return NiebezpiecznaFunkcja(m); } )
.OrderBy( m => { return m; } );

Jak widać, przygotowując zapytanie, możemy swobodnie przełączać się między


współbieżnym typem ParallelQuery<> a sekwencyjnym IEnumerable<>. Oczywiście,
ceną takich zabiegów jest wydajność. Gdy jednak któraś z metod rozszerzających
wiąże się z wykonaniem wyrażenia lambda, które trudno ulega zrównolegleniu, wy-
starczy dopiero za nią ustawić wywołanie metody AsParallel. Tylko ta część zapytania,
która stoi za tą metodą, wykonana zostanie równolegle. Scenariusz taki prezentuję na
listingu 9.12.

Listing 9.12. Funkcja NiebezpiecznaFunkcja zostanie wykonana sekwencyjnie, a filtrowanie i sortowanie


— równolegle
var zapytanie = lista
.Select ( m=> { return NiebezpiecznaFunkcja(m); } )
.AsParallel()
.Where ( m%2==0 )
.OrderBy( m => { return m; } );

Podsumowując, mogę stwierdzić, że każda z czterech wspomnianych tu metod, a więc


AsOrdered, AsUnordered, AsParallel i AsSequential, zmienia sposób, w jaki traktowane
są dane przez kolejne operatory i kryjące się za nimi metody rozszerzające LINQ i PLINQ.
Ich właściwe dobranie może wpłynąć korzystnie na czas wykonania zapytania.

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).

Listing 9.13. Przerwanie zapytania PLINQ


IEnumerable<int> lista = Enumerable.Range(0, 100);
CancellationTokenSource cts =new CancellationTokenSource();
CancellationToken ct=cts.Token;

var zapytanie = lista


.AsParallel().WithCancellation(ct)
.Select (m => { return m; })
210 Programowanie równoległe i asynchroniczne w C# 5.0

.Where (m => { return Funkcja(m); })


.OrderBy(m => { return m; });
cts.Cancel();
try
{
foreach (var i in zapytanie)
{
Console.WriteLine(i);
}
}
catch
{
Console.WriteLine("Wystąpiło przerwanie");
}

Przykładowa definicja funkcji Funkcja przyjmującej jeden argument typu int została
przedstawiona na listingu 9.14.

Listing 9.14. Funkcja filtrująca, która jednocześnie spowalnia działanie zapytania


static bool Funkcja(int i)
{
Thread.SpinWait(10000000);
if (i % 2 == 0)
{
return true;
}
return false;
}

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

Listing 9.15. Obsługa przerwania zapytania w trakcie jego wykonania


foreach (var i in zapytanie)
{
try
{
Console.WriteLine(i);
ct.ThrowIfCancellationRequested();
}
catch
{
Console.WriteLine("Wystąpiło przerwanie");
break;
}
}

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.

Listing 9.16. Pełna obsługa wyjątków PLINQ


try
{
foreach (var i in zapytanie)
{
if(ct.IsCancellationRequested)
{
throw new AggregateException(
new Exception("Błąd!"),
new OperationCanceledException()
);
}
Console.WriteLine(i);
}
}
catch (OperationCanceledException)
{
Console.WriteLine("Wystąpiło przerwanie zapytania");
}
catch (AggregateException ae)
{
foreach (Exception e in ae.InnerExceptions)
{
if (e.GetType() == typeof(OperationCanceledException))
{
Console.WriteLine("Wystąpiło przerwanie");
}
else
212 Programowanie równoległe i asynchroniczne w C# 5.0

{
//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

 Koszt obliczeniowy — jak wspomniałem przed chwilą, warto zrównoleglać


zapytania, w których obliczenia mogą być wykonywane bez synchronizacji;
im większa ilość obliczeń, tym lepszą oszczędność czasu wykonania może
przynieść zrównoleglenie zapytania.
 Ilość jednostek obliczeniowych — oczywiste jest, że duża ilość danych i obliczeń
wymaga większej ilości zasobów.
 Rodzaj operacji i organizacja danych — zasadniczo PLINQ działa szybciej
na zbiorach nieuporządkowanych; wymuszanie kolejności metodą AsOrdered
bądź wykorzystanie operatora OrderBy negatywnie wpływa na jego efektywność.
 Operacje „wplecione” w zapytanie — nikt nie oceni za nas, czy metody,
funkcje i wyrażenia, które wykorzystujemy przy każdym z operatorów LINQ,
są na tyle złożone, by je efektywnie zrównoleglić oraz czy nie wymagają dużej
ilości operacji synchronizacji.
 Forma wykonania zapytania — domyślnie zapytanie kończy się zebraniem
wyników do jednej listy (tablicy). Jeżeli to nie jest konieczne, a zależy nam na
wykonaniu operacji na (przefiltrowanych bądź nie) wynikach, lepiej skorzystać
z metody ForAll, która pozwala zaoszczędzić czas potrzebny na synchronizację
i scalanie.
 Określane przez użytkownika (programistę) parametry wykonania — efektywność
w dużej mierze zależy od odpowiedniego dobrania opisanych wcześniej metod
WithDegreeOfParallelism, WithMergeOptions oraz WithExecutionMode.
 Odpowiednie dzielenie danych — obejmuje m.in. rozważenie utworzenia
własnego mechanizmu podziału danych.
 Synchronizacja metod — z jednej strony, korzystanie z metod
niezsynchronizowanych może prowadzić do niepożądanych wyników, z drugiej
jednak, synchronizacja powoduje zwiększenie ilości i czasu obliczeń. Należy
pamiętać, że klasy statyczne środowiska .NET Framework są odpowiednio
przygotowane do wykonania współbieżnego, natomiast nie mamy tej gwarancji
dla metod wywoływanych na rzecz konkretnych instancji.

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

W większości rozdziałów z tej książki do omówienia różnych technik programowania


współbieżnego korzystamy z aplikacji konsolowych. Tak jest wygodnie, przede wszyst-
kim ze względu na możliwość nieblokującego wyświetlania komunikatów w konsoli.
W rzeczywistych projektach wątków i zadań używa się w różnego typu aplikacjach,
także desktopowych, sieciowych (ASP.NET), serwisach WCF itd. W aplikacjach z „bo-
gatym” interfejsem problematyczne staje się synchronizowanie interfejsu kontrolowanego
przez wątek interfejsu z innymi wątkami. Problem ten został już omówiony w rozdziale 5.
Jednak chcę do niego wrócić po omówieniu TPL ze względu na nowe możliwości, które
daje ta biblioteka.

Dostęp do kontrolek utworzonych w wątku interfejsu z dodatkowych wątków można


zrealizować za pomocą kontekstu synchronizacji. Technika ta została już omówiona
w rozdziale 5., jednak nie będzie ona wykorzystywana w sposób bezpośredni, a przy
użyciu odpowiednich narzędzi biblioteki TPL.

Zadania w aplikacjach Windows Forms


Jako pierwszego przykładu użyjemy aplikacji Windows Forms wyszukującej dzielniki
wskazanej liczby całkowitej. Przykład ten jest rozwinięciem programu omówionego
w rozdziale 6. (listing 6.3), w którym sprawdzaliśmy, czy podana liczba jest liczbą
pierwszą. Samo wyszukiwanie dzielników odbywać się będzie w pętli sekwencyjnej,
jednak uruchamianej asynchronicznie w utworzonym do tego zadaniu. W ten sposób nie
będzie blokowała działania interfejsu użytkownika. Główne okno aplikacji składa się
z kilku kontrolek widocznych na rysunku 10.1. Typy i identyfikatory kontrolek przed-
stawione zostały na rysunku.
216 Programowanie równoległe i asynchroniczne w C# 5.0

Rysunek 10.1.
Interfejs aplikacji
wyszukującej dzielniki
liczby całkowitej

Kod odpowiedzialny za wyszukiwanie dzielników znajduje się w metodzie obsługują-


cej zdarzenie kliknięcia przycisku bWyszukaj (listing 10.1). Zostanie on przeanalizowany
dalej w tym rozdziale. Już teraz należy jednak uprzedzić, że jest to kod wadliwy i będzie
korygowany.

Listing 10.1. Wyszukiwanie dzielników nieblokujące interfejsu, modyfikujące zawartość kontrolek


spoza głównego wątku
private void bWyszukaj_Click(object sender, EventArgs e)
{
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(() =>
{
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.

Rysunek 10.2. Wyjątek informujący o odwołaniu do interfejsu spoza głównego wątku


218 Programowanie równoległe i asynchroniczne w C# 5.0

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()

Następnie utworzony w ten sposób obiekt typu TaskScheduler trzeba przekazać do


zadania, które modyfikować będzie zawartość okna. Ale uwaga! Ponieważ wykonanie
zadania w tym samym kontekście, co główny wątek, spowoduje niepożądane bloko-
wanie interfejsu, kontekst ten nie powinien być przypisany do zasadniczego zadania,
wykonującego asynchronicznie obliczenia, a do odrębnych, dodatkowych minizadań
odpowiedzialnych jedynie za modyfikację kontrolek interfejsu. W omawianym przykła-
dzie należy wprowadzić nowe zadania w trzech miejscach: przy dodawaniu elementy
do listy, aktualizacji paska postępu oraz po wykonaniu zadania przy aktywowaniu
przycisku Wyszukaj. Wszystkie zmiany wprowadzone do listingu 10.1 wyróżnione zo-
stały na listingu 10.2.

Listing 10.2. Nieblokujące wyszukiwanie dzielników, modyfikujące zawartość kontrolek w kontekście


synchronizacji z wątkiem głównym
private void bWyszukaj_Click(object sender, EventArgs e)
{
TaskScheduler planistaInterfejsu = 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(() =>
{

for (int i = 1; i <= n; i++)


{
Thread.Sleep(100);
if (n % i == 0)
Rozdział 10.  Synchronizacja kontrolek interfejsu z zadaniami 219

{
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.

Zadania w aplikacjach WPF


W aplikacjach WPF mechanizm synchronizowania kontrolek interfejsu z uruchomio-
nymi asynchronicznie zadaniami jest niemal identyczny.Aby się o tym przekonać,
odtworzymy powyższą aplikację z użyciem biblioteki WPF, zachowując wygląd in-
terfejsu (rysuneku 10.3).
220 Programowanie równoległe i asynchroniczne w C# 5.0

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.

Listing 10.3. Program wyszukujący dzielniki zaimplementowany w oparciu o WPF


private void bWyszukaj_Click(object sender, RoutedEventArgs e)
{
TaskScheduler planistaInterfejsu = 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.IsEnabled = false;
pbPostep.Value = 0;

Task.Factory.StartNew(() =>
{

for (int i = 1; i <= n; i++)


{
Thread.Sleep(100);
if (n % i == 0)
{
Task.Factory.StartNew((i2) =>
Rozdział 10.  Synchronizacja kontrolek interfejsu z zadaniami 221

{
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

Ponownie użyjemy metody zdarzeniowej obsługującej kliknięcie przycisku, jednak


samo obliczanie przybliżenia liczby  znajduje się w metodzie ObliczPiRownolegle,
analogicznie do przykładu z rozdziału 7. Ponieważ interfejs aktualizowany będzie jedy-
nie po zakończeniu obliczeń, nie trzeba wprowadzać modyfikacji wewnątrz tej metody,
dlatego jej kod na listingu 10.4 został pominięty. Używa ona wprawdzie równoległej
pętli Parallel.ForEach, ale nie ma to wpływu na aspekt synchronizacji z interfejsem,
która jest przeprowadzana już po zakończeniu tej pętli. Na listingu 10.4 przedstawiam
kod źródłowy metody bOblicz_click.

Listing 10.4. Synchronizacja zadania z interfejsem WPF za pomocą operatora await


private async void bOblicz_Click(object sender, RoutedEventArgs e)
{
int n;

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
);

tbWynik.Text = (await t).ToString();


bOblicz.IsEnabled = true;
}

Wyróżniony fragment programu przedstawia tworzenie zadania, w obrębie którego


przeprowadzane będą obliczenia. Zadanie to zwraca wartość typu double, która obli-
czana będzie bezpośrednio przez metodę ObliczPiRownolegle. Aby wartość tę przypisać
do własności Text kontrolki TextBox, należy jedynie poprzedzić odwołanie do zadania
operatorem await (pomijając rzutowanie typu double na String). Operator await spraw-
dzi, czy zadanie jest już wykonane. Jeżeli nie, wykonywanie metody zostanie wstrzyma-
ne, a wątek wróci do pętli głównej obsługującej reakcje interfejsu na czynności użyt-
kownika. Powrót nastąpi dopiero w momencie zakończenia zadania, uzyskania wyniku.
Dopiero wtedy zostanie wykonane przypisanie wartości do pola tekstowego i przełącze-
nie własności IsEnabled przycisku. A ponieważ wszystkie te operacje (oczywiście,
poza kodem wykonywanym asynchronicznie przez zadanie) przeprowadzane są w wątku
interfejsu, zmiana kontrolek nie wymaga żadnych specjalnych zabiegów, co znakomicie
upraszcza kod aplikacji.
Rozdział 10.  Synchronizacja kontrolek interfejsu z zadaniami 223

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

Wprowadzeniu biblioteki TPL do platformy .NET towarzyszyło rozbudowanie śro-


dowiska programistycznego Visual Studio o narzędzia pozwalające na analizowanie
tworzonego kodu aplikacji równoległych. Dotyczy to zarówno narzędzi do debugowania
kodu, a więc śledzenia wykonywanego kodu i stanu zmiennych w programie, oraz jego
profilowania, czyli analizowania i wizualizacji informacji o programie zebranych pod-
czas jego wykonywania. W poniższym rozdziale opisane zostaną narzędzia wspoma-
gające debugowanie: podokna Parallel Tasks, Parallel Stack i Parallel Watch, a także
profiler aplikacji równoległych Concurrency Visualiser. Każde z nich jest w zasadzie
rozszerzeniem narzędzi, które były dostępne już we wcześniejszych wersjach Visual
Studio. Zostały one po prostu rozbudowane o funkcje związane z programowaniem
równoległym, a więc przedstawianie informacji o zadaniach, wątkach, jednostkach
obliczeniowych itp. Opisane zostanie także okno Threads udostępniające bardzo dużo
informacji na temat wątków, które nowością w Visual Studio nie jest.

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ą.

Wszystkie narzędzia służące do debugowania pozwalają na uzyskanie informacji do-


piero po wstrzymaniu wykonania programu. Można to wykonać, wstawiając punkty
przerwania (ang. breakpoint) w wybranym miejscu kodu źródłowego. Aby taki punkt
226 Programowanie równoległe i asynchroniczne w C# 5.0

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 (Threads)


Opisane dalej w tym rozdziale narzędzia zostały wprowadzone do Visual Studio 2010
wraz z biblioteką TPL. Natomiast okno wątków (ang. Threads) jest obecne w Visual
Studio już od dawna. Po wprowadzeniu nowych narzędzi debugowania aplikacji rów-
noległych okno wątków zostało z nimi zintegrowane poprzez współdzielenie pewnych
mechanizmów (jak choćby oflagowanie wątków i zadań).

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.

Rysunek 11.1. Okno wątków

Widoczne na rysunku 11.1 okno zawiera informacje o wszystkich wątkach zaangażo-


wanych w wykonanie programu. Jak widać, dane przedstawione są w postaci tabeli. Aby
ułatwić jej zrozumienie, w tabeli 11.1 przedstawiono krótki opis każdej z kolumn.

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.

Przydatna jest również możliwość zaznaczenia wątku (czerwona flaga w pierwszej


kolumnie). Możliwe to jest tylko w tym oknie. Oznaczenie wątku umożliwia śledzenie
jego dalszego przebiegu. W tym celu z menu kontekstowego wątku w tabeli w oknie
Threads należy wybrać polecenie Switch to Thread i wykonywanie kolejnych poleceń
przez naciskanie klawiszy F5, F10, F11. W aplikacjach korzystających z TPL zazna-
czenie wątku ułatwia też podejrzenie związanego z tym wątkiem zadania w oknie zadań
(opisanym dalej w tym rozdziale), bo zaznaczenie wątku powoduje również zaznaczenie
wykonywanych w jego obrębie zadań. I odwrotnie, zaznaczenie zadania powoduje
zaznaczenie skojarzonego z nim wątku.
Rozdział 11.  Analiza aplikacji wielowątkowych. Debugowanie i profilowanie 227

Tabela 11.1. Opis kolumn widocznych w oknie wątków


Nazwa kolumny Opis
Pierwsza kolumna zawiera ikony flag. Kliknięcie ikony wybranego wątku
powoduje jego oznaczenie (zmiana flagi na czerwoną) lub usunięcie zaznaczenia.
Druga kolumna również zawiera znaki graficzne. Wskazuje, który spośród
wątków jest aktywny. Żółta strzałka wskazuje ten wątek, który był aktywny
1
w momencie wstrzymania wykonania programu .
ID Kolumna zawiera identyfikator systemowy wątku.
Managed ID Identyfikator wątku zarządzanego przypisywany wątkom tworzonym przez .NET.
Category Kategoria przypisywana wątkom zgodnie z przeznaczeniem i miejscem
utworzenia. Wyróżniony jest wątek główny (ang. Main Thread), wątki robocze
tworzone przez programistę (ang. Worker Thread), wątki interfejsu (ang. Interface
Thread) oraz wątki związane ze zdalną obsługą procedur (ang. Remote Procedure
2
Call Handler) .
Name Jest to nieużywana w tej książce nazwa wątku (własność Name obiektów klasy Thread).
Location Miejsce aktualnie wykonywane w wątku — w większości przypadków jest
to nazwa metody.
Priority W kolumnie podany jest priorytet wątku.

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.

Rysunek 11.2. Oznaczenie wykonywanych wątków w oknie edytora

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ń równoległych


(Parallel Tasks)
Okno zadań (ang. Parallel Tasks) jest narzędziem podobnym do okna wątków, z tym
że pokazuje informacje o zadaniach. Podobnie jak w oknie wątków, informacje
przedstawione są tu w formie tabeli. W oknie zadań (rysunek 11.4) podejrzeć można
numery identyfikacyjne każdego z istniejących w danym momencie zadań oraz inne
dane, np. wątek i proces, do których zadanie jest przypisane. Bardzo przydatna podczas
debugowania aplikacji jest możliwość zobaczenia stanu zadania (kolumna Status).
Dzięki temu widać, które zadania zostały zaplanowane do wykonania, a które są w stanie
oczekiwania itp.

Rysunek 11.4. Okno zadań równoległych

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

Rysunek 11.5. Zadania pogrupowane według stanu

Okno stosów równoległych


(Parallel Stacks)
Kolejne użyteczne narzędzie to okno stosów równoległych (ang. Parallel Stacks Window),
które służy do prezentowania stosów wywołań. Mogą być one pokazywane w kontekście
wątków lub zadań. Do zmiany widoku służy lista rozwijana w lewym górnym rogu
okna. Widoczny na rysunku 11.6 diagram przedstawia stosy wywołań każdego z wątków.
W tym akurat przypadku jest to zrzut ekranu zrobiony podczas debugowania programu
z listingu 7.5, w którym przeszukujemy drzewo binarne z wykorzystaniem Parallel.Invoke.

Rysunek 11.6. Okno stosów równoległych w momencie wywołania Parallel.Invoke

Na diagramie uchwycono moment wywołania metody DoTree przez każdy z wątków.


Podobnie jak w przypadku wcześniej opisywanych okien i edytora kodu, także tutaj
zaznaczone są miejsca, w których zatrzymały się pracujące wątki (żółta strzałka dla
wątku aktywnego i szara ikona dla pozostałych). Co prawda, oznaczenia tego nie widać
na środkowym wątku, jednak jest to spowodowane tym, że wykonuje on kod zewnętrz-
ny. Oznaczenie będzie widoczne po wybraniu z menu kontekstowego opcji Show
external code. Opcja ta spowoduje również rozrośnięcie się diagramu o wcześniej
ukryte wątki oraz o liczne wywołania kodu zewnętrznego (rysunek 11.7).

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.8. Widok stosów wywołań z włączoną opcją widoku metod

Okno równoległego śledzenia


zmiennych (Parallel Watch)
Zwykłe okno śledzenia zmiennych (ang. Watch Window) w trakcie kontrolowanego
uruchamiania programu znane jest chyba wszystkim programistom. Wydaje się, że
jest to najczęściej wykorzystywane narzędzie debugowania w każdym środowisku
Rozdział 11.  Analiza aplikacji wielowątkowych. Debugowanie i profilowanie 231

programistycznym. Dostępne jest — oczywiście — także w Visual Studio. Parallel


Watch Window jest rozszerzeniem tego narzędzia, pozwalającym na podgląd zmiennej,
której wartość jest odmienna w różnych wątkach (dokładniej mówiąc, jest to podgląd
wartości lokalnych w obrębie wątku dla podanej nazwy zmiennej). Aby zobrazować
działanie tego okna, wróćmy do programu sprawdzającego, czy wskazana liczba jest
liczbą pierwszą (listing 7.8). Program ten wykonuje równoległą pętlę, w której każdy
z kroków sprawdza podzielność przez jedną z liczb z zakresu od 2 do pierwiastka ze
sprawdzanej liczby. Gdy chcemy sprawdzić, jak jest to realizowane przez poszczegól-
ne zadania, należy po otwarciu okna obserwacji równoległych (rysunek 11.9) dodać ob-
serwację zmiennej i, wpisując jej nazwę w nagłówku pustej kolumny. Narzędzie w przej-
rzysty sposób zaprezentuje aktualne wartości przechowywane we wszystkich aktualnie
aktywnych zadaniach.

Rysunek 11.9.
Obserwacja zmiennej
„i” podczas wykonania
pętli równoległej

W kolumnie [Task] widoczna jest informacja o identyfikatorach zadań. Podobnie jak


w zwykłym oknie Watch, czerwony kolor czcionki wskazuje nowo zmienione wartości,
co jest bardzo przydatne przy analizowaniu wykonania poprzez wznawianie pracy pro-
gramu w trybie debugowania. W przykładzie przybliżającym wartość liczby  (listing
7.14) do wydajnego zrównoleglenia niewielkich bloków obliczeń wykorzystano klasę
Partitioner, która ma wykonywać automatyczny podział danych na zakresy. Po wpro-
wadzeniu obserwacji możliwy jest podgląd tych zakresów w trakcie wykonania pętli (ry-
sunek 11.10). Można na tej podstawie zaobserwować, ile zakresów jest tworzonych i jaka
jest rozpiętość każdego z nich.

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.

Widok Wykorzystanie CPU


Jak wspomniałem, raport podzielony jest na trzy widoki. Pierwszy z nich to wykres
przedstawiający wykorzystanie procesorów3 w funkcji czasu (ang. utilization view).
3
Pozwala on również śledzić wykorzystanie GPU (procesorów graficznych), jednak ta kwestia nie
będzie poruszana.
Rozdział 11.  Analiza aplikacji wielowątkowych. Debugowanie i profilowanie 233

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.

Raport widoczny na rysunku 11.13 przedstawia analizę wykonania programu obliczają-


cego przybliżenie liczby  z listingu 7.14. Na rysunku widać moment rozpoczęcia
współbieżnej pracy rdzeni procesora (na samym początku, dla czasu równego 0,5 se-
kundy), gdy wykorzystanie procesorów przekracza 50%. W końcowej fazie działania
widać spadek zużycia procesora, co wiąże się z zakończeniem równoległej pracy, gdy
w programie obliczany jest wynik końcowy. Przy analizowaniu działania programu
bardzo przydatny jest suwak Zoom pozwalający na manipulację skalą czasu, dzięki
czemu możliwy jest bardziej szczegółowy podgląd wybranego przedziału czasowego.
W ten sposób można dokładnie zaobserwować ogólną wydajność zrównoleglenia. Go-
łym okiem widać, w których momentach program nie wykorzystuje całego potencjału
mocy obliczeniowej komputera. Podobnie w łatwy sposób można odnaleźć tzw. wąskie
gardła — czyli sytuacje, gdy wykorzystanie CPU gwałtownie spada, ograniczając się
np. do jednego rdzenia.

Rysunek 11.13. Widok Wykorzystanie CPU

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

Rysunek 11.14. Widok Wątki

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.

Tabela widoczna na rysunku 11.15 zawiera informację o zaangażowanych kompo-


nentach oraz dodatkowe informacje specyficzne dla danej kategorii operacji. Przełącznik
Just My Code spowoduje odfiltrowanie pozycji jedynie do kodu aplikacji (co np. w przy-
padku operacji synchronizacji zakończy się wyświetleniem pustej listy).
Rozdział 11.  Analiza aplikacji wielowątkowych. Debugowanie i profilowanie 235

Rysunek 11.15. Podsumowanie dla operacji przetwarzania (ang. Execution)

Na analizowanym przykładzie (rysunek 11.14) widać, że — mimo iż kod wykonywany


jest przez wiele wątków — obliczenia (operacje oznaczone na zielono — Execution)
przeprowadzone są przez maksymalnie dwa wątki jednocześnie4. Jasny obszar to czas
tracony przez wątki na przełączanie kontekstu (ang. Preemtion). Jest to zjawisko na ogół
niepożądane, jednak w tym przypadku nieuniknione. Wynika ono z faktu, że mecha-
nizm pętli Parallel.For powoduje utworzenie większej liczby wątków niż liczba rdzeni.
Dla wydajności ważna jest przecież ilość wykorzystywanych rdzeni, a nie działają-
cych wątków.

W opisywanym przykładzie przedstawiam dobrze zrównoleglony kod, gdzie przez


praktycznie cały czas do obliczeń wykorzystywane były wszystkie dostępne jednostki
obliczeniowe. Nieco inaczej sytuacja wygląda w przypadku wcześniejszych wersji
programu. Widoczna na rysunku 11.16 analiza programu z listingu 7.13, w którym wy-
korzystywana była klasa RandomThreadSafe, ujawnia bardzo dużą ilość operacji przełą-
czania kontekstu, a także długie okresy synchronizacji. Przedstawiają to różowe oraz
żółte fragmenty wykresu obciążenia.

Rysunek 11.16. Analiza przebiegu programu z listingu 7.13

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

Rysunek 11.17. Wykres wykorzystania wątków przez program z listingu 7.11

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.

Rysunek 11.18. Widok Rdzenie

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

Profilowanie aplikacji zewnętrznych


Analizowanie aplikacji konsolowych przygotowywanych przez nas w Visual Studio nie
stwarza żadnych problemów. Po wczytaniu rozwiązania i projektu możemy uruchomić
analizę z menu Analyze/Concurrency Visualizer/Start with Current Project. Czasem
konieczne jest jednak profilowanie działania projektu innego typu. Załóżmy, że pra-
cujemy nad aplikacją webową w technologii ASP.NET, która wykonuje złożone obli-
czenia po stronie serwera. Uruchomienie narzędzia Concurrency Visualizer opisaną
wcześniej metodą zakończy się pojawieniem komunikatu, że dany typ projektu nie jest
obsługiwany. Na szczęście, istnieje możliwość gromadzenia informacji po podłączeniu
się do dowolnego działającego w systemie procesu. Załóżmy, że nasza aplikacja webowa
jest testowana lokalnie z wykorzystaniem deweloperskiego serwera IIS Express. Załóż-
my też, że serwer ten jest uruchomiony. Gdy chcemy wykonać analizę działania apli-
kacji, należy w menu Analyze/Concurrency Visualizer kliknąć pozycję Attach to Pro-
cess…, po czym wybrać proces o nazwie iisexpress z listy działających procesów
(rysunek 11.19).

Rysunek 11.19.
Lista wyboru
procesu do analizy

Po zatwierdzeniu przyciskiem Attach rozpocznie się proces gromadzenia danych.


Kiedy zbieranie informacji zakończy się, zobaczymy raport, który niczym nie różni
się od przedstawionych we wcześniejszych przykładach.

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

Komenda start spowoduje uruchomienie polecenia w nowym oknie, co zapobiegnie


blokowaniu konsoli na czas działania narzędzia. Po uruchomieniu narzędzia i po-
myślnym zidentyfikowaniu procesu wyświetlony zostanie komunikat „Attaching to
process”, po czym rozpocznie się gromadzenie danych. Aby je zakończyć, należy wydać
polecenie
CvCollectionCmd.exe /Detach

Podobnie jak w Visual Studio, po zatrzymaniu analizowania pracy programu nastąpi


generowanie raportu do pliku .CvTrace. Gdy ten etap zakończy się, pozostaje już tylko
przenieść plik wynikowy na maszynę z zainstalowaną aplikacją Visual Studio i otworzyć
go za pomocą menu File/Open/File. Spowoduje to wyświetlenie raportu w formie
identycznej z poprzednimi. Raport zawierał będzie wszystkie zgromadzone informacje
na temat działania procesu serwera IIS.

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.

Aby wprowadzić znaczniki do projektu, należy najpierw podłączyć do projektu refe-


rencję do SDK Concurrency Visualizer. Służy do tego polecenie Add SDK to Project…
z menu Analyze/Concurrency Visualizer. Tworzenie znaczników zostało zaprezentowane
na listingu 11.1. Przedstawiam na nim program z listingu 7.14 wzbogacony o znaczniki
przekazujące dodatkowe informacje do profilera. Na listingu wyróżniono zmiany w ko-
dzie w stosunku do pierwowzoru.

Listing 11.1. Tworzenie znaczników z wykorzystaniem SDK profilera


static double ObliczPiRownolegle(long n)
{
long k = 0;

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.WriteMessage("Granice przedziału: {0} - {1}", przedzial.Item1,


przedzial.Item2);
Span span = Markers.EnterSpan("Pętla sekwencyjna");
for (long i = przedzial.Item1; i < przedzial.Item2; i++)
{
double x, y;
x = r.NextDouble();
y = r.NextDouble();
if (x * x + y * y < 1)
sumaCzesciowa++;
}
span.Leave();
return sumaCzesciowa;
},
(sumaCzesciowa) => { Interlocked.Add(ref k, sumaCzesciowa); }
);

Markers.WriteFlag("Koniec obliczeń");

return 4.0 * k / n;
}

Zaznaczam, że skorzystanie z klas SDK wymaga dołączenia następującej klauzuli using:


using Microsoft.ConcurrencyVisualizer.Instrumentation;

Po wykonaniu profilowania w widoku Wątki raportu pojawiają się dodatkowe pozycje,


zawierające znaczniki dla każdego z wątków (rysunek 11.20). Pierwsza pozycja za-
wiera znacznik wygenerowany automatycznie przez TPL i pokazujący przedział czasu
wykonania pętli ForEach. Zaraz pod nim znajdują się utworzone przez nas znaczniki
przypisane do wątku głównego. Zielone ikony przedstawiają znaczniki flag. Po najecha-
niu na nie kursorem pokazuje się etykieta flagi. Jak widać na rysunku, flagi te przypi-
sane są do wątku głównego. Dzieje się tak dlatego, że są tworzone poza pętlą ForEach,
czyli podczas wykonania sekwencyjnego. Flagi definiowane są w następujący sposób:
Markers.WriteFlag("Początek obliczeń");

Komunikaty natomiast oznaczane są szarym symbolem, widocznym na rysunku 11.20


przy rozpoczęciu każdego z zakresów. Najechanie kursorem na symbol komunikatu
spowoduje wyświetlenie informacji o granicach utworzonego zakresu danych. Komu-
nikaty tworzy się przy użyciu polecenia
Markers.WriteMessage("Granice przedziału: {0} - {1}", przedzial.Item1,
przedzial.Item2);

Zakresy zaprezentowane są na wykresie jako niebieskie prostokąty. Ich rozpiętość


oznacza przedział czasu wykonania danej fazy. W przypadku programu z listingu 11.1
są to sekwencyjne pętle for. W tym przykładzie zakresy definiowane są za pomocą
dwóch linii kodu:
240 Programowanie równoległe i asynchroniczne w C# 5.0

Rysunek 11.20. Znaczniki na diagramie Wątki


Span span = Markers.EnterSpan("Pętla sekwencyjna");
/* ... */
span.Leave();

Wygodniejszym sposobem może być tworzenie zakresu z wykorzystaniem operatora


using. Pozwala on na pominięcie wywołania metody Leave i blokową organizację kodu
danej fazy:
using(Span span = Markers.EnterSpan("Pętla sekwencyjna"))
{
/* ... */
}

Aby zobaczyć wszystkie komunikaty jednocześnie, należy skorzystać z opcji Markers


znajdującej się w lewej, dolnej części raportu. Kliknięcie tej pozycji spowoduje wy-
świetlenie w zakładce Profile Report tabeli zawierającej szczegółowe informacje na
temat wszystkich znaczników. Przy użyciu przycisku Export możliwe jest zapisanie
wszystkich tych informacji do pliku .csv.
***
Visual Studio zawiera bogatą paletę narzędzi wspomagających proces tworzenia i optyma-
lizacji programów równoległych, zarówno opartych na wątkach, jak i wykorzystujących
bibliotekę TPL. Pozwalają one na wyśledzenie wielu problemów, szczególnie tych, które
dotyczą synchronizacji. Możliwa jest również analiza wykorzystania CPU za pomocą
profilera.
W dokumentacji znaleźć można zestawienie przykładowych raportów6 przedstawiających
m.in. niepoprawną synchronizację, dzięki czemu łatwo uniknąć często popełnianych
błędów. Dodatkowo od wersji Visual Studio 2012 Concurrency Visualizer posiada
również możliwość analizowania wykorzystania procesorów graficznych. Uzupełniony
o własne SDK7, Concurrency Visualizer stanowi naprawdę potężne wsparcie dla pro-
gramisty.
6
Znajdują się one w dodatku B książki opublikowanej online pod adresem
http://msdn.microsoft.com/en-us/library/ff963553.aspx.
7
Nie zostało ono tu wyczerpująco opisane. Dodatkowo umożliwia tworzenie serii znaczników,
czy określanie ich kategorii i priorytetu.
Rozdział 11.  Analiza aplikacji wielowątkowych. Debugowanie i profilowanie 241

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.

Te dwa przypadki dobrze pokazują siłę skalowalności tego rozwiązania. Dobrze


sprawdzają się zarówno w przypadku małych robotów konstruowanych w dziecięcym
pokoju, których mózgiem jest laptop z biurkowym systemem Windows, a nawet
mniejsze, zintegrowane komputery działające pod kontrolą Windows CE, jak i w roz-
budowanych farmach serwerów zarządzających portalem społecznościowym.
244 Programowanie równoległe i asynchroniczne w C# 5.0

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.

Należy również podkreślić, że korzystając z technologii zawartych w platformie .NET


4.5, potrzebowalibyśmy zespołu kilku profesjonalnych programistów pracujących
przez rok, aby otrzymać pełną i przetestowaną funkcjonalność, którą „na dzień dobry”
oferuje CCR i DSS. Jest to dojrzały i sprawdzony w różnych warunkach produkt, którego
podstawowa funkcjonalność wraz z dodatkami zapewnia wygodę i wszechstronność
w sytuacji, gdy zrównoleglanie obliczeń i pracy przekracza granice procesów i poje-
dynczych komputerów. Nie bez znaczenia jest również fakt, że jest to technologia
udostępniana i wspierana przez Microsoft, twórcę platformy .NET. No i wreszcie
ostatnia zaleta: od niedawna dostępna jest za darmo, bez konieczności wnoszenia ja-
kichkolwiek opłat licencyjnych.

W poniższym rozdziale chcemy zapoznać czytelników z zagadnieniami związanymi


z użyciem CCR i DSS w możliwie łagodny sposób. Pokażemy, jak, korzystając z tych
technologii, można przekroczyć granicę jednego procesu, a następnie jednego urzą-
dzenia. Jednak zanim przejdziemy do pisania kodu, opiszemy proces instalacji śro-
dowiska Microsoft Robotics, które będzie niezbędne w fazie projektowania aplikacji,
i typowe problemy, jakie mogą się z tym wiązać.

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ń).

Usługa1 jest podstawowym blokiem, który wykorzystujemy do rozwiązywania zadań


wymagających współbieżności z wykorzystaniem bibliotek CCR i DSS. Architektura
typowego bloku jest pokazana na rysunku 12.1. Na jego strukturę składają się m.in.
1
W tym i kolejnym rozdziale pojęć „usługa” i „serwis” używamy zamiennie. Odnoszą się one do
podstawowego bloku programowego w architekturze DSS.
Rozdział 12.  Wstęp do CCR i DSS 245

jednoznaczny identyfikator szablon, na bazie którego powstała usługa, nazywany


identyfikatorem kontraktu, oraz jednoznaczny identyfikator usługi. Cała logika dzia-
łania opiera się na opisie usługi zawartym w stanie usługi, który jest modyfikowany
przez wiadomości przychodzące na główny port usługi i obsługiwane przez metody
obsługi zdarzeń. W tym kontekście zdarzenia i wiadomości mogą być traktowane za-
miennie. Przy użyciu mechanizmu subskrypcji możemy otrzymywać powiadomienia
o zmianach, jakie zaszły w usługach partnerskich. Usługi są wykonywane w kontekście
węzłów DSS, reprezentowanych faktycznie przez instancje programu DssHost.exe.
Bazowe bloki możemy specjalizować w zależności od potrzeb, przekazując im konkretne
podzadania wynikające z rozbicia głównego problemu. Przykładem może być utworze-
nie usługi służącej do interakcji z użytkownikiem, usług zajmujących się obliczeniami
oraz usługi dystrybuującej zadania i koordynującej ich wykonanie. Wszystkie usługi
mają wbudowaną funkcjonalność sieciową, wynikającą z użycia protokołu DSS Pro-
tocol jako warstwy komunikacji opartej o TCP/IP. Jest to jedna z ważniejszych zalet,
pozwalająca na komunikację z wykorzystaniem wewnętrznej sieci firmowej, internetu
lub chmury. Typowy scenariusz przepływu informacji demonstrujemy na rysunku 12.2,
w którym źródłem wiadomości jest jedna z usług, a odbiorcą inna. Jednak nic nie stoi
na przeszkodzie, aby odbiorcą i nadawcą była ta sama usługa, co może być przydatne
w kontekście synchronizacji wykonania zadań jednej usługi z wykorzystaniem wielu
wątków działających współbieżnie. Biblioteki CCR i DSS zapewniają synchronizację
oraz bezpieczne, ze względu na współbieżność, przyjmowanie i wysyłanie wiadomości.

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

Agent przekazujący Agent przekazujący

Transport
Transport
DSS
DSSProtocol
Protocol
(TCP/IP)
(TCP/IP)

Instalacja Microsoft Robotics Developer Studio (rysunek 12.3) przebiega standardowo.


Należy tylko postępować zgodnie z instrukcjami instalatora. Warto skorzystać z domyśl-
nych ścieżek proponowanych przez instalator (typowo jest to C:\Users\UserName\
Microsoft Robotics Dev Studio 4), a same projekty bazujące na środowisku przechowywać
w jednym z podkatalogów. Na użytek tego rozdziału będzie to folder C:\Users\UserName\
Microsoft Robotics Dev Studio 4\projects.

Rysunek 12.3.
Ekran początkowy
instalacji Microsoft
Robotics Developer
Studio 4

Jeżeli planujemy rozwijać kod związany z urządzeniem Kinect lub przygotowywać


aplikacje w technologii Silverlight, konieczne będzie zainstalowanie jeszcze dwóch
bibliotek: Kinect for Windows SDK (http://www.kinectforwindows.org) oraz Microsoft
Silverlight 4.0 SDK (http://www.microsoft.com/en-us/download/details.aspx?id=7335).
CCR i DSS bardzo dobrze z nimi współpracują.

Warto zauważyć, że choć środowisko Microsoft Robotics zajmuje sporo miejsca na


dysku, to sam pakiet redystrybucyjny konieczny do uruchamiania naszych aplikacji
na innych komputerach zajmuje już tylko 1,5 MB. Możemy go znaleźć w katalogu,
Rozdział 12.  Wstęp do CCR i DSS 247

w którym zainstalowany został Robotics, w podfolderze redistributables. Licencja tego


pakietu pozwala na jego dalsze bezpłatne udostępnianie i dołączanie do komercyjnych
i otwartych projektów.

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.

Kod źródłowy dołączony do książki powinien zostać zaktualizowany przed pierwszym


uruchomieniem na innym komputerze, ze względu na podpisy cyfrowe oraz inne
ścieżki instalacji. Dokładny opis procedury znaleźć można w podrozdziale „Kompila-
cja i uruchamianie projektów dołączonych do książki”.

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

Prawdopodobną przyczyną są ustawienia zapory internetowej, inny program działają-


cy w tle na porcie 50000 lub 50001 lub brak odpowiednich uprawnień. Ostatnie zda-
rza się najczęściej. Rozwiązaniem problemu jest rezerwacja odpowiednich portów.
Możemy to zrobić z linii komend z uprawnieniami administratora:
248 Programowanie równoległe i asynchroniczne w C# 5.0

netsh http add urlacl url=http://127.0.0.1:50000/ user=UserName


netsh http add urlacl url=http://127.0.0.1:50001/ user=UserName

Możemy też skorzystać z HttpReserve.exe — narzędzia dostarczonego razem ze śro-


dowiskiem Microsoft Robotics:
HttpReserve.exe /r /p:50000 /x:127.0.0.1
HttpReserve.exe /r /p:50001 /x:127.0.0.1

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" />

Kompilacja i uruchamianie projektów


dołączonych do książki
Projekt Microsoft Robotics dołączony do książki powinien zostać zaktualizowany przed
pierwszym uruchomieniem na innym komputerze niż ten, na którym został przygoto-
wany. Dotyczy to każdego kodu przenoszonego pomiędzy różnymi komputerami. Wszyst-
kie serwisy korzystają z systemu podpisów i certyfikatów zapewniających bezpieczeń-
stwo i spójność użytych bibliotek. Do aktualizacji służy program DssProjectMigration.exe,
który znajduje się w folderze bin, w domyślnym miejscu instalacji środowiska Microsoft
Robotics. Warto skorzystać ze specjalnej linii komend, którą znajdziemy w Menu
Start w folderze Microsoft Robotics Developer Studio 4 (w Windows 8 na ekranie
Start wpisujemy po prostu DSS Command Prompt x64 lub DSS Command Prompt w przy-
padku środowiska 32-bitowego). W przypadku dwóch pierwszych projektów odpo-
wiednie komendy to:
C:\Users\UserName\Microsoft Robotics Dev Studio 4\bin>DssProjectMigration.exe
"c:\Users\UserName\Microsoft Robotics Dev Studio 4\Projects\VentilationService"
C:\Users\UserName\Microsoft Robotics Dev Studio 4\bin>DssProjectMigration.exe
"c:\Users\UserName\Microsoft Robotics Dev Studio 4\Projects\TemperatureService"

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

CCR i DSS w pigułce


Sprawdzimy najpierw działanie środowiska Robotics oraz bibliotek CCR i DSS w pro-
stych przykładach. Pokażemy m.in., że biblioteki CCR i DSS mogą być potraktowane
jako jeden komponent służący do równoległych, rozproszonych zadań sterowanych
zdarzeniami.

Czujniki i urządzenia — tworzenie pierwszej usługi


Wyobraźmy sobie, że musimy przygotować oprogramowanie dla inteligentnego budyn-
ku, które zapewni mu w miarę znośne warunki dla pracy farmy serwerów. Niestety,
ze względu na ograniczony budżet, jedyne, co dostajemy od naszego zleceniodawcy,
to dwa komputery sterujące. Jeden steruje systemem wentylacji i pozwala ustawić
wartość w zakresie od zera (brak wentylacji) do 100 (maksymalna wydajność systemu).
Drugi komputer odpowiada tylko za pomiar temperatury w pomieszczeniu i zwraca
uśrednioną wartość z kilku czujników. Przy tak postawionym zadaniu WCF mógłby
być dobrym wyborem. Jednak klient chciałby mieć także dostęp do całego systemu
z poziomu przeglądarki internetowej. Poszczególne operacje odczytu temperatury lub
ustawiania systemu wentylacji w określonym trybie mogą zająć sporo czasu, potrzebna
jest zatem asynchroniczność. Zadanie to może zostać łatwo wykonane z wykorzysta-
niem bibliotek CCR i DSS.

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.

Nie mówimy jednak tylko o zagadnieniach związanych z przemysłem. Małe i nie-


skomplikowane roboty stają się coraz bardziej powszechne i dostępne dla hobbystów.
Jednym z przykładów takiego produktu jest seria produkowana przez grupę Lego i na-
zwana Lego Mindstorms. Jest to zestaw prostych siłowników oraz czujników, które
mogą być programowane i zarządzane np. z wykorzystaniem Microsoft Robotics oraz
bibliotek CCR i DSS. Podstawowe komponenty zestawu to silniki, prosty komputer
i czujniki dotyku, odległości oraz światła. Można rozbudować taki zestaw o dodatkowe
250 Programowanie równoległe i asynchroniczne w C# 5.0

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.

Przystąpmy do utworzenia pierwszej usługi. W CCR i DSS usługi to biblioteki, które


będą ładowane przez program DssHost.exe. Sama biblioteka CCR tego nie wymaga
i możemy tworzyć samodzielne pliki wykonywalne, jednak jej pełen potencjał wyko-
rzystamy dopiero w środowisku DssHost integrującym zarządzanie, wykonanie i ob-
sługę błędów. Samo środowisko może być modyfikowane i w ogóle utworzone od zera,
ale to temat wykraczający poza ten wprowadzający opis.

W Visual Studio 2012 wybieramy polecenie utworzenia nowego projektu i szablon


Visual C#, Microsoft Robotics DSS Service (4.0). Nadajmy mu nazwę Temperature
Service (rysunek 12.5) i umieśćmy w utworzonym przez nas folderze projects, umiesz-
czonym w katalogu, w którym zainstalowany został Microsoft Robotics.

Rysunek 12.5. Tworzymy nasz pierwszy projekt wykorzystujący środowisko CCD i DSS na bazie
dostarczonego szablonu

Następnie klikamy przycisk OK i przechodzimy do kreatora, zbierającego dodatkowe


informacje. Domyślnie wybrane wartości są wystarczające do naszych celów (służą
przede wszystkim do jednoznacznej identyfikacji usług, z którymi będziemy chcieli
się komunikować). Przechodzimy zatem dalej, klikając OK.
Rozdział 12.  Wstęp do CCR i DSS 251

W tym momencie środowisko Microsoft Robotics utworzy projekt, który możemy


skompilować oraz uruchomić. Jest to nasz pierwszy program wykorzystujący biblio-
teki CCR i DSS. Po chwili na ekranie pojawi się okno konsoli programu hostującego
DssHost.exe, a skompilowana biblioteka zostanie załadowana, tworząc usługę dostępną
przy użyciu przeglądarki. W oknie konsoli może pojawić się żółty napis, ostrzeżenie,
że serwis, który próbujemy załadować, nie istnieje (rysunek 12.6). Może to mieć miejsce,
gdy po raz pierwszy uruchamiamy dany serwis. Nie jest on jeszcze widoczny i program
DssHost musi odświeżyć pamięć podręczną dostępnych usług. Przy kolejnych uru-
chomieniach komunikat już się nie pojawi.

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

Aby rozpocząć interakcję z usługą, musimy uruchomić przeglądarkę internetową. Ze


względu na to, iż cały protokół komunikacji, zdalnego wywoływania procedur i trans-
feru danych pomiędzy usługami oparty jest na DSSP (wspomniany DSS Protocol)
oraz komunikacji TCP/IP, najbardziej naturalnym sposobem interakcji jest właśnie
interfejs stron internetowych. Mimo to, nic nie stoi na przeszkodzie, aby interakcja
z użytkownikiem odbywała się za pomocą aplikacji desktopowych korzystających z bi-
bliotek Windows Forms lub WPF.

W wybranej przeglądarce internetowej wpisujemy adres: http://127.0.0.1:50000/.


Jest to adres panelu domowego środowiska i zarazem miejsce, w którym rozpoczy-
namy interakcję z środowiskiem Microsoft Robotics (rysunek 12.7).

Podczas pierwszego wejścia na stronę internetową zostaniemy poproszeni o podanie


hasła. Osoba uruchamiająca program DssHost.exe jest zawsze uznawana za upraw-
nioną do korzystania z panelu jako administrator. Kolejne osoby oraz ich uprawnienia
możemy zmienić, korzystając z linków znajdujących się po lewej stronie, a konkret-
niej z łącza do usługi zarządcy zabezpieczeń (Security Manager).

Wszystkie łącza widoczne na rysunku 12.7 w zakładce System Services to podstawo-


we usługi uruchamiane domyślnie przez program DssHost.exe. Każda z nich pełni
wyspecjalizowaną rolę zapewniającą podstawową funkcjonalność środowiska. Jest to
kolejno panel domowy (Home) wyświetlający informację o aktualnie działających usłu-
gach, panel kontrolny (Control Panel), który pozwala na tworzenie i usuwanie działają-
cych już usług z dostępnej puli, spis usług (Service Directory) zawierający listę dzia-
łających usług oraz ich wzajemne powiązania, a także usługa kontrolująca komunikaty,
ostrzeżenia i informacje o błędach (Debug and Trace Messages). Usługa odpowiedzialna
za ładowanie manifestów opisujących w opcjonalny sposób usługi ma także swoje łącze
252 Programowanie równoległe i asynchroniczne w C# 5.0

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

zawierający jej jednoznaczny identyfikator GUID (ang. Globally Unique Identifier).


Identyfikator ten pozwala na rozróżnienie wielu instancji usługi. W kolejnym etapie
prac zmienimy ten adres, usuwając z niego GUID, ponieważ będziemy uruchamiać
tylko jedną instancję usługi.

Zakończymy działanie programu, naciskając kombinację Ctrl+C w oknie DssHost lub


przerywając działanie programu w środowisku Visual Studio 2012 (np. przy użyciu
kombinacji klawiszy Ctrl+Shift+F5). Teraz przejdźmy do kodu. Projekt usługi zawiera
dwa ważne pliki. W naszym przypadku jest to TemperatureService.cs zawierający
funkcjonalność usługi oraz TemperatureServiceTypes.cs, który opisuje wykorzystywane
typy, komunikaty oraz stan usługi.

Wczytajmy do edytora plik TemperatureServiceTypes.cs i odszukajmy w nim klasę


TemperaturaServiceState opisującą stan usługi (listing 12.2). Dodajmy do niej wła-
sność Temperature z towarzyszącym jej polem temperature, która będzie wirtualnym
odczytem temperatury z hali, w jakiej trzymamy serwery. Odczyt temperatury będzie
widoczny w przeglądarce internetowej.

Listing 12.2. Dodawanie do stanu usługi nowej własności reprezentującej temperaturę


/// <summary>
/// TemperatureService state
/// </summary>
[DataContract]
public class TemperatureServiceState
{
private double temperature;
[DataMember]
public double Temperature
{
get { return temperature; }
set { temperature = value; }
}
}

Uruchamiamy ponownie tak zmodyfikowany serwis. W przeglądarce zobaczymy kod


XML zawierający informacje o zdefiniowanej przed chwilą własności temperatury (ry-
sunek 12.8). Najważniejszy jest fragment <Temperature>0</Temperature> zawierający
wartość temperatury, która równa jest zeru i nie zmienia się w czasie. Nie jest to jednak
zbyt czytelna informacja. Nasz zleceniodawca nie będzie zadowolony, jeżeli zmusimy go
do analizy pliku XML w poszukiwaniu potrzebnej mu wartości. Jedyne, czego naprawdę
oczekuje, to podana w czytelny sposób temperatura i może czas odczytu. Dodajmy
zatem do stanu usługi jeszcze czas odczytu (listing 12.3) oraz zajmijmy się kwestią
wyświetlania. Aby w przystępny i automatyczny sposób zająć się wyświetlaniem in-
formacji zawartych w pliku XML, skorzystamy z technologii obsługiwanej przez CCR
i DSS, a mianowicie XSLT (ang. Extensible Stylesheet Language Transformations).
W tym celu do naszego projektu dodajmy plik XSLT o nazwie TemperatureService.xslt
(rysunek 12.9).
254 Programowanie równoległe i asynchroniczne w C# 5.0

Rysunek 12.8. Ekran przeglądarki zawierający informację o temperaturze i standardowe nagłówki


protokołu DSSP

Listing 12.3. Dodawanie czasu odczytu temperatury jako nowej własności


[DataContract]
public class TemperatureServiceState
{
...
private DateTime time = DateTime.Now;
[DataMember]
public DateTime Time
{
get { return time; }
set { time = value; }
}

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

Transformata XSLT ma za zadanie przekształcić jeden dokument XML w drugi. W na-


szych przykładach wykorzystujemy ją do utworzenia stron internetowych reprezentują-
cych stan tworzonych serwisów. Jednak nic nie stoi na przeszkodzie, aby wykorzystać
takie transformaty do generowania zwykłych plików tekstowych, dokumentów Micro-
soft Office czy nawet grafiki wektorowej lub binarnej. W nowej, trzeciej już, odsło-
nie standardu z 2012 roku wprowadzono nawet możliwość modyfikowania stru-
mieni XML, co pozwala na łatwiejszą pracę z dużymi dokumentami i ich wydajniejszą
modyfikację.

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">

<xsl:import href="/resources/dss/Microsoft.Dss.Runtime.Home.MasterPage.xslt" />

<!-- Profilowanie szablonów -->


<xsl:template match="/">
<xsl:call-template name="MasterPage">
<xsl:with-param name="serviceName">
Temperature Service
</xsl:with-param>
<xsl:with-param name="description">
Represents the array of temperature sensors in the DSS environment.
</xsl:with-param>
</xsl:call-template>
</xsl:template>

<!-- Główna transformata pliku XML do formatu HTML -->


<xsl:template match="/tst:TemperatureServiceState">
<table>
<tr class="even">
<th colspan="2">Temperature Service</th>
</tr>
<tr class="odd">
<th>Time:</th>
<td>
<xsl:value-of select="tst:Time"/>
</td>
</tr>
<tr class="even">
<th>Temperature:</th>
<td>
<xsl:value-of select="format-number(tst:Temperature, '0.000')"/>
</td>
</tr>
256 Programowanie równoległe i asynchroniczne w C# 5.0

</table>
</xsl:template>
</xsl:stylesheet>

Warto zwrócić uwagę na fragment wyróżniony w listingu 12.4. Zawiera on informację


o unikatowym ciągu znaków, który jednoznacznie identyfikuje usługę. Zwyczajowo
przybiera formę adresu http, jednak nie musi tak być. Może to być dowolny unikatowy
ciąg znaków pozwalający na odróżnienie usługi. Zwyczajowo używane są jednak ad-
resy URL, pod którymi dostępne są materiały opisujące daną usługę.

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

/// Main service port


/// </summary>
[ServicePort("/TemperatureService", AllowMultipleInstances = false)]
TemperatureServiceOperations _mainPort = new TemperatureServiceOperations();

[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);
}
}
}

Przy okazji zmienimy także atrybut ServicePort pola TemperatureServiceOperations,


a konkretnie przełączymy jego parametr AllowMultipleInstances na false (wyróż-
nienie w listingu 12.5). Dzięki temu w środowisku DSS Host będzie mogła być uru-
chomiona tylko jedna instancja usługi. Główny port usługi zapewnia synchronizację
przetwarzania wymagających wyłączności wiadomości oraz współbieżne wykonanie
pozostałych.

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

Listing 12.6. Modyfikacje pliku TemperatureServiceTypes.cs dodające obsługę HttpGet


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 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; }
}

private double temperature;


[DataMember]
public double Temperature
{
get { return temperature; }
set { temperature = 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

/// <param name="responsePort">the response port for the request</param>


public Subscribe(SubscribeRequestType body, PortSet<SubscribeResponseType,
Fault> responsePort)
: base(body, responsePort)
{
}
}
}

Tak zmodyfikowane kody źródłowe możemy ponownie skompilować i uruchomić,


a następnie przejść do strony reprezentującej naszą usługę. Powinniśmy zobaczyć stronę
podobną do tej z rysunku 12.11. Usługa zaczyna coraz bardziej przypominać to, co
chcieliśmy osiągnąć; pomijamy tu fakt, że prezentowana przez nią temperatura stale
wynosi 0 stopni. Należałoby teraz „podłączyć” usługę do czujnika temperatury. Jednak
my skorzystamy z generatora liczb losowych, który będzie symulował czujnik, poda-
jąc losową temperaturę z zakresu od 10 do 40°C. Aby to uzyskać, wprowadzimy zmianę
do pliku TemperatureServiceTypes.cs i dodamy cykliczne komunikaty do listy przyj-
mowanych wiadomości (listing 12.7). W tym celu zmodyfikujemy definicję klasy ob-
sługującej główny port serwisu TemperatureServiceOperations, który przyjmuje dwa
nowe typy wiadomości odpowiedzialne za zastąpienie stanu serwisu Replace i wia-
domość odpowiedzialną za aktualizację odczytu temperatury UpdateTemperature.

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

Listing 12.7. Dwa nowe typy wiadomości Replace i UpdateTemperature


/// <summary>
/// TemperatureService main operations port
/// </summary>
[ServicePort]
public class TemperatureServiceOperations : PortSet<DsspDefaultLookup,
DsspDefaultDrop, Get, Subscribe, HttpGet, UpdateTemperature, Replace>
{
}
Rozdział 12.  Wstęp do CCR i DSS 261

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()
{
}

public Replace(TemperatureServiceState body)


: base(body)
{
}
}

/// < summary >


/// Temperature Service - update current temperature
/// < /summary >
public class UpdateTemperature : Submit<UpdateRequest,
PortSet<DefaultSubmitResponseType, Fault>>
{
public UpdateTemperature()
: base (new UpdateRequest())
{
}
}

[DataContract]
public class UpdateRequest { }

Z kolei w pliku TemperatureService.cs konieczne jest wprowadzenie zmian, które po-


zwolą na obsłużenie dwóch nowych wiadomości oraz inicjację procesu cyklicznego
odczytu wartości temperatury. Dwie metody obsługujące wiadomości UpdateRequest
oraz Replace pokazane są na listingu 12.9.

Listing 12.9. Dwie metody zapewniające poprawną obsługę przekazywanych wiadomości


UpdateRequest oraz Replace
/// <summary>
/// Exclusive Temperature Service state replace handler
/// </summary>

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

/// <param name="replace">The new service state</param>


/// <returns></returns>
[ServiceHandler(ServiceHandlerBehavior.Exclusive)]
public IEnumerator<ITask> ReplaceHandler(Replace replace)
{
_state = replace.Body;

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);

Console.WriteLine("New temperature readout: " + newState.Temperature.ToString("F3"));


// Set the timer for the next tick
Activate(
Arbiter.Receive(false, TimeoutPort(3000),
delegate(DateTime time)
{
_mainPort.Post(new UpdateTemperature());
}
));
request.ResponsePort.Post(DefaultSubmitResponseType.Instance);
yield break;
}

Przyjrzyjmy się trochę uważniej ostatnim modyfikacjom. W metodach obsługujących


wiadomości wykorzystujemy dwa rodzaje atrybutów, które informują środowisko
DSS o wymaganym poziomie synchronizacji:
 ServiceHandlerBehavior.Exclusive — tryb wyłączności,
 ServiceHandlerBehavior.Concurrent — tryb współbieżny.

W pierwszym przypadku kod działający z takim atrybutem wymaga wyłącznego do-


stępu do zasobów i może na nich bezpośrednio operować, bez obawy o interferencję
innych wątków. W drugim przypadku DSS postara się przetworzyć jak największą
ilość komunikatów współbieżnie, korzystając z dostępnych wątków. Możemy użyć
ustawień domyślnych środowiska lub w wyjątkowych sytuacjach próbować je opty-
Rozdział 12.  Wstęp do CCR i DSS 263

malizować samodzielnie poprzez modyfikację pliku konfiguracyjnego lub odpowied-


nie polecenia umieszczone w kodzie usługi. W trybie wyłącznym żadna wiadomość
nie zostanie wykonana do czasu zakończenia obsługi wszystkich wcześniej otrzyma-
nych wiadomości. Wszystkie wiadomości, które odebrane zostaną w trakcie wyłącznej
obsługi wiadomości, zostaną umieszczone w kolejce i wykonane dopiero po zakończe-
niu, zgodnie z kolejnością przybycia. Sam proces wysyłania i kolejkowania wiadomo-
ści jest w pełni bezpieczny z wielowątkowego punktu widzenia. Jeżeli do wykonywa-
nia zadań asynchronicznych wykorzystujemy różne techniki, CCR i DSS zapewniają
synchronizację i bezpieczną modyfikację własności oraz zmiennych podczas wysyłania,
odbierania i przetwarzania wiadomości definiowanych przez ich środowisko. Jeśli zatem
postanowimy skorzystać z mechanizmów TPL, sami musimy zapewnić synchronizację
pomiędzy technologiami TPL i CCR/DSS, tak aby nie naruszyć zabezpieczeń CCR i DSS.

Inną ważną koncepcją jest zwracanie przez metodę UpdateTemperatureHandler instan-


cji IEnumerator<ITask> oraz użycie w niej poleceń yield oraz yield break. Zapew-
niają one, że wykonywana metoda jest nieblokująca, w sytuacji gdy wykonywane są
fragmenty kodu, które mogą zająć dłuższy okres czasu i są oznaczone właśnie przez
yield. Aby w takim przypadku wątek wykonujący kod nie musiał czekać na powrót
metody, możemy zastosować polecenie yield. W tym momencie wątek jest zwalniany
i może wykonywać inne zadania, aż do momentu zakończenia metody, przed którą
znalazło się yield. Jest to zachowanie zapewniane przez bibliotekę CCR. Metoda
ostatecznie kończy działanie w linijce zwierającej yield break. Z punktu widzenia kodu,
jego wykonanie jest wstrzymywane na jakiś czas, a potem wznawiane, tak jakby wy-
konywał się sekwencyjnie, aż do dotarcia do yield break. Cała koncepcja w Microsoft
Robotics oparta jest na iteratorach, wprowadzonych do języka C# już w wersji 2.0, i opi-
sana w książce z 2008 roku autorstwa Kyle’a Johnsa i Trevora Taylora Microsoft Ro-
botics Developer Studio, wydanej przez Wiley Publishing, Inc.

Metoda UpdateTemperatureHandler zawiera także wywołanie metody Activate (li-


sting 12.10), która zapewnia uruchomienie kodu wskazanego w delegacji po wskazanym
okresie czasu, niezależnie od tego, czy macierzysta usługa nadal działa. Używana jest
do tego klasa Arbiter, która jest „strażnikiem” kontrolującym kolejność wykonywania
zadań za pomocą filtrowania wiadomości przychodzących, zgodnie z ich przeznacze-
niem. Jest to podstawowa klasa zapewniająca koordynację współbieżnego wykonania
w bibliotekach CCR i DSS; w połączeniu z metodą TimeoutPort pozwala na wyko-
nywanie zadań w precyzyjnie określonym momencie.

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

Na zakończenie wywoływana jest metoda ResponsePort.Post, która powiadamia


nadawcę o poprawnym przetworzeniu wiadomości. Gdyby doszło do zgłoszenia wy-
jątku, nadawca otrzymałby pełną informację o nim, a sam Dss Host zarejestrowałby
wystąpienie błędu.

Aby wywołać metodę odpowiedzialną za rejestrację temperatury, zmodyfikujemy metodę


Start (listing 12.11) usługi, dodając pierwszą wiadomość do kolejki komunikatów.

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());
}

Ta ostatnia zmiana kończy przygotowanie naszej pierwszej usługi, a zarazem pierw-


szego programu wykorzystującego technologie CCR i DSS. Rezultat naszej pracy po
uruchomieniu projektu powinien być taki, jak na rysunku 12.12. Naszym celem było
przede wszystkim pokazanie podstawowych założeń architektury oprogramowania asyn-
chronicznego i rozproszonego opartego na technologiach CCR i DSS. Warto zwrócić
uwagę, że praktycznie za darmo otrzymujemy interfejs sieciowy dla programu, zuni-
fikowaną obsługę błędów, bezpieczeństwa, kontroli dostępu i filtrowania oraz panel
internetowy uruchamiania i rejestracji usług czy subskrypcji, a także mechanizm two-
rzenia i usuwania usług partnerskich. Możemy też wyświetlać proste informacje w oknie
konsoli wykorzystywanym przez DSS Host. Obsługa błędów, łatwość ich rejestracji
i przechwytywania, nawet wtedy gdy występują na zdalnym komputerze, to duża zaleta
prezentowanego rozwiązania. Cała komunikacja i interakcja pomiędzy usługami jest
oparta o adresy IP, porty oraz protokół TCP/IP. Ponadto synchronizacja dostępu, jaką
otrzymujemy, preferuje model, w którym możemy mieć wielu czytelników zasobów
i ich jednoczesny odczyt może następować z wielu wątków jednocześnie. Jedynie w sy-
tuacji zapisu dostęp jest blokowany. Jest to ważna zaleta, w porównaniu np. z bardzo
popularnym wykorzystaniem metody lock do synchronizacji kodu, gdzie zapis i od-
czyt są operacjami blokującymi. Ponadto gdy program albo nasza usługa muszą zo-
stać szybko wyłączone, pojawia się jeszcze trzeci typ wiadomości obsługującej ten
scenariusz. Ma on wyższy priorytet w stosunku do pozostałych aktualnie obsługiwanych
i oczekujących na obsługę wiadomości. Jest to polecenie Drop obsługujące wiadomość
DsspDefaultDrop, a metoda, która nim zawiaduje, posiada atrybut ServiceHandler
Behavior.Teardown. Jest ona automatycznie implementowana i wymaga modyfikacji
tylko podczas korzystania z niezarządzanych zasobów.
Rozdział 12.  Wstęp do CCR i DSS 265

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ą.

Do domyślnych ustawień, podawanych w trakcie tworzenia projektu, dodajmy informację


o partnerze, którym będzie utworzona przez nas wcześniej usługa TemperatureService.
W tym celu:
1. Przejdź na zakładkę Partners kreatora nowej usługi DSS (rysunek 12.13).

Rysunek 12.13.
Wygląd okna
konfiguracyjnego
nowego serwisu
po wprowadzonych
zmianach
266 Programowanie równoległe i asynchroniczne w C# 5.0

2. Z dostępnej listy usług wybierz TemperatureService i kliknij przycisk Add as


partner.
3. Następnie zaznacz opcję Add notification port.

Te ustawienia spowodują, że podczas uruchamiania usługi automatycznie zostanie


włączona usługa partnerska, jeśli już nie jest uruchomiona, bo jeżeli jest — zostanie
użyta jej istniejąca instancja. Powyższa zmiana w ustawieniach usługi spowoduje różnice
w pliku VentilationService.cs tworzonego projektu względem podstawowego szablonu,
który mogliśmy zobaczyć po utworzeniu projektu TemperatureService. Główna różnica
została zaznaczona na listingu 12.12 i wprowadzona na podstawie ustawień podanych
podczas tworzenia projektu.

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);
}
}
}

Atrybut Partner pola prywatnego temperatureServicePort informuje środowisko o za-


mierzonej interakcji z inną usługą. Druga zmienna tego samego typu, ale zawierająca
w nazwie „Notify” (z ang. powiadamiać), służy do odbierania wiadomości od usługi-
partnera. Koncepcja powiadomień oparta jest na systemie subskrypcji. Usługa wysyłają-
ca powiadomienie o wybranych zmianach korzysta z komendy SendNotification, która
powoduje przesłanie wiadomości do systemowej usługi środowiska Dss Host zajmują-
cej się rozgłaszaniem (ang. broadcast) uzyskanej wiadomości do wszystkich subskry-
bentów (jeżeli istnieją). W ten sposób otrzymujemy pełną separację działania serwisu
zajmującego się monitorowaniem temperatury i usługi odpowiadającej za wentylację.
W rzeczywistości nie korzystamy nawet z wcześniej utworzonej biblioteki Tempera-
tureService.Y2013.M01.dll, a jedynie z zastępcy tej biblioteki zawierającego minimum
potrzebnych do komunikacji informacji, czyli z TemperatureService.Y2013.M01.Proxy.dll.
Jest to jeden z plików generowanych po kompilacji usługi z wykorzystaniem programu
DssProxy.exe, dostarczany wraz z środowiskiem Microsoft Robotics i automatycznie
włączany do wykonania w konfiguracji projektu każdego nowo tworzonego serwisu.
268 Programowanie równoległe i asynchroniczne w C# 5.0

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.

Podobnie jak w poprzedniej usłudze, zdefiniujemy w klasie opisującej stan serwisu


VentilationServiceState dwie własności. Będzie to wydajność wentylacji Ventilation
Efficiency i czas Time. Następnie utworzymy plik XSLT zapewniający poprawne ich
wyświetlanie w przeglądarce. Potrzebne modyfikacje w pliku VentilationService.cs
zamieściliśmy na listingu 12.13, w pliku VentilationServiceTypes.cs — na listingu 12.14,
a nową transformatę XSLT — na listingu 12.15. Należy także pamiętać o zaznaczeniu
VentilationService.xslt jako wbudowanego zasobu w opcjach pliku (rysunek 12.10). Po
uruchomieniu usługi rezultat jej działania powinien być podobny do tego z rysunku 12.14.

Listing 12.13. Obsługa transformaty XSLT w pliku VentilationService.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;
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(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

get { return time; }


set { time = value; }
}

private double ventilationEfficiency;


[DataMember]
public double VentilationEfficiency
{
get { return ventilationEfficiency; }
set { ventilationEfficiency = value; }
}
}

/// <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

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)
{
}
}
}

Listing 12.15. Zawartość pliku XSLT zawierającego transformatę stanu


<?xml version="1.0" encoding="UTF-8" ?>
<xsl:stylesheet version="1.0"
xmlns:xsl="http://www.w3.org/1999/XSL/Transform"

xmlns:vst="http://schemas.tempuri.org/2013/02/ventilationservice.html">

<xsl:import href="/resources/dss/Microsoft.Dss.Runtime.Home.MasterPage.xslt" />


<xsl:template match="/">
<xsl:call-template name="MasterPage">
<xsl:with-param name="serviceName">
Ventilation Service
</xsl:with-param>
<xsl:with-param name="description">
Represents the ventilation system in the DSS environment.
</xsl:with-param>
</xsl:call-template>
</xsl:template>

<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>

Rysunek 12.14. Okno serwisu obsługującego wentylację po pierwszych modyfikacjach kodu

Załóżmy następujący model fizyczny wymiany ciepła w naszym wirtualnym budyn-


ku: sprzęt komputerowy ustawiony w hali generuje stałą ilość ciepła i wentylacja, gdy
pracuje z pełną wydajnością, jest w stanie go schłodzić do temperatury o 5°C mniej-
szej niż temperatura otoczenia. Kiedy wentylacja jest wyłączona, sprzęt ogrzewa się
do temperatury o 5°C wyższej niż otoczenie. Celem całego systemu jest natomiast
utrzymanie urządzeń w optymalnej dla nich temperaturze 20°C.

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

ją jako wymagającą wyłączności poprzez dodanie atrybutu ServiceHandlerBehavior.


Exclusive. Zapobiegnie to konfliktowi, w którym zapis i odczyt mogłyby nastąpić
jednocześnie i doprowadzić do niespójności danych. Poprawne wyświetlanie stanu
serwisu w oknie przeglądarki wymaga drobnej modyfikacji pliku XSLT, wyróżnionej
w listingu 12.15.

Listing 12.16. Obsługa subskrypcji oraz logiki wentylacji w pliku VentilationService.cs


...
protected override void Start()
{
SubscribeRequestType subscribeRequest = new SubscribeRequestType();
try
{
subscribeRequest.TypeFilter = new string[]
{
GetTypeFilterDescription(typeof(temperatureservice.Replace))
};
}
catch (Exception e)
{
LogError(e);
}
temperatureservice.Subscribe subscribeTemperature = new
temperatureservice.Subscribe(subscribeRequest);
subscribeTemperature.NotificationPort = _temperatureServiceNotify;
_temperatureServicePort.Post(subscribeTemperature);

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

Listing 12.17. Dwie nowe własności AmbientTemperature i HardwareTemperature w pliku


VentilationServiceTypes.cs
...
/// <summary>
/// VentilationService state
/// </summary>
[DataContract]
public class VentilationServiceState
{
private DateTime time = DateTime.Now;
[DataMember]
public DateTime Time
{
get { return time; }
set { time = value; }
}

private double ventilationEfficiency;


[DataMember]
public double VentilationEfficiency
{
get { return ventilationEfficiency; }
set { ventilationEfficiency = value; }
}

private double ambientTemperature;


[DataMember]
public double AmbientTemperature
{
get { return ambientTemperature; }
set { ambientTemperature = value; }
}

private double hardwareTemperature;


[DataMember]
public double HardwareTemperature
{
get { return hardwareTemperature; }
set { hardwareTemperature = value; }
}
}

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

Ostateczny rezultat naszej pracy możemy obejrzeć po uruchomieniu usługi w przeglą-


darce internetowej (rysunek 12.16). Powinniśmy zobaczyć, że wydajność wentylacji jest
automatycznie regulowana — korekty następują po każdym odebraniu nowej wartości
temperatury z czujników monitorujących temperaturę za pośrednictwem usługi partnera.

Rysunek 12.16.
Okno serwisu
obsługującego
wentylację po
ostatecznych
modyfikacjach kodu
276 Programowanie równoległe i asynchroniczne w C# 5.0

Bardzo bogatym źródłem wiedzy oraz pomocy dotyczącej przygotowywania usług


wykorzystujących technologie CCR i DSS jest forum użytkowników i twórców
Microsoft Robotics, które jest dostępne w internecie pod adresem: http://social.
msdn.microsoft.com/Forums/en-US/category/robotics. Jest to forum często odwie-
dzane przez osoby znające środowisko od podszewki i skarbnica wiedzy o nie zaw-
sze udokumentowanych zastosowaniach czy rozwiązaniach CCR i DSS.
Rozdział 13.
Skalowalne rozwiązanie
dla systemów
rozproszonych na bazie
technologii CCR i DSS
Piotr Sybilski, Rafał Pawłaszek

Poznaliśmy już podstawy tworzenia usług w środowisku Microsoft Robotics z wyko-


rzystaniem bibliotek CCR i DSS. W dwóch kolejnych projektach pokażemy, w jaki
sposób można wykorzystać te biblioteki do rozproszonych obliczeń. Nie jest to może
najczęstsze zastosowanie tych technologii, ale sprawdza się bardzo dobrze. Dzięki
łatwości w skalowaniu oraz łatwemu rozpraszaniu usług po węzłach obliczeniowych jest
to doskonałe narzędzie do tworzenia klastrów obliczeniowych, do ich zarządzania oraz
synchronizacji. Oczywiście, nic nie stoi na przeszkodzie, abyśmy mogli na konkret-
nych węzłach klastra zrównoleglić dodatkowo obliczenia, korzystając z wątków lub
biblioteki TPL opisanych w poprzednich rozdziałach. CCR i DSS doskonale spraw-
dzą się podczas synchronizacji wyników z poszczególnych klastrów.

W naszym przykładzie użyjemy kosztownej czasowo metody obliczającej przybli-


żenie wartości π o nazwie CalculatePi (listing 2.1 z rozdziału 2.). Zbudujemy usługę
CalculateService, która na żądanie wykona obliczenia z zadanymi parametrami, oraz
serwis zlecający i koordynujący obliczenia o nazwie SpawnService. Uwzględnimy fakt,
że liczba usług wykonujących zadania nie jest określona z góry. Jej wartość, a więc liczba
instancji usługi CalculateService, zostanie określona przez stałą numberOfCalculators
zdefiniowaną w usłudze koordynującej Jak wspominaliśmy, usługi wykonujące oblicze-
nia zostaną uruchomione przy użyciu usługi-zarządcy, który po ich utworzeniu podłączy
odpowiednie porty oraz subskrypcje potrzebne do wysyłania żądań obliczeniowych
Calculate oraz odbierania wyników Replace.
278 Programowanie równoległe i asynchroniczne w C# 5.0

Zacznijmy od zaprojektowania usługi CalculateService służącej do przeprowadzania


obliczeń. Będzie to standardowy projekt typu DSS Service. Na stan tej usługi będzie
składała się tylko własność udostępniająca wynik obliczeń, czyli przybliżoną wartość
liczby π (listing 13.1). Natomiast dodatkowe interakcje obsługiwane przez usługę
wymagać będą trzech wartości zdefiniowanych w klasie CalculateServiceOperations.
Jedna z tych interakcji, a konkretnie klasa HttpGet, była już wcześniej używana (należy
pamiętać, że wymaga ona dodania przestrzeni nazw Microsoft.Dss.Core.DsspHttp).
Dwie pozostałe to Replace i Calculate. Konieczna będzie ich samodzielna imple-
mentacja. Pokazujemy to na listingu 13.1. Nie ma w tym kodzie jakościowych nowości,
w porównaniu z wcześniej omawianym przypadkiem mechanizmu tworzenia wiado-
mości dla powiadamiania w serwisie TemperatureService.

Listing 13.1. Własności dodane do stanu serwisu w pliku CalculateServiceTypes.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 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

/// CalculateService main operations port


/// </summary>
[ServicePort]
public class CalculateServiceOperations : PortSet<DsspDefaultLookup,
DsspDefaultDrop, Get, Subscribe, Replace, Calculate, HttpGet>
{
}

/// < summary >


/// Calculate Service - replace the current state
/// < /summary >
public class Replace : Replace<CalculateServiceState,
PortSet<DefaultReplaceResponseType, Fault>>
{
public Replace()
{
}

public Replace(CalculateServiceState body)


: base(body)
{
}
}

/// < summary >


/// Calculate Service - calculates the Pi
/// < /summary >
public class Calculate : Replace<CalculationParameters,
PortSet<DefaultReplaceResponseType, Fault>>
{
public Calculate()
{
}

public Calculate(CalculationParameters body)


: base(body)
{
}
}

/// <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);
}

private double CalculatePi(long numberOfTrials, int seed)


{
Random rnd = new Random(seed);
double x, y;
long numberOfHits = 0;
for (int i = 0; i < numberOfTrials; ++i)
{
Rozdział 13.  Skalowalne rozwiązanie dla systemów rozproszonych 283

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:import href="/resources/dss/Microsoft.Dss.Runtime.Home.MasterPage.xslt" />


<xsl:template match="/">
<xsl:call-template name="MasterPage">
<xsl:with-param name="serviceName">
Calculate Service
</xsl:with-param>
<xsl:with-param name="description">
Represents the calculate service in the DSS environment.
</xsl:with-param>
</xsl:call-template>
</xsl:template>

<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

Widok okna przeglądarki po uruchomieniu usługi służącej do obliczeń jest widoczny


na rysunku 13.1. Ze względu na to, iż usługa ta nie wykonała jeszcze żadnego zadania
obliczeniowego, prezentowany w przeglądarce wynik ostatnich obliczeń równy jest
zeru. W pasku adresu przeglądarki widoczna jest długa nazwa zawierająca unikatowy
identyfikator usługi. Jak zobaczymy dalej w tym rozdziale, tworząc wiele usług tego
samego typu, możemy im nadać bardziej przyjazne nazwy. Będziemy jednak musieli
zrobić to samodzielnie.

Rysunek 13.1.
Widok strony internetowej
wyświetlanej dla serwisu
obliczeniowego
CalculateService

Teraz zaprojektujmy usługę o nazwie SpawnService odpowiedzialną za tworzenie i koor-


dynację obliczeń. Tworząc ją, nie wskażemy jednak żadnych usług-partnerów. Usługi
te będziemy tworzyć i łączyć się z nimi dynamicznie (z poziomu kodu). Do rozwiązania
dodajemy projekt typu DSS Service (4.0) o nazwie SpawnService. Po jego utworzeniu
przechodzimy do edycji pliku SpawnServiceTypes.cs. Listę obsługiwanych wiadomości
uzupełnijmy o klasę HttpGet. Zdefiniujmy także listę wyników obliczeń typu List
<double>, w której będziemy umieszczać przybliżenia liczby π uzyskiwane od usług
wykonujących obliczenia. Wszystkie zmiany zostały wyróżnione w listingu 13.4.

Listing 13.4. Modyfikacje pliku do obsługi wyświetlania oraz przechowywania danych


SpawnServiceTypes.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 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)
{
}
}
}

W głównym pliku projektu, o nazwie SpawnService.cs, opisującym funkcjonalność ser-


wisu będziemy musieli wprowadzić poważniejsze modyfikacje. Zanim do nich przej-
dziemy, dodajmy do referencji projektu plik biblioteki CalculateService.Y2013.M02.
Proxy.dll usługi obliczeniowej. Uzyskamy to, klikając prawym przyciskiem myszy
w podoknie Solution Explorer na folder References (rysunek 13.2). Z rozwijanej listy
wybieramy opcję Add Reference i w oknie dialogowym klikamy przycisk przeszuki-
wania (ang. Browse) w celu znalezienia naszej biblioteki. Znajduje się ona w folderze
Rozdział 13.  Skalowalne rozwiązanie dla systemów rozproszonych 287

domyślnej instalacji środowiska Microsoft Robotics, w podfolderze bin, jeśli —


oczywiście — wcześniej został zbudowany serwis CalculateService. Należy pamię-
tać o wyborze referencji właśnie w taki sposób, a nie poprzez dodanie referencji do
projektu, ponieważ nasza usługa nie będzie działać. W folderze bin wyszukujemy plik
CalculateService.Y2013.M02.Proxy.dll i dołączamy go do naszego rozwiązania. Jak
wcześniej uprzedzaliśmy, rok i miesiąc zapisany w nazwie biblioteki mogą być inne.
Drugą ważną rzeczą jest to, że używamy pliku zawierającego w nazwie „Proxy”, a nie
samej biblioteki bazowej CalculateService.Y2013.M02.dll, ponieważ wszystkie usługi
komunikują się ze sobą na podstawie klas zawierających same kontrakty, na które
składa się jedynie opis metod oraz własności, bez ich implementacji.

Rysunek 13.2.
Dodawanie referencji
do serwisu obsługującego
obliczenia

Następnie, na początku pliku SpawnService.cs definiujemy alias do klasy Proxy usługi


CalculateService:
using calc = CalculateService.Proxy;

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;

using calc = CalculateService.Proxy;

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();

private const int numberOfCalculators = 3;


private string[] calculatorUris = new string[numberOfCalculators];
private calc.CalculateServiceOperations[] calculatorPorts = new
calc.CalculateServiceOperations[numberOfCalculators];
private calc.CalculateServiceOperations calculatorNotify = new
calc.CalculateServiceOperations();

/// <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

for (int i = 0; i < numberOfCalculators; i++)


{
calculatorUris[i] = @"http://localhost/calculate" + i.ToString();
ServiceInfoType serviceInfo = new
ServiceInfoType(calc.Contract.Identifier, calculatorUris[i]);
CreateService(serviceInfo);
}

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);
}
}
}

Dynamiczne tworzenie i podłączenie usług polega tutaj na zapisaniu ich identyfikatorów


w tablicy calculatorUris. Identyfikatory te są przygotowywane przez nas w oparciu
o wiedzę na temat węzła, na którym mają być utworzone, w tym przypadku localhost,
oraz z nazwą, jaką chcemy im nadać, czyli calculateN, gdzie N to numer serwisu ob-
liczeniowego. Do utworzenia usługi używamy metody SpawnService.CreateService,
która została zdefiniowana w klasie bazowej DsspServiceBase. Jest to klasa bazowa
klasy usługi. Do powiadomień wykorzystujemy tylko jeden zestaw portów zdefinio-
wany w zmiennej calculatorNotify. To wystarczy, bo wszystkie subskrypcje będą
tego samego typu. Wszystkie operacje na utworzonych usługach obliczeniowych wy-
konujemy w pętli for (listingi 13.5 i 13.6), zatem nic nie stoi na przeszkodzie, aby
zmieniać ilość tworzonych usług. Liczba ta powinna być dopasowana do liczby rdzeni
procesora i zwyczajowo jest to liczba tych rdzeni pomnożona przez dwa. Większa ilość
usług spowoduje zmniejszenie wydajności, gdyż wzrośnie koszt tworzenia usług, za-
rządzania nimi i ich synchronizacji.

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

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;
}
}

private void ConnectToPartners()


{
SubscribeRequestType subscribeRequest = new SubscribeRequestType();
try
{
subscribeRequest.TypeFilter = new string[]
{
GetTypeFilterDescription(typeof(calc.Replace))
};
}
catch (Exception e)
{
LogError(e);
}
calc.Subscribe subscribeCalculator = new
calc.Subscribe(subscribeRequest);
subscribeCalculator.NotificationPort = calculatorNotify;
for (int i = 0; i < numberOfCalculators; i++)
{
calculatorPorts[i] = ServiceForwarder
<calc.CalculateServiceOperations>(calculatorUris[i]);
calculatorPorts[i].Post(subscribeCalculator);
LogInfo("Connected to the Service Calculate" + i.ToString());
}

Console.WriteLine("All Services connected.");


MainPortInterleave.CombineWith(new Interleave(
new TeardownReceiverGroup(),
new ExclusiveReceiverGroup
(
Arbiter.Receive<calc.Replace>(true, calculatorNotify,
CalculationFinishedHandler)
),
new ConcurrentReceiverGroup()));
}

private void CalculationFinishedHandler(calc.Replace replaceMessage)


{
_state.CalculationList.Add(replaceMessage.Body.CalculationResult);
}
Rozdział 13.  Skalowalne rozwiązanie dla systemów rozproszonych 291

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ę.

Warto zwrócić uwagę na to, iż same obliczenia wykonywane są w usłudze Calculate


Service metodą CalculatePi, która może być wykonywana jednocześnie z innymi
operacjami równoległymi. Natomiast wysyłanie powiadomienia o zakończeniu obliczeń,
które otrzyma usługa zarządcy, umieszczone jest we fragmencie kodu odpowiedzial-
nym za modyfikację stanu usługi ReplaceHandler, a zatem musi być opatrzone klau-
zulą wyłączności. W efekcie środowisko CCR i DSS zaczeka na zakończenie wątków
równoległych i dopiero wtedy zaktualizuje stan usługi. Moglibyśmy natychmiast po-
informować zarządcę o zakończeniu zadania, wysyłając powiadomienie już w kodzie
wykonywanym współbieżnie CalculationFinishedHandler. Ta prosta modyfikacja
byłaby szczególnie potrzebna w sytuacji, gdy zadania krótkie i bardzo długie liczone
byłyby jednocześnie. Wtedy jednak warto skorzystać z innego mechanizmu synchro-
nizacji w danej usłudze, aby nie czekać na zakończenie długo trwających zadań, albo
w ogóle zrezygnować z modyfikacji stanu usługi po zakończeniu obliczeń.

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

Uruchamianie obliczeń na klastrze


W prosty sposób możemy zmienić istniejący serwis SpawnService tak, aby mógł uru-
chamiać obliczenia na innych węzłach, nawet takich, które działają na innych kom-
puterach. Aby wykonać tę zmianę, musimy przygotować pakiet dystrybucyjny serwisu
obliczeniowego CalculateService. Posłużymy się do tego konsolą DSS Command
Prompt. W jej oknie należy wpisać i wykonać następujące polecenia:
cd bin
DssDeploy.exe /p /a:"C:\Users\UserName\Microsoft Robotics Dev Studio 4\bin\
CalculateService.Y2013.M02.dll" CalculateInstall.exe

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"/>

i zamienić w niej wartość false na true, tak aby wyglądała następująco:


<add key="Microsoft.Dss.Services.Transports.AllowUnsecuredRemoteAccess" value="true"/>

Na zdalnym komputerze należy z linii komend, najlepiej włączonej z podwyższonymi


uprawnieniami, uruchomić program DssHost.exe z następującymi parametrami:
C:\dss\bin>DssHost.exe /p:50000 /h:192.168.1.101

C:\dss\bin to folder, do którego został wypakowany program DssHost.exe oraz nasza


usługa, a podany adres IP to adres maszyny, na której został uruchomiony program.
Tak uruchomiony węzeł będzie oczekiwał na nawiązanie komunikacji z usługą odpo-
wiedzialną za dystrybucję zadań. Na drugim węźle uruchomimy nieco zmodyfikowaną
usługę. Aby komunikacja przebiegła sprawnie i bez problemów ze strony zabezpieczeń,
powinniśmy oba węzły uruchomić z linii komend z uprawnieniami administratora. Na
jednym komputerze będzie to wyżej wspomniana komenda, a na drugim:
C:\Users\UserName\Microsoft Robotics Dev Studio 4\bin>DssHost.exe /p:50000
/dll:SpawnService.Y2013.M02.dll

Ponadto powinniśmy wyłączyć wbudowane zabezpieczenia środowiska Microsoft


Robotics. W tym celu w panelu nawigacyjnym dostępnym przez przeglądarkę (rysu-
nek 13.3) klikamy łącze Security Manager. Zobaczymy okno zarządzania zabezpie-
czeniami, podobne do widocznego na rysunku 13.4. Klikamy przycisk Edit Mode, aby
tymczasowo wyłączyć zabezpieczenia. Pojawi się strona internetowa widoczna na ry-
sunku 13.5, gdzie będziemy musieli kliknąć przycisk Disable. Operację tę należy wy-
konać na obu węzłach biorących udział w obliczeniach. Program DssHost.exe musi
zostać uruchomiony ponownie po zmianie parametrów zabezpieczeń. Taka korekta
294 Programowanie równoległe i asynchroniczne w C# 5.0

Rysunek 13.4.
Widok strony internetowej
z wybranym panelem
Security Manager
z bocznej listy łączy

ustawień zabezpieczeń jest wykonywana jedynie w celach demonstracyjnych i nie


musi być robiona w środowisku produkcyjnym, gdzie zabezpieczenia obu maszyn
oraz środowisk mogą zostać skonfigurowane odpowiednio, aby zapewnić i bezpie-
czeństwo, i funkcjonalność.

Wszystkie wymagane modyfikacje kodu usługi SpawnService, konieczne do urucho-


mienia serwisów na różnych komputerach, dotyczą pliku SpawnService.cs i są wy-
różnione na listingu 13.8. Najważniejsza z nich to zastąpienie sieciowego węzła lo-
kalnego localhost adresem IP (np. 192.168.1.101). W tym miejscu czytelnicy powinni
— oczywiście — użyć adresu maszyny, na której będą wykonywane obliczenia. Druga
ważna modyfikacja to użycie usługi tworzącej na zdalnej maszynie, co następuje poprzez
podanie odpowiedniego adresu w kodzie, np. http://158.75.101.74:50000/constructor.
Ostatnia modyfikacja dotyczy subskrypcji. Wykonujemy ją tutaj w uproszczony spo-
sób za pomocą metody Subscribe na porcie każdej usługi obliczającej. Parametrem tej
metody jest port nasłuchujący po stronie usługi SpawnService.

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;

using calc = CalculateService.Proxy;


Rozdział 13.  Skalowalne rozwiązanie dla systemów rozproszonych 295

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();

private const int numberOfCalculators = 3;


private string[] calculatorUris = new string[numberOfCalculators];
private calc.CalculateServiceOperations[] calculatorPorts = new
calc.CalculateServiceOperations[numberOfCalculators];
private calc.CalculateServiceOperations calculatorNotify = new
calc.CalculateServiceOperations();
296 Programowanie równoległe i asynchroniczne w C# 5.0

/// <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);
}
));
}

private void SpawnCalculations(int calculationNumber)


Rozdział 13.  Skalowalne rozwiązanie dla systemów rozproszonych 297

{
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;
}
}

private void ConnectToPartners()


{
for (int i = 0; i < numberOfCalculators; i++)
{
calculatorPorts[i] = ServiceForwarder<calc.
CalculateServiceOperations>(calculatorUris[i]);

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());
}

Console.WriteLine("All Services connected.");


MainPortInterleave.CombineWith(new Interleave(
new TeardownReceiverGroup(),
new ExclusiveReceiverGroup
(
Arbiter.Receive<calc.Replace>(true, calculatorNotify,
CalculationFinishedHandler)
),
new ConcurrentReceiverGroup()));
}

private void CalculationFinishedHandler(calc.Replace replaceMessage)


{
_state.CalculationList.Add(replaceMessage.Body.CalculationResult);
}

/// <summary>
298 Programowanie równoległe i asynchroniczne w C# 5.0

/// Handles Subscribe messages


/// </summary>
/// <param name="subscribe">the subscribe request</param>
[ServiceHandler]
public void SubscribeHandler(Subscribe subscribe)
{
SubscribeHelper(_submgrPort, subscribe.Body, subscribe.ResponsePort);
}
}
}

Wynik działania kodu będzie widoczny na obu węzłach, a szczególnie na ekranie


usługi inicjującej i zbierającej wyniki SpawnService. Na rysunku 13.6 prezentujemy
jeden z typowych wyników, jaki powinniśmy zobaczyć.

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

w kontekście obliczeń równoległych na systemach wielordzeniowych jest publikacja


High Performance Multi-Paradigm Messaging Run Time on Multicore Systems1 z 2008
roku czwórki autorów: Xiaohonga Qiu, Geoffreya Foksa, George’a Chrysanthako-
poulosa i Henrika Frystyka Nielsena, wydana przez Indiana University. Ta publikacja
w szczegółach opisuje narzut czasowy i obliczeniowy bibliotek CCR i DSS w typowym
scenariuszu intensywnie wykorzystującym zasoby oraz porównuje z wynikami osią-
ganymi przez MPI (ang. Message Passing Interface).

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

Współczesne aplikacje coraz częściej skoncentrowane są na zmieniających się danych.


Dane rynkowe, różnego rodzaju notyfikacje, takie jak informacje dotyczące aktualnego
stanu serwerów, nowe wiadomości z portali typu social-media pojawiają się niezależnie
od działania programu, a użytkownik nie ma zamiaru cały czas dopytywać się o nie,
lecz chce być informowany o nich na bieżąco. Informacje powinny być aktualne,
a w trakcie ich pobierania, transformowania, analizowania cały program musi być
gotowy do przyjęcia innych zadań. Istotna dla takich aplikacji jest możliwość reagowania
na działania użytkownika w trakcie przetwarzania zadań. Dla przykładu w aplikacjach
dla nowej platformy WinRT (tzw. aplikacjach Windows Store) dla Windows 8 wy-
mogiem jest to, by operacje trwające dłużej niż 50 ms były wykonywane asynchro-
nicznie. Można wykorzystać tutaj TPL i oddelegować przetwarzanie do oddzielnych za-
dań (klasa Task, rozdział 6. i następne). Jeśli jednak aplikacja oprócz pobierania danych
musi je automatycznie filtrować, łączyć bądź tworzyć zapytania dla zmieniających się
danych, wówczas ilość i komplikacja kodu nawet przy wykorzystaniu zadań może
przyprawić o zawrót głowy. Odpowiedzią na te wymogi jest Reactive Extensions.

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

Zamiast więc aktywnie oczekiwać na zakończenie operacji, można wysłać zapytanie


i wrócić do normalnego działania. Dopiero gdy dane będą gotowe, aplikacja powinna
przejąć nad nimi kontrolę i przygotować je do używania. W nomenklaturze określa się
Rozdział 14.  Wprowadzenie do Reactive Extensions 303

to mianem programowania, w którym dane spływają (są spychane), kiedy są potrzebne


(ang. push-based). Wówczas mówi się o programowaniu reaktywnym — program
reaguje na pojawiające się dane, a nie czeka od momentu wysłania zapytania. Różnice
w dwóch podejściach przedstawione są poglądowo na rysunku 14.1., gdzie zwrot
strzałki reprezentuje inicjatora w procesie pobierania 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.

Listing 14.1. Deklaracja interfejsu IObservable<T>


namespace System
{
public interface IObservable<out T>
{
IDisposable Subscribe(IObserver<T> observer);
}
}

Zatem „jedyne”, co można zrobić z tym typem, to dokonać subskrypcji. Za każdym


razem, gdy tej subskrypcji dokonamy, otrzymujemy w odpowiedzi typ IDisposable,
który służy do rezygnacji z subskrypcji, czyli odłączenia się od mechanizmu infor-
mowania o kolejnych zdarzeniach w sekwencji.

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

Listing 14.2. Deklaracja interfejsu IObserver<T>


namespace System
{
public interface IObserver<in T>
{
void OnCompleted();
void OnError(Exception error);
void OnNext(T value);
}
}

Widać, że interfejs otrzymuje informacje w trzech przypadkach. Gdy w sekwencji


pojawi się nowe zdarzenie lub nowy element T, zostanie wykonana metoda OnNext. Jej
argumentem value jest zdarzenie. Jeśli sekwencja zdarzeń jest zakończona, po otrzyma-
niu ostatniej wartości zostanie wywołana metoda OnCompleted. Gdy natomiast w któ-
rymkolwiek momencie życia sekwencji pojawi się wyjątek, zostanie on przekazany
w metodzie OnError(Exception error), a sama sekwencja zakończy swoje życie. Co
jest istotne, po ewentualnym pojawieniu się wyjątku sekwencja nie wykona metody
OnCompleted. Dla sekwencji nieskończonych ani OnCompleted, ani OnError(Exception
error) nie zostaną wykonane. W sekwencji skończonej OnError oraz OnCompleted wy-
konane będą w trybie albo-albo.

W początkach tworzenia biblioteki Rx IObservable<T> określano jako kolekcje zda-


rzeń. Jednak w świecie .NET nazwa ta jest bardzo dobrze zdefiniowana przez interfejs
ICollection/ICollection<T>. Poza tym kolekcje (generyczne) są policzalne. Sekwencje
zdarzeń, ogólnie mówiąc, nie mają określonej liczby. Z drugiej strony, IObservable<T>
jest nazywany strumieniem zdarzeń. Jednak i w tym przypadku termin ten kojarzy się
z istniejącym wcześniej w .NET typem Stream. Mimo że w miarę podobne jest poję-
cie odczytywania sekwencyjnego źródła, jednak strumienie mają m.in. możliwość prze-
suwania pozycji w dowolne miejsce strumienia (wykorzystując własność Position), na-
tomiast w Rx IObserver<T> jest obserwatorem aktualnych zdarzeń. Stąd określenie
sekwencja, ze względu na określoną definicję innych nazw, wydaje się być najwła-
ściwszym w kwestii określania IObservable<T>. Warto jednak mieć świadomość, że czę-
sto stosowane są nazwy alternatywne, traktowane zamiennie.

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.

Listing 14.3. Deklaracja interfejsów IEnumerable<T> oraz IEnumerator<T>


namespace System.Collections.Generic
{
public interface IEnumerable<out T>
{
IEnumerator<T> GetEnumerator();
}
Rozdział 14.  Wprowadzenie do Reactive Extensions 305

public interface IEnumerator<out T> : IDisposable


{
T Current { get; }
bool MoveNext();
void Reset();
}
}

Jeśli zamierzamy pobrać elementy z pewnej enumeracji IEnumerable<T>, musimy


wpierw pobrać obiekt, który będzie wiedział, jak „poruszać się” w tej enumeracji.
Tym typem jest IEnumerator<T> pobrany za pomocą metody GetEnumerator. Następ-
nie przy użyciu metody MoveNext następuje przesunięcie do kolejnego elementu. Jeśli
taki element istnieje, w odpowiedzi zostanie zwrócona wartość true. Aby ten element
pobrać, należy skorzystać z własności Current. Jeśli iteracja doszła do końca zbioru,
metoda MoveNext zwróci wartość false. Metoda MoveNext i własność Current są przy-
czyną tzw. aktywnego czekania. Oznacza to, że w momencie ich wywołania angażowany
jest aktualny wątek, który wyciąga dane (stąd wspomniana wcześniej nazwa pull-based)
i tym samym jest „wyłączony” z innych zadań do momentu zakończenia odczytywania
danych.

IObservable<T> oraz IObserver<T> powstały jako alternatywy dla typów IEnumerable<T>


i IEnumerator<T>. W tym przypadku typ IObserver<T> jest typem, który nie czeka
aktywnie na kolejny element ze zbioru IObservable<T>, lecz jest informowany dopiero
w momencie, gdy informacja została nadesłana i jest gotowa do odbioru. Można
powiedzieć, że IObserver<T> jest obserwatorem sekwencji IObservable<T>, do której
dokonał subskrypcji.

Obserwator — wzorzec projektowy


Rx w idei nie jest zupełnie nowym rozwiązaniem. Jest w .NET nową implementacją
dobrze znanego wzorca projektowego o nazwie „obserwator”. Wzorzec obserwatora
to jeden z wielu wzorców projektowych opisanych m.in. w znanej książce tzw. gangu
czworga: Wzorce projektowe. Elementy oprogramowania obiektowego wielokrotnego
użytku2. Oznacza to, że Rx jest rozwiązaniem często napotykanego problemu.

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ą.

Tabela 14.1. Przedstawienie warstw architektury zaimplementowanej w Reactive Extensions


Warstwy Opis
Warstwa LINQ do zdarzeń Warstwa, z którą programiści mają najwięcej do czynienia, gdzie
odbywa się tworzenie zapytań, selekcja, filtrowanie oraz subskrypcja
do obserwabli (zwanych w Rx często sekwencjami zdarzeń). Jest to
warstwa najwyższa, która operuje na sekwencjach. Poznamy ją dalej
w tym rozdziale, podczas opisu gramatyki Rx.
Warstwa sekwencji zdarzeń W tej warstwie zawarty jest zbiór pojęć, którymi opisane są operatory
LINQ do zdarzeń, czyli IObservable<T> oraz IObserver<T>, a także
zbudowane na nich interfejsy oraz typy pomocnicze.
Warstwa zarządzania Warstwa, w której operacje opisane za pomocą LINQ do zdarzeń
współbieżnością oparte o sekwencje zdarzeń są faktycznie wykonywane. Kluczem
do efektywnego wykorzystania Rx jest zrozumienie, jak działają
klasy zarządców oparte o interfejs IScheduler. Z punktu widzenia
tej książki jest to najważniejsza część, opisana w trzeciej części tego
rozdziału.

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.

Utworzymy w Visual Studio projekt konsolowy. Do projektu pobierzemy potrzebne


pakiety Rx. W tym celu należy kliknąć prawym przyciskiem myszy projekt w oknie
Solution Explorer, a następnie wybrać Manage NuGet Packages... i dalej wyszukać frazę
reactive, co pokazujemy na rysunku 14.3.

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

Na liście dostępnych pakietów pojawią się najbardziej popularne pakiety biblioteki


Reactive Extensions. Należy wybrać Reactive Extensions — Main Library (w skrócie:
Rx-Main). Jest to pakiet, który w wygodny sposób instaluje podstawowe zależności
wymagane w rozwoju aplikacji korzystających z Rx. Zainstalowane zostaną wszystkie
biblioteki wymienione w tabeli 14.2.

Warto zapamiętać, że trzy pierwsze biblioteki są bibliotekami Portable Class Library4,


tzn. są dokładnie takie same dla wszystkich platform. Platform Services Library jest
zależnością, która tworzy „klej” pomiędzy trzema pierwszymi a faktyczną platformą
i służy tylko do wybrania optymalnych mechanizmów zarządzania na konkretnej
platformie.
4
W Visual Studio 2012 projekt Portable Class Library służy do tworzenia projektów, które mogą być
konsumowane przez wszelkie inne projekty, bez względu na konkretną platformę.
308 Programowanie równoległe i asynchroniczne w C# 5.0

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.

Po wybraniu do instalacji Rx-Main oraz zaakceptowaniu licencji jesteśmy gotowi do


sprawdzenia, czy możemy korzystać z Rx. W tym celu w pliku Program.cs dodajemy
przestrzeń nazw do już dostępnej przestrzeni nazw System.Reactive.Linq. Następnie
w metodzie Main wpiszemy kod z listingu 14.4.

Listing 14.4. Pierwszy program napisany przy wykorzystaniu Rx


class Program
{
static void Main(string[] args)
{
IObservable<char> witaj = "Witaj Rx!".ToObservable();

witaj.Subscribe(Console.WriteLine);

Console.WriteLine("\nNaciśnij ENTER, aby zakończyć...");


Console.ReadLine();
}
}

Jeśli kompilacja przebiegnie poprawnie i wynik będzie analogiczny do pokazanego na


rysunku 14.4, będzie to znaczyło, że jesteśmy gotowi do pracy z Reactive Extensions.

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.

Jak korzystać z interfejsów w Rx?


Interfejs w programowaniu jest tylko deklaracją. Aby użyć interfejsu, należy go za-
implementować. Takie jest oczywiste podejście. W Rx jednak „dobrą praktyką” jest
unikanie implementowania interfejsów. Wynika to z faktu, że IObservable<T> oraz
IObserver<T> są tylko częścią całej platformy Rx, w której bardzo duży nacisk został
położony na zarządzanie współbieżnością. Przy ich jawnej implementacji istnieje
groźba, że płynące z tego korzyści zostaną całkowicie zaprzepaszczone.

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);
});

Console.WriteLine("Naciśnij ENTER, aby zakończyć...");


Console.ReadLine();
}
}

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);
});

Console.WriteLine("Naciśnij ENTER, aby zakończyć...");


Console.ReadLine();
}
}

Analogicznie do poprzedniego przypadku, tak i tutaj po skompilowaniu i uruchomie-


niu na ekranie pojawi się dziesięć liczb w kolejnych wierszach.

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

momentu osiągnięcia przez nią wartości 10 obserwator będzie informowany o kolejnym


elemencie ciągu liczbowego poprzez wywołanie metody OnNext obserwatora o. Na
koniec zostanie wywołana jego metoda OnCompleted. Ostatecznie zostanie zwrócona
wartość akcji, która będzie wykonana po zakończeniu całej pętli. Jak widać, w tym
przypadku akcja nic nie robi.

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);
});

Console.WriteLine("Naciśnij ENTER, aby zakończyć...");


Console.ReadLine();
}
}

W tej implementacji brakuje jeszcze jednej metody interfejsu, a mianowicie OnError.


Mimo że w tym przypadku nie spodziewamy się żadnych błędów, dla kompletności
przykładu zmodyfikujemy utworzoną obserwablę na przykładzie listingu 14.8.

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

while (i < 10)


{
obserwator.OnNext(i);
i++;
}

obserwator.OnCompleted();
}
catch (Exception error)
{
obserwator.OnError(error);
}

return () => { };
});

sekwCreate.Subscribe(
onNext: (element) =>
{
Console.WriteLine(element);
});

Console.WriteLine("Naciśnij ENTER, aby zakończyć...");


Console.ReadLine();
}
}

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.

Utwórzmy zatem nowy projekt konsolowy i dodajmy do niego wymagane biblioteki


Rx. Skorzystajmy z przykładu z metodą tworzącą Create i utwórzmy sekwencję tak,
jak to zaprezentowaliśmy na listingu 14.8.

Spojrzenie na listę podpowiedzi IntelliSense w momencie wpisania Subscribe poka-


zuje, że oprócz metody, która była przedstawiona wcześniej, istnieje jeszcze sporo in-
nych, za pomocą których możemy otrzymywać dane z sekwencji. Zespół tworzący Rx
zbudował przeładowania do Subscribe w celu zapewnienia wygody programistycznej.
Wykorzystujemy tutaj wpierw już poznane przeładowanie, które tylko obserwuje dane
nadchodzące podczas wywołania metody OnNext.
Rozdział 14.  Wprowadzenie do Reactive Extensions 313

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.

Listing 14.9. Subskrypcja wykorzystująca metodę OnNext oraz OnCompleted


class Program
{
static void Main(string[] args)
{
IObservable<int> sekwCreate = Observable.Create<int>(
subscribe: obserwator =>
{
try
{
int i = 0;

while (i < 10)


{
obserwator.OnNext(i);
i++;
}

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;

while (i < 10)


{
obserwator.OnNext(10 / i);
i++;
}
obserwator.OnCompleted();
}
catch (Exception error)
{
obserwator.OnError(error);
}
return () => { };
});
sekwCreate.Subscribe(
onNext: (element) =>
{
Console.WriteLine(element);
},
onCompleted: () =>
{
Console.WriteLine("Koniec przetwarzania");
},
onError: (error) =>
{
Console.WriteLine("Błąd przetwarzania: {0}", error.Message);
});
Console.WriteLine("Naciśnij ENTER, aby zakończyć...");
Console.ReadLine();
}
}

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).

Obecnie nie ma jeszcze ustalonego tłumaczenia zwrotu marble diagrams. Będzie


ono inne w różnych regionach naszego kraju. W języku angielskim słowo marble
oznacza małe, szklane kuleczki służące do zabawy i gier, które w Polsce znane są
jako marmurki, murmelki czy dunie. Proponujemy jednak stosować nazwę diagramy
koralikowe, która ma przywoływać skojarzenie z koralami nanizanymi na nić.

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

W diagramach koralikowych przedstawione są cztery pojęcia: upływ czasu oznaczany


jest przez ciągłą linię — linię życia. Linia życia płynie zwyczajowo w prawą stronę
diagramu. Każde wywołanie metody OnNext oznaczone jest przez kolejny koralik po-
jawiający się na linii życia (rysunek 14.8). Jeśli sekwencja zakończy działanie powo-
dzeniem i wywołana zostanie metoda OnCompleted, linia życia zakończona jest pionową
linią (rysunek 14.8, górny), jeśli natomiast działanie zostanie zakończone niepowodze-
niem i zostanie wywołana metoda OnError, linia życia będzie zakończona krzyżykiem
(rysunek 14.8, dolny).

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.

Listing 14.11. Definicja obserwabli interval oraz subskrypcji


class Program
{
static void Main(string[] args)
{
IObservable<long> interval = Observable.Interval(TimeSpan.FromSeconds(2));

interval.Subscribe(
onNext: (element) =>
{
Console.WriteLine("Element: {0}", element);
},
onCompleted: () =>
{
Console.WriteLine("Sekwencja zakończyła działanie.");
});

Console.WriteLine("Naciśnij ENTER, aby zakończyć.");


Console.ReadLine();
}
}

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));

Console.WriteLine("Czas uruchomienia subskrypcji: {0}",


DateTimeOffset.UtcNow);
interval.Subscribe(
onNext: (element) =>
{
Console.WriteLine("Element: {0}; Przekazany: {1}",
element, DateTimeOffset.UtcNow);
318 Programowanie równoległe i asynchroniczne w C# 5.0

},
onCompleted: () =>
{
Console.WriteLine("Sekwencja zakończyła działanie.");
});

Console.WriteLine("Naciśnij ENTER, aby zakończyć.");


Console.ReadLine();
}
}

W Rx istnieje jednak metoda, która wykonuje tę samą pracę, polegającą na wypisywaniu


na ekranie własności DateTimeOffset.UtcNow. Jest to metoda Observable.Timestamp.
Dekoruje ona wszystkie elementy sekwencji, która ją wywołuje, typem Timestamped
<TElement>, który posiada własność Value oraz Timestamp. Jest to rozsądniejsze roz-
wiązanie, bo jeśli sekwencja jest przekazywana między wieloma warstwami aplikacji,
to każdy subskrybent otrzyma tę samą wartość czasu. Zmodyfikujmy więc kod zgodnie
ze wzorem z listingu 14.13.

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();

Console.WriteLine("Czas uruchomienia subskrypcji: {0}", DateTimeOffset.UtcNow);


interval.Subscribe(
onNext: (element) =>
{
Console.WriteLine("Element: {0}; Przekazany: {1}",
element.Value, element.Timestamp);
},
onCompleted: () =>
{
Console.WriteLine("Sekwencja zakończyła działanie.");
});

Console.WriteLine("Naciśnij ENTER, aby zakończyć.");


Console.ReadLine();
}
}

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();

Console.WriteLine("Czas startu: {0}", DateTimeOffset.UtcNow);

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

Console.WriteLine("Naciśnij ENTER, aby zakończyć...");


Console.ReadLine();
}
}

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

});

Console.WriteLine("Naciśnij ENTER, aby zakończyć...");


Console.ReadLine();
}
}

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

var sekw1 = Observable.Range(0, 10);


var sekw2 = Observable.Interval(TimeSpan.FromSeconds(1));

var wynik = sekw1.Zip(sekw2,


(elementSekw1, elementSekw2) => new
{
Elem1 = elementSekw1,
Elem2 = elementSekw2
});

wynik.Subscribe(
onNext: (element) =>
{
Console.WriteLine("Elem1 = {0}; Elem2 = {1}",
element.Elem1, element.Elem2);
},
onCompleted: () =>
{
Console.WriteLine("Zakończono przetwarzać sekwencję.");
});

Console.WriteLine("Naciśnij ENTER, aby zakończyć...");


Console.ReadLine();
}
}

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.

Listing 14.17. Wykorzystanie w definicji metody Timestamp


class Program
{
static void Main(string[] args)
{
var sekw1 = Observable.Range(0, 10);
var sekw2 = Observable.Interval(TimeSpan.FromSeconds(1));

var wynik = sekw1.Zip(sekw2,


(elementSekw1, elementSekw2) => elementSekw1)
.Timestamp();

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ę.");
});

Console.WriteLine("Naciśnij ENTER, aby zakończyć...");


Console.ReadLine();
}
}

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

Listing 14.18. Kod programu pokazującego działanie metody CombineLatest


class Program
{
static void Main(string[] args)
{
var sekwencja = Observable.Range(0, 10)

.Zip(Observable.Interval(TimeSpan.FromSeconds(1)),
(lewy, prawy) => lewy);

var wynik = sekwencja.CombineLatest(sekwencja.Skip(1),


(lewy, prawy) => new
{
Lewy = lewy,
Prawy = prawy
});

wynik.Subscribe(
onNext: (element) =>
{
Console.WriteLine("Para ({0},{1})", element.Lewy, element.Prawy);
},
onCompleted: () =>
{
Console.WriteLine("Zakończono przetwarzać sekwencję.");
});

Console.WriteLine("Naciśnij ENTER, aby zakończyć...");


Console.ReadLine();
}
}

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);

foreach (var element in bufor)


{
326 Programowanie równoległe i asynchroniczne w C# 5.0

Console.WriteLine("{0}{1}({2})",
new string('\t', numerTegoBufora),
element.Value,
element.Timestamp);
}
},
onCompleted: () =>
{
Console.WriteLine("Zakończono przetwarzać sekwencję.");
});

Console.WriteLine("Naciśnij ENTER, aby zakończyć...");


Console.ReadLine();
}
}

Subskrybując do bufora, wypiszemy wszystkie elementy znajdujące się w nim oraz


numer bufora, który będzie kolejną liczbą całkowitą, poczynając od zera. Ponieważ
bufor implementuje interfejs IList, możemy wykorzystać pętlę foreach, aby wydru-
kować elementy, które się w nim znajdują wraz z informacją o czasie, w którym po-
wstały. Po udanej kompilacji i uruchomieniu powinniśmy zobaczyć wynik, podobny
do tego z rysunku 14.15.

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

this IObservable<TSource> source, int count, int skip);


public static IObservable<IObservable<TSource>> Window<TSource>(
this IObservable<TSource> source, TimeSpan timeSpan, int count);

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

Zimne i gorące obserwable


Za każdym razem, gdy w naszych przykładach korzystaliśmy ze zdefiniowanych se-
kwencji zdarzeń, dokonywaliśmy subskrypcji tylko raz. Co stanie się, jeśli do tej se-
kwencji ustanowimy dwie subskrypcje? Aby to sprawdzić, utworzymy nowy projekt
konsolowy i dodamy pakiet NuGet Rx-Main. Następnie w metodzie Main zapiszemy kod
z listingu 14.21. Jak widać, subskrypcje wyglądają tak samo, jednak czas ich podłączenia
jest przesunięty o 4 sekundy. Po skompilowaniu spróbujemy uruchomić program i spraw-
dzimy, czy rezultat jest taki sam jak na rysunku 14.18.

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);
});

Console.WriteLine("Naciśnij ENTER, aby zakończyć...");


Console.ReadLine();
}
}

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).

Jednak zgodnie z intuicją, sekwencje zdarzeń powinny być niezależne od obserwatora.


Tak na pewno będą działały informacje pobierane przez sieć internetową. Dostaniemy
dane aktualne i nie będziemy mieli na nie wpływu. Tego typu obserwable, będące
żywymi sekwencjami, działającymi niezależnie od naszego programu, nazywa się go-
rącymi obserwablami (ang. hot observables). Pojawia się więc pytanie, czy można
utworzyć taką gorącą obserwablę, mimo że jest wygenerowana w programie? Odpo-
wiedź brzmi „tak”. Istnieje metoda, która powoduje, że sekwencja zdarzeń nie będzie
ponownie ewaluowana dla każdego kolejnego obserwatora, lecz faktycznie będzie nie-
zależnym źródłem zdarzeń. Ta metoda to Publish. Wystarczy dodać ją na końcu in-
strukcji przetwarzania sekwencji, aby utworzyć źródło tego typu. Wywołanie Publish
powoduje, że wynikowa obserwabla będzie typu IConnectableObservable<T>.

Na listingu 14.22 widać, że jedyną różnicą pomiędzy IObservable<T> oraz Iconnectable


Observable<T> jest możliwość podłączenia przy użyciu metody Connect. Metoda ta
powoduje, że obserwabla jest podłączona, tzn. zaczyna przetwarzać zdefiniowane zda-
rzenia i dzieje się to niezależnie od podłączających się obserwatorów. Jak widać z opisu
interfejsu, metoda ta zwraca obiekt typu IDisposable, który służy do zerwania połącze-
nia, jeśli przestaniemy interesować się zdarzeniami przychodzącymi z opublikowanej
sekwencji. Zmodyfikujemy zatem przykład z listingu 14.21, zgodnie ze wzorem z li-
stingu 14.23. W efekcie zamiast na zimnej obserwabli będziemy operować na gorącej.
W konsekwencji po podłączeniu do sekwencji drugi subskrybent otrzymuje te same da-
ne, co pierwszy, natomiast po usunięciu łącznika lacznik obaj subskrybenci przestają
otrzymywać dane. Jak widać, nie zostaną wywołane operacje zakończenia, gdyż se-
kwencja nie zdążyła przesłać tej informacji — to my zrezygnowaliśmy z otrzymywania
kolejnych zdarzeń.

Listing 14.22. Definicja interfejsu IConnectableObservable<T>


namespace System.Reactive.Subjects
{
public interface IConnectableObservable<out T> : IObservable<T>
{
IDisposable Connect();
}
}

Listing 14.23. Zmiana zimnej obserwabli w gorącą


class Program
{
static void Main(string[] args)
{
Rozdział 14.  Wprowadzenie do Reactive Extensions 331

var sekwencja = Observable.Range(0, 10)

.Zip(Observable.Interval(TimeSpan.FromSeconds(1)),
(lewy, prawy) => lewy)
.Publish();

var lacznik = sekwencja.Connect();

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();

Console.WriteLine("Naciśnij ENTER, aby zakończyć...");


Console.ReadLine();
}
}
332 Programowanie równoległe i asynchroniczne w C# 5.0
Rozdział 15.
Współbieżność w Rx
Rafał Pawłaszek i Piotr Sybilski

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.

Na platformie .NET istnieje wiele możliwych punktów wejścia do rozpoczęcia pracy


z kodem asynchronicznym. Oto przykłady:
 new Thread(() => { /* akcja */ }).Start()
 ThreadPool.QueueUserWorkItem( _ => { /* akcja */ }, null)
 Task.Factory.StartNew(() => { /* akcja */ });
 synchronizationContext.Post(_ => { /* akcja */ }, null)
 Dispatcher.BeginInvoke(() => { /* akcja */ });

Metoda ThreadPool.QueueUserWorkItem przyjmuje parametr WaitCallback, który


tutaj jest zaznaczony podkreśleniem dolnym. Wśród twórców Rx jest to zapis bar-
dzo często wykorzystywany, gdy sama zmienna bądź parametr nie są istotne, lecz
potrzebna jest oparta na nich funkcjonalność.

Każde z tych podejść, choć ostatecznie wykonuje asynchronicznie dokładnie tę samą


akcję, wyraźnie różni się od pozostałych. Jak zatem w Rx połączono powyższe możli-
wości? W tym celu został utworzony interfejs IScheduler oraz implementujące go klasy,
które korzystają z powyższych konstrukcji.
334 Programowanie równoległe i asynchroniczne w C# 5.0

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.

Listing 15.1. Deklaracja interfejsu IScheduler


namespace System.Reactive.Concurrency
{
public interface IScheduler
{
DateTimeOffset Now { get; }

IDisposable Schedule<TState>(TState state, Func<IScheduler, TState,


IDisposable> action);
IDisposable Schedule<TState>(TState state, DateTimeOffset dueTime,
Func<IScheduler, TState, IDisposable> action);
IDisposable Schedule<TState>(TState state, TimeSpan dueTime,
Func<IScheduler, TState, IDisposable> action);
}
}

Z poprzedniego rozdziału pamiętamy, że interfejs IObservable<T> definiuje sekwen-


cję zdarzeń, a IObserver<T> określa, w jaki sposób chcemy reagować na pojawiające
się informacje. Natomiast przy użyciu interfejsu IScheduler decydujemy, w jaki sposób
jest realizowana obsługa sekwencji zdarzeń. Na diagramach koralikowych można by
go zatem utożsamiać z linią życia. Korzystając z niego, należy odpowiedzieć sobie na
pytania: „W jaki sposób zdarzenia powinny być zarządzane?” albo „Gdzie powinny
pojawiać się informacje?”. Większość metod rozszerzających zaimplementowanych
w statycznej klasie Observable posiada przeciążone wersje, które jako parametr przyj-
mują interfejs IScheduler. Dzięki temu możemy określić, czy zdarzenia sekwencji
mają pojawiać się w wątkach (klasa Thread), zadaniach (klasa Task), czy mają być zarzą-
dzane przez określony kontekst synchronizacyjny (klasa SynchronizationContext), czy
w inny sposób. Możemy nawet utworzyć własnego planistę. Wtedy określenie sposobu
wykonania jest parametrem, który możemy swobodnie ustalać. Jeśli nie zdecydujemy
się na konkretnego planistę — Rx wybierze go za nas.

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.

Warstwa zarządzania współbieżnością jest jednak o wiele bardziej skomplikowana,


niż można by wnioskować na podstawie stosunkowo prostego interfejsu IScheduler.
W Rx czas jest wirtualny. Jest to spowodowane przez kilka czynników, m.in. taki, że
Rozdział 15.  Współbieżność w Rx 335

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.

Wirtualizacja czasu pozwala też na wykorzystanie Rx nie do informacji z „teraz”


(bieżących), a do informacji historycznych. Istnieje specjalny planista, Historical
Scheduler, który służy do tego celu. Dzięki wirtualizacji czasu można też zmienić
tempo upływu czasu, co powoduje, że Rx staje się wygodnym narzędziem do testo-
wania. W tym celu został zaimplementowany planista TestScheduler, który znajdu-
je się w pakiecie Rx-Testing. Omówienie obu planistów wykracza jednak poza ramy
tego rozdziału.

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


Planista Opis
DefaultScheduler Jak sama nazwa wskazuje, jest to domyślny planista wykorzystywany, kiedy
programista nie wskazał jawnie żadnego innego. To klasa statyczna, która posiada
statyczną własność Instance pozwalającą na dostęp do instancji planisty.
Rx „wybiera” domyślnego planistę, posługując się zasadą „najmniejszej
współbieżności”.
CurrentThread Wszelkie zadania zostaną przydzielone aktualnemu wątkowi, co powoduje, że
Scheduler ich wykonanie jest synchroniczne. Jest to klasa posiadająca statyczną własność
Instance, umożliwiającą dostęp do instancji planisty. Własność ta to niejako
przekaźnik do aktualnego wątku, więc naturalne jest, że nie można tworzyć jej
instancji.
ImmediateScheduler Zadanie przydzielone planiście ImmediateScheduler zostanie wykonane
w aktualnym wątku i będzie wykonane natychmiast. Jest to klasa, która
posiada statyczną własność Instance. Tak samo jak poprzedni planiści,
tak i ImmediateScheduler przekazuje pracę do aktualnego wątku. Różni się tym,
że ImmediateScheduler jest planistą synchronicznym.
EventLoopScheduler Planista korzystający z oddzielnego wątku, który zostanie oddelegowany
do wykonania wszelkich zaplanowanych zadań. Nie jest to klasa statyczna,
zatem aby go użyć, trzeba utworzyć nową instancję.
NewThreadScheduler Przy wykorzystaniu planisty NewThreadScheduler każde nowe zadanie zostanie
wykonane w nowym osobnym wątku. Klasa NewThreadScheduler posiada
własność statyczną Instance, jednak można utworzyć jej instancję, która
będzie przekazywana do planowania zadań.
336 Programowanie równoległe i asynchroniczne w C# 5.0

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.

Warto zwrócić uwagę na planistów CurrentThreadScheduler oraz ImmediateScheduler


— zgodnie z opisem obaj wykonują pracę w bieżącym wątku. Jaka jest między nimi
różnica? W Rx wielki nacisk został położony na sekwencyjność zdarzeń. Oznacza to,
że zdarzenia przychodzące nie mogą nachodzić na siebie. Jeśli w trakcie przetwarzania
konkretnego zadania zostanie nadesłane następne, zostanie ono „wstrzymane” do mo-
mentu zakończenia aktualnego zadania. Jednak ImmediateScheduler jako jedyny działa
inaczej. Pokażemy to na przykładzie.

Utworzymy nowy projekt konsolowy i dodamy do niego pakiet NuGet Rx-Main.


Obok metody Main zdefiniujemy metodę TestujSchedule, zgodnie z listingiem 15.2,
która będzie zarządzana przez planistę.

Listing 15.2. Definicja testu planistów


private static void TestujSchedule(string nazwa, IScheduler planista)
{
Action akcja = null;
int i = 0;
akcja = () =>
{
var wewnI = i++;

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);
}

Metoda rozpoczyna się od zadeklarowania akcji o nazwie akcja. W definicji akcji


określany jest warunek, kiedy akcja wywoła samą siebie. Głębokość rekurencji równa
jest trzy. Na końcu metody akcja ta jest zaplanowana — to tutaj rozpocznie się faktyczne
Rozdział 15.  Współbieżność w Rx 337

wykonanie wcześniej zdefiniowanej akcji. Widać więc, że akcja powinna zostać


przekazana planiście trzy razy. Z racji tego, że chcemy też zobaczyć, w jakim wątku
będzie wykonywana konkretna akcja, w oknie konsoli wydrukujemy informację doty-
czącą aktualnego planisty (zdefiniowaną nazwę oraz informację z numerem aktualnie
działającego wątku). Dodatkowo głębokość rekurencji będzie określona przez głębokość
wcięcia danej linii wydruku. Metodę TestujSchedule wywołujemy z metody Main, co
pokazujemy na listingu 15.3.

Listing 15.3. Program testujący zachowanie metody Schedule u wybranych planistów


Console.WriteLine("Wątek programu: {0}", Environment.CurrentManagedThreadId);
var planisci = new Dictionary<string, IScheduler>() {
{ "ImmediateScheduler", ImmediateScheduler.Instance },
{ "CurrentThreadScheduler", CurrentThreadScheduler.Instance }
};

foreach (var planista in planisci)


{
Console.WriteLine("\nNaciśnij ENTER, aby przetestować planistę {0}",
planista.Key);
Console.ReadLine();

TestujSchedule(planista.Key, planista.Value);
}

Console.WriteLine("\nNaciśnij ENTER, aby zakończyć");


Console.ReadLine();

Jak widać na listingu 15.3, planiści zdefiniowani są z wykorzystaniem słownika (kla-


sa Dictionary), w którym kluczem jest nazwa planisty. Słownik zawiera tylko dwa
elementy ImmediateScheduler i CurrentThreadScheduler. Po teście każdego z planistów
program zostanie wstrzymany aż do naciśnięcia klawisza Enter.

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.

Z kolei CurrentThreadScheduler planuje akcje zupełnie inaczej. W odróżnieniu od


ImmediateScheduler, ustawia zaplanowane akcje w kolejności ich planowania. Stąd
odpowiadające sobie głębokością, a więc momentem planowania, linie Przed... oraz Po...
pojawiają tuż obok siebie. CurrentThreadScheduler wykazuje zachowanie asynchroniczne,
tak samo jak pozostali wymienieni planiści. Aby to sprawdzić, dodamy do słownika
planisci kolejne referencje do innych zdefiniowanych w Rx planistów (listing 15.4).
338 Programowanie równoległe i asynchroniczne w C# 5.0

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() }

Przetestujemy teraz zachowanie metody Schedule dla pozostałych planistów. Wynik


znajduje się na rysunku 15.2.

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.

Generalnie idea sekwencyjnego wykonywania planowanych akcji przez wybranego


planistę jest pewnym narzutem, jeśli chodzi o wykorzystanie Rx1. Dobrą praktyką
programistyczną jest tworzenie obserwatorów z jak najszybszą metodą OnNext, wtedy
szybko przychodzące po sobie zadania nie będą długo przetwarzane. Ponadto można
posłużyć się metodami Buffer oraz Window w celu gromadzenia szybko pojawiających
się danych, aby przetworzyć je w całości. Gdy znamy sposób przetwarzania zadań w Rx,
możemy wykorzystać możliwości optymalnie.

Metody SubscribeOn i ObserveOn


W klasie Observable zdefiniowane są także dwie metody służące do określania, z ja-
kiego planisty zamierzamy skorzystać. Pierwsza z nich to SubscribeOn. Określa ona,
w jakim wątku będzie wykonana subskrypcja (nasłuchiwanie zdarzeń). Druga metoda
to ObserveOn. Wyznacza planistę, który będzie wykorzystywany do obserwacji sekwencji
(w trakcie wywoływania metod obserwatora).

Tak jak oddelegowanie subskrypcji do innego wątku z wykorzystaniem metody


SubscribeOn wydaje się oczywistym atutem, bo zwalniamy z pracy główny wątek i prze-
kazujemy tę pracę innemu — tak metoda ObserveOn może na pierwszy rzut oka wyda-
wać się zbędna. Jednak programiści, którzy pracowali z bibliotekami kontrolek WPF
i Windows Forms, na pewno wiedzą, że w tego typu aplikacjach zdefiniowany jest
uprzywilejowany wątek interfejsu (rozdziały 5. i 10.), w jakim tworzone są kontrolki
składające się na interfejs i wyłącznie w nim ich stan może być modyfikowany2. Widać
więc, że oprócz przekazywania pracy do osobnych wątków ważnym problemem jest
także „powrót” do macierzystego wątku, aby opublikować wyniki pracy.

W następnym rozdziale przedstawimy przykłady wykorzystujące obie metody —


czyli SubscribeOn oraz ObserveOn — oparte o technologię WPF.

Aby dokładniej przyjrzeć się temu zagadnieniu, przejdźmy do przykładu. Utworzymy


nowy projekt konsolowy z dodanym pakietem Rx-Main. Poniżej metody Main utworzy-
my metodę pomocniczą ObservableRange, która będzie generowała sekwencję liczb
całkowitych (listing 15.5), analogicznie do metody Observable.Range. Metoda ta,
oprócz typowego zachowania, wyświetla także aktualny element oraz numer wątku,
w którym generowana jest sekwencja. Następnie dodamy jeszcze trzy metody również
widoczne na listingu 15.5.

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++);
}

Console.WriteLine("{0}Subskrypcja: OnCompleted(); Wątek: {1}",


new string('\t', 2), Environment.CurrentManagedThreadId);
observer.OnCompleted();
}
catch (Exception error)
{
Console.WriteLine("{0}Subskrypcja: OnError({1}); Wątek: {2}",
new string('\t', 2), error.ToString(), Environment.CurrentManagedThreadId);
observer.OnError(error);
}

return Disposable.Empty;
});
}

static void TestujObserveOn(IScheduler scheduler)


{
var xs = ObservableRange(0, 5).ObserveOn(scheduler);
xs.Subscribe(element => PrzedstawElement(element));
}

static void TestujSubscribeOn(IScheduler scheduler)


{
var xs = ObservableRange(0, 5).SubscribeOn(scheduler);
xs.Subscribe(element => PrzedstawElement(element));
}

static void PrzedstawElement(int element)


{
Console.WriteLine("{0}Obserwacja: OnNext({1}); Wątek: {2}.",
new string('\t', 4),
element,
Environment.CurrentManagedThreadId);
}

Metody TestujObserveOn oraz TestujSubscribeOn służą do sprawdzenia zachowania


metod ObserveOn oraz SubscribeOn. Metoda Przedstaw prezentuje aktualnie obserwo-
wany element oraz wyświetla identyfikator wątku, który odpowiada za obserwację.
Rozdział 15.  Współbieżność w Rx 341

Mamy zadeklarowane powyższe metody pomocnicze, zatem wykorzystamy je w meto-


dzie Main programu, aby przetestować poznanych wcześniej planistów (tabela 15.1).
Pokazujemy to na listingu 15.6.

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() }
};

foreach (var planista in planisci)


{
Console.WriteLine("Program działa na wątku {0}",
Environment.CurrentManagedThreadId);
Console.WriteLine("\tTest: ObserveOn({0})", planista.Key);
TestujObserveOn(planista.Value);
Console.ReadLine();
Console.WriteLine("Program działa na wątku {0}",
Environment.CurrentManagedThreadId);
Console.WriteLine("\tTest: SubscribeOn({0})", planista.Key);
TestujSubscribeOn(planista.Value);
Console.ReadLine();
}
Console.WriteLine("Naciśnij ENTER, aby zakończyć.");
Console.ReadLine();
}
}

Po udanej kompilacji trzeba uruchomić aplikację. Po każdym teście należy wcisnąć


klawisz Enter, aby przejść do kolejnego testu. Z wydruku zaprezentowanego na ry-
sunkach 15.3 oraz 15.4 można wywnioskować dwie rzeczy. Po pierwsze, jeśli nie jest
jawnie wskazany żaden planista dla subskrypcji, przetwarzanie odbywa się w głównym
wątku. Po drugie, jeśli nie jest określony żaden planista dla obserwacji, wykorzystany
jest planista, który został przyporządkowany do zarządzania subskrypcją.

Daje to dwa stopnie swobody ze względu na wykorzystanie równoległości, co przed-


stawimy w postaci diagramu. Na rysunku 15.5 widać przykładowy zapis działania
programu, który wykorzystuje jakąś sekwencję zdarzeń IObservable<T>. Linia przedsta-
wia wątek, w którym zachodzą zdarzenia oraz obserwacje. Gdy skorzystamy z meto-
dy SubscribeOn i wskażemy planistę, który używa oddzielnego wątku, przetwarzanie
subskrypcji (oraz obserwacji) staje się procesem równoległym do działania głównego
wątku programu. Ten przypadek przedstawiony jest na rysunku 15.6. Jeśli jednak za-
stosujemy metodę ObserveOn, dodany zostanie jeszcze jeden wątek, w którym będą
wywoływane metody obserwatora. Taki stan prezentujemy na rysunku 15.7.
342 Programowanie równoległe i asynchroniczne w C# 5.0

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.

Pakiety Rx-Core, Rx-Interfaces oraz Rx-Linq są takie same, niezależnie od platformy,


na której z nich korzystamy. Można bezpiecznie używać tych samych bibliotek zarówno
w WinRT, jak i Windows Phone 8. Jednak wszelkie różnice, które istnieją ze względu
na różne środowiska wykonania, znajdują się w Rx-PlatformServices. Istnieje tam
klasa EnlightenmentProvider, która w momencie uruchomienia sprawdza platformę, na
której wykonuje się program, i ustala, czym faktycznie są EventLoopScheduler, NewThread
Scheduler, TaskPoolScheduler oraz ThreadPoolScheduler.

Bez pakietu Rx-PlatformServices program będzie działał, jednak zdecydowanie obniży


się wydajność wykorzystania odpowiednich struktur, na których Rx jest zbudowany.
Stąd też sugerowaną praktyką jest dodawanie do rozwiązań Rx-PlatformServices w celu
jak najwydajniejszego wykorzystania Reactive Extensions.
344 Programowanie równoległe i asynchroniczne w C# 5.0
Rozdział 16.
Przykłady użycia
technologii Rx
w aplikacjach WPF
Rafał Pawłaszek i Piotr Sybilski
Siłą Rx jest to, że bardzo płynnie łączy się z innymi technologiami .NET i to w każdym
modelu programowania. Dobrym przykładem są zdarzenia. Opierając się na wiedzy
z dwóch poprzednich rozdziałów, możemy już utworzyć bardziej skomplikowane i uży-
teczniejsze programy, które będą lepiej pokazywały możliwości biblioteki Rx.
Przedstawimy dwa przykłady aplikacji korzystających z biblioteki kontrolek WPF.
Pierwszym programem będzie prosta aplikacja wykorzystująca Rx do rysowania na
płótnie siatki (na obiekcie Grid.Canvas). Drugi program będzie natomiast prostą wy-
szukiwarką internetową korzystającą z silnika wyszukiwania Bing.
Zanim przystąpimy do opisu tych przykładowych aplikacji, przedstawimy jeszcze
dwa nowe pakiety Rx, czyli Rx-WPF oraz Rx-Xaml. Są one niezbędne, aby możliwe
było manipulowanie interfejsem użytkownika zbudowanym w technologii WPF. W tej
technologii (jak również w Windows Forms) obiekty interfejsu, które powstały w wątku
interfejsu, nie mogą być modyfikowane z innych wątków (rozdziały 5. i 10.). Aby
rozwiązać ten problem, WPF oferuje klasy Dispatcher oraz SynchronizationContext,
które umożliwiają przesyłanie akcji do wątku interfejsu, co umożliwia jego modyfikacje.
Pakiet Rx-WPF jest zależny od Rx-Xaml i nie posiada żadnych bibliotek. Pakiet Rx-Xaml
może być wykorzystywany we wszystkich technologiach opartych o XAML, nato-
miast Rx-WPF przydaje się właściwie tylko podczas wyszukiwania pakietów dla WPF
w oknie menedżera NuGet1.

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

Rx-Xaml zawiera nowego planistę o nazwie DispatcherScheduler oraz dodatkowe


metody rozszerzające interfejs IObservable<T>, które z tego planisty korzystają. Pakiet
Rx-Xaml używa przestrzeni nazw System.Reactive.Windows.Threading.

Rysowanie z użyciem Rx
Zastanówmy się nad stwierdzeniem, że kursor myszy jest bazą danych punktów2.

Z programistycznego punktu widzenia, gdy tylko poruszamy kursorem na ekranie


monitora, wysyłana jest notyfikacja zmiany położenia. Tę notyfikację można prze-
chwycić i określić aktualne położenie kursora na ekranie. Zatem nie jest to statyczna
baza danych, lecz dynamiczna sekwencja. Opierając się na razie tylko na tej nomen-
klaturze, można dostrzec przestrzeń do wykorzystania Rx, aby tą bazą danych — se-
kwencją punktów — manipulować.

Utworzymy nowy projekt WPF i nazwiemy go Rysowanie Rx. W edytorze XAML do


elementu Window.Grid dodamy znacznik Canvas, zgodnie z listingiem 16.1.

Listing 16.1. Definicja interfejsu użytkownika w aplikacji korzystającej z WPF


<Window x:Class="RysowanieRx.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">
<Grid>
<Canvas Name="canvas" Margin="0,0,0,0"/>
</Grid>
</Window>

Następnie za pomocą menedżera pakietów NuGet (dodatek C) wyszukamy pakiet Rx-WPF


i zainstalujemy (rysunek 16.1). Wraz z tym pakietem zostaną zainstalowane wszyst-
kie pakiety, do których się odwołuje, a zatem Rx-Xaml i Rx-Main. A skoro Rx-Main,
to także Rx-Core, Rx-Linq, Rx-Interfaces oraz Rx-PlatformServices.

Po instalacji Rx-WPF zdefiniujemy w klasie MainWindow metodę wywoływaną po ini-


cjacji okna. W tym celu w edytorze XAML zaznaczamy okno, a następnie we wła-
snościach obiektu wybieramy zdarzenie Initialized. Dwukrotne kliknięcie lewym
przyciskiem myszy zdarzenia Initialized utworzy automatycznie metodę Window_
Initialized_1 w pliku MainWindow.xaml.cs oraz dowiązanie do tej metody w pliku
MainWindow.xaml. Następnie przejdziemy do pliku MainWindow.xaml.cs i nad kon-
struktorem MainWindow zdefiniujemy pole — etykietę lblPozycja. W metodzie Window_
Initialized_1 zainicjujemy etykietę lblPozycja, zgodnie z listingiem 16.2.

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();
}

private void Window_Initialized_1(object sender, EventArgs e)


{
lblPozycja = new Label()
{
Content = string.Empty,
Margin = new Thickness(0)
};

this.canvas.Children.Add(lblPozycja);
}
}

W efekcie po zainicjowaniu głównego okna programu tworzymy obiekt lblPozycja.


Jego własność Content określa obiekt, który będzie wyświetlany na tej etykiecie, na-
tomiast własność Margin ustala jego położenie, wskazując odległość od krawędzi. Linia
kodu, w której etykieta dołączana jest do elementów-dzieci obiektu canvas, oznacza,
że krawędzie, od których mierzony będzie odstęp, będą krawędziami obiektu canvas.
348 Programowanie równoległe i asynchroniczne w C# 5.0

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);
});
}

Ponieważ korzystamy z metody ObserveOnDispatcher z pakietu Rx-Xaml, miejsce ob-


serwacji sekwencji zostanie ustalone za pomocą obiektu Dispatcher. Będzie to zatem
wątek główny interfejsu. Po skompilowaniu i uruchomieniu powinniśmy zobaczyć okno
Rozdział 16.  Przykłady użycia technologii Rx w aplikacjach WPF 349

programu, nad którym — gdy poruszymy kursorem myszy — zobaczymy aktualną


pozycję kursora, tak jak na rysunku 16.2.

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.

W typowym przypadku aplikacji rysującej należałoby utrzymywać informację o przy-


ciśnięciu lewego przycisku myszy (być może jako zmienną logiczną bool), która w mo-
mencie zwolnienia tego przycisku wracałaby do pozycji false. Rx pozwala jednak na
coś o wiele ciekawszego, o czym wcześniej już pisaliśmy; posłużymy się zapytaniem
LINQ. Utworzymy zapytanie mouseMoveWhileLeftButtonDown zdefiniowane zgodnie z li-
stingiem 16.4. Zapytanie to należy rozumieć następująco: „od momentu, w którym w se-
kwencji leftMouseButtonDownDb pojawi się zdarzenie wciśnięcia lewego przycisku
myszy, pobieraj pojawiające się zdarzenia nowego położenia w sekwencji mouseMoveDb
do czasu, aż nie pojawi się zdarzenie zwolnienia lewego przycisku myszy”. Przy okazji
wykorzystaliśmy kolejną metodę rozszerzającą Rx o nazwie TakeUntil. Oznacza ona
właśnie to, że z poprzedniej sekwencji (tutaj mouseMoveDb) będą pobierane zdarzenia
do momentu pojawienia się pierwszego elementu w sekwencji podanej jako parametr
TakeUntil (tutaj leftMouseButtonUpDb). Aby sprawdzić, jak to działa, dodamy subskryp-
cję do wynikowej sekwencji (listing 16.4). W efekcie dla każdego nowego położenia
myszy przy wciśniętym lewym przycisku myszy na płótnie powinna być rysowana
mała, niebieska elipsa przedstawiająca punkt — rysunek 16.3.

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 mouseMoveDiffsDb = mouseMoveDb.Zip(


mouseMoveDb.Skip(1),
(lewy, prawy) =>
{
return new
{
X1 = lewy.X,
Y1 = lewy.Y,
X2 = prawy.X,
Y2 = prawy.Y
};
});

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.

Wykorzystamy silnik wyszukiwania Bing i, opierając się na nim, utworzymy prostą


wyszukiwarkę w WPF. W jaki sposób można komunikować się z silnikiem wyszuki-
wania Bing z poziomu aplikacji C#? Pierwszym krokiem będzie odwiedzenie strony
https://datamarket.azure.com/dataset/bing/search, na której dostępne jest Bing Search
API. Strona ta „hostowana” jest na Windows Azure Marketplace.
354 Programowanie równoległe i asynchroniczne w C# 5.0

Zanim zaczniemy korzystać z możliwości wyszukiwania za pomocą Bing z poziomu


aplikacji, rozejrzyjmy się po tej stronie. Bardzo ważne informacje znajdują się w liście
po prawej stronie. Jest to cennik usług. Z niego widać, że obniżanie ilości zapytań wysy-
łanych z aplikacji jest bardzo opłacalnym celem cząstkowym. W tym przykładzie naszym
celem będzie, aby nie przekraczać pułapu 5000 zapytań miesięcznie, co pozwala ko-
rzystać z silnika za darmo (rysunek 16.5)!

Rysunek 16.5.
Ilość transakcji wyszukiwania
za pomocą silnika Bing,
która jest nieodpłatna

Żeby rozpocząć pracę z silnikiem wyszukiwania Bing, musimy zalogować się do


Windows Azure Marketplace, wykorzystując LiveID. W trakcie pierwszego logowania
wyświetli się okno rejestracji (rysunek 16.6). Po przeczytaniu informacji o polityce
prywatności (i jeśli się z nią zgadzamy) należy wypełnić formularz rejestracyjny. Do
naszych celów wystarczy podać imię, nazwisko, kraj zamieszkania oraz adres e-mail.
Następnie trzeba wcisnąć przycisk Continue.

Rysunek 16.6. Okno rejestracji do Windows Azure Marketplace

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

<TextBlock Text="{Binding Path=Title}" FontWeight="Bold"


FontSize="18" TextWrapping="Wrap"/>
<TextBlock Text="{Binding Path=Description}"
TextWrapping="Wrap"/>
<TextBlock Text="{Binding Path=DisplayUrl}" FontSize="10"/>
<TextBlock Text="{Binding Path=Url}" FontSize="9"/>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</Window>

Element XAML odpowiadający liście zawiera definicję szablonu ItemTemplate. Określa


on sposób wyświetlania obiektów w liście. Elementy te będziemy dodawać z poziomu
kodu C#. Będzie to lista wyników wyszukiwania. Szablon ustala, że wpierw będzie
wyświetlana własność Title, następnie Description, DisplayUrl oraz Url. Ważne jest
też to, że lista będzie przyjmowała obiekty za pomocą mechanizmu wiązania danych.
Dodatkowo ze zdarzeniem Initialized okna wiążemy metodę Window_Initialized_1.
Mając tak przygotowany widok, przechodzimy do pliku MainWindow.xaml.cs z kodem
źródłowym C#.

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();
}

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);

IObservable<IEnumerable<WebResult>> wynik =
from fraza in frazy
Rozdział 16.  Przykłady użycia technologii Rx w aplikacjach WPF 357

from rezultat in PobierzWyniki(fraza)


select rezultat;

wynik.SubscribeOn(ThreadPoolScheduler.Instance)
.ObserveOnDispatcher()
.Subscribe(lista =>
{
this.lboRezultaty.ItemsSource = lista;
});
}

private IObservable<IEnumerable<WebResult>> PobierzWyniki(string 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();
}

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.

Metoda Observable.FromAsyncPattern nie tworzy obserwabli, a referencję do metody


Func<IObservable<T>>. Metoda ta zostanie wykonana dopiero podczas zwracania se-
kwencji obserwowalnej. Stąd, aby otrzymać wymagany wynik, czyli IObservable<T>,
należy ją wywołać. Funkcja ta nie posiada żadnych parametrów, więc wystarczy in-
strukcja wynik();.
358 Programowanie równoległe i asynchroniczne w C# 5.0

Korzystając z odczytanego z pola tekstowego łańcucha oraz metody pomocniczej


PobierzWyniki, można zapisać w metodzie Window_Initialized_1 zapytanie pobierające
wyniki wyszukiwania, wynik, zwracane przez Bing tak, jak jest to pokazane na listingu
16.7. Zapytanie to należy rozumieć tak: „dla każdej frazy, która pojawi się w obserwabli
fraz, pobierz wynik zapytania, zapisz do zmiennej pomocniczej rezultat i podaj ją ja-
ko ostateczny wynik”. Do tak zdefiniowanej obserwabli podłączamy się za pomocą
ostatniej części kodu z listingu 16.7. Aby nie blokować interfejsu użytkownika, w mo-
mencie subskrypcji korzystamy z puli wątków.

Po udanej kompilacji uruchamiamy aplikację. Nasza podstawowa przeglądarka rze-


czywiście działa, jednak posiada pewien, może nie od razu zauważalny, mankament.
Gdy wpisujemy frazę wyszukiwania powoli, wyniki pojawiają się dla każdej kolejnej
zmiany. To może być wręcz bolesne dla oczu, a zdecydowanie nie sprzyja oszczędzaniu
zapytań, które — jak pamiętamy — ograniczone są do 5000 na miesiąc. Należałoby za-
tem wstrzymać wysyłanie kwerendy podczas wpisywania, a także zaniechać wysyła-
nia kwerendy, jeśli w pewnym momencie wrócimy do pisania frazy (albo ją całkowicie
zmienimy).

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.

Listing 16.9. Modyfikacja zapytania LINQ, by wykorzystać metodę Switch


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))
.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.

Listing 16.10. Modyfikacja sekwencji frazy, by wykorzystać operator DistinctUntilChanged


private void Window_Initialized_1(object sender, EventArgs e)
{
bing = new BingSearchContainer(
new Uri("https://api.datamarket.azure.com/Bing/Search/"))
360 Programowanie równoległe i asynchroniczne w C# 5.0

{
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;
});
}

Obecny stan rozwiązania pozwala na jeszcze jedną możliwość. Metoda PobierzWyniki


jest teraz parametrem metody Select w kwerendzie zbierającej wyniki, która opiera
się na obserwabli frazy. Powoduje to, że można połączyć obie obserwable i usunąć
z przetwarzania dwa obiekty trzymające stan. Ta modyfikacja jest uwzględniona na
listingu 16.11.

Listing 16.11. Redefinicja zapytań w jedno


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)
};

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.

Reactive Extensions powstawała równolegle ze zdarzeniami TPL. Stąd niezależnie


wprowadzono tutaj wygodną transformację, z której obecnie korzystamy. Rx jednak
w zamiarze cały czas miała łączyć różne źródła danych oraz rozwiązania asynchro-
niczne. Z tego powodu, że zarówno Rx, jak i TPL zawierają metody transformacji ze
wzorca APM, a Rx dodatkowo posiada też metody konwersji zadań TPL, więc obecne
są jeszcze, na zasadzie kompatybilności wstecznej, metody konwersji z APM. Metoda
Observable.FromAsyncPattern jest jednak opatrzona atrybutem Obsolete (z ang. prze-
starzałe). W efekcie podczas kompilacji pojawia się ostrzeżenie, co jest przedstawione
na rysunku 16.8.

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.

Listing 16.8. Modyfikacja metody przekształcania wzorca APM do obserwabli


private void Window_Initialized_1(object sender, EventArgs e)
{
bing = new BingSearchContainer(
new Uri("https://api.datamarket.azure.com/Bing/Search/"))
362 Programowanie równoległe i asynchroniczne w C# 5.0

{
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

***

Obecnie tworzone oprogramowanie nadzwyczaj często wymaga zarządzania danymi.


Interakcja ze źródłami danych oraz analiza i przygotowywanie danych do prezentacji
nie mogą utrudniać obsługi takich aplikacji. Reactive Extensions, opierając się na in-
terfejsach IObservable<T> oraz IObserver<T>, wykorzystuje paradygmat programo-
wania typu push-based, czyli programowania reaktywnego. Dzięki temu naturalne
jest odejście od problemów związanych z aktywnym oczekiwaniem. Co więcej, dane
obsługiwane przez program coraz częściej pochodzą z różnych źródeł, natomiast apli-
kacje zajmują się ich porównywaniem oraz określaniem zależności. Wykorzystanie
Rozdział 16.  Przykłady użycia technologii Rx w aplikacjach WPF 363

kwerend LINQ w Rx pozwala na analizę danych w ruchu3 oraz ogłaszanie wyników


przez zdefiniowane subskrypcje. Aby odciążyć główny wątek programu, można sko-
rzystać z wielu mechanizmów oferowanych przez platformę .NET. Rx dzięki wpro-
wadzeniu pojęcia planistów pozwala w bardzo wygodny sposób na użycie tych me-
chanizmów.

Ostatecznie Rx sprowadza się do triady: obserwable, LINQ do zdarzeń oraz parame-


tryczne zarządzanie współbieżnością. Wielką siłą tego rozwiązania jest naturalna
współpraca z innymi technologiami .NET, naturalna ekspresywność oraz czytelność
kodu podczas tworzenia skomplikowanych zapytań pomiędzy sekwencjami zdarzeń. To
wszystko sprawia, że Rx to warte poznania narzędzie.

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.

Powstało wiele wrapperów umożliwiających wykorzystanie technologii CUDA w pro-


gramach pisanych w innych językach niż C. Możliwe stało się wykorzystanie CUDA
w Pythonie3, w środowisku MATLAB4 czy w kodzie napisanym w języku Java5. Nas
interesuje jednak przede wszystkim język C#. W tym przypadku możemy skorzystać
z biblioteki CUDA.NET, którą można pobrać ze strony http://www.cass-hpc.com/
solutions/libraries/cuda-net. Niestety, CUDA.NET nie jest już rozwijana.
1
Obecnie technologia ta znana jest pod nazwą AMD Accelerated Parallel Processing.
2
Lista kart dostępna jest pod adresem https://developer.nvidia.com/cuda-gpus.
3
Zob. http://mathema.tician.de/software/pycuda.
4
Zob. http://sourceforge.net/projects/gpumat.
5
Zob. http://www.jcuda.org.
366 Programowanie równoległe i asynchroniczne w C# 5.0

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.

Pragnę zaznaczyć, że w tym rozdziale czytelnicy nie znajdą informacji na temat


podstaw technologii CUDA. Rozdział nie jest także opisem pisania i optymalizacji
kerneli. Zakładam, że czytelnicy już to potrafią i chcieliby nauczyć się wykorzysty-
wać tę wiedzę w kontekście platformy .NET i języka C#. Osoby, które chcą dopiero
zacząć naukę programowania z wykorzystaniem technologii CUDA, odsyłam do
książki pt. CUDA w przykładach. Wprowadzenie do ogólnego programowania pro-
cesorów GPU autorstwa J. Sandersa i E. Kandrota, która ukazała się w wydawnictwie
Helion w 2012 roku.

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.

CUDAfy.NET w wersji 1.12 współpracuje z systemem operacyjnym Windows XP


(z Service Pack 3) lub wyższym. Do poprawnego działania potrzebuje również sterowni-
ków do karty graficznej obsługującej CUDA w wersji 5.0 lub wyższej. Wersję CUDA,
obsługiwaną przez sterowniki zainstalowane już w systemie, można sprawdzić, wy-
bierając z panelu sterowania ikonę Panel Sterowania NVIDIA, a następnie klikając
ikonę Informacje o systemie w lewym dolnym rogu okna Panel sterowania NVIDIA
(rysunek 17.1).

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

Aby korzystać z CUDAfy.NET, w zmiennej środowiskowej PATH musi być obecna


ścieżka do kompilatora cl.exe języka C/C++ z Visual Studio 20108. Jeśli takiej ścieżki
nie ma, należy ją dodać. W przypadku mojego systemu jest to ścieżka c:\Program
Files (x86)\Microsoft Visual Studio 10.0\VC\bin\amd64\ (rysunek 17.2).

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.

CUDAfy.NET korzysta również z wrappera CUDA.NET oraz dekompilatora ILSpy,


dlatego też w pobranej paczce można znaleźć pliki potrzebne do działania tych dwóch
bibliotek. O wrapperze CUDA.NET wspominałem już wcześniej (warto nadmienić,
że mimo iż ta biblioteka nie jest już rozwijana, autorzy CUDAfy.NET modyfikują ją,
lecz wyłącznie na potrzeby ich produktu), natomiast informacje o dekompilatorze ILSpy
można znaleźć na stronie http://ilspy.net/. Bardzo przydatnymi plikami, z punktu widze-
nia programisty, są także CUDAfy API Documentation.url, który jest skrótem do strony
zawierającej opis API biblioteki Cudafy.NET.dll, oraz podręcznik użytkownika CUDAfy_
User_Manual_1.12.pdf.

Spróbujmy otworzyć w Visual Studio rozwiązanie dostarczone z CUDAfy.NET z pod-


katalogu CudafyByExample. W tym celu otwieramy plik CudafyByExample.sln, który
znajduje się w tym podkatalogu, wciskamy klawisz F6 i kompilujemy całe rozwiąza-
nie. Jeśli wszystkie sterowniki mamy poprawnie zainstalowane, kompilacja powinna
przebiec bez problemów. Spróbujemy uruchomić program, wciskając klawisz F5. Nie-
stety, na moim systemie podczas próby uruchomienia programu zobaczyłem komuni-
kat przedstawiony na rysunku 17.3. Problemem jest domyślny potencjał obliczeniowy
(ang. compute capability) karty graficznej, który ustawiony jest w CUDAfy.NET na 1.3.
Dla mojej karty graficznej powinien on być równy 1.1.

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();

GPGPU gpu = CudafyHost.GetDevice(CudafyModes.Target);


gpu.LoadModule(km);
gpu.Launch().kernel();
Console.WriteLine("Hello, World!");
}

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);

GPGPU gpu = CudafyHost.GetDevice(CudafyModes.Target);


gpu.LoadModule(km);
gpu.Launch().kernel(); // or gpu.Launch(1, 1, "kernel");
Console.WriteLine("Hello, World!");
}
370 Programowanie równoległe i asynchroniczne w C# 5.0

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

Tym razem pierwszy program przykładowy wykorzystujący kartę graficzną wykonał


się prawidłowo. Pojawiły się natomiast kolejne błędy przy próbie uruchomienia ko-
lejnego programu przykładowego (rysunek 17.4). Aby wyeliminować wszystkie pro-
blemy tego typu, należy w każdym dostarczonym przykładzie ustawić odpowiednią
wersję potencjału obliczeniowego karty graficznej. Można to wykonać bardzo szybko,
używając narzędzia wielokrotnej zamiany, co pokazuję na rysunku 17.5. Po wprowa-
dzeniu zmian można już bez problemów uruchomić pozostałe przykłady.

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

Rysunek 17.6. Tworzenie pierwszego projektu w języku C# o nazwie hello_world wykorzystującego


moc obliczeniową karty graficznej

Tworzony program będzie wykorzystywał funkcję umożliwiającą bezpośrednie wy-


świetlanie napisów na monitorze komputera przez karty graficzne (bez konieczności
jawnego pobierania danych z karty graficznej w celu ich wyświetlenia przez host).
Brzmi to może dość dziwnie — przecież karty graficzne właśnie od tego są, aby wy-
świetlać grafikę. Jednak chodzi tu o funkcję podobną do printf z języka C. Taką możli-
wość oferują — niestety — dopiero karty graficzne obsługujące wersję potencjału
obliczeniowego 2.0 i wyższą. Użytkownicy posiadający starsze modele kart nie będą
mogli uruchomić programu. Dla nich zmodyfikuję program w taki sposób, aby i oni
mogli zobaczyć swoje pierwsze „Hello World”.

Po utworzeniu projektu pierwszą czynnością, jaką musimy wykonać, jest dodanie do


niego referencji do biblioteki Cudafy.NET.dll. Jak już wcześniej pisałem, bibliotekę tę
znajdziemy w pobranej paczce, w katalogu CudafyV1.12\bin. Następnym krokiem jest
dodanie do sekcji instrukcji using trzech przestrzeni nazw, które dostarcza ta biblioteka.
W tym momencie kod źródłowy powinien wyglądać tak, jak na listingu 17.3.

Listing 17.3. Przestrzenie nazw dostarczone przez bibliotekę Cudafy.NET.dll.


using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

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)
{
}
}
}

W metodzie Main utworzymy instancję klasy CudafyModule o nazwie modułCuda za


pomocą statycznej metody Cudafy (listing 17.2). Ustawiamy wersję potencjału obli-
czeniowego karty graficznej na 2.0. Ta klasa oprócz własności, które już wcześniej
opisałem, posiada także szereg innych udogodnień. Zawiera metodę Compile, która
kompiluje kod źródłowy przeznaczony do wykonania na GPU za pomocą kompilatora
NVCC. Moduł skompilowany do formatu PTX9 również jest przechowywany przez
instancję klasy CudafyModule. Klasa zawiera także mechanizmy, które dbają o to, aby
kod źródłowy wykonywany na GPU był kompilowany tylko wtedy, kiedy jest to wy-
magane, czyli tylko wtedy, kiedy został zmieniony. W przeciwnym przypadku uży-
wany jest wcześniej skompilowany moduł PTX. Takie podejście znacznie przyśpiesza uru-
chamianie programu. Wszystkie te dane są przechowywane w pliku XML o domyślnym
rozszerzeniu .cdfy tworzonym podczas wywołania metody CudafyTranslator.Cudafy.

Kolejnym krokiem jest pozyskanie uchwytu reprezentującego kartę graficzną, na której


chcemy wykonywać obliczenia. Odbywa się to za pomocą statycznej metody GetDevice
pochodzącej z klasy CudafyHost. Uchwyt jest reprezentowany przez instancje klasy
GPGPU. Następnie musimy załadować modułCuda. Służy do tego metoda LoadModule klasy
GPGPU. Jej argumentem jest instancja klasy CudafyModule. Na listingu 17.4 pokazuję,
jak wygląda funkcja Main na tym etapie tworzenia aplikacji.

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);
}

W tym momencie powinniśmy przejść do implementacji funkcji wykonywanej przez


kartę graficzną. Funkcję taką nazywa się kernelem. Implementowany tu kernel wy-
świetli na monitorze komputera tekst Witaj swiecie!. Kod kernela przedstawiam na li-
stingu 17.5.

9
Zob. http://docs.nvidia.com/cuda/parallel-thread-execution/index.html
Rozdział 17.  CUDA w .NET 373

Listing 17.5. Kernel wyświetlający na monitorze komputera tekst „Witaj świecie!”


[Cudafy]
public static void witajSwiecie()
{
Console.WriteLine("Witaj swiecie!");
}

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);

dynamic startKernel = uchwytGPU.Launch();


startKernel.witajSwiecie();

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();
}

Oba sposoby wywołania kernela witajSwiecie zaprezentowane na listingach 17.7 i 17.8


są równoważne. Obie metody uruchamiają kernel na sieci o rozmiarze 1 i ilości wątków
w bloku również równej 1. Rozmiar sieci oraz bloku (ilość wątków w bloku) określa
się podczas wywoływania metody Launch.

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.

Na tym skończyliśmy implementację naszego pierwszego programu wykonywanego


na GPU. Rezultat jego działania jest zaprezentowany na rysunku 17.7. Efekty nie są
być może zbyt spektakularne, niemniej jednak jest to w pełni działający program wy-
korzystujący moc obliczeniową karty graficznej. Niestety, efektami jego działania
Rozdział 17.  CUDA w .NET 375

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.

Rysunek 17.7. Program hello_world w działaniu

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.

Listing 17.9. Modyfikacja umożliwiająca uruchomienie programu na kartach graficznych z potencjałem


obliczeniowym niższym niż 2.0
static void Main(string[] args)
{
CudafyModule modułCuda = CudafyTranslator.Cudafy(eArchitecture.sm_20);
GPGPU uchwytGPU = CudafyHost.GetDevice(eGPUType.Emulator);

uchwytGPU.LoadModule(modułCuda);

dynamic startKernel = uchwytGPU.Launch(1, 1);


startKernel.witajSwiecie();

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

Warto przyjrzeć się również drugiemu parametrowi metody CudafyHost.GetDevice.


Nie ma on nic wspólnego z emulacją karty graficznej. Jest natomiast przydatny, gdy
komputer wyposażony jest w kilka kart graficznych. Umożliwia określenie, na której
karcie mają wykonywać się nasze obliczenia. Domyślna wartość tego parametru to 0,
co oznacza kartę graficzną o numerze identyfikacyjnym równym 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);

dynamic startKernel = uchwytGPU.Launch(1, 1);


startKernel.witajSwiecie();
Rozdział 17.  CUDA w .NET 377

//wykonanie kernela standardową metodą


//uchwytGPU.Launch(1, 1, "witajSwiecie");

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

Przekazywanie parametrów do kerneli


Utworzymy teraz nowy projekt typu Console Application, o nazwie Iloczyn_Schura.
Trzeba pamiętać w nim o dodaniu odpowiednich referencji do biblioteki CUDAfy.NET
oraz przestrzeni nazw w kodzie źródłowym, tak jak to robiliśmy w programie hello_world.

Przed przystąpieniem do implementacji przydałoby się wyjaśnić pojęcie iloczynu


Schura (inaczej nazywanego także iloczynem Hadamarda lub iloczynem po współ-
rzędnych). Iloczyn Schura (oznaczany przez •) jest operacją matematyczną, która mo-
że być wykonywana na macierzach o dowolnych rozmiarach n×m i która dana jest
wzorem (A•B)ij=a ijbij. Zgodnie ze wzorem, operacja ta polega na pomnożeniu każdego
elementu macierzy A o indeksie ij przez element macierzy B o tym samym indeksie i za-
pisywaniu w macierzy wynikowej, także w elemencie o indeksie ij. Oto przykład:
 1 2   5 6   1  5 2  6   5 12 
 3 4    7 8    3  7 4  8    21 32 
       

Naszym zadaniem będzie przygotowanie kernela umożliwiającego wykonanie takiej


operacji. Kernel będzie się nazywał iloczynSchura, a jego argumentami będą dwie
macierze (elementy macierzy A i B ułożone będą wierszami) oraz trzecia, do której
zapiszemy wynik iloczynu Schura. Jednak z punktu widzenia uczenia się CUDAfy.NET
najważniejszym parametrem kernela będzie obiekt thread klasy GThread. Klasa ta re-
prezentuje wątki CUDA. Umożliwia ona dostęp do identyfikatora wątku za pomocą
pola threadIdx typu dim3 (klasa reprezentuje natywny typ dim3 obecny w języku C for
CUDA). Dzięki niej możemy także odczytać identyfikator bloku (pole blockIdx) oraz
rozmiar bloku (pole blockDim). Wszystkich tych pól użyjemy przy implementacji kernela
iloczynSchura. Zaczniemy więc od zdefiniowania parametrów, jakie będzie przyj-
mować nasz kernel. Potrzebne będą obiekt klasy GThread do reprezentowania sieci, na
jakiej wykonywane będą nasze obliczenia, oraz trzy macierze: dwie na dane wejściowe
Rozdział 17.  CUDA w .NET 379

oraz jedna umożliwiająca zwrócenie wyniku obliczeń. Operacje będziemy wykony-


wać na macierzach o elementach rzeczywistych typu float. W związku z tym, wszystkie
trzy macierze będą typu float[,]. Wobec tego implementowany przez nas kernel ma
sygnaturę taką, jak na listingu 17.11.

Listing 17.11. Nagłówek kernela wykonującego operację iloczynu Schura


[Cudafy]
public static void iloczynSchura(GThread thread, float[,] macierzA, float[,]
macierzB, float[,] wynik)
{
}

W kolejnym etapie przygotujemy ciało kernela. Najpierw musimy wyznaczyć indeksy


elementów macierzy, które będziemy mnożyć. Muszą one uwzględniać rozmiar bloku
oraz identyfikator bloku i wątku, który w danym momencie wykonuje obliczenia. Na li-
stingu 17.12 pokazuję służące do tego instrukcje. Zmienna xIndex reprezentuje wiersze
macierzy, a zmienna yIndex — jej kolumny. Kernela iloczynSchura możemy użyć do
wyznaczenia iloczynu Schura dowolnie dużych macierzy dzięki uwzględnieniu nume-
ru identyfikacyjnego bloku oraz jego rozmiaru przy obliczaniu indeksów wyznaczanego
elementu.

Listing 17.12. Wyznaczenie indeksów elementu macierzy


[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;
}

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;

wynik[xIndex, yIndex] = macierzA[xIndex, yIndex] * macierzB[xIndex, yIndex];


}

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

Listing 17.14. Funkcja Main programu Iloczyn_Schura


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

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.

Przed przesłaniem danych do karty graficznej musimy je utworzyć w pamięci opera-


cyjnej komputera. Zainicjujmy więc macierze A i B, których iloczyn będziemy obli-
czać, oraz utwórzmy macierz Wynik, do której skopiujemy wynik operacji znajdujący
się w pamięci karty graficznej. Zmodyfikujemy kod programu przedstawiony na listingu
17.14 według wzoru z listingu 17.15. Aby łatwo ocenić, czy nasz kernel poprawnie
wykonuje obliczenia, utwórzmy macierze o rozmiarze 2 na 2 i o wartościach elementów
takich samych jak w przykładzie z podrozdziału „Przekazywanie parametrów do kerneli”.

Listing 17.15. Inicjacja macierzy w pamięci operacyjnej 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];
Console.ReadKey();
}
Rozdział 17.  CUDA w .NET 381

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.

Następnym krokiem jest skopiowanie danych z tablic znajdujących się w pamięci


operacyjnej komputera do pamięci globalnej karty. W tym celu używamy metody Copy
ToDevice, która także jest składową klasy dostępną za pomocą obiektu uchwytGPU.
W najprostszej postaci przyjmuje ona jako argumenty wywołania dwie macierze typu
float[,] — źródłową oraz docelową. Po skopiowaniu wszystkich danych możemy
uruchomić kernel i wykonać obliczenia. Będziemy je wykonywać na jednym bloku
o rozmiarze 2 na 2 wątki. Ogólna ilość wątków biorących udział w obliczeniach jest
dokładnie taka sama jak ilość elementów w naszych macierzach. Oznacza to, że oblicze-
nia wykonamy dla wszystkich elementów w tym samym czasie. Warto zwrócić uwagę,
że teraz, uruchamiając kernel, musimy podać argumenty jego wywołania. Wszystkie
powyższe modyfikacje kodu programu przedstawione są na listingu 17.16.

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];

float[,] gpu_A = uchwytGPU.Allocate<float>(A);


float[,] gpu_B = uchwytGPU.Allocate<float>(B);
float[,] gpu_Wynik = uchwytGPU.Allocate<float>(Wynik);

uchwytGPU.CopyToDevice<float>(A, gpu_A);
uchwytGPU.CopyToDevice<float>(B, gpu_B);

    dynamic startKernel = uchwytGPU.Launch(1, new dim3(2, 2));


startKernel.iloczynSchura(gpu_A, gpu_B, gpu_Wynik);

Console.ReadKey();
}

Kod programu przedstawionego na listingu 17.16 powinien skompilować się i wyko-


nać bez problemu. Niestety, nie umożliwia on obejrzenia wyników działania kernela.
Aby to było możliwe, musimy przede wszystkim skopiować wynik operacji do pamięci
382 Programowanie równoległe i asynchroniczne w C# 5.0

operacyjnej komputera. Służy do tego metoda uchwytGPU.CopyFromDevice, która, po-


dobnie jak CopyToDevice, jako argumenty wywołania przyjmuje dwie tablice — źró-
dłową w pamięci karty graficznej i docelową w pamięci gospodarza. Po skopiowaniu
danych możemy już wyświetlić je na monitorze komputera, używając metody Console.
WriteLine. Na zakończenie programu dobrym zwyczajem jest zwolnienie przydzielo-
nej pamięci karty graficznej. Służy do tego metoda uchwytGPU.Free, która zwalnia
przydzielone zasoby. Jej argumentem jest tablica („wskaźnik”) znajdująca się w pa-
mięci karty. Powyższe modyfikacje przedstawione są na listingu 17.17.

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];

float[,] gpu_A = uchwytGPU.Allocate<float>(A);


float[,] gpu_B = uchwytGPU.Allocate<float>(B);
float[,] gpu_Wynik = uchwytGPU.Allocate<float>(Wynik);

uchwytGPU.CopyToDevice<float>(A, gpu_A);
uchwytGPU.CopyToDevice<float>(B, gpu_B);

dynamic startKernel = uchwytGPU.Launch(1, new dim3(2, 2));


startKernel.iloczynSchura(gpu_A, gpu_B, gpu_Wynik);

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

Pomiar czasu wykonania


Zostawimy na razie implementację kerneli. Zajmiemy się natomiast sprawdzeniem, ile
czasu potrzebuje karta graficzna na wykonanie naszych obliczeń. W tym celu zmody-
fikujmy program z listingu 17.17 tak, aby obliczenia wykonywały się na znacznie
większych macierzach, przez co czas wykonywania programu będzie dłuższy. Szcze-
gółowe zmiany w kodzie źródłowym pokazane są na listingu 17.18. Najpierw zdefinio-
wałem zmienne określające rozmiar macierzy (dla uproszczenia zakładam, że macierze
są kwadratowe) oraz liczbę wątków w jednym wymiarze. Następnie zmodyfikowałem
instrukcje tworzące macierze A, B i Wynik. W kolejnym kroku wywołałem funkcję
InicjacjaTablic inicjującą elementy macierzy A i B. Nie będę omawiał szczegółowo
tej funkcji — jej kod jest bardzo prosty. Poza tym sposób inicjacji tablic nie jest ważny
z punktu widzenia korzystania z biblioteki CUDAfy.NET. Ponieważ w obecnej im-
plementacji rozmiary macierzy są znacznie większe, modyfikacji musiało ulec także
wywołanie metody uchwytGPU.Launch. W tej wersji programu obliczenia są wykony-
wane na więcej niż jednym bloku. Liczba bloków w wymiarze X i w wymiarze Y jest ta-
ka sama i równa liczbaDanychWjednymWymiarze/liczbaWątkówWjednymwymiarze. Zmo-
dyfikowałem także sposób wyświetlania danych wejściowych i wyniku. Ponieważ
macierze są duże, wyświetlam tylko podmacierz o rozmiarach 3 na 3 elementy.

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);

int liczbaDanychWjednymWymiarze = 512;


int liczbaWątkówWjednymWymiarze = 16;

//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);

float[,] gpu_A = uchwytGPU.Allocate<float>(A);


float[,] gpu_B = uchwytGPU.Allocate<float>(B);
float[,] gpu_Wynik = uchwytGPU.Allocate<float>(Wynik);

uchwytGPU.CopyToDevice<float>(A, gpu_A);
uchwytGPU.CopyToDevice<float>(B, gpu_B);

dynamic startKernel = uchwytGPU.Launch(new dim3(liczbaDanychWjednymWymiarze /


liczbaWątkówWjednymwymiarze, liczbaDanychWjednymWymiarze /
liczbaWątkówWjednymWymiarze),
new dim3(liczbaWątkówWjednymwymiarze, liczbaWątkówWjednymWymiarze));

startKernel.iloczynSchura(gpu_A, gpu_B, gpu_Wynik);


384 Programowanie równoległe i asynchroniczne w C# 5.0

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.

W celu porównania, o ile obliczenia wykonywane na GPU są szybsze od obliczeń na


CPU, dodałem funkcję obliczającą iloczyn Schura dwóch macierz na CPU. Do wyzna-
czenia czasu wykonania tej funkcji użyłem instancji klasy Stopwatch (listing 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

int liczbaDanychWjednymWymiarze = 512;


int liczbaWątkówWjednymWymiarze = 16;

//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);

float[,] gpu_A = uchwytGPU.Allocate<float>(A);


float[,] gpu_B = uchwytGPU.Allocate<float>(B);
float[,] gpu_Wynik = uchwytGPU.Allocate<float>(Wynik);

uchwytGPU.CopyToDevice<float>(A, gpu_A);
uchwytGPU.CopyToDevice<float>(B, gpu_B);

dynamic startKernel = uchwytGPU.Launch(new dim3(liczbaDanychWjednymWymiarze /


liczbaWątkówWjednymWymiarze, liczbaDanychWjednymWymiarze /
liczbaWątkówWjednymWymiarze),
new dim3(liczbaWątkówWjednymWymiarze, liczbaWątkówWjednymWymiarze));

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);

Stopwatch stopWatch = new Stopwatch();


stopWatch.Start();
iloczynSchuraNaCPU(A, B, Wynik);
stopWatch.Stop();
Console.WriteLine("Wynik obliczeń na CPU:");
WyświetlElementy(Wynik);
TimeSpan ts = stopWatch.Elapsed;
Console.WriteLine();
Console.WriteLine("Czas wykonania obliczeń na CPU: {0} ms", ts.TotalMilliseconds);

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

public static void iloczynSchuraNaCPU(float[,] macierzA, float[,] macierzB,


float[,] wynik)
{
for (int i = 0; i < macierzA.GetLongLength(0); i++)
for (int j = 0; j < macierzA.GetLongLength(1); j++)
{
wynik[i, j] = macierzA[i, j] * macierzB[i, j];
}
}

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

Dostęp zwarty do pamięci globalnej


i pamięć współdzielona
Zajrzymy teraz do pliku CUDAFYSOURCETEMP.cu i sprawdzimy, jak wygląda kernel
iloczynSchura przetłumaczony na język C for CUDA. Szczególnie przyjrzymy się ostat-
niej instrukcji w tym kernelu (listing 17.20):
wynik[(num) * wynikLen1 + ( num2)] = macierzA[(num) * macierzALen1 + ( num2)] *
macierzB[(num) * macierzBLen1 + ( num2)];
Rozdział 17.  CUDA w .NET 387

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)];
}

Należy zauważyć, że do przechodzenia pomiędzy elementami w danym wierszu ma-


cierzy używana jest zmienna num2, która jest wyznaczana na podstawie składowej y
identyfikatora bloku oraz wątku:
int num2 = blockIdx.y * blockDim.y + threadIdx.y;

Przypomnę, że wątki „znajdujące się obok siebie” są numerowane tą samą wartością


składowej y i różnymi, zmieniającymi się o 1, wartościami składowej x. Przypomnę
także, że aby dostęp do pamięci globalnej był jak najbardziej optymalny, należy na
niej wykonywać operacje w tzw. zwarty sposób (ang. coalesced)11. Oznacza to, że
w naszym przypadku operacje na macierzach są wykonywane w nieoptymalny sposób.

Postaram się to zmienić. Utworzymy kopię naszego kernela i nazwiemy go iloczyn


SchuraPamiecWspoldzielona. Parametry wywołania tego kernela są takie same jak jego
poprzednika. W celu zapewnienie zwartego dostępu do pamięci globalnej musimy
utworzyć dwie macierze w pamięci współdzielonej (tego typu pamięć jest dostępna
dla wszystkich wątków w tym samym bloku). Do tych macierzy będziemy kopiować
dane z pamięci globalnej. Wynik obliczeń również będzie przechowywany w pamięci
współdzielonej. W ostatnim kroku zsynchronizujemy wątki oraz skopiujemy wyniki
z pamięci współdzielonej do pamięci globalnej karty graficznej (zoptymalizujemy w ten
sposób zapis danych wyznaczonych przez kernel do pamięci globalnej karty).

Trzeba się zastanowić, w jakiej kolejności powinniśmy wyznaczać elementy macierzy


z wynikami, aby operacje wykonywane na pamięci odbywały się w sposób zwarty. Otóż
należy je obliczać w taki sposób, aby wątki o kolejnych identyfikatorach threadIdx.x
wyznaczały kolejne elementy macierzy wynik w danym wierszu.

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.

Listing 17.21. Wykorzystanie pamięci współdzielonej do zaimplementowania dostępu zwartego do pamięci


globalnej karty graficznej
[Cudafy]
public static void iloczynSchuraPamiecWspoldzielona(GThread thread, float[,]
macierzA, float[,] macierzB, float[,] wynik)
{
float[,] cacheMacierzA = thread.AllocateShared<float>("cache",
liczbaWątkówWjednymWymiarze, liczbaWątkówWjednymWymiarze);
float[,] cacheMacierzB = thread.AllocateShared<float>("cache",
liczbaWątkówWjednymWymiarze, liczbaWątkówWjednymWymiarze);

int xIndex = thread.blockIdx.x * thread.blockDim.x + thread.threadIdx.x;


int yIndex = thread.blockIdx.y * thread.blockDim.y + thread.threadIdx.y;

cacheMacierzA[yIndex, xIndex] = cacheMacierzA[yIndex, xIndex] *


cacheMacierzB[yIndex, xIndex];
thread.SyncThreads();
wynik[yIndex, xIndex] = cacheMacierzA[yIndex, xIndex];
}

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;

static void Main(string[] args)


{
...
        uchwytGPU.LoadModule(modułCuda);

int liczbaDanychWjednymWymiarze = 512;


Rozdział 17.  CUDA w .NET 389

       int liczbaWątkówWjednymWymiarze = 16;


...
Console.WriteLine("Czas wykonania kernela: {0} ms", upłynęłoCzasu);

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);
...
}
}

Na rysunku 17.11 prezentuję uzyskane wyniki. Najciekawsza jest wartość przyśpieszenia


obliczeń wykonywanych przez kernel iloczynSchuraPamiecWspoldzielona w stosunku
do obliczeń z wykorzystaniem CPU (ostatnia linia). W przypadku mojego systemu to
przyśpieszenie wynosiło prawie 41! Myślę, że ten wynik powinien usatysfakcjonować
każdego. W porównaniu z poprzednią wersją kernela, która wykonywała te same ob-
liczenia, nie dbając o optymalny dostęp do pamięci globalnej, jest to ogromne, prawie
czternastokrotne przyśpieszenie. Gdy porównamy uzyskany czas obliczeń za pomocą
kernela iloczynSchuraPamiecWspoldzielona z teoretycznym czasem wykonywania obli-
czeń przez przykładowe cztery rdzenie CPU, stwierdzimy, że przyśpieszenie, jakie uzy-
skaliśmy, jest dziesięciokrotne. Ten wynik również jest imponujący.

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

Generator liczb pseudolosowych


Firma NVidia udostępniła bibliotekę cuRAND, która umożliwia generowanie liczb
pseudolosowych za pomocą karty graficznej. Dostępnych jest kilka algorytmów. Do-
kumentacja dla tej biblioteki znajduje się pod adresem http://docs.nvidia.com/cuda/
curand/index.html. Biblioteka CUDAfy.NET również umożliwia wykorzystanie zalet
cuRAND w aplikacji C#. Pokażę to w tym podrozdziale.

Utworzymy nowy projekt o nazwie cudafy_rand, a następnie dodamy do niego refe-


rencję do biblioteki CUDAfy.NET. Jak zawsze, dodajemy także odpowiednie prze-
strzenie nazw oraz tworzymy instancje niezbędnych klas. Oprócz poznanych wcześniej
przestrzeni nazw dodajemy także Cudafy.Maths.RAND. Zawiera ona klasy, które umożli-
wiają generowanie liczb przy użyciu GPU. W funkcji Main tworzymy za pomocą me-
tody Create instancję klasy GPGPURAND o nazwie generujLiczby. W jednej ze swych
wersji, której będziemy używać, metoda Create przyjmuje trzy argumenty: uchwyt
reprezentujący kartę graficzną, parametr typu wyliczeniowego curandRngType oraz pa-
rametr typu bool. Pierwszy parametr jest już znany z poprzednich programów. Drugi
umożliwia wybór jednej z metod generowania liczb pseudolosowych. Wybiorę CURAND_
RNG_PSEUDO_XORWOW, ale zachęcam czytelników do przetestowania innych. Trzeci ar-
gument określa, czy wygenerowane liczby mają być skopiowane do pamięci RAM
komputera (wartość true), czy też mają być przechowywane w pamięci karty graficz-
nej (wartość false). Domyślnie wartość tego parametru jest ustawiona na false i taką
pozostawimy.

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).

Rysunek 17.12. Wyniki działania programu cudafy_rand


Rozdział 17.  CUDA w .NET 391

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;

static void Main(string[] args)


{
CudafyModule modułCuda = CudafyTranslator.Cudafy(eArchitecture.sm_11);
GPGPU uchwytGPU = CudafyHost.GetDevice(eGPUType.Cuda);
uchwytGPU.LoadModule(modułCuda);

GPGPURAND generujLiczby = GPGPURAND.Create(uchwytGPU,


curandRngType.CURAND_RNG_PSEUDO_XORWOW);
generujLiczby.SetPseudoRandomGeneratorSeed(ziarno);

float[] tablicaLiczbCPU = new float[ilośćElementów];


float[] tablicaLiczbGPU = uchwytGPU.Allocate<float>(tablicaLiczbCPU);
generujLiczby.GenerateUniform(tablicaLiczbGPU);
uchwytGPU.CopyFromDevice(tablicaLiczbGPU, tablicaLiczbCPU);
WyświetlWartości(tablicaLiczbCPU);

uchwytGPU.Free(tablicaLiczbGPU);

Console.ReadKey();
}

static void WyświetlWartości(float[] tablica)


{
Console.WriteLine("Elementy tablicy: ");
for (int i = 0; i < tablica.Length; i++)
Console.WriteLine("tablica[" +i + "]= " + tablica[i]);
}
}
}
392 Programowanie równoległe i asynchroniczne w C# 5.0

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.

Spróbujemy policzyć transformatę FFT na danych, które wygenerowaliśmy w poprzed-


nim podrozdziale za pomocą generatora liczb pseudolosowych. Po pierwsze, musimy
utworzyć instancję klasy GPGPUFFT. Nazwijmy ją gpuFFT. Tworzymy ją, wywołując,
podobnie jak w przypadku GPGPURAND, metodę Create. Pobiera ona jeden parametr bę-
dący uchwytem reprezentującym kartę graficzną. Następnie tworzymy instancję klasy
FFTPlan1D. Klasa przygotowuje bibliotekę cuFFT pod względem optymalizacji obli-
czeń jednowymiarowego FFT, z uwzględnieniem własności sprzętu, na którym obli-
czenia są wykonywane. Oprócz FFTPlan1D biblioteka dostarcza także klasy FFTPlan2D
i FFTPlan3D, które umożliwiają obliczanie szybkich transformat Fouriera na danych
dwu- i trójwymiarowych. Instancję klasy FFTPlan1D tworzymy, wywołując metodę
gpuFFT.Plan1D, która przyjmuje trzy argumenty. Pierwszy jest typu eFFTType i określa
typ wykonywanego FFT. My wykonamy FFT na danych rzeczywistych, a wynik bę-
dzie liczbami zespolonymi (eFFTType.Real2Complex). Drugi parametr oznacza typ danych.
W naszym przypadku jest to eDataType.Single. Ostatni argument określa ilość danych,
na których chcemy wykonać FFT (w tworzonym programie jest to ilośćElementów).
W następnym kroku tworzymy dwie tablice przechowujące wyniki operacji FFT: jed-
ną w pamięci karty graficznej i jedną w pamięci operacyjnej komputera. Obie tablice
mają rozmiar ilośćElementów i są typu ComplexF. Typ ComplexF jest strukturą dostar-
czoną przez bibliotekę CUDAfy.NET. Reprezentuje typ natywny cufftComplex do-
stępny w języku C for CUDA. Po utworzeniu tablic możemy wykonać operację FFT
na przykładowych danych. Służy do tego metoda fft1D.Execute. Pobiera trzy argu-
menty. Pierwsze dwa to tablice: przechowująca dane oraz wynik. Ostatni parametr
jest typu bool i określa, czy ma być wykonana transformata w przód (wartość false),
czy odwrotna (wartość true). Po wykonaniu FFT pozostaje nam skopiować dane do
pamięci RAM oraz wyświetlić je na monitorze. Proszę zauważyć, że liczba danych
wyjściowych jest równa połowie ilości danych wejściowych plus jeden12. W związku
z tym przeciążyłem metodę WyświetlWartości. Zadaniem nowej metody jest wyświe-
tlenie wyniku operacji FFT na danych rzeczywistych w odpowiedni sposób. Na li-
stingu 17.24 przedstawiam wszystkie niezbędne modyfikacje, które należy wykonać,
aby obliczyć FFT na danych losowych.

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;

static void Main(string[] args)


{
CudafyModule modułCuda = CudafyTranslator.Cudafy(eArchitecture.sm_11);
GPGPU uchwytGPU = CudafyHost.GetDevice(eGPUType.Cuda);
uchwytGPU.LoadModule(modułCuda);

GPGPURAND generujLiczby = GPGPURAND.Create(uchwytGPU,


curandRngType.CURAND_RNG_PSEUDO_XORWOW);
generujLiczby.SetPseudoRandomGeneratorSeed(ziarno);

float[] tablicaLiczbCPU = new float[ilośćElementów];


float[] tablicaLiczbGPU = uchwytGPU.Allocate<float>(tablicaLiczbCPU);
generujLiczby.GenerateUniform(tablicaLiczbGPU);
uchwytGPU.CopyFromDevice(tablicaLiczbGPU, tablicaLiczbCPU);
WyświetlWartości(tablicaLiczbCPU);

GPGPUFFT gpuFFT = GPGPUFFT.Create(uchwytGPU);


FFTPlan1D fft1D = gpuFFT.Plan1D(eFFTType.Real2Complex,
eDataType.Single, ilośćElementów);
ComplexF[] fftCPU = new ComplexF[ilośćElementów];
ComplexF[] fftGPU = uchwytGPU.Allocate<ComplexF>(fftCPU);
fft1D.Execute<float, ComplexF>(tablicaLiczbGPU, fftGPU, false);
uchwytGPU.CopyFromDevice(fftGPU, fftCPU);
WyświetlWartości(fftCPU);

uchwytGPU.Free(tablicaLiczbGPU);

Console.ReadKey();
}

static void WyświetlWartości(ComplexF[] tablica)


{
Console.WriteLine("Elementy tablicy: ");
for (int i = 0; i < (int)Math.Truncate(tablica.Length / 2.0) + 1; i++)
Console.WriteLine("tablica[" + i + "]= " + tablica[i]);
}

static void WyświetlWartości(float[] tablica)


{
Console.WriteLine("Elementy tablicy: ");
for (int i = 0; i < tablica.Length; i++)
394 Programowanie równoległe i asynchroniczne w C# 5.0

Console.WriteLine("tablica[" +i + "]= " + tablica[i]);


}
}
}

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.

Modyfikacje, które musimy wykonać, są bardzo proste, bo jedyną niezbędną czynnością


jest utworzenie instancji klasy GPGPUBLAS. Z jej pomocą możemy wywoływać metody
wykonujące podstawowe operacje algebraiczne na wektorach i macierzach. W programie
z listingu 17.25 wykorzystywaną metodą z tej biblioteki jest DOT, która oblicza iloczyn
skalarny dwóch wektorów. Z opisem wszystkich funkcji, jakie mamy do dyspozycji
w cuBLAS, można zapoznać się pod adresem http://docs.nvidia.com/cuda/cublas/index.
html#topic_6_1.

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);

GPGPUBLAS gpuBLAS = GPGPUBLAS.Create(uchwytGPU);


float iloczynSkalarny = gpuBLAS.DOT(tablicaLiczbGPU, tablicaLiczbGPU);
Console.WriteLine("iloczyn skalarny tablicaLiczbGPU * tablicaLiczbGPU = " +
iloczynSkalarny);

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

tekstu wykorzystano obiekt typu TextBlock o nazwie InfoLabel. Operacje związane


z przekazywaniem tekstu do tej kontrolki zamieszczono w metodzie msg (listing A.1).

Listing A.1. Zastąpienie Console.WriteLine metodą msg


private static TaskScheduler ts;

void msg(string komunikat)


{
Task.Factory.StartNew(() =>
{
InfoLabel.Text += komunikat + "\n";
}, CancellationToken.None, TaskCreationOptions.None, ts);
}

private void Button_Click_1(object sender, RoutedEventArgs e)


{
ts = TaskScheduler.FromCurrentSynchronizationContext();
/* ... */
}

Jak widać na listingu A.1, obliczenia wykonywane będą po wciśnięciu przycisku


w związanej z tym zdarzeniem metodzie Button_Click_1. Ponieważ metoda wykony-
wana jest w wątku interfejsu, jest to odpowiednie miejsce, aby zainicjować obiekt
statyczny typu TaskScheduler.

Mając tak przygotowane narzędzia do przeniesienia elementów konsolowych do in-


terfejsu graficznego, można przystąpić do testowania programów z książki w środo-
wisku WinRT. Poniżej zaprezentowane zostały przykłady z części pierwszej i drugiej
tej książki, które w opinii autora pozwalają najlepiej odzwierciedlić podobieństwa i róż-
nice w programowaniu równoległym w tych środowiskach. Ponieważ porównywane
zagadnienia związane są z konkretnymi klasami lub metodami, dodatek podzielony
został na podrozdziały ich dotyczące.

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.

Listing A.2. Podstawy pracy z klasą Task


Action a = () =>
{
msg("Start zadania nr " + Task.CurrentId);
Task.Delay(100).Wait();
msg("Koniec zadania nr " + Task.CurrentId);
};

List<Task> listaZadan = new List<Task>();


Dodatek A  Biblioteka TPL w WinRT 399

for (int i = 0; i < 100; i++)


{
listaZadan.Add(new Task(a));
}
foreach (var t in listaZadan)
{
t.Start();
}
foreach (var t in listaZadan)
{
t.Wait();
}

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).

Rysunek A.1. Fragment dokumentacji klasy List<T> (por http://msdn.microsoft.com/en-us/library/


6sh2ey19.aspx) z adnotacjami dotyczącymi środowisk, w których dana metoda jest dostępna

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.

Listing A.3. Kod zastępujący metodę SpinWait klasy Thread


public static void SpinWait(int iterations)
{
SpinWait sw = new SpinWait();
for(int i = 0 ;i<iterations; i++)
{
sw.Reset();
sw.SpinOnce();
}
}

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).

Listing A.4. Kod zastępujący metodę Sleep klasy Thread


public static void Sleep(int millisecondsTimeout)
{
Task.Delay(millisecondsTimeout).Wait();
}
Dodatek A  Biblioteka TPL w WinRT 401

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.

Na listingu A.5 przedstawiono zmodyfikowany kod programu z listingu 2.12, w któ-


rym liczba π liczona była przez wiele wątków powołanych do życia przy użyciu puli
wątków. Powyższy kod stanowi jedynie wycinek programu. W obrębie tego frag-
mentu kodu nastąpiło jednak najwięcej kluczowych zmian. W pozostałych metodach
zmieniony został jedynie sposób wypisywania informacji.

Listing A.5. Praca z ThreadPool w aplikacjach WinRT


int czasPoczatkowy = Environment.TickCount;

//tworzenie wątków
WorkItemHandler metodaWatku = uruchamianieObliczenPi;

//czekanie na zakończenie wątków


int ileDzialajacychWatkowPuli = 0;

for (int i = 0; i < ileWatkow; ++i)


{
Interlocked.Increment(ref ileDzialajacychWatkowPuli);
IAsyncAction pracaDoWykonania = ThreadPool.RunAsync(metodaWatku);
pracaDoWykonania.Completed = new AsyncActionCompletedHandler((IAsyncAction
source, AsyncStatus status) =>
{
msg("Zadanie wykonane");
Interlocked.Decrement(ref ileDzialajacychWatkowPuli);
});
}

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)));

int czasKoncowy = Environment.TickCount;


int roznica = czasKoncowy - czasPoczatkowy;
msg("Czas obliczeń: " + (roznica).ToString());

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

Kod przedstawiony na listingu A.5 przeszedł szereg modyfikacji. Usunięto z niego


zmienne ileDostepnychWatkowWpuli i ileWszystkichWatkowWPuli. Do takich informacji
i tak nie ma dostępu w nowej klasie ThreadPool (nie ma w niej metod GetAvailable
Threads, GetMaxThreads i SetMaxThreads). Zmieniony został typ metody obsługi
zdarzenia z WaitCallBack, którego nie ma w WinRT, na WorkItemHandler. Pętla ocze-
kująca na zakończenie pracy wszystkich wątków i wypisująca ilość wątków zajętych
oraz wolnych również została zmodyfikowana. Ze względu na brak dostępu do infor-
macji o ilości pracujących wątków oczekuje ona jedynie na zakończenie pracy wątków
puli. Najważniejsza zmiana dotyczy jednak dodawania operacji do puli wątków —
zamiast metody QueueUserWorkItem wywołujemy RunAsync, a następnie definiujemy
dla każdego obiektu zwróconego przez tę metodę obsługę zdarzenia wywoływanego
w momencie zakończenia pracy wątku. Ponieważ nie wiadomo, ile wątków jest w danym
momencie w puli, wykorzystano zmienną ileDzialajacychWatkowWPuli, której war-
tość jest zwiększana przy tworzeniu wątku i zmniejszana podczas zakończenia pracy.

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.

Listing A.6. Przykład użycia klasy ThreadPoolTimer


int ileWatkow = 10;
int ilośćPróbWWątku = 10;
long całkowitaIlośćPrób = 0;

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.

Aby to pokazać, przeniesiono do środowiska WinRT aplikację WPF opisaną w rozdziale


10. (listing 10.4). Dla przypomnienia: przykład ten oparty jest na pętli Parallel.For,
a uruchomienie tej pętli odbywa się w osobnym zadaniu, aby interfejs programu nie
był blokowany w trakcie wykonywania obliczeń. W aplikacji wykorzystano wprowa-
dzone w .NET 4.5 operatory async i await (rozdział 1.). Na rysunku A.2 zaprezento-
wano najważniejsze elementy interfejsu aplikacji — widać tutaj podobieństwo do
aplikacji WPF (rysunek 10.4). Dodana została jedynie etykieta typu TextBlock, w której
wyświetlana będzie ewentualna informacja o błędzie wprowadzonych danych. Na li-
stingu A.7 pokazano metodę wykonującą obliczenia. Jak widać, jest on niemal iden-
tyczny z listingiem 10.4. Jedyną różnicą jest wykorzystanie elementu tbError zamiast
okna tworzonego za pomocą MessageBox.

Rysunek A.2.
Elementy interfejsu
aplikacji Windows
Store obliczającej
przybliżenie liczby π

Listing A.7. Wykorzystanie operatorów async i await w aplikacji WinRT


private async void bOblicz_Click(object sender, RoutedEventArgs e)
{
int n;

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

);

tbWynik.Text = (await t).ToString();


bOblicz.IsEnabled = true;
}

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.

Listing A.8. Kod klasy Zadania


using System.Collections.Concurrent;
using System.Linq;
using System.Threading.Tasks;
Dodatek A  Biblioteka TPL w WinRT 405

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.

Listing A.9. Zastosowanie metody ObliczPi w aplikacjach z interfejsem graficznym


int i = int.Parse(tbDana.Text);
double pi = await Zadania.ObliczPi(i);
tbWynik.Text = pi.ToString();

Niestety, powyższa biblioteka nie będzie działać w aplikacji Silverlight, ze względu na


brak obsługi kolekcji współbieżnych w tym środowisku. Z powodzeniem jednak można
ją wykorzystać w aplikacjach konsolowych. Zostało to przedstawione na listingu A.10.

Listing A.10. Zastosowanie metody ObliczPi w aplikacji konsolowej


static void Main(string[] args)
{
var zadanie = Zadania.ObliczPi(10000000);
zadanie.Wait();
Console.WriteLine(zadanie.Result);
}

***
406 Programowanie równoległe i asynchroniczne w C# 5.0

Jak widać na powyższych przykładach, większość klas i struktur, a szczególnie te,


które wprowadzono w wersjach .NET 4.0 i 4.5, dostępna jest również w WinRT. Usunięto
z niej jednak wszelkie klasy i struktury pozwalające na bezpośredni dostęp do wątków.
W aplikacjach Windows Store można używać obiektów typu CancellationToken, ko-
lekcji współbieżnych, synchronizacji z wykorzystaniem lock, Monitor, Barrier,
Interlocked i semaforów4. Można też bez obaw korzystać z dobrodziejstw PLINQ i klasy
Parallel.

Powyżej przedstawiono kilka różnic między środowiskami .NET i WinRT. Pokazano


także rozwiązania prostych problemów, które czytelnicy napotkają przy przenoszeniu
projektów. Nie są to jednak wszystkie różnice między opisywanymi środowiskami.
Dokładniejszych informacji należy szukać na stronach MSDN.

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.

Sekcje krytyczne i zakleszczenia


Błędne wykorzystanie sekcji krytycznych oraz synchronizacji wątków jest zazwyczaj
przyczyną zakleszczeń. Z tego powodu próba uzyskania dostępu do współdzielonych
zasobów powinna być realizowana z ostrożnością i zachowaniem dodatkowych reguł,
które opiszę w tym podrozdziale.

Typową sytuacją, która może spowodować zakleszczenie wątków, jest nieskończone


oczekiwanie na zwolnienie współdzielonego zasobu. Aby nie być gołosłownym, posłużę
się następującym przykładem, w którym utworzę prostą aplikację Windows Forms.
408 Programowanie równoległe i asynchroniczne w C# 5.0

1. Utwórz projekt aplikacji Windows Forms o nazwie DobrePraktyki.


2. Na formie aplikacji umieść trzy przyciski, kontrolkę typu ToolStrip
oraz komponent BackgroundWorker.
3. Etykiety przycisków zmień na: Uruchom wątek i zablokuj współdzielony
zasób, Zatrzymaj wątek i zwolnij blokadę, Odczytaj dane zapisane we
współdzielonym zasobie.
4. Nazwę komponentu BackgroundWorker zmień na
backgroundWorkerSekcjeKrytyczne, a komponentu ToolStrip na
toolStripStatus.
5. Przejdź do edycji kodu źródłowego aplikacji, czyli otwórz plik Form1.cs.
6. Wstaw w nim polecenia z listingu B.1.

Listing B.1. Nieskończone oczekiwanie na zwolnienie współdzielonego zasobu prowadzi do zakleszczenia


using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;

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;
}

private void KonfigurujWatekSekcjeKrytyczne()


{
backgroundWorkerSekcjeKrytyczne.WorkerSupportsCancellation
= true;
backgroundWorkerSekcjeKrytyczne.DoWork +=
backgroundWorkerSekcjeKrytyczne_DoWork;
}

void backgroundWorkerSekcjeKrytyczne_DoWork(object sender,


DoWorkEventArgs e)
Dodatek B  Dobre praktyki programowania aplikacji wielowątkowych 409

{
Monitor.Enter(_wspoldzielonyZasob);

while (!backgroundWorkerSekcjeKrytyczne.CancellationPending)
{
// Blokuj dostęp do pola _wspoldzielonyZasob
}
Monitor.Exit(_wspoldzielonyZasob);
}

private void buttonOdczytajDane_Click(object sender,


EventArgs e)
{
Monitor.Enter(_wspoldzielonyZasob);
MessageBox.Show(_wspoldzielonyZasob.ToString());

Monitor.Exit(_wspoldzielonyZasob);
}

private void buttonZatrzymajWatekSekcjeKrytyczne_Click(


object sender, EventArgs e)
{
if (backgroundWorkerSekcjeKrytyczne.IsBusy)
{
backgroundWorkerSekcjeKrytyczne.CancelAsync();
toolStripWatekSekcjeKrytyczne.Text =
_watekSekcjeKrytyczneNieJestAktywny;
}
}

private void buttonUruchomWatekSekcjeKrytyczne_Click(


object sender, EventArgs e)
{
if (!backgroundWorkerSekcjeKrytyczne.IsBusy)
{
backgroundWorkerSekcjeKrytyczne.RunWorkerAsync();
toolStripWatekSekcjeKrytyczne.Text =
_watekSekcjeKrytyczneAktywny;
}
}

}
}

Zasada działania powyższej aplikacji nie jest skomplikowana. Po kliknięciu przycisku


z etykietą Uruchom wątek i zablokuj współdzielony zasób następuje uruchomienie
wątku roboczego, który wywołuje metodę Enter klasy Monitor na rzecz pola
_wspoldzielonyZasob, blokując w ten sposób dostęp do tego pola z funkcji innych wąt-
ków. W przypadku gdy ten wątek roboczy jest aktywny, po kliknięciu przycisku z ety-
kietą Odczytaj dane zapisane we współdzielonym zasobie nastąpi próba uzyskania dostę-
pu do pola _wspoldzielonyZasob. Ponieważ zasób ten został wcześniej zablokowany,
próba uzyskania do niego dostępu z innego wątku (w tym przypadku jest to wątek UI)
nie powiedzie się. W efekcie wątek UI jest blokowany na wywołaniu metody Enter
klasy Monitor i oczekuje na zwolnienie zasobu _wspoldzielonyZasob, co — oczywi-
410 Programowanie równoległe i asynchroniczne w C# 5.0

ście — nie nastąpi. Z tego powodu aplikacja stwarza wrażenie zawieszonej, a jedynym
ratunkiem jest przerwanie jej działania.

Taki nieskończony sposób oczekiwania na zwolnienie współdzielonego zasobu nie jest


poprawny. W celu rozwiązania tego problemu wystarczy zmodyfikować implementa-
cję metody zdarzeniowej przycisku z etykietą Odczytaj dane zapisane we współdzie-
lonym zasobie zgodnie z listingiem B.2. Dzięki temu aplikacja nie zawiesi się, ponieważ
czas oczekiwania na uzyskanie dostępu do pola _wspoldzielonyZasob będzie skoń-
czony, a po jego upłynięciu mogą zostać podjęte działania naprawcze. W tym przy-
padku ograniczyłem się jedynie do wyświetlenia komunikatu o błędzie.

Listing B.2. Nieskończone oczekiwanie na uzyskanie dostępu do współdzielonego zasobu


private void buttonOdczytajDane_Click(object sender, EventArgs e)
{
Monitor.Enter(_wspoldzielonyZasob);
MessageBox.Show(_wspoldzielonyZasob.ToString());
Monitor.Exit(_wspoldzielonyZasob);
const int msTimeOut = 500;
if (Monitor.TryEnter(_wspoldzielonyZasob, msTimeOut))
{
MessageBox.Show(_wspoldzielonyZasob.ToString());

if (Monitor.IsEntered(_wspoldzielonyZasob))
{
Monitor.Exit(_wspoldzielonyZasob);
}
}
else
{
MessageBox.Show("Przekroczono limit czasu oczekiwania na
zwolnienie współdzielonego zasobu");
}
}

Skończone oczekiwanie nie dotyczy wyłącznie metody Monitor.TryEnter. Jest ono


dobrą praktyką w wielu innych aspektach programowania współbieżnego. Jako przy-
kłady można podać oczekiwanie na sygnalizację zdarzeń, zakończenia metod wątków
roboczych, realizację działań asynchronicznych czy wreszcie oczekiwanie na zakoń-
czenie transmisji danych. We wszystkich przypadkach dobre praktyki nakazują ogra-
niczenie czasu oczekiwania na wystąpienie odpowiednich zdarzeń oraz odpowiednią
implementację na wypadek przekroczenia czasu oczekiwania.
Na zakończenie tego podrozdziału warto zwrócić uwagę to, że programista musi za-
dbać o poprawne zakończenie sekcji krytycznej za pomocą metody Exit klasy Monitor.
Dobre praktyki nakazują otaczanie kodu uruchamianego wewnątrz sekcji krytycznej
blokiem try catch finally i bezwzględne umieszczenie wywołania metody Exit
klasy Monitor w bloku finally. Dzięki temu sekcja krytyczna zawsze zostanie zakoń-
czona, nawet w przypadku wystąpienia wyjątku.

Mechanizm opisany w powyższym akapicie jest równoważny z użyciem słowa kluczo-


wego lock, omówionego w rozdziale 4. Jednakże implementacja oparta na tym słowie
kluczowym nie umożliwia skończonego oczekiwania na zwolnienie sekcji krytycznej.
Dodatek B  Dobre praktyki programowania aplikacji wielowątkowych 411

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.

W tym podrozdziale uzupełnię projekt DobrePraktyki o przykładowy kod, generujący


wyścig oraz pokażę, w jaki sposób można zabezpieczyć aplikację przed wystąpieniem
tego efektu. Przykładowe wygenerowanie wyścigu polegać będzie na utworzeniu
określonej liczby wątków, których zadaniem będzie inkrementacja wartości zapisanej
w polu klasy, pełniącej rolę współdzielonego zasobu. Realizacja tego zadania w aplikacji
DobrePraktyki wymaga wykonania następujących czynności.
1. Na formie aplikacji umieść przycisk z etykietą Inkrementuj wspólny zasób
oraz kontrolkę typu ListBox.
2. Kliknij dwukrotnie lewym przyciskiem myszy kontrolkę typu Button i w tak
utworzonej domyślnej metodzie zdarzeniowej umieść polecenia z listingu B.3.

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;

const int liczbaWatkow = 400;


Thread[] watki = new Thread[liczbaWatkow];

for (int i = 0; i < watki.Length; i++)


{
watki[i] = new Thread(AktualizujWspoldzielonePole);
watki[i].Start();
}

const int msTimeOut = 50000;


for (int i = 0; i < watki.Length; i++)
{
watki[i].Join(msTimeOut);
}

listBoxWyniki.Items.Add(_wspolnyZasob.ToString());
}

private void AktualizujWspoldzielonePole (Object kwota)


{
_wspolnyZasob++;
}

3. Uruchom aplikację i kilkakrotnie kliknij przycisk z etykietą Inkrementuj wspólny


zasób. Wynik jej działania powinien być analogiczny do tego z rysunku B.1.
412 Programowanie równoległe i asynchroniczne w C# 5.0

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.

W tym przykładzie problem wyścigu można stosunkowo łatwo rozwiązać za pomocą


statycznej metody Increment klasy Interlocked (rozdział 2.). Jej wywołanie wystarczy
umieścić w metodzie AktualizujWspoldzielonePole (listing B.4). Po wykonaniu tej
zmiany wynik działania aplikacji będzie przewidywalny (rysunek B.2), bo dostęp do
współdzielonego pola będzie atomowy — każdy wątek uzyska do niego wyłączny dostęp.

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);
}

W tym miejscu warto dodać, że zamiast wykorzystywać klasę Interlocked do atomowej


inkrementacji wartości zapisanej we współdzielonym zasobie można samodzielnie utwo-
rzyć sekcję krytyczną za pomocą słowa kluczowego lock lub z wykorzystaniem
statycznych metod klasy Monitor. Jednakże wydajność implementacji atomowego dostę-
pu w oparciu o klasę Interlocked jest wydajniejsza i jest to preferowany mechanizm.
Dodatek B  Dobre praktyki programowania aplikacji wielowątkowych 413

Rysunek B.2.
Wynik działania aplikacji
jest teraz poprawny we
wszystkich przypadkach

W powyższym przykładzie zjawisko wyścigu wynikało z tego, że wątki nie uzyski-


wały wyłącznego dostępu do współdzielonego dostępu. Wyścig może wystąpić również
w przypadku, gdy działanie aplikacji uzależnione jest od kolejności wykonywania
funkcji wątków. W takiej sytuacji implementację atomowego dostępu do współdzie-
lonych zasobów należy zastąpić odpowiednią synchronizacją w oparciu o obiekty opisa-
ne w rozdziale 4.

Przykład naiwnej implementacji, zakładającej, że wątki uzyskują czas procesora sekwen-


cyjnie, obejmuje wykonanie zmian w metodach buttonInkrementujWspolnyZasob_Click
oraz AktualizujWspoldzielonePole według wzoru, który przedstawiłem na listingu
B.5. W poniższej implementacji funkcje wątków zapisują w polu _wspolnyZasob licz-
by całkowite równe indeksom pętli. Każdy indeks jednoznacznie odpowiada procedurom,
za pomocą których wątki zostały utworzone i uruchomione. Dzięki temu wiadomo,
który wątek zakończył swoją pracę jako ostatni.

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;

const int liczbaWatkow = 400;


Thread[] watki = new Thread[liczbaWatkow];
_sygnalStartowy.Reset();
414 Programowanie równoległe i asynchroniczne w C# 5.0

for (int i = 0; i < watki.Length; i++)


{
watki[i] = new Thread(AktualizujWspoldzielonePole);
watki[i].Name = i.ToString();
watki[i].Start();
}

// Funkcje wątków oczekują na sygnalizację tego zdarzenia


_sygnalStartowy.Set();

const int msTimeOut = 50000;


for (int i = 0; i < watki.Length; i++)
{
watki[i].Join(msTimeOut);
}

listBoxWyniki.Items.Add(_wspolnyZasob.ToString());
}

private void AktualizujWspoldzielonePole()


{
_wspolnyZasob++;
Interlocked.Increment(ref _wspolnyZasob);

int numerWatku = Convert.ToInt32(Thread.CurrentThread.Name);

const int msTimeOut = 1000;


_sygnalStartowy.Wait(msTimeOut);

_wspolnyZasob = numerWatku;
}

Wspomniane indeksy przechowywane są we własności Name obiektu typu Thread. Jeśli


wątki będą sekwencyjnie uzyskiwały dostęp do czasu procesora, liczbą wyświetloną
w komponencie typu ListBox powinna być wartość liczbaWatkow — 1, co odpowiada
ostatniemu indeksowi pętli for, w której wątki są tworzone, a następnie uruchamiane.
Jednak, jak widać z rysunku B.3, taki wynik został uzyskany jedynie w dwóch przy-
padkach.

Wykorzystany przeze mnie obiekt typu ManualResetEventSlim posłużył do zsynchro-


nizowania funkcji wątków — wszystkie czekają na zasygnalizowanie tego zdarzenia.
Jak pokazuje przygotowany przeze mnie przykład, kolejność uzyskiwania dostępu do
zasobów sprzętowych przez wątki jest losowa. Aby nad nią zapanować, należy wprowa-
dzić dodatkowe mechanizmy synchronizacyjne. Listę niezbędnych zmian przedsta-
wiam na listingu B.6.

Listing B.6. Funkcje wątków wykonywane są teraz synchronicznie


private int _wspolnyZasob;
ManualResetEventSlim _sygnalStartowy = new ManualResetEventSlim(false);
AutoResetEvent[] _sygnaly;
Dodatek B  Dobre praktyki programowania aplikacji wielowątkowych 415

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

private void buttonInkrementujWspolnyZasob_Click(object sender, EventArgs e)


{
_wspolnyZasob = 0;

const int liczbaWatkow = 400;


Thread[] watki = new Thread[liczbaWatkow];
_sygnaly = new AutoResetEvent[liczbaWatkow];

_sygnalStartowy.Reset();

for (int i = 0; i < watki.Length; i++)


{
watki[i] = new Thread(AktualizujWspoldzielonePole);
watki[i].Name = i.ToString();
watki[i].Start();

_sygnaly[i] = new AutoResetEvent(i == 0 ? true : false);


}

// Wątki należy uruchomić po utworzeniu tablicy obiektów AutoResetEvent


for (int i = 0; i < watki.Length; i++)
{
watki[i].Start();
}

// Funkcje wątków oczekują na sygnalizację tego zdarzenia


_sygnalStartowy.Set();

const int msTimeOut = 50000;


for (int i = 0; i < watki.Length; i++)
416 Programowanie równoległe i asynchroniczne w C# 5.0

{
watki[i].Join(msTimeOut);
}

listBoxWyniki.Items.Add(_wspolnyZasob.ToString());
}

private void AktualizujWspoldzielonePole()


{
int numerWatku = Convert.ToInt32(Thread.CurrentThread.Name);

const int msTimeOut = 1000;


_sygnalStartowy.Wait(msTimeOut);

if (_sygnaly[numerWatku].WaitOne(msTimeOut))
{
_wspolnyZasob = numerWatku;
}
else
{
Debug.WriteLine("Przekroczono limit oczekiwania
na sygnalizację zdarzenia");
}

// Wysłanie sygnału do funkcji następnego wątku


if(numerWatku < _sygnaly.Length - 1)
{
_sygnaly[numerWatku + 1].Set();
}
}

W celu synchronizacji pracy wątków uzupełniłem kod źródłowy aplikacji Dobre-


Praktyki o tablicę obiektów typu AutoResetEvent. Tablica ta służy do informowania
funkcji wątków o możliwości rozpoczęcia swojego działania. Na początku tylko pierw-
szy element tej listy jest w stanie zasygnalizowanym, co umożliwia pierwszemu wąt-
kowi wykonanie swojej pracy. Po jej zakończeniu wątek ten zmienia stan drugiego
elementu listy _sygnaly na zasygnalizowany, co umożliwia drugiemu wątkowi wy-
konanie swojej funkcji. Proces ten jest kontynuowany dla pozostałych wątków. Dzię-
ki temu są one uruchamiane sekwencyjnie, a wynik działania aplikacji jest przewidy-
walny i zawsze taki sam (rysunek B.4). Podobny efekt można uzyskać za pomocą
metody Join obiektu Thread — kolejne wątki musiałyby wtedy oczekiwać na zakoń-
czenie funkcji poprzednich wątków. Oczywiście, w tej konkretnej sytuacji użycie tylu
wątków mija się z celem. Sekwencyjną inkrementację pola _wspolnyZasob można zre-
alizować za pomocą zwykłej pętli w jednej funkcji wątku. Powyższy projekt stanowi
jedynie przykład synchronizacji pracy wielu wątków w celu uniknięcia wyścigu.

Synchronizacja pracy wątków pozwala uniknąć wyścigu. Jednak, z drugiej strony,


wprowadza ona opóźnienia w działaniu aplikacji. Z tego powodu zaleca się, aby apli-
kacje wielowątkowe były projektowane w taki sposób, aby zminimalizować użycie
obiektów synchronizujących pracę wątków. Dotyczy to szczególnie projektów typu
Class Library, czyli bibliotek DLL.
Dodatek B  Dobre praktyki programowania aplikacji wielowątkowych 417

Rysunek B.4.
Synchronizacja pracy
wątków pozwala
uniknąć negatywnych
skutków wyścigu

Słowo kluczowe volatile


i kontrola pętli wykonywanej
w ramach funkcji wątku
W kilku miejscach w tej książce posługiwaliśmy się polami typu bool do kontroli
wykonywania pętli w ramach funkcji wątków roboczych. Taki mechanizm wymaga,
aby deklaracje danych pól były uzupełnione o słowo kluczowe volatile (rozdział 3.),
informujące kompilator o tym, że stan danej zmiennej jest kontrolowany przez różne
wątki. Zapewni to, że kompilator nie zoptymalizuje kodu aplikacji, którego działanie
opiera się na tych polach, co mogłoby doprowadzić do niepożądanych skutków.

W tym podrozdziale zilustruję wpływ procedur optymalizacyjnych na działanie aplikacji


wielowątkowej. Przykład ten obejmuje następujące zmiany w projekcie DobrePraktyki.
1. Na formie aplikacji umieść kolejny przycisk z etykietą Uruchom wątek.
2. W domyślnej metodzie zdarzeniowej przycisku wstaw polecenia z listingu B.7.

Listing B.7. Tworzenie i uruchamianie funkcji wątku roboczego


private bool _watekAktywny = false;
private void buttonUruchomWatek_Click(object sender, EventArgs e)
{
if (!_watekAktywny)
418 Programowanie równoległe i asynchroniczne w C# 5.0

{
Thread thread = new Thread(ThreadFunc);
_watekAktywny = true;

thread.IsBackground = true;
thread.Start();

Thread.Sleep(1000);

_watekAktywny = false;

listBoxWyniki.Items.Add("Wątek powinien zakończyć


swoje działanie i wyświetlić wynik...");
}
}

private void ThreadFunc()


{
ulong licznik = 0;

while (_watekAktywny)
{
licznik++;
}

AddItemToListBoxTS(listBoxWyniki, licznik);
}

private delegate void AddItemToListBoxDelegate(


ListBox listBox, object item);

private void AddItemToListBoxTS(ListBox listBox, object item)


{
if (listBox.InvokeRequired)
{
listBox.BeginInvoke(new
AddItemToListBoxDelegate(AddItemToListBoxTS),
new object[] { listBox, item });
}
else
{
listBox.Items.Add(item);
listBox.SelectedIndex = listBox.Items.Count - 1;
}
}

3. W menu Project kliknij opcję DobrePraktyki Properties….


4. Przejdź na zakładkę Build i zaznacz opcję Optimize Code.
5. Skompiluj aplikację w trybie Release i uruchom ją spoza środowiska Visual
Studio 2012.

W powyższym przykładzie metoda zdarzeniowa przycisku buttonUruchomWatek two-


rzy i uruchamia wątek, który w pętli while inkrementuje wartość zmiennej lokalnej
licznik. Pętla ta wykonywana jest, dopóki pole _watekAktywny ma wartość true. Po
Dodatek B  Dobre praktyki programowania aplikacji wielowątkowych 419

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;

Dzięki temu unikniemy powyżej opisanego efektu. Alternatywnie, w tym konkretnym


przypadku procedury optymalizacyjne zostaną pominięte przez kompilator, jeśli we-
wnątrz pętli while umieścimy wywołanie funkcji Thread.Sleep z argumentem więk-
szym od zera.

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.

W tym podrozdziale skoncentruję się na aspekcie bezpieczeństwa wątków w statycznych


konstruktorach. W tym przypadku bezpieczeństwo wątków oznacza, że jeśli w statycz-
nym konstruktorze klasy tworzony i uruchamiany jest wątek roboczy, jest on automa-
tycznie blokowany do momentu zakończenia wykonywania konstruktora. Zilustruję
to następującym przykładem.
420 Programowanie równoległe i asynchroniczne w C# 5.0

1. Uzupełnij projekt aplikacji DobrePraktyki o plik SampleClass.cs.


2. Przejdź do edycji pliku SampleClass.cs i wstaw w nim polecenia z listingu B.8.

Listing B.8. Zawartość pliku SampleClass.cs


using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace DobrePraktyki
{
class SampleClass
{
static SampleClass()
{
Debug.WriteLine("Konstruktor został uruchomiony: "
+ DateTime.Now);

Thread thread = new Thread(ThreadFunc);


thread.Start();

Thread.Sleep(1000);

Debug.WriteLine("Konstruktor zakończył swoją pracę: "


+ DateTime.Now);
}

private static void ThreadFunc()


{
Debug.WriteLine("Funkcja wątku została uruchomiona: "
+ DateTime.Now);
}
}
}

3. Na formie aplikacji DobrePraktyki umieść kolejny przycisk, a jego domyślną


metodę zdarzeniową zdefiniuj według listingu B.9.

Listing B.9. Utworzenie instancji klasy SampleClass


private void buttonStatycznyKonstruktor_Click(object sender, EventArgs e)
{
SampleClass sampleClass = new SampleClass();
}

Przykładowy efekt działania powyższej aplikacji przedstawiłem na rysunku B.5. Wy-


nika z niego, że działanie wątków tworzonych w ramach statycznych konstruktorów
jest blokowane do momentu zakończenia tworzenia instancji obiektu. Z tego powodu
dobre praktyki nakazują, aby wszystkie statyczne pola oraz metody były domyślnie
implementowane z zachowaniem bezpieczeństwa wątków w oparciu o metody i obiekty
poznane w tej książce.
Dodatek B  Dobre praktyki programowania aplikacji wielowątkowych 421

Rysunek B.5.
Funkcje wątków roboczych
uruchamianych z poziomu
statycznych konstruktorów
są blokowane do momentu
zakończenia tworzenia
obiektu

W ramach podsumowania tego podrozdziału warto przekonać się, że w bibliotece


.NET standardowe konstruktory, nazywane konstruktorami instancyjnymi, nie imple-
mentują bezpieczeństwa wątków. Do tego celu w listingu B.8 wystarczy zmodyfikować
deklarację konstruktora SampleClass:
static SampleClass()

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

NuGet jest rozszerzeniem Visual Studio służącym do zarządzania pakietami bibliotek.


W momencie dołączania konkretnego pakietu do rozwiązania automatycznie tworzone są
odpowiednie wiązania z bibliotekami. Co więcej, NuGet zarządza także zależnościami
między bibliotekami, co oznacza, że jeśli instalujemy pakiet zależny od innego pakietu,
ten drugi również zostanie dołączony do rozwiązania. NuGet zarządza też licencjami
bibliotek.

W formie pakietów NuGet dystrybuowane są m.in. Entity Framework oraz ASP.NET


MVC. Coraz więcej firm oferuje swoje rozwiązania za pomocą NuGet. Więcej infor-
macji dotyczących samego rozszerzenia można znaleźć na stronie http://nuget.org.

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ń.

W przeciwnym przypadku należy z tego samego menu wybrać zakładkę Extensions


and Updates..., a dalej z menu po lewej stronie wybrać Online. Jeśli na liście rozszerzeń
nie pojawi się NuGet Package Manager, należy tę frazę wpisać w polu tekstowym
wyszukiwania (rysunek C.2). Następnie trzeba wybrać pakiet do pobrania. W trakcie
instalacji pojawi się okno z licencją, z którą warto się zapoznać. Po jej zaakceptowaniu
możemy korzystać z menedżera pakietów.
424 Programowanie równoległe i asynchroniczne w C# 5.0

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

Bing Search API, 353 delegat, 108, 110, 138


BLAS, 394 diagram koralikowy, 315, 316, 320
blokada, 77, 179, 181, 188, 191 dokumentacja MSDN, 16, 33, 149, 155, 205, 399
wirująca, 45 domena aplikacji, 31
broadcast, Patrz: rozgłaszanie DSS, 243, 244, 249, 276, 277, 291, 298
konsola Command Prompt, 293
C
E
C for CUDA, 365
callback function, Patrz: funkcja odpowiedzi edytor XAML, Patrz: XAML
CCR, 243, 244, 249, 276, 277, 291, 298 Euler Leonhard, 47
CLR, 31 extension method, Patrz: metoda rozszerzająca
cold observable, Patrz: obserwabla zimna
COM, 124 F
Common Language Runtime, Patrz: CLR
Component Object Model, Patrz: COM factory method, Patrz: metoda tworząca
compute capability, Patrz: karta graficzna FFT, 392
potencjał obliczeniowy FIFO, Patrz: kolejka FIFO
Compute Unified Device Architecture, flaga, 34, 239, 302
Patrz: CUDA IsStopped, 22
Concurrency and Coordination Runtime, Fouriera transformata szybka, Patrz: FFT
Patrz: CCR funkcja
Concurrency Visualizer, 225, 232, 237 odpowiedzi, 302
Console Application, Patrz: aplikacja konsolowa WinAPI InterlockedAdd, 52
CUDA, 365
CUDAfy.NET, 366, 368, 376 G
czas
obliczeń, 20, 383 General-Purpose computing on Graphics
wirtualny, 334, 335 Processor Units, Patrz: GPGPU
generator liczb
losowych, 36, 37
D pseudolosowych, 59, 390, 392
dane GPGPU, 365
metody przekształcające, 208 GPU, 365, 372
partycjonowanie, 175 emulator, 375
podział, 205, 213 Graphical User Interface, Patrz: interfejs
przekazywane do zadania, 140 użytkownika
przesyłanie do wątku, 45 GUI, Patrz: interfejs:użytkownika
spychane, 303
SQL, 212 H
struktura współbieżna, 187, 188
Hadamarda iloczyn, 378
w programowaniu równoległym, 187, 188
hot observable, Patrz: obserwabla gorąca
współdzielone przez wątki, 40, 187
Hybrid DSP, 366
wyciąganie, 302
zwracane przez zadanie, 141
DCOM, 124 I
deadlock, Patrz: zakleszczenie identyfikator
debugowanie, 93, 148, 225, 227 kontraktu, 245
Decentralized Software Services, Patrz: DSS sekcji krytycznej, 44
dekompilator ILSpy, Patrz: ILSpy usługi, 245
Skorowidz 429

iloczyn ConcurrentBag, 188, 189


Hadamarda, 378 ConcurrentDictionary, 188
po współrzędnych, 378 ConcurrentQueue, 188, 190
Schura, 378 ConcurrentStack, 188, 190
ILSpy, 368, 369 CountdownEvent, 51
inicjacja CudafyHost, 372, 376
leniwa, Patrz: inicjacja z opóźnieniem CudafyModule, 372
z opóźnieniem, 60, 61, 62, 63 CudafyTranslator, 369, 373
instancja Dictionary, 337
aplikacji, Patrz: aplikacja instancja Dispatcher, 345
programu, 31, Patrz też: wątek DispatcherTimer, 402
interfejs EnlightenmentProvider, 343
graficzny, 397 Enumerable, 204
ICollection, 304 EventWaitHandle, 85, 184
IEnumerable, 156, 188, 203, 304 FFTPlan1D, 392
IEnumerator, 304 GPGPU, 372, 384
implementacja, 309 GPGPUProperties, 376
IObservable, 303, 304, 305, 309, 315, 316, 334, GThread, 378, 388
346 HttpClient, 16
IObserver, 303, 305, 309, 312, 315, 334 instancja, 45
IProducerConsumerCollection, 78, 155, 187, Interlocked, 64, 188, 412
188, 191, 193, 195 Lazy, 60
IScheduler, 334 leniwa, 61
stron internetowych, 251 List, 399
użytkownika, 95, 96 ManualResetEvent, 85, 184
aktualizacja, 110 ManualResetEventSlim, 184
wątek, Patrz: wątek interfejsu użytkownika Monitor, 44, 50, 413
Mutex, 88, 89
J Observable, 316, 339
jądro, Patrz: kernel odpowiedzialna za obsługę plików, 16
język XAML, Patrz: XAML Parallel, 22, 138, 161, 403
ParallelEnumerable, 199, 203
ParallelLoopResult, 168
K ParallelLoopState, 22, 168
karta graficzna, 365, 371, 376, 390 ParallelOptions, 166
czas obliczeń, 383 ParallelQuery, 199
pamięć, 380, 381, 387 Partitioner, 175, 187
potencjał obliczeniowy, 368, 370, 375 Queue, 189
uchwyt, 372 Random, 21
kernel, 366, 372, 373, 386 ReaderWriterLock, 73
wywołanie, 373, 374 ReaderWriterLockSlim, 73, 77
Kinect, 246 SemaphorSlim, 93
klasa SpinLock, 45
AutoResetEvent, 85, 184 Stack, 189
BackgroundWorker, 111 statyczna, 49, 316
Barrier, 86, 88 StorageFile, 16
BlockingCollection, 78, 190, 191, 192 StreamReader, 16
CancellationToken, 145, 154, 166, 209 StremWriter, 16
CancellationTokenSource, 209 SynchronizationContext, 128, 334, 345
430 Programowanie równoległe i asynchroniczne w C# 5.0

klasa linia obrazu, 95, 99


System.Threading.Interlocked, 52, 53 LINQ, 203, 205, 209, 212, 301, 315
System.Threading.LazyInitializer, 63 do zdarzeń, 302, 306, 315, 316
System.Threading.Timer, 54 lock, Patrz: blokada
Task, 13, 19, 138, 144, 398, 399, 400 log, 419
TaskContinuationOptions, 154
TaskCreationOptions, 154 M
TaskFactory, 138, 144, 152, 153
TaskScheduler, 138, 154, 155 macierz, 378, 379, 380, 388, 394
Thread, 29, 334, 399, 400 manifest, 251
ThreadPool, 48, 343, 401 Manifest Load Results, Patrz: manifest
ThreadPoolTimer, 402 marble diagram, Patrz: diagram koralikowy
Timer, 402 marmurki, 315
WCF, 16 maszyna wirtualna, 31
WindowsFormsSynchronizationContext, 130 MATLAB, 365
XmlReader, 16 metoda
klaster obliczeniowy, 277 Add, 191
kod XAML, 116 Aggregate, 199
kolejka AllocateShared, 388
FIFO, 155, 156, 189, 191 AsOrdered, 208, 209
wiadomości, 244 AsParallel, 138, 199, 209
współbieżna, 189 AsSequential, 209
kolekcja, 189, 191 AsUnordered, 208, 209
równoległa, 199 async, 18
współbieżna, 189, 193 błędy, 19
własna, 193, 195 zwracająca wartość, 18
kompilator, 18 asynchroniczna, 131
C#, 64 BackgroundWorker.CancelAsync, 110
JIT, 64 BackgroundWorker.DoWork, 110, 114
komponent wizualny, 124 BackgroundWorker.ProgressChanged, 110
komunikat, 239, 251 BackgroundWorker.RunWorkerAsync, 110
konsola BackgroundWorker.RunWorkerCompleted, 110
DSS Command Prompt, 293 blokująca, 131, 191, 357
Xbox 360, 404 Break, 22
kontrolka, 103, 108, 110, 215, 339 Buffer, 324, 326, 327
BackgroundWorker, 25 Cancel, 210
Timer, 25 CancellationTo-
ken.ThrowIfCancellationRequested, 146, 147
WPF, 345
CancellationTokenSource.Cancel, 145, 168
kursor myszy, 346, 348
CombineLatest, 323
Console.WriteLine, 382
L ContinueWhenAny, 144
Language INtegrated Query, Patrz: LINQ Control.BeginInvoke, 107, 108, 131
Lego Mindstorms, 243, 249 Control.Dispatcher.BeginInvoke, 128, 131
liczba Control.Dispatcher.Invoke, 128
losowa, 37 Control.EndInvoke, 131
pierwsza, 141, 215 Control.Invoke, 104, 107, 108, 124, 130, 131
π, 25, 47, 115, 170, 277 CountDownEvent, 188
LIFO, Patrz: stos Create, 392
Cudafy, 369
Skorowidz 431

Delay, 400 Publish, 330


DropHandler, 244 rozszerzająca, 199, 203, 204, 206, 316, 348
EnsureInitialized, 63 Salamina i Brenta, 47
Eulera, 47 Schedule, 334
ForEach, 138, 399 SemaphoreSlim, 188
FromCurrentSynchronizationContext, 221 Send, 128, 132
GetConsumingEnumerable, 192 Skip, 320
GetDevice, 372 Sleep, 399, 400
GetDeviceProperties, 376 SpinLock, 188
GetEnumerator, 305 SpinLock.Enter, 45
Interlocked.Add, 52, 53 SpinLock.Exit, 45
Interlocked.Increment, 412 SpinOnce, 400
Leave, 240 SpinWait, 188, 399
LoadModule, 372 StartTimer, 384
Log, 274 statyczna, 31, 64, 181, 373, 413
LogError, 274 Stop, 22
LogInfo, 274 StopTimer, 384
LogVerbose, 274 SubscribeOn, 339
LogWarning, 274 Switch, 359
MessageBox.Show, 26 SynchronizationContext.Post, 131
Monitor.Enter, 44, 45 SynchronizationContext.Send, 131
Monitor.Exit, 44, 45, 410 System.Threading.Thread.VolatileRead, 64
Monitor.Pulse, 50, 81, 84, 86, 182 System.Threading.Thread.VolatileWrite, 64
Monitor.Wait, 81, 84, 86, 182 Take, 191
Monitor.WaitOne, 50 TakeWhile, 206
Monte Carlo, 25, 47, 115, 170 Task.ContinueWith, 143, 145, 147
MoveNext, 305 Task.Delay, 399
nieblokująca, 263 Task.Factory.ContinueWhenAll, 152, 153
Observable.Create, 310 Task.Factory.ContinueWhenAny, 152, 153
Observable.FromAsyncPattern, 357 Task.Factory.StartNew, 152, 153, 154, 179, 181
Observable.FromEventPattern, 348 Task.Wait, 138, 143, 147
Observable.Generate, 310 Task.WaitAll, 143, 147
Observable.Interval, 317 Task.WaitAny, 143, 147
Observable.Range, 309, 323 TaskFactory.ContinueWhenAny, 153
Observable.Timer, 319 TaskScheduler.FromCurrentSynchronization
Observable.Timestamp, 318 Context, 219
ObservableRange, 339 Thread.Abort, 30, 32, 33, 44, 103
ObserveOn, 339 Thread.Interrupt, 44
obsługi zdarzeń, 245 Thread.Join, 40, 135, 136
OnCompleted, 316 Thread.MemoryBarrier, 64
OnError, 304, 316 Thread.ResetAbort, 34
OnNext, 304, 316 Thread.Resume, 30, 34, 80
Parallel.For, 21, 22, 161, 162, 166, 176, 403 Thread.Sleep, 14, 31, 181
Parallel.ForEach, 161, 163, 166, 176, 212 Thread.SpinWait, 140, 399, 400
Parallel.Invoke, 161, 164 Thread.Suspend, 30, 34, 80
ParallelQuery.ForAll, 212 ThreadPool.QueueUserWorkItem, 49, 333
Post, 128, 132 ThreadPool.SetMaxThreads, 49
przekształcająca dane wynikowe, 208 Throttle, 358
przełączenie widoku, 229 ThrowIfCancellationRequested, 210
432 Programowanie równoległe i asynchroniczne w C# 5.0

metoda obserwabla, 305, 310, 312


tworząca, 309, 319 czasu, 316
Wait klasy Task, 17 gorąca, 329, 330
Window, 326 Observable.Interval, 317
WithCancellation, 209 Observable.Timer, 319
WithDegreeOfParallelism, 205, 213 zimna, 329, 330
WithExecutionMode, 213 obserwator, 305
WithMergeOptions, 213 odległość w przestrzeni euklidesowej, 199
Wolfa, 47 okno
zdarzeniowa, 110, 114, 117 stosów równoległych, 229
przycisku, 14 śledzenia zmiennych, 230
Zip, 321, 323 wątków, 226, 227
Microsoft OLE, 124 zadań równoległych, 228
Microsoft Robotics, 243, 248, 249, 251, 276, 298 opakowanie, 190, 365, 366, 368, 369
instalacja, 246 operacja
uruchamianie, 247 algebraiczna, 394
zabezpieczenia, 293 asynchroniczna, 302
model STA, Patrz: STA atomowa, 51, 55, 64
modyfikator async, 16, 17 operator
MTA, 124 async, 403
Multi-Threaded Apartment, Patrz: MTA await, 13, 16, 17, 18, 221, 403
multithreading, Patrz: wielowątkowość lock, 53, 188
murmelki, 315 using, 240
muteks, 88, 89, 91, 93, 104 optymalizacji wyłączanie, 64
lokalny, 89
tworzenie, 90 P
MySpace, 243
mysz, 346, 348 pamięci bariera, 64
Parallel Extensions, 19, 137, 188
N Parallel Stacks Window, Patrz: okno stosów
równoległych
NA, 124 Parallel Tasks, Patrz: okno zadań równoległych
Neutral Apartment, Patrz: NA Parallel Watch Window, Patrz: okno
NuGet, 307, 345, 423, 425 równoległego śledzenia zmiennych
instalacja, 423 pełnomocnictwo, 108
NVidia, 365, 375 pętla, 161
For, 20
O liczba kroków, 175, 176
Parallel.For, 13, 189, 403
obiekt przerywanie, 166, 168
CancellationTokenSource, 145 równoległa, 13, 20
COM, 124, Patrz: COM współbieżna, Patrz: pętla równoległa
interfejsu, 345 planista, 334
jądra, 88, 89 CurrentThreadScheduler, 336, 337
synchronizacji, 45 DispatcherScheduler, 346
Task, 152 HistoricalScheduler, 335
timer, Patrz: timer ImmediateScheduler, 336, 337, 339
typu referencyjnego, 45 Reactive Extensions, 335, 336, 339
zarządzany, 124 platforma CLR, Patrz: CLR
PLINQ, 19, 161, 199, 203, 204, 205, 207, 209, 212
Skorowidz 433

pole statyczne, 40, 45 platforma, 306


port TimeoutPort, 291 rysowanie, 346
powiadomienia, 267 unifikacja, 343
problem warstwa, Patrz: warstwa
czytelników i pisarzy, 73 zarządzanie współbieżnością, 333
konsumenta i producenta, 78, 155, 188, 191 Rx-Cor, 343
pięciu ucztujących filozofów, 68 Rx-Interfaces, 343
proces, 31 Rx-Linq, 343
program Rx-PlatformServices, 343
DssHost.exe, 245, 247 Rx-Silverlight, 345
administrator, 251 Rx-WPF, 345
oparty na wyciąganiu danych, 302 Rx-Xaml, 345
w którym dane spływają, 303
programowanie S
interaktywne, 302, 304, 357
reaktywne, 303, 304 Schura iloczyn, 378
protokół sekcja krytyczna, 44, 53, 55, 89, 91, 104, 170,
DSS Protocol, 245, 251 188, 234, 407, 410
komunikacji między procesami, 243 semafor, 91, 92, 93, 104
TCP/IP, 245, 251 lokalny, 93
przedstawicielstwo, 108 serwis WCF, 215
przeglądarka internetowa, 249, 251 silnik wyszukiwania, 345, 353, 357
przekrój linii obrazu, 95 Silverlight, 246, 404
przestrzeń nazw Single-Threaded Apartment, Patrz: STA
System.Collections.Concurrent, 187, 188, słownik, 188, 337
189, 190 słowo kluczowe
System.Reactive.Concurrency, 335 delegate, 108
System.Reactive.Linq, 316 lock, 44, 45, 51, 64, 81, 84, 181, 412
System.Reactive.Windows.Threading, 346 params, 143
System.Threading, 14, 29, 181, 209, 401, 402 return, 18
System.Threading.Tasks, 138, 161 volatile, 64, 417
System.Windows.Shapes, 119 spinning, 188
pull-based, Patrz: program oparty na wyciąganiu STA, 124
danych starvation, Patrz: wątek zagłodzony
punkt synchronizacji, 14 stos, 189, 191, 195
Python, 365 okno, Patrz: okno stosów równoległych
współbieżny, 189
R struktura CancellationToken, 34
subskrypcja, 312, 339
race condition, Patrz: wątek wyścig sygnał, 182
Reactive Extensions, Patrz: Rx system
planista, 335, 336, 339 operacyjny planista, 31
ReactiveCocoa, 302 rozproszony, 277
Representational State Transfer, Patrz: REST
Resource Diagnostics, Patrz: usługa diagnostyki T
zasobów
rozgłaszanie, 267 tablica
rozszerzenie, Patrz: metoda rozszerzająca deklaracja, 64
Rx, 301, 307, 345, 361 sortowanie, 212
gramatyka, 309 Task, Patrz: zadanie
434 Programowanie równoległe i asynchroniczne w C# 5.0

Task Parallel Library, Patrz: TPL kontekst


technologia działania, Patrz: ATM
niezarządzana, 124 synchronizacji, 128, 130, 132, 215, 218, 221
REST, Patrz: REST obsługa zakończenia, 110
thread, Patrz: wątek oflagowanie, 226
Threads, Patrz: okno wątków okno, Patrz: okno wątków
timer, 55 pamięć lokalna, 39
token przerwania, 148, 209 pobieranie danych, 45
TPL, 13, 19, 137, 161, 175, 204, 205, 215, 221, pomocniczy, 234
225, 243, 361, 398, 404 priorytet, 35, 36, 56, 57
transformata Fouriera szybka, Patrz: FFT przerywanie działania metody, 110
pula, 25, 47, 48, 50, 54, 55, 155, 179, 185,
U 205, 401
raportowanie postępu pracy, 110
układ sekcja krytyczna, Patrz: sekcja krytyczna
kartezjański lewoskrętny, 122 synchronizacja, 25, 34, 43, 45, 67, 84, 88, 169,
współrzędnych, 115, 122 179, 234, 262, 291, 407, 413, 414, 416
usługa, 245 z interfejsem użytkownika, 104
diagnostyki zasobów, 252 za pomocą blokad, 68
identyfikacja, 256 tła, 35, 56
identyfikator, Patrz: identyfikator usługi usypianie, 31, 78, 81, 188, 400
partnerska, 265, 266, 291 wstrzymanie, 34, 400
port TimeoutPort, 291 wyścig, 104, 124, 208, 411, 412, 413, 416
port główny, 245 wznawianie, 78, 81
rozpraszanie, 277 zagłodzony, 71, 135
stan, 245 zakleszczenie, 68, 135, 104, 124, 407
synchronizacja, 291 zamrażanie, 188
tworzenie, 250, 284 zmienna lokalna, Patrz: zmienna lokalna
zrównoleglenie, 36
V wektor, 394
wiadomość, 245, 261, 262
Visual Studio, 225, 245, 307, 368, 397, 423
Timeout, 291
widok
W Wątki, 233, 239
warstwa Wykorzystanie CPU, 232
LINQ do zdarzeń, 306, 315 Widok Rdzenie, 236
sekwencji zdarzeń, 306, 315, 320 wielowątkowość, 25
zarządzania współbieżnością, 306, 334 Windows Azure Marketplace, 353
Watch Window, Patrz: okno śledzenia zmiennych Windows Communication Foundation, 243
wątek, 25, 28, 30, 334, 399 Windows Forms, 124, 131, 215, 218, 345, 407
aktywny, 227, 229 Windows Phone, 404
bezpieczeństwo, Patrz: bezpieczeństwo Windows Presentation Foundation, 96, 114, 116,
blokada wirująca, 45 122, 131, 132, 218, 219, 345
budzenie, Patrz: wątek wznawianie WinRT, 397, 400, 402, 403, 404
CUDA, 378 własność
czas wykonania, 234 BackgroundWorker.CancellationPending, 114
dane współdzielone, 40 Control.InvokeRequired, 104
interfejsu Control.InvokeRequired, 107, 128
uprzywilejowany, 339 Environment.ProcessorCount, 206
użytkownika, 95, 130 Task.Status, 149
Skorowidz 435

WPF, Patrz: Windows Presentation Foundation zakleszczenie, Patrz: wątek zakleszczenie


wrapper, Patrz: opakowanie zależność rekurencyjna, 21
wyjątek, 103, 304, 410 zapytanie, 302, 357
IndexOutOfRangeException, 189 czas wykonania, 203
InvalidOperationException, 103, 107, 124 LINQ, Patrz: LINQ
OperationCanceledException, 146, 147, 209 PLINQ, Patrz: PLINQ
przechwytywanie, 148 przerywanie, 209
wyjątki, 33 wydajność, 207
wyszukiwarka internetowa, 345, 353 zrównoleglone, 203, 205, 207
wzorzec projektowy, 125 zintegrowane z językiem programowania,
obserwator, 305 161
zasada Pareto, 13
X zdarzenie, 245, 302, 304, 345, Patrz też:
wiadomość
XAML, 116, 345, 355, 397 kolekcja, 304
MouseMove, 348
Z sekwencja, 306, 315, 320, 334, 336
strumień, 304
zadanie, 137, 138, 334, 398, 399
ziarno, 390
dane, 140, 141
zmienna
fabryka, 152, 154
globalna, 40
oflagowanie, 226
lokalna, 39, 59
okno, Patrz: okno zadań równoległych
statyczna, 59
planista, 153, 154, 155, 159, 219, 221
typu referencyjnego, 44
planowanie, 334
znacznik, 238, 239
priorytet, 159
przerywanie, 145
stan, 149
synchronizacja, 179
sztafeta, 144

You might also like