Wszystkie wpisy

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 synchronizacji
  • CancellationToken - bezpieczny “stop button” dla operacji asynchronicznych przez kooperatywne anulowanie
  • IAsyncEnumerable<T> - asynchroniczne strumienie z await 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 piszeszConfigureAwait(false)?
Kod aplikacji UI (WinForms, WPF), aktualizujący kontrolki po awaitNIE - kontynuacja musi wrócić na UI thread
Biblioteka klas, paczka NuGetZAWSZE TAK - nie wiesz, gdzie kod będzie konsumowany
Logika w tle, nie dotykająca UITAK
Kontroler ASP.NET CoreBez 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:

  1. Utwórz CancellationTokenSource - obiekt, który wysyła sygnał anulowania.
  2. Przekaż Token z tego source do każdej operacji, która ma reagować.
  3. W operacji nasłuchuj - sprawdzaj IsCancellationRequested lub używaj ThrowIfCancellationRequested.
  4. 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 #

IsCancellationRequestedThrowIfCancellationRequested()
Zwracabool (flaga)void - rzuca OperationCanceledException przy anulowaniu
KontrolaRęczna - możesz wyjść break, posprzątać, zalogowaćAutomatyczna - wyjątek propaguje
Task statusPozostaje RanToCompletion (jeśli normalnie wyjdziesz z metody)Automatycznie staje się Canceled (nie Faulted!)
Kiedy używaćGdy potrzebujesz dodatkowego cleanup’u przed wyjściemStandardowy 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 typProblem
IEnumerable<T> z yield returnSynchroniczne - 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 strumienia
  • System.Collections.Generic.IAsyncEnumerator<T> - iterator z MoveNextAsync zamiast MoveNext
  • System.IAsyncDisposable - asynchroniczny Dispose

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ą (IAsyncEnumerable strumieniuje)
  • 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 (brak SynchronizationContext)
  • CancellationToken - kooperatywne anulowanie (cytat MS: not forced on the listener); duet CancellationTokenSource (pilot) + CancellationToken (odbiornik); IsCancellationRequested (polling) kontra ThrowIfCancellationRequested() (Task → Canceled); przekazywanie tokena do Task.Delay/HttpClient/ReadAsync; CancelAfter dla timeoutu; CreateLinkedTokenSource dla łączenia źródeł
  • IAsyncEnumerable<T> - strumień async z yield return w async metodzie; konsumpcja przez await foreach; [EnumeratorCancellation] na parametrze CancellationToken plus .WithCancellation(token) w konsumencie; ValueTask pod spodem dla wydajności; asynchroniczne DisposeAsync na 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

🔗 Linkują tu