Blog Dla Programistów C#/.NET

Deadlock w C# przez .Result: jak działa async/await, SynchronizationContext i ConfigureAwait(false)

czwartek, 24 września 2026 Tagi: C#/.NETProgramowanie

Jest jedno pytanie, które słyszałem na dziesiątkach rozmów rekrutacyjnych na stanowiska .NET. Wygląda banalnie: kilka linijek kodu, metoda asynchroniczna i jedno .Result. A mimo to wykłada się na nim zdecydowana większość kandydatów - według moich obserwacji nawet 95%, łącznie z osobami, które mają w CV 5 lat doświadczenia.

Co gorsza, jeśli nie znasz odpowiedzi, to całkiem możliwe, że masz ten błąd w swoim kodzie na produkcji. Właśnie teraz. Każdy z nas używa async/await na co dzień, ale mało kto wie, co dzieje się pod spodem. Dlatego to pytanie jest idealnym filtrem: junior zna składnię, mid zna zasady, senior rozumie mechanizm. Rekruterzy to uwielbiają.

W tym artykule pokażę Ci to pytanie, odtworzymy deadlock w prostej aplikacji WinForms, a potem rozbierzemy temat na części: co naprawdę robi await, czym jest SynchronizationContext, jak krok po kroku powstaje zakleszczenie i jakie 3 zasady chronią przed nim w prawdziwym kodzie. Na koniec dostaniesz gotową, wzorcową odpowiedź na rozmowę - słowo w słowo.

Wersje .NET: opisany mechanizm nie zależy od wersji frameworka. Tak samo zachowuje się w .NET Framework, jak i we współczesnym .NET (8, 9, 10). Różnice wynikają z typu aplikacji: WinForms i WPF mają SynchronizationContext w każdej wersji, stary ASP.NET (Framework) też go ma, a ASP.NET Core nie ma go od samego początku. Dlatego ten sam kod potrafi w jednym projekcie działać latami, a w innym zawiesić aplikację przy pierwszym kliknięciu.

Deadlock w C# przez .Result: jak działa async/await, SynchronizationContext i ConfigureAwait(false)

Pytanie: co się stanie po wywołaniu tej metody?


Oto kod, który dostajesz na kartce albo na ekranie:

public string PobierzDane()
{
var task = PobierzDaneAsync();
return task.Result;
}

public async Task<string> PobierzDaneAsync()
{
await Task.Delay(1000);
return "Hello!";
}

Pytanie rekrutera jest krótkie: co się stanie po wywołaniu metody PobierzDane()?

Proste, prawda? Metoda asynchroniczna czeka sekundę i zwraca "Hello!". Wołamy ją, bierzemy .Result i zwracamy stringa. Co może pójść nie tak?

Zanim przeczytasz dalej, zatrzymaj się na chwilę i odpowiedz sobie na to pytanie. Serio, to test. Jeśli Twoja odpowiedź brzmi "po sekundzie zwróci Hello!", to niestety właśnie trafiasz do większości, która na tym pytaniu odpada.

3 poziomy odpowiedzi: junior, mid i senior


Odpowiedzi kandydatów da się podzielić na 3 poziomy:

• Junior: "Zwróci Hello! po sekundzie." Nie zawsze.

• Mid: ".Result blokuje wątek, to zła praktyka." Prawda, ale dlaczego?

• Senior: "W WPF, WinForms i starym ASP.NET będzie deadlock. W ASP.NET Core i aplikacji konsolowej zadziała. I wiem dlaczego."

I tu jest cały haczyk: poprawna odpowiedź brzmi "to zależy" - ale musisz umieć powiedzieć, od czego. Ten sam kod:

• w aplikacji konsolowej zadziała,
• w ASP.NET Core zadziała, choć zmarnuje wątek,
• w WPF, WinForms albo starym ASP.NET Framework zawiesi aplikację na zawsze.

Zwróć uwagę na ostatni przypadek: to nie jest wyjątek ani crash. To wieczne zamrożenie.

