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

C# Events to Awaitable Pattern

5.00/5 (11 votes)
30 Apr 2023CPOL2 min read 13.9K  
Convert an Event-based API to an awaitable pattern API
In this tip, you will see how to adapt an old event based API to a fully modern Task based API.

Introduction

Events in C# existed long before that asynchronous programming with async await was introduced. Sometimes, it can be useful to know how to adapt an "old" event based API, to a fully modern Task based API.

Code

Let's consider this simple snippet. We have a game class, with an event that tells us when the game has started.

C#
var game = new Game();
game.Started += () =>
{
    Console.WriteLine("Game started");
};

class Game
{
    public event Action? Started;

    public void Start()
    {
        Started?.Invoke();
        Console.WriteLine("Game started notified");
    }
}

Suppose we can't modify the Game class, can we await the event?

Of course:

C#
var game = new Game();
await game.StartedAsync();

public static class GameExtensions
{
    public static async Task StartedAsync(this Game game)
    {
        var tcs = new TaskCompletionSource();
        game.Started += Game_Started;

        void Game_Started() => tcs.SetResult();
        try
        {
            await tcs.Task.ConfigureAwait(false);
        }
        finally
        {
            game.Started -= Game_Started;
        }
    }
}

That's how it works:

  1. We create an extension method on the Game class returning a Task. So that we can now write await game.StartedAsync()
  2. Inside the method, we create an instance a TaskCompletionSource, that we are going to use as an "async wrapper" around our event.
  3. We subscribe to the Started event. In the handler, we just mark the Task property of the TaskCompletionSource as completed.
  4. We call await on that Task. The method will "asynchronously wait" until the Task is completed, but the Task will be marked as completed in the Game_Started handler. So here, we are just waiting for the StartedEvent to be raised!
  5. We make sure to unsubscribe from the event handler when we are done with it (in the finally block).

Can We Generalize this Code?

The first idea would be create an extension method over the event, in order to be able to write something like:

C#
await game.Started.WaitAsync();

Unfortunately, C# does not allow us to create extension methods over events.

We have two possible workarounds:

Subscribe & Unsubscribe Delegates

C#
var game = new Game();
await AsyncEx.WaitEvent(handler => game.Started += handler, 
                        handler => game.Started -= handler);

public static class AsyncEx
{
    public static async Task WaitEvent(Action<Action> subscriber, 
                                       Action<Action> unsubscriber)
    {
        var tcs = new TaskCompletionSource();

        subscriber.Invoke(SetResult);

        void SetResult() => tcs.SetResult();
        try
        {
            await tcs.Task.ConfigureAwait(false);
        }
        finally
        {
            unsubscriber.Invoke(SetResult);
        }
    }
}

Because we can't create extension methods over events, we have to pass to the method "How to subscribe" and "How to unsubscribe".

Reflection

We can use reflection to subscribe and unsubscribe from the event. We just need to pass the name of the event:

C#
var game = new Game();
await game.WaitEvent(nameof(Game.Started));

public static class AsyncEx
{
    public static async Task WaitEvent<T>(this T @this, string eventName)
    {        
        var eventInfo = @this.GetType().GetEvent(eventName);
        var tcs = new TaskCompletionSource();
        Delegate handler = SetResult;
        eventInfo!.AddEventHandler(@this, handler);

        void SetResult() => tcs.SetResult();
        try
        {
            await tcs.Task.ConfigureAwait(false);
        }
        finally
        {
            eventInfo!.RemoveEventHandler(@this, handler);
        }
    }
}

Important

Event handlers are obviously synchronous. That means that whenever someone raises the Started event, all the handlers are executed synchronously.

On the other hand, above, we are awaiting a Task, so it's possible that the code after the await doesn't run synchronously after someone raises the Event. That's the case, for example, if we are awaiting from a Thread with a custom SynchronizationContext (UI Thread of WPF, MAUI, etc.), but we are completing the Task from another Thread. That's because the TaskAwaiter tries to restore the context before the await was called, so pushes the continuation in the custom SynchronizationContext.

Can We Await a Task and Be Sure that the Code After the Await Runs Synchronously?

Well, no, but kinda. We have to abandon Tasks, and create our own custom awaitable. The idea is to create an awaitable that runs the continuation always synchronously:

C#
var game = new Game();
await game.WaitEvent(nameof(Game.Started));

public static class AsyncEx
{
    public static Awaitable WaitEvent<T>(this T @this, string eventName)
    {        
        var eventInfo = @this.GetType().GetEvent(eventName);
        var awaitable = new Awaitable();
        Delegate handler = null!;
        handler = SetResult;
        eventInfo!.AddEventHandler(@this, handler);
        void SetResult() => awaitable.ContinueWith(() => 
                            eventInfo!.RemoveEventHandler(@this, handler));
        return awaitable;
    }
}

public class Awaitable : INotifyCompletion
{
    Action? _continuation;
    Action? _continueWith;
    public Awaitable GetAwaiter() => this;
    public bool IsCompleted => false;
    public void OnCompleted(Action continuation)
    {
        _continuation = continuation;
        ExecuteContinuationIfPossible();
    }

    public void GetResult() { }
    public void ContinueWith(Action continueWith)
    {
        _continueWith = continueWith;
        ExecuteContinuationIfPossible();
    }

    void ExecuteContinuationIfPossible()
    {
        if (_continuation is null || _continueWith is null)
            return;

        _continuation.Invoke();
        _continueWith();
    }
}

History

  • 30th April, 2023: Initial version

License

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