Wszystkie wpisy

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.


W poprzednich pięciu wpisach o asynchroniczności poznałeś async/await, maszynę stanów, ValueTask, ConfigureAwait, CancellationToken oraz IAsyncEnumerable. Teraz - ostatni wpis w sekcji async - poznasz pułapki, które nie rzucają wyjątkiem, nie dają stack trace’a, ale zabijają systemy produkcyjne w długim czasie. Dwa najgorsze rodzaje błędów w karierze programisty:

  • Deadlock - aplikacja zawiesza się w nieskończoność. Bez logu, bez błędu, po prostu cisza.
  • Wyciek pamięci - aplikacja powoli, przez godziny lub dni, zżera RAM aż serwer umiera bez ostrzeżenia.

W tej części rozbroimy obie te bomby zegarowe na konkretnych przykładach z C#. Plus wzorce obronne, których senior developerzy używają, by zapobiec problemom, zamiast je gasić w środku nocy.

1. Deadlock: sync-over-async #

Jak powstaje #

Cytat z bloga inżynierów .NET:

you invoke the method on that one thread and then block it waiting for the operation to complete. The operation kicks off the network download and awaits it. Since by default awaiting a Task will capture the current SynchronizationContext, it does so, and when the network download completes, it queues back to the SynchronizationContext the callback that will invoke the remainder of the operation. But the only thread that can process the queued callback is currently blocked by your code blocking waiting on the operation to complete. And that operation won’t complete until the callback is processed. Deadlock!

Rozłóżmy to na kroki w aplikacji WPF / WinForms (klasyczny ASP.NET działa identycznie):

KrokCo się dzieje
1UI thread wchodzi do ButtonClick
2ButtonClick wywołuje async metodę i robi .Result lub .Wait() na zwróconym Task
3UI thread jest zablokowany - czeka na zakończenie Task
4Wewnątrz async metody await client.GetStringAsync(...) - I/O leci w tle
5I/O kończy się, kontynuacja chce wrócić na UI thread (kontekst synchronizacji)
6KATASTROFA: UI thread jest zablokowany przez .Wait(). Kontynuacja nie może się wykonać. Task nie skończy się bez kontynuacji. .Wait() nie puści, dopóki Task się nie skończy. Wieczne czekanie.

Przykład kodu, który zabija aplikację #

public void OnButtonClick(object sender, EventArgs e)
{
    // ❌ TO ZAMRAŻA APLIKACJĘ
    string data = FetchDataAsync().Result;
    DisplayOnScreen(data);
}

public async Task<string> FetchDataAsync()
{
    using HttpClient client = new HttpClient();
    return await client.GetStringAsync("https://api.example.com/data");
    // Powyższa linia chce wrócić na UI thread - który jest ZAMROŻONY przez .Result
}

Aplikacja zawiesza się bez żadnego śladu w logach. Profilery wątków pokażą zablokowany UI thread. Diagnostyka bez wiedzy o sync-over-async to dni stracone na zgadywanie.

Trzy poziomy obrony #

Poziom 1: Async all the way #

Złota zasada: jeśli używasz await gdziekolwiek - cała ścieżka wywołań w górę musi być async. Nigdy .Result/.Wait() w kodzie, który zawiera await.

// ✅ Async all the way - kontynuacja wraca na UI thread bez deadlocka
public async void OnButtonClick(object sender, EventArgs e)
{
    string data = await FetchDataAsync();
    DisplayOnScreen(data);
}

Uwaga: async void to jedyny dopuszczalny przypadek void-zwracającej async metody - event handlery. Wszędzie indziej async Task albo async Task<T>.

Poziom 2: ConfigureAwait(false) w bibliotekach #

Z poprzedniego wpisu - ConfigureAwait(false) mówi schedulerowi nie wracaj na kontekst. Wewnątrz biblioteki eliminuje przyczynę deadlocka: kontynuacja idzie na ThreadPool, UI thread przestaje być potrzebny.

public async Task<string> FetchDataAsync()
{
    using HttpClient client = new HttpClient();
    string result = await client.GetStringAsync("https://api.example.com/data")
                                .ConfigureAwait(false);
    // Kontynuacja na ThreadPool - UI thread nie jest zaangażowany
    return result;
}

Cytat MS: If instead the library method had used ConfigureAwait(false), it would not queue the callback back to the original context, avoiding the deadlock scenarios. To obrona w głębi - nawet gdy konsument biblioteki głupio użyje .Result, biblioteka się nie wieszać.

Poziom 3: Task.Run ewakuacja (ostateczność) #

Czasem musisz wywołać async metodę z synchronicznej (legacy interface, brak możliwości zmiany sygnatury). Wtedy brudny hack:

public string FetchSynchronously()
{
    return Task.Run(() => FetchDataAsync()).GetAwaiter().GetResult();
}

Task.Run ewakuuje delegata na ThreadPool z pustym kontekstem synchronizacji. Wewnętrzny await w FetchDataAsync() ma “nigdzie nie wracać” - kontynuacja po prostu leci na dowolny wątek puli. Deadlock niemożliwy.

