How Async/Await actually work in C#. Part 2 Artifacts from the EAP pattern, SynchronizationContext

How Async/Await actually work in C#. Part 2 Artifacts from the EAP pattern, SynchronizationContext

As far as I understand from the comments on my previous articles on this topic:

  1. Part 1. Challenges of the Asynchronous Programming Model (APM)

  2. Lessons in asynchronous programming from the first half of the work

  3. Parallel computing — It’s all about the synchronization context (SynchronizationContext)

  4. Async/Await with C#. A puzzle for the compiler developers and for us

and by the number of views, the topic is still of interest, so I want to try to continue, but not just a translation, but a translation with explanations, although the translation itself must also be different from the original version, since I did not read it, but only according to the results, instantly , looked at a couple of paragraphs. Also, the author of that original translation asked for help with the translation, so I hope my version can help in some way in that sense, or just be interesting from a comparison point of view.

It also seems to me that there are several readers who will be interested in my version of the translation, and that is why I continue to write for them, first of all.


In the .NET Framework 2.0, several APIs appeared that implement various patterns for working with asynchronous operations, one of them is designed for such work in the context of client applications. This Event-based Asynchronous Pattern, or EAP, also came in the form of several elements (at a minimum, but possibly more):

  1. method for initiating an asynchronous operation and

  2. event to listen for its completion.

Thus, our previous doStuff example could be rewritten to include the following elements something 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; }
}

That is, you would register your work продолжения <editor: refers to the function that should be called when the asynchronous operation completes, продолжение is a term that means such a function> using the DoStuffCompleted event, and then called the DoStuffAsync method; it would initiate an asynchronous operation, and upon completion of that operation, the DoStuffCompleted event would be raised asynchronously by the rogue party (the party that initiated the operation). The handler could then run its job продолженияprobably checking that a given UserToken matches what is expected, allowing multiple handlers to connect to the event at the same time.

<editor: in fact, the very use of the event-event allows you to connect several handlers – this is a predetermined function of events, it was possible not to mention it specifically>

This pattern made some of the use cases a little easier, while other use cases, on the contrary, became much more complicated (and looking at the previous CopyStreamToStream example for APM, you can also appreciate these difficulties). In general, this technique was not widely adopted, this technique was successfully implemented in a single release of the .NET Framework, although it left behind some APIs <editor: probably left it for further releases>added when using this technique, such as Ping.SendAsync/Ping.PingCompleted:

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

However, this technique also added one notable improvement that the APM pattern didn’t account for at all, and which remains in the patterns we use today: SynchronizationContext.

SynchronizationContext was also introduced in .NET Framework 2.0 as an abstraction for the current scheduler. In particular, the most commonly used SynchronizationContext method is Post, which queues a work item for the scheduler represented by this context <editor: it probably means that there is a scheduler object that is an implementation for the SynchronizationContext abstract class, for example, then ThreadPool is declared as one of the implementations of SynchronizationContext>. The base implementation of SynchronizationContext, for example, simply represents a ThreadPool and, accordingly, the base implementation of SynchronizationContext.Post:

public virtual void Post(SendOrPostCallback d, object? state) => ThreadPool.QueueUserWorkItem(static s => s.d(s.state), (d, state), preferLocal: false);

simply delegates <editor: is replaced by the> method ThreadPool.QueueUserWorkItem used to force ThreadPool to call “Callback d” from Post parameters with the corresponding state in one of the ThreadPool threads. However, the point of SynchronizationContext is not only to support arbitrary schedulers, rather, to support such a scheduling method that would work according to the needs of different models of organizing asynchronous operations in the application.

<editor: it seems to me that the author overdid it a bit here, it seems that both are equally important, maybe he just wanted to voice both of these goals, but it turned out that he prioritized them>

Consider a UI framework such as Windows Forms. As with most UI frameworks on Windows, controls are associated with a specific thread, and that thread runs the message handler <editor: in English, the procedure for processing messages is called “pump” for short, like “pumping” or “working out” in one word in Russian>that performs work (specified in the message) capable of interacting with these controls: only this thread must manipulate these controls, and any other thread that wants to interact with (influence) the controls must do so by by sending a message that will be executed by the message handler of the UI thread. Windows Forms simplifies this with methods such as Control.BeginInvoke, which enqueues the provided delegate and arguments to be started by the thread from the message, only the thread associated with that 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 allow the ComputeMessage() job to run on one of the ThreadPool threads (so that the UI remains responsive while ComputeMessage() is running), and then when that job is done, the lambda operation (продолжение) with the button1 control will be enqueued back to the thread associated with button1 to update the label for button1. It seems that everything is quite simple.

<editor: it turns out that here we use two SynchronizationContext at once: one ThreadPool, the second – something based on the message processing procedure of the thread associated with button1. Therefore, at first, I did not quite understand how and why the author of the Post jumped from SynchronizationContext to asynchronous operations for the UI, as if there is some gap in the logic of the story, but this gap is explained further.

WPF has something similar, only with a dispatcher type:

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

and .NET MAUI has something similar. 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 could use it like this:

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

but how would one implement the calculation of the message and the update call in such a way that it could work in any of these applications (for Windows Forms, for WPF, for .NET MAUI, …)? Does it need to be hardcoded to be aware of all possible UI frameworks? This is where SynchronizationContext’s star shines. We could 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);
        }
    });
}

<editor: if you haven’t noticed, I’ll explain – SynchronizationContext allows you to hide or abstract from such a difference in the code:

button1.BeginInvoke(() =>

against:

button1.Dispatcher.InvokeAsync(() =>

agree that such a difference is difficult to notice until you are pointed at it with a finger, or…

or until it gives you some tangible problems, which I, for example, had to deal with, for the first time, more than 20 years ago (in the context of a task that, of course, has nothing to do with SynchronizationContext or async/await, and not even C# and C++). Since then, I have developed the ability to notice such a difference. And I assure you that this abstraction does make sense in a certain context. Please note that we were not shown the code of the third implementation with .NET MAUI, and there this difference can be much more significant.

It seems to me that the main idea of ​​this paragraph is already expressed here, then the author paints it more verbosely from various sides and lists special caveats of abstraction, which, it seems to me, are designed to convince you of the significance of this idea, and of what I have just tried to assure you

I also found it interesting that ThreadPool in implementation SynchronizationContext is used precisely through delegation, not through inheritance>

Our function now uses the SynchronizationContext as an abstraction to target any “scheduler” that should be used to return back to the source environment to interact with the UI. Each application model now ensures that it is represented as SynchronizationContext.Current – a SynchronizationContext -derived type that performs the “correct actions”. For example, Windows Forms does this:

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

WPF does this:

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

ASP.NET used to do this, this implementation didn’t really care which thread the work was running on, but rather cared that the work associated with that request was serialized in such a way that multiple threads couldn’t access the given HttpContext:

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

Hence, this technique is not only applicable to basic application asynchrony models. For example, xunit is a popular unit testing framework that .NET Core repositories use for their 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 to enable it? 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 object simply places the job in its own internal queue of jobs (operations), which it then executes in its own worker threads, where it controls their number based on the desired maximum parallelism. Well, you get the idea.

How does this relate to the asynchronous event-based pattern? Both the EAP and the SynchronizationContext were injected at the same time, and the EAP dictated that completion events (продолжения) must be queued to whatever SynchronizationContext was current when the asynchronous operation started. To simplify this a bit (and perhaps not enough to justify the additional complexity of introducing new types), 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 served as a simple factory to execute that capture and instantiate an AsyncOperation . EAP implementations would then use them, for example, Ping.SendAsync would call AsyncOperationManager.CreateOperation to capture the SynchronizationContext, and then, when the operation completes, the PostOperationCompleted method of the AsyncOperation object would be called to call the stored Post method of the SynchronizationContext object.

SynchronizationContext provides a few more minor features that are worth mentioning, as they will come up again in a bit. Specifically, it provides the OperationStarted and OperationCompleted methods. The base implementation of these virtual methods is empty and does nothing, but a derived implementation can override them to be aware of runtime operations. This means that EAP implementations will also call these OperationStarted/OperationCompleted at the start and end of each operation to inform any current SynchronizationContext of the start and end of each operation and allow it to track these events. This is especially true for the EAP pattern, because methods that initiate asynchronous operations return void: you don’t get anything that allows you to track each operation individually. We will return to this later.

<editor: it looks like EAP was still not canceled after the only release in which it appeared, since its implementations still cause these OperationStarted/OperationCompleted as we will see in the following sections of the Post, it turns out that it went deep into the core or .NET libraries>

So we needed something better than the APM template, and the subsequent EAP brought some new things, but didn’t really address the core issues we were facing. We still needed something better.

Related posts