Dlaczego "zmarnuje wątek" to też problem? W ASP.NET Core ten kod nie zrobi deadlocka, ale .Result trzyma wątek z puli przez całą sekundę, w której ten wątek nic nie robi, tylko czeka. Przy jednym żądaniu nikt tego nie zauważy. Przy dużym ruchu takich zablokowanych wątków robi się jednak dużo, pula wątków nie nadąża z dokładaniem nowych, żądania czekają w kolejce i aplikacja zaczyna zwalniać albo przestaje odpowiadać. To zjawisko nazywa się thread pool starvation (zagłodzenie puli wątków) i bywa bardzo trudne do namierzenia, bo na lokalnej maszynie wszystko działa.

Rekruter nie zadaje tego pytania po to, żeby usłyszeć wyrecytowaną regułkę. Chce sprawdzić, czy rozumiesz, jak async/await działa pod maską. Najlepiej zobaczyć to na żywym przykładzie.

Demo: jak jedno .Result zamraża aplikację WinForms


Weźmy najprostszy możliwy projekt WinForms: 1 przycisk i 1 etykietę. W handlerze kliknięcia wołamy metodę asynchroniczną:

private void btnPobierz_Click(object sender, EventArgs e)
{
var task = PobierzDaneAsync();
lblWynik.Text = task.Result; /* tu aplikacja zamarza */
}

private async Task<string> PobierzDaneAsync()
{
await Task.Delay(1000);
return "Hello!";
}

Klasyka. Ktoś dostał metodę asynchroniczną, ale jego handler jest synchroniczny, więc "na szybko" dokleja .Result. Widziałem to w prawdziwych projektach więcej razy, niż chciałbym pamiętać.

Uruchamiasz aplikację i klikasz przycisk. Aplikacja zamarza. Próbujesz przesunąć okno - nic. Po chwili Windows dopisuje do paska tytułu "(Brak odpowiedzi)".

I to nie jest sytuacja, w której "aplikacja chwilę myśli". Ona nigdy z tego nie wyjdzie. Task.Delay miał trwać 1 sekundę, a minęło już 10. Możesz czekać do jutra. To jest deadlock (zakleszczenie): 2 kawałki kodu czekają na siebie nawzajem i żaden nie może ruszyć dalej.

Żeby zrozumieć, dlaczego tak się dzieje, trzeba zobaczyć, co kompilator robi z await. I to jest dokładnie ta wiedza, która oddziela najlepszych kandydatów od reszty.

Co naprawdę robi await? Nie czeka, tylko zwalnia wątek


Większość programistów ma w głowie mniej więcej taki obraz:

• Co myśli większość: await zatrzymuje wątek, aż task się skończy.
• Co dzieje się naprawdę: await zwalnia wątek i mówi: "wróć tu, jak task się skończy".

Jeśli masz zapamiętać z tego artykułu 1 zdanie, niech to będzie to: await nie czeka. Await zwalnia wątek.

Kompilator tnie Twoją metodę na pół. Kiedy kompilator widzi await, zamienia metodę w maszynę stanów. Wszystko przed await to pierwszy stan, który wykonuje się od razu. Wszystko po await to kontynuacja, która wykona się później - wtedy, gdy oczekiwany task się zakończy:

public async Task<string> PobierzDaneAsync()
{
/* CZĘŚĆ 1: wykonuje się od razu */
await Task.Delay(1000); /* tu metoda zwalnia wątek */

/* CZĘŚĆ 2: kontynuacja - wykona się PÓŹNIEJ */
return "Hello!";
}

Gdy wykonanie dochodzi do await, a Task.Delay jeszcze się nie zakończył, metoda od razu zwraca wywołującemu niedokończony Task i oddaje wątek. W praktyce mówi: "zwalniam wątek, dokończcie mnie, jak Delay się skończy".

Jeśli łatwiej Ci myśleć w kodzie, w dużym uproszczeniu wygląda to mniej więcej tak (to model myślowy, a nie dokładny kod generowany przez kompilator):

