Programowanie asynchroniczne #
Programowanie asynchroniczne to model programowania, który pozwala na wykonywanie długotrwałych operacji bez blokowania głównego wątku aplikacji. Jest to ważne dla zapewnienia responsywności interfejsu użytkownika oraz skalowalności aplikacji serwerowych.
W nowoczesnym C# programowanie asynchroniczne jest realizowane głównie za pomocą słów kluczowych async i await. Współpracują one z typami Task i Task<TResult>, reprezentującymi asynchroniczne zadania. Model ten pozwala pisać kod asynchroniczny, który w swojej strukturze i czytelności bardzo przypomina kod synchroniczny.
Oczekiwanie - await
#
Słowo kluczowe await jest cukierkiem składniowym, który znacznie upraszcza pracę z operacjami asynchronicznymi. Kompilator tłumaczy ten zapis na znacznie bardziej złożoną strukturę opartą na maszynie stanów.
var result = await expression;
statement(s);Wyrażenie (expression) jest najczęściej typu
TasklubTask<TResult>. Jednakże każdy obiekt z metodą GetAwaiter zwracający awaiter zadowoli kompilator. Patrz: await-anything.
Transformacja ta obejmuje kilka kroków:
GetAwaiter(): Z wyrażenia (najczęściej z Taska) pobierany jest obiektawaiter, który zarządza procesem oczekiwania.IsCompleted: Wykonywana jest optymalizacja – jeśli operacja jest już zakończona, reszta kodu wykonuje się synchronicznie, bez przełączania wątków.- Przechwycenie kontekstu: Jeśli operacja nie jest zakończona,
awaitprzechwytuje bieżącySynchronizationContext. Jest to kluczowe w aplikacjach UI, aby móc wrócić na główny wątek. OnCompleted: Rejestrowana jest “kontynuacja” – czyli reszta metody. Ten kod zostanie wywołany w przyszłości, gdy zadanie się zakończy.- Powrót do kontekstu: Wewnątrz kontynuacji sprawdzany jest przechwycony kontekst. Jeśli istnieje (np. w aplikacji okienkowej), reszta kodu jest wywoływana (
Post) do wykonania w miejscu wskazywanym przez kontekst synchronizacji. W przeciwnym razie kod jest wykonywany na wątku z puli. GetResult(): Na samym końcu wywoływana jest metodaGetResult(), która zwraca wynik operacji lub rzuca wyjątek, jeśli zadanie zakończyło się błędem.
var awaiter = expression.GetAwaiter();
if (!awaiter.IsCompleted)
{
var context = SynchronizationContext.Current;
awaiter.OnCompleted(() =>
{
if (context != null)
{
context.Post(_ =>
{
var result = awaiter.GetResult();
statement(s);
}, null);
}
else
{
var result = awaiter.GetResult();
statement(s);
}
});
}
else
{
var result = awaiter.GetResult();
statement(s);
}Zachowanie domyślnego przechwytywania kontekstu przez
awaitmożna wyłączyć, używającawait expression.ConfigureAwait(false).
Przykład ilustrujący przełączanie wątków:
Metody asynchroniczne #
Metody asynchroniczne w C# są realizowane jako forma korutyn. Metoda asynchroniczna w C# to metoda, która jest oznaczona słowem kluczowym async. Oznaczenie metody jako async ma dwa główne cele:
- Umożliwia użycie operatora
awaitwewnątrz tej metody do oczekiwania na zakończenie operacji asynchronicznych (np. Tasków). - Instruuje kompilator, aby przekształcił metodę w maszynę stanów, która potrafi zarządzać zawieszaniem i wznawianiem swojego działania.
- Słowo
asyncsamo w sobie nie tworzy nowego wątku. Samo oznaczenie metody jakoasyncnie sprawia, że wykonuje się ona w tle. Metoda rozpoczyna swoje działanie synchronicznie na bieżącym wątku. Dopiero napotkanieawaitna operacji, która jeszcze się nie zakończyła, powoduje zawieszenie metody i zwolnienie wątku. - Typy zwracane: Metoda
asyncmusi zwracać jeden z trzech typów:Task: Dla operacji asynchronicznych, które nie zwracają wartości.Task<TResult>: Dla operacji, które po zakończeniu zwracają wartość typu TResult.void: Zalecane tylko dla obsługi zdarzeń (np.async void Button_Click(...)). Użycieasync voidw innych miejscach jest złą praktyką, ponieważ utrudnia obsługę wyjątków i śledzenie zakończenia operacji.
Poniższy przykład przedstawia synchroniczne wykonanie operacji. Metoda GetPrimesCount() używa Thread.Sleep(1000), czyli wątek, który ją wywołał, zostaje zablokowany na jedną sekundę. Jeśli byłby to wątek UI, aplikacja przestałaby odpowiadać na ten czas.
void PrintPrimesCount()
{
int primes = GetPrimesCount();
Console.WriteLine($"Primes: {primes}");
}
int GetPrimesCount()
{
Thread.Sleep(1000);
return 42;
}Poniżej ten sam cel osiągnięty w sposób asynchroniczny:
async Task PrintPrimesCountAsync()
{
Task<int> primesTask = GetPrimesCountAsync();
Console.WriteLine($"Primes: {await primesTask}");
}
async Task<int> GetPrimesCountAsync()
{
await Task.Delay(1000);
return 42;
}Wyrażenia lambda również mogą być asynchroniczne. Obowiązują w nich te same zasady, co dla zwykłych metod asynchronicznych.
C# wspiera również asynchroniczne sekwencje poprzez interfejs
IAsyncEnumerablei konstrukcjęawait foreach. Chociaż samyield returnw bloku iteratora jest zawsze synchroniczny, to cały iterator może pobierać dane asynchronicznie, zawieszając swoje działanie między kolejnymi elementami.
Przykład aplikacji okienkowej z asynchronicznym wywołaniem:
Równoległość #
Programowanie asynchroniczne może być wykorzystane żeby osiągnąć równoległe wykonanie kodu.
class Program
{
private static async Task Main()
{
Task<int> task1 = CountPrimesAsync(0, 1000);
Task<int> task2 = CountPrimesAsync(1000, 2000);
Console.WriteLine($"Primes(0-1000): {await task1}");
Console.WriteLine($"Primes(1000-2000): {await task2}");
}
private static async Task<int> CountPrimesAsync(int start, int end)
{
return await Task.Run(() =>
{
int count = 0;
for (int i = start; i < end; i++)
{
if (IsPrime(i))
{
count++;
}
}
return count;
});
}
static bool IsPrime(int number)
{
if (number < 2)
return false;
for (int i = 2; i <= Math.Sqrt(number); i++)
{
if (number % i == 0)
return false;
}
return true;
}
}Kod źródłowy:
Czekanie na wiele zadań #
Task.WhenAll
#
Task.WhenAll tworzy zadanie, które zakończy się dopiero wtedy, gdy wszystkie zadania z podanej kolekcji zostaną ukończone.
Task<int> task1 = CountPrimesAsync(1, 100);
Task<int> task2 = CountPrimesAsync(101, 200);
int[] primesCounts = await Task.WhenAll(task1, task2);- Jeśli
Task.WhenAlldostaje kolekcjęTask, zwracaTask. - Jeśli dostaje kolekcję
Task<TResult>, zwracaTask<TResult[]>, czyli zadanie, którego wynikiem jest tablica wyników ze wszystkich ukończonych zadań (w tej samej kolejności, w jakiej były zadania wejściowe).
Task.WhenAny
#
Task.WhenAny tworzy zadanie, które zakończy się, gdy tylko którekolwiek zadanie z podanej kolekcji zostanie ukończone.
Task<int> task1 = CountPrimesAsync(0, 100);
Task<int> task2 = CountPrimesAsync(100, 200);
Task<int> task = await Task.WhenAny(task1, task2);
Console.WriteLine($"Primes: {await task}");Task.WhenAny zwraca Task<Task> (lub Task<Task<TResult>>).
- Zewnętrzny
Taskkończy się, gdy dowolne zadanie z kolekcji się zakończy. - Wynikiem zewnętrznego
Taska jest ten wewnętrznyTask,który właśnie się zakończył. Trzeba go odpakować, aby dostać się do jego wyniku.
Anulowanie zadań #
Do anulowania zadań w służy mechanizm oparty na dwóch powiązanych ze sobą typach: CancellationTokenSource i CancellationToken.
CancellationTokenSource– to obiekt, który tworzy tokenyCancellationTokeni sygnalizuje anulowanie.CancellationToken– to struktura przekazywana do zadania. Zadanie używa go do sprawdzania, czy zażądano anulowania.
Jak to działa?
- Tworzysz instancję
CancellationTokenSource. Konstruktor przyjmuje opcjonalny parametrint timeout, jeżeli obecny, to anulowanie zostanie automatycznie zasygnalizowane po określonym czasie. - Z tego źródła pobierasz
CancellationTokenza pomocą właściwościToken. - Przekazujesz ten token do zadania, które chcesz móc anulować.
- Wewnątrz zadania musisz okresowo sprawdzać stan tokenu. Najprostszym sposobem jest wywołanie metody
token.ThrowIfCancellationRequested(). Rzuca ona wyjątekOperationCanceledException, jeśli anulowanie zostało zasygnalizowane. Alternatywnie można sprawdzić stan tokenu właściwościątoken.IsCancellationRequested. - Aby zainicjować anulowanie, wywołujesz metodę
Cancel()na obiekcieCancellationTokenSource. - Gdy zadanie jest anulowane, oczekiwanie na nie (np. przez
Wait()lubawait) zakończy się wyjątkiemOperationCanceledException.
class Program
{
private static async Task Main()
{
var cancellationSource = new CancellationTokenSource(5000);
try
{
List<int> primes = await GetPrimesAsync(2, cancellationSource.Token);
Console.WriteLine($"Number of primes: {primes.Count}");
Console.WriteLine($"Last prime: {primes[^1]}");
}
catch (OperationCanceledException)
{
Console.WriteLine("Canceled");
}
}
static async Task<List<int>> GetPrimesAsync(int start, CancellationToken token)
{
return await Task.Run(() =>
{
List<int> primes = [];
for (int i = start; i < int.MaxValue; i++)
{
// if (token.IsCancellationRequested) break;
token.ThrowIfCancellationRequested();
if (IsPrime(i))
{
primes.Add(i);
}
}
return primes;
});
}
static bool IsPrime(int number)
{
if (number < 2)
return false;
for (int i = 2; i <= Math.Sqrt(number); i++)
{
if (number % i == 0)
return false;
}
return true;
}
}Większość systemowych metod asynchronicznych zawiera przeciążenie akceptujące
CancellationToken.
Kod źródłowy: