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

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

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?
| Typ | Kiedy używać | Przykład |
| Task | Metoda 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() |
| void | NIGDY (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

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
- GetData() wywołuje GetDataAsync() i blokuje wątek UI przez .Result.
- GetDataAsync() dochodzi do await Task.Delay(1000).
- Domyślnie await zapamiętuje SynchronizationContext (w WPF = wątek UI) i chce na niego wrócić po zakończeniu.
- Po sekundzie Task.Delay się kończy — kontynuacja chce wrócić na wątek UI.
- Ale wątek UI jest zablokowany przez .Result z kroku 1.
- 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

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) 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)
| Kontekst | ConfigureAwait(false)? | Dlaczego |
| Biblioteka / NuGet package | ✅ ZAWSZE | Nie wiesz, kto Cię wywoła — może mieć SynchronizationContext |
| Kod aplikacyjny ASP.NET Core | ➖ Niepotrzebne | Brak SynchronizationContext — nie ma na co wracać |
| WPF / WinForms code-behind | ❌ Często NIE | Po 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

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

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
- async all the way — gdy zaczynasz async, idź async do samego końca. Nigdy nie blokuj.
- await zwalnia wątek — to nie jest „czekanie”, to jest „oddanie wątku do puli na czas I/O”.
- 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ń.