/* w dużym uproszczeniu - model myślowy, nie prawdziwy kod kompilatora */
public Task<string> PobierzDaneAsync()
{
var wynik = new TaskCompletionSource<string>();

/* CZĘŚĆ 1: wykonuje się od razu */
var kontekst = SynchronizationContext.Current; /* await zapamiętuje kontekst */
Task.Delay(1000).ContinueWith(_ =>
{
/* CZĘŚĆ 2: kontynuacja wraca do zapamiętanego kontekstu, jeśli jakiś był */
if (kontekst != null)
kontekst.Post(__ => wynik.SetResult("Hello!"), null);
else
wynik.SetResult("Hello!");
});

return wynik.Task; /* wywołujący od razu dostaje niedokończony Task */
}

No dobrze, ale skoro wątek został zwolniony, to kto i gdzie wykona drugą część metody? Tu wchodzi bohater drugiego planu, o którym większość kandydatów nigdy nie słyszała: SynchronizationContext.

SynchronizationContext - kto dokończy robotę? To mechanizm, który decyduje, gdzie mają się wykonać kontynuacje. To, czy w ogóle istnieje, zależy od typu aplikacji:

ŚrodowiskoSynchronizationContextKontynuacja wraca na...
WPF / WinFormsjestwątek UI (ten jeden jedyny)
Stary ASP.NET (Framework)jestkontekst requestu
ASP.NET Corebrakdowolny wątek z puli
Aplikacja konsolowabrakdowolny wątek z puli


Domyślnie await zapamiętuje kontekst, w którym wystartował, i tam wraca z kontynuacją. W WinForms i WPF ma to dużo sensu: po await zwykle aktualizujesz interfejs, a kontrolek UI można dotykać tylko z wątku UI. Dzięki temu linijka lblWynik.Text = ... po await po prostu działa i nie musisz ręcznie przełączać się na właściwy wątek.

Problem w tym, że właśnie ta "uprzejmość" awaita w połączeniu z .Result tworzy pułapkę. Złóżmy to w całość.

Anatomia deadlocka krok po kroku


Prześledźmy, co dzieje się po kliknięciu przycisku z naszego dema:

1. Wątek UI woła PobierzDaneAsync() i dochodzi do await Task.Delay(1000).
2. await zapamiętuje: "kontynuacja ma wrócić na wątek UI".
3. Metoda zwraca niedokończony Task, sterowanie wraca do handlera i wykonuje się task.Result.
4. .Result blokuje wątek UI - wątek stoi i czeka na wynik.
5. Sekundę później Delay się kończy, a kontynuacja (return "Hello!") ustawia się w kolejce do wątku UI.
6. Wątek UI czeka na task. Task czeka na wątek UI. Koniec.

Każdy czeka na każdego. Na zawsze.

Przeczytaj to jeszcze raz powoli, bo to serce całego zagadnienia. Wątek UI stoi zablokowany na .Result i czeka, aż task się skończy. Ale task, żeby się skończyć, musi wykonać kontynuację, czyli return "Hello!". A kontynuacja, przez SynchronizationContext, może wykonać się tylko na wątku UI. Tym samym, który stoi zablokowany. Wątek czeka na task, task czeka na wątek. Klasyczny uścisk śmierci.

Przy okazji wyjaśnia się też "(Brak odpowiedzi)" na pasku tytułu. W WinForms kontynuacja trafia do kolejki komunikatów okna - tej samej, z której wątek UI obsługuje kliknięcia, przesuwanie i odrysowywanie. Skoro wątek UI stoi na .Result, nikt tej kolejki nie przetwarza: ani kontynuacji, ani niczego innego. Windows widzi okno, które przestało odbierać komunikaty, i oznacza je jako zawieszone.

I teraz już rozumiesz, czemu w konsoli i ASP.NET Core ten sam kod działa. Nie ma tam SynchronizationContextu, więc kontynuacja wykonuje się na dowolnym wolnym wątku z puli i nie ma na kogo czekać w kółko. Kod jest tak samo zły, bo blokuje wątek, ale akurat nie zabija aplikacji.

W starym ASP.NET mechanizm jest analogiczny, tylko zamiast wątku UI mamy kontekst requestu, w którym naraz może działać tylko 1 wątek. Request stoi na .Result, kontynuacja czeka, aż kontekst się zwolni, a żądanie po prostu wisi.

