Zaawansowany async w C# - ConfigureAwait, CancellationToken, IAsyncEnumerable
Trzy mechanizmy programisty Senior - ConfigureAwait(false) eliminujący niepotrzebne wracanie do kontekstu synchronizacji, CancellationToken jako kooperatywne anulowanie, IAsyncEnumerable jako strumień asynchroniczny z await foreach. Piętnasta część kursu C#.
W poprzednim wpisie Task kontra ValueTask zobaczyłeś jedną z optymalizacji async. Teraz trzy kolejne narzędzia, których używają Seniorzy i architekci .NET budujący systemy odporne na duże obciążenia - i które regularnie pojawiają się na rozmowach kwalifikacyjnych:
ConfigureAwait(false)- dlaczego biblioteki powinny “uwalniać” wątek z kontekstu synchronizacjiCancellationToken- bezpieczny “stop button” dla operacji asynchronicznych przez kooperatywne anulowanieIAsyncEnumerable<T>- asynchroniczne strumienie zawait foreach, eliminujące potrzebę ładowania całej kolekcji do pamięci
Każde z nich rozwiązuje konkretny problem produkcyjny. Razem - dają język wyrażania zaawansowanej asynchroniczności, której nie da się wyrazić zwykłym async/await.
1. ConfigureAwait(false) - uwolnienie wątku z kontekstu #
Problem: SynchronizationContext #
Pewne typy aplikacji mają kontekst synchronizacji - mechanizm, który zapewnia, że pewien kod zawsze wykonuje się na konkretnym wątku. Najsłynniejszy przykład: UI thread w WinForms/WPF. Tylko jeden wątek może modyfikować kontrolki (przycisk, etykietę) - próba z innego wątku rzuca InvalidOperationException.
Domyślnie maszyna stanów async po await próbuje wrócić na ten sam kontekst, w którym była. W UI to konieczne - inaczej kod po await nie mógłby aktualizować interfejsu. Ale w bibliotece, która tylko pobiera dane i zapisuje na dysk? Marnotrawstwo. Zmuszamy system, by czekał na UI thread, mimo że nie planujemy ruszać UI.
Co gorsza - w klasycznym ASP.NET kontekst też istniał i nieświadome użycie .Result/.Wait() na metodzie z bibliotek powodowało deadlock: główny wątek zablokowany przez .Result, kontynuacja czekająca na powrót na ten sam wątek - oboje wieczne.
Rozwiązanie #
task.ConfigureAwait(false) mówi schedulerowi: po kontynuacji nie wracaj na kontekst - weź dowolny wolny wątek z puli. Kontynuacja idzie na ThreadPool, kontekst zostaje wolny.
public async Task FetchDataForLibraryAsync()
{
HttpClient client = new HttpClient();
// ConfigureAwait(false) - kontynuacja na dowolnym wątku ThreadPool
string result = await client.GetStringAsync("https://api.example.com")
.ConfigureAwait(false);
// Ta linia nie potrzebuje UI thread - może być na dowolnym wątku
File.WriteAllText("data.txt", result);
}
Złota zasada #
| Co piszesz | ConfigureAwait(false)? |
|---|---|
Kod aplikacji UI (WinForms, WPF), aktualizujący kontrolki po await | NIE - kontynuacja musi wrócić na UI thread |
| Biblioteka klas, paczka NuGet | ZAWSZE TAK - nie wiesz, gdzie kod będzie konsumowany |
| Logika w tle, nie dotykająca UI | TAK |
| Kontroler ASP.NET Core | Bez znaczenia (brak SynchronizationContext) - można pominąć dla czytelności |
| Klasyczny ASP.NET (.NET Framework) | TAK - chroni przed deadlockiem |
Cytat MS: ASP.NET Core doesn’t have a SynchronizationContext - to oznacza, że ten cały problem znika w nowoczesnych aplikacjach webowych. Ale w bibliotekach i tak stosuje się ConfigureAwait(false) - twój kod nie wie, kto go będzie wywoływał.
2. CancellationToken - kooperatywne anulowanie #
Czemu nie “Thread.Abort” #
W bardzo starych wersjach .NET istniała metoda Thread.Abort - brutalne zabicie wątku. Powodowała uszkodzone pliki, niedomknięte połączenia, niespójne stany współdzielonych obiektów. Została deprecated w .NET 5+ i całkowicie usunięta. Wątku nie można w nowoczesnym .NET ubić z zewnątrz - bezpieczeństwo wymaga, by sama operacja wiedziała, jak grzecznie zakończyć.
Zamiast tego mamy kooperatywne anulowanie. Cytat z MS: Cancellation is cooperative and is not forced on the listener. The listener determines how to gracefully terminate in response to a cancellation request.
Wzorzec - cztery kroki #
Cytowane wprost z dokumentacji MS:
- Utwórz
CancellationTokenSource- obiekt, który wysyła sygnał anulowania. - Przekaż
Tokenz tego source do każdej operacji, która ma reagować. - W operacji nasłuchuj - sprawdzaj
IsCancellationRequestedlub używajThrowIfCancellationRequested. - Wywołaj
Cancel()na source, gdy chcesz anulować.
public async Task FetchDataLoopAsync(CancellationToken cancellationToken)
{
for (int i = 0; i < 100; i++)
{
// Wariant 1: polling - łagodne wyjście
if (cancellationToken.IsCancellationRequested)
{
Console.WriteLine("Cancelled, cleaning up...");
break;
}
// Wariant 2: rzucenie wyjątku - Task automatycznie zostanie Canceled (nie Faulted)
// cancellationToken.ThrowIfCancellationRequested();
Console.WriteLine($"Fetching batch {i}...");
// KLUCZOWE: przekaż token dalej do wbudowanych metod async
await Task.Delay(1000, cancellationToken);
}
}
Strona “pilota”:
CancellationTokenSource cts = new CancellationTokenSource();
Task task = FetchDataLoopAsync(cts.Token);
await Task.Delay(3000);
Console.WriteLine("Too slow, cancelling!");
cts.Cancel(); // wysyła sygnał
try
{
await task;
}
catch (OperationCanceledException)
{
Console.WriteLine("Successfully cancelled");
}
cts.Dispose(); // CancellationTokenSource implementuje IDisposable!
IsCancellationRequested kontra ThrowIfCancellationRequested #
IsCancellationRequested | ThrowIfCancellationRequested() | |
|---|---|---|
| Zwraca | bool (flaga) | void - rzuca OperationCanceledException przy anulowaniu |
| Kontrola | Ręczna - możesz wyjść break, posprzątać, zalogować | Automatyczna - wyjątek propaguje |
Task status | Pozostaje RanToCompletion (jeśli normalnie wyjdziesz z metody) | Automatycznie staje się Canceled (nie Faulted!) |
| Kiedy używać | Gdy potrzebujesz dodatkowego cleanup’u przed wyjściem | Standardowy wzorzec dla większości metod async |
Przekazywanie tokena do metod BCL #
Większość metod async w bibliotece standardowej akceptuje CancellationToken:
await Task.Delay(1000, ct); // anulowanie czekania
await httpClient.GetStringAsync(url, ct);// anulowanie HTTP
await stream.ReadAsync(buffer, ct); // anulowanie odczytu
await context.SaveChangesAsync(ct); // anulowanie zapisu EF Core
Reguła: jeśli metoda przyjmuje CancellationToken w przeciążeniu - zawsze przekazuj swój token. Inaczej anulowanie zatrzyma się na pierwszym await bez tokena.
CancelAfter - automatyczny timeout #
CancellationTokenSource cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(5)); // anuluje samo po 5 sekundach
try
{
await DownloadAsync(cts.Token);
}
catch (OperationCanceledException)
{
// Timeout albo manualne cts.Cancel()
}
Linked tokens - kilka źródeł naraz #
// Token od użytkownika (np. zamknięcie strony w ASP.NET) + nasz timeout 10s
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
userToken, timeoutCts.Token);
// linkedCts.Token zostanie anulowany, gdy KTÓRYKOLWIEK z dwóch będzie anulowany
await DoWorkAsync(linkedCts.Token);
Linked source jest idealny dla bibliotek, które chcą respektować zewnętrzny token konsumenta plus wymusić własny timeout.
3. IAsyncEnumerable - asynchroniczne strumienie #
Problem przed C# 8.0 #
Dwie nieprzyjemne opcje:
| Zwracany typ | Problem |
|---|---|
IEnumerable<T> z yield return | Synchroniczne - blokuje wątek przy każdym elemencie |
Task<IEnumerable<T>> | Trzeba poczekać na całą kolekcję przed zwróceniem pierwszego elementu |
Dla zapytania do bazy, które zwraca miliony rekordów, pierwszy wariant zatyka wątek; drugi alokuje całość w pamięci.
Rozwiązanie #
C# 8.0 wprowadził IAsyncEnumerable<T> plus składnię await foreach. Trzy nowe interfejsy w .NET Standard 2.1:
System.Collections.Generic.IAsyncEnumerable<T>- źródło strumieniaSystem.Collections.Generic.IAsyncEnumerator<T>- iterator zMoveNextAsynczamiastMoveNextSystem.IAsyncDisposable- asynchronicznyDispose
Twoja metoda zwraca IAsyncEnumerable<T>, jest oznaczona jako async, używa yield return po każdym await:
public async IAsyncEnumerable<string> FetchLinesFromServerAsync(string url)
{
using HttpClient client = new HttpClient();
using Stream stream = await client.GetStreamAsync(url);
using StreamReader reader = new StreamReader(stream);
string line;
while ((line = await reader.ReadLineAsync()) != null)
{
await Task.Delay(100); // symulacja przetwarzania
yield return line; // jedna linia naraz do konsumenta!
}
}
Konsumpcja przez await foreach #
await foreach (string line in FetchLinesFromServerAsync("https://example.com/big.txt"))
{
Console.WriteLine($"Got: {line}"); // pierwsza linia natychmiast po pobraniu!
}
await foreach rozwija się do (uproszczenie):
var enumerator = source.GetAsyncEnumerator();
try
{
while (await enumerator.MoveNextAsync())
{
var line = enumerator.Current;
Console.WriteLine($"Got: {line}");
}
}
finally
{
await enumerator.DisposeAsync(); // asynchroniczne zwolnienie zasobów
}
Asynchroniczny DisposeAsync na końcu - dla strumieni, które trzymają połączenia z bazą albo otwartym socketem - to fundamentalne.
Anulowanie strumienia: [EnumeratorCancellation] #
Specjalny atrybut z System.Runtime.CompilerServices, który łączy token od konsumenta z parametrem metody iteratora:
using System.Runtime.CompilerServices;
public async IAsyncEnumerable<string> FetchLinesAsync(
string url,
[EnumeratorCancellation] CancellationToken ct = default)
{
using HttpClient client = new HttpClient();
// ct jest "dostarczany" przez WithCancellation w konsumencie
using Stream stream = await client.GetStreamAsync(url, ct);
using StreamReader reader = new StreamReader(stream);
string line;
while ((line = await reader.ReadLineAsync()) != null)
{
ct.ThrowIfCancellationRequested();
yield return line;
}
}
Konsument przekazuje token przez .WithCancellation(token):
CancellationTokenSource cts = new CancellationTokenSource();
cts.CancelAfter(5_000);
try
{
await foreach (string line in FetchLinesAsync(url).WithCancellation(cts.Token))
{
Console.WriteLine(line);
}
}
catch (OperationCanceledException)
{
Console.WriteLine("Stream cancelled after 5 seconds");
}
[EnumeratorCancellation] mówi kompilatorowi: gdy konsument wywoła WithCancellation(token), podstaw ten token jako wartość parametru ct iteratora. Bez tego atrybutu ct zostałby z wartością domyślną (None) - anulowanie nie miałoby efektu.
ValueTask pod spodem #
Cytat MS: ValueTask is used in these interfaces for performance reasons. IAsyncEnumerator<T>.MoveNextAsync() zwraca ValueTask<bool>, nie Task<bool> - bo strumień ma być wydajny dla milionów elementów. To jeden z głównych przypadków, w których ValueTask świeci.
4. Łączenie wszystkich trzech #
W realnym kodzie często łączy się te trzy mechanizmy. Wyobraź sobie bibliotekę pobierającą rekordy z API z paginacją:
public class GitHubClient
{
public async IAsyncEnumerable<Issue> StreamIssuesAsync(
string repo,
[EnumeratorCancellation] CancellationToken ct = default)
{
int page = 1;
while (true)
{
ct.ThrowIfCancellationRequested();
// ConfigureAwait(false) - jesteśmy w bibliotece
var batch = await _http.GetFromJsonAsync<Issue[]>(
$"/repos/{repo}/issues?page={page}",
ct).ConfigureAwait(false);
if (batch == null || batch.Length == 0) break;
foreach (var issue in batch)
yield return issue;
page++;
}
}
}
// Użycie w aplikacji UI:
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2));
try
{
await foreach (Issue i in client.StreamIssuesAsync("dotnet/runtime")
.WithCancellation(cts.Token))
{
ListBox.Items.Add(i.Title); // UI thread - to działa, bo IAsyncEnumerable
// przekazuje kontekst pomimo ConfigureAwait
// w pojedynczych await wewnątrz iteratora
}
}
catch (OperationCanceledException)
{
StatusBar.Text = "Timeout";
}
Wartość:
- Setki tysięcy issue ze stałą pamięcią (
IAsyncEnumerablestrumieniuje) - Użytkownik może zamknąć stronę / wcisnąć Stop (
CancellationToken) - Wewnątrz iteratora
ConfigureAwait(false)minimalizuje narzut kontekstu
Podsumowanie tematu #
W tej części kursu poznałeś:
ConfigureAwait(false)- mówi schedulerowi po kontynuacji bierz dowolny wolny wątek z puli, nie wracaj na kontekst. Zawsze w bibliotekach; nigdy w kodzie UI dotykającym kontrolek; bez znaczenia w ASP.NET Core (brakSynchronizationContext)CancellationToken- kooperatywne anulowanie (cytat MS: not forced on the listener); duetCancellationTokenSource(pilot) +CancellationToken(odbiornik);IsCancellationRequested(polling) kontraThrowIfCancellationRequested()(Task → Canceled); przekazywanie tokena doTask.Delay/HttpClient/ReadAsync;CancelAfterdla timeoutu;CreateLinkedTokenSourcedla łączenia źródełIAsyncEnumerable<T>- strumień async zyield returnwasyncmetodzie; konsumpcja przezawait foreach;[EnumeratorCancellation]na parametrzeCancellationTokenplus.WithCancellation(token)w konsumencie;ValueTaskpod spodem dla wydajności; asynchroniczneDisposeAsyncna koniec iteracji- Łączenie trzech - biblioteka strumieniująca dane z API z paginacją, anulowaniem przez timeout/użytkownika i
ConfigureAwait(false)w iteratorze - kanoniczny wzorzec produkcyjny
W następnym wpisie Deadlocki i wycieki pamięci w async C# - ostatnim z trylogii async - poznasz pułapki, które nie rzucają wyjątkiem, ale zabijają systemy produkcyjne: klasyczny sync-over-async deadlock z .Result/.Wait, wycieki przez CancellationToken.Register bez Dispose oraz “fire-and-forget” z połkniętymi wyjątkami.
Quiz i zadanie poniżej. Zadanie łączy dwa z trzech narzędzi z dzisiejszego wpisu: IAsyncEnumerable strumieniujący rekordy + CancellationToken anulujący strumień w trakcie. Trzeci - ConfigureAwait(false) - pomijam dla czytelności (i tak nie ma efektu w prostej aplikacji konsolowej bez SynchronizationContext).
Podobne wpisy
Memory i ReadOnlyMemory w C# - asynchroniczny krewniak Spana
Memory<T> jako obietnica Span<T> przeżywająca await, ReadAsync z buforem, ReadOnlyMemory<char> dla tekstu w async, IMemoryOwner i MemoryPool dla recyklingu, lifetime rules - reguły 3-8 z dokumentacji MS o czasie życia bufora. Osiemnasta część kursu C#.
Deadlocki i wycieki pamięci w async C# - sync-over-async, fire-and-forget, CancellationToken.Register
Trzy najgroźniejsze pułapki async w produkcji - klasyczny deadlock z .Result/.Wait(), połknięte wyjątki z fire-and-forget, wycieki pamięci z CancellationToken.Register. Diagnostyka i wzorce obronne. Szesnasta i ostatnia część kursu C# o asynchroniczności.
Task kontra ValueTask w C# - kiedy struct zamiast referencji oszczędza GC
Optymalizacja asynchroniczności dla hot path - ValueTask jako struct unika alokacji Task na stercie, gdy operacja kończy się synchronicznie. Cztery żelazne zasady bezpieczeństwa, trade-offy, kiedy używać a kiedy stać przy zwykłym Task. Czternasta część kursu C#.
🔗 Linkują tu