HistoriaNr.2 W tym wpisie zobaczmy ewolucję kodu .NET, jeżeli chodzi o asynchroniczność. Dlaczego? Warto być świadomym tego, dlaczego async i await jest taki wygodny w użyciu, zwłaszcza gdy zobaczysz dawne rozwiązania.

W poprzednim wpisie omówiliśmy terminologię oraz zrobiliśmy krótki wstęp na temat tego, jak  wątek i procesor działa. 

Teraz pora na przygodę po kodzie .NET w C#.

Klasa Thread

Klasa Thread to najstarsze API asynchronicznego C#. Nie powinieneś korzystać z tego API, ponieważ jest przestarzałe. Jest tak stare, że nawet gdy zaczynałem programować w 2008 roku w C# na studiach to nie korzystałem z tej klasy. Już wtedy były lepsze rozwiązania.

static void Main(string[] args)
{
    Thread thread = new Thread(new ThreadStart(Do));
    thread.IsBackground = true;
    thread.Start();
    thread.Join();
}
               
public static void Do()
{}

Klasa Thread jednak może nauczyć Cię czegoś na temat wątków w .NET.

Po pierwsze w .NET są wątki Background i Foreground.

Wątki Foreground blokują zamknięcie aplikacji i z tego powodu pewien byśmy ich nie chcieli. Z drugiej strony masz pewność, że wątek zrobi swoje, nawet jeśli ktoś zamknie twoją aplikację.

Wątki Background natomiast zostaną usunięte wraz z zamknięciem aplikacji.

Czyli właściwość 

thread.IsBackground = true;

To ustawia.

Metoda "Start" uruchamia wątek, a metoda "Join" scala wątek z wątkiem głównym.

Warto jeszcze wspomnieć, że .NET ma swoje specjalne wątki "GC", "Finalizare". Jak pamiętasz z poprzedniego wpisu, możesz ustawić priorytet wątku.

Process p = Process.GetCurrentProcess();
p.PriorityClass = ProcessPriorityClass.High;
            
Thread t = Thread.CurrentThread;
t.Priority = ThreadPriority.Normal;

Czy istnieje jakiś przypadek, w którym ta klasa Thread byłaby jeszcze użyteczna? Jedyne co przychodzi mi do głowy to absurdalnie długie zadanie, które musi się wykonać w osobnym wątku. Task API np. znakomicie się sprawuje do krótki i średnio długich zadań.

Co można jeszcze powiedzieć o klasie Thread. Jak widzisz, musiała ona być od .NET 1.1, ponieważ możesz zauważyć, że w swoim konstruktorze przyjmuje ona delegatę. 

Tylko ta delegata nie jest generyczna tylko swoim własnym tworem. Jakbyś chciał stworzyć wątek, który wykona metodę z parametrem, to byś napisał taki kod.

static void Main(string[] args)
{
    Thread thread2 = new Thread(new ParameterizedThreadStart(Do2));
    thread2.IsBackground = true;
    thread2.Start("parametr");
    thread2.Join();
}
                
public static void Do2(object par)
{}

Największa wada tego rozwiązania polega na tym, że jeśli w tej operacji pojawi się wyjątek, to cała twoja aplikacja eksploduje. 

static void Main(string[] args)
{
    Thread thread = new Thread(new ThreadStart(DoEx));
    thread.IsBackground = true;
    thread.Start();
    thread.Join();
}
       
public static void DoEx()
{
    throw new Exception();
}

Nie możesz tego zatrzymać.

Skoro jesteśmy przy klasie Thread to pora powiedzieć coś o metodzie statycznej Sleep.

Thread.Sleep(4000);

Zapewne nie raz skorzystałeś z tej metody statycznej. Usypia ona obecny wątek na określoną liczbę milisekund. 

Jeśli jednak skorzystałeś z tej metody chociaż raz, to możesz zauważyć, że wątek niezostanie uśpiony dokładnie tyle, ile powiesz.

Dlaczego tak jest?

ContextSwitching czyli przełączanie wątków

Otóż procesor i jego rdzeń nie będzie grzecznie czekał, aż twój wątek się obudzi. W tym momencie procesor zajmie się innym wątkiem. 

Czyli jeśli skorzystasz z Thread.Sleep to musisz jeszcze brać pod uwagę czas procesora, który wyniknie z przełączeniu się pomiędzy wątkami.

Teraz gdy to wiesz. To, co się stanie, gdy uśpijmy wątek na 1 milisekundę. 

Thread.Sleep(1);

1 milisekunda to bardzo mały czas. Pytanie, czy ta mała jednostka czasu nawet będzie brana pod uwagę.

Thread.Sleep(1) wymusi na procesorze przełączenie wątku, który popracuje swój "kwant" czasu.

Dodatkowo istnieje jeszcze taki hak.

Thread.Sleep(0);

