Task to obiekt, który reprezentuje trwającą pracę. Może działać na wątku z puli, czekać na timer albo na odpowiedź z sieci; w każdym przypadku możesz zapytać, czy się zakończył, pobrać jego wynik, poczekać na niego albo dołączyć do niego kolejną pracę. async i await to składnia języka nad taskami. Ta strona omawia samo API Task oraz wątki.
Task.Run: praca w puli wątków
Task.Run przyjmuje lambdę i kolejkuje ją w puli wątków, czyli zestawie wątków, które .NET trzyma w gotowości, żeby nie tworzyć nowego dla każdego zadania. Zwraca Task albo Task<T>, jeśli lambda zwraca wartość.
Wynik:
Started, completed yet: False
Primes up to 200,000: 17984
Status: RanToCompletion
Result blokuje wątek wywołujący, dopóki task się nie zakończy. W Main programu konsolowego to akceptowalne, ale w kodzie asynchronicznym pisze się int primes = await task;, co czeka bez blokowania. To samo dotyczy task.Wait() dla Task bez wyniku.
Task.Run służy do pracy obciążającej procesor: obliczeń, które zajmują rdzeń. Do wejścia-wyjścia, takiego jak czytanie pliku albo wywołanie web API, użyj metody asynchronicznej, którą biblioteka już udostępnia (File.ReadAllTextAsync, HttpClient.GetStringAsync), i czekaj na nią bezpośrednio przez await. Dopóki operacja wejścia-wyjścia trwa, asynchroniczne wywołanie z await nie zajmuje żadnego wątku, więc opakowanie go w Task.Run nic nie daje i tylko dodaje przeskok przez pulę wątków. Przypadek, który rzeczywiście blokuje wątek z puli, to Task.Run wokół synchronicznego blokującego wywołania, takiego jak File.ReadAllText: ten wątek bezczynnie czeka, aż odpowie dysk albo sieć.
Kilka tasków naraz: WhenAll
Podziel niezależną pracę na taski, uruchom je wszystkie i połącz wyniki przez Task.WhenAll. Na maszynie z kilkoma rdzeniami części działają równolegle.
Wynik:
Parts: 14999995, 14999999, 15000003, 15000000
Total: 59999997
Każdy fragment pracuje na własnym zakresie i zwraca własną sumę, więc taski niczego nie współdzielą i nie potrzebują blokad. WhenAll zwraca wyniki w kolejności tasków, niezależnie od tego, w jakiej kolejności skończyły wątki. Gdy taski muszą aktualizować współdzielony stan, zobacz lock.
WhenAny i limity czasu
Task.WhenAny kończy się, gdy zakończy się pierwszy z jego tasków, i zwraca ten task. Ściganie prawdziwej pracy z Task.Delay daje limit czasu:
Wynik:
fast mirror answered after 100 ms
Timed out after 300 ms
WhenAny zwraca sam zwycięski task, a nie jego wynik, więc czekasz na niego ponownie przez await, żeby dostać wartość (albo wyjątek, jeśli się nie powiódł). Pamiętaj, że przegrane taski działają dalej; WhenAny ich nie zatrzymuje. Żeby naprawdę zatrzymać wolną pracę, anuluj ją tokenem, jak pokazano niżej. W .NET 6 i nowszych await work.WaitAsync(TimeSpan.FromMilliseconds(300)) wyraża ten sam limit czasu w jednym wywołaniu.
Kontynuacje z ContinueWith
Przed await kod uruchamiany po zakończeniu taska zapisywało się przez ContinueWith. Nadal spotkasz to w starszym kodzie:
Wynik:
Report total: 42
Każda kontynuacja otrzymuje poprzedni task i uruchamia się, gdy ten się zakończy. await robi to samo ze znacznie mniejszym narzutem, naturalnie obsługuje wyjątki i wraca do właściwego kontekstu, więc w nowym kodzie wybieraj właśnie jego. ContinueWith uruchamia się nawet wtedy, gdy poprzedni task się nie powiódł albo został anulowany, więc starszy kod musi sprawdzać t.IsFaulted albo przekazywać TaskContinuationOptions, żeby nie wywołać .Result na nieudanym tasku.
Anulowanie przez CancellationToken
Taski anuluje się kooperacyjnie. CancellationTokenSource wydaje token; kod wykonujący pracę sprawdza token i sam się zatrzymuje. Nic nie jest przerywane siłą.
Wynik:
Cancelled before finishing all 10 orders
Processed 3
Token jest przekazywany w dół przez każdą warstwę, a asynchroniczne API, takie jak Task.Delay, HttpClient i wywołania bazy danych, też go przyjmują, więc anulowanie zatrzymuje je w trakcie czekania. Łap OperationCanceledException; TaskCanceledException, który rzucają niektóre API, dziedziczy po nim. CancellationTokenSource implementuje IDisposable, stąd using.
Thread a Task
Thread to wątek systemu operacyjnego, który tworzysz sam. Jest cięższy: każdy ma własny stos (domyślnie 1 MB w Windows), jego uruchomienie zajmuje zauważalny czas i nie daje ci wyniku ani obsługi wyjątków.
Wynik:
Thread started
Result: 42
Thread | Task | |
|---|---|---|
| Działa na | Nowym dedykowanym wątku systemowym | Wątku z puli (albo bez wątku, przy wejściu-wyjściu) |
| Koszt uruchomienia | Wysoki | Niski |
| Wartość wyniku | Nie, współdziel zmienną | Task<T>.Result / await |
| Wyjątki | Nieobsłużone wyłączają proces | Przechowywane w tasku, rzucane ponownie przy await |
| Anulowanie | Ręczne flagi | Wbudowany CancellationToken |
| Czekanie na zakończenie | Join() | await, Wait(), WhenAll |
Po Thread sięgaj tylko wtedy, gdy potrzebujesz czegoś, czego pula nie daje: długo działającej pętli, która nigdy nie powinna pożyczać wątku z puli, konkretnego priorytetu albo jednowątkowego apartamentu (STA) dla COM. Dla długo działających tasków Task.Factory.StartNew(work, TaskCreationOptions.LongRunning) dostaje dedykowany wątek, zachowując API tasków.
Parallel.For i Parallel.ForEach
Do zadania "zrób to z każdym elementem, używając wszystkich rdzeni" System.Threading.Tasks.Parallel jest prostszy niż ręczne tworzenie tasków. Dzieli zakres między wątki i kończy się, gdy wszystkie iteracje są gotowe:
Przykładowy wynik:
image 4 on thread 7
image 3 on thread 6
image 5 on thread 8
image 0 on thread 1
image 1 on thread 4
image 2 on thread 5
Total pixels: 385425
Linie dla poszczególnych obrazów pojawiają się w takiej kolejności, w jakiej wykonały je wątki, a identyfikatory wątków zmieniają się między uruchomieniami; stała jest tylko suma. Taka jest natura pętli równoległych: iteracje nie mogą zależeć od wzajemnej kolejności, a każdy współdzielony stan wymaga synchronizacji (tutaj Interlocked.Add). Parallel.ForEach robi to samo na dowolnej kolekcji, a .NET 6 dodaje Parallel.ForEachAsync dla asynchronicznych ciał pętli.
Częste błędy
.Resultalbo.Wait()w kodzie asynchronicznym. Blokuje wątek i w aplikacjach z interfejsem może spowodować zakleszczenie; zamiast tego użyjawait.- Łapanie oryginalnego typu wyjątku wokół
.Result. Błędy przychodzą opakowane wAggregateException;awaitje rozpakowuje. - Uruchom i zapomnij.
Task.Run(...)bez await i bez zapisania taska gubi jego wyjątki. Task.Runwokół wejścia-wyjścia. Wokół metody asynchronicznej dodaje tylko przeskok przez pulę wątków; wokół metody blokującej, takiej jakFile.ReadAllText, blokuje wątek z puli na cały czas oczekiwania. Czekaj bezpośrednio przez await na asynchroniczną metodę wejścia-wyjścia.- Ignorowanie tokenu. Przekazanie
CancellationTokennic nie daje, jeśli praca go nie sprawdza. - Niesynchronizowany współdzielony stan w kodzie równoległym. Użyj
Interlocked,lockalbo daj każdemu taskowi własne dane.
Najczęściej zadawane pytania
Czym jest Task w C#?
Task reprezentuje operację, która może zakończyć się w przyszłości: kod wykonywany w puli wątków, timer, żądanie wejścia-wyjścia. Można na niego czekać przez await, informuje, czy zakończył się sukcesem, błędem, czy został anulowany, a Task<T> niesie też wynik. Taski są podstawą, na której działa async/await.
Jaka jest różnica między Task a Thread w C#?
Thread to wątek systemu operacyjnego, który tworzysz i którym zarządzasz sam, z własnym stosem; jego uruchomienie jest kosztowne i nie zwraca wartości. Task opisuje pracę, a Task.Run planuje ją we współdzielonej puli wątków wielokrotnego użytku; może zwrócić wynik, przekazać wyjątki, zostać anulowany i można na niego czekać przez await. Używaj tasków, chyba że potrzebujesz dedykowanego, długo żyjącego wątku ze specjalnymi ustawieniami.
Kiedy używać Task.Run?
Do pracy obciążającej procesor, którą chcesz przenieść z bieżącego wątku: przetwarzania obrazów, dużych obliczeń, parsowania dużego pliku w pamięci, zwłaszcza żeby interfejs pozostał responsywny. Nie opakowuj wejścia-wyjścia w Task.Run: asynchroniczne metody wejścia-wyjścia, takie jak ReadAllTextAsync, już zwalniają wątek na czas oczekiwania.
Jak anulować Task w C#?
Utwórz CancellationTokenSource, przekaż jego Token do pracy i wywołaj na źródle Cancel() (albo CancelAfter(timeout)). Praca musi współpracować: sprawdzaj token.IsCancellationRequested albo wywołuj token.ThrowIfCancellationRequested() w pętli i przekazuj token dalej do asynchronicznych API, takich jak Task.Delay. Czekanie przez await na anulowany task rzuca OperationCanceledException.
Jaka jest różnica między Task.WhenAll a Task.WhenAny?
Task.WhenAll kończy się, gdy zakończy się każdy task, i daje ci wszystkie wyniki. Task.WhenAny kończy się, gdy tylko zakończy się pierwszy task, i zwraca ten task. WhenAny to typowy sposób na dodanie limitu czasu: ścigaj pracę z Task.Delay.
Dlaczego Task.Result rzuca AggregateException?
.Result i .Wait() opakowują każdy błąd w AggregateException, bo task może w ogólności przechowywać kilka wyjątków. await go rozpakowuje i bezpośrednio rzuca ponownie pierwszy wewnętrzny wyjątek, co jest jednym z powodów, żeby wybierać await. Jeśli musisz blokować, .GetAwaiter().GetResult() też rzuca oryginalny wyjątek.