Zagadnienia egzaminu 70-483 opisane w tej notatce:
  • Task Parallel Library (ParallelFor, Plinq, Tasks).
  • Użycie ThreadPool i obiektów Thread.
  • Odblokowywanie UI przy użyciu klasy BackgroundWorker.
  • Użycie słów kluczowych async i await.
  • Zarządzanie danymi za pomocą równoległych kolekcji. Użycie klas z przestrzeni nazw System.Concurrent.Collections.
  • Synchronizacja zasobów za pomocą klas ManualResetEvent i AutoResetEvent.
  • Implementacja blokowania za pomocą klas MonitorMutex i Semaphore.
  • Przerywanie długich zadań za pomocą klas CancellationTokenSource i CancellationToken.

Tworzenie responsywnych aplikacji

Pierwsze komputery zostały stworzone w architekturze nazywanej architekturą von Newmann’a. Komputry te posiadały jedną jednostkę przetwarzania, moduł sterujący, pamięć i system wejścia i wyjścia (IO). Ponieważ posiadały tylko jedną jednostkę przetwarzania to programy pisane na tą architekturę musiały być bisane tak aby były wykonywane sekwencyjnie. taki rodzaj programowania jest używany do dzisiaj. Największą wadą takiego podejścia jest to, że ilekroć aplikacja musi czekać aż coś się wykona to cały system zostaje zatrzymany, tworząc bardzo nieprzyjemne doświadczenie użytkownika. Wątki zostały wprowadzone aby zminimalizować tego rodzaju problemy.

Praca z wątkami

Kiedy aplikacja pobiera dane z I/O to CPU czeka na dane które maja być odebrane nie wykonując w tym czasie żadnych obliczeń. Aby polepszyć responsywność aplikacji została wprowadzona wielowątkowość. W aplikacjach wielowątkowych jeden wątek tworzy następny wątek który pobiera dane I/O i czeka aż ojczysty wątek powróci aby wykonywać dalszą pracę. Kiedy dane są potrzebne to ojczysty wątek jest zablokowany czekając aż utworzony wątek zakończy pracę. Ten wzorzec jest nazywany fork-join
Jeżeli mamy do dyspozycji jeden procesor to oznacza, że tylko jeden wątek może być wykonywany w danym punkcie czasu. Może to być osiągnięte na dwa różne sposoby:

  • Grupowo - każdy wątek musi zrezygnować z kontroli, tak aby inny wątek mógł być wykonany.
  • Zapobiegawczo - system operacyjny posiada komponent zwany scheduler który sprawdza czy żaden wątek nie monopolizuje CPUWindows jest zbudowany w ten sposób.

Algorytm działania harmonogramu Windows:

  1. Każdy wątek posiada priorytet nadany przy jego tworzeniu. Tworzony wątek nie jest startowany automatycznie, trzeba go uruchomić.
  2. Kiedy wątek jest wystartowany, zostaje dodany do kolejki wszystkich wątków które mogą być uruchomione.
  3. Harmonogram pobiera wątek z najwyższym priorytetem w kolejce i uruchamia go.
  4. Jeżeli kilka wątków posiada ten sam priorytet to harmonogram rozkłady je w kołowym porządku (round robin).
  5. Kiedy upływa przeznaczony czas, harmonogram wstrzymuje wątek i dodaje go na koniec kolejki. Po tym pobiera kolejny wątek z kolejki i uruchamia go.
  6. Jeżeli nie ma innego wątku z wyższym priorytetem niż ten wstrzymany, to ten wątek zostaje wykonywany ponownie.
  7. Kiedy wątek jest blokowany i czeka na operację I/O lub jest blokowany z innego powodu to wątek ten jest usuwany z kolejki i następny wątek jest uruchamiany.
  8. Kiedy zablokowany wątek zostaje odblokowany to wraca z powrotem do kolejki.
  9. Kiedy wątek zostaje zakończony to harmonogram pobiera kolejny wątek do uruchomienia.

Istnieje jeden wątek nazywany System idle process (Proces bezczynności systemu), który nie robi nic oprócz trzymania procesora jako zajętego kiedy ten nie posiada żadnych innych wątków do uruchomienia. 
.NET wszystkie aplikacje posiadają kilka wątków. Oto lista niektórych z nich:

  • Garbage Collector thread - wątek czyszczenia pamięci.
  • Finalizer thread - uruchamia metodę Finalize w obiektach.
  • Main thread - uruchamia metody główne aplikacji.
  • UI thread - aktualizuje interfejs użytkownika. 
    Poza wątkiem Main wszystkie pozostałe wymienione watki są wątkami wykonywanymi w tle. Tworząc nowy wątek masz możliwość określenia, czy wątek powinien być wątkiem wykonywanym w tle. 
    Kiedy wątek główny i wszystkie wątki które nie są wątkami tła zostają zakończone to aplikacja również zostaje zakończona.

Wielowątkowe aplikacje posiadają również wady, oto najważniejsze z nich:

  • Wszystkie wątki używają zasobów. Potrzebują do pracy dużo pamięci (standardowo 1MB) i za każdym razem kiedy harmonogram przełącza sie pomiędzy wątkami, procesor jest zajęty zapisywaniem kontekstu wstrzymywanego wątku oraz przywróceniem kontekstu wątku uruchamianego.
  • Jeżeli aplikacja tworzy za dużo wątków to przełączenie kontekstu pochłania dużo czasu.
  • Ponieważ wątki potrzebują dużo pamięci to zwykle ich tworzenie i usuwanie zajmuje sporo czasu dla systemu.

.NET wątki są zaimplementowane w klasie System.Threading.Thread
Praca z tworzeniem wątków odbywa sie w następujący sposób:

  1. Utworzenie obiektu wątku.
  2. Uruchomienie wątku.
  3. Wykonanie większej ilości pracy w metodzie wywołującego.
  4. Czekanie aż wątek się zakończy.
  5. Kontynuacja pracy metody wywołującej.

Przykład aplikacji symulującej wykonywanie dwóch rzeczy: czytania I/O i wykonywania intensywnych obliczeń:

class Program {
staticvoidMain(string[] args) {
// We are usingStopwatch to time the code
Stopwatch sw = Stopwatch.StartNew();

// Here we call different methods
// for different ways of running our application.
RunSequencial();

// Print the time it took to run the application.
Console.WriteLine("We're done sequencial in {0}ms!", sw.ElapsedMilliseconds);

// We are usingStopwatch to time the code
sw = Stopwatch.StartNew();

// Here we call different methods
// for different ways of running our application.
RunWithThreads();

// Print the time it took to run the application.
Console.WriteLine("We're done with threads in {0}ms!", sw.ElapsedMilliseconds);

if (Debugger.IsAttached) {
Console.Write("Press any key to continue . . .");
Console.ReadKey(true);
}
}
staticvoidRunSequencial() {
double result = 0d;

// Call the function to read data from I/O
result += ReadDataFromIO();
// Add the resultof the second calculation
result += DoIntensiveCalculations();

// Print the result
Console.WriteLine("The result is {0}", result);
}

staticvoidRunWithThreads() {
double result = 0d;

// Create the thread to read from I/O
var thread = new Thread(() => result = ReadDataFromIO());

// Start the thread
thread.Start();

// Save the resultof the calculation into another variable
double result2 = DoIntensiveCalculations();

// Waitfor the thread to finish
thread.Join();

// Calculate the endresult
result += result2;

// Print the result
Console.WriteLine("The result is {0}", result);
}

static double ReadDataFromIO() {
// We are simulating an I/O by putting the current thread to sleep.
Thread.Sleep(5000);
return10d;
}

static double DoIntensiveCalculations(){
// We are simulating intensive calculations
// by doing nonsens divisions
double result = 100000000d;
var maxValue = Int32.MaxValue;
for(int i=1; i < maxValue; i++){
result /= i;
}
returnresult + 10d;
}
}

Wynik działania:

The result is 20 
We’re done sequencial in 16489ms! 
The result is 20 
We’re done with threads in 11388ms!

Użycie ThreadPool

Ponieważ wątki są zasobami drogimi w utrzymaniu to aby ulepszyć wydajność aplikacji, można wybrać wątki już utworzone przez .NET które są dostępne w puli wątków. Służy do tego klasa System.Threading.ThreadPool. Klasa ta posada tylko etody statyczne, najważniejsze z nich to:

  • GetAvailableThreads - zwraca liczbę wątków dostępnych w puli wątków.
  • GetMaxThreads - zwraca maksymalną liczbę wątków jaką może utworzyć pula wątków.
  • GetMinThreads - zwraca minimalną liczbę wątków jaką może utworzyć pula wątków.
  • QueueUserWorkItem - dodaje żądanie o wykonanie do kolejki puli wątków. jJeżeli istnieją wolne wątki w puli to żądanie zostanie wykonane natychmiast.
  • RegisterWaitForSingleObject - rejestruje metodę do wywołania, gdy albo określony, jako pierwszy parametr WaitHandle zostanie zasygnalizowany lub gdy limit czasu określony jako czwarty parametr upłynie.
  • SetMaxThreads - ustawia maksymalną liczbę wątków które mogą być utworzone w puli.
  • SetMinThreads - ustawia minimalną liczbę wątków dostępnych w puli w określonym czasie.