Zero tutaj reprezentuję liczbę specjalną. W sumie w tym wypadku procesor przełączy swój wątek, jeśli istnieje jakiś wątek w stanie "ready".

Klasa ThreadPool

Klasa ThreadPool reprezentuje pule wątków, czyli w pewnym sensie kolejkę wątków obsługiwanych przez .NET.

ThreadPool zarządza dwoma rodzajami wątków: 

  • Worker Threads: roboczy wątek
  • I/O completion port thread: wykonują one operację wejścia/wyjścia np. dysk

ThreadPool jest używany przez: WCF, ASP.NET, ASP.NET Core, TPL, PLinq, czyli w sumie cały .NET.

Nie możesz stworzyć swojej puli wątków. Ma to sens. Jak widzisz mechanizm wątków, jest bardzo skomplikowany i jeśli byś napisał źle aplikację, która np. tworzy kosmiczną liczbę wątków, to byś nie tylko uśmiercił swoją aplikację, ale także cały system, na którym się ona uruchomiła.

Dlaczego więc nie stworzyć domyślnego mechanizmu ThreadPool, który zrobi to za Ciebie.

ThreadPool posiada mechanizm trzymania dobrej liczby wątków. 

Na przykładzie aplikacji ASP.NET CORE wyobraź sobie, że każde zapytanie HTTP to tak naprawdę nowy wątek.

ThreadPool jak działa w ASP.NET

Klasa ThreadPool do każdego zapytania przypisuje wątek, a ten wykonuje odpowiednio kod.

Co możesz zrobić w klasie ThreadPool.

int workerThreadsMin; int IOCPThreadMin;

ThreadPool.GetMinThreads(out workerThreadsMin,
    out IOCPThreadMin);
        
int workerThreadsMax; int IOCPThreadMax;
        
ThreadPool.GetMaxThreads(out workerThreadsMax,
    out IOCPThreadMax);
        
ThreadPool.SetMaxThreads(1000, 32767);
ThreadPool.SetMinThreads(8, 8);

Możesz pobrać liczbę maksymalnych i minimalnych wątków dostępnych dla platformy .NET. 

Ta liczba jest zależna od platformy .NET. oraz tego, czy jest to wersja 64-bitowa, czy 32.

Dla .NET CORE 3.2 liczba maksymalna wynosi 32767

Teraz warto zaznaczyć, czym jest ta liczba minimalna. Minimum nie określa liczby, z którą każda aplikacja .NET startuje.

Minimum określa, jak szybko ma powstawać nowy wątek w twojej aplikacji. Gdy ta liczba jest przekroczona, to wtedy tworzy on nowy wątek z przerwami.

Minimum dla .NET CORE 3.2 wynosi 8. Warto o tym pamiętać, ponieważ masz żywy dowód na to, że twoja aplikacja może działać wolniej, jeśli potrzebuje ona więcej niż 8 wątków. 

Używając ThreadPool, możesz także dodawać asynchroniczne zadania.

Action a = (state) => { };

ThreadPool.
QueueUserWorkItem(a, "StanOk", true);

Oczywiście wygląda to bardzo nieporęcznie.

static void Main(string[] args)
{
    ThreadPool.QueueUserWorkItem(Do);
}

public static void Do(object? state)
{}

Dodatkowo tak jak wcześniej, jeśli tutaj pojawi się wyjątek, to cała twoja aplikacja umiera.

static void Main(string[] args)
{
    AppDomain.CurrentDomain.UnhandledException
        += OnUnhandleException;
    ThreadPool.QueueUserWorkItem(Do);
}

private static void OnUnhandleException(object sender, UnhandledExceptionEventArgs e)
{
    Console.WriteLine("Terminacja? : " + e.IsTerminating);
    Console.WriteLine("Przyczyna : " + e.ExceptionObject);
}

public static void Do(object? state)
{
    throw new Exception();
}

Możesz zapisać informację o wyjątku w taki sposób, ale gdy on wystąpi, to nie możesz już tego zatrzymać. Twoja aplikacja umiera.

IAsyncResult, czyli jak kiedyś robiło się operację asynchroniczne

Jeżeli kiedyś będziesz narzekał na Task API albo na async i await to spójrz na ten kod.

static void Main(string[] args)
{              
    Func method =
        (int a) => { return a; };

    IAsyncResult handle =
        method.BeginInvoke(100,
        new AsyncCallback(WhenDone),
        new object());
}

private static void WhenDone(IAsyncResult ar)
{
    var target = (Func)ar.AsyncState;
    int result = target.EndInvoke(ar);

    Console.WriteLine(result);
}

Ten kod nie tak stary, jak klasy Thread i ThreadPool. Gdy uruchomiłem ten kod, to dla .NET CORE 3.2 otrzymałem wyjątek: PlatformNotSupportedException.

