Mengapa kelanjutan dari Task.WhenAll dieksekusi secara serempak?

14

Saya baru saja membuat pengamatan yang aneh tentang Task.WhenAllmetode ini, ketika berjalan di .NET Core 3.0. Saya melewati Task.Delaytugas sederhana sebagai argumen tunggal Task.WhenAll, dan saya berharap bahwa tugas yang dibungkus akan berperilaku identik dengan tugas asli. Tapi ini bukan masalahnya. Kelanjutan dari tugas asli dijalankan secara tidak sinkron (yang diinginkan), dan kelanjutan dari beberapa Task.WhenAll(task)pembungkus dijalankan secara sinkron satu per satu (yang tidak diinginkan).

Ini adalah demo dari perilaku ini. Empat tugas pekerja sedang menunggu Task.Delaytugas yang sama untuk diselesaikan, dan kemudian melanjutkan dengan perhitungan yang berat (disimulasikan oleh 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);

Ini outputnya. Keempat kelanjutan berjalan seperti yang diharapkan di utas yang berbeda (secara paralel).

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

Sekarang jika saya berkomentar baris await taskdan batalkan komentar pada baris berikut await Task.WhenAll(task), hasilnya sangat berbeda. Semua lanjutan berjalan di utas yang sama, sehingga perhitungannya tidak diparalelkan. Setiap perhitungan dimulai setelah penyelesaian yang sebelumnya:

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

Anehnya ini hanya terjadi ketika setiap pekerja menunggu pembungkus yang berbeda. Jika saya mendefinisikan pembungkus di muka:

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

... dan kemudian awaittugas yang sama di dalam semua pekerja, perilaku identik dengan kasus pertama (kelanjutan asinkron).

Pertanyaan saya adalah: mengapa ini terjadi? Apa yang menyebabkan kelanjutan dari pembungkus berbeda dari tugas yang sama untuk dieksekusi di utas yang sama, secara serempak?

Catatan: menyelesaikan tugas dengan Task.WhenAnyalih - alih Task.WhenAllmenghasilkan perilaku aneh yang sama.

Pengamatan lain: Saya berharap bahwa membungkus bungkus di dalam Task.Runakan membuat kelanjutan asinkron. Tetapi itu tidak terjadi. Kelanjutan dari baris di bawah ini masih dijalankan di utas yang sama (serempak).

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

Klarifikasi: Perbedaan di atas diamati dalam aplikasi Konsol yang berjalan pada platform .NET Core 3.0. Pada .NET Framework 4.8, tidak ada perbedaan antara menunggu tugas asli atau pembungkus tugas. Dalam kedua kasus, kelanjutan dijalankan secara sinkron, di utas yang sama.

Theodor Zoulias
sumber
hanya ingin tahu, apa yang akan terjadi jika await Task.WhenAll(new[] { task });?
vasily.sib
1
Saya pikir ini karena hubungan arus pendek di dalamTask.WhenAll
Michael Randall
3
LinqPad memberikan output kedua yang diharapkan sama untuk kedua varian ... Lingkungan mana yang Anda gunakan untuk menjalankan paralel (konsol vs. WinForms vs ..., .NET vs Core, ..., versi kerangka kerja)?
Alexei Levenkov
1
Saya bisa menduplikasi perilaku ini pada .NET Core 3.0 dan 3.1, tetapi hanya setelah mengubah awal Task.Delaydari 100ke 1000sehingga tidak selesai saat awaited.
Stephen Cleary
2
@BlueStrat temukan yang bagus! Bisa dipastikan ada hubungannya. Menariknya saya gagal mereproduksi perilaku salah kode Microsoft pada .NET Frameworks 4.6, 4.6.1, 4.7.1, 4.7.2 dan 4.8. Saya mendapatkan id utas yang berbeda setiap kali, yang merupakan perilaku yang benar. Ini biola yang berjalan di 4.7.2.
Theodor Zoulias

Jawaban:

2

Jadi, Anda memiliki beberapa metode async yang menunggu variabel tugas yang sama;

    await task;
    // CPU heavy operation

Ya, kelanjutan ini akan dipanggil secara seri ketika taskselesai. Dalam contoh Anda, setiap kelanjutan kemudian memilik utas untuk detik berikutnya.

Jika Anda ingin setiap kelanjutan berjalan secara asinkron Anda mungkin perlu sesuatu seperti;

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

Sehingga tugas Anda kembali dari kelanjutan awal, dan memungkinkan beban CPU berjalan di luar SynchronizationContext.

Jeremy Lakeman
sumber
Terima kasih Jeremy atas jawabannya. Ya, Task.Yieldadalah solusi yang bagus untuk masalah saya. Namun pertanyaan saya lebih tentang mengapa ini terjadi, dan lebih sedikit tentang bagaimana memaksa perilaku yang diinginkan.
Theodor Zoulias
Jika Anda benar-benar ingin tahu, kode sumbernya ada di sini; github.com/microsoft/referencesource/blob/master/mscorlib/…
Jeremy Lakeman
Saya berharap semudah itu, untuk mendapatkan jawaban dari pertanyaan saya dengan mempelajari kode sumber dari kelas terkait. Butuh waktu lama bagi saya untuk memahami kode dan mencari tahu apa yang terjadi!
Theodor Zoulias
Kuncinya adalah menghindari SynchronizationContext, memanggil ConfigureAwait(false)sekali pada tugas asli mungkin cukup.
Jeremy Lakeman
Ini adalah aplikasi konsol, dan SynchronizationContext.Currentini adalah nol. Tapi saya baru saja memastikannya. Saya menambahkan ConfigureAwait(false)di awaitbaris dan tidak ada bedanya. Pengamatannya sama seperti sebelumnya.
Theodor Zoulias
1

Ketika tugas dibuat menggunakan Task.Delay(), opsi pembuatannya diatur Nonedaripada RunContinuationsAsychronously.

Ini mungkin melanggar perubahan antara .net framework dan .net core. Terlepas dari itu, tampaknya menjelaskan perilaku yang Anda amati. Anda juga dapat memverifikasi ini dari menggali ke dalam kode sumber yang Task.Delay()adalah newing up sebuah DelayPromiseyang memanggil default Taskconstructor tanpa meninggalkan pilihan penciptaan ditentukan.

Tanveer Badar
sumber
Terima kasih Tanveer untuk jawabannya. Jadi Anda berspekulasi bahwa pada .NET Core RunContinuationsAsychronouslytelah menjadi default, bukan None, ketika membangun Taskobjek baru ? Ini akan menjelaskan beberapa pengamatan saya tetapi tidak semua. Secara khusus itu tidak akan menjelaskan perbedaan antara menunggu Task.WhenAllpembungkus yang sama , dan menunggu pembungkus yang berbeda.
Theodor Zoulias
0

Dalam kode Anda, kode berikut ini keluar dari badan berulang.

var task = Task.Delay(100);

jadi setiap kali Anda menjalankan yang berikut ini akan menunggu tugas dan menjalankannya di utas terpisah

await task;

tetapi jika Anda menjalankan yang berikut, ia akan memeriksa keadaan task, jadi itu akan berjalan dalam satu Thread

await Task.WhenAll(task);

tetapi jika Anda memindahkan pembuatan tugas di sampingnya WhenAllakan menjalankan setiap tugas di utas terpisah.

var task = Task.Delay(100);
await Task.WhenAll(task);
Seyedraouf Modarresi
sumber
Terima kasih Seyedraouf untuk jawabannya. Penjelasan Anda kedengarannya tidak terlalu memuaskan bagi saya. Tugas yang dikembalikan oleh Task.WhenAllhanya biasa Task, seperti aslinya task. Kedua tugas diselesaikan di beberapa titik, yang asli sebagai hasil dari acara pengatur waktu, dan gabungan sebagai hasil dari penyelesaian tugas asli. Mengapa kelanjutan mereka harus menampilkan perilaku yang berbeda? Dalam aspek apa perbedaan tugas satu dengan yang lainnya?
Theodor Zoulias