Powyższa lista nie jest kompletna. Najważniejsza z tej listy metoda to QueueUserWorkItem
Pula wątków działa w następujący sposób. Kiedy potrzebujesz odseparowanego wątku na wykonanie długotrwałej metody, to zamiast tworzyć nowy wątek, wywołujesz metodę QueueUserWorkItem w celu umieszczenie nowego elementu pracy w kolejce zarządzanej przez pulę wątków. Jeżeli w kolejce jest bezczynny wątek to startuje element pracy i wykonuje go tak samo jak każdy inny wątek. Jeżeli nie ma dostępnego wolnego wątku i liczba wątków w puli jest mniejsza niż MaxThreads, to pula tworzy nowy wątek który uruchomi element pracy. W przeciwnym wypadku element pracy czeka w kolejce na wolny wątek. 
Metoda SetMinThread jest używana aby poprawić wydajność pracy aplikacji jeżeli wiemy, że będziemy używać puli wątków. 
Metoda QueueUserWorkItem posiada dwa przeciążenia:

publicstaticbool QueueUserWorkItem(WaitCallback callBack)
publicstaticbool QueueUserWorkItem(WaitCallback callBack, Object state)

Parametr System.Threading.WaitCallback jest delegatą zdefiniowaną następująco:

public delegate void WaitCallback(Object state)

Istnieją pewne różnice pomiędzy wątkami utworzonymi manualnie a wątkami z puli ThreadPool, są to:

  • Wszystkie wątki z puli wątków są wątkami wykonywanymi w tle, natomiast wątki utworzone manualnie są domyślnie wątkami pierwszoplanowymi ale mogą być ustawione jako watki wykonywane w tle.
  • Nie można przerwać lub anulować wątku z puli wątków.
  • Nie możesz dołączyć wątku z puli wątków.
  • Wątki z puli po zakończeniu pracy trafiają z powrotem do kolejki i mogą być użyte ponownie natomiast wątki utworzone manualnie są niszczone po zakończeniu pracy.
  • Nie można kontrolować priorytetu wątków z puli wątków.

Maksymalna liczba wątków w puli jest różna dla różnych wersji .NET i powinna być traktowana jako detal implementacyjny. W .NET 2.0 jest 25 wątków na rdzeń, w .NET 3.5 jest 250 a w .NET 4 jest 1023 dla 32 bitowych aplikacji i 32,767 dla 64 bitowych. 
Przykład metody z poprzedniej sekcji wykonującej odczyt z I/O w osobnym wątku napisanej za pomącą puli wątków:

staticvoidRunInThreadPool() {
double result = 0d;
// Create a work item to read from I/O
ThreadPool.QueueUserWorkItem((x) => result += ReadDataFromIO());
// Save the resultof the calculation into another variable
double result2 = DoIntensiveCalculations();
// Waitfor the thread to finish

// TODO: We will need a way to indicate
// when the thread pool thread finished the execution
// Calculate the endresult
result += result2;

// Print the result
Console.WriteLine("The result is {0}", result);
}

Wywołanie metody ThreadPool.QueueUserWorkItem wstawia jednostkę pracy do kolejki zarządzanej przez pulę wątków. kiedy wątek z puli jest dostępny to pobiera jednostkę i wykonuje ją aż do jej zakończenia. Jedynym problemem jest to, że nie wiadomo kiedy wątek zakończy swoją pracę. Nie istnieje metoda Join ani nic podobnego. Aby rozwiązać ten problem nalerzy użyć jakiegoś mechanizmu sygnalizującego koniec pracy. 
Bardzo często nie chcemy używać klasy ThreadPool bezpośrednio. Zamiast tego można użyć innych technologii zbudowanych na jej podstawie jak Task Parallel Library (TPL), Asynchronous Pattern Model (APM), Eventbased Asynchronous Pattern (EAP), Task-based Asynchronous Pattern Model (TAP), lub nowych słów kluczowych async i await.

Odblokowywanie interfejsu użytkownika

Jednym z największych problemów aplikacji jest to, że mogą przestać odpowiadać. Aby temu zapobiec można przetwarzać czasochłonne operacje w osobnych wątkach. Istnieje wiele wzorców programowania asynchronicznego które stosuje się aby tego dokonać. 
Aplikacje od wersji .NET 4.5 posiadają nowy model programowania async/await. Jeżeli aplikacja napisana jest w niższej wersji .NET Frameworka to niestety trzeba samemu zadbać o asynchroniczność.

Klasa BackgroundWorker

.NET 2.0 prowadził klasę System.ComponentModel.BackgroundWorker która abstrahuje tworzenie wątku i wykorzystanie puli wątków. 
Przydatne metody tej klasy to:

  • RunWorkerAsync - rejestruje żądanie wystartowania operacji w tle.
  • ReportProgress - zgłasza zdarzenie ProgressChanged.
  • CancelAsync - rejestruje żądanie anulowania operacji w tle.

Przydatne właściwości tej klasy to:

  • CancellationPending - ustawione na true jeżeli wywołana została metoda CancelAsync.
  • IsBusy - zwraca true jeżeli metoda RunAsync została uruchomiona i operacja nie została jeszcze ukończona.
  • WorkerReportsProgress - ustawienie na true powoduje raportowanie postępu przez operację wykonywaną w tle.
  • WorkerSupportsCancellation - ustawienie na true umożliwia wsparcie dla anulowania operacji.

Przydatne zdarzenia tej klasy to:

  • DoWork - wyzwalane kiedy RunWorkerAsync jest wywoływany. tutaj wykonuje się długotrwałe operacje.
  • ProgressChanged - wyzwalane kiedy ReportProgress jest wywoływany.
  • RunWorkerCompleted - wyzwalane kiedy operacja jest zakończona, również przez anulowanie lub wyjątek.

Algorytm pracy z BackgroundWorker jest następujący:

  1. Tworzymy metodę o sygnaturze DoWorkEventHandler.
  2. W tej metodzie wywołujemy długotrwałą operację. Kiedy metoda się zakonczy to przypisujemy wynik operacji do właściwości Result parametru DoWorkEventArgs.
  3. Tworzymy instancję klasy BackgroundWorker.
  4. Zapisujemy utworzoną metodę do zdarzenia DoWork.
  5. Tworzymy metodę o sygnaturze RunWorkerCompletedEventHandler.
  6. W tej metodzie pobieramy wynik długotrwałej operacji i aktualizujemy UI.
  7. Zapisujemy tę metodę do zdarzenia RunWorkerCompleted. Jeżeli operacja wyrzuciła wyjątek to znajdziemy go we właściwości Error parametru RunWorkerCompletedEventArgs, w przeciwnym wypadku właściwość ta ma wartość null.
  8. Jeżeli chcemy śledzić postęp wykonania operacji to tworzymy metodę o sygnaturze ProgressChangedEventHandler i zapisujemy ją do zdarzenia ProgressChanged.
  9. Startujemy proces w tle poprzez uruchomienie metody RunWorkerAsync.
  10. Możemy użyć metody CancelAsync do anulowania zadania.

Załóżmy, że mamy aplikację Windows Forms która posiada formularz z etykietą o nazwie lblResult i guzikiem btnRun. Przykładowo długotrwałe zadanie z użyciem klasy BackgroundWorker może wyglądać tak:

public partial class Form1 : Form {
private BackgroundWorker worker;

public Form1() {
InitializeComponent();
worker = new BackgroundWorker();
worker.DoWork += worker_DoWork;
worker.RunWorkerCompleted += worker_RunWorkerCompleted;
}

void worker_DoWork(object sender, DoWorkEventArgs e) {
e.Result = DoIntensiveCalculations();
}

void worker_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e) {
lblResult.Text = e.Result.ToString());
}

private void btnRun_Click(object sender, EventArgs e) {
if (!worker.IsBusy) {
worker.RunWorkerAsync();
}
}

static double DoIntensiveCalculations() {
// We are simulating intensive calculations
// by doing nonsens divisions
double result = 100000000d;
var maxValue = Int32.MaxValue;
for (int i = 1; i < maxValue; i++) {
result /= i;
}
returnresult + 10d;
}
}

Wielowątkowe aplikacje Windows Forms