Jak widzisz .NET CORE i zapewne .NET 5 nie pozwoli Ci nawet uruchomić taki kod. 

Jakby co nic nie straciłeś, ponieważ ten cały BeginInvoke i AsyncCallback wygląda jak kod spaghetti.

To właśnie był Asynchronous programming model (APM)

Event Asynchronous Pattern

Przed Task API istniał jeszcze model zdarzeniowy. Najlepiej go pokazać na klasie BackgroundWorker.

static void Main(string[] args)
{
    var worker = new BackgroundWorker();

    worker.DoWork += WorkerDoSomething;

    worker.ProgressChanged += Worker_ProgressChanged;
    worker.RunWorkerCompleted += Worker_RunWorkerCompleted;
    worker.RunWorkerAsync(worker);
}

private static void Worker_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e)
{}
private static void WorkerDoSomething(object sender, DoWorkEventArgs e)
{}
private static void Worker_ProgressChanged(object sender, ProgressChangedEventArgs e)
{}

Jak widzisz BackgroundWorker, przyjmuje 3 zdarzenia:

  • Worker_RunWorkerCompleted: Uruchomi się, gdy zadanie zostało wykonane.
  • WorkerDoSomething: Zdarzenie, które uruchomi się w innym wątku.
  • Worker_ProgressChanged: Zdarzenie, które informuje o postępie pracy 

Podobnie działa WebClient

static void Main(string[] args)
{
    WebClient client = new WebClient();

    client.DownloadStringCompleted += OnDownloadCompl;
    client.DownloadStringAsync(new Uri(@"http://cezarywalenciuk.pl"))
    Console.ReadKey();
}

private static void OnDownloadCompl(object sender, 
    DownloadStringCompletedEventArgs e)
{
    if (e.Cancelled)
        Console.WriteLine("Anulowane");
    else if (e.Error != null)
        Console.WriteLine(e.Error.Message);
    else
        Console.WriteLine(e.Result);
}

Co prawda kod nadal może być pokręcony, jeśli z jakiegoś powodu masz 5 lub więcej BackgroundWorkerów. To rozwiązanie na razie wygląda na najbardziej ludzkie i czytelne.

Niestety tak jak w innym przykładach nadal występuje problem z wyjątkiem.

Task Api

Czym jest Task i Task<T> ?

Jest to obietnica, że operacja się zakończy. Gdy korzystasz z wersji Task, to tworzysz zadanie, które ma nie zwrócić NIC.

Gdy tworzysz generyczny Task<T>, to wtedy musisz zwrócić zmienną o typie T.

Istniał on przed async i await. To komplikuje trochę sprawę, ponieważ jeśli zaczynasz programować i zaczniesz mieszać stare Task API, z nowym stworzonym pod async i await to zaczną się problemy.

O tym jednak później. Najpierw sprawdźmy, czy Task na pewno jest wątkiem background.

static void Main()
{
    Task
        .Factory
        .StartNew(
            () => Console.WriteLine
            (Thread.CurrentThread.IsBackground))
        .Wait();
}

Co pojawi się w konsoli?

Task czy to wątek background czy foreground

Otrzymasz prawdę.

Task i Task<T> ma swoje stany:

  • Created/WaitingForActivation/WaitingToRun: stworzony, ale nie zaplanowany do uruchomienia
  • Running: Działający
  • WaitingForChildrenToComplete: Czeka, aż pod zadania zostaną zakończone
  • RanToCompletion: Zakończony z sukcesem
  • Faulted: Wyskoczył wyjątek
  • Canceled: Został on odwołany

Task i Task<T> ma właściwości, które upraszczają te stany do 3:

  • IsCanceled
  • IsFaulted
  • IsCompleted: Prawda dla: RanToCompletion, Faulted, Canceled

Uważałbym na ostatnią właściwość, gdyż twierdzenie "zakończenia" jest poprawne i dla zadania, w którym wystąpił wyjątek i dla zadania które zostało odwołane.

Task zawiera mnóstwo metod. Ja omówię tylko niektóre z nich.

Najpierw jednak musimy omówić największe pomyłki związane z używaniem Tasków. Niektóre są oczywiste, niektóre nie.

static void Main(string[] args)
{
    var d = DateTime.Now;
    Console.WriteLine(d.Minute + ":" + d.Second);
    Task.Delay(7000);
    var d2 = DateTime.Now;
    Console.WriteLine(d2.Minute + ":" + d2.Second);
}

Patrząc na ten kod, zapewne zakładasz, że Task.Delay się uruchomił na 7 sekund. Tylko co tak naprawdę robi metoda Task.Delay w tym wypadku?

Przypadkiem to zadanie nie uruchomiło się w innym wątku.

W takim razie wątek główny powinien przejść dalej bez żadnego zatrzymania.

Pytanie na temat Task.Delay

W tym przypadku otrzymasz ten sam czas.

