Tại sao các phần tiếp theo của Task.When ALL được thực thi đồng bộ?


14

Tôi chỉ thực hiện một quan sát tò mò về Task.WhenAllphương pháp, khi chạy trên .NET Core 3.0. Tôi đã chuyển một Task.Delaytác vụ đơn giản thành một đối số duy nhất Task.WhenAllvà tôi dự kiến ​​rằng tác vụ được bao bọc sẽ hành xử giống hệt với tác vụ ban đầu. Nhưng đây không phải là trường hợp. Các phần tiếp theo của tác vụ gốc được thực thi không đồng bộ (điều này là mong muốn) và phần tiếp theo của nhiều Task.WhenAll(task)hàm bao được thực hiện đồng bộ lần lượt từng phần tử (điều không mong muốn).

Dưới đây là bản demo của hành vi này. Bốn nhiệm vụ công nhân đang chờ cùng một Task.Delaynhiệm vụ để hoàn thành, và sau đó tiếp tục với một tính toán nặng nề (mô phỏng bởi 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);

Đây là đầu ra. Bốn phần tiếp theo đang chạy như mong đợi trong các luồng khác nhau (song song).

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

Bây giờ nếu tôi nhận xét dòng await taskvà bỏ ghi chú dòng sau await Task.WhenAll(task), đầu ra hoàn toàn khác. Tất cả các phần tiếp theo đang chạy trong cùng một luồng, vì vậy các tính toán không được song song. Mỗi tính toán được bắt đầu sau khi hoàn thành trước đó:

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

Đáng ngạc nhiên là điều này chỉ xảy ra khi mỗi công nhân chờ đợi một trình bao bọc khác nhau. Nếu tôi xác định trả trước bao bọc:

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

... Và sau đó awaitlà cùng một nhiệm vụ bên trong tất cả các công nhân, hành vi này giống hệt với trường hợp đầu tiên (tiếp tục không đồng bộ).

Câu hỏi của tôi là: tại sao điều này xảy ra? Điều gì gây ra sự tiếp tục của các trình bao bọc khác nhau của cùng một tác vụ để thực thi trong cùng một luồng, một cách đồng bộ?

Lưu ý: gói một nhiệm vụ Task.WhenAnythay vì Task.WhenAllkết quả trong cùng một hành vi lạ.

Một quan sát khác: Tôi dự kiến ​​rằng việc bọc trình bao bọc bên trong Task.Runsẽ làm cho các phần tiếp theo không đồng bộ. Nhưng nó không xảy ra. Các phần tiếp theo của dòng bên dưới vẫn được thực hiện trong cùng một luồng (đồng bộ).

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

Làm rõ: Sự khác biệt ở trên đã được quan sát thấy trong một ứng dụng Console chạy trên nền tảng .NET Core 3.0. Trên .NET Framework 4.8, không có sự khác biệt giữa việc chờ đợi tác vụ gốc hoặc trình bao bọc tác vụ. Trong cả hai trường hợp, các phần tiếp theo được thực hiện đồng bộ, trong cùng một luồng.


Chỉ tò mò, chuyện gì sẽ xảy ra nếu await Task.WhenAll(new[] { task });?
vasily.sib

1
Tôi nghĩ rằng đó là do sự ngắn mạch bên trongTask.WhenAll
Michael Randall

3
LinqPad cung cấp cùng một đầu ra thứ hai dự kiến ​​cho cả hai biến thể ... Môi trường nào bạn sử dụng để chạy song song (bảng điều khiển so với WinForms so với ..., .NET so với Core, ..., phiên bản khung)?
Alexei Levenkov

1
Tôi đã có thể sao chép hành vi này trên .NET Core 3.0 và 3.1, nhưng chỉ sau khi thay đổi Task.Delaytừ ban đầu 100sang 1000để nó không được hoàn thành khi awaited.
Stephen Cleary

2
@BlueStrat tìm thấy tốt đẹp! Nó chắc chắn có thể liên quan bằng cách nào đó. Thật thú vị, tôi đã thất bại trong việc tái tạo hành vi sai lầm của của Microsoft trên .NET Frameworks 4.6, 4.6.1, 4.7.1, 4.7.2 và 4.8. Tôi nhận được id id khác nhau mỗi lần, đó là hành vi chính xác. Đây là một fiddle chạy trên 4.7.2.
Theodor Zoulias

Câu trả lời:


2

