How Async/Await actually works in C# (Part 2) / Habr

Short description

The .NET Framework 2.0 introduced the Event-Based Asynchronous Pattern (EAP) which contained a method to initiate an asynchronous operation and an event to listen for its completion. The pattern was primarily designed for execution in the context of client applications and added the SynchronizationContext as an abstraction for a generic scheduler that queues work items to any scheduler represented by the context. The SynchronizationContext targets any “scheduler” that should be used to return to the required environment for UI interaction, and each application model provides a SynchronizationContext derived type that carries out the “right thing”. EAP and SynchronizationContext were introduced at the same time, and EAP dictated that completion events should be queued to the SynchronizationContext that was current when the asynchronous operation was initiated.

How Async/Await actually works in C# (Part 2) / Habr

Since the original article is quite voluminous, I took the liberty of breaking it into several independent parts that are easier to translate and understand.

Disclaimer: I am not a professional translator, the translation is prepared rather for myself and colleagues. I will be grateful for any corrections and help in the translation, the article is very interesting, let’s make it available in Russian.

  1. Part 1: In the beginning…

  2. Part 2: Event-Driven Asynchronous Model (EAP)

  3. The emergence of Tasks (Task-Based Asynchronous Model (TAP))

    1. …and ValueTasks

  4. C# iterators to the rescue

    1. Async/await: Internal device

      1. Compiler conversion

      2. SynchronizationContext and ConfigureAwait

      3. Fields in the State Machine

  5. Conclusion

Event-Driven Asynchronous Model (EAP)

NET Framework 2.0 introduced several APIs that implement a different pattern for handling asynchronous operations, designed primarily for execution in the context of client applications. This Event-based Asynchronous Pattern, or EAP, also consisted of a pair of members (at least possibly more), this time a method to initiate an asynchronous operation and an event to listen for its completion. So our previous DoStuff example could be represented as a member set like this:

class Handler
{
    public int DoStuff(string arg);

    public void DoStuffAsync(string arg, object? userToken);
    public event DoStuffEventHandler? DoStuffCompleted;
}

public delegate void DoStuffEventHandler(object sender, DoStuffEventArgs e);

public class DoStuffEventArgs : AsyncCompletedEventArgs
{
    public DoStuffEventArgs(int result, Exception? error, bool canceled, object? userToken) :
        base(error, canceled, usertoken) => Result = result;

    public int Result { get; }
}

You register your continuation work with the DoStuffCompleted event and then call the DoStuffAsync method; it initiates the operation, and when it completes, the DoStuffCompleted event will be raised asynchronously by the rogue party. The handler can then continue its work, probably checking that the supplied userToken matches what is expected, allowing multiple handlers to connect to the event at the same time.

This pattern made some use cases a little easier, but made others a lot harder (and given the previous APM CopyStreamToStream example, that’s saying something). It did not become widely adopted, but came and went in virtually one release of the .NET Framework, although it left behind APIs added during its existence, such as Ping.SendAsync/Ping.PingCompleted:

public class Ping : Component
{
    public void SendAsync(string hostNameOrAddress, object? userToken);
    public event PingCompletedEventHandler? PingCompleted;
    ...
}

However, it added one notable advance that was not considered in the APM model, and which remains in the models we use today: SynchronizationContext.

SynchronizationContext was also introduced in the .NET Framework 2.0 as an abstraction for a generic scheduler. In particular, the most used SynchronizationContext method is Post, which queues a work item to any scheduler represented by this context.

The base implementation of SynchronizationContext, for example, simply represents a ThreadPool, and so the base implementation of SynchronizationContext.Post simply delegates to ThreadPool.QueueUserWorkItem, which is used to ask ThreadPool to call a given callback with the appropriate state on one of the pool’s threads. However, the point of SynchronizationContext is not only to support arbitrary schedulers, but rather to support scheduling in such a way that it works according to the needs of different application models.

Consider such a structure of the user interface as Windows Forms. As with most Windows UI frameworks, controls are associated with a specific thread, and that thread runs a message-handling loop that does work capable of interacting with those controls: only that thread should attempt to manipulate those controls, and any another thread that wants to interact with the controls must do so by sending a message that will be consumed by the UI thread’s message loop. Windows Forms simplifies this task with methods such as Control.BeginInvoke, which enqueues the provided delegate and arguments for execution by any thread associated with the given control. So you can write code like this:

private void button1_Click(object sender, EventArgs e)
{
    ThreadPool.QueueUserWorkItem(_ =>
    {
        string message = ComputeMessage();
        button1.BeginInvoke(() =>
        {
            button1.Text = message;
        });
    });
}

This will offload the ComputeMessage() job to run on the ThreadPool thread (so that the UI remains responsive during processing), and then when that job is done, pass the delegate back to the thread associated with button1 to update the button1 label. Pretty simple. WPF has something similar, only with the Dispatcher type:

private void button1_Click(object sender, RoutedEventArgs e)
{
    ThreadPool.QueueUserWorkItem(_ =>
    {
        string message = ComputeMessage();
        button1.Dispatcher.InvokeAsync(() =>
        {
            button1.Content = message;
        });
    });
}

And there is something similar in .NET MAUI. But what if I want to put this logic in a helper method? Example:

// Call ComputeMessage and then invoke the update action to update controls.
internal static void ComputeMessageAndInvokeUpdate(Action<string> update) { ... }

Then I can use it like this:

private void button1_Click(object sender, EventArgs e)
{
    ComputeMessageAndInvokeUpdate(message => button1.Text = message);
}

but how can one implement ComputeMessageAndInvokeUpdate in such a way that it can work in any of these applications? Does it need to be hardcoded to know about every possible UI framework? This is where SynchronizationContext will help us. We can implement the method like this:

internal static void ComputeMessageAndInvokeUpdate(Action<string> update)
{
    SynchronizationContext? sc = SynchronizationContext.Current;
    ThreadPool.QueueUserWorkItem(_ =>
    {
        string message = ComputeMessage();
        if (sc is not null)
        {
            sc.Post(_ => update(message), null);
        }
        else
        {
            update(message);
        }
    });
}

This uses the SynchronizationContext as an abstraction to target any “scheduler” that should be used to return to the required environment for UI interaction. Each application model then provides a publication as SynchronizationContext.Current of a SynchronizationContext derived type that does the “right thing”. For example, Windows Forms has the following:

public sealed class WindowsFormsSynchronizationContext : SynchronizationContext, IDisposable
{
    public override void Post(SendOrPostCallback d, object? state) =>
        _controlToSendTo?.BeginInvoke(d, new object?[] { state });
    ...
}

and in WPF it is:

public sealed class DispatcherSynchronizationContext : SynchronizationContext
{
    public override void Post(SendOrPostCallback d, Object state) =>
        _dispatcher.BeginInvoke(_priority, d, state);
    ...
}

ASP.NET used to have one that didn’t care in which thread the work was done, but rather that the work associated with that request was serialized so that multiple threads couldn’t access that HttpContext at the same time :

internal sealed class AspNetSynchronizationContext : AspNetSynchronizationContextBase
{
    public override void Post(SendOrPostCallback callback, Object state) =>
        _state.Helper.QueueAsynchronous(() => callback(state));
    ...
}

It is also not limited to such basic application models. For example, xunit is a popular unit testing framework used in the core .NET repositories for unit testing, and it also uses multiple SynchronizationContexts. You can, for example, allow tests to run in parallel, but limit the number of tests that can run at the same time. How can this be done? Using SynchronizationContext:

public class MaxConcurrencySyncContext : SynchronizationContext, IDisposable
{
    public override void Post(SendOrPostCallback d, object? state)
    {
        var context = ExecutionContext.Capture();
        workQueue.Enqueue((d, state, context));
        workReady.Set();
    }
}

The Post method of the MaxConcurrencySyncContext context simply puts the work in its own internal queue, which it then processes on its own worker threads, where it controls the number of them depending on the desired maximum parallelism. You get the idea.

How does this relate to the Event-based Asynchronous Pattern? EAP and SynchronizationContext were introduced at the same time, and EAP dictated that completion events should be queued to the SynchronizationContext that was current when the asynchronous operation was initiated.

How does this relate to the Event-based Asynchronous Pattern? EAP and SynchronizationContext were introduced at the same time, and EAP dictated that completion events should be queued to the SynchronizationContext that was current when the asynchronous operation was initiated.

To make this task a little easier (and probably not enough to justify the extra complexity), System.ComponentModel also introduced some helper types, including AsyncOperation and AsyncOperationManager. The first was simply a tuple that wrapped a user-supplied state object and a captured SynchronizationContext , and the second just served as a simple factory to perform the capture and instantiate an AsyncOperation . EAP implementations then used them, for example Ping.SendAsync called AsyncOperationManager.CreateOperation to capture the SynchronizationContext, and then when the operation was complete, the PostOperationCompleted AsyncOperation method was called to call the Post method of the stored SynchronizationContext.

SynchronizationContext provides a few more “feint flushes” that are worth mentioning, as they will appear more than once. Specifically, it exposes the OperationStarted and OperationCompleted methods. The base implementation of these virtual methods is empty, doing nothing, but a derived implementation can override them to be aware of in-flight operations. This means that EAP implementations will also call these OperationStarted/OperationCompleted at the start and end of each operation to inform any SynchronizationContext present and allow it to track the operation. This is especially true for the EAP pattern, because methods that initiate asynchronous operations return a void: you don’t get anything that would allow you to track the work individually. We will return to this later.

So we needed something better than the APM template, and the EAP that followed introduced some new things but didn’t solve the main problems we were facing. We still needed something better.

Related posts