Co więcej, Task.Delay() z samego siebie nie uruchomił się.

Status Taska Task.Delay

To zadanie ma status "WaitingForAcrivation".

Gdybyś chciał zatrzymać wątek główny, to byś musiał skorzystać z metody "Wait", Dodatkowo metoda "Wait" uruchomi za Ciebie Taska, skoro nie zrobiliśmy tego wcześniej.

static void Main(string[] args)
{
    var d = DateTime.Now;
    Console.WriteLine(d.Minute + ":" + d.Second);
    Task t = Task.Delay(7000);
    t.Wait();
    var d2 = DateTime.Now;
    Console.WriteLine(d2.Minute + ":" + d2.Second);
}

Korzystając z async i await aktywowałbyś zadanie tak.

static async Task Main(string[] args)
{
    var d = DateTime.Now;
    Console.WriteLine(d.Minute + ":" + d.Second);
    await Task.Delay(7000);
    var d2 = DateTime.Now;
    Console.WriteLine(d2.Minute + ":" + d2.Second);
}

Jednak na razie omówimy, jak działa i działało Task API.

Jak widzisz, istnieje wiele problemów ze zrozumienie, kiedy zadanie jest już uruchomione, a kiedy ono czeka na uruchomienie. Jeżeli wcześnie korzystałeś z async i await to może Ci się wydawać, że domyślnie Taski są ciepłe i od razu są uruchomione, ale tak nie jest.

static void Main()
{
    var t = What();
    Console.WriteLine(t.Status);
}

static Task What()
{
    return new Task(
        () => Console.WriteLine("1"));
}

Jak myślisz, czy ten kod uruchomi od razu zadanie, czy nie. Jaki jest tutaj status.

Stan Taska

Konstruktor klasy Task tworzy tylko zadanie. Jeszcze nie jest ono uruchomione.

Uruchomić zadanie możesz na 3 sposoby. Oto pierwsze i najstarsza metoda, z której nie chcesz już korzystać.

static void Main(string[] args)
{
    Task task = new Task(Do);
    task.Start();
}

private static void Do(){}

Jest to metoda Start. Niestety nie jest ona kontatybilna z "async i await" i dlatego najlepiej jest o niej zapomnieć.

Istnieje bardziej zaawansowana metoda na uruchomienie Taska. Ona też nie jest kontatybilna z async i await, ale ma dużo zaawansowanych opcji, które mogą się przydać.

static void Main(string[] args)
{
    Task task = Task.Factory.StartNew(Do);
}

private static void Do(){}

Ostatnia i trzecia metoda Run(), która może być używana z async i await.

static void Main(string[] args)
{
    Task task = Task.Run(Do);
}

private static void Do(){}

Teraz gdy wiemy jak uruchomić Taska, to pozostało jedno pytanie, jak kiedyś programiści synchronizowali rezultaty tych poleceń do wątku głównego.

static void Main(string[] args)
{
    Task task = Task.Run(Do);
    task.Wait();

    Task<int> task2 = Task.Run(Do2);
    var r = task2.Result;
}

private static void Do() { }
private static int Do2() { return 1; }

Korzystali oni z metody Wait() dla Tasków bez zwrotnych. Dla Tasków które miały coś zwracać jest właściwość Result. 

Tak było kiedyś. Teraz, gdybyś chciał zrobić coś podobnego, to byś także mógł skorzystać z metody GetAwaiter().GetResult()

static void Main(string[] args)
{
    Task task = Task.Run(Do);
    task.GetAwaiter().GetResult();

    Task<int> task2 = Task.Run(Do2);
    var r = task2.GetAwaiter().GetResult(); ;
}

private static void Do() { }
private static int Do2() { return 1; }

To w sumie robi słowo kluczowe "await".

Różnica polegałaby na tym, że korzystając z GetAwaiter, otrzymasz prawdziwy wyjątek. Natomiast korzystając z Result lub metody Wait ten wyjątek zostanie opakowany w wyjątek AggregateException.

Różnica pomiędzy Task.Run i Task.Factory.StartNew

Wcześniej wspomniałem, że Task.Factory.StartNew, mimo iż jest legacy, to ma wciąż pewne zaawansowane opcje.

Przykładowo możesz w nim ustawić opcję: LongRunning. Chociaż Task-i zostały stworzone z myślą o średnich i krótkich zadaniach, to ta opcja być może Tobie pomoże, gdy będziesz chciał wykonać DŁUGIE zadanie w osobnym wątku. Jednak pewne źródła polecają skorzystać z klasy Thread, gdy masz długie zadania.

Ciekawszą opcją jest "AttachedToParent". Zmienia on zachowanie działania pod tasków wewnątrz tasków.

var parent = Task.Factory.StartNew(() =>
{
    Console.WriteLine("Parent Start");

    var child = Task.Factory.StartNew(() =>
    {
        Console.WriteLine("ChildTask Start");
        Thread.Sleep(4000);
        Console.WriteLine("ChildTask End");

    }, TaskCreationOptions.AttachedToParent);

    Console.WriteLine("Parent END");
});

parent.Wait();
Console.WriteLine("DEMO END");

W tym przykładzie zobaczysz, że zadanie dziecko zostało podpięte pod rodzica.

Co pojawi się w konsoli?

Przykład AttachedToParent

Najpierw wykona się zadanie rodzica, później wykona się zadanie dziecka. A później całe demo się zakończy.

Gdy ustawisz inną opcję np. DenyChildAttach.

var parent = Task.Factory.StartNew(() =>
{
    Console.WriteLine("Parent Start");

    var child = Task.Factory.StartNew(() =>
    {
        Console.WriteLine("ChildTask Start");
        Thread.Sleep(4000);
        Console.WriteLine("ChildTask End");

    }, TaskCreationOptions.DenyChildAttach);

    Console.WriteLine("Parent END");
});

parent.Wait();
Console.WriteLine("DEMO END");

To wtedy zadanie dziecka i rodzica będą traktowane oddzielnie. 

Przykład DenyChildAttach

Są to więc oddzielne Taski. W takim razie kolejność będzie inna.

Najpierw wykona się polecenie rodzica, później zacznie się niby Task dziecka, ale ponieważ on trwa 4 sekund, to nie zdąży się on wykonać przed zakończeniem całej aplikacji.

Oto jedyne powody, dla których chciałbyś skorzystać z Task.Factory.StartNew

Co się stanie, gdy pojawi się wyjątek w Tasku

W poprzednich przykładach mocno podkreślaliśmy, że wyjątki rozwalają całą aplikację, jeśli ich nie przechwycisz. 

Z Taskami jest inaczej.

Jedyne, o czym musisz pamiętać to fakt, że Task pakują wyjątki do typu AggregateException.

Ten mechanizm pakowania został stworzony, dlatego że istnieje szansa, że wewnątrz jednego Taska są inne Taski i w nich mogą wystąpić wyjątki (w liczbie mnogiej)

Dodatkowo jakbyś chciał to od .NET 4.0 może w taki sposób wysłać zbiór wyjątków.

List<Exception> exceptions = new List<Exception>();

exceptions.Add(new TimeoutException("It timed out", new ArgumentException("ID missing")));

// all done, now create the AggregateException and throw it
AggregateException aggEx = new AggregateException(exceptions);
throw aggEx;

Ważne jest jednak to, że ten kod nie wysadzi aplikacji.

Task<int> task = Task.Run(() =>
{
    throw new AccessViolationException();
    return -1;
});

Console.ReadKey();

try
{
    _ = task.Result;
}
catch (AggregateException ex)
{
    if (ex.InnerExceptions is
        AccessViolationException)
    {
        Console.WriteLine(ex.Message)
    }

}

Tak, nawet gdybyś nie przechwycił  Task.Result w Try i Catch to aplikacja i tak nie wybuchnie.

GC ogarnie za Ciebie wyjątki, które nie przechwyciłeś. Korzystając z "TaskScheduler.UnobservedTaskException" jesteś w stanie zobaczyć wszystkie wyjątki, jakie przegapiłeś w formie zdarzenia.

static void Main(string[] args)
{
    TaskScheduler.UnobservedTaskException += TaskScheduler_UnobservedTaskException;

    var task = Task.Run(() => { throw new Exception(); });

}

private static void TaskScheduler_UnobservedTaskException(object sender, 
    UnobservedTaskExceptionEventArgs e)
{
    Console.WriteLine("Przegapiłeś :" + e.Exception);
    e.SetObserved();
}

ContinueWith, czyli po co stworzyli async i await

Przed Async i Await musiał istnieć sposób uruchamiania zadań jedno po drugim.

Jak to się kiedyś robiło bez blokowania głównego wątku właściwością "Result" lub metodą "Wait()".

Do tego służyła metoda ContinueWith.

Oto przykład uruchomienie jednego Taska, gdy poprzedni się zakończył.

Task<int> t = Task.Run(() =>
{
        return 44;
});
                
Task<int> t2 = t.ContinueWith(a =>
{
        return t.Result + 66;
});
                
Console.WriteLine(t2.Result);
Console.Read();

Korzystając z metody, ContinueWith jesteś nawet w stanie określić czy dany kod powinien zostać wykonany w zależność, od stanu poprzedniego Taska.

Możesz stworzyć jedno wyrażenie Lambda dla Taska, gdy zakończył się on z sukcesem. 

Task<int>t = Task.Run(() =>
{
    return 44;
});

Task<int> t2 = t.ContinueWith(a =>
{
    return t.Result + 66;
},
TaskContinuationOptions.OnlyOnRanToCompletion);