Jak to naprawić: 3 zasady


Skoro wiesz już, skąd bierze się deadlock, naprawa jest prosta. Wystarczą 3 zasady.

Zasada 1: async na całej ścieżce. Zamiast blokować wątek, zrób handler asynchronicznym:

/* było - deadlock */
private void btnPobierz_Click(object sender, EventArgs e)
{
lblWynik.Text = PobierzDaneAsync().Result;
}

/* jest - async na całej ścieżce */
private async void btnPobierz_Click(object sender, EventArgs e)
{
lblWynik.Text = await PobierzDaneAsync();
}

Skoro coś w środku jest asynchroniczne, to asynchroniczna musi być cała ścieżka wywołań - od handlera po sam dół. Żadnego .Result, żadnego .Wait(). Po tej zmianie kliknięcie działa tak, jak powinno: okno przez cały czas reaguje, a po sekundzie w etykiecie pojawia się "Hello!". Wątek UI nie jest blokowany, więc kontynuacja ma gdzie wrócić.

Mała uwaga: async void jest dopuszczalny wyłącznie w handlerach zdarzeń, jak tutaj. Wszędzie indziej używaj async Task. Metody async void nie da się awaitować, a wyjątku z niej nie złapiesz zwykłym try/catch u wywołującego - trafia prosto do kontekstu synchronizacji i potrafi ubić cały proces.

Zasada 2: ConfigureAwait(false) w bibliotekach. Druga linia obrony jest po stronie metody asynchronicznej:

public async Task<string> PobierzDaneAsync()
{
await Task.Delay(1000).ConfigureAwait(false);
return "Hello!";
}

ConfigureAwait(false) mówi awaitowi: "nie wracaj do kontekstu, dokończ na dowolnym wątku". Pisz tak w kodzie bibliotecznym, czyli tam, gdzie nie dotykasz UI. Nawet jeśli ktoś gdzieś wyżej zrobi .Result, nie będzie deadlocka, bo kontynuacja nie będzie się pchać na zablokowany wątek.

Przy każdym await, nie tylko przy pierwszym. Jeśli task jest już zakończony w momencie await, ConfigureAwait(false) niczego nie zmienia i metoda dalej działa w oryginalnym kontekście. Wtedy kolejny await bez ConfigureAwait(false) znowu złapie kontekst UI. Dlatego w bibliotece dawaj go konsekwentnie wszędzie:

public async Task<string> PobierzStroneAsync(string url)
{
/* ConfigureAwait(false) przy KAŻDYM await, nie tylko przy pierwszym */
var odpowiedz = await _httpClient.GetAsync(url).ConfigureAwait(false);
odpowiedz.EnsureSuccessStatusCode();
return await odpowiedz.Content.ReadAsStringAsync().ConfigureAwait(false);
}

Nie w kodzie, który po await dotyka UI. W handlerze z zasady 1 ConfigureAwait(false) byłby błędem: kontynuacja wykonałaby się poza wątkiem UI, a ustawianie lblWynik.Text z innego wątku to proszenie się o kłopoty. Tam chcesz, żeby await wrócił do kontekstu.

A co z ASP.NET Core? W kodzie aplikacji ASP.NET Core nie ma kontekstu, więc ConfigureAwait(false) niczego tam nie zmienia. W bibliotekach dalej warto go używać, bo nie wiesz, czy ktoś nie wywoła ich z WPF albo WinForms.

Plaster, nie lekarstwo. Ciekawostka: jeśli w naszym demie dodasz ConfigureAwait(false) w PobierzDaneAsync, a w handlerze zostawisz .Result, deadlock faktycznie zniknie. Ale to plaster, a nie rozwiązanie. Wątek UI dalej stoi zablokowany przez całą sekundę, więc okno na ten czas i tak zamarza. Do tego bezpieczeństwo zależy teraz od tego, czy każda metoda w dół stosu wywołań ma ConfigureAwait(false) przy każdym await. Wystarczy jedna zapomniana i deadlock wraca.