Aplikacje Windows Forms i WPF posiadają dedykowany wątek do aktualizacji interfejsu użytkownika. Zapobiega on sytuacjom w których więcej niż jeden wątek próbuje dostać się do tego samego wspólnego zasobu. Taka sytuacja nazywana jest race conditions. Jeżeli próbujemy aktualizować UI z innego wątku to .NET Framework wyrzuci wyjątek InvalidOperationException z informacją “Cross-thread operation not valid: Control ‘ctrlName’ accessed from a thread other than the thread it was created on.”
Kod z poprzedniej sekcji działał ponieważ BackgroundWorker został uruchomiony z wątku UI. Jeżeli zostałby uruchomiony z innego wątku to otrzymalibyśmy wyjątek. Aby rozwiązać ten problem należy zmodyfikować metodę worker_RunWorkerCompleted:

voidworker_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e) {
if (this.InvokeRequired) {
this.Invoke(
new Action<string>(UpdateLabel),
e.Result.ToString());
}
else {
UpdateLabel(e.Result.ToString());
}
}
privatevoidUpdateLabel(string text) {
lblResult.Text = text;
}

Właściwość InvokeRequired jest zdefiniowana w klasie Control i zwraca true jeżeli jest sprawdzana w innym wątku niż wątek UI. 
Metoda Invoke również należy do klasy Control i przyjmuje jako pierwszy parametr delegatę. metoda Invoke umieszcza metodę w kolejce wątku UI.

Wielowątkowe aplikacje WPF

Aplikacje WPF tak jak aplikacje Windows Forms posiadają wątek odpowiedzialny za aktualizację UI. Dodatkowo aplikacje WPF posiadają prywatny wątek renderowania UI. 
Przykładowa aplikacja WPF wykonująca metodę DoIntensiveCalculation:

public partial class MainWindow : Window {
private BackgroundWorker worker;

public MainWindow() {
InitializeComponent();
worker = new BackgroundWorker();
worker.DoWork += worker_DoWork;
worker.RunWorkerCompleted += worker_RunWorkerCompleted;
}

void worker_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e) {
lblResult.Content = e.Result;
}

void worker_DoWork(object sender, DoWorkEventArgs e) {
e.Result = DoIntensiveCalculations();
}

private void btnRun_Click(object sender, EventArgs e) {
if (!worker.IsBusy) {
worker.RunWorkerAsync();
}
}
static double DoIntensiveCalculations() {
// We are simulating intensive calculations
// by doing nonsens divisions
double result = 100000000d;
var maxValue = Int32.MaxValue;
for (int i = 1; i < maxValue; i++) {
result /= i;
}
returnresult + 10d;
}
}

Jedyną różnicą jest sposób aktualizacji etykiety. Zamiast aktualizacji właściwości Text aktualizujemy właściwość Content. Niestety aplikacja WPF również wyrzuca wyjątek InvalidOperationException z informacją “The calling thread cannot access this object because a different thread owns it.” jeżeli aktualizujemy wątek UI z innego wątku. Rozwiązanie wygląda następująco:

void worker_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e) {
this.Dispatcher.Invoke(()=> lblResult.Content = e.Result);
}

Praca z Task Parallel Library

.NET 4 została wprowadzona klasa Task która reprezentuje operację asynchroniczną. Klasa ta została wprowadzona w celu abstrahowania programisty od wątków. Używa ona wątków z puli wątków ale oferuje dużą kontrolę i elastyczność w tym jak zadania są tworzone. Klasa Task znajduje się w przestrzeni nazw System.Treading.Tasks. Istnieją dwie klasy zadań: Task i Task<TResult>. Pierwsza z nich jest używana kiedy wykonujemy w zadaniu metodę i nie potrzebujemy zwracanej wartości, drugą używamy kiedy metoda zwraca wynik.

Najczęściej używane metody klasy Task:

  • ContinueWith - tworzy nowe zadanie które będzie uruchomione asynchroniczne kiedy aktualne zadanie się zakończy.
  • Delay - metoda statyczna tworząca zadanie które jest oznaczone jako zakończone po wyznaczonym opóźnieniu.
  • Run - metoda statyczna wysyłająca żądanie do puli wątków i zwracająca obiekt klasy Task.
  • Start - startuje zadanie reprezentowane przez tą instancję klasy.
  • Wait - czeka na zakończenie zadania wykonywanego przez tą instancję klasy.
  • WaitAll - metoda statyczna czekająca na wykonanie wszystkich zadań wysłanych jako parametr.
  • WaitAny - metoda statyczna czekająca na wykonanie któregokolwiek z zadań wysłanych jako parametr.
  • WhenAll - metoda statyczna tworząca zadanie oznaczone jako zakończone kiedy wszystkie zadania wysłane jako parametr zostaną zakończone.
  • WhenAny - metoda statyczna tworząca zadanie oznaczone jako zakończone kiedy którekolwiek zadanie wysłane jako parametr zostanie zakończone.

Najczęściej używane właściwości klasy Task:

  • CurrentId - statyczna właściwość tylko do odczytu, identyfikator wykonywanego zadania.
  • Exception - właściwość tylko do odczytu zawierająca nieobsługiwany wyjątek AggregateException, jeśli 
    wystąpił wyjątek który spowodował zakończenie zadania.
  • Factory - statyczna właściwość tylko do odczytu zwraca obiekt fabryki który może być użyty do utworzenia nowych zadań.
  • ID - właściwość tylko do odczytu zwracająca identyfikator instancji klasy Task.
  • IsCanceled - właściwość tylko do odczytu posiadająca wartość true jeżeli zadanie zostało zakończone poprzez anulowanie.
  • IsCompleted - właściwość tylko do odczytu posiadająca wartość true jeżeli zadanie zostało zakończone.
  • IsFaulted - właściwość tylko do odczytu posiadająca wartość true jeżeli zadanie zostało zakończone przez nieobsłużony wyjątek.
  • Status - właściwość tylko do odczytu zwracająca status zadania.
  • Result - właściwość tylko do odczytu zwraca wartość zwracaną przez operację asynchroniczną reprezentowaną poprzez to zadanie.

Statyczna właściwość Factory jest typu TaskFactory i jest używana do tworzenia nowych zadań. Najczęściej wykorzystywane metody tej klasy to:

  • ContinueWhenAll - tworzy nowe zadanie które startuje kiedy wszystkie zadania przesłane jako parametr zostaną zakończone.
  • ContinueWhenAny - tworzy nowe zadanie które startuje kiedy jakiekolwiek zadanie przesłane jako parametr zostanie zakończone.
  • FromAsync - metoda używana do przeniesienia kodu starego modelu asynchronicznego do modelu TAP poprzez opakowanie wywołania asynchronicznego przez zadanie.
  • StartNew - metoda tworząca nowe zadanie i startująca je.

Tworzenie zadań

Zadania możemy tworzyć na kilka sposobów:

  • Można utworzyć instancję obiektu Task. Instancja nie jest automatycznie uruchamiana więc trzeba wywołać metodę Start.
  • Można użyć jednego z przeciążeń metody TaskFactory.StartNew. Ta metoda tworzy i startuje zadanie.
  • Można użyć jednego z przeciążeń statycznej metody Task.Run. Jest to uproszczona wersja metody TaskFactory.StartNew.
  • Można wywołać jedną z metod kontynuacji jak np Task.WhenAllTask.WhenAnyTaskFactory.ContinueWhenAllTaskFactory.ContinueWhenAny.

Metoda TaskFactory.StartNew oferuje dużą elastyczność. Kiedy tworzymy zadanie musimy przynajmniej określić metodę lub funkcję którą chcemy uruchomić jako zadanie. Ponadto, można określić opcje dotyczące tworzenia zadania, token anulowania i harmonogram kolejki zadań w wątkach. 
Enumerator TaskCreationOptions opisuje opcje tworzenia zadania:

  • None - domyślne zachowanie.
  • PreferFairness - Zadania powinny być zaplanowane w sposób uczciwy. Jest to tylko podpowiedź i oczekiwanym jej rezultatem jest to, że zadania zaplanowane wcześniej będą wykonywane wcześniej.
  • LongRunning - Oznacza, że zadanie zajmuje dużo czasu. jest to tylko podpowiedź i oczekiwanym jej rezultatem jest to, że zadania mogą być wykonywane w większej ilości wątków niż dostępna liczba wątków sprzętowych.
  • AttachedToParent - Nowe zadanie jest przypięte do zadania rodzica w hierarchii.
  • DenyChildAttach - Oznacza, że żadne podzadania nie mogą być przypięte do tego zadania.
  • HideScheduler - Oznacza, że aktualny harmonogram nie powinien być używany przy tworzeniu zadania z tego nowo utworzonego zadania.

Enumerator posiada również atrybut FlagsAttribute oznaczający, że opcje mogą być kombinowane.

Przykład programu wywołującego metodę obliczeniową 32 razy:

