async/await w C# — metafora kelnera oddającego wątek do puli podczas operacji I/O

async/await w C# — kompletny przewodnik z pułapkami

async i await to dwa słowa kluczowe, które wyglądają prosto, a potrafią zniszczyć produkcję. Dodajesz async, dorzucasz await, kompiluje się i działa. Do momentu, aż pod obciążeniem aplikacja zaczyna się dławić, pojawia się tajemniczy deadlock, albo wyjątek znika bez śladu.

Problem nie w tym, że async jest trudny. Problem w tym, że wygląda na łatwiejszy niż jest.

W tym przewodniku rozłożę async/await na czynniki pierwsze:

  • Co naprawdę robi await z wątkiem (nie metafory — mechanizm)
  • Dlaczego .Result i .Wait() powodują deadlock
  • Kiedy async void jest bombą zegarową
  • Co robi ConfigureAwait(false) i kiedy go używać
  • Jak CancellationToken ratuje Twój serwer
  • 7 najczęstszych pułapek z realnych projektów

📖 Ten artykuł rozszerza pytanie #04 z postu 10 pytań rekrutacyjnych Junior .NET Developer. Jeśli przygotowujesz się do rozmowy — zacznij tam.


Spis treści

  1. Po co async — problem, który rozwiązuje
  2. Co naprawdę robi await
  3. Task vs Task<T> vs ValueTask
  4. Pułapka #1: deadlock przez .Result
  5. Pułapka #2: async void
  6. Pułapka #3: brak ConfigureAwait w bibliotekach
  7. Pułapka #4: sekwencyjne await zamiast WhenAll
  8. Pułapka #5: async bez CancellationToken
  9. Pułapka #6: async over sync
  10. Pułapka #7: fire-and-forget bez obsługi błędów
  11. Obsługa wyjątków w async
  12. Checklist

Po co async — problem, który rozwiązuje

Porównanie synchronicznego i asynchronicznego przetwarzania — zablokowane wątki kontra wysoki throughput
Porównanie synchronicznego i asynchronicznego przetwarzania — zablokowane wątki kontra wysoki throughput

Wyobraź sobie serwer ASP.NET Core z pulą 100 wątków. Każde żądanie HTTP zajmuje jeden wątek.

// ❌ SYNCHRONICZNIE — wątek BLOKUJE się na czas zapytania do bazy
public Order GetOrder(int id)
{
    var order = _db.Orders.First(o => o.Id == id);  // czeka 200ms na DB
    return order;
}

Podczas tych 200 ms wątek nic nie robi — czeka na odpowiedź z bazy. Ale jest zajęty. Nie może obsłużyć innego żądania.

Przy 100 wątkach i zapytaniach po 200 ms Twój serwer obsłuży maksymalnie 500 żądań na sekundę. Reszta czeka w kolejce. CPU jest w 5% — ale serwer „pełny”.

// ✅ ASYNCHRONICZNIE — wątek WRACA do puli na czas zapytania
public async Task<Order> GetOrderAsync(int id)
{
   var order = await _db.Orders.FirstAsync(o => o.Id == id); // wątek wolny!
   return order;
}

Teraz podczas tych 200 ms wątek wraca do puli i obsługuje inne żądania. Ten sam serwer obsłuży tysiące żądań na sekundę — bo wątki nie marnują się na czekanie.

Kluczowy wniosek: async nie przyspiesza pojedynczego żądania. Przyspiesza przepustowość (throughput) ile żądań obsłużysz jednocześnie.


Co naprawdę robi await

Mechanizm await krok po kroku — wątek zwalniany do puli i wznawiany na innym wątku po operacji I/O
Mechanizm await krok po kroku — wątek zwalniany do puli i wznawiany na innym wątku po operacji I/O

To jest sedno, którego większość Juniorów nie rozumie. Rozłóżmy to krok po kroku.

public async Task<string> GetDataAsync()
{
    Console.WriteLine("1. Start — wątek A");

    var data = await _http.GetStringAsync("https://api.example.com/data");

    Console.WriteLine("2. Po await — wątek B (może być inny!)");

    return data.ToUpper();
}

Co się dzieje pod maską:

Krok 1 — przed await: Kod wykonuje się normalnie na wątku A (np. wątku z puli ASP.NET Core).

Krok 2 — napotkanie await: Wywołanie GetStringAsync startuje operację I/O (żądanie sieciowe) i zwraca Task, który jeszcze się nie zakończył. W tym momencie:

  • Metoda GetDataAsync zwraca sterowanie do swojego wywołującego.
  • Wątek A wraca do puli wątków — jest wolny, może obsłużyć inne żądania.
  • Operacja sieciowa trwa „gdzieś indziej” (obsługiwana przez system operacyjny / kartę sieciową — nie przez wątek!).

Krok 3 — zakończenie operacji: Gdy odpowiedź sieciowa wraca:

  • .NET bierze dowolny wolny wątek z puli (wątek B — niekoniecznie A).
  • Kontynuuje wykonanie metody od linijki po await.
  • data zawiera wynik, Console.WriteLine(“2…”) wykonuje się na wątku B.

Dlaczego to ważne?

Bo między „przed await” a „po await” możesz być na innym wątku. Jeśli polegasz na czymś związanym z wątkiem (np. Thread.CurrentPrincipal, HttpContext w starym ASP.NET, wątek UI w WPF) — musisz rozumieć, że kontekst może się zmienić.

Metafora, ale precyzyjna: Wyobraź sobie kelnera w restauracji. Składa zamówienie w kuchni (await) i nie stoi pod kuchnią czekając — idzie obsłużyć inne stoliki. Gdy danie jest gotowe, dowolny wolny kelner (niekoniecznie ten sam) je odbiera i przynosi. Kelner = wątek. Kuchnia = operacja I/O.


Task vs Task<T> vs ValueTask

Trzy typy zwracane przez metody async. Kiedy który?

TypKiedy używaćPrzykład
TaskMetoda async bez wyniku (jak void, ale awaitable)Task SaveAsync()
Task<T>Metoda async zwracająca wartośćTask<Order> GetOrderAsync()
ValueTask<T>Hot path, gdzie wynik często jest już dostępny (cache)ValueTask<Order> GetCachedAsync()
voidNIGDY (poza event handlerami)
// Task — brak wyniku
public async Task SaveOrderAsync(Order order)
{
    await _db.Orders.AddAsync(order);
    await _db.SaveChangesAsync();
}
// Task<T> — z wynikiem
public async Task<Order?> GetOrderAsync(int id)
{
    return await _db.Orders.FindAsync(id);
}
// ValueTask<T> — optymalizacja gdy często synchroniczne (cache hit)
public ValueTask<Order?> GetOrderCachedAsync(int id)
{
    if (_cache.TryGetValue(id, out var cached))
       return new ValueTask<Order?>(cached);  // bez alokacji Task!

    return new ValueTask<Order?>(LoadFromDbAsync(id));
}

Reguła ValueTask:
Używaj tylko gdy zmierzysz, że alokacje Task są bottleneckiem (np. miliony wywołań z cache). W 95% przypadków Task<T> jest właściwy. ValueTask ma pułapki, nie wolno go awaitować dwa razy, nie wolno trzymać.


Pułapka #1: deadlock przez .Result

Deadlock przez .Result w C# — cykliczna zależność między wątkiem UI a Task
Deadlock przez .Result w C# — cykliczna zależność między wątkiem UI a Task

Najsłynniejszy bug async. Klasyk pytań rekrutacyjnych.

// ❌ DEADLOCK w WPF / WinForms / ASP.NET Classic
public string GetData()
{
    return GetDataAsync().Result;  // 💀
}

private async Task<string> GetDataAsync()
{
    await Task.Delay(1000);  // tutaj wraca na kontekst
    return "data";
}

Dlaczego to deadlockuje — krok po kroku

  1. GetData() wywołuje GetDataAsync() i blokuje wątek UI przez .Result.
  2. GetDataAsync() dochodzi do await Task.Delay(1000).
  3. Domyślnie await zapamiętuje SynchronizationContext (w WPF = wątek UI) i chce na niego wrócić po zakończeniu.
  4. Po sekundzie Task.Delay się kończy — kontynuacja chce wrócić na wątek UI.
  5. Ale wątek UI jest zablokowany przez .Result z kroku 1.
  6. Deadlock: wątek UI czeka na Task, Task czeka na wolny wątek UI. Nikt nie ustąpi.

Rozwiązanie: async all the way

// ✅ Nigdy nie blokuj — propaguj async w górę
public async Task<string> GetDataAsync()
{
    return await FetchAsync();
}

A co w ASP.NET Core?

W ASP.NET Core nie ma SynchronizationContext — więc .Result technicznie nie zdeadlockuje. Ale to nie znaczy, że jest OK:

// ❌ W ASP.NET Core nie zdeadlockuje, ALE...
public IActionResult Get()
{
    var data = _service.GetDataAsync().Result;  // blokuje wątek z puli!
    return Ok(data);
}

.Result blokuje wątek z puli na czas operacji — czyli niszczy cały sens async. Pod obciążeniem wyczerpiesz pulę wątków (thread pool starvation) i serwer przestanie odpowiadać.

Zasada żelazna: Nigdy .Result, .Wait(), .GetAwaiter().GetResult() w kodzie aplikacyjnym. async all the way.


Pułapka #2: async void

Pułapka async void — wyjątek ucieka przed try-catch i crashuje proces
Pułapka async void — wyjątek ucieka przed try-catch i crashuje proces

async void to metoda, która wygląda niewinnie, a jest bombą.

// ❌ async void — wyjątek NIE DA SIĘ złapać
public async void ProcessData()
{
    await Task.Delay(100);
    throw new InvalidOperationException("Boom!");  // crash całej aplikacji
}

// Wywołanie:
try
{
    ProcessData();  // wyjątek NIE trafi tutaj!
}
catch (Exception ex)
{
    // Ten catch NIGDY się nie wykona
    Console.WriteLine(ex.Message);
}

Dlaczego to groźne

  • async void nie zwraca Task, więc nie da się go awaitować.
  • Wyjątek rzucony w async void leci prosto do SynchronizationContext — czyli w praktyce crashuje proces (lub jest cicho gubiony).
  • Nie da się go przetestować — testy nie poczekają na zakończenie.

Jedyny dozwolony przypadek: event handlery

// ✅ JEDYNY akceptowalny async void — event handler UI
private async void OnButtonClick(object sender, EventArgs e)
{
    try  // ZAWSZE try-catch w async void event handler!
    {
        await SaveDataAsync();
    }
    catch (Exception ex)
    {
        ShowError(ex.Message);
    }
}

Zasada: Jeśli metoda nie jest event handlerem — zwracaj Task, nigdy void.


Pułapka #3: brak ConfigureAwait w bibliotekach

ConfigureAwait(false) w C# — wybór między powrotem na kontekst a dowolnym wątkiem z puli
ConfigureAwait(false) w C# — wybór między powrotem na kontekst a dowolnym wątkiem z puli

ConfigureAwait(false) mówi: „po zakończeniu operacji nie wracaj na oryginalny kontekst — kontynuuj na dowolnym wątku z puli”.

// ❌ W bibliotece — bez ConfigureAwait
public async Task<string> FetchFromLibraryAsync()
{
    var response = await _http.GetAsync(_url);          // wraca na kontekst
    return await response.Content.ReadAsStringAsync();  // wraca na kontekst
}
// ✅ W bibliotece — z ConfigureAwait(false)
public async Task<string> FetchFromLibraryAsync()
{
    var response = await _http.GetAsync(_url).ConfigureAwait(false); // dowolny wątek

    return await response.Content.ReadAsStringAsync().ConfigureAwait(false); // dowolny wątek
}

Kiedy używać ConfigureAwait(false)

KontekstConfigureAwait(false)?Dlaczego
Biblioteka / NuGet package✅ ZAWSZENie wiesz, kto Cię wywoła — może mieć SynchronizationContext
Kod aplikacyjny ASP.NET Core➖ NiepotrzebneBrak SynchronizationContext — nie ma na co wracać
WPF / WinForms code-behind❌ Często NIEPo await często potrzebujesz wątku UI (do aktualizacji kontrolki)

Najważniejsze: W bibliotekach zawsze dawaj ConfigureAwait(false) — bo nie wiesz, w jakim środowisku ktoś Cię wywoła. Chronisz callera przed deadlockiem i niepotrzebnym przełączaniem kontekstu.

💡 W .NET 6+ możesz włączyć analyzer CA2007, który ostrzega o braku ConfigureAwait w bibliotekach.


Pułapka #4: sekwencyjne await zamiast WhenAll

Task.WhenAll vs sekwencyjne await — równoległe operacje skracają czas z 530ms do 200ms
Task.WhenAll vs sekwencyjne await — równoległe operacje skracają czas z 530ms do 200ms

Najczęstszy „cichy” performance killer.