Zasada 3: .Result i .Wait() traktuj jak code smell. Jeśli widzisz je w code review, zapytaj, dlaczego tam są. Prawie zawsze to czyjeś "na szybko", które kiedyś wybuchnie. W nowym kodzie po prostu ich nie używaj.

To samo dotyczy popularnego .GetAwaiter().GetResult(). Blokuje wątek dokładnie tak samo jak .Result i tak samo prowadzi do deadlocka. Różni się tylko tym, że rzuca oryginalny wyjątek zamiast opakowanego w AggregateException.

Nie musisz też wyłapywać tego wyłącznie wzrokiem. Analizatory zrobią to za Ciebie: pakiet Microsoft.VisualStudio.Threading.Analyzers zgłasza synchroniczne czekanie na taski (reguła VSTHRD002) i metody async void (VSTHRD100), a wbudowana w .NET SDK reguła CA2007 (domyślnie wyłączona) przypomina o ConfigureAwait przy każdym await - przydatne w projektach bibliotek.

Co, jeśli naprawdę nie możesz pójść w async?


Czasem pada argument: "ale tu nie mogę zrobić async". Najczęściej chodzi o jedną z 3 sytuacji.

Metoda Main w aplikacji konsolowej. Od C# 7.1 Main może być asynchroniczny, więc .Result w Main nie jest już potrzebny:

static async Task Main(string[] args)
{
var dane = await PobierzDaneAsync();
Console.WriteLine(dane);
}

A jeśli używasz top-level statements (od C# 9), możesz pisać await bezpośrednio w Program.cs.

Konstruktor. Konstruktor nie może być async, więc kusi, żeby wywołać w nim metodę asynchroniczną z .Result. Zamiast tego zrób prywatny konstruktor i statyczną, asynchroniczną metodę fabrykującą:

public class Raport
{
private readonly string _dane;

private Raport(string dane) => _dane = dane;

public static async Task<Raport> UtworzAsync(IZrodloDanych zrodlo)
{
var dane = await zrodlo.PobierzDaneAsync();
return new Raport(dane);
}
}

/* użycie */
var raport = await Raport.UtworzAsync(zrodlo);

Synchroniczny interfejs, którego nie możesz zmienić. Na przykład implementujesz interfejs z zewnętrznej biblioteki, który wymaga synchronicznej metody, a w środku musisz zawołać kod asynchroniczny. Jeśli naprawdę nie ma innego wyjścia, ostatecznością jest:

/* ostateczność - świadomy wyjątek, a nie wzorzec */
var dane = Task.Run(() => PobierzDaneAsync()).GetAwaiter().GetResult();

Task.Run uruchamia metodę na wątku z puli, gdzie nie ma SynchronizationContextu, więc kontynuacje nie pchają się na zablokowany wątek i deadlocka nie będzie. Ale to wciąż blokowanie: wywołujący wątek stoi dokładnie tak samo jak przy .Result. Traktuj to jako świadomy, opisany komentarzem wyjątek, a nie sposób na wygodne obejście zasady 1.

Wzorcowa odpowiedź na rozmowie rekrutacyjnej


Teraz możesz złożyć wszystko w jedną odpowiedź. Powiedz to na rozmowie (i obserwuj minę rekrutera):

"To zależy od środowiska. W WPF, WinForms i starym ASP.NET będzie deadlock: .Result blokuje wątek z SynchronizationContextem, a kontynuacja po await chce wrócić dokładnie na ten zablokowany wątek - czekają na siebie nawzajem. W ASP.NET Core i aplikacji konsolowej kod zadziała, bo nie ma tam SynchronizationContextu - ale i tak jest zły, bo synchronicznie blokuje wątek. Poprawne rozwiązanie to async na całej ścieżce wywołań, a w kodzie bibliotecznym dodatkowo ConfigureAwait(false)."

4 zdania. Pokazujesz, że znasz mechanizm, znasz różnice między środowiskami i znasz rozwiązanie. To jest odpowiedź z górnych 5%.

Pytania dodatkowe, na które warto być gotowym. Dobry rekruter rzadko kończy na jednym pytaniu. Po takiej odpowiedzi możesz usłyszeć dopytanie:

• "Dlaczego w ASP.NET Core nie ma deadlocka, skoro kod jest zły?" Bo nie ma tam SynchronizationContextu - kontynuacja wykonuje się na dowolnym wątku z puli. Kod dalej jednak blokuje wątek, a pod dużym obciążeniem grozi to zagłodzeniem puli wątków.

• "Czym .GetAwaiter().GetResult() różni się od .Result?" Tylko sposobem zgłaszania wyjątków: rzuca oryginalny wyjątek zamiast AggregateException. Blokuje tak samo i tak samo może doprowadzić do deadlocka.

• "Kiedy async void jest w porządku?" Tylko w handlerach zdarzeń. Wszędzie indziej async Task, bo async void nie da się awaitować, a wyjątki z niego potrafią ubić proces.

• "Czy w ASP.NET Core trzeba pisać ConfigureAwait(false)?" W kodzie aplikacji nie ma to znaczenia, bo nie ma tam kontekstu. W bibliotekach tak, bo nie wiesz, kto i skąd je wywoła.

Ściąga: zamiast → lepiej


Na koniec najważniejsze zasady w jednym miejscu - przydadzą się zarówno przed rozmową, jak i podczas code review:

ZamiastLepiejDlaczego
task.Result, task.Wait()await tasknie blokuje wątku, więc kontynuacja ma gdzie wrócić
.GetAwaiter().GetResult()await taskblokuje tak samo jak .Result, inaczej rzuca tylko wyjątek
synchroniczny handler z .Resultasync void handler z awaithandler zdarzenia to jedyne miejsce na async void
async void w zwykłej metodzieasync Taskda się ją awaitować i złapać z niej wyjątek
await w kodzie bibliotekiawait ... .ConfigureAwait(false)kontynuacja nie wraca na (potencjalnie zablokowany) wątek UI
.Result w metodzie Mainstatic async Task Mainod C# 7.1 Main może być asynchroniczny
.Result w konstruktorzestatyczna metoda UtworzAsync()konstruktor nie może być async


Podsumowanie


Całe pytanie sprowadza się do 4 rzeczy, które warto mieć w głowie na każdej rozmowie (i w każdym code review):

• await zwalnia wątek, a nie go blokuje.
• Kontynuacja domyślnie wraca do SynchronizationContextu.
• .Result + kontekst UI = deadlock.
• Leczenie: async na całej ścieżce + ConfigureAwait(false) w bibliotekach.

To jest dokładnie ten typ wiedzy, który robi różnicę na rozmowie. Nie kolejny framework, nie kolejna biblioteka - tylko zrozumienie, co Twój kod robi naprawdę. A jeśli w Twoim projekcie gdzieś siedzi .Result albo .Wait(), to dobry moment, żeby go poszukać, zanim sam da o sobie znać na produkcji.

Takich pytań-pułapek jest w .NET więcej i regularnie je rozbieram. Jeśli nie chcesz ich przegapić, dołącz do mojej społeczności .NET - jest nas tam już ponad 10 000 programistów. Raz w tygodniu wysyłam jednego konkretnego maila: najważniejsze newsy ze świata .NET, smaczki takie jak ten z dzisiejszego artykułu i rabaty na moje kursy, których nie znajdziesz nigdzie indziej. Bez lania wody i zero spamu - wypisujesz się jednym kliknięciem.

Jeśli chcesz dołączyć, zostaw swój adres tutaj: modestprogrammer.pl/vip. Powodzenia na najbliższej rozmowie :)

Autor artykułu:
Kazimierz Szpin
Kazimierz Szpin
CTO & Founder - FindSolution.pl
Programista C#/.NET. Specjalizuje się w Blazor, ASP.NET Core, ASP.NET MVC, ASP.NET Web API, WPF oraz Windows Forms.
Autor bloga ModestProgrammer.pl
Dodaj komentarz
© Copyright 2026 modestprogrammer.pl | Sztuczna Inteligencja | Regulamin | Polityka prywatności. Design by Kazimierz Szpin. Wszelkie prawa zastrzeżone.