🔥 Zapisy zamknięte, ale możesz pobrać Roadmapę .NET i dołączyć do listy oczekujących — Pobierz i dołącz do Listy VIP →

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

async/await w ASP.NET Core — await zwalnia wątek do puli, .Result blokuje wątek i głodzi pulę wątków

Znasz już async/await od strony językaawait 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 void

Jedyny 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łędy

Zadania 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

  • await na każdej operacji I/O — nigdy .Result/.Wait()
  • endpoint przyjmuje i przekazuje CancellationToken do bazy/HttpClient
  • Task.WhenAll tylko dla niezależnych operacji (różne zasoby)
  • równoległe zapytania do bazy = osobne instancje DbContext
  • zawsze async Task, nigdy async 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.

👨‍💻
Mariusz Jurczenko
Senior .NET Developer · 10+ lat doświadczenia komercyjnego

Programista .NET z doświadczeniem komercyjnym w firmach takich jak NFZ, Kamsoft, Diagnostyka, Hermes Reply Polska czy Etisoft Smart Solutions. Twórca kursów, z których skorzystało już ponad 11 000 osób w Strefie Kursów i ponad 1 000 kursantów na dev-hobby.pl.

Specjalizacja: Clean Code, Clean Architecture i uczenie programowania tak, żeby dało się je naprawdę zrozumieć — nie wykuć.

🚀 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.

Dodaj komentarz

czytanie to początek

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ę →