Perché le continuazioni di Task.WhenAll vengono eseguite in modo sincrono?


14

Ho appena fatto un'osservazione curiosa riguardo al Task.WhenAllmetodo, quando eseguivo .NET Core 3.0. Ho passato un'attività semplice Task.Delaycome argomento singolo a Task.WhenAll, e mi aspettavo che l'attività avvolta si comportasse in modo identico all'attività originale. Ma non è così. Le continuazioni dell'attività originale vengono eseguite in modo asincrono (il che è desiderabile) e le continuazioni di più Task.WhenAll(task)wrapper vengono eseguite in modo sincrono l'una dopo l'altra (il che è indesiderabile).

Ecco una demo di questo comportamento. Quattro attività di lavoro sono in attesa Task.Delaydi essere completate dalla stessa attività, quindi continuano con un calcolo pesante (simulato da 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);

Ecco l'output. Le quattro continuazioni vengono eseguite come previsto in thread diversi (in parallelo).

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

Ora, se commento la riga await taske rimuovo il commento sulla riga seguente await Task.WhenAll(task), l'output è piuttosto diverso. Tutte le continuazioni sono in esecuzione nello stesso thread, quindi i calcoli non sono parallelizzati. Ogni calcolo inizia dopo il completamento del precedente:

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 ciò accade solo quando ciascun lavoratore attende un wrapper diverso. Se definisco il wrapper in anticipo:

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

... e quindi awaitlo stesso compito in tutti i lavoratori, il comportamento è identico al primo caso (continuazioni asincrone).

La mia domanda è: perché sta succedendo questo? Che cosa fa sì che le continuazioni di diversi wrapper della stessa attività vengano eseguite nello stesso thread, in modo sincrono?

Nota: il wrapping di un'attività con Task.WhenAnyinvece di Task.WhenAllottenere lo stesso strano comportamento.

Un'altra osservazione: mi aspettavo che l'avvolgimento del wrapper all'interno di a Task.Runavrebbe reso le continuazioni asincrone. Ma non sta succedendo. Le continuazioni della riga seguente vengono comunque eseguite nello stesso thread (in modo sincrono).

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

Chiarimento: le differenze di cui sopra sono state osservate in un'applicazione Console in esecuzione sulla piattaforma .NET Core 3.0. In .NET Framework 4.8, non vi è alcuna differenza tra l'attesa dell'attività originale o il wrapper di attività. In entrambi i casi, le continuazioni vengono eseguite in modo sincrono, nello stesso thread.


solo curioso, cosa succederà se await Task.WhenAll(new[] { task });?
vasily.sib

1
Penso che sia a causa del cortocircuito all'internoTask.WhenAll
Michael Randall

3
LinqPad offre lo stesso secondo output previsto per entrambe le varianti ... Quale ambiente si utilizza per ottenere esecuzioni parallele (console vs WinForms vs ..., .NET vs. Core, ..., versione framework)?
Alexei Levenkov il

1
Sono stato in grado di duplicare questo comportamento su .NET Core 3.0 e 3.1, ma solo dopo aver cambiato l'iniziale Task.Delayda 100a in 1000modo che non sia completato quando awaited.
Stephen Cleary,

2
@BlueStrat bella scoperta! Potrebbe certamente essere collegato in qualche modo. È interessante notare che non sono riuscito a riprodurre il comportamento errato del codice di Microsoft su .NET Frameworks 4.6, 4.6.1, 4.7.1, 4.7.2 e 4.8. Ricevo id di thread diversi ogni volta, che è il comportamento corretto. Ecco un violino in esecuzione su 4.7.2.
Theodor Zoulias,

Risposte:


2

Quindi hai più metodi asincroni in attesa della stessa variabile di attività;

    await task;
    // CPU heavy operation

Sì, queste continuazioni verranno chiamate in serie al tasktermine. Nel tuo esempio, ogni continuazione fa quindi il giro del thread per il secondo successivo.

Se si desidera che ogni continuazione venga eseguita in modo asincrono, potrebbe essere necessario qualcosa di simile;

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

In modo che le attività tornino dalla continuazione iniziale e consentano l'esecuzione del carico della CPU all'esterno di SynchronizationContext.


Grazie Jeremy per la risposta. Sì, Task.Yieldè una buona soluzione al mio problema. La mia domanda però è più sul perché sta accadendo questo, e meno su come forzare il comportamento desiderato.
Theodor Zoulias il

Se vuoi davvero saperlo, il codice sorgente è qui; github.com/microsoft/referencesource/blob/master/mscorlib/…
Jeremy Lakeman il

Vorrei che fosse così semplice ottenere la risposta alla mia domanda studiando il codice sorgente delle classi correlate. Mi occorrerebbero anni per capire il codice e capire cosa sta succedendo!
Theodor Zoulias il

La chiave sta evitando il SynchronizationContext, chiamando ConfigureAwait(false)una volta sull'attività originale può essere sufficiente.
Jeremy Lakeman l'

Questa è un'applicazione console e SynchronizationContext.Currentè null. Ma l'ho appena controllato per essere sicuro. Ho aggiunto ConfigureAwait(false)la awaitlinea e non ha fatto differenza. Le osservazioni sono le stesse di prima.
Theodor Zoulias l'

1

Quando un'attività viene creata utilizzando Task.Delay(), le sue opzioni di creazione sono impostate su Noneanziché RunContinuationsAsychronously.

Questo potrebbe spezzare il cambiamento tra .net framework e .net core. Indipendentemente da ciò, sembra spiegare il comportamento che stai osservando. Puoi anche verificarlo scavando nel codice sorgente che Task.Delay()sta aggiornando un DelayPromiseche chiama il Taskcostruttore predefinito senza lasciare opzioni di creazione specificate.


Grazie Tanveer per la risposta. Quindi si ipotizza che su .NET Core RunContinuationsAsychronouslysia diventato il valore predefinito invece di None, durante la costruzione di un nuovo Taskoggetto? Questo spiegherebbe alcune delle mie osservazioni ma non tutte. In particolare, non spiegherebbe la differenza tra l'attesa dello stesso Task.WhenAllwrapper e l'attesa di wrapper diversi.
Theodor Zoulias, l'

0

Nel tuo codice, il seguente codice è fuori dal corpo ricorrente.

var task = Task.Delay(100);

quindi ogni volta che esegui quanto segue, attenderà l'attività ed eseguirà in un thread separato

await task;

ma se esegui quanto segue, controllerà lo stato di task, quindi lo eseguirà in un thread

await Task.WhenAll(task);

ma se sposti la creazione di attività accanto WhenAll, ogni attività verrà eseguita in Thread separato.

var task = Task.Delay(100);
await Task.WhenAll(task);

Grazie Seyedraouf per la risposta. La tua spiegazione non mi sembra troppo soddisfacente. L'attività restituita da Task.WhenAllè solo una normale Task, come l'originale task. Entrambe le attività vengono completate a un certo punto, l'originale come risultato di un evento timer e il composito come risultato del completamento dell'attività originale. Perché le loro continuazioni dovrebbero mostrare comportamenti diversi? In quale aspetto un compito è diverso dall'altro?
Theodor Zoulias,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.