In this tip, you will see how to adapt an old event based API to a fully modern Task based API.
Introduction
Event
s 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.
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:
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:
- We create an extension method on the
Game
class returning a Task
. So that we can now write await game.StartedAsync()
- Inside the method, we create an instance a
TaskCompletionSource
, that we are going to use as an "async wrapper" around our event. - We subscribe to the
Started
event. In the handler, we just mark the Task
property of the TaskCompletionSource
as completed. - 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! - 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:
await game.Started.WaitAsync();
Unfortunately, C# does not allow us to create extension methods over events.
We have two possible workarounds:
Subscribe & Unsubscribe Delegates
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
:
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 Task
s, and create our own custom awaitable. The idea is to create an awaitable that runs the continuation always synchronously:
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