Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Languages / C#

An Alternative to the Await vs. Wait Analogies and TaskCompletionSource Article

0.00/5 (No votes)
7 Jan 2024CPOL6 min read 3.9K   18  
An explanation of the code execution pathways in an application that employs a TaskCreationSource to correlate activity between its threads
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.

C#
int ShowThreadInfo(string location, bool isSummary = true)
{
    int managedThreadID;
    lock (lockObj)
    {
        Thread thread = Thread.CurrentThread;
        managedThreadID = thread.ManagedThreadId;
        //A raw string literal, no escape characters needed other than'{}'
        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.

C#
object lockObj = new();
bool isContinuationAsync = false;
//bool isContinuationAsync = true;
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.

C#
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 for MoreWork to complete
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.

C#
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>.

C#
public static class Extensions
{
    public static async Task<int> FindMeaningAsync
           (this MeaningOfLifeFinder meaningOfLifeFinder)
    {
        var tcs = new TaskCompletionSource<int>();
        //Define a local function to handle the event
        void handler(object? s, FindMeaningEventArgs e) => tcs.TrySetResult(e.Meaning);
        try
        {
            //subscribe to the Event
            meaningOfLifeFinder.MeaningFound += handler;
            //call a method that triggers the Event
            meaningOfLifeFinder.FindMeaning();
            return await tcs.Task;
        }
        finally
        {
            //unsubscribe from the Event
            meaningOfLifeFinder.MeaningFound -= handler;
        }
    }
}

It is used like this:

C#
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

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)