// ❌ SEKWENCYJNIE — operacje czekają jedna na drugą
public async Task<Dashboard> GetDashboardSlowAsync(int userId)
{
    var orders   = await _orderService.GetOrdersAsync(userId);   // 200ms
    var profile  = await _userService.GetProfileAsync(userId);   // 150ms
    var messages = await _msgService.GetMessagesAsync(userId);   // 180ms
    // ŁĄCZNIE: 200 + 150 + 180 = 530ms

    return new Dashboard(orders, profile, messages);
}

Te trzy operacje są niezależne — nie ma powodu, żeby czekały na siebie. Task.WhenAll uruchamia je równolegle:

// ✅ RÓWNOLEGLE — wszystkie startują naraz
public async Task<Dashboard> GetDashboardFastAsync(int userId)
{
var ordersTask   = _orderService.GetOrdersAsync(userId);   // start
var profileTask  = _userService.GetProfileAsync(userId);   // start
var messagesTask = _msgService.GetMessagesAsync(userId);   // start

await Task.WhenAll(ordersTask, profileTask, messagesTask);
// ŁĄCZNIE: max(200, 150, 180) = 200ms — 2.6x szybciej!

return new Dashboard(await ordersTask, await profileTask, await messagesTask);

}

Uwaga — kiedy NIE używać WhenAll:

  • Gdy operacje są zależne (wynik jednej jest inputem drugiej).
  • Z DbContext w EF Core — nie jest thread-safe! Nie odpalaj wielu zapytań równolegle na tym samym DbContext. Użyj osobnych kontekstów albo sekwencyjnie.
// ❌ DbContext nie jest thread-safe — to się wywali
var t1 = _db.Orders.ToListAsync();
var t2 = _db.Users.ToListAsync();
await Task.WhenAll(t1, t2);  // InvalidOperationException!

Pułapka #5: async bez CancellationToken

Bez CancellationToken Twoje operacje nie da się anulować. Użytkownik zamyka kartę, ale serwer dalej mieli zapytanie.

// ❌ Brak możliwości anulowania
public async Task<List<Order>> SearchOrdersAsync(string query)
{
    return await _db.Orders
       .Where(o => o.Description.Contains(query))
       .ToListAsync();  // jak user anuluje — to i tak się wykona
}
// ✅ Z CancellationToken — operacja anulowalna
public async Task<List<Order>> SearchOrdersAsync(
   string query,
   CancellationToken cancellationToken = default)
{
    return await _db.Orders
       .Where(o => o.Description.Contains(query))
       .ToListAsync(cancellationToken);  // przekazany dalej!
}

W kontrolerze ASP.NET Core

ASP.NET Core automatycznie dostarcza CancellationToken, który anuluje się gdy klient rozłączy połączenie:

[HttpGet("search")]
public async Task<IActionResult> Search(
    string query,
    CancellationToken cancellationToken)  // wstrzyknięty automatycznie
{
    var results = await _service.SearchOrdersAsync(query, cancellationToken);

    return Ok(results);
}

Zasada: Każda metoda async powinna przyjmować CancellationToken i przekazywać go dalej. To znak dojrzałego, produkcyjnego kodu — i rekruterzy to doceniają.


Pułapka #6: async over sync

Owijanie synchronicznego kodu w Task.Run, żeby „był async”. To oszustwo, które pogarsza sytuację.

// ❌ Fałszywy async — Task.Run nie czyni operacji asynchroniczną
public async Task<string> ReadFileFakeAsync(string path)
{
    return await Task.Run(() => File.ReadAllText(path));
    // To NADAL blokuje wątek — tylko inny wątek z puli!
}
// ✅ Prawdziwy async I/O — system nie blokuje żadnego wątku
public async Task<string> ReadFileRealAsync(string path)
{
    return await File.ReadAllTextAsync(path);  // prawdziwe async I/O
}

Task.Run przenosi pracę na inny wątek z puli — ale ten wątek nadal jest zablokowany. W aplikacji webowej to antywzorzec: zamieniasz jeden zablokowany wątek na inny zablokowany wątek, dorzucając narzut przełączania kontekstu.

Kiedy Task.Run jest OK: Tylko dla operacji CPU-bound (intensywne obliczenia) w aplikacji desktopowej, żeby nie blokować wątku UI. Nigdy dla I/O. Nigdy w ASP.NET Core.


Pułapka #7: fire-and-forget bez obsługi błędów

Czasem chcesz uruchomić coś „w tle” bez czekania. Ale _ = SomethingAsync() gubi wyjątki.