class Program {
constintNUMBER_OF_ITERATIONS = 32;

staticvoidMain(string[] args) {
// We are usingStopwatch to time the code
Stopwatch sw = Stopwatch.StartNew();
// Run the method
RunSequential();
// Print the time it took to run the application.
Console.WriteLine("We're done in {0}ms!", sw.ElapsedMilliseconds);
}

staticvoidRunSequential() {
double result = 0d;
// Here we call same method several times.
for (int i = 0; i < NUMBER_OF_ITERATIONS; i++) {
result += DoIntensiveCalculations();
}
// Print the result
Console.WriteLine("The result is {0}", result);
}

static double DoIntensiveCalculations() {
// We are simulating intensive calculations
// by doing nonsens divisions and multiplications
double result = 10000d;
var maxValue = Int32.MaxValue >> 4;
for (int i = 1; i < maxValue; i++) {
if (i % 2 == 0) {
result /= i;
}
else {
result *= i;
}
}
returnresult;
}
}

Wynik działania programu to:

The result is 22.0386557304958 
We’re done in 41860ms!

jak widać wykonanie tego programu trwało 42s. Ulepszmy wydajność tego zadania używając zadań. Aby tego dokonać zastąpimy metodę RunSequential za pomocą metody RunTasks:

staticvoidRunTasks() {
double result = 0d;
Task[] tasks = new Task[NUMBER_OF_ITERATIONS];
// We create one task per iteration.
for (int i = 0; i < NUMBER_OF_ITERATIONS; i++) {
tasks[i] = Task.Run(() => result += DoIntensiveCalculations());
}
// Print the result
Console.WriteLine("The result is {0}", result);
}

Wynik działania programu to:

The result is 2.75483196631197 
We’re done in 10115ms! 
Jak widać wynik jest niepoprawny. Aplikacja nie jest 8 razy szybsza jak się tego można spodziewać.
Powodem dla którego aplikacja nie jest 8 razy szybsza jest to, że używamy nowoczesnego procesora Intel Core I7. Jest to czterordzeniowy procesor z funkcją hyper-threading. Hyperthreading oznacza, że każdy rdzeń ma dwa potoki instrukcji, ale tylko jeden silnik wykonania. System operacyjny widzi to jako 8 różnych procesorów. Hyperthreading poprawia wydajność o około 30%. 
Wynik aplikacji jest niepoprawny ponieważ występuje tu zjawisko race conditions i zamiast sumowania wyniku każdy wątek go nadpisuje. poprawna wersja metody to:

staticvoidRunTasksCorrected() {
double result = 0d;
Task<double>[] tasks = new Task<double>[NUMBER_OF_ITERATIONS];
// We create one task per iteration.
for (int i = 0; i < NUMBER_OF_ITERATIONS; i++) {
tasks[i] = Task.Run(() => DoIntensiveCalculations());
}

// We wait for the tasks to finish
Task.WaitAll(tasks);

// We collect the results
foreach (var task in tasks) {
result += task.Result;
}

// Print the result
Console.WriteLine("The result is {0}", result);
}

Wynik działania programu to:

The result is 22.0386557304958 
We’re done in 10369ms!

Wywołanie Task.WaitAll(tasks) nie jest konieczne ponieważ task.Result zablokuje pętlę wywołującą jeżeli kalkulacja jeszcze nie została zakończona.

Praca z harmonogramem zadań

Kolejkowanie zadań w wątkach jest wykonywane przez komponent zwany harmonogramem zadań (ang. task scheduler), zaimplementowanym w klasie TaskScheduler. Normalnie nie używa się go bezpośrednio. Startując zadanie bez wyznaczenia harmonogramu używany jest domyślny. 
W aplikacjach Windows Forms lub WPF używa się harmonogramu zadań bezpośrednio. Ponieważ UI może być zaktualizowane tylko przez wątek UI to żeby zadanie mogło aktualizować UI musi używać tego wątku. Aby to osiągnąć używa się metody StartNew lub ContinueWith pobierającej jako parametr obiekt TaskScheduler i wstawia się jako wartość TaskScheduler.FromCurrentSynchronizationContext()
Przykład użycia metody UpdateLabel jako zadania w aplikacji Windows Forms w wątku UI:

Task.Factory.StartNew(
UpdateLabel,
CancellationToken.None,
TaskCreationOptions.None,
TaskScheduler.FromCurrentSynchronizationContext()
);

Użycie klasy Parallel

Klasa Task jest abstrakcją reprezentującą operacje asynchroniczne wykonywane przez wątki. Mimo, że są lżejsze niż wątki, czasami po prostu trzeba lepszej abstrakcji przy pracy wielozadaniowej. Dlatego Microsoft stworzył klasę Parallel. Jest ona częścią przestrzeni nazw System.Threading.Tasks. Klasa ta posiada trzy metody statyczne opisane poniżej:

  • For - Metoda podobna do pętli for lecz wykonywana równolegle. Posiada 12 przeciążeń akceptujących jako parametry min ParallelOptions i ParallelLoopState.
  • ForEach - Podobna do pętli foreach ale wykonywana równolegle. Posiada 20 przeciążeń z parametrami podobnymi do metody For.
  • Invoke - Metoda ta będzie próbować uruchomić dostarczonych akcji równolegle. Posiada dwa przeciążenia. Oba przyjmują delegatę Action do wykonania. Jedno przeciążenie przyjmuje parametr ParallelOptions.

Wszystkie trzy metody wspominają o możliwości uruchomienia równoległego ale żadna z nich tego nie gwarantuje. 
Klasa ParallelLoopState jest używana jako parametr wejściowy dla niektórych metod For i ForEach. Posiada ona dwie metody: Stop i Break których można użyć, aby przedwcześnie przerwać pętlę.

Przykład użycia Parallel.For:

staticvoidRunParallelFor() {
double result = 0d;

// Here we call same method several times in parallel.
Parallel.For(0, NUMBER_OF_ITERATIONS, i => {
result += DoIntensiveCalculations();
});

// Print the result
Console.WriteLine("The result is {0}", result);
}

Wynik działania aplikacji:

The result is 2.06612397473398 
We’re done in 10186ms!

Tak jak w poprzednim przypadku wynik jest błędny. 
Poprawne użycie wymaga uzycia przeciążonej metody Parallel.For:

public static ParallelLoopResult For<TLocal>(
int fromInclusive,
int toExclusive,
Func<TLocal> localInit,
Func<int, ParallelLoopState, TLocal, TLocal> body,
Action<TLocal> localFinally
)

Przykład:

staticvoidRunParallelForCorrected() {
double result = 0d;

// Here we call same method several times.
//for (int i = 0; i < NUMBER_OF_ITERATIONS; i++)
Parallel.For(0, NUMBER_OF_ITERATIONS,
// Func<TLocal> localInit,
() => 0d,

// Func<int, ParallelLoopState, TLocal, TLocal> body,
(i, state, interimResult) => interimResult + DoIntensiveCalculations(),

// Final step after the calculations
// we add the result to the final result
// Action<TLocal> localFinally
(lastInterimResult) => result += lastInterimResult
);

// Print the result
Console.WriteLine("The result is {0}", result);
}

Wynik działania aplikacji:

The result is 22.0386557304958 
We’re done in 10370ms!

Praca z kontynuacjami

W niektórych sytuacjach nie można przekształcić wszystkiego w zadania bez łamania algorytmu naszej aplikacji. Musimy dbać o zależności wynikające z algorytmu. Jeżeli z zależności w algorytmie wynika, że nie można wykonać kroku 3 przed zakończeniem kroku 1 i kroku 2 to można użyć pewnych mechanizmów kontynuacji dostępnych w TPL. Załóżmy, że mamy w aplikacji trzy metody nazwane Step1Step2 i Step3 np:

classProgram {
staticvoidMain(string[] args) {
Step1();
Step2();
Step3();
}

staticvoidStep1() {
Console.WriteLine("Step1");
}

staticvoidStep2() {
Console.WriteLine("Step2");
}

staticvoidStep3() {
Console.WriteLine("Step3");
}
}

Zaimplementujmy funkcjonalność opartą o zadania dla różnych scenariuszy:

  1. Jeżeli metody są niezależne od pozostałych to możemy użyć np:
staticvoidMain(string[] args){
Parallel.Invoke(Step1, Step2, Step3);
}
  1. Metody step1 i Step2 są niezależne lecz metoda Step3 może być wykonana po zakończeniu metody Step1 np:
static void Main(string[] args) {
Task step1Task = Task.Run(() => Step1());
Task step2Task = Task.Run(() => Step2());
Task step3Task = step1Task.ContinueWith( (previousTask) => Step3());

Task.WaitAll(step2Task, step3Task);
}

Metoda Task.WaitAll(step2Task, step3Task); gwarantuje, że poczekamy na wyniki wszystkich kroków.

  1. Metody step1 i Step2 są niezależne lecz metoda Step3 może być wykonana po zakończeniu metod Step1 i Step2 np:
static void Main(string[] args) {
Task step1Task = Task.Run(() => Step1());
Task step2Task = Task.Run(() => Step2());
Task step3Task = Task.Factory.ContinueWhenAll(
new Task[] { step1Task, step2Task },
(previousTasks) => Step3()
);

step3Task.Wait();
}

Metoda ContinueWhenAll pobiera jako pierwszy parametr tablicę zadań które mają być wykonane i delegatę do uruchomienia kiedy zadania się zakończa. Delegata pobiera jako parametr tablicę zadań na których wykonanie oczekuje.

  1. Metody step1 i Step2 są niezależne lecz metoda Step3 może być wykonana po zakończeniu metod Step1 lub Step2 np:
static void Main(string[] args) {
Task step1Task = Task.Run(() => Step1());
Task step2Task = Task.Run(() => Step2());
Task step3Task = Task.Factory.ContinueWhenAny(
new Task[] { step1Task, step2Task },
(previousTask) => Step3()
);

step3Task.Wait();
}

Asynchroniczne Aplikacje w C # 5.0

C# 5.0Microsoft wprowadził dwa nowe słowa kluczowe do języka: async i await
Słowa kluczowego async możemy użyć aby oznaczyć metodę jako asynchroniczną i aby powiadomić kompilator, że metoda będzie miała co najmniej jedno wyrażenie await. Jeżeli kod nie posiada wyrażenia await to kompilator wygeneruje ostrzeżenie. 
Operator await stosuje się do zadania w metodzie asynchronicznej aby zawiesić wykonanie metody aż do momentu kiedy zawieszone zadanie zostanie wykonane. 
Wiele klas w bibliotece .NET Framework używających I/O zostało zmodyfikowanych poprzez dodanie do nich metod asynchronicznych wspierających wzorzec async/await
Przykład metody synchronicznej symulującej odczyt z I/O:

publicstaticdoubleReadDataFromIO(){
// We are simulating an I/O by putting the current thread to sleep.
Thread.Sleep(2000);
return10d;
}

Wariant asynchroniczny metody może wyglądać tak:

publicstaticTask<double> ReadDataFromIOAsync() {
returnTask.Run(new Func<double>(ReadDataFromIO));
}

Aby metoda była asynchroniczna musi zwracać obiekt Task lub Task i posiadać sufiks Async w nazwie.

Kiedy metoda jest oznaczona modyfikatorem async to musi zwracać jako wynik voidTask lub Task. Jeżeli metoda synchroniczna zwraca void to można wybrać pomiędzy void i Task. Jeżeli metoda nie jest obsługą zdarzenia to zaleca się aby zwracała Task dzięki czemu jest nie tylko metodą asynchroniczną ale również można oczekiwać na jej wykonanie za pomocą słowa kluczowego await. Kiedy metoda synchroniczna zwraca wynik jakiegokolwiek typu to należy zwracać Task w metodzie asynchronicznej.

Wracając do aplikacji WPF z poprzedniej sekcji zamieńmy metodę ReadDataFromIO na jej wersję asynchroniczną:

publicpartialclassMainWindow : Window {
publicMainWindow() {
InitializeComponent();
}
privateasyncvoidbtnRun_Click(object sender, EventArgs e) {
lblResult.Content = await ReadDataFromIOAsync();
}
}

kod napisany w ten sposób powoduje, że aplikacja nie zamarza i zwraca ten sam rezultat. Przeanalizujmy jak to działa:

  1. Dodajemy słowo kluczowe async aby oznaczyć metodę jako asynchroniczną. Zwracany typ metody nie ulega zmianie ponieważ musimy użyć sygnatury delegaty EventHandler.
  2. Zastępujemy wywołanie metody ReadDataFromIO poprzez await ReadDataFromIOAsync. Jest to równe wyrażeniu:
Task<double> task = ReadDataFromIOAsync();
lblResult.Content = task.Result;
  1. Kiedy wywołujemy ReadDataFromIOAsync to .NET Framework wykonuje kod do momentu kiedy zostaje on zablokowany przez operację I/O. W tym momencie stan metody zostaje zapisany, otoczony w zadanie i zwrócony do metody wywołującej.
  2. Metoda wywołująca kontynuuje wykonanie do momentu kiedy potrzebuje wyniku. W tym wypadku wynik otrzymuje od razu ale możliwe jest, że zapisuje zadanie, wykonuje więcej pracy synchronicznej i dopiero później wywołuje await blokując metodę wywołującą.

Rozpatrzmy przykład w którym wywołujemy metodę ReadDataFromIO kilkukrotnie z wnętrza kodu a nie z obsługi zdarzeń i aktualizujemy w ten sposób kilka etykiet np:

publicpartialclassMainWindow : Window {
publicMainWindow() {
InitializeComponent();
}
privatevoidbtnRun_Click(object sender, EventArgs e) {
GetData();
}
privatevoidGetData() {
lblResult.Content = ReadDataFromIO();
lblResult2.Content = ReadDataFromIO();
}
}

Aby zmienić kod w asynchroniczny musimy najpierw zmienić metodę GetData np:

privateasync Task GetDataAsync() {
lblResult.Content = await ReadDataFromIOAsync();
lblResult2.Content = await ReadDataFromIOAsync();
}

Dodaliśmy modyfikator async do metody i zmieniliśmy jej nazwę poprzez dodanie sufiksu Assync. Zwracany typ został zmieniony do typu Task i użyliśmy asynchronicznej wersji metody ReadDataFromIOAsync wraz ze słowek kluczowym await
Kiedy uruchomimy aplikacje to nie zostanie ona zamrożona po wciśnięciu guzika. po kilku sekundach otrzymamy pierwszy wynik a po kolejnych kilku następny. Dzieje się tak ponieważ blokujemy wywołującą metodę pierwszym wywołaniem await i druga metoda czeka na jego ukończenie.
Można to ulepszyć poprzez przepisanie metody GetDataAsync np:

privateasync Task GetDataAsync() {
var task1 = ReadDataFromIOAsync();
var task2 = ReadDataFromIOAsync();

// Here we can do more processing
// that doesn't need the data from the previous calls.
// Now we need the data so we have to wait
await Task.WhenAll(task1, task2);

// Now we have data to show.
lblResult.Content = task1.Result;
lblResult2.Content = task2.Result;
}

Zaawansowane zagadnienia dotyczące wielowątkowości

Programowanie wielowątkowe jest o wiele trudniejsze niż programowanie jednowątkowe z kilku przyczyn:

  • Kilka wątków może próbować zaktualizować ten sam fragment danych w tym samym czasie.
  • Nie zawsze gwarantuje jednakowe wyniki.
  • Jest trudne do śledzenia, rozumienia i odnajdywania błędów.
  • Jest trudne do testowania.

Jednym z najczęściej spotykanych problemów jest tzw race condition. Dzieje się to gdy co najmniej dwa wątki próbują zaktualizować ten sam zasób. Przeanalizujmy przykład takiej sytuacji. Załóżmy, że posiadamy zmienną nazwaną sharedData i dwa wątki które próbują wykonać instrukcję sharedData++, która jest wykonywana przez CPU w następujący sposób:

  1. Odczyt sharedData w rejestrze.
  2. Dodanie 1 do wartości w rejestrze.
  3. Zapis nowej wartości z rejestru z powrotem do zmiennej sharedData.

Dlaczego jest to tak ważne? Ponieważ jeśli byłaby to tylko jedna instrukcja, CPU mógłby wykonać to raz i żaden błąd nie może być tutaj wprowadzony. nazywane jest to operacja atomową. Jeżeli mamy aplikację wielowątkową to obiekt harmonogramu może przerwać aktualny wątek w każdej chwili i może to doprowadzić do błędu, np:

  1. Zmienna sharedData posiada wartość 0.
  2. Pierwszy wątek uruchamia pierwszą instrukcję, odczytując wartość 0.
  3. Drugi wątek uruchamia pierwszą instrukcję, odczytując wartość 0. Na maszynie z pojedynczym rdzeniem może to się wydarzyć kiedy harmonogram przerwie pierwszy wątek i uruchomi drugi. Na maszynach wielordzeniowych jest to normalna sytuacja ponieważ wątki mogą być uruchamiane na różnych rdzeniach.
  4. Pierwszy wątek inkrementuje wartość do 1.
  5. Pierwszy wątek zapsuje wartość 1 do zmiennej sharedData.
  6. Drugi wątek inkrementuje wartość do 1. W tej sytuacji wartość powinna być równa 2 lecz wartość początkowa która jest zapisana dla wątku drugiego posiada jest “stara” i wynosi 0.
  7. Drugi wątek zapisuje wartoś 1 do zmiennej sharedData.