To rozwiązanie ostatecznej szansy - w nowym kodzie zawsze preferuj async all the way.

2. Wycieki pamięci - cisi mordercy serwerów #

Wycieki w async są szczególnie wredne, bo:

  • Nie ma stack trace’a wskazującego problem
  • Pojawiają się dopiero po godzinach pracy
  • W testach jednostkowych z 1-2 operacjami nigdy nie wystąpią
  • Profiler pamięci jest jedyną drogą diagnostyki

Trzy najczęstsze źródła.

Pułapka A: niesprzątany CancellationToken.Register #

CancellationToken.Register(callback) rejestruje akcję do wykonania przy anulowaniu. Zwraca obiekt typu CancellationTokenRegistration implementujący IDisposable. Brak Dispose = wyciek.

public async Task LeakyMethodAsync(CancellationToken token)
{
    // ❌ Rejestracja nigdy nie zostanie zwolniona
    token.Register(() => Console.WriteLine("Cancelled"));

    await Task.Delay(100);
}
// Po wyjściu z metody, anonimowa lambda + jej domknięcie żyje w pamięci
// dopóki CancellationTokenSource (źródło tokena) żyje

Token z długim cyklem życia (np. global app cancellation) + miliony takich rejestracji w endpoint’ach HTTP = miliony lambd w pamięci. Każda trzyma Console.WriteLine “z capture” - relatywnie mała, ale w grę wchodzą domknięcia z większymi obiektami.

Rozwiązanie: using (lub jawny Dispose):

public async Task SafeMethodAsync(CancellationToken token)
{
    using CancellationTokenRegistration registration = token.Register(
        () => Console.WriteLine("Cancelled"));

    await Task.Delay(100);
    // Tu `using` wywoła Dispose - rejestracja zostaje usunięta
}

Pułapka B: fire-and-forget z połkniętymi wyjątkami #

public void StartBackgroundWork()
{
    DoSomethingAsync();   // BRAK await - "fire and forget"
}

private async Task DoSomethingAsync()
{
    await Task.Delay(5000);
    throw new InvalidOperationException("Background failure");
}

Co się dzieje z tym wyjątkiem?

Wersja .NETZachowanie
Pre-.NET 4.5Ubija cały proces
.NET 4.5+ (default)Wyjątek staje się unobserved - cicho ginie, aplikacja kontynuuje

W obu przypadkach źle. W pre-4.5 - katastrofa. W nowoczesnym .NET - błąd nigdy nie wypłynie, a Task z capture’owanymi zmiennymi może pozostać w pamięci.

Plus jest jeszcze ostrzeżenie kompilatora:

CS4014: Because this call is not awaited, execution of the current method
continues before the call is completed. Consider applying the 'await'
operator to the result of the call.

To bardzo użyteczne ostrzeżenie - chroni przed klasycznym “zapomniałem await”.

Bezpieczny wzorzec fire-and-forget #

Gdy świadomie chcesz zadanie w tle bez czekania:

public static class SafeFireAndForget
{
    public static async void Run(Task task, Action<Exception> onError)
    {
        try
        {
            await task;
        }
        catch (Exception ex)
        {
            onError(ex);
        }
    }
}

// Użycie:
SafeFireAndForget.Run(
    DoSomethingAsync(),
    ex => _logger.LogError(ex, "Background failure"));

Lub krócej - _ = task; (discard) jako sygnał świadomej ignorancji:

_ = DoSomethingAsync();   // ostrzeżenie znika, intencja jawna

Ale to nie loguje wyjątków. discard jest OK dla zadań, które naprawdę nie mogą się wykrzaczyć. Dla wszystkiego innego - SafeFireAndForget.

Globalny safety net: TaskScheduler.UnobservedTaskException #

W programie startowym aplikacji:

TaskScheduler.UnobservedTaskException += (sender, args) =>
{
    Logger.LogError(args.Exception, "Unobserved task exception");
    args.SetObserved();   // mówi .NET: 'obsłużyłem, nie wywalaj'
};

To ostatnia linia obrony. Każdy unobserved wyjątek (z fire-and-forget, z Task na który nikt nie await’ował) ląduje tutaj. Nie polegaj na nim jako głównej obronie - ale zawsze ustaw go w starcie aplikacji, żeby wykryć błędy w istniejących fire-and-forget.

Pułapka C: nieodpięte handlery zdarzeń #

Z poprzedniego wpisu o delegatach: publisher trzyma silną referencję do subscriber’a przez listę inwokacji eventu. W kodzie async często długoterminowy serwis emituje eventy do krótkoterminowych komponentów (ViewModel okna, request scope), które subskrybują, ale nie wypisują się:

public class WindowViewModel
{
    public WindowViewModel(IDataService service)
    {
        service.DataChanged += OnDataChanged;   // ❌ brak -=
    }

