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):
| Krok | Co się dzieje |
|---|---|
| 1 | UI thread wchodzi do ButtonClick |
| 2 | ButtonClick wywołuje async metodę i robi .Result lub .Wait() na zwróconym Task |
| 3 | UI thread jest zablokowany - czeka na zakończenie Task |
| 4 | Wewnątrz async metody await client.GetStringAsync(...) - I/O leci w tle |
| 5 | I/O kończy się, kontynuacja chce wrócić na UI thread (kontekst synchronizacji) |
| 6 | KATASTROFA: 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 .NET | Zachowanie |
|---|---|
| Pre-.NET 4.5 | Ubija 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:
- Snapshot 1 w czystym stanie aplikacji (po starcie, przed obciążeniem)
- N operacji (1000, 10000 - zależnie od skali)
- Snapshot 2
- Porównanie w narzędziu - jakie typy mają rosnącą liczbę instancji?
Narzędzia:
| Narzędzie | Charakterystyka |
|---|---|
| dotMemory (JetBrains) | Najbardziej intuicyjne, świetna wizualizacja graphów referencji |
| Visual Studio Diagnostic Tools | Wbudowane, podstawowa wystarczalność |
| PerfView | Microsoft, darmowe, potężne ale stroma krzywa uczenia |
| dotnet-counters | Live monitoring (counters) bez profilera |
Typowe sygnały:
- Liczba instancji
CancellationTokenRegistrationrośnie liniowo → niesprzątaneRegister - Liczba
Taskroś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.Runewakuacja (ostateczność dla legacy) CancellationToken.RegisterzwracaIDisposable- 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
-=wDispose - 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
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#.
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#.
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