Task t3 = t.ContinueWith(a =>
{
    Console.WriteLine("Error");

}, TaskContinuationOptions.OnlyOnFaulted);

A potem możesz stworzyć drugie wyrażenie lambda dla Taska, gdy nie wykonał się on poprawnie.

Oczywiście brudzi to trochę kod. Pomyśl, ile taki wrażeń ContinueWith miałbyś napisać, gdybyś miał skomplikowaną logikę kilkunastu Tasków.

Moim zdaniem to jest jedna z przyczyn, dlaczego Async i Await powstał.

CancellationTokenSource

Każdy Task może zostać wycofany z działania. Robisz to poprzez token anulujący. 

Warto określić jego zachowanie na "ThrowIfCancellationRequested", gdyż nie chciałbyś cały czas sprawdzać, w jaki stanie jest ten token. Lepiej z góry powiedzieć, że anulowanie zadania liczby się z wyjątkiem.

var tokenSource2 = new CancellationTokenSource();
CancellationToken ct = tokenSource2.Token;

var t = Task.Run(() =>
{
    ct.ThrowIfCancellationRequested();
});

try
{
    t.Wait();
}
catch (OperationCanceledException e)
{
    Console.WriteLine($"{nameof(OperationCanceledException)} thrown with message: {e.Message}");

}
finally
{
    tokenSource2.Dispose();
}

Warto także zaznaczyć, że możesz anulować zadanie, które jeszcze nawet się nie uruchomiło.

W takim wypadku Task nawet nie trafia do kolejki planowania, więc kto wie może dla wydajności, chciałbyś mieć taką opcję. 

var tokenSource2 = new CancellationTokenSource();
CancellationToken ct = tokenSource2.Token;

tokenSource2.Cancel();

var t = Task.Run(() =>
{
    
}, ct);

try
{
    t.Wait();
}
catch (AggregateException e)
{
    Console.WriteLine(e.InnerException);
}
finally
{
    tokenSource2.Dispose();
}

WaitAll i WaitAny

Task API oferuje także metody obsługujące zbiór Tasków. 

Task t1 = Task.Delay(2000);
Task t2 = Task.Delay(4000);
Task t3 = Task.Delay(6000);

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

Metoda WaitAll wymusi poczekanie na wszystkie Taski i dopiero wtedy program ruszy dalej.

Metoda WaitAny poczeka na najszybsze zadanie i wtedy program ruszy dalej.

Ignorując przy tym rezultaty...

-- wszystkich innych tasków.

Unwrap

Metoda Unwrap powstała, aby ułatwić wypakowywanie tasków w tasków dla async i await.

Jak to wygląda? Spójrz na ten kod.

Task<Task<Task<int>>> t =
    Task.Factory.StartNew
    (() =>
    {
        return Task.Factory.StartNew(
        () =>
        Task.Run(() => { return 1; }));
    
    });

var result = t.Result.Result.Result;

Zamiast robić Result.Result.Result.

Task<Task<Task<int>>> t =
Task.Factory.StartNew
(() =>
{
    return Task.Factory.StartNew(
    () =>
    Task.Run(() => { return 1; }));

});
        
var unwrap = t.Unwrap();
var unwrap2 = unwrap.Unwrap();
var result = unwrap2.Result;

To możesz zrobić Unwrap() i Unwrap().

Jeżeli w trakcie wypakowania wystąpił wyjątek, to go otrzymasz w swoim oryginale. Nie będzie on zapakowany do AgreggateException.

Async i Await

Tyle, jeżeli chodzi o Task API. Jeżeli chodzi o użycie async i await to wygląda to tak. 

Async i await istnieje w C# od 2012 roku. Taka ciekawostka, ale mechanizm async i await istnieje także w:

  • TypeScript
  • Python
  • JavaScript
  • Rust

Ciekawe, że o ile .NET jako framework Microsoftu wzbudza czasem mieszane uczucia to async i await jest czymś, co przeszło na inne języki programowania.

public int GiveNumber()
{
    return -1;
}

public async Task<int> GiveNumberAsync()
{
    return -1;
}

Jak widzisz metoda, Asynchroniczna zwraca Task<int> i jest oznaczona słowem kluczowym async.

Jeżeli chce taką metodę asynchroniczną wywołać, to wtedy korzystasz ze słowa await.

public async Task<int> GiveMeRandomNumberAsync()
{
    var i = await GiveAnotherNumberAsync();
    return await GiveNumberAsync();
}
    
public async Task<int> GiveNumberAsync()
{
    return 1;
}
    
public async Task<int> GiveAnotherNumberAsync()
{
    return 2;
}

Kiedyś dawno temu pisałem proste przykłady async i await. Zawsze był problem z prostymi aplikacjami konsolowymi, ponieważ one nie rozumiał za bardzo słowa async w metodzie głównej Main.

