Poprzednia notatka dotycząca wielowątkowości, a dokładnie wątków, stanowiła
wstęp do zadań, czyli klasy Task
, która jest poniekąd warstwą nad klasą Thread
. Klasa Task
reprezentuje pracę, która zostanie wykonana.
Zacznijmy od porównania. Zarówno Task
jak i Thread
poprzez konstruktor tworzą zadania wymagające uruchomienia (ang. cold tasks) przy użyciu metody Start.
Podstawową różnicą jest miejsce wykonania delegata. W przypadku Task
będzie to ThreadPool
, dzięki czemu nie musimy implementować obsługi samego wątku.
new Thread(DoWork).Start();
new Task(DoWork).Start();
void DoWork()
{
// Zrób coś...
}
Kolejnym podobieństwem jest metoda umożliwiająca odczekanie określonej liczby milisekund, z tą różnicą, że metoda Sleep, klasy Thread
powoduje zablokowanie bieżącego wątku.
W przypadku metody Delay klasy Task
zwracany jest obiekt typu Task
, dzięki czemu możliwa jest praca asynchroniczna, a sam wątek nie jest blokowany.
Klasa Task
w zestawieniu z ThreadPool
posiada kilka dodatkowych mechanizmów takich jak: sygnalizacja momentu zakończenia wykonywanej pracy, możliwość zwrócenia wyniku
oraz propagację zgłoszonego wyjątku. To, co różni klasy, to moment uruchomienia zadania. W przypadku ThreadPool
zadania automatycznie zostają uruchomione (ang. hot tasks).
Klasa Task
nie posiada metody Join, zamiast niej dysponuje metodami Wait, WaitAll oraz WaitAny. Metoda Wait oczekuje na zakończenie zadania, WaitAll
na zakończenie wszystkich zadań wewnątrz przekazanej tablicy, natomiast WaitAny oczekuje na pierwsze zakończone zadanie. Podobnie jak w przypadku metody Join, ich stosowanie nie jest
zalecane ze względu na blokowanie wątku oraz możliwość utworzenia następnych wątków przez ThreadPool
. Zamiast nich zaleca się stosowanie ContinueWith, która tworzy
kolejne zadanie, wykonywane natychmiast po zakończeniu nadrzędnego.
var task = new Task(DoWork);
task.Start();
task.ContinueWith(t =>
{
// Zrób coś, po zakończeniu zadania...
});
void DoWork()
{
// Zrób coś...
}
Zadanie może znajdować się w jednym z kilku stanów reprezentowanych przez wyliczenie TaskStatus
udostępnione przez właściwość Status (odczyt aktualnego stanu nie powoduje zablokowania).
Stworzone zadanie otrzymuje status Created. Podczas oczekiwania na aktywację status zmieniany jest na WaitingForActivation. Kolejne statusy to WaitingToRun oznaczający zaplanowanie,
przekształcający się w Running, czyli przetwarzanie. Jeśli wewnątrz zadania utworzone zostanie inne zadanie z opcją AttachedToParent, zadanie otrzyma status WaitingForChildrenToComplete,
co oznacza oczekiwanie na zakończenie zadań podrzędnych. Zadanie kończy się oznaczeniem jednym z trzech stanów: RunToCompletion, Canceled lub Faulted, które kolejno oznaczają: zakończono powodzeniem,
anulowane oraz zakończone z powodu nieobsłużonego wyjątku. Oprócz właściwości Status, udostępnione zostały właściwości powiązane ze stanem końcowym: IsCompletedSuccessfully, IsCanceled
oraz IsFaulted. Jeżeli potrzebujemy informacji czy zadanie zostało zakończone bez rozróżnienia na jego stan końcowy, możemy skorzystać z IsCompleted.
Jeżeli utworzony został obiekt typu Task<T>
, dostępna jest właściwość Result, zwracająca wynik, dostępny po wykonaniu zadania. W sytuacji, gdy zadanie nie zostało
zakończone, użycie metody spowoduje zablokowanie wątku, do momentu jego zakończenia. Problem demonstruje kod poniżej.
var task = new Task<int>(DoWork);
task.Start();
Console.WriteLine($"Result: {task.Result}");
int DoWork()
{
Thread.Sleep(5000);
return Environment.CurrentManagedThreadId;
}
Przykład poniżej korzysta zarówno z metody ContinueWith jak i właściwości Result. Zwróć uwagę, iż kod nie czeka na zakończenie metody DoWork, a dodatkowo użycie Result nie blokuje wątku.
var task = new Task<int>(DoWork);
task.Start();
task.ContinueWith(t =>
{
Console.WriteLine($"Result: {t.Result}");
});
int DoWork()
{
Thread.Sleep(5000);
return Environment.CurrentManagedThreadId;
}
Metoda ContinueWith umożliwia przekazanie wartości TaskContinuationOptions
, określającej warunek uruchomienia utworzonego zadania będącego kontynuacją innego zadania.
Pozwala to na specyficzną obsługę zadań zakończonych powodzeniem, wyjątkiem lub anulowanych. Jedną z wartości TaskContinuationOptions
jest ExecuteSynchronously, powodująca
umieszczenie zadania kontynuacji w tym samym elemencie puli wątków, co zadanie poprzedzające. Zadanie zostanie wykonane natychmiast po zakończeniu poprzednika, bez zwrócenia wątku do puli.
var task = new Task<int>(DoWork);
task.Start();
task.ContinueWith(t =>
{
Console.WriteLine($"Result: {t.Result}");
}, TaskContinuationOptions.OnlyOnRanToCompletion);
task.ContinueWith(t =>
{
Console.WriteLine($"Exception: {t.Exception}");
}, TaskContinuationOptions.OnlyOnFaulted);
int DoWork()
{
throw new Exception();
}
Zwróć uwagę, że klasa Task
w przeciwieństwie do Thread
, w sytuacji nieobsłużenia wyjątku nie powoduje dalszych "nieprzyjemności". Zadanie zakończone wyjątkiem otrzyma
status Faulted. Próba uzyskania wyniku poprzez właściwość Result lub użycie metody Wait, spowoduje zgłoszenie wyjątku AggregateException
. Wyjątek możemy pobrać poprzez
właściwość Exception. Jeżeli stan zadania zakończonego niepowodzeniem nie zostanie odczytany, klasa TaskScheduler
zgłosi statyczne zdarzenie UnobservedTaskException. Jest to
ostatnia okazja na przekazanie informacji, którą można potwierdzić korzystając z metody SetObserved argumentu zdarzenia. To, co wydarzy się, jeśli nie zareagujemy, zależy od wersji .NET Framework.
W wersji 4.0 zostanie uruchomiony domyślny mechanizm, o którym mogliśmy przeczytać przy okazji klasy Thread
, doprowadzający do zakończenia całego procesu. W nowszych wersjach platforma
nie robi nic oprócz zgłoszenia zdarzenia. Nie mniej mechanizm zostanie wywołany w sytuacji, gdy zadanie stanie się nieosiągalne, czyli wraz z uruchomieniem mechanizmu odzyskiwania pamięci.
Chciałbym podkreślić, że domyślnie utworzone zadanie wykonuje się niezależnie od innych zadań. Kod poniżej doprowadza do sytuacji, w której zadanie nadrzędne uruchamia zadania podrzędne, których wynik
zwracany jest przez zadanie nadrzędne. Opisana sytuacja wymusza użycie konstruktora przyjmującego enumerację TaskCreationOptions
zmieniającą zachowanie tworzonego zadania. Wybranie opcji
AttachedToParent
spowoduje oznaczenie zadania nadrzędnego jako zakończonego, gdy wszystkie zadania podrzędne zostaną zakończone. Jeżeli potrzebujesz szczegółowego opisu
TaskCreationOptions
, zostawiam link.
var task = new Task<int[]>(DoWork);
task.Start();
task.ContinueWith(t =>
{
Console.WriteLine($"Result: {t.Result[0]}, {t.Result[1]}");
});
Thread.Sleep(6000);
int[] DoWork()
{
var counts = new int[2];
new Task(() =>
{
counts[0] = GetCount();
}, TaskCreationOptions.AttachedToParent).Start();
new Task(() =>
{
counts[1] = GetCount();
}, TaskCreationOptions.AttachedToParent).Start();
return counts;
}
int GetCount()
{
Thread.Sleep(3000);
return Environment.CurrentManagedThreadId;
}
Klasa Task
udostępnia właściwość Factory typu TaskFactory
. Właściwość Factory posiada metodę StartNew
tworzącą gorące zadanie, czyli takie, które zostanie uruchomione bez potrzeby wywołania metody Start. W celu skrócenia zapisu udostępniona została statyczna metoda Run. Metoda wykorzystywana jest do oddelegowania
synchronicznej pracy. Opisane metody wykorzystywane do utworzenia zadania, odkładają je na lokalnej kolejce danego wątku, natomiast metody QueueUserWorkItem
oraz UnsafeQueueUserWorkItem
domyślnie kierują zadanie na globalną wspólną kolejkę dzieloną pomiędzy wszystkie wątki. Zgodnie z wcześniej przekazaną informacją, w pierwszej kolejności sprawdzana jest lokalna kolejka, oznacza to, że zadanie zlecone
poprzez metodę Run potencjalnie zostanie rozpoczęte w pierwszej kolejności.
Task.Factory.StartNew(DoWork);
Task.Run(DoWork);
Tworząc zadanie poprzez konstruktor klasy Task
, niezbędne jest samodzielne uruchomienie. W przypadku metody Run nie ma takiej potrzeby. Dodatkowo zapobiegamy sytuacji, w których metoda
Start zostaje uruchomiona więcej niż raz, dzięki czemu nie ma potrzeby wykonywania kosztownej synchronizacji wewnątrz metody Start. To z kolei sprawia, że metoda Run wykonuje się szybciej.
Użycie konstruktora wraz z metodą Start powinniśmy ograniczyć do kilku sytuacji. Pierwszą z nich i najbardziej oczywistą jest potrzeba oddzielenia inicjalizacji zadania od jego uruchomienia.
Kolejną, określenie typu Task
jako typ bazowego klasy pochodnej. Ostatnią użycie referencji do zadania w jego wnętrzu.
Kilka linii wyżej pisałem, że metoda Run jest skróconą wersją StartNew. Metoda StartNew zalecana jest w sytuacji, gdy potrzebujemy określić jeden z parametrów
CancellationToken
, TaskCreationOptions
lub TaskScheduler
. Kod poniżej prezentuje dwa równorzędne zapisy.
Task.Run(DoWork);
Task.Factory.StartNew(DoWork, CancellationToken.None,
TaskCreationOptions.DenyChildAttach, TaskScheduler.Default);
Wspomnę jeszcze, że metoda Run nie wymaga ręcznego wywołania metody Unwrap w przypadku zwrócenia zagnieżdżonego zadania Task<Task<T>>
. Więcej o tym zagadnieniu
możesz przeczytać w notatce opisującej asynchroniczność.
Przejdźmy do kwestii zatrzymania pracy wykonywanej przez klasę Task
. Wspomniana wcześniej struktura CancellationToken
posiadająca prywatne pole, wskazujące czy zadanie zostało
anulowane. Do zmiany wartości pola używamy metody Cancel klasy CancellationTokenSource
. Strukturę CancellationToken
powinniśmy przekazać zarówno do metody tworzącej
zadanie, jak i wykonującej pracę. Umożliwi to anulowanie pracy jeszcze przed jej rozpoczęciem. W takiej sytuacji zgłaszany jest wyjątek TaskCanceledException
, który jest pochodną OperationCanceledException
.
Przyjętym standardem jest zgłoszenie wyjątku OperationCanceledException
, w celu poinformowania, że zadanie zostało przerwane. W przeciwnym razie ustawiony zostanie status RanToCompletion.
Przed zgłoszeniem wyjątku należy wykonać czynności związane ze "sprzątaniem". Zastosowanie CancellationToken
wymaga regularnego sprawdzenia, czy zadanie nie zostało anulowane. Takie działanie
prezentuje kod poniżej.
var cts = new CancellationTokenSource();
CancellationToken token = cts.Token;
Task.Run(() => DoWork(token), token);
cts.Cancel();
void DoWork(CancellationToken token)
{
while (true)
{
if (token.IsCancellationRequested)
{
Clear();
throw new OperationCanceledException(token);
}
// Zrób coś...
}
}
Alternatywnym rozwiązaniem jest zastosowanie metody ThrowIfCancellationRequested zgłaszającej wyjątek, w sytuacji, gdy praca została przerwana. W celu zapewnienia wywołania metody Clear
przed zgłoszeniem wyjątku (identycznie jak we wcześniejszym przykładzie), należy użyć metody Register, do której przekazujemy delegata metody "sprzątającej". Pamiętaj, że anulowanie zadania, może
wcale nie przerwać jego realizowanego. Kod realizowany przez zadanie nie ma obowiązku używać struktury CancellationToken
.
void DoWork(CancellationToken token)
{
token.Register(Clear);
while (true)
{
token.ThrowIfCancellationRequested();
// Zrób coś...
}
}
Podobnie jak w przypadku wątków, zadania również zostały wyposażone w mechanizm pozwalający na wykonanie w określonym kontekście (wątku). W tym celu udostępniona została klasa TaskScheduler
.
Statyczna właściwość Current udostępnia domyślny "harmonogram zadania" o typie ThreadPoolTaskScheduler
, który przypisuje zadania do wątków ThreadPool
. Dostęp do niego
można uzyskać również poprzez właściwość Default. W nawiązaniu do notatki wielowątkowości,
kod poniżej po wywołaniu zdarzenia Click rozpoczyna zadanie, którego celem jest wykonanie "zaawansowanej" metody DoWork. Jej wynik przetwarzany jest przez kolejne zadanie realizowane poprzez
ContinueWith. Metoda ContinueWith oprócz delegata przyjmuje parametr typu TaskScheduler
. Zadanie tworzone jest z wykorzystaniem statycznej metody FromCurrentSynchronizationContext,
dzięki czemu zmieniony zostaje "harmonogram zadania" na SynchronizationContextTaskScheduler
. Zlecona praca wykonywana jest poprzez metodę Post z użyciem kontekstu w czasie tworzenia zadania.
W praktyce pozwala to na wykorzystanie wątku rysownika, bez potrzeby dostępu do SynchronizationContext
. Próba modyfikacji elementu UI w domyślnym kontekście, doprowadzi do zgłoszenia
wyjątku InvalidOperationException
.
private void button_Click(object sender, EventArgs e)
{
var task = Task<int>.Run(DoWork);
task.ContinueWith(t =>
{
label.Text = t.Result.ToString();
}, TaskScheduler.FromCurrentSynchronizationContext());
}
private int DoWork()
{
return new Random().Next(100);
}
Kod poniżej porównuje dostępny TaskScheduler
w zależności od kontekstu. Obie składnie posiadają identyczny SynchronizationContext
, ale różny TaskScheduler
.
var ctx = SynchronizationContext.Current;
Task.Run(() =>
{
ctx.Post(obj =>
{
var sc = SynchronizationContext.Current; // WindowsFormsSynchronizationContext
var ts = TaskScheduler.Current; // ThreadPoolTaskScheduler
}, null);
});
Task.Factory.StartNew(() =>
{
var sc = SynchronizationContext.Current; // WindowsFormsSynchronizationContext
var ts = TaskScheduler.Current; // SynchronizationContextTaskScheduler
}, CancellationToken.None, TaskCreationOptions.None, TaskScheduler.FromCurrentSynchronizationContext());
Podsumowując nasze dotychczasowe omówienie mechanizmów Task
oraz Task<T>
, przed nami jeszcze głębsze zagłębienie się w temat przetwarzania równoległego. Ta dyskusja stanowi wprowadzenie do bardziej zaawansowanych technik zarządzania asynchronicznością i wielowątkowością w .NET. Kluczowe jest zrozumienie podstaw, które omówiliśmy, aby móc skutecznie wykorzystać potencjał przetwarzania równoległego w swoich projektach. Zachęcam do dalszego eksplorowania i eksperymentowania z kodem, aby lepiej zrozumieć te koncepcje. W kolejnej notatce skupimy się na przetwarzaniu równoległym, rozszerzając naszą wiedzę i umiejętności. Do zobaczenia wkrótce!