Tak jak wyjaśniono taka sytuacja może zajść nawet w sytuacji kiedy aplikacja jest uruchamiana na maszynie z jednym rdzeniem lecz występuje dużo częściej na maszynach wielordzeniowych. Aby zapobiec takiej sytuacji należy upewnić się, że tylko jeden wątek ma dostęp do zmiennych współdzielonych w danym momencie czasu. 
Najlepszą metodą jest nie współdzielenie danych. Jeżeli musimy współdzielić dane to sprawmy aby były tylko do odczytu. Czasami jest to niemożliwe ze względu na rozmiar danych. Jeżeli tak jest to należy wyizolować dane i mieć pewność, że są używane w kontrolowany sposób. Jeżeli nie jest to możliwe to należy użyć mechanizmów synchronizacji danych. Podsumowując, kolejność rozważania na temat danych współdzielonych jest następująca:

  1. Nie współdziel.
  2. Spraw aby dane były tylko do odczytu.
  3. Izoluj dane w mniejszych modułach.
  4. Użyj mechanizmów synchronizacji danych.

Synchronizacja zasobów

Microsoft zapewnia kilka klas pozwalających na synchronizację zasobów. Istnieją klasy umożliwiające sygnalizację, wzajemne wykluczenie, anulowanie i klasy obsługujące kolekcja współbieżne.

Sygnalizacja jest używana jako mechanizm komunikacji pomiędzy watkami. W .NET istnieją dwa rodzaje sygnalizacji: zdarzenia synchronizacji i bariery.

Zdarzenia synchronizacji

Zdarzenia synchronizacji są obiektami które mogą się znajdować w dwóch stanach: sygnalizowany i niesygnalizowany. Jeżeli wątek potrzebuje aby coś było wykonane w innym wątku to może użyć zdarzenia sygnalizacji i badać stan zdarzenia jako mechanizm komunikacji. jeżeli jest w stanie sygnalizowanym to znaczy, że kontynuuje wykonywanie, jeżeli nie to znaczy, że blokuje wykonywanie, oczekując na zdarzenie, aż będzie sygnalizowane. Kiedy inne wątki zakończa swoją pracę, to sygnalizuje zdarzenie odblokowując oczekujące wątki. Zdarzenia synchronizacji są zaimplementowane w duch klasach: EventWaitHandle i CountdownEvent.

Klasa EventWaitHandle reprezentuje zdarzenie synchronizacji i jest zdefiniowana w przestrzeni nazw System.Threading. najczęściej wykorzystywane metody tej klasy to:

  • EventWaitHandle - Konstruktor z czterema przeciążeniami. Trzeba określić co najmniej czy zdarzenie powinno być sygnalizowane i czy zdarzenie powinno być resetowane manualnie czy automatycznie z użyciem enumeratora EventResetMode.
  • Dispose - implementacja interfejsu IDisposable. Metoda oczyszczająca niepotrzebne zasoby kiedy obiekt nie jest już potrzebny.
  • Reset - ustawia stan zdarzenia na niesygnalizowany powodując blokowanie wątków.
  • Set - ustawia stan zdarzenia na sygnalizowany. oczekujące wątki będą w stanie kontynuować. Jeżeli zdarzenie zostało utworzone jako AutoReset to tylko jeden wątek może wywołać WaitOne bez bycia zablokowanym lub jeżeli istnieją blokowane wątki jako wynik działania metodyWaitOne to tylko jeden wątek zostanie odblokowany i zdarzenie będzie w stanie niesygnalizowanym do momętu wywołania metody Set. Jeżeli zdarzenie było utworzone jako ManualReset to zdarzenie będzie sygnalizowane do momentu uzycia na nim metody Reset.
  • WaitOne - Blokuje aktualny wątek jeżeli jest niesygnalizowany. jeżeli zdarzenie jest sygnalizowane i zostało utworzone jako AutoReset to odblokowuje zdarzenie i resetuje je do stanu niesygnalizowanego.

Przykład użycia tych klas pokazujący jak zyskać pewność, że nie odczytujemy wyniku obliczeń przed ich zakończeniem. Oryginalna metoda wygląda tak:

staticvoidRunInThreadPool() {
double result = 0d;

// Create a work item to read from I/O
ThreadPool.QueueUserWorkItem((x) => result += ReadDataFromIO());

// Save the resultof the calculation into another variable
double result2 = DoIntensiveCalculations();

// Waitfor the thread to finish
// TODO: We will need a way to indicate
// when the thread pool thread finished the execution
// Calculate the endresult
result += result2;

// Print the result
Console.WriteLine("The result is {0}", result);
}

Przykład rozwiązania za pomocą sygnalizacji:

staticvoidRunInThreadPoolWithEvents() {
double result = 0d;

// We use this event to signal when the thread is don executing.
EventWaitHandle calculationDone =
new EventWaitHandle(false, EventResetMode.ManualReset);

// Create a work item to read from I/O
ThreadPool.QueueUserWorkItem((x) => {
result += ReadDataFromIO();
calculationDone.Set();
});

// Save the resultof the calculation into another variable
double result2 = DoIntensiveCalculations();

// Waitfor the thread to finish
calculationDone.WaitOne();

// Calculate the endresult
result += result2;

// Print the result
Console.WriteLine("The result is {0}", result);
}

Kod działa następująco:

  1. Tworzymy obiekt klasy EventWaitHandle w stanie niesygnalizowanym.
  2. Dodajemy do kolejki nowe zadanie. po otrzymaniu pierwszego wyniku sygnalizujemy zdarzenie aby wskazać, że obliczenia są zakończone.
  3. Wywołujemy drugą metodę w wątku głównym.
  4. Po otrzymaniu wyniku drugiej metody, czekamy na zakonczenie kalkulacji pierwszej metody poprzez oczekiwanie na to kiedy zdarzenie będzie w stanie sygnalizowanym.
  5. kiedy otrzymamy stan sygnalizowany wiemy, że posiadamy wynik więc możemy obliczyć wartość finalną i ją pokazać.

.NET posiada dwie klasy dziedziczące z EventWaitHandle, są to: AutoResetEvent i ManualResetEvent. obie posiadają tylko jeden konstruktor i nie posiadają żadnych metod ani właściwości. Ich konstruktor przyjmuje wartość logiczną oznaczającą czy zdarzenie jest w stanie sygnalizowanym czy nie.

Klasa CoundownEvent jest zdefiniowana w przestrzeni nazw System.Threading i została wprowadzona w .NET 4. Scenariusz użycia klasy jest prosty: czekamy, aż zdefiniowana liczba wątków zostanie zakończona. najczęściej wykorzystywane metody i właściwości klasyCoundownEvent:

  • CountdownEvent - konstruktor przyjmujący ilość oczekiwanych sygnałów zanim zmieni stan na sygnalizowany.
  • AddCount - inkrementuje aktualną liczbę zdarzeń. Jeżeli zdarzenie jest ustawione to zgłasza wyjątek InvalidOperationException.
  • Dispose - implementacja interfejsu IDisposable. Metoda oczyszczająca niepotrzebne zasoby kiedy obiekt nie jest już potrzebny.
  • Reset - resetuje właściwość InitialCount.
  • Signal - Rejestruje sygnał dekrementując właściwość CurrentCount o określoną wartość.
  • TryAddCount - inkrementuje aktualną liczbę zdarzeń i nie zgłasza wyjątku jak metoda AddCount lecz zwraca true lub false.
  • Wait - Blokuje aktualny wątek dopóki zdarzenie CountdownEvent jest ustawione.
  • CurrentCount - właściwość tylko do odczytu zwracająca aktualna wartość wymaganych sygnałów brakujących do ustawienia zdarzenia.
  • InitialCount - właściwość tylko do odczytu, wartość początkowa wymaganych sygnałów brakujących do ustawienia zdarzenia.
  • IsSet - właściwość tylko do odczytu, zwraca true jeżeli zdarzenie jest ustawione.
  • WaitHandle - właściwość tylko do odczytu zwracająca WaitHandle uzyty do oczekiwania na zdarzenie do ustawienia.

Klasa CoundownEvent nie dziedziczy z klasy WaitHandle jek większość klas synchronizacji dlatego posiada właściwość WaitHandle zwracającą instancję używanego obiektu WaitHandle.

Bariery

