czwartek, 07 kwietnia 2022

Zadania

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!

Troska Robert