This alternative perspective on multi-threaded programming, contrasting with anthropomorphic analogies, focuses on real-time observation of variable and thread states. The provided code example illustrates the complexities of thread behavior, TaskCompletionSource, and asynchronous programming, addressing issues and proposing solutions. The importance of avoiding thread-blocking statements for optimal execution is emphasized, concluding with insights into TaskCompletionSource applications in asynchronous event handling.
Introduction
This piece is an alternative to the excellent Await vs. Wait Analogies and TaskCompletionSource article by David Deley. The original article used anthropomorphic analogies to explain the behaviour of threads in an example application that employed a TaskCreationSource
to correlate activity between its threads. In contrast, the explanation presented here is based on the observation of the state of selected variables and threads in real time as the application proceeds.
Observing the State of the Example Application
The traditional way of examining the state of an application as it runs is to use a debugger to step through the code. A more fluent and narrative approach is to post descriptive messages to the console at strategic points without interrupting the flow of the application, and that's the approach used here. Most of the messages are posted by the following ShowThreadInfo
method.
int ShowThreadInfo(string location, bool isSummary = true)
{
int managedThreadID;
lock (lockObj)
{
Thread thread = Thread.CurrentThread;
managedThreadID = thread.ManagedThreadId;
string msgLong = $"""
Location: {location}
Is Background Thread?: {thread.IsBackground}
IsThread Pool?: {thread.IsThreadPoolThread}
Thread ID: {managedThreadID}
""";
string msgShort = $"""
Location: {location}
Thread ID: {managedThreadID}
""";
string msg = isSummary ? msgShort : msgLong;
Console.WriteLine(msg);
}
return managedThreadID;
}
The Example Application
The code is largely as shown in the original article with some observation points added. A Console.ReadLine()
prevents the main method from closing the application. The DoWork
and MoreWork
methods use Thread.Sleep
so that their execution times can be varied experimentally by changing the value of that method's input parameter.
object lockObj = new();
bool isContinuationAsync = false;
TaskCreationOptions option = isContinuationAsync ?
TaskCreationOptions.RunContinuationsAsynchronously : TaskCreationOptions.None;
TaskCompletionSource<int> tcs = new(option);
ShowThreadInfo($"At Main start calling Task.Run", isSummary: false);
var runTask = Task.Run(() =>
{
DoWork();
Console.WriteLine("\n***About to call tcs.SetResult(5)***");
tcs.SetResult(5);
MoreWork();
});
Console.WriteLine("\n**At Main awaiting TaskCompletionSource Task**\n");
await tcs.Task;
ShowThreadInfo($"At Main after awaiting TaskCompletionSource Task",false);
Console.WriteLine("\n**Hit 'return to exit Main**");
Console.ReadLine();
void DoWork()
{
ShowThreadInfo($"'DoWork' start", isSummary: false);
Thread.Sleep(1000);
ShowThreadInfo($"'DoWork' end");
}
void MoreWork()
{
ShowThreadInfo($"'MoreWork' has started", isSummary: false);
Thread.Sleep(2000);
ShowThreadInfo($"'MoreWork' has finished.");
}
The Console Output and Observations
The identification value given to threadpool threads is liable to change every time the code is run but the sequence of observations should be fairly consistent. Here's the output and observations from a test run.
Location: At Main start, calling Task.Run
Is Background Thread?: False
IsThread Pool?: False
Thread ID: 1
The Main
method uses the main thread. This thread always has an Id of 1
and remains active throughout the application. It calls Task.Run
. The main thread runs on to the await
statement where it encounters an uncompleted Task
. At this stage, the thread is released and is free to carry out management duties commensurate with the application's needs. If the task was complete, the thread would have continued and completed the Main
method.
**At Main awaiting TaskCompletionSource Task**
In the mean time, the Task.Run
call results in a threadpool thread being recruited to run the delegate that was passed to the method. The thread’s Id is arbitrarily assigned; in this case it’s 10
. The threadpool is a reservoir of ready-made threads that are available to be employed as required.
Location: 'DoWork' start
Is Background Thread?: True
IsThread Pool?: True
Thread ID: 10
Location: 'DoWork' end
Thread ID: 10
The DoWork
method completes and the TaskCompletionSource.SetResult
method is called. SetResult
causes Main's continuation, the code after the await
, to be scheduled to be run on the threadpool thread 10
before the code following the SetResult
call is also scheduled to run on thread 10
. How this is achieved is complicated and varies depending on the type of application. Stephen Toub has a good explanation of the mechanics involved. The reason why the code on both sides of the await
statement is executed by the same thread is because it's more efficient to continue using a live thread rather than enabling and managing another threadpool thread.
***About to call tcs.SetResult(5)***
The SetResult
call sets the Task
to a completed state and thread 10
begins to execute Main's continuation.
Location: At Main after awaiting TaskCompletionSource Task
Is Background Thread?: True
IsThread Pool?: True
Thread ID: 10
Thread 10
runs on until it reaches the Console.ReadLine
method, this is a blocking call as the thread is paused waiting for user input. As soon as the return key is pressed, the Main
method runs to completion and the application terminates. The MoreWork
method is never called.
**Hit 'return to exit Main**
A Solution
A solution to the problem of the application terminating prematurely as proposed in the original article is to set the TaskCompletionSource TaskCreationOption
parameter to TaskCreationOption.RunContinuationsAsynchronously
when the class is instantiated. Here is the relevant output when that option is chosen.
Location: 'DoWork' start
Is Background Thread?: True
IsThread Pool?: True
Thread ID: 5
Location: 'DoWork' end
Thread ID: 5
***About to call tcs.SetResult(5)***
After SetResult
, MoreWork
is called on the same thread 5
.
Location: 'MoreWork' has started
Is Background Thread?: True
IsThread Pool?: True
Thread ID: 5
But Main's continuation now runs on another threadpool thread, Id10
.
Location: At Main after awaiting TaskCompletionSource Task
Is Background Thread?: True
IsThread Pool?: True
Thread ID: 10
Thread 10
Blocks, waiting for user input:
**Hit 'return to exit Main**
In the mean time, MoreWork
completes on thread 5
.
Location: 'MoreWork' has finished.
Thread ID: 5
The application ends when the user presses the return key.
An Unresolved Race Condition
The solution illustrated above does ensure that the MoreWork
method is called, however there is still an unresolved race situation. If the return key is pressed before MoreWork
completes, the application will close before that method ends. The solution is to await the Task
returned from the Task.Run
method. This will ensure that the user only has the opportunity to close the application after the MoreWork
method has completed.
object lockObj = new();
bool isContinuationAsync = false;
TaskCreationOptions option = isContinuationAsync ?
TaskCreationOptions.RunContinuationsAsynchronously : TaskCreationOptions.None;
TaskCompletionSource<int> tcs = new(option);
var runTask = Task.Run(() =>
{
DoWork();
tcs.SetResult(5);
MoreWork();
});
await tcs.Task;
await runTask;
Console.WriteLine("\n**Hit 'return to exit Main**");
Console.ReadLine();
There is no need to select the TaskCreationOptions.RunContinuationsAsynchronously
as there is now no blocking continuation code that prevents all tasks from completing. The important thing here is to make sure that all tasks are awaited and structure the code so that the application does not end before all Tasks
have completed. It's also good policy not to use statements that block the thread. Any Task
method that begins with 'Wait
' blocks e.g. Wait
, WaitAll
, WaitAny
. Task
methods that contain the word 'Result
' e.g. Result
, GetAwaiter.GetResult
also block.
TaskCompletionSource Examples
The References section below contains links to a couple of useful classes that are based on a TaskCompletionSource
. They allow processes to asynchronously wait for a signal to proceed. The signal can be issued from a remote sender and there is an option to clear (reset) the signal. Another more common use of the TaskCompletionSource
is to convert a synchronous event into an asynchronous event. There is some debate about the wisdom of exposing an asynchronous wrapper for a synchronous event. However, there may be occasions where the versatility afforded by some of the Task
class methods is required. There is a Microsoft video that serves as a canonical example of how to wrap events within methods that return an awaitable Task
. Here is another example based on a class that raises an Event
that returns an int
.
public class FindMeaningEventArgs : EventArgs { public int Meaning { get; set; } }
public class MeaningOfLifeFinder
{
public event EventHandler<FindMeaningEventArgs>? MeaningFound;
public void FindMeaning()
{
int meaning = 42;
OnMeaningFound(meaning);
}
protected void OnMeaningFound(int meaning)
{
MeaningFound?.Invoke(this, e: new FindMeaningEventArgs() { Meaning = meaning });
}
}
A TaskCompletionSource
is used within a MeaningOfLifeFinder
extension method to return a Task<int>
.
public static class Extensions
{
public static async Task<int> FindMeaningAsync
(this MeaningOfLifeFinder meaningOfLifeFinder)
{
var tcs = new TaskCompletionSource<int>();
void handler(object? s, FindMeaningEventArgs e) => tcs.TrySetResult(e.Meaning);
try
{
meaningOfLifeFinder.MeaningFound += handler;
meaningOfLifeFinder.FindMeaning();
return await tcs.Task;
}
finally
{
meaningOfLifeFinder.MeaningFound -= handler;
}
}
}
It is used like this:
MeaningOfLifeFinder meaningOfLifeFinder = new();
int result= await meaningOfLifeFinder.FindMeaningAsync();
Conclusion
The TaskCreationSource
class provides an efficient means for enabling asynchronous Task-based correlation between blocks of code that exist within separate methods. However, some caution is needed when deploying the class as, by default, the Task
consumer's continuation code following an await
statement is executed sequentially on the same thread as the Task's
producer's code that follows its call to the TaskCreationSource.SetResult
. So it's important not to block the execution of the consumer's continuation code as it prevents the timely completion of code in the producer's method. This situation can be preventing by selecting TaskCreationOptions.RunContinuationsAsynchronously
as a parameter when constructing the TaskCreationSource
class. That option will result in the consumer's continuation being executed on a different thread to that of the producer's method. But, it seems to me, the best option is to simply avoid writing thread-blocking statements in the consumer's continuation.
References
History
- 8th January, 2024: Initial version