    private void OnDataChanged(object s, DataEventArgs e) { /* ... */ }
    // Brak Dispose / unsubscribe - referencja trzyma ViewModel w pamięci
}

Każde nowe okno = nowy subscriber. Stare okna nigdy się nie zwalniają. Po godzinie kliknięć - megabajty ViewModeli w RAM-ie.

Rozwiązanie: zawsze pamiętaj o -= przy zniszczeniu subscriber’a (np. w IDisposable.Dispose).

Pułapka D: closure trzymający duże obiekty #

public async Task ProcessAsync()
{
    byte[] hugeBuffer = await ReadFileAsync();   // 500 MB

    _scheduler.Schedule(() => Console.WriteLine($"Length: {hugeBuffer.Length}"));
    //              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    //              Lambda capture'uje hugeBuffer - dopóki scheduler żyje,
    //              500 MB siedzi w pamięci, nawet po wyjściu z ProcessAsync
}

Closure C# przechwytuje każdą zmienną używaną w lambdzie. Lambda zarejestrowana w długoterminowym serwisie = długoterminowe trzymanie capture’owanych obiektów. Profilery pokazują to ładnie - zobaczysz hugeBuffer żyjący po wyjściu z metody.

Rozwiązanie: nie capture’uj dużych obiektów w długoterminowych callbackach. Wyciągnij to, co naprawdę potrzebne (np. samą długość):

int length = hugeBuffer.Length;   // wyciągnij wartość
_scheduler.Schedule(() => Console.WriteLine($"Length: {length}"));
// Teraz lambda capture'uje tylko int, GC może zwolnić hugeBuffer

3. Diagnostyka - jak znaleźć wyciek #

Bez profilera nie znajdziesz wycieku async. Stack trace nic nie pokaże; logi nic nie pokażą. Wzorzec diagnostyczny:

  1. Snapshot 1 w czystym stanie aplikacji (po starcie, przed obciążeniem)
  2. N operacji (1000, 10000 - zależnie od skali)
  3. Snapshot 2
  4. Porównanie w narzędziu - jakie typy mają rosnącą liczbę instancji?

Narzędzia:

NarzędzieCharakterystyka
dotMemory (JetBrains)Najbardziej intuicyjne, świetna wizualizacja graphów referencji
Visual Studio Diagnostic ToolsWbudowane, podstawowa wystarczalność
PerfViewMicrosoft, darmowe, potężne ale stroma krzywa uczenia
dotnet-countersLive monitoring (counters) bez profilera

Typowe sygnały:

  • Liczba instancji CancellationTokenRegistration rośnie liniowo → niesprzątane Register
  • Liczba Task rośnie - może być fire-and-forget bez czyszczenia
  • ViewModele/Controllers żyjące po zamknięciu okna/żądania → wycieki przez eventy
  • Tablice/obiekty z dużą “retained size” trzymane przez delegate → closure capture

Podsumowanie tematu #

W tej części kursu poznałeś:

  • Deadlock sync-over-async - klasyk z .Result/.Wait() blokującym UI thread, kontynuacja chce wrócić na ten sam thread → wieczne czekanie. Cytat MS o mechanizmie
  • Trzy poziomy obrony - async all the way (najlepsze), ConfigureAwait(false) w bibliotekach (obrona w głębi), Task.Run ewakuacja (ostateczność dla legacy)
  • CancellationToken.Register zwraca IDisposable - konieczność using; bez tego rejestracja żyje dopóki token żyje
  • Fire-and-forget i połknięte wyjątki - od .NET 4.5+ stają się unobserved, cicho giną; obrona przez SafeFireAndForget + TaskScheduler.UnobservedTaskException
  • Ostrzeżenie kompilatora CS4014 - chroni przed zapomnianym await; _ = task; (discard) gdy intencjonalne
  • Nieodpięte handlery zdarzeń - publisher trzyma subscriber’a; zawsze -= w Dispose
  • Closure capture - lambda przechwytuje zmienne; długoterminowe callbacki + duże obiekty = długoterminowe trzymanie w pamięci
  • Diagnostyka - profilery (dotMemory, VS Diagnostic Tools, PerfView) jako jedyne realne narzędzie; wzorzec snapshot → N operacji → snapshot → porównanie

To koniec sekcji async. W następnym wpisie Span i ReadOnlySpan w C# wejdziemy w optymalizację pamięci od innej strony - okna na pamięć bez alokacji, eliminacja kopiowania w pętlach przetwarzania, stackalloc, Memory<T> jako most do async i ArrayPool<T> do recyklingu buforów.

Quiz i zadanie poniżej. Zadanie demonstruje bezpieczny wzorzec fire-and-forget - pomocnik SafeFireAndForget.Run przyjmuje Task plus delegata do logowania wyjątków. Bez tego wzorca wyjątek w zadaniu tła zostałby cicho połknięty - tutaj dostajesz go w handlerze i decydujesz, co zrobić.

Podobne wpisy

🔗 Linkują tu