Note: phiên bản Tiếng Việt của bài này ở link dưới.
https://duongnt.com/task-yield-vie
Task.Yield
is just one among many methods of the Task
class. Compared to its siblings, we certainly use it much less frequently. However, there are still cases where it is helpful.
You can download the sample code in this article from the link below.
https://github.com/duongntbk/TaskYieldDemo
What does the Task.Yield method do?
Below is the definition of the Task.Yield
method.
Creates an awaitable task that asynchronously yields back to the current context when awaited.
It is somewhat similar to the Task.FromResult
method, or the Task.CompletedTask
property. When awaited, they all finish immediately. But let’s take a look at the code below.
using System.Threading.Thread;
Console.WriteLine($"Thread Id: {CurrentThread.ManagedThreadId}"); // Print a number
await Task.CompletedTask;
Console.WriteLine($"Thread Id: {CurrentThread.ManagedThreadId}"); // Print the same number as above
await Task.FromResult(1);
Console.WriteLine($"Thread Id: {CurrentThread.ManagedThreadId}"); // Still the same number as above
And this is the result when we use Task.Yield
.
Console.WriteLine($"Thread Id: {CurrentThread.ManagedThreadId}"); // Print a number
await Task.Yield();
Console.WriteLine($"Thread Id: {CurrentThread.ManagedThreadId}"); // Print a different number
The difference is Task.Yield
can force an asynchronous method to complete asynchronously. But what exactly does that mean? And how does that help us? We will find out in the next few sections.
When synchronicity is not good
A somewhat contrived example
Let’s consider the method below. It keeps printing a log message and invoking the provided delegate until we stop it via a CancellationToken
.
public async Task RunUntilCancelAsync(Func<Task> handler, CancellationToken token)
{
while (!token.IsCancellationRequested)
{
Console.WriteLine("Executing handler...");
await handler();
}
Console.WriteLine("================ TASK CANCEL ================");
}
Now, we will let that method run for 2 seconds before cancelling it.
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;
}
The code above works correctly when we pass an asynchronous handler.
Func<Task> asyncHandler = async () => await Task.Delay(500);
await DriverMethod(asyncHandler);
Will print the following.
Executing handler...
Executing handler...
Executing handler...
Executing handler...
Executing handler...
================ TASK CANCEL ================
What if the delegate returns a completed task?
Next, we try passing a new delegate that always returns a completed task.
Func<Task> completedTaskHandler = () => Task.CompletedTask;
await DriverMethod(asyncHandler);
Suddenly, our program runs into an infinite loop.
Executing handler...
Executing handler...
Executing handler...
...
... infinite loop
Just because we have an asynchronous method does not guarantee that it will run asynchronously. Instead, the runtime will execute our method synchronously until it encounters the first await
statement that is not completed yet. Only then will our method "yield" control back to the current context.
When we pass asyncHandler
into DriverMethod
, our method yields control when it reaches the command await handler()
inside RunUntilCancelAsync
. At that time, we continue executing the rest of DriverMethod
.
await Task.Delay(2000); // wait for 2000ms
cts.Cancel(); // cancel the token
await runTask; // wait until RunUntilCancelAsync is stopped by the token
But things are different when we pass completedTaskHandler
into DriverMethod
. When we reach the command await handler()
, because Task.CompletedTask
is already completed, the runtime keeps executing RunUntilCancelAsync
synchronously and doesn’t yield control back to the current context. This means we never reach the line cts.Cancel()
in DriverMethod
. However, without cancelling the token, the loop in RunUntilCancelAsync
will never stop. We have created an infinite loop.
What if we create a delegate with Task.Yield?
Below is another delegate that uses Task.Yield
.
Func<Task> taskYieldHandler = async () => await Task.Yield();
await DriverMethod(taskYieldHandler);
We can verify that there is no infinite loop.
Executing handler...
Executing handler...
Executing handler...
... many more
Executing handler...
================ TASK CANCEL ================
Each time we call await
on taskYieldHandler
, it finishes immediately. This explains why we see so many log messages before RunUntilCancelAsync
is stopped. But unlike in the previous section, we do yield control back to the current context. Thanks to that, we can cancel the token after 2 seconds.
Task.Yield in unit testing
The only real life application of Task.Yield
I can find is in unit testing.
A simple BackgroundService class
Extending the BackgroundService
class is a simple way to create long running services in C# .NET Core (please see this link for more information). This class in the sample project is a service to retrieve current weather statuses and write them to the console in a loop.
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
Console.WriteLine($"Starting {nameof(CheckWeatherService)}...");
await _weatherRetriever.InitializeAsync(stoppingToken);
Console.WriteLine($"Stopping {nameof(CheckWeatherService)}...");
}
Below is the InitializeAsync
method of the WeatherRetriever
class. It retrieves data using an IDataReader
and writes data using an IDataWriter
. Those interfaces are used in a loop until the stoppingToken
is cancelled.
while (!stoppingToken.IsCancellationRequested)
{
try
{
var currentWeather = await _reader.ReadAsync();
await _writer.WriteAsync(currentWeather);
}
catch (Exception ex)
{
Console.WriteLine($"Error while retrieving weather: {ex.Message}");
}
}
And because we care about our software’s quality, we will write unit tests for this method. While doing so, it’s necessary to mock out the IDataReader
and IDataWriter
objects.
A naive approach to testing InitializeAsync
You can find the complete test case here. We will let InitializeAsync
run for 200 ms
and then stop it by cancelling the token. And we mock IDataReader
and IDataWriter
with 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();
But if you try to run this test, you’ll find that it will never stop. The reason is exactly the same as in the case of the RunUntilCancelAsync
method. When called, the mock objects created by FakeItEasy return a value right away. Because of that, InitializeAsync
keeps running synchronously forever in a loop. Which means we never yield control back to the current context, and we never reach this line cts.Cancel()
to cancel the token.
Task.Yield to the rescue
It’s actually quite simple to fix the issue in the code above. We only need to make InitializeAsync
yield back to the current context inside the loop. And that sounds like the perfect job for Task.Yield
. Below is how we force IDataReader.ReadAsync
to complete asynchronously.
A.CallTo(() => reader.ReadAsync()).ReturnsLazily(async () =>
{
await Task.Yield();
return weatherModel;
});
We pass an asynchronous delegate into the ReturnsLazily
method of FakeItEasy
. And inside that delegate, we await Task.Yield
to force the control back to the current context. Thanks to that, we can reach the code to cancel the token and stop InitializeAsync
.
You can find the full code for the new test here. If you try to run it, you can verify that it will pass.
Conclusion
Task.Yield
is one of those features that don’t seem to be all that useful but still have their niches. Aside from using Task.Yield
, there is another way to cancel the token while testing InitializeAsync
. Can you find it?
One Thought on “Task.Yield – a rarely used method in C#”