// ❌ Fire-and-forget — wyjątek znika bez śladu
public void LogActivity(string activity)
{
    _ = SaveLogAsync(activity);  // jak rzuci wyjątek — nikt się nie dowie
}

// ✅ Fire-and-forget z obsługą błędów
public void LogActivity(string activity)
{
    _ = SaveLogSafelyAsync(activity);
}

private async Task SaveLogSafelyAsync(string activity)
{
    try
    {
        await _logRepository.SaveAsync(activity);
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "Background log failed for {Activity}", activity);
        // Obsłuż — nie pozwól wyjątkowi zniknąć
    } 
}

Lepsze rozwiązanie dla zadań w tle w ASP.NET Core: użyj IHostedService, BackgroundService, albo dedykowanej kolejki (Channel<T>, Hangfire, MassTransit). Fire-and-forget to ostateczność.

📖 Zobacz też: BackgroundService w ASP.NET Core — zadania w tle zrobione dobrze


Obsługa wyjątków w async

Wyjątki w async mają subtelności, które warto znać.

Wyjątek jest „przechowywany” w Task

public async Task<int> ThrowingAsync()
{
    await Task.Delay(10);
    throw new InvalidOperationException("Boom");
}

// Wyjątek wyrzuca się dopiero przy await:
var task = ThrowingAsync();   // tutaj NIE rzuca — Task jest faulted
// ... inny kod ...
await task;                   // tutaj rzuca InvalidOperationException

Wiele wyjątków przy WhenAll

// Task.WhenAll z wieloma błędami — await rzuca TYLKO pierwszy
try
{
    await Task.WhenAll(task1, task2, task3);
}
catch (Exception ex)
{
    // ex to PIERWSZY wyjątek. Reszta jest w task.Exception
    // Żeby dostać wszystkie:
    var allTasks = new[] { task1, task2, task3 };
    var allErrors = allTasks
        .Where(t => t.IsFaulted)
        .SelectMany(t => t.Exception!.InnerExceptions);
}

Globalna obsługa w ASP.NET Core

Nie owijaj każdej metody w try-catch. Użyj middleware:

// Program.cs — globalny handler dla wszystkich wyjątków
app.UseExceptionHandler("/error");

// Albo własny middleware z ProblemDetails
app.UseMiddleware<GlobalExceptionMiddleware>();

📖 Zobacz też: Global Exception Handling w ASP.NET Core


Checklist

Checklist async/await — dziewięć zasad produkcyjnego kodu asynchronicznego w C#
Checklist async/await — dziewięć zasad produkcyjnego kodu asynchronicznego w C#

Zanim zmergujesz kod async, sprawdź:

  • [ ] async all the way — żadnego .Result, .Wait(), .GetAwaiter().GetResult().
  • [ ] Brak async void — poza event handlerami (a te mają try-catch).
  • [ ] ConfigureAwait(false) w każdej bibliotece / pakiecie NuGet.
  • [ ] CancellationToken przyjmowany i przekazywany dalej w każdej metodzie async.
  • [ ] Task.WhenAll dla niezależnych operacji (ale NIE na jednym DbContext).
  • [ ] Prawdziwe async I/O — ReadAllTextAsync, nie Task.Run(() => ReadAllText).
  • [ ] Fire-and-forget ma try-catch albo lepiej: użyj BackgroundService.
  • [ ] Sufiks Async w nazwach metod async (GetOrderAsync, nie GetOrder).
  • [ ] Obsługa wyjątków — globalny middleware zamiast try-catch wszędzie.

Podsumowanie — 3 zasady, które zapamiętasz

  1. async all the way — gdy zaczynasz async, idź async do samego końca. Nigdy nie blokuj.
  2. await zwalnia wątek — to nie jest „czekanie”, to jest „oddanie wątku do puli na czas I/O”.
  3. CancellationToken wszędzie — każda operacja async powinna być anulowalna.

Reszta to szczegóły, które wynikają z tych trzech.


Co dalej?

async/await to fundament nowoczesnego .NET — ale to część większej układanki. Jeśli chcesz opanować backend w C# kompleksowo:

🗺️ Pobierz darmową roadmapę Junior .NET Developer — async, EF Core, ASP.NET Core w logicznej kolejności.

🎓 7 dni bezpłatnego dostępudo kursów — z praktycznymi projektami i code review.

🎬 Subskrybuj kanał YouTube — nowe odcinki co tydzień.


Dodaj komentarz