Awaiting an async void method in .NET

 
 
  • Gérald Barré

async void methods are not a recommended way to define async methods. You should return a Task or ValueTask instead, so callers can await the operation. But is it actually possible to await an async void method? This is not a recommendation to use async void in your code; it is simply an exploration of whether it can be done.

When you create an async void method, the compiler generates code for you. You can see the generated code using a decompiler or Sharplab: async void example. The most interesting part is the usage of AsyncVoidMethodBuilder:

C#
public void MyAsyncMethod()
{
    <MyAsyncMethod>d__0 stateMachine = default(<MyAsyncMethod>d__0);
    stateMachine.<>t__builder = AsyncVoidMethodBuilder.Create();
    stateMachine.<>1__state = -1;
    stateMachine.<>t__builder.Start(ref stateMachine);
}

This AsyncVoidMethodBuilder.Create method starts by capturing the current SynchronizationContext (source code):

C#
public static AsyncVoidMethodBuilder Create()
{
    SynchronizationContext? sc = SynchronizationContext.Current;
    sc?.OperationStarted();
    return new AsyncVoidMethodBuilder() { _synchronizationContext = sc };
}

Then, the builder will execute the async state machine and report completion to the captured SynchronizationContext using SynchronizationContext.OperationCompleted() (source code):

C#
public void SetResult()
{
   // [code omitted for brevity]

   _builder.SetResult();
   if (_synchronizationContext != null)
   {
        NotifySynchronizationContextOfCompletion();
   }
}

private void NotifySynchronizationContextOfCompletion()
{
   try
   {
        _synchronizationContext.OperationCompleted();
   }
   catch (Exception exc)
   {
       // If the interaction with the SynchronizationContext goes awry,
       // fall back to propagating on the ThreadPool.
       Task.ThrowAsync(exc, targetContext: null);
   }
}

This means we can implement a custom SynchronizationContext, set it before calling the async void method, and then wait for OperationCompleted to be called to know when the operation has finished.

C#
sealed class AsyncVoidSynchronizationContext : SynchronizationContext
{
    private static readonly SynchronizationContext s_default = new SynchronizationContext();

    private readonly SynchronizationContext _innerSynchronizationContext;
    private readonly TaskCompletionSource _tcs = new();
    private int _startedOperationCount;

    public AsyncVoidSynchronizationContext(SynchronizationContext? innerContext)
    {
        _innerSynchronizationContext = innerContext ?? s_default;
    }

    public Task Completed => _tcs.Task;

    public override void OperationStarted()
    {
        Interlocked.Increment(ref _startedOperationCount);
    }

    public override void OperationCompleted()
    {
        if (Interlocked.Decrement(ref _startedOperationCount) == 0)
        {
            _tcs.TrySetResult();
        }
    }

    public override void Post(SendOrPostCallback d, object? state)
    {
        Interlocked.Increment(ref _startedOperationCount);

        try
        {
            _innerSynchronizationContext.Post(s =>
            {
                try
                {
                    d(s);
                }
                catch (Exception ex)
                {
                    _tcs.TrySetException(ex);
                }
                finally
                {
                    OperationCompleted();
                }
            }, state);
        }
        catch (Exception ex)
        {
            _tcs.TrySetException(ex);
        }
    }

    public override void Send(SendOrPostCallback d, object? state)
    {
        try
        {
            _innerSynchronizationContext.Send(d, state);
        }
        catch (Exception ex)
        {
            _tcs.TrySetException(ex);
        }
    }
}

We can now use the custom synchronization context before calling the async void method and wait for the completion using the Completed property.

C#
public static async Task Run(Action action)
{
    var currentContext = SynchronizationContext.Current;
    var synchronizationContext = new AsyncVoidSynchronizationContext(currentContext);
    SynchronizationContext.SetSynchronizationContext(synchronizationContext);
    try
    {
        action();

        // Wait for the async void method to call OperationCompleted or to report an exception
        await synchronizationContext.Completed;
    }
    finally
    {
        // Reset the original SynchronizationContext
        SynchronizationContext.SetSynchronizationContext(currentContext);
    }
}

Here's how you can use the previous method to await an async void method:

C#
Console.WriteLine("before");
await Run(() => Test());
Console.WriteLine("after");

async void Test()
{
    Console.WriteLine("begin");
    await Task.Delay(1000);
    Console.WriteLine("end");
}

You can see that messages are in the expected order in the console:

#Additional Resources

Do you have a question or a suggestion about this post? Contact me!

Follow me:
Enjoy this blog?