Returning void from an Asynchronous Method

We've already looked at several valid async return types, including Task, Task<T>, ValueTask<T>, and now IAsyncEnumerable<T>, all of which support a GetAwaiter() method. There is one allowable return type (or non-type) that does not support the GetAwaiter(). This return option, which is available for an async method, is void—a method henceforth referred to as an async void method. In most cases, async void methods should be avoided and might more accurately be considered a non-option. Unlike when returning a GetAwaiter() supporting type, when there is a void return it is indeterminate when a method completes executing. If an exception occurs, returning void means there is no such container to report an exception. In such a case, any exception thrown on an async void method is likely to end up on the UI SynchronizationContext—effectively becoming an unhandled exception (see Advanced Topic: Dealing with Unhandled Exceptions on a Thread in Chapter 19).

If async void methods should be generally avoided, why are they allowed in the first place? It’s because async void methods can be used to enable async event handlers. As discussed in Chapter 14, an event should be declared as an EventHandler<T>, where EventHandler<T> has a signature of the following form:

void EventHandler<TEventArgs>(object sender, TEventArgs e)

Thus, to fit the convention of an event matching the EventHandler<T> signature, an async event needs to return void. One might suggest changing the convention, but (as discussed in Chapter 14) there could be multiple subscribers, and retrieving the return from multiple subscribers is nonintuitive and cumbersome. For this reason, the guideline is to avoid async void methods unless they are subscribers to an event handler—in which case they should not throw exceptions. Alternatively, you should provide a synchronization context to receive notifications of synchronization events such as the scheduling of work (e.g., Task.Run()) and, perhaps more important, unhandled exceptions. Listing 20.9 and the accompanying Output 20.2 provide an example of how to do this.

Listing 20.9: Catching an Exception from an async void Method
using System;
using System.Threading;
using System.Threading.Tasks;
 
public class AsyncSynchronizationContext : SynchronizationContext
{
    public Exception? Exception { getset; }
    public ManualResetEventSlim ResetEvent { get; } = new();
 
    public override void Send(SendOrPostCallback callback, object? state)
    {
        try
        {
            Console.WriteLine($@"Send notification invoked...(Thread ID: {
                Thread.CurrentThread.ManagedThreadId})");
            callback(state);
        }
        catch (Exception exception)
        {
            Exception = exception;
        }
        finally
        {
            ResetEvent.Set();
        }
    }
 
    public override void Post(SendOrPostCallback callback, object? state)
    {
        try
        {
            Console.WriteLine($@"Post notification invoked...(Thread ID: {
                Thread.CurrentThread.ManagedThreadId})");
            callback(state);
        }
        catch (Exception exception)
        {
            Exception = exception;
        }
        finally
        {
            ResetEvent.Set();
        }
    }
}
 
public static class Program
{
    public static bool EventTriggered { getset; }
 
    public const string ExpectedExceptionMessage = "Expected Exception";
 
    public static void Main()
    {
        SynchronizationContext? originalSynchronizationContext =
            SynchronizationContext.Current;
        try
        {
            AsyncSynchronizationContext synchronizationContext = new();
            SynchronizationContext.SetSynchronizationContext(
                synchronizationContext);
 
            OnEvent(typeof(Program), EventArgs.Empty);
 
            synchronizationContext.ResetEvent.Wait();
 
            if (synchronizationContext.Exception != null)
            {
                Console.WriteLine($@"Throwing expected exception....(Thread ID: {
                Thread.CurrentThread.ManagedThreadId})");
                System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(
                    synchronizationContext.Exception).Throw();
            }
        }
        catch (Exception exception)
        {
            Console.WriteLine($@"{exception} thrown as expected.(Thread ID: {
                Thread.CurrentThread.ManagedThreadId})");
        }
        finally
        {
            SynchronizationContext.SetSynchronizationContext(
                originalSynchronizationContext);
        }
    }
 
    private static async void OnEvent(object sender, EventArgs eventArgs)
    {
        Console.WriteLine($@"Invoking Task.Run...(Thread ID: {
                Thread.CurrentThread.ManagedThreadId})");
        await Task.Run(() =>
        {
            EventTriggered = true;
            Console.WriteLine($@"Running task... (Thread ID: {
                Thread.CurrentThread.ManagedThreadId})");
            throw new Exception(ExpectedExceptionMessage);
        });
    }
}
Output 20.2
Invoking Task.Run...(Thread ID: 8)
Running task... (Thread ID: 9)
Post notification invoked...(Thread ID: 8)
Post notification invoked...(Thread ID: 8)
Throwing expected exception....(Thread ID: 8)
System.Exception: Expected Exception
  at AddisonWesley.Michaelis.EssentialCSharp.Chapter20.Listing20_09.Program.Main() in
...Listing20.09.AsyncVoidReturn.cs:line 80 thrown as expected.(Thread ID: 8)

The code in Listing 20.9 executes procedurally up until the await Task.Run() invocation within OnEvent() starts. Following its completion, control is passed to the Post() method within AsyncSynchronizationContext. After the execution and completion of the Post() invocation, the Console.WriteLine("throw Exception...") method executes, and then an exception is thrown. This exception is captured by the AsyncSynchronizationContext.Post() method and passed back into Main().

In this example, we use a Task.Delay() call to ensure the program doesn’t end before the Task.Run() invocation. In the real world, as shown in Chapter 22, a ManualResetEventSlim would be the preferred approach.

Advanced Topic
Valid Async Return Types Expanded

Generally, the expression that follows the await keyword is of type Task, Task<T>, ValueTask<T>, occasionally void, or in C# 8.0, IAsyncEnumerable<T>/IAsyncEnumerator<string>. The word Generally, however, is a deliberate injection of incertitude. In fact, the exact rule regarding the return type that await requires is more generic than just these types. Additionally, it allows the type to be an awaitable type—supporting a GetAwaiter() method. This method produces an object that has certain properties and methods needed by the compiler’s rewriting logic—specifically, an object that supports the INotifyCompletion interface with the addition of a GetResult() method. By allowing an awaitable type, the system is extensible by third parties. In other words, if you want to design your own non-Task-based asynchrony system that uses some other type to represent asynchronous work, you can do so and still use the await syntax.

Prior to C# 8.0, it was not possible to make async methods return something other than void, Task, or Task<T> or ValueTask<T>, no matter which type is awaited inside the method. However, leveraging the more general GetAwaiter() approach, C# 8.0 introduced the IAsyncEnumerable<T>/IAsyncEnumerator<string> async return described earlier in the chapter.

{{ snackbarMessage }}