Por que devo preferir um único 'aguarde Task.WhenAll' em vez de vários aguarda?

127

Caso eu não me importe com a ordem de conclusão da tarefa e precise apenas de todos para concluir, ainda devo usar em await Task.WhenAllvez de vários await? por exemplo, está DoWork2abaixo de um método preferido para DoWork1(e por quê?):

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
fonte
2
E se você não souber quantas tarefas precisa fazer paralelamente? E se você tiver 1000 tarefas que precisam ser executadas? O primeiro não será muito legível await t1; await t2; ....; await tn=> o segundo é sempre a melhor escolha em ambos os casos
cuongle
Seu comentário faz sentido. Eu só estava tentando esclarecer algo para mim, relacionado a outra pergunta que respondi recentemente . Nesse caso, havia três tarefas.
precisa saber é

Respostas:

113

Sim, use WhenAllporque propaga todos os erros de uma só vez. Com o múltiplo aguarda, você perde erros se um dos anteriores aguarda arremessos.

Outra diferença importante é que WhenAllaguardará a conclusão de todas as tarefas, mesmo na presença de falhas (tarefas com falha ou canceladas). Aguardar manualmente em sequência causaria simultaneidade inesperada, porque a parte do seu programa que deseja aguardar continuará mais cedo.

Eu acho que também facilita a leitura do código, porque a semântica que você deseja está diretamente documentada no código.

usr
fonte
9
"Porque propaga todos os erros de uma só vez" Não se você awaitresultar.
svick
2
Quanto à questão de como as exceções são gerenciadas com o Task, este artigo fornece uma visão rápida, mas boa, do raciocínio por trás dele (e também faz uma anotação rápida dos benefícios do WhenAll, em contraste com vários espera): blogs .msdn.com / b / pfxteam / archive / 2011/09/28 / 10217876.aspx
Oskar Lindberg
5
@OskarLindberg, o OP está iniciando todas as tarefas antes de aguardar a primeira. Então eles correm simultaneamente. Obrigado pelo link.
usr
3
@usr Ainda estava curioso para saber se o WhenAll não faz coisas inteligentes, como conservar o mesmo SynchronizationContext, para afastar ainda mais seus benefícios da semântica. Não encontrei nenhuma documentação conclusiva, mas olhando para o IL há evidentemente diferentes implementações do IAsyncStateMachine em jogo. Eu não leio IL tão bem, mas WhenAll, no mínimo, parece gerar um código IL mais eficiente. (Em qualquer caso, o simples facto do resultado de WhenAll reflete o estado de todas as tarefas envolvidas para mim é motivo suficiente para preferi-lo na maioria dos casos.)
Oskar Lindberg
17
Outra diferença importante é que o WhenAll aguardará a conclusão de todas as tarefas, mesmo que, por exemplo, t1 ou t2 gerem uma exceção ou sejam canceladas.
Magnus
28

Meu entendimento é que o principal motivo para preferir Task.WhenAllmúltiplos awaits é desempenho / tarefa "agitação": o DoWork1método faz algo parecido com isto:

  • comece com um determinado contexto
  • salve o contexto
  • aguarde t1
  • restaurar o contexto original
  • salve o contexto
  • aguarde t2
  • restaurar o contexto original
  • salve o contexto
  • aguarde t3
  • restaurar o contexto original

Por outro lado, DoWork2faz isso:

  • comece com um determinado contexto
  • salve o contexto
  • aguarde todos os T1, T2 e T3
  • restaurar o contexto original

Se isso é importante o suficiente para o seu caso em particular, é "dependente do contexto" (perdoe o trocadilho).

Marcel Popescu
fonte
4
Você parece pensar que enviar uma mensagem para o contexto de sincronização é caro. Realmente não é. Você tem um delegado que é adicionado a uma fila, essa fila será lida e o delegado executado. A sobrecarga que isso adiciona é honestamente muito pequena. Não é nada, mas também não é grande. A despesa de quaisquer que sejam as operações assíncronas, superará essa sobrecarga em quase todos os casos.
Servy
Concordo, era apenas a única razão pela qual eu pensava em preferir um ao outro. Bem, isso mais a semelhança com Task.WaitAll, em que a troca de threads é um custo mais significativo.
Marcel Popescu
1
@Servy Como Marcel salienta que REALMENTE depende. Se você usar wait em todas as tarefas de banco de dados por uma questão de princípio, por exemplo, e esse banco de dados estiver na mesma máquina que a instância do asp.net, há casos em que você aguardará um hit de banco de dados que esteja na memória no índice, mais barato do que o comutador de sincronização e o conjunto de linhas aleatório. Pode haver uma vitória geral significativa com WhenAll () nesse tipo de cenário, então ... realmente depende.
Chris Moschini
3
@ChrisMoschini Não há como a consulta ao banco de dados, mesmo que esteja atingindo um banco de dados localizado na mesma máquina que o servidor, seja mais rápida do que a sobrecarga de adicionar alguns delegados a uma bomba de mensagens. Essa consulta na memória ainda é quase certamente muito mais lenta.
Servy
Observe também que se t1 é mais lento e t2 e t3 são mais rápidos - o outro aguarda o retorno imediatamente.
David Refaeli
18

Um método assíncrono é implementado como uma máquina de estado. É possível escrever métodos para que não sejam compilados em máquinas de estado, isso geralmente é chamado de método assíncrono de via rápida. Eles podem ser implementados da seguinte maneira:

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

Ao usá- Task.WhenAlllo, é possível manter esse código acelerado, assegurando ao mesmo tempo que o chamador aguarda a conclusão de todas as tarefas, por exemplo:

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
fonte
7

(Isenção de responsabilidade: Esta resposta foi tirada / inspirada do curso TPL Async de Ian Griffiths sobre Pluralsight )

Outro motivo para preferir o tratamento WhenAll é Exception.

Suponha que você tenha um bloco try-catch nos seus métodos DoWork e suponha que eles estejam chamando diferentes métodos 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)
    {
        // ...
    }

}

Nesse caso, se todas as três tarefas gerarem exceções, apenas a primeira será capturada. Qualquer exceção posterior será perdida. Ou seja, se t2 e t3 lançarem uma exceção, apenas t2 será capturado; etc. As exceções de tarefas subsequentes não serão observadas.

Onde, como no WhenAll - se alguma ou todas as tarefas falharem, a tarefa resultante conterá todas as exceções. A palavra-chave wait sempre ainda lança novamente a primeira exceção. Portanto, as outras exceções ainda são efetivamente não observadas. Uma maneira de superar isso é adicionar uma continuação vazia após a tarefa WhenAll e colocar a espera lá. Dessa forma, se a tarefa falhar, a propriedade result lançará a Exceção Agregada completa:

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
fonte
6

As outras respostas a esta pergunta oferecem razões técnicas pelas quais await Task.WhenAll(t1, t2, t3);é preferido. Esta resposta terá como objetivo analisá-la de um lado mais suave (ao qual @usr faz alusão) e ainda chegar à mesma conclusão.

await Task.WhenAll(t1, t2, t3); é uma abordagem mais funcional, pois declara intenção e é atômica.

Com await t1; await t2; await t3;, não há nada que impeça um colega de equipe (ou talvez o seu futuro eu!) De adicionar código entre as awaitdeclarações individuais . Claro, você o compactou em uma linha para conseguir isso, mas isso não resolve o problema. Além disso, geralmente é uma má forma em um ambiente de equipe incluir várias instruções em uma determinada linha de código, pois pode dificultar o arquivo de origem para os olhos humanos verificarem.

Simplificando, await Task.WhenAll(t1, t2, t3);é mais sustentável, pois comunica sua intenção com mais clareza e é menos vulnerável a bugs peculiares que podem resultar de atualizações bem-intencionadas do código, ou até mesmo se fundem de maneira errada.

rarrarrarrr
fonte