W scenariuszach wielowątkowych istnieją sytuacje w których wywołujemy kilka wątków i chcemy mieć pewność, że wszystkie dotrą do pewnego punktu, zanim będzie można kontynuować wykonywanie kodu. 
Przykładem takiego scenariusza możne być np sytuacja w której grupa przyjaciół decyduje się podróżować samochodem z punktu A do punktu C przez punk B. Wszyscy startują z punktu A i zatrzymują się w B; potem planują wystartować wspólnie jeszcze raz i spotkać sie w punkcie C. Niektórzy z nich mogą nawet zdecydować się, że nie chcą już podróżować i wrócić do domu. 
.NET 4 wprowadził nową klasę System.Threading.Barrier która dotyczy dokładnie takich sytuacji. 
Najczęściej uzywane metody i właściwości klasy Barrier:

  • Barrier - konstruktor z dwoma przeciążeniami przyjmującymi jako parametr liczbę uczestników. Drugie przeciążenie pobiera dodatkowy parametr typu Action który będzie uruchomiony po przybyciu wszystkich uczestników do bariery w jednej fazie.
  • AddParticipant - wysyła informację do bariery o tym, że będzie jeszcze jeden uczestnik.
  • AddParticipants - wysyła informację do bariery o tym, że będzie jeszcze kilku uczestników.
  • Dispose - implementacja interfejsu IDisposable. Metoda oczyszczająca niepotrzebne zasoby kiedy obiekt nie jest już potrzebny.
  • RemoveParticipant - wysyła informację do bariery o tym, że będzie o jednego z uczestników mniej.
  • RemoveParticipants - wysyła informację do bariery o tym, że będzie o kilku uczestników mniej.
  • SignalAndWait - sześć przeciążeń. Sygnalizuje, że uczestnik dotarł do bariery i oczekuje na dotarcie innych uczestników. Przeciążenia służą do wywołania metody z tokenem anulującym i/lub limitem czasu.
  • CurrentPhaseNumber - właściwość tylko do odczytu zwracająca aktualną fazę bariery.
  • ParticipantCount - właściwość tylko do odczytu zwracająca całkowitą liczbę uczestników bariery.
  • ParticipantsRemaining - właściwość tylko do odczytu zwracająca liczbę uczestników którzy jeszcze nie dotarli do bariery.

Przykład użycia klasy Barrier:

staticvoidMain(string[] args){
var participants = 5;

Barrier barrier = new Barrier(participants + 1,
// We add one for the main thread.
b => { // This method is only called when all the paricipants arrived.
Console.WriteLine("{0} paricipants are at rendez-vous point {1}.",
b.ParticipantCount -1, // We substract the main thread.
b.CurrentPhaseNumber);
});

for (int i = 0; i < participants; i++) {
var localCopy = i;
Task.Run(() => {
Console.WriteLine("Task {0} left point A!", localCopy);
Thread.Sleep(1000 * localCopy + 1); // Do some "work"
if (localCopy % 2 == 0) {
Console.WriteLine("Task {0} arrived at point B!", localCopy);
barrier.SignalAndWait();
}
else {
Console.WriteLine("Task {0} changed its mind and went back!",
localCopy);
barrier.RemoveParticipant();
return;
}
Thread.Sleep(1000 * (participants - localCopy)); // Do some "more work"
Console.WriteLine("Task {0} arrived at point C!", localCopy);
barrier.SignalAndWait();
});
}

Console.WriteLine("Main thread is waiting for {0} tasks!",
barrier.ParticipantCount - 1);
barrier.SignalAndWait(); // Waiting at the first phase
barrier.SignalAndWait(); // Waiting at the second phase
Console.WriteLine("Main thread is done!");
}

Wynik działania kodu

Main thread is waiting for 5 tasks! 
Task 4 left point A! 
Task 2 left point A! 
Task 0 left point A! 
Task 3 left point A! 
Task 1 left point A! 
Task 0 arrived at point B! 
Task 1 changed its mind and went back! 
Task 2 arrived at point B! 
Task 3 changed its mind and went back! 
Task 4 arrived at point B! 
3 paricipants are at rendez-vous point 0. 
Task 4 arrived at point C! 
Task 2 arrived at point C! 
Task 0 arrived at point C! 
3 paricipants are at rendez-vous point 1. 
Main thread is done!

Użycie mechanizmów blokowania

Jednym ze sposobów radzenia sobie z udostępniania danych jest wzajemne wykluczanie (ang mutual exclusion). Wzajemne wykluczanie zapewnia, że tylko jeden wątek ma dostęp do udostępnianego zasobu w danym czasie. jeżeli inny wątek próbuje dostać się do zasobu to zostaje zablokowany do czasu aż pierwszy z wątków nie zwolni dostępu do zasobu. Zamiast kontrolować wszystkie ścieżki kodu które kierują się do określonego regionu danych kontrolujemy regiony kodu które próbują otrzymać dostęp do części danych. Wzajemne wykluczanie jest zaimplementowane w .NET na kilka sposobów: monitory, muteksy, semafory, blokady odczytu i zapisu oraz kilka implementacji nieblokujących.

Monitory

Monitory są prymitywnymi synchronizatorami używanymi do synchronizacji dostępu do obiektów. Monitor jest zaimplementowany w klasie System.Threading.Monitor. Klasa Monitor jest używana w połączeniu z typami referencyjnymi aby upewnić się, że tylko jeden wątek ma dostęp do obiektu w danej chwili. Klasa udostępnia tylko metody statyczne które pobierają jako pierwszy parametr obiekt który ma być blokowany. W każdym momencie najwyżej jeden wątek może zablokować obiekt za pomocą wywołania metody statycznej Monitor.Enter. Jeżeli inny wątek wywoła metodę Monitor.Enter zanim pierwszy wątek wywoła metodę Monitor.Exit to drugi wątek będzie blokowany do czasu aż pierwszy wywoła metodę Monitor.Exit. W .NET wszystkie typy referencyjne posiadają pole które przechowuje referencję do wątku który nałożył blokadę na obiekt oraz listę wątków gotowych które czekają na nałożenie blokady oraz listę obiektów oczekujących na powiadomienie poprzez metodę Pulse lub PulseAll
Najczęściej wykorzystywane metody klasy Monitor:

  • Enter - zakłada wyłączną blokadę na obiekcie.
  • Exit - zwalnia blokadę na obiekcie.
  • IsEntered - zwraca true jeżeli na obiekcie nałożona jest blokada. (Metoda została wprowadzona w .NET 4.5).
  • Pulse - powiadamia wątek na liście oczekujących o zmianie stanu obiektu, w efekcie przenosząc wątek z listy oczekujących na listę gotowych.
  • PulseAll - powiadamia wszystkie wątki na liście oczekujących o zmianie stanu obiektu, w efekcie przenosząc wątki z listy oczekujących na listę gotowych.
  • TryEnter - próbuje założyć wyłączną blokadę na obiekcie. metoda posiada sześć przeciążeń.
  • Wait - zwalnia blokadę na obiekcie i blokuje aktualny wątek do czasu aż będzie potrzebował blokady. Aktualny wątek zostanie umieszczony na liście oczekujących i będzie czekał na wywołanie metody Pulse lub PulseAll aby mógł kontynuować wykonywanie.

Metody WaitPulsePulseAll mogą być wywoływane tylko przez wątek który jest właścicielem blokady. 
Prosty przykład użycia monitora:

objectsyncObject =newobject();
Monitor.Enter(syncObject);
// Code updating some shared data
Monitor.Exit(syncObject);

Kod ten nie sprawdza czy w czasie wykonywania nie został zgłoszony wyjątek. Jeżeli wyjątek został zgłoszony to blokada nie zostanie zwolniona prowadząc do zakleszczenia. Aby rozwiązać ten problem można użyć bloku try-catch-finally np:

objectsyncObject =newobject();
Monitor.Enter(syncObject);
try {
// Code updating some shared data
}
finally {
Monitor.Exit(syncObject);
}

Język C# posiada skrót do tego wyrażenia nazwany lock np:

objectsyncObject =newobject();
lock (syncObject) {
// Code updating some shared data
}

Alternatywy Lock-free

Blokady są niebezpieczne i konsumują wiele zasobów. Czasem potrzebujemy wykonać proste operacje i chcemy być pewni, że są one atomowe. Aby rozwiązać tego typu problemy .NET oferuje klasę Interlocked zdefiniowaną w przestrzeni nazw System.Threading. Klasa ta posiada jedynie metody statyczne, które reprezentują wyłącznie operacje atomowe co oznacza, że zostaną wykonane bez przerywania przez harmonogram. 
lista metod klasy Interlocked:

  • Add - dodaje dwie liczby 32-bitowe lub 64-bitowe i zastępuje pierwszą liczbę całkowitą z sumą, jako operacja atomowa.
  • CompareExchange - porównuje pierwszy i trzeci parametr dla równości i jeżeli są równe, zastępuje pierwszy parametr wartością drugiego.
  • Decrement - dekrementuje wybraną zmienną i zapisuje wynik, jako operacja atomowa.
  • Exchange - ustawia obiekt do określonej wartości i zwraca odwołanie do oryginalnego obiektu, jako operacja atomowa.
  • Increment - inkrementuje wybraną zmienną i zapisuje wynik, jako operacja atomowa.
  • Read - Ładuje wartość 64-bitową jako operacja atomowa i zwraca ją do wywołującego. Jest to konieczne tylko na platformach 32-bitowych.

Kolekcje współbieżne

Kolekcje zaimplementowane w przestrzeniach nazw System.Collections i System.Collections.Generic nie zostały zaimplementowane jako thread-safe. Zostały zaimplementowane tak aby były szybkie. Przed .NET 4.0 jeżeli potrzebowaliśmy kolekcji współbieżnej to musieliśmy zaimplementować ją sami. Od .NET 4.0Microsoft wprowadził kolekcje współbieżne. Wszystkie są zdefiniowane w przestrzeni nazw System.Collections.Concurrent. Wszystkie kolekcje współbieżne, z wyjątkiem klasy ConcurrentDictionary, implementują interfejsIProducerConsumerCollection. Ten interfejs wprowadza następujące metody:

  • CopyTo - kopiuje elementy obiektu IProducerConsumerCollection do tablicy Array, startując od wybranej pozycji.
  • ToArray - zwraca nową tablicę zawierającą wszystkie elementy obiektu IProducerConsumerCollection.
  • TryAdd - próbuje dodać obiekt do obiektu IProducerConsumerCollection.
  • TryTake - próbuje usunąć i zwrócić obiekt z obiektu IProducerConsumerCollection.

Lista klas implementujących interfejs IProducerConsumerCollection:

  • BlockingCollection - implementuje interfejs IProducerConsumerCollection, zapewniając blokowanie i ograniczenia możliwości.
  • ConcurrentBag - reprezentuje bezpieczną dla wątków kolekcję obiektów.
  • ConcurrentDictionary <TKey, TValue> - reprezentuje bezpieczną dla wątków wersję klasy Dictionary <TKey, TValue>.
  • ConcurrentQueue - reprezentuje bezpieczną dla wątków wersję klasy Queue.
  • ConcurrentStack - reprezentuje bezpieczną dla wątków wersję klasy Stack.

Praca z anulacjami (ang. Cancellations)

Przed .NET 4.0 każde anulowanie operacji było niebezpieczne. Aby anulować operację należało przerwać wątek lub porzucić operację. Pomimo, że to dziłało w większości przypadków to było źródłem wielu błędów. 
.NET 4.0 wprowadził anulacje jako obywateli pierwszej klasy biblioteki .NET. Anulacje są odwołaniami kooperacyjnymi, co oznacza, że można wysłać żądanie unieważnienia do innego wątku, lub zadania, ale to ich wybór, aby uhonorować to żądanie. 
Możliwości anulacji są implementowane przy użyciu jednej klasy CancellationTokenSource i jednej struktury CancellationToken działających w następujący sposób:

  1. Jeśli wątek chce mieć możliwość odwołania kolejnej operacji, tworzy obiekt CancellationTokenSource.
  2. Kiedy odwoływalna asynchroniczna operacja startuje, wątek wywołujący podaje CancellationToken uzyskany poprzez właściwość Token obiektu CancellationTokenSource. Odwoływalna asynchroniczna operacja oznacza, albo operację wspierającą anulowanie albo nowy watek lub, że zostanie utworzona poprzez aktualny wątek. To jest zwykle wyrażone w postaci jednej lub kilku metod przeciążonych akceptujących parametr CancellationToken. Wysyłając CancellationToken chcemy pozwolić operacji na zainicjowanie anulowania. Może to być wykonane jedynie przez obiekt CancellationTokenSource.
  3. Jeśli wątek rodzic chce, aby anulować trwające odwoływalne operacje, wywołuje Cancel na obiekcie CancellationTokenSource.
  4. Wszystkie bieżące operacje mogą używać CancelationToken wysłany jako parametr, aby sprawdzić, czy odwołanie jest w toku i odpowiednio na nie reagować.

Przykład użyty w sekcji z barierami przerobiony w taki sposób aby anulował trasy:

staticvoidMain(string[] args){
var participants = 5;

// We create a CancellationTokenSource to be able to initiate the cancellation
var tokenSource = new CancellationTokenSource();

// We create a barrier object to use it for the rendez-vous points
var barrier = new Barrier(participants,
b => {
Console.WriteLine("{0} paricipants are at rendez-vous point {1}.",
b.ParticipantCount,
b.CurrentPhaseNumber);
});
for (int i = 0; i < participants; i++) {
var localCopy = i;
Task.Run(() => {
Console.WriteLine("Task {0} left point A!", localCopy);
Thread.Sleep(1000 * localCopy + 1); // Do some "work"
if (localCopy % 2 == 0) {
Console.WriteLine("Task {0} arrived at point B!", localCopy);
barrier.SignalAndWait(tokenSource.Token);
}
else {
Console.WriteLine("Task {0} changed its mind and went back!",
localCopy);
barrier.RemoveParticipant();
return;
}
Thread.Sleep(1000 * localCopy + 1);
Console.WriteLine("Task {0} arrived at point C!", localCopy);
barrier.SignalAndWait(tokenSource.Token);
});
}
Console.WriteLine("Main thread is waiting for {0} tasks!",
barrier.ParticipantsRemaining - 1);
Console.WriteLine("Press enter to cancel!");
Console.ReadLine();
if(barrier.CurrentPhaseNumber < 2){
tokenSource.Cancel();
Console.WriteLine("We canceled the operation!");
}
else{
Console.WriteLine("Too late to cancel!");
}
Console.WriteLine("Main thread is done!");
}

Kod startuje od utworzenia obiektów CancellationTokenSource i Barrier. Liczba uczestników bariery jest taka sama jak liczba wszystkich uczestników ponieważ chcemy użyć głównego wątku do anulowania. 
Następnie w kodzie dla każdego uczestnika tworzone jest zadanie. Tak jak poprzednio, każde zadanie które zmieniło zdanie zostaje usunięte z bariery. 
Następnie kod wywołuje SignalAndWait, co wysyła token anulowania z CancellationTokenSource
Główny wątek wywołuje Console.ReadLine czekając na komendę użytkownika. Kiedy użytkownik naciśnie Enter, kod sprawdza czy wszyscy dotarli do celu. Kiedy się tego dowie poprzez zapytanie o wartość CurrentPhaseNumber obiektu bariery. Jeżeli wartość wynosi 2 to oznacza, że wszyscy przeszli przez dwie fazy, więc nie trzeba anulować operacji. 
Finalnie, kod wywołuje tokenSource.Cancel, co wysyła sygnał anulowania do wszystkich obiektów czekających w obiekcie bariery, co odblokowuje je i anuluje pozostałą część operacji.

Podsumowanie

Wątki (ang. Threads)

  • Wątek tworzymy poprzez wysłanie do konstruktora delegaty która będzie główną metodą wątku.
  • Wątek startujemy poprzez bezpośrednie wywołanie metody Start na jego obiekcie.
  • Aby użyć puli wątków wywołujemy ThreadPool.QueueUserWorkItem lub ThreadPool.RegisterWaitForSingleObject.

Zadania (ang. Tasks)

  • Zadanie tworzymy poprzez wysłanie do konstruktora delegaty która będzie główną metodą zadania. Zadanie startujemy poprzez bezpośrednie wywołanie metody Start na jego obiekcie.
  • Wywołanie metody statycznej Task.Run z delegatą jako parametrem która będzie główną metodą zadania, tworzymy zadanie które jest startowane automatycznie.
  • Wywołanie metody statycznej Task.Factory.StartNew z delegatą jako parametrem która będzie główną metodą zadania, tworzymy zadanie które jest startowane automatycznie.

Blokady (ang. Locks)

  • Używamy metod Monitor.Enter lub Monitor.TryEnter aby założyć blokadę. Używamy Monitor.Exit aby zwolnić blokadę.
  • W języku C# można użyć wyrażenia lock, które jest skrótem dla utworzenia i zwolnienia blokady w bloku try-finally.

Anulowanie (ang. Cancellations)

  • Używamy obiektu CancellationTokenSource do kontrolowania operacji anulowalnych.
  • Używamy obiektu CancellationToken uzyskanego z CancellationTokenSource za pomocą właściwości Token aby wystartować operację anulowalną.
  • Aby anulować operację wywołujemy metodę Cancel obiektu CancellationTokenSource.

async/await

  • Są to dwa nowe słowa kluczowe wprowadzone w języku C# 5.0.
  • Słowo kluczowe async oznacza metodę jako asynchroniczną, mówiąc kompilatorowi, że metoda posiada instrukcję await w swoim ciele. Jeżeli w ciele metody nie ma instrukcji await to kompilator zwróci ostrzeżenie.
  • Słowo kluczowe await blokuje wykonanie aktualnej metody, czekając na wykonanie oczekiwanej operacji.

BackgroundWorker

  • Wykonuje długotrwałe operacje które trzeba wykonać jako zdarzenie DoWork.
  • Operację startujemy poprzez wywołanie RunWorkerAsync.
  • Aby dowiedzieć się kiedy długotrwała operacja została wykonana należy subskrybować zdarzenie RunWorkerCompleted.
  • Aby dowiedzieć się o postępie operacji należy subskrybować zdarzenie ProgressChanged.

Kontynuacje zadań (ang. Task continuation)

  • Kontynuuje zadanie za pomocą metod ContinueWithWhenAll lub WhenAny.