Wrap Event based functions into awaitable Tasks - Meet TaskCompletionSource

20/06/2022

In the older days of .NET asynchronous programming was achieved via events. Not only back then but also new API's do use events. As a developer we sometimes faced with the following situation:

var myObject = CreateMyObject();
myObject.OnInitialized += DoSomethingWhenInitizialized();

// Hmm I would like to wait until myObject is really Initialized
myObject.MethodWhichNeedsTheObjectToBeInitialized();

The object tells the environment via an event when it is initialized and ready to use. Another great examples are timer. Let's say we want to wait 1.5 seconds and do something else in a non-block manner without using Task.Delay? You can work with while loops and nice boolean flags but that is not really readable.

That is why we have TaskCompletionSource plus it's generic overload. The main task is that you can control the result and in case of an error the exception of this type. To the outside world we will return the task it represents.

Case 1: Task.Delay

Let's start simple. We can build our own Task.Delay to better understand the principles behind the TaskCompletionSource.

static Task WaitAsync(int timeInMilliSeconds)
{
    var taskCompletionSource = new TaskCompletionSource();
    var timerCallback = new TimerCallback(_ => { });
    var timer = new Timer(timerCallback, null, timeInMilliSeconds, int.MaxValue);

    return taskCompletionSource.Task;
}

In this example we just created a Timer, which invokes its callback exactly once after the given time period. To the outside world we will return an await-table Task. That is super convenient. Well almost. Right now if you would use it like this: WaitAsync(100); it will wait indefinitely. We create the Task but we never tell the task object when it is done. So let's do that.

var timerCallback = new TimerCallback(_ => { taskCompletionSource.SetResult(); });

Given the whole example:

var stopwatch = Stopwatch.StartNew();
await WaitAsync(100);
Console.WriteLine(stopwatch.ElapsedMilliseconds);

static Task WaitAsync(int timeInMilliSeconds)
{
    var taskCompletionSource = new TaskCompletionSource();
    var timerCallback = new TimerCallback(_ => { taskCompletionSource.SetResult(); });
    var timer = new Timer(timerCallback, null, timeInMilliSeconds, int.MaxValue);

    return taskCompletionSource.Task;
}

This will print a number slightly higher than 100. Please don't use this exact code in your code base. For one Task.Delay already exists and two: That code above can lead to memory leaks as no-one cleans up the timer. I just wanted to show case how the TaskCompletionSource works.

Case 2: Results and exceptions

But there is more than just setting the result. We can also set an exception.

var bytesWritten = await UploadFileAsync("test.txt"); 

static Task<int> UploadFileAsync(string filename)
{
    var taskCompletionSource = new TaskCompletionSource<int>();
    var myUploader = new MyUploader();
    myUploader.Upload(filename,
        success => { taskCompletionSource.SetResult(success.BytesWritten); },
        error => { taskCompletionSource.SetException(new Exception($"Could not upload file {filename}")); });
    
    return taskCompletionSource.Task
}

Pitfalls

There is an excellent article from Sergey Tepliakov about the pitfalls of TaskCompletionSource. I will give just a short wrap-up. The examples shown above can lead to deadlocks due to the fact that the continuation-task wants to run in the same thread. We can eliminate that behaviour by explicitly passing TaskCreationOptions options to the constructor like this:

var taskCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);

Conclusion

TaskCompletionSource is a nice tool to transform already asynchronous functions into something await-able (for example events but also things like BeginInvoke).

0
Buy Me a Coffee at ko-fi.com
An error has occurred. This application may no longer respond until reloaded. Reload x