Почему я должен предпочесть одно ожидание Task.WhenAll нескольким ожиданиям?

127

В случае, если меня не волнует порядок выполнения задач и мне просто нужно, чтобы они все выполнялись, следует ли мне использовать await Task.WhenAllвместо нескольких await? например, DoWork2ниже предпочтительный метод DoWork1(и почему?):

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
источник
2
Что делать, если вы на самом деле не знаете, сколько задач вам нужно выполнить параллельно? Что делать, если вам нужно выполнить 1000 задач? Первый будет не очень читабельным await t1; await t2; ....; await tn=> второй всегда лучший выбор в обоих случаях
cuongle
Ваш комментарий имеет смысл. Я просто пытался кое-что прояснить для себя, связанный с другим вопросом, на который я недавно ответил . В этом случае было 3 задачи.
избег

Ответы:

113

Да, использовать, WhenAllпотому что он распространяет сразу все ошибки. При использовании нескольких ожиданий вы теряете ошибки, если одно из предыдущих ожиданий выбрасывает.

Еще одно важное отличие - это WhenAllожидание завершения всех задач даже при наличии сбоев (сбойных или отмененных задач). Последовательное ожидание вручную может вызвать неожиданный параллелизм, потому что часть вашей программы, которая хочет ждать, фактически продолжит работу раньше.

Я думаю, что это также упрощает чтение кода, потому что нужная вам семантика прямо задокументирована в коде.

USR
источник
9
«Потому что он распространяет сразу все ошибки» Нет, если вы awaitего результат.
svick
2
Что касается вопроса о том, как управлять исключениями с помощью Task, эта статья дает быстрое, но хорошее понимание причин, лежащих в основе этого (и, как раз так получилось, также упоминаются преимущества WhenAll в отличие от множественных ожиданий): blogs .msdn.com / b / pfxteam / archive / 28.09.2011 / 10217876.aspx
Оскар Линдберг
5
@OskarLindberg OP запускает все задачи, прежде чем он ожидает первого. Таким образом, они работают одновременно. Спасибо за ссылку.
usr
3
@usr Мне все еще было любопытно узнать, не делает ли WhenAll таких умных вещей, как сохранение того же SynchronizationContext, чтобы еще больше отодвинуть свои преимущества от семантики. Я не нашел убедительной документации, но, глядя на IL, очевидно, что в игре присутствуют разные реализации IAsyncStateMachine. Я не очень хорошо читаю IL, но, по крайней мере, WhenAll генерирует более эффективный код IL. (В любом случае, тот факт, что результат WhenAll отражает состояние всех задействованных для меня задач, является достаточной причиной, чтобы предпочесть его в большинстве случаев.)
Оскар Линдберг
17
Еще одно важное отличие состоит в том, что WhenAll будет ожидать завершения всех задач, даже если, например, t1 или t2 вызовут исключение или будут отменены.
Magnus
28

Насколько я понимаю, основной причиной предпочтения Task.WhenAllмножественных awaits является "перемешивание" производительности / задачи: DoWork1метод делает что-то вроде этого:

  • начать с заданного контекста
  • сохранить контекст
  • подожди t1
  • восстановить исходный контекст
  • сохранить контекст
  • подожди t2
  • восстановить исходный контекст
  • сохранить контекст
  • подожди t3
  • восстановить исходный контекст

Напротив, DoWork2делает это:

  • начать с заданного контекста
  • сохранить контекст
  • ждать всех t1, t2 и t3
  • восстановить исходный контекст

Достаточно ли это для вашего конкретного случая, конечно, "контекстно-зависимый" (простите за каламбур).

Марсель Попеску
источник
4
Вы, кажется, думаете, что отправка сообщения в контекст синхронизации обходится дорого. Это действительно не так. У вас есть делегат, который добавляется в очередь, эта очередь будет прочитана и делегат выполнен. Честно говоря, накладные расходы очень малы. Ничего страшного, но и небольшого размера. Затраты на асинхронные операции почти во всех случаях затмевают такие накладные расходы.
Servy
Согласен, это была единственная причина, по которой я мог придумать, что предпочитаю одно другому. Что ж, это плюс сходство с Task.WaitAll, где переключение потоков обходится дороже.
Марсель Попеску
1
@Servy Как отмечает Марсель, ДЕЙСТВИТЕЛЬНО зависит. Если вы используете await для всех задач db, например, из принципа, и этот db находится на том же компьютере, что и экземпляр asp.net, есть случаи, когда вы будете ждать попадания db, которое находится в памяти в индексе, дешевле чем этот переключатель синхронизации и тасование пула потоков. В этом сценарии с WhenAll () может быть значительная общая победа, так что ... это действительно зависит.
Крис Москини,
3
@ChrisMoschini Нет никакого способа, чтобы запрос к БД, даже если он попадает в БД, находящуюся на том же компьютере, что и сервер, не будет быстрее, чем накладные расходы на добавление нескольких делегатов в насос сообщений. Этот запрос в памяти почти наверняка будет намного медленнее.
Servy
Также обратите внимание, что если t1 медленнее, а t2 и t3 быстрее, тогда другой ожидает возврата немедленно.
Дэвид Рафаэли
18

Асинхронный метод реализован в виде конечного автомата. Можно написать методы так, чтобы они не компилировались в конечные автоматы, это часто называют ускоренным асинхронным методом. Их можно реализовать так:

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

При использовании Task.WhenAllможно сохранить этот код ускоренного отслеживания, при этом гарантируя, что вызывающий абонент сможет дождаться завершения всех задач, например:

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
источник
7

(Отказ от ответственности: этот ответ взят / вдохновлен асинхронным курсом Яна Гриффитса по TPL на Pluralsight )

Еще одна причина предпочесть WhenAll - обработка исключений.

Предположим, у вас есть блок try-catch для ваших методов DoWork, и предположим, что они вызывают разные методы 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)
    {
        // ...
    }

}

В этом случае, если все 3 задачи выдают исключения, будет обнаружена только первая. Любое последующее исключение будет потеряно. Т.е. если t2 и t3 вызывают исключение, будет перехвачен только t2; и т.д. Последующие исключения задач останутся незамеченными.

Где, как в WhenAll - в случае сбоя какой-либо или всех задач итоговая задача будет содержать все исключения. Ключевое слово await по-прежнему всегда повторно вызывает первое исключение. Таким образом, другие исключения по-прежнему остаются незамеченными. Один из способов решить эту проблему - добавить пустое продолжение после задачи WhenAll и поместить туда ожидание. Таким образом, если задача не удалась, свойство result выдаст полное исключение Aggregate Exception:

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)
    {
        // ...
    }
}
Давид Рафаэли
источник
6

Другие ответы на этот вопрос предлагают технические причины, почему await Task.WhenAll(t1, t2, t3);это предпочтение. Этот ответ будет направлен на то, чтобы взглянуть на это с более мягкой стороны (на которую ссылается @usr), но при этом прийти к тому же выводу.

await Task.WhenAll(t1, t2, t3); - это более функциональный подход, поскольку он декларирует намерение и является атомарным.

При await t1; await t2; await t3;этом ничто не мешает товарищу по команде (или, возможно, даже вам в будущем!) Добавлять код между отдельными awaitоператорами. Конечно, для этого вы сжали его до одной строки, но это не решает проблемы. Кроме того, обычно плохим тоном в командной настройке включать несколько операторов в заданную строку кода, так как это может затруднить сканирование исходного файла человеческим глазом.

Проще говоря, await Task.WhenAll(t1, t2, t3);он более удобен в обслуживании, поскольку он более четко передает ваши намерения и менее уязвим для специфических ошибок, которые могут возникнуть из-за благонамеренных обновлений кода или даже просто неудачных слияний.

rarrarrarrr
источник