Note: see the link below for the English version of this article.

https://duongnt.com/task-yield

Task.Yield - một hàm ít dùng trong C#

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 IDataReaderIDataWriter.

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 IDataReaderIDataWriter 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?

A software developer from Vietnam and is currently living in Japan.

Leave a Reply