Note: see the link below for the English version of this article.
https://duongnt.com/task-yield
Task.Yield
là một trong nhiều hàm của lớp Task
. So với các anh em của nó, chúng ta ít khi sử dụng hàm này. Tuy nhiên trong một số trường hợp, hàm Task.Yield
vẫn là rất hữu ích.
Các bạn có thể tải code ví dụ trong bài từ đường link dưới đây.
https://github.com/duongntbk/TaskYieldDemo
Hàm Task.Yield có tác dụng gì?
Dưới đây là định nghĩa của hàm Task.Yield
.
Creates an awaitable task that asynchronously yields back to the current context when awaited.
Tạm dịch.
Tạo một task có thể được await. Khi ta await task này, nó sẽ trả lại quyền điều khiển một cách không đồng bộ cho context hiện tại.
Về mặt chức năng, hàm này có điểm tương đồng với hàm Task.FromResult
hay với property Task.CompletedTask
. Khi được await, chúng đều trả lại kết quả ngay lập tức. Nhưng hãy thử xem đoạn code dưới đây.
using System.Threading.Thread;
Console.WriteLine($"Thread Id: {CurrentThread.ManagedThreadId}"); // In ra một Id
await Task.CompletedTask;
Console.WriteLine($"Thread Id: {CurrentThread.ManagedThreadId}"); // In ra cùng Id ở trên
await Task.FromResult(1);
Console.WriteLine($"Thread Id: {CurrentThread.ManagedThreadId}"); // Vẫn in ra Id ở trên
Còn đây là kết quả khi sử dụng Task.Yield
.
Console.WriteLine($"Thread Id: {CurrentThread.ManagedThreadId}"); // In ra một Id
await Task.Yield();
Console.WriteLine($"Thread Id: {CurrentThread.ManagedThreadId}"); // In ra một Id khác
Điểm khác biệt là Task.Yield
đảm bảo rằng một hàm không đồng bộ sẽ chạy một cách không đồng bộ. Điều đó nghĩa là gì? Và nó quan trọng ra sao? Chúng ta sẽ cùng tìm hiểu trong các phần sau đây.
Đôi khi chạy đồng bộ là không ổn
Một ví dụ thuần lý thuyết
Hàm dưới đây sẽ lặp lại việc in ra một dòng log rồi thực thi delegate được truyền vào cho đến khi ta dừng nó lại bằng CancellationToken
.
public async Task RunUntilCancelAsync(Func<Task> handler, CancellationToken token)
{
while (!token.IsCancellationRequested)
{
Console.WriteLine("Executing handler...");
await handler();
}
Console.WriteLine("================ TASK CANCEL ================");
}
Bây giờ ta cho nó chạy trong 2 giây.
public async Task DriverMethod(Func<Task> handler)
{
var cts = new CancellationTokenSource();
var runTask = RunUntilCancelAsync(handler, cts.Token);
await Task.Delay(2000);
cts.Cancel();
await runTask;
}
Đoạn code trên sẽ chạy đúng nếu như ta truyền vào một delegate không đồng bộ.
Func<Task> asyncHandler = async () => await Task.Delay(500);
await DriverMethod(asyncHandler);
Kết quả là như sau.
Executing handler...
Executing handler...
Executing handler...
Executing handler...
Executing handler...
================ TASK CANCEL ================
Nếu ta sử dụng delegate đồng bộ thì sao?
Sau đây ta sẽ truyền vào một delegate mà giá trị trả về là một task đã được hoàn thành sẵn.
Func<Task> completedTaskHandler = () => Task.CompletedTask;
await DriverMethod(asyncHandler);
Đột nhiên hàm của ta sẽ rơi vào vòng lặp vô hạn.
Executing handler...
Executing handler...
Executing handler...
...
... lặp vô hạn
Cho dù hàm của ta là không đồng bộ thì cũng không có gì đảm bảo là nó sẽ chạy một cách không đồng bộ. Runtime sẽ chạy hàm của ta một cách đồng bộ cho tới khi nó gặp lệnh await
đầu tiên mà task ở đó chưa được hoàn thành. Chỉ lúc đó thì hàm của ta mới trả lại quyền điều khiển cho context hiện tại.
Khi ta truyền asyncHandler
vào hàm DriverMethod
, hàm của ta trả lại quyền điều khiển khi nó gặp lệnh await handler()
bên trong hàm RunUntilCancelAsync
. Lúc đó, ta tiếp tục thực hiện phần còn lại của hàm DriverMethod
.
await Task.Delay(2000); // đợi 2000ms
cts.Cancel(); // hủy token
await runTask; // đợi hàm RunUntilCancelAsync được dừng bởi token
Nhưng khi ta truyền completedTaskHandler
thì mọi chuyện lại khác. Khi ta đến dòng await handler()
thì task tạo bởi Task.CompletedTask
đã được hoàn thành sẵn rồi. Bởi thế runtime sẽ tiếp tục thực hiện RunUntilCancelAsync
một cách đồng bộ và không trả quyền điều khiển lại cho context hiện tại. Có nghĩa là ta sẽ không bao giờ đến được dòng cts.Cancel()
trong hàm DriverMethod
. Nếu như ta không hủy token thì vòng lặp trong RunUntilCancelAsync
sẽ không bao giờ dừng. Ta đã tạo ra một vòng lặp vô hạn.
Nếu ta tạo delegate bằng Task.Yield thì sao?
Dưới đây là một delegate có sử dụng Task.Yield
.
Func<Task> taskYieldHandler = async () => await Task.Yield();
await DriverMethod(taskYieldHandler);
Có thể xác nhận rằng ta không gặp phải vòng lặp vô hạn nữa.
Executing handler...
Executing handler...
Executing handler...
... rất nhiều dòng khác
Executing handler...
================ TASK CANCEL ================
Một khi ta await
cho taskYieldHandler
, task đó sẽ được hoàn thành ngay lập tức. Đó là lý do tại sao ta thấy nhiều dòng log đến vậy trước khi ta dừng hàm RunUntilCancelAsync
. Nhưng khác với trường hợp trước, ta có trả lại quyền điều khiển cho context hiện tại. Vì vậy, ta có thể chạy phần còn lại của hàm DriverMethod
và hủy token sau 2 giây.
Sử dụng Task.Yield trong unit test
Ứng dụng thực tế duy nhất của Task.Yield
mà tôi từng gặp là trong unit test.
Một lớp BackgroundService đơn giản
Kế thừa lớp BackgroundService
là cách đơn giản nhất để tạo service chạy nền trong C# .NET Core (xin xem link này để biết thêm chi tiết). Lớp này trong project ví dụ là một service thực hiện việc đọc thông tin thời tiết rồi xuất kết quả ra console trong một vòng lặp.
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
Console.WriteLine($"Starting {nameof(CheckWeatherService)}...");
await _weatherRetriever.InitializeAsync(stoppingToken);
Console.WriteLine($"Stopping {nameof(CheckWeatherService)}...");
}
Dưới đây là nội dung hàm InitializeAsync
của lớp WeatherRetriever
. Nó đọc dữ liệu thông qua IDataReader
và ghi dữ liệu thông qua IDataWriter
. Các interface này được gọi trong một vòng lặp cho đến khi stoppingToken
bị hủy.
while (!stoppingToken.IsCancellationRequested)
{
try
{
var currentWeather = await _reader.ReadAsync();
await _writer.WriteAsync(currentWeather);
}
catch (Exception ex)
{
Console.WriteLine($"Error while retrieving weather: {ex.Message}");
}
}
Vì ta có quan tâm tới chất lượng của sản phẩm nên ta sẽ viết unit test cho hàm InitializeAsync
. Lúc này, ta cần mock các object IDataReader
và IDataWriter
.
Cách tiếp cận ban đầu cho việc test hàm InitializeAsync
Các bạn có thể tham khảo test case này tại đây. Ta cho hàm InitializeAsync
chạy trong 200 ms
rồi dừng nó lại bằng cách hủy token. Và ta mock IDataReader
và IDataWriter
bằng FakeItEasy.
var weatherModel = new WeatherModel();
var reader = A.Fake<IDataReader>();
A.CallTo(() => reader.ReadAsync()).Returns(weatherModel);
var writer = A.Fake<IDataWriter>();
var sut = new WeatherRetriever(reader, writer);
var cts = new CancellationTokenSource();
var initTask = sut.InitializeAsync(cts.Token);
await Task.Delay(200);
cts.Cancel();
await initTask;
A.CallTo(() => writer.WriteAsync(weatherModel)).MustHaveHappened();
Nhưng khi thử chạy test này, các bạn sẽ thấy rằng nó không bao giờ dừng. Nguyên nhân ở đây cũng y hệt trường hợp hàm RunUntilCancelAsync
ở trên. Khi được gọi, đối tượng mock tạo bởi FakeItEasy sẽ trả lại kết quả ngay lập tức. Vì thế, hàm InitializeAsync
sẽ chạy đồng bộ trong một vòng lặp vô hạn. Có nghĩa là ta sẽ không bao giờ trả lại quyền điều khiển, và không đến được dòng cts.Cancel()
để hủy token.
Giải quyết vấn đề trên bằng Task.Yield
Sửa lỗi trên không phải là việc khó. ta chỉ cần đảm bảo rằng hàm InitializeAsync
sẽ trả lại quyền điều khiển từ bên trong vòng lặp. Đây là thời điểm thích hợp để sử dụng hàm Task.Yield
. Dưới đây là cách ta khiến hàm IDataReader.ReadAsync
chạy một cách không đồng bộ.
A.CallTo(() => reader.ReadAsync()).ReturnsLazily(async () =>
{
await Task.Yield();
return weatherModel;
});
Ta truyền một delegate không đồng bộ vào hàm ReturnsLazily
của FakeItEasy
. Bên trong delegate đó, ta gọi hàm Task.Yield
để trả quyền điều khiển lại cho context hiện tại. Nhờ đó, ta sẽ đến được dòng code để hủy token và dừng hàm InitializeAsync
.
Các bạn có thể tham khảo code hoàn chỉnh của test này tại đây. Khi chạy thử, các bạn sẽ thấy test này chạy thông.
Kết thúc
Task.Yield
thuộc vào nhóm các hàm không mấy phổ biến, nhưng nó vẫn có tác dụng riêng. Ngoài cách dùng Task.Yield
, chúng ta còn có một phương án khác để hủy token trong khi test InitializeAsync
. Các bạn có biết là cách nào không?