Na szczęście od C# 7.1 słowo kluczowe async nie gryzie z metodą Main w konsoli, więc możesz pisać przykłady demo bez żadnych problemów.

Visual Studio podpowiada aby korzystać z async Task

Gdy chciałem skorzystać z metody File.ReadAllTextAsync jak widzisz, samo Visual Studio zasugerowało mi, aby moja główna metoda Main w konsoli zwracała Task.

Dlaczego async Task?

A nie async void.

Async void powinien zostać zlikwidowany z całego .NET . Jednakże istnieje sporo starych API zdarzeniowych, które właśnie mają async void.

Jaki jest problem z async void. 

Async void wywala proces aplikacji, gdy pojawia się wyjątek. Dlatego zawsze chciałbyś korzystać z async Task.

W .NET od przedstawienia "async i await" powstało mnóstwo metod z końcówką Async. Jak się domyślasz, zwracają one Taski.

static async Task Main()
{
    var numbers = 
        await File.ReadAllTextAsync(@"D:\numbers.txt");

    Task<string> t = File.ReadAllTextAsync(@"D:\numbers2.txt");
    await t;

}

Taki kod więc jest poprawny.

Jak to nie ma wątku?

Pamiętam jak w 2012 roku, słuchałem prezentacji na temat WinRT - aplikacji w sklepie Microsoft. Tam był przestawiony pomysł, aby cały framework korzystał z Tasków i słów kluczowych async i await.

Pojawiła mi się jednak pewna myśl w głowie. Zaraz czy to będzie wydajne. Zakładając, że każda operacja async i await tworzy nowy wątek, to tworzenie wszędzie metod asynchronicznych wydaje się głupim pomysłem.

Do tej kwestii zaraz wrócimy. Chce zadać ci jeszcze większe pytanie. Czy każda metoda async tworzy nowy wątek?

Możemy to sprawdzić takim kodem.

static async Task Main()
{
    WriteThread("Begin Main");
    var a = await M1Async();
    WriteThread("Middle Main");
    var b = await M1Async();
    Console.WriteLine(a + b);
    WriteThread("End Main");
    Console.ReadKey();
}

public static async Task<int> M1Async()
{
    WriteThread("Begin M1Async");
    var i = await M2Async();
    WriteThread("End M1Async");
    return i + 1;
}

public static async Task<int> M2Async()
{
    WriteThread("At M2Async");
    return -1;
}

public static void WriteThread(string helpfultoken)
{
    Console.WriteLine(helpfultoken
        + " : " + Thread.CurrentThread.ManagedThreadId);
}

Możesz się zdziwić.

Mimo iż uruchamiam tutaj dwie metody asynchroniczne, to ciągle operuje tutaj na JEDNYM wątku.

Tak konsola powinna zwrócić ID wątku głównego, czyli 1.

Polecam lekturę, aby wyjaśnić jak, to jest możliwe.

https://stackoverflow.com/questions/37419572/if-async-await-doesnt-create-any-additional-threads-then-how-does-it-make-appl

https://blog.stephencleary.com/2013/11/there-is-no-thread.html

Cytat z artykułu:

"The idea that “there must be a thread somewhere processing the asynchronous operation” is not the truth."

Jak się okazuje system operacyjny i procesor umie określić czy dane asynchroniczne polecenie jest trywialne, czy nie. Jeżeli jest trywialne, to nowy wątek nie zostanie stworzony.

Oczywiście, to bardzo uproszczone wyjaśnienie. Spójrz na ten kod. Tym razem jedna metoda asynchroniczna będzie działać przez 4 sekundy.

static async Task Main()
{
    WriteThread("Begin Main");
    var a = await M1Async();
    WriteThread("Middle Main");
    var b = await M1Async();
    Console.WriteLine(a + b);
    WriteThread("End Main");
    Console.ReadKey();
}

public static async Task<int> M1Async()
{
    WriteThread("Begin M1Async");
    await Task.Delay(2000);
    WriteThread("End M1Async");
    return 0;
}

public static void WriteThread(string helpfultoken)
{
    Console.WriteLine(helpfultoken
        + " : " + Thread.CurrentThread.ManagedThreadId);
}

Tym razem w konsoli zobaczysz, że nowy wątek został utworzony. 

Rezultat działania aplikacji async i await

Jak widzisz nawet wątek, od którego zaczęliśmy, nie jest tym samym wątkiem, na którym skończyliśmy.

Czy jednak wszędzie robić metody asynchroniczne z async i await?

Odpowiedź brzmi "NIE". Async i Await zawsze tworzy maszynę stanów, więc warto korzystać z tych słów kluczowych, gdy są naprawdę potrzebne.

Przy okazji tej maszyny stanów - Task i await może być wykorzystany do cachowania rezultatów. Jeżeli spróbujesz z-awaitować zadanie, które już się wykonało, to otrzymasz poprzedni rezultat.

