Dlaczego powinienem preferować jedno zadanie „await Task.WhenAll” zamiast wielu oczekujących?

127

W przypadku, gdy nie zależy mi na kolejności wykonywania zadań i po prostu potrzebuję ich wszystkich, czy nadal powinienem używać await Task.WhenAllzamiast wielu await? np. jest DoWork2poniżej preferowanej metody DoWork1(i dlaczego?):

using System;
using System.Threading.Tasks;

namespace ConsoleApp
{
    class Program
    {
        static async Task<string> DoTaskAsync(string name, int timeout)
        {
            var start = DateTime.Now;
            Console.WriteLine("Enter {0}, {1}", name, timeout);
            await Task.Delay(timeout);
            Console.WriteLine("Exit {0}, {1}", name, (DateTime.Now - start).TotalMilliseconds);
            return name;
        }

        static async Task DoWork1()
        {
            var t1 = DoTaskAsync("t1.1", 3000);
            var t2 = DoTaskAsync("t1.2", 2000);
            var t3 = DoTaskAsync("t1.3", 1000);

            await t1; await t2; await t3;

            Console.WriteLine("DoWork1 results: {0}", String.Join(", ", t1.Result, t2.Result, t3.Result));
        }

        static async Task DoWork2()
        {
            var t1 = DoTaskAsync("t2.1", 3000);
            var t2 = DoTaskAsync("t2.2", 2000);
            var t3 = DoTaskAsync("t2.3", 1000);

            await Task.WhenAll(t1, t2, t3);

            Console.WriteLine("DoWork2 results: {0}", String.Join(", ", t1.Result, t2.Result, t3.Result));
        }


        static void Main(string[] args)
        {
            Task.WhenAll(DoWork1(), DoWork2()).Wait();
        }
    }
}
avo
źródło
2
A jeśli tak naprawdę nie wiesz, ile zadań musisz wykonywać równolegle? A co jeśli masz 1000 zadań do wykonania? Pierwsza z nich będzie mało czytelna await t1; await t2; ....; await tn=> druga jest zawsze najlepszym wyborem w obu przypadkach
cuongle
Twój komentarz ma sens. Chciałem tylko wyjaśnić sobie coś związanego z innym pytaniem, na które ostatnio odpowiedziałem . W tym przypadku były 3 zadania.
avo

Odpowiedzi:

113

Tak, używaj, WhenAllponieważ propaguje wszystkie błędy naraz. Dzięki wielokrotnym oczekiwaniom tracisz błędy, jeśli jeden z wcześniejszych rzutów oczekuje.

Inną ważną różnicą jest to, że WhenAllbędzie czekać na zakończenie wszystkich zadań, nawet w przypadku wystąpienia błędów (zadań z błędami lub anulowanych). Oczekiwanie ręcznie w sekwencji spowodowałoby nieoczekiwaną współbieżność, ponieważ część programu, która chce czekać, w rzeczywistości będzie działać wcześnie.

Myślę, że ułatwia to również czytanie kodu, ponieważ wymagana semantyka jest bezpośrednio udokumentowana w kodzie.

usr
źródło
9
„Ponieważ propaguje wszystkie błędy naraz” Nie, jeśli jesteś awaitjego wynikiem.
Svick
2
Jeśli chodzi o sposób zarządzania wyjątkami za pomocą Task, ten artykuł daje szybki, ale dobry wgląd w rozumowanie, które za nim stoi (i tak się składa, że ​​zwraca uwagę na korzyści płynące z WhenAll w porównaniu z wieloma oczekiwaniami): blogi .msdn.com / b / pfxteam / archive / 2011/09/28 / 10217876.aspx
Oskar Lindberg
5
@OskarLindberg OP rozpoczyna wszystkie zadania, zanim będzie czekał na pierwsze. Więc działają równolegle. Dzięki za link.
usr
3
@usr Byłem nadal ciekawy, czy WhenAll nie robi sprytnych rzeczy, takich jak zachowanie tego samego SynchronizationContext, aby dalej odsuwać jego zalety poza semantykę. Nie znalazłem ostatecznej dokumentacji, ale patrząc na IL widać ewidentnie różne implementacje IAsyncStateMachine w grze. Nie czytam dobrze języka IL, ale przynajmniej WhenAll wydaje się generować bardziej wydajny kod IL. (W każdym razie sam fakt, że wynik WhenAll odzwierciedla stan wszystkich związanych ze mną zadań, jest wystarczającym powodem, aby preferować go w większości przypadków.)
Oskar Lindberg
17
Inną ważną różnicą jest to, że WhenAll będzie czekać na zakończenie wszystkich zadań, nawet jeśli np. T1 lub t2 zgłoszą wyjątek lub zostaną anulowane.
Magnus,
28

