async/await w ASP.NET Core — deadlocki i CancellationToken

Znasz już async/await od strony języka — await zwalnia wątek na czas operacji I/O. Na serwerze ta sama mechanika ma inną stawkę: wątki pochodzą ze wspólnej puli, a każdy zablokowany wątek to jedno żądanie mniej, które serwer może obsłużyć. Źle użyty async w ASP.NET Core nie tylko spowalnia — pod obciążeniem potrafi zagłodzić pulę wątków i zatrzymać całą aplikację. Ten post to async w kontekście serwera: endpointy, anulowanie, równoległość i pułapki, które wywalają produkcję.
Dlaczego na serwerze to ważniejsze
W aplikacji konsolowej zablokowany wątek to Twój problem. Na serwerze wątki obsługują żądania wszystkich użytkowników naraz. Gdy endpoint blokuje wątek (czeka synchronicznie na bazę), ten wątek nie obsłuży nikogo innego, dopóki nie skończy. Przy kilkuset równoczesnych żądaniach pula się wyczerpuje — nowe żądania czekają w kolejce, czasy odpowiedzi rosną lawinowo, a serwer „stoi”, choć procesor się nudzi.
await na operacji I/O oddaje wątek z powrotem do puli na czas oczekiwania — ten sam wątek obsłuży w tym czasie inne żądanie. To nie kwestia „szybkości pojedynczego żądania”, tylko przepustowości całego serwera.
Na serwerze async nie przyspiesza jednego żądania — zwiększa liczbę żądań, które serwer obsłuży naraz. Blokując wątek, kradniesz go wszystkim pozostałym użytkownikom.
Async all the way — endpointy
Endpoint deklarujesz jako async i await-ujesz każdą operację I/O aż do końca łańcucha:
app.MapGet("/api/produkty/{id:int}", async (int id, SklepDbContext db) =>
{
var produkt = await db.Produkty.FindAsync(id); // await, nie .Result
return produkt is null ? Results.NotFound() : Results.Ok(produkt);
});Zasada „async all the way”: jeśli metoda woła coś asynchronicznego, sama jest async i jest await-owana wyżej. Przerwanie tego łańcucha (jedno .Result w środku) to źródło pułapki #1.
Pułapka #1 — .Result i .Wait() blokują pulę
Kuszące jest wywołać metodę async „synchronicznie”:
// ❌ NIGDY w kodzie serwerowym
var produkt = db.Produkty.FindAsync(id).Result;.Result i .Wait() blokują bieżący wątek do czasu zakończenia zadania. W starym ASP.NET (z SynchronizationContext) prowadziło to wprost do zakleszczenia (deadlock). ASP.NET Core nie ma SynchronizationContext, więc klasycznego deadlocka nie dostaniesz — ale efekt bywa gorszy: thread-pool starvation. Każde takie wywołanie zajmuje wątek, który mógłby obsługiwać inne żądania; przy ruchu pula pustoszeje i serwer przestaje odpowiadać. Reguła jest prosta: w kodzie serwerowym await, nigdy .Result/.Wait().
CancellationToken — gdy klient się rozłączy
Klient zamyka kartę w połowie długiego zapytania — a serwer dalej je liczy, marnując zasoby. ASP.NET Core wstrzykuje do endpointu CancellationToken powiązany z żądaniem (RequestAborted); przekazujesz go dalej do bazy i HttpClient:
app.MapGet("/api/raport", async (SklepDbContext db, CancellationToken ct) =>
{
var dane = await db.Produkty
.Where(p => p.Stan > 0)
.ToListAsync(ct); // anulowane, gdy klient przerwie żądanie
return Results.Ok(dane);
});Gdy klient przerywa połączenie, token zostaje anulowany, a ToListAsync(ct) rzuca OperationCanceledException — praca się zatrzymuje, wątek i połączenie z bazą wracają do puli. Nieprzekazany token oznacza serwer liczący wyniki, których nikt już nie odbierze.
Task.WhenAll — niezależne operacje równolegle
Gdy endpoint potrzebuje kilku niezależnych danych (np. z dwóch usług), sekwencyjne await czeka na sumę czasów. Task.WhenAll uruchamia je równolegle i czeka na najdłuższą:
// sekwencyjnie: 200 ms + 150 ms = 350 ms
var pogoda = await pogodaApi.PobierzAsync(ct);
var kursy = await kursyApi.PobierzAsync(ct);
// równolegle: max(200, 150) = 200 ms
var tPogoda = pogodaApi.PobierzAsync(ct);
var tKursy = kursyApi.PobierzAsync(ct);
await Task.WhenAll(tPogoda, tKursy);
var pogoda2 = tPogoda.Result; // tu .Result jest OK — zadanie już zakończone
var kursy2 = tKursy.Result;(Po Task.WhenAll odczyt .Result jest bezpieczny — zadania na pewno się zakończyły, nic nie blokujesz.)
Pułapka #2 — jeden DbContext w Task.WhenAll
Najgroźniejsza pułapka async w web. Task.WhenAll z operacjami na tym samym DbContext wysadza aplikację:
// ❌ DbContext NIE jest bezpieczny wątkowo
var t1 = db.Produkty.ToListAsync();
var t2 = db.Zamowienia.ToListAsync();
await Task.WhenAll(t1, t2); // wyjątek: "A second operation started on this context..."DbContext z Entity Framework Core jest zaprojektowany do jednej operacji naraz — dwie równoległe na tej samej instancji rzucają InvalidOperationException. Równoległe zapytania do bazy wymagają osobnych instancji DbContext (np. przez IDbContextFactory). Task.WhenAll jest do niezależnych źródeł (różne HttpClient, różne usługi), nie do współdzielonego kontekstu bazy.
Pułapka #3 — async void
async void to metoda, której nie da się await-ować, a jej wyjątki nie mają gdzie wypłynąć — w kontekście serwera wywalają cały proces zamiast zwrócić błąd żądania:
public async void ZapiszLog(...) // ❌ wyjątek = crash aplikacji
public async Task ZapiszLogAsync(...) // ✅ zawsze Task, nie voidJedyny uprawniony async void to obsługa zdarzeń UI — czego na serwerze nie ma. Zawsze async Task.
Pułapka #4 — fire-and-forget gubi wyjątki
Wywołanie async bez await („odpalę i zapomnę”) sprawia, że wyjątek z tego zadania nie zostanie złapany, a samo zadanie może nie dokończyć się przed końcem żądania:
_ = wyslijMailAsync(...); // ❌ wyjątek zniknie, zadanie może się urwać
await wyslijMailAsync(...); // ✅ czekasz i łapiesz błędyZadania w tle, które mają przeżyć żądanie, zleca się do dedykowanego mechanizmu (np. IHostedService albo kolejka jak Hangfire), nie przez porzucony Task.
Checklist — async w endpointach bez min
awaitna każdej operacji I/O — nigdy.Result/.Wait()- endpoint przyjmuje i przekazuje
CancellationTokendo bazy/HttpClient Task.WhenAlltylko dla niezależnych operacji (różne zasoby)- równoległe zapytania do bazy = osobne instancje
DbContext - zawsze
async Task, nigdyasync void - żadnego fire-and-forget — zadania w tle przez
IHostedService/kolejkę
Podsumowanie
Async na serwerze to nie prędkość pojedynczego żądania, lecz przepustowość całej aplikacji: await na I/O oddaje wątek z puli, żeby obsłużył innych. Dlatego kardynalny grzech to .Result/.Wait(), które blokują wątek i pod obciążeniem prowadzą do thread-pool starvation. Do tego dochodzą narzędzia i pułapki specyficzne dla web: CancellationToken przerywający pracę po rozłączeniu klienta, Task.WhenAll do niezależnych operacji (ale nigdy na współdzielonym DbContext, który nie jest thread-safe), oraz żelazne async Task zamiast async void i zero fire-and-forget. Async „all the way” plus te reguły zamieniają asynchroniczność z pułapki w realną przewagę wydajnościową serwera.
Co dalej
Async przewija się przez każdy endpoint pierwszego REST API i każdą warstwę Clean Architecture w .NET 10. Jeśli chcesz odświeżyć samą mechanikę async/await w oderwaniu od serwera, wróć do ogólnego przewodnika. A zadania, które mają żyć dłużej niż żądanie, oddaj do Hangfire zamiast porzucać je jako fire-and-forget.
🚀 Co dalej?
Zobacz to w praktyce na wideo i pobierz darmową roadmapę, żeby ułożyć naukę w spójną ścieżkę do pierwszej pracy.
- 🗺️ Pobierz darmową roadmapę Junior .NET Developer — 12 kroków od podstaw C# do pierwszej pracy: dev-hobby.pl
- 🎬 Subskrybuj kanał YouTube — nowe filmy co tydzień.
Zamień wiedzę w umiejętności
Pobierz darmową Roadmapę .NET i ułóż takie tematy jak ten w spójną ścieżkę do pierwszej pracy.
Pobieram roadmapę →