Por que as continuações de Task.WhenAll são executadas de forma síncrona?

14

Acabei de fazer uma observação curiosa sobre o Task.WhenAllmétodo, ao executar no .NET Core 3.0. Passei uma Task.Delaytarefa simples como argumento único para Task.WhenAll, e esperava que a tarefa agrupada se comportasse de forma idêntica à tarefa original. Mas esse não é o caso. As continuações da tarefa original são executadas de forma assíncrona (o que é desejável) e as continuações de vários Task.WhenAll(task)wrappers são executadas de forma síncrona, uma após a outra (o que é indesejável).

Aqui está uma demonstração desse comportamento. Quatro tarefas de trabalho aguardam a mesma Task.Delaytarefa para serem concluídas e, em seguida, continue com um cálculo pesado (simulado por a Thread.Sleep).

var task = Task.Delay(500);
var workers = Enumerable.Range(1, 4).Select(async x =>
{
    Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff}" +
        $" [{Thread.CurrentThread.ManagedThreadId}] Worker{x} before await");

    await task;
    //await Task.WhenAll(task);

    Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff}" +
        $" [{Thread.CurrentThread.ManagedThreadId}] Worker{x} after await");

    Thread.Sleep(1000); // Simulate some heavy CPU-bound computation
}).ToArray();
Task.WaitAll(workers);

Aqui está a saída. As quatro continuações estão sendo executadas conforme o esperado em diferentes threads (em paralelo).

05:23:25.511 [1] Worker1 before await
05:23:25.542 [1] Worker2 before await
05:23:25.543 [1] Worker3 before await
05:23:25.543 [1] Worker4 before await
05:23:25.610 [4] Worker1 after await
05:23:25.610 [7] Worker2 after await
05:23:25.610 [6] Worker3 after await
05:23:25.610 [5] Worker4 after await

Agora, se eu comentar await taske descomentar a linha a seguir await Task.WhenAll(task), a saída será bem diferente. Todas as continuações estão em execução no mesmo encadeamento, portanto, os cálculos não são paralelizados. Cada cálculo é iniciado após a conclusão do anterior:

05:23:46.550 [1] Worker1 before await
05:23:46.575 [1] Worker2 before await
05:23:46.576 [1] Worker3 before await
05:23:46.576 [1] Worker4 before await
05:23:46.645 [4] Worker1 after await
05:23:47.648 [4] Worker2 after await
05:23:48.650 [4] Worker3 after await
05:23:49.651 [4] Worker4 after await

Surpreendentemente, isso acontece apenas quando cada trabalhador aguarda um invólucro diferente. Se eu definir o wrapper antecipadamente:

var task = Task.WhenAll(Task.Delay(500));

... e awaita mesma tarefa em todos os trabalhadores, o comportamento é idêntico ao primeiro caso (continuações assíncronas).

Minha pergunta é: por que isso está acontecendo? O que faz com que as continuações de diferentes wrappers da mesma tarefa sejam executadas no mesmo encadeamento, de forma síncrona?

Nota: agrupar uma tarefa em Task.WhenAnyvez de Task.WhenAllresultar no mesmo comportamento estranho.

Outra observação: eu esperava que envolver o invólucro dentro de a Task.Runtornasse as continuações assíncronas. Mas isso não está acontecendo. As continuações da linha abaixo ainda são executadas no mesmo segmento (de forma síncrona).

await Task.Run(async () => await Task.WhenAll(task));

Esclarecimento: As diferenças acima foram observadas em um aplicativo Console em execução na plataforma .NET Core 3.0. No .NET Framework 4.8, não há diferença entre aguardar a tarefa original ou o invólucro de tarefa. Nos dois casos, as continuações são executadas de forma síncrona, no mesmo encadeamento.

Theodor Zoulias
fonte
apenas curioso, o que acontecerá se await Task.WhenAll(new[] { task });?
vasily.sib 10/03
11
Eu acho que é por causa do curto-circuito internoTask.WhenAll
Michael Randall
3
O LinqPad fornece a mesma segunda saída esperada para ambas as variantes ... Qual ambiente você usa para obter execuções paralelas (console x WinForms x ..., .NET x Core, ..., versão da estrutura)?
Alexei Levenkov 10/03
11
Consegui duplicar esse comportamento no .NET Core 3.0 e 3.1, mas somente após alterar a inicial Task.Delayde 100para 1000para que não seja concluída quando awaited.
Stephen Cleary
2
@BlueStrat nice find! Certamente poderia estar relacionado de alguma forma. Curiosamente, não consegui reproduzir o comportamento incorreto do código da Microsoft nos .NET Frameworks 4.6, 4.6.1, 4.7.1, 4.7.2 e 4.8. Sempre recebo diferentes IDs de threads, que é o comportamento correto. Aqui está um violino rodando em 4.7.2.
Theodor Zoulias

Respostas:

2

Portanto, você tem vários métodos assíncronos aguardando a mesma variável de tarefa;

    await task;
    // CPU heavy operation

Sim, essas continuações serão chamadas em série quando taskconcluídas. No seu exemplo, cada continuação monopoliza o encadeamento pelo segundo seguinte.

Se você deseja que cada continuação seja executada de forma assíncrona, pode ser necessário algo como;

    await task;
    await Task.Yield().ConfigureAwait(false);
    // CPU heavy operation

Para que suas tarefas retornem da continuação inicial e permitam que a carga da CPU seja executada fora do SynchronizationContext.

Jeremy Lakeman
fonte
Obrigado Jeremy pela resposta. Sim, Task.Yieldé uma boa solução para o meu problema. Minha pergunta, porém, é mais sobre por que isso está acontecendo e menos sobre como forçar o comportamento desejado.
Theodor Zoulias
Se você realmente quer saber, o código fonte está aqui; github.com/microsoft/referencesource/blob/master/mscorlib/…
Jeremy Lakeman
Eu gostaria que fosse assim tão simples, obter a resposta da minha pergunta estudando o código fonte das classes relacionadas. Levaria séculos para entender o código e descobrir o que estava acontecendo!
Theodor Zoulias
A chave é evitar a SynchronizationContextchamada ConfigureAwait(false)uma vez na tarefa original pode ser suficiente.
Jeremy Lakeman
Este é um aplicativo de console e o valor SynchronizationContext.Currenté nulo. Mas eu apenas verifiquei para ter certeza. Eu adicionei ConfigureAwait(false)na awaitlinha e não fez diferença. As observações são as mesmas que anteriormente.
Theodor Zoulias
1

Quando uma tarefa é criada usando Task.Delay(), suas opções de criação são definidas como em Nonevez de RunContinuationsAsychronously.

Isso pode estar quebrando a mudança entre a estrutura .net e o núcleo .net. Independentemente disso, parece explicar o comportamento que você está observando. Você também pode verificar isso digitando o código-fonte que Task.Delay()está lançando um DelayPromiseque chama o Taskconstrutor padrão, sem deixar opções de criação especificadas.

Tanveer Badar
fonte
Obrigado Tanveer pela resposta. Então, você especula que, no .NET Core RunContinuationsAsychronously, o padrão se tornou o padrão ao invés de Noneconstruir um novo Taskobjeto? Isso explicaria algumas das minhas observações, mas não todas. Especificamente, isso não explicaria a diferença entre aguardar o mesmo Task.WhenAllinvólucro e aguardar invólucros diferentes.
Theodor Zoulias
0

No seu código, o código a seguir está fora do corpo recorrente.

var task = Task.Delay(100);

portanto, sempre que você executar o seguinte, ele aguardará a tarefa e a executará em um encadeamento separado

await task;

mas se você executar o seguinte, ele verificará o estado de task, portanto, executará em um Thread

await Task.WhenAll(task);

mas se você mover a criação de tarefas para o lado WhenAll, cada tarefa será executada em um thread separado.

var task = Task.Delay(100);
await Task.WhenAll(task);
Seyedraouf Modarresi
fonte
Obrigado Seyedraouf pela resposta. Sua explicação não me parece muito satisfatória. A tarefa retornada por Task.WhenAllé apenas regular Task, como a original task. As duas tarefas estão sendo concluídas em algum momento, o original como resultado de um evento de timer e o composto como resultado da conclusão da tarefa original. Por que suas continuações exibem um comportamento diferente? Em que aspecto a tarefa é diferente da outra?
Theodor Zoulias