Rozumiem, że głównym powodem preferowania Task.WhenAllwielu awaits jest „ubijanie” wydajności / zadań: DoWork1metoda działa mniej więcej tak:

  • zacznij od danego kontekstu
  • zapisz kontekst
  • poczekaj na t1
  • przywrócić oryginalny kontekst
  • zapisz kontekst
  • poczekaj na t2
  • przywrócić oryginalny kontekst
  • zapisz kontekst
  • poczekaj na t3
  • przywrócić oryginalny kontekst

Z kolei DoWork2robi to:

  • zacznij od danego kontekstu
  • zapisz kontekst
  • czekaj na wszystkie t1, t2 i t3
  • przywrócić oryginalny kontekst

To, czy jest to wystarczająco ważna sprawa w twoim konkretnym przypadku, jest oczywiście „zależne od kontekstu” (przepraszam za kalambur).

Marcel Popescu
źródło
4
Wydaje się, że myślisz, że wysłanie wiadomości do kontekstu synchronizacji jest kosztowne. Naprawdę nie. Masz delegata, który zostanie dodany do kolejki, ta kolejka zostanie odczytana, a delegat zostanie wykonany. Koszt, który to dodaje, jest naprawdę bardzo mały. To nie jest nic, ale też nie jest duże. Koszt jakichkolwiek operacji asynchronicznych znacznie przewyższy takie koszty w prawie wszystkich przypadkach.
Servy
Zgoda, to był jedyny powód, dla którego mogłem wymyślić jedno nad drugim. Cóż, plus podobieństwo do Task.WaitAll, gdzie przełączanie wątków jest bardziej znaczącym kosztem.
Marcel Popescu
1
@Servy Jak zauważa Marcel, NAPRAWDĘ zależy. Jeśli na przykład używasz await na wszystkich zadaniach db, a baza danych znajduje się na tym samym komputerze co instancja asp.net, są przypadki, w których będziesz oczekiwać na trafienie bazy danych, które jest w indeksie w pamięci, tańsze niż ten przełącznik synchronizacji i tasowanie puli wątków. W tego rodzaju scenariuszu może być znacząca ogólna wygrana z WhenAll (), więc ... to naprawdę zależy.
Chris Moschini,
3
@ChrisMoschini Nie ma możliwości, aby zapytanie DB, nawet jeśli trafiło do bazy danych znajdującej się na tej samej maszynie co serwer, było szybsze niż narzut związany z dodaniem kilku delegatów do pompy komunikatów. To zapytanie w pamięci nadal prawie na pewno będzie znacznie wolniejsze.
Servy
Zauważ też, że jeśli t1 jest wolniejsze, a t2 i t3 są szybsze - wtedy drugi czeka natychmiast na powrót.
David Refaeli
18

Metoda asynchroniczna jest implementowana jako maszyna stanu. Możliwe jest pisanie metod tak, aby nie były kompilowane do maszyn stanu, jest to często nazywane szybką metodą asynchroniczną. Można je zaimplementować w następujący sposób:

public Task DoSomethingAsync()
{
    return DoSomethingElseAsync();
}

Podczas korzystania Task.WhenAllmożna zachować ten kod przyspieszony, jednocześnie zapewniając, że dzwoniący jest w stanie czekać na zakończenie wszystkich zadań, np .:

public Task DoSomethingAsync()
{
    var t1 = DoTaskAsync("t2.1", 3000);
    var t2 = DoTaskAsync("t2.2", 2000);
    var t3 = DoTaskAsync("t2.3", 1000);

    return Task.WhenAll(t1, t2, t3);
}
Lukazoid
źródło
7

(Zastrzeżenie: ta odpowiedź jest zaczerpnięta / zainspirowana kursem TPL Async Iana Griffithsa w Pluralsight )

Innym powodem preferowania WhenAll jest obsługa wyjątków.

Załóżmy, że masz blok try-catch w metodach DoWork i przypuśćmy, że wywołują one różne metody DoTask:

static async Task DoWork1() // modified with try-catch
{
    try
    {
        var t1 = DoTask1Async("t1.1", 3000);
        var t2 = DoTask2Async("t1.2", 2000);
        var t3 = DoTask3Async("t1.3", 1000);

        await t1; await t2; await t3;

        Console.WriteLine("DoWork1 results: {0}", String.Join(", ", t1.Result, t2.Result, t3.Result));
    }
    catch (Exception x)
    {
        // ...
    }

}

W takim przypadku, jeśli wszystkie 3 zadania rzucają wyjątki, tylko pierwsze zostanie przechwycone. Każdy późniejszy wyjątek zostanie utracony. To znaczy, jeśli t2 i t3 rzucą wyjątek, tylko t2 zostanie przechwycony; itd. Kolejne wyjątki zadań pozostaną niezauważone.

Gdzie, tak jak w przypadku WhenAll - jeśli którekolwiek lub wszystkie zadania powodują błąd, wynikowe zadanie będzie zawierać wszystkie wyjątki. Słowo kluczowe await nadal zawsze zgłasza ponownie pierwszy wyjątek. Tak więc inne wyjątki są nadal skutecznie niezauważane. Jednym ze sposobów rozwiązania tego problemu jest dodanie pustej kontynuacji po zadaniu WhenAll i umieszczenie tam oczekiwania. W ten sposób, jeśli zadanie się nie powiedzie, właściwość result spowoduje zgłoszenie pełnego wyjątku agregacji:

static async Task DoWork2() //modified to catch all exceptions
{
    try
    {
        var t1 = DoTask1Async("t1.1", 3000);
        var t2 = DoTask2Async("t1.2", 2000);
        var t3 = DoTask3Async("t1.3", 1000);

        var t = Task.WhenAll(t1, t2, t3);
        await t.ContinueWith(x => { });

        Console.WriteLine("DoWork1 results: {0}", String.Join(", ", t.Result[0], t.Result[1], t.Result[2]));
    }
    catch (Exception x)
    {
        // ...
    }
}
David Refaeli
źródło
6

Inne odpowiedzi na to pytanie podają przyczyny techniczne await Task.WhenAll(t1, t2, t3); jest to preferowane. Ta odpowiedź będzie miała na celu spojrzenie na to z łagodniejszej strony (do której nawiązuje @usr), jednocześnie dochodząc do tego samego wniosku.

await Task.WhenAll(t1, t2, t3); jest podejściem bardziej funkcjonalnym, ponieważ deklaruje zamiar i jest atomowe.

W await t1; await t2; await t3;przypadku nic nie stoi na przeszkodzie, aby członek zespołu (a może nawet twoje przyszłe ja!) Mógł dodać kod między poszczególnymi awaitinstrukcjami. Jasne, skompresowałeś go do jednej linii, aby zasadniczo to osiągnąć, ale to nie rozwiązuje problemu. Poza tym generalnie jest to zła forma w ustawieniach zespołowych, aby zawierać wiele instrukcji w danym wierszu kodu, ponieważ może to utrudniać skanowanie pliku źródłowego dla ludzkich oczu.

Mówiąc najprościej, await Task.WhenAll(t1, t2, t3);jest łatwiejszy w utrzymaniu, ponieważ jaśniej przekazuje twoje zamiary i jest mniej podatny na osobliwe błędy, które mogą wynikać z dobrych intencji aktualizacji kodu, a nawet po prostu nieudane scalanie.

rarrarrarrr
źródło