¿Por qué las continuaciones de Task.WhenAll se ejecutan sincrónicamente?

14

Acabo de hacer una observación curiosa sobre el Task.WhenAllmétodo, cuando se ejecuta en .NET Core 3.0. Le pasé una Task.Delaytarea simple como argumento único a Task.WhenAll, y esperaba que la tarea envuelta se comportara de manera idéntica a la tarea original. Pero este no es el caso. Las continuaciones de la tarea original se ejecutan de forma asíncrona (lo cual es deseable), y las continuaciones de varios Task.WhenAll(task)envoltorios se ejecutan sincrónicamente una tras otra (lo cual no es deseable).

Aquí hay una demostración de este comportamiento. Cuatro tareas de los trabajadores esperan la misma Task.Delaytarea para completar, y luego continúan con un 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);

Aquí está la salida. Las cuatro continuaciones se ejecutan como se esperaba en diferentes subprocesos (en 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

Ahora, si comento la línea await tasky descomento la siguiente línea await Task.WhenAll(task), el resultado es bastante diferente. Todas las continuaciones se ejecutan en el mismo hilo, por lo que los cálculos no están paralelos. Cada cálculo comienza después de completar el 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

Sorprendentemente, esto ocurre solo cuando cada trabajador espera un contenedor diferente. Si defino el contenedor por adelantado:

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

... y luego awaitla misma tarea dentro de todos los trabajadores, el comportamiento es idéntico al primer caso (continuaciones asincrónicas).

Mi pregunta es: ¿por qué está pasando esto? ¿Qué hace que las continuaciones de diferentes contenedores de la misma tarea se ejecuten en el mismo hilo, sincrónicamente?

Nota: envolver una tarea en Task.WhenAnylugar de Task.WhenAllresultados en el mismo comportamiento extraño.

Otra observación: esperaba que envolver el contenedor dentro de a Task.Runharía que las continuaciones fueran asincrónicas. Pero no está pasando. Las continuaciones de la siguiente línea todavía se ejecutan en el mismo hilo (sincrónicamente).

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

Aclaración: las diferencias anteriores se observaron en una aplicación de consola que se ejecuta en la plataforma .NET Core 3.0. En .NET Framework 4.8, no hay diferencia entre esperar la tarea original o el contenedor de tareas. En ambos casos, las continuaciones se ejecutan sincrónicamente, en el mismo hilo.

Theodor Zoulias
fuente
Por curiosidad, ¿qué pasará si await Task.WhenAll(new[] { task });?
vasily.sib
1
Creo que es por el cortocircuito en el interiorTask.WhenAll
Michael Randall
3
LinqPad ofrece la misma segunda salida esperada para ambas variantes ... ¿Qué entorno utiliza para obtener ejecuciones paralelas (consola vs. WinForms vs ..., .NET vs. Core, ..., versión de marco)?
Alexei Levenkov
1
Pude duplicar este comportamiento en .NET Core 3.0 y 3.1, pero solo después de cambiar la inicial Task.Delayde 100a 1000para que no se complete cuando se awaitedita.
Stephen Cleary
2
@BlueStrat buen hallazgo! Ciertamente podría estar relacionado de alguna manera. Curiosamente, no pude reproducir el comportamiento erróneo del código de Microsoft en .NET Frameworks 4.6, 4.6.1, 4.7.1, 4.7.2 y 4.8. Obtengo diferentes identificadores de hilo cada vez, que es el comportamiento correcto. Aquí hay un violín que se ejecuta en 4.7.2.
Theodor Zoulias

Respuestas:

2

Entonces tiene múltiples métodos asíncronos esperando la misma variable de tarea;

    await task;
    // CPU heavy operation

Sí, estas continuaciones se llamarán en serie cuando se taskcomplete. En su ejemplo, cada continuación acapara el hilo durante el siguiente segundo.

Si desea que cada continuación se ejecute de forma asincrónica, puede necesitar algo como;

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

Para que sus tareas regresen de la continuación inicial y permitan que la carga de la CPU se ejecute fuera del SynchronizationContext.

Jeremy Lakeman
fuente
Gracias Jeremy por la respuesta. Sí, Task.Yieldes una buena solución a mi problema. Sin embargo, mi pregunta es más sobre por qué sucede esto y menos sobre cómo forzar el comportamiento deseado.
Theodor Zoulias
Si realmente quieres saber, el código fuente está aquí; github.com/microsoft/referencesource/blob/master/mscorlib/…
Jeremy Lakeman
Desearía que fuera así de simple, obtener la respuesta de mi pregunta estudiando el código fuente de las clases relacionadas. ¡Me llevaría mucho tiempo entender el código y descubrir qué está pasando!
Theodor Zoulias
La clave es evitarlo SynchronizationContext, llamar ConfigureAwait(false)una vez a la tarea original puede ser suficiente.
Jeremy Lakeman
Esta es una aplicación de consola, y SynchronizationContext.Currentes nula. Pero acabo de comprobarlo para estar seguro. Agregué ConfigureAwait(false)en elawait línea y no hizo ninguna diferencia. Las observaciones son las mismas que anteriormente.
Theodor Zoulias
1

Cuando se crea una tarea utilizando Task.Delay(), sus opciones de creación se establecen en Nonelugar de RunContinuationsAsychronously.

Esto podría estar rompiendo el cambio entre .net framework y .net core. Independientemente de eso, parece explicar el comportamiento que está observando. También puede verificar esto cavando en el código fuente que se Task.Delay()está renovando y DelayPromiseque llama al Taskconstructor predeterminado sin dejar opciones de creación especificadas.

Tanveer Badar
fuente
Gracias Tanveer por la respuesta. ¿Entonces especula que en .NET Core se RunContinuationsAsychronouslyha convertido en el valor predeterminado en lugar de Nonecuando se construye un nuevo Taskobjeto? Esto explicaría algunas de mis observaciones, pero no todas. Específicamente, no explicaría la diferencia entre esperar el mismo Task.WhenAllcontenedor y esperar diferentes contenedores.
Theodor Zoulias
0

En su código, el siguiente código está fuera del cuerpo recurrente.

var task = Task.Delay(100);

así que cada vez que ejecutes lo siguiente esperará la tarea y la ejecutará en un hilo separado

await task;

pero si ejecuta lo siguiente, verificará el estado de task, por lo que lo ejecutará en un subproceso

await Task.WhenAll(task);

pero si mueve la creación de tareas a su lado WhenAll, ejecutará cada tarea en un subproceso separado.

var task = Task.Delay(100);
await Task.WhenAll(task);
Seyedraouf Modarresi
fuente
Gracias Seyedraouf por la respuesta. Sin embargo, su explicación no me parece demasiado satisfactoria. La tarea devuelta por Task.WhenAlles solo una regular Task, como la original task. Ambas tareas se están completando en algún momento, el original como resultado de un evento de temporizador y el compuesto como resultado de la finalización de la tarea original. ¿Por qué sus continuaciones muestran un comportamiento diferente? ¿En qué aspecto es diferente una tarea de la otra?
Theodor Zoulias