static async Task Main()
{
    Task<string> t = 
    File.ReadAllTextAsync(@"D:\numbers2.txt");

    await t;
    await t;
    await t;
    await t;
}

Rzeczy, o których warto wiedzieć

Ten wpis powoli dobiega do końca. Ja widzisz historia asynchronicznego ".NET-a" jest ciekawa. 

Myślisz, że to bardzo dużo wiedzy, ale prawda jest taka, że dopiero zaczęliśmy omawiać współczesne techniki "async i await".

O czym więc jeszcze warto opowiedzieć.

Po pierwsze istnieje pewna technika, która nazywa się async eliding

static Task<int> M1()
{
    return Task.FromResult(1);
}
    
static async Task<int> M2()
{
    return await M1();
}

Jak napisałem wcześniej słowo async i await tworzy maszynę stanów. Dlaczego więc nie wysłać tego Taska wyżej? Być może na tym etapie nie musi wykonać się "await".

Wydaje się to bardzo proste, ale warto być ostrożny z wysłaniem Tasków wyżej.

public async Task<string> GetWithKeywordsAsync(string url)
{
    using (var client = new HttpClient())
        return await client.GetStringAsync(url);
}
    
public Task<string> GetElidingKeywordsAsync(string url)
{
    using (var client = new HttpClient())
        return client.GetStringAsync(url);
}

W tym przykładzie metoda "GetElidingKeywordsAsync" jest nieprawidłowa. 

Dlaczego? Otóż using wykona operację dispose na HttpClient, czyli nasz HttpClient już na tym etapie nie istnieje. Natomiast tutaj próbujemy przekazać nierozpoczętego Taska tylko już zamkniętym HttpClient.

Tutaj więc technika async eliding się nie przyda.

A jak jest z wyjątkami. Na pewno nie możesz ich zwrócić w taki sposób.

Jak nie wyrzucać wyjątków w Task

Taki kod jest poprawny i łącząc Task API i "async i await" możesz sprawdzić, czy wyjątek wystąpił.

static async Task Main()
{
    var task = M2();
                
    if (task.IsFaulted)
        Console.WriteLine("Error");
}
                
static Task<int> M1()
{
    throw new Exception();
}
                
static async Task<int> M2()
{
    return await M1();
}

Ten kod tylko uświadamia nam jedno. Jeśli coś pójdzie nie tak, to w kodzie asynchroniczny zawsze możesz wyrzucić wyjątek.  Nigdy przenigdy nie wyrzucaj NULL jako odpowiedź na nieprawidłowe zachowanie.

To bardzo komplikuje sprawę z instancją klasy Task.

Podobnie jak z Task Api możesz przekazywać token anulujący,

static async Task Main()
{
    CancellationTokenSource tokenSource = new CancellationTokenSource();
    CancellationToken token = tokenSource.Token;
    tokenSource.Cancel();

    var task = await ReadFile(token);

}

static async Task<string> ReadFile(CancellationToken cancellation = default)
{
    var t = await File.ReadAllTextAsync(@"D:\numbers2.txt", token);
    return t;
}

Pamiętaj, tylko aby ten token przekazywać dalej. W końcu token anulujący powinien także odwołać pod Taski.

***

Warto także poczytać o ValueTask. Jest to chudsza wersja Task<T> i czuje, że będzie on bardzo wykorzystywany w .NET 5.0.

Oto jego przykład użycia i co ciekawe w swoim konstruktorze można mu przesłać *stałą* wartość, gdy wiemy, że operacja nie wymaga asynchroniczności dla pewnego przypadku.

static ValueTask<double> Do(int divideArg)
{
    if (divideArg == 0)
        return new ValueTask<double>(0);
    
    return new ValueTask<double>
        (Divide(divideArg));
}
    
static Task<double> Divide(int divideArg)
{
        double a = 1 / divideArg;
        return Task.FromResult(a);
}

Na koniec zapewne warto wspomnieć o tym, jakie są problemy, gdy próbujesz mieszać Task API z "async i await". Zazwyczaj tego nie chcesz robić.

O to pokaz katastrofalnego błędu wynikającego z tego, że Task domyślnie nie uruchamiają się 

static async Task Main()
{
    await What();
}

static Task What()
{
    return new Task(
        () => Console.WriteLine("1"));

}

Normalnie nie widać tutaj nic szczególnego, ale ten kod zablokuje całą aplikację. Await będzie próbował uruchomić zadanie, ale to zadanie nigdy się nie skończy.

Skoro zadanie się nigdy nie skończy w wyniku błędu API Task, to aplikacja na zawsze się zawiesi.

Czekając na coś, co nigdy nie nastąpi.

Jak widzisz, istnieje wiele tematów, do omówienia przez nas. Do zobaczenia w następnym wpisie z tego cyklu.