Większość wolnych operacji w programie to czekanie: aż serwer WWW odpowie, baza danych zwróci wiersze, plik zostanie odczytany. async i await pozwalają metodzie wstrzymać się na czas takiego czekania bez trzymania wątku jako zakładnika, a potem kontynuować od miejsca, w którym przerwała. Kod nadal czyta się od góry do dołu jak zwykły kod.
Pierwsza metoda asynchroniczna
Metoda async zwraca Task (bez wyniku) albo Task<T> (wynik typu T). W jej wnętrzu await czeka na inne zadanie i daje ci jego wynik.
Wynik:
Tea 2.50, cake 4.00, total 6.50
GetPriceAsync zawiera return 2.50m, a kompilator opakowuje to w Task<decimal>, które metoda zwraca. await z powrotem to rozpakowuje. Task.Delay to asynchroniczna wersja Thread.Sleep: kończy się po upływie czasu, nie blokując w międzyczasie wątku.
Od C# 7.1 sama metoda Main może być asynchroniczna i tak napisałoby się ten program we współczesnym .NET:
static async Task Main()
{
decimal tea = await GetPriceAsync("tea");
Console.WriteLine(tea);
}
Przykłady do uruchomienia na tej stronie wywołują asynchroniczną metodę RunAsync ze zwykłego Main przez .GetAwaiter().GetResult(), czyli to, co kompilator generuje dla asynchronicznego Main. W aplikacji konsolowej jest to bezpieczne; sekcja o zakleszczeniach wyjaśnia, dlaczego to samo wywołanie jest niebezpieczne w kodzie UI.
Co się dzieje przy await
Metoda asynchroniczna działa synchronicznie aż do pierwszego await na niezakończonym zadaniu. W tym momencie zwraca kodowi wywołującemu Task, a reszta metody staje się kontynuacją, która uruchomi się po zakończeniu oczekiwanego zadania.
Wynik:
Calling DownloadAsync
Download: starting
DownloadAsync returned a task; doing other work
Download: finished
Download awaited
"Download: starting" wypisuje się, zanim DownloadAsync zwróci wartość, bo wszystko aż do pierwszego await działa w wątku kodu wywołującego. Potem metoda oddaje niezakończone zadanie, kod wywołujący działa dalej, a "Download: finished" pojawia się dopiero po upływie opóźnienia. Wywołanie metody asynchronicznej ją uruchamia; await to sposób, żeby poczekać na wynik.
async nie tworzy wątku. Gdy metoda jest wstrzymana, żaden wątek na nią nie czeka, dlatego serwer może mieć tysiące żądań czekających na bazę danych przy zaledwie kilku wątkach. Do pracy obciążającej procesor, która ma działać w innym wątku, służy Task.Run, omówione w zadaniach.
Jednoczesne operacje z Task.WhenAll
Czekanie na jedno wywołanie po drugim jest sekwencyjne: każde zaczyna się dopiero po zakończeniu poprzedniego. Gdy operacje od siebie nie zależą, uruchom je wszystkie, a potem poczekaj na nie razem przez Task.WhenAll:
Przykładowy wynik:
Sequential: 160 units in ~900 ms
Concurrent: 160 units in ~300 ms
Wersja sekwencyjna trwa tyle, ile suma trzech oczekiwań, a współbieżna mniej więcej tyle, co najwolniejsze z nich. Task.WhenAll zwraca wyniki w tej samej kolejności co przekazane zadania, niezależnie od tego, które skończyło się pierwsze. Działa też z listą, co jest typowym układem przy "pobierz każdy element": await Task.WhenAll(ids.Select(id => FetchAsync(id))).
Task.WhenAny to odpowiednik, który kończy się, gdy tylko skończy się pierwsze zadanie, przydatny przy limitach czasu i scenariuszu "wygrywa pierwsza odpowiedź".
Wyjątki w kodzie asynchronicznym
Wyjątek rzucony w metodzie asynchronicznej jest zapisywany w zwróconym zadaniu i rzucany ponownie, gdy ktoś na to zadanie czeka, więc zwykłe try/catch wokół await działa:
Wynik:
Task created, completed yet: False
Caught ArgumentOutOfRangeException for userId
WhenAll rethrew the first failure
All failures: 1
The other task still succeeded: profile 7
Wywołanie LoadProfileAsync(-1) nie rzuciło wyjątku: wyjątek siedzi w zadaniu, dopóki coś na nie nie poczeka. Zadanie, na które nikt nigdy nie czeka, całkowicie ukrywa swój wyjątek, co jest kolejnym powodem, żeby zawsze czekać na to, co uruchamiasz. Przy WhenAll await rzuca ponownie pierwszy wyjątek, a właściwość Exception połączonego zadania (typu AggregateException) przechowuje je wszystkie. Odczyt ok.Result jest tam w porządku, bo ok już się zakończyło.
async void: tylko dla procedur obsługi zdarzeń
Metoda asynchroniczna może też zwracać void. Unikaj tego wszędzie poza procedurami obsługi zdarzeń:
// Bad: the caller cannot await it or catch its exceptions.
static async void SaveAsync(Order order)
{
await db.InsertAsync(order); // if this throws, the process may crash
}
// Good: return Task, so callers can await and handle errors.
static async Task SaveAsync(Order order)
{
await db.InsertAsync(order);
}
// Acceptable: an event handler must return void.
private async void SaveButton_Click(object sender, EventArgs e)
{
try { await SaveAsync(currentOrder); }
catch (Exception ex) { ShowError(ex); }
}
Przy async void nie ma zadania, na które można poczekać, więc kod wywołujący idzie dalej, zanim praca się skończy, a wyjątek rzucony w środku jest zgłaszany bezpośrednio w kontekście synchronizacji (albo w puli wątków), gdzie nie dosięgnie go żadne try/catch po stronie wywołującej. W aplikacji konsolowej lub serwerowej zwykle kończy to proces. Wewnątrz procedury obsługi zdarzeń typu async void łap wszystkie wyjątki samodzielnie.
Pokrewny błąd to wywołanie metody asynchronicznej bez await. Kompilator ostrzega (CS4014), a metoda działa w tle i nic nie obserwuje jej wyniku ani błędów.
Zakleszczenia przez .Result i .Wait()
Blokowanie na zadaniu przez .Result lub .Wait() to miejsce, w którym kod asynchroniczny najczęściej się psuje. W aplikacji z kontekstem synchronizacji (WinForms, WPF, MAUI, klasyczny ASP.NET) sekwencja wygląda tak:
- Wątek UI wywołuje
GetDataAsync().Resulti blokuje się, czekając na zadanie. - Wewnątrz
GetDataAsynckończy się jakiśawait. Domyślnie jego kontynuacja musi działać w kontekście, w którym się zaczęła: w wątku UI. - Wątek UI jest zablokowany w kroku 1, więc kontynuacja nigdy się nie uruchamia, więc zadanie nigdy się nie kończy, więc krok 1 nigdy się nie kończy.
Aplikacja zawiesza się bez żadnego wyjątku. Aplikacje konsolowe i ASP.NET Core nie mają takiego kontekstu, dlatego ten sam kod działa w programie testowym, a zawiesza się w aplikacji desktopowej. Rozwiązania:
- Używaj
awaitna całej ścieżce wywołań zamiast blokować ("async all the way"). Procedury obsługi zdarzeń mogą w tym celu byćasync void. - W kodzie biblioteki, który nie musi wracać do kontekstu kodu wywołującego, pisz
await SomethingAsync().ConfigureAwait(false);. Kontynuacja działa wtedy w puli wątków zamiast w przechwyconym kontekście, a biblioteka unika zbędnego przełączania wątku. Chroni to blokujący kod wywołujący tylko wtedy, gdy każdyawaitw łańcuchu robi to samo, więc traktuj to jako dobrą higienę bibliotek, a nie jako lekarstwo na.Result.
Kod aplikacji w ASP.NET Core nie potrzebuje ConfigureAwait(false), bo nie ma kontekstu, do którego trzeba wracać.
Częste błędy
- Czekanie na niezależne wywołania po kolei. Uruchom je, a potem
await Task.WhenAll(...). - Metody
async void. ZwracajTask;async voidzostaw dla procedur obsługi zdarzeń. .Resulti.Wait()w kodzie UI lub klasycznym ASP.NET. Powodują zakleszczenie; zamiast tego używajawait.- Brak
await. Praca działa bez nadzoru, a jej wyjątki znikają. - Opakowywanie operacji wejścia i wyjścia w
Task.Run. Asynchroniczne API, takie jakFile.ReadAllTextAsyncczyHttpClient.GetStringAsync, już zwalnia wątek;Task.Rundodaje tylko przeskok do puli wątków. - Założenie, że
asyncznaczy "działa w innym wątku". Znaczy "może się wstrzymać bez blokowania". Do pracy obciążającej procesor używajTask.Run.
Najczęściej zadawane pytania
Jak działają async i await w C#?
Oznaczenie metody jako async pozwala jej używać await. Gdy metoda dochodzi do await na zadaniu, które jeszcze się nie zakończyło, od razu wraca do kodu wywołującego i oddaje mu Task reprezentujący resztę pracy. Gdy oczekiwana operacja się zakończy, metoda wznawia działanie za await. W czasie oczekiwania żaden wątek nie stoi zablokowany.
Czym różni się Task od Task<T>?
Task reprezentuje operację, która nie zwraca wartości, czyli asynchroniczną wersję metody void. Task<T> reprezentuje operację, która zwraca T: await na Task<int> daje ci int. Metoda zadeklarowana jako async Task<int> po prostu pisze return 42;, a kompilator opakowuje to w zadanie.
Jak uruchomić kilka operacji asynchronicznych jednocześnie?
Najpierw uruchom je wszystkie, a potem poczekaj na nie razem: var a = GetUserAsync(); var b = GetOrdersAsync(); await Task.WhenAll(a, b);. Zapis await GetUserAsync(); await GetOrdersAsync(); wykonuje je jedna po drugiej, więc łączny czas to suma, a nie czas najdłuższej z nich.
Dlaczego async void jest złe w C#?
Na metodę async void nie da się poczekać, więc kod wywołujący nie wie, kiedy się skończy, a wyjątku rzuconego w jej wnętrzu nie da się złapać po stronie wywołującej: jest zgłaszany w kontekście synchronizacji i zwykle kończy proces. Zwracaj Task. async void istnieje tylko dla procedur obsługi zdarzeń, których sygnatura wymaga void.
Dlaczego .Result lub .Wait() powoduje zakleszczenie?
W aplikacji z kontekstem synchronizacji (WinForms, WPF, klasyczny ASP.NET) .Result blokuje wątek UI lub wątek żądania, a kontynuacja oczekiwanej metody czeka na uruchomienie właśnie w tym wątku. Każde czeka na drugie w nieskończoność. Zamiast blokować, używaj await na całej ścieżce wywołań, a w kodzie bibliotek używaj ConfigureAwait(false).
Czy Main może być async w C#?
Tak, od C# 7.1: static async Task Main() albo static async Task<int> Main(). Instrukcje najwyższego poziomu (C# 9) mogą używać await bezpośrednio. W starszym kodzie odpowiednikiem jest wywołanie MainAsync().GetAwaiter().GetResult() ze zwykłego Main, co jest tam bezpieczne, bo aplikacja konsolowa nie ma kontekstu synchronizacji.