Vì vậy, bạn có nhiều phương thức async đang chờ cùng một biến nhiệm vụ;

    await task;
    // CPU heavy operation

Có, những phần tiếp theo này sẽ được gọi theo chuỗi khi taskhoàn thành. Trong ví dụ của bạn, mỗi lần tiếp tục sau đó sẽ xâu chuỗi cho giây tiếp theo.

Nếu bạn muốn mỗi phần tiếp tục chạy không đồng bộ, bạn có thể cần một cái gì đó như;

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

Vì vậy, các tác vụ của bạn trở lại từ phần tiếp theo ban đầu và cho phép tải CPU chạy bên ngoài SynchronizationContext.


Cảm ơn Jeremy vì câu trả lời. Vâng, Task.Yieldlà một giải pháp tốt cho vấn đề của tôi. Câu hỏi của tôi là nhiều hơn về lý do tại sao điều này xảy ra, và ít hơn về cách buộc hành vi mong muốn.
Theodor Zoulias

Nếu bạn thực sự muốn biết, mã nguồn ở đây; github.com/microsoft/referencesource/blob/master/mscorlib/ory
Jeremy Lakeman

Tôi ước rằng nó thật đơn giản, để có được câu trả lời cho câu hỏi của tôi bằng cách nghiên cứu mã nguồn của các lớp liên quan. Tôi sẽ mất nhiều thời gian để hiểu mã và tìm hiểu chuyện gì đang xảy ra!
Theodor Zoulias

Điều quan trọng là tránh SynchronizationContext, gọi ConfigureAwait(false)một lần vào nhiệm vụ ban đầu có thể là đủ.
Jeremy Lakeman

Đây là một ứng dụng giao diện điều khiển, và SynchronizationContext.Currentlà null. Nhưng tôi chỉ kiểm tra nó để chắc chắn. Tôi đã thêm ConfigureAwait(false)vào awaitdòng và nó không có sự khác biệt. Các quan sát giống như trước đây.
Theodor Zoulias

1

Khi một tác vụ được tạo bằng cách sử dụng Task.Delay(), các tùy chọn tạo của nó được đặt thành Nonechứ không phải RunContinuationsAsychronously.

Điều này có thể phá vỡ sự thay đổi giữa khung .net và lõi .net. Bất kể điều đó, nó xuất hiện để giải thích hành vi bạn đang quan sát. Bạn cũng có thể xác minh điều này từ việc đào sâu vào mã nguồn Task.Delay()đang tạo ra một DelayPromiselệnh gọi hàm tạo mặc định Taskkhông để lại tùy chọn tạo nào được chỉ định.


Cảm ơn Tanveer cho câu trả lời. Vì vậy, bạn suy đoán rằng trên .NET Core, nó RunContinuationsAsychronouslyđã trở thành mặc định thay vì Nonekhi xây dựng một Taskđối tượng mới ? Điều này sẽ giải thích một số quan sát của tôi nhưng không phải tất cả. Cụ thể, nó sẽ không giải thích sự khác biệt giữa việc chờ đợi cùng một Task.WhenAlltrình bao bọc và chờ các trình bao bọc khác nhau.
Theodor Zoulias

0

Trong mã của bạn, đoạn mã sau nằm ngoài phần định kỳ.

var task = Task.Delay(100);

Vì vậy, mỗi lần bạn chạy tiếp theo, nó sẽ chờ tác vụ và chạy nó trong một luồng riêng biệt

await task;

nhưng nếu bạn chạy như sau, nó sẽ kiểm tra trạng thái task, vì vậy nó sẽ chạy nó trong một Thread

await Task.WhenAll(task);

nhưng nếu bạn di chuyển tạo tác vụ bên cạnh WhenAllnó sẽ chạy từng tác vụ trong luồng riêng biệt.

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

Cảm ơn Seyedraouf cho câu trả lời. Giải thích của bạn không có vẻ quá thỏa đáng với tôi mặc dù. Nhiệm vụ được trả về Task.WhenAllchỉ là thông thường Task, giống như bản gốc task. Cả hai nhiệm vụ đang hoàn thành tại một số điểm, bản gốc là kết quả của một sự kiện hẹn giờ và kết hợp là kết quả của việc hoàn thành nhiệm vụ ban đầu. Tại sao các phần tiếp theo của họ nên hiển thị hành vi khác nhau? Ở khía cạnh nào thì một nhiệm vụ khác với nhiệm vụ khác?
Theodor Zoulias
Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.