Pourquoi les suites de Task.WhenAll sont exécutées de manière synchrone?

14

Je viens de faire une observation curieuse concernant la Task.WhenAllméthode, lors de l'exécution sur .NET Core 3.0. J'ai transmis une Task.Delaytâche simple en tant qu'argument unique à Task.WhenAll, et je m'attendais à ce que la tâche encapsulée se comporte de manière identique à la tâche d'origine. Mais ce n'est pas le cas. Les continuations de la tâche d'origine sont exécutées de manière asynchrone (ce qui est souhaitable), et les continuations de plusieurs Task.WhenAll(task)wrappers sont exécutées de manière synchrone l'une après l'autre (ce qui n'est pas souhaitable).

Voici une démonstration de ce comportement. Quatre tâches de travail attendent la fin de la même Task.Delaytâche, puis poursuivent un calcul lourd (simulé par 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);

Voici la sortie. Les quatre suites s'exécutent comme prévu dans différents threads (en parallèle).

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

Maintenant, si je commente la ligne await tasket commente la ligne suivante await Task.WhenAll(task), la sortie est assez différente. Toutes les continuations s'exécutent dans le même thread, donc les calculs ne sont pas parallélisés. Chaque calcul commence après la fin du précédent:

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

Étonnamment, cela ne se produit que lorsque chaque travailleur attend un emballage différent. Si je définis le wrapper à l'avance:

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

... puis awaitla même tâche à l'intérieur de tous les travailleurs, le comportement est identique au premier cas (suites asynchrones).

Ma question est: pourquoi cela se produit-il? Qu'est-ce qui provoque l'exécution continue des différents wrappers de la même tâche dans le même thread, de manière synchrone?

Remarque: encapsuler une tâche avec Task.WhenAnyau lieu de Task.WhenAllrésulte dans le même comportement étrange.

Autre observation: je m'attendais à ce que le wrapper à l'intérieur d'un Task.Runrende les suites asynchrones. Mais ça n'arrive pas. Les suites de la ligne ci-dessous sont toujours exécutées dans le même thread (de manière synchrone).

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

Clarification: les différences ci-dessus ont été observées dans une application console exécutée sur la plate-forme .NET Core 3.0. Sur le .NET Framework 4.8, il n'y a aucune différence entre l'attente de la tâche d'origine ou l'encapsuleur de tâches. Dans les deux cas, les suites sont exécutées de manière synchrone, dans le même thread.

Theodor Zoulias
la source
juste curieux, que se passera- await Task.WhenAll(new[] { task });t-il si ?
vasily.sib
1
Je pense que c'est à cause du court-circuit à l'intérieurTask.WhenAll
Michael Randall
3
LinqPad donne la même sortie attendue pour les deux variantes ... Quel environnement vous utilisez pour obtenir des exécutions parallèles (console vs WinForms vs ..., .NET vs Core, ..., version du framework)?
Alexei Levenkov
1
J'ai pu dupliquer ce comportement sur .NET Core 3.0 et 3.1, mais seulement après avoir changé l'initiale Task.Delayde 100en 1000afin qu'elle ne soit pas terminée lorsqu'elle est awaitéditée.
Stephen Cleary
2
@BlueStrat belle trouvaille! Cela pourrait certainement être lié d'une manière ou d'une autre. Fait intéressant, je n'ai pas réussi à reproduire le comportement erroné du code de Microsoft sur les cadres .NET 4.6, 4.6.1, 4.7.1, 4.7.2 et 4.8. J'obtiens des identifiants de thread différents à chaque fois, ce qui est le bon comportement. Voici un violon fonctionnant sur 4.7.2.
Theodor Zoulias

Réponses:

2

Vous disposez donc de plusieurs méthodes asynchrones attendant la même variable de tâche;

    await task;
    // CPU heavy operation

Oui, ces suites seront appelées en série une fois taskterminées. Dans votre exemple, chaque continuation monopolise ensuite le thread pendant la seconde suivante.

Si vous souhaitez que chaque continuation s'exécute de manière asynchrone, vous aurez peut-être besoin de quelque chose comme;

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

Pour que vos tâches reviennent de la suite initiale et permettent à la charge CPU de s'exécuter en dehors de SynchronizationContext.

Jeremy Lakeman
la source
Merci Jeremy pour la réponse. Oui, Task.Yieldc'est une bonne solution à mon problème. Ma question est cependant plus sur pourquoi cela se produit, et moins sur la façon de forcer le comportement souhaité.
Theodor Zoulias
Si vous voulez vraiment savoir, le code source est ici; github.com/microsoft/referencesource/blob/master/mscorlib/…
Jeremy Lakeman
Je souhaite que ce soit aussi simple que cela, d'obtenir la réponse à ma question en étudiant le code source des classes concernées. Il me faudrait des années pour comprendre le code et comprendre ce qui se passe!
Theodor Zoulias
La clé est d'éviter le SynchronizationContext, appeler ConfigureAwait(false)une fois sur la tâche d'origine peut être suffisant.
Jeremy Lakeman
Il s'agit d'une application console et la valeur SynchronizationContext.Currentest nulle. Mais je l'ai juste vérifié pour être sûr. J'ai ajouté ConfigureAwait(false)dans la awaitligne et cela n'a fait aucune différence. Les observations sont les mêmes que précédemment.
Theodor Zoulias
1

Lorsqu'une tâche est créée à l'aide de Task.Delay(), ses options de création sont définies sur Noneplutôt que RunContinuationsAsychronously.

Cela pourrait rompre le changement entre le framework .net et le core .net. Quoi qu'il en soit, cela semble expliquer le comportement que vous observez. Vous pouvez également vérifier de creuser dans le code source qui Task.Delay()est Newing jusqu'à un DelayPromisequi appelle le défaut Taskconstructeur laissant aucune option de création spécifiées.

Tanveer Badar
la source
Merci Tanveer pour la réponse. Donc, vous spéculez que sur .NET Core le RunContinuationsAsychronouslyest devenu la valeur par défaut au lieu de None, lors de la construction d'un nouvel Taskobjet? Cela expliquerait certaines de mes observations mais pas toutes. Plus précisément, cela n'expliquerait pas la différence entre attendre le même Task.WhenAllwrapper et attendre différents wrappers.
Theodor Zoulias
0

Dans votre code, le code suivant est hors du corps récurrent.

var task = Task.Delay(100);

donc chaque fois que vous exécutez ce qui suit, il attendra la tâche et l'exécutera dans un thread séparé

await task;

mais si vous exécutez ce qui suit, il vérifiera l'état de task, donc il l'exécutera dans un thread

await Task.WhenAll(task);

mais si vous déplacez la création de tâche à côté, WhenAllelle exécutera chaque tâche dans un thread séparé.

var task = Task.Delay(100);
await Task.WhenAll(task);
Seyedraouf Modarresi
la source
Merci Seyedraouf pour la réponse. Cependant, votre explication ne me semble pas trop satisfaisante. La tâche retournée par Task.WhenAllest juste un habitué Task, comme l'original task. Les deux tâches sont terminées à un moment donné, l'original à la suite d'un événement de minuterie et le composite à la suite de l'achèvement de la tâche d'origine. Pourquoi leurs suites devraient-elles afficher un comportement différent? Sous quel aspect la tâche est-elle différente de l'autre?
Theodor Zoulias