How Async actually works

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 was prepared more for myself and colleagues. I will be grateful for any corrections and help in translation, the article is very interesting, let’s make it available in Russian.

  1. Part 1: At the very beginning…

  2. Part 2: Event Based Asynchronous Model (EAP)

  3. Part 3: The advent of Tasks (Task-based Asynchronous Model (TAP)

    1. …and ValueTasks

  4. C# iterators to the rescue

    1. Async/await: Internals

      1. Compiler conversions

      2. SynchronizationContext and ConfigureAwait

      3. Fields in the State Machine

  5. Conclusion

The advent of Tasks (Task-based Asynchronous Model (TAP)

The .NET Framework 4.0 introduced the System.Threading.Tasks.Task type. At its core, a Task is just a data structure that represents the possible completion of some asynchronous operation (in other platforms, a similar type is called “promise” or “future”).

A Task is created to represent some operation, and then when the operation it logically represents completes, the results are stored in the Task. Simple enough. But the key feature of Task, which makes it much more useful than IAsyncResult, is that it embeds the notion of a continuation. This feature means that you can walk up to any task and ask to be notified asynchronously of its completion, with the task itself handling the synchronization to ensure the continuation is called whether the task has completed, has not yet completed, or is completing at the same time as the notification request. Why is it so important? If you remember our discussion of the old APM pattern, there were two main problems.

  1. You had to implement your own implementation of iasyncresult for each operation: there was no built-in implementation of IAsyncResult that you could just use for your own needs.

  2. Before you call the Begin method, you must have known what you want to do after it completes. This makes implementing combinators and other generic routines for consuming and compiling arbitrary asynchronous implementations quite a challenge.

Unlike Task, this generic view allows you to approach an asynchronous operation after you’ve already initiated the operation, and provide a continuation after you’ve already initiated the operation… you don’t have to provide that continuation to the method that initiates the operation. Anyone with asynchronous operations can produce a Task, and anyone who consumes asynchronous operations can consume a Task, and you don’t have to do anything special to communicate between them: Task becomes the universal language for communicating producers and consumers of asynchronous operations. And it changed the face of .NET. More on this later…

Instead of diving into complex task code, we’ll be pedagogical and just implement a simpler version. It doesn’t pretend to be a big implementation, but just enough functionality to help understand the essence of the task, which, after all, is just a data structure coordinating the sending and receiving of a completion signal. We’ll start with just a few fields:

class MyTask
{
    private bool _completed;
    private Exception? _error;
    private Action<MyTask>? _continuation;
    private ExecutionContext? _ec;
    ...
}

We need a field to know if the task completed (_completed) and we need a field to hold any error that caused the task to fail (_error); if we were also implementing a public MyTask, there would also be a private TResult _result to hold the successful result of the operation. So far, this is very similar to our custom implementation of IAsyncResult (not by accident, of course). But now the most important thing is the _continuation field. In this simple implementation, we only support one continuation, but that’s enough for clarification (the real Task uses object field, which can be either a single continuation object or a List<> of continuation objects). This is the delegate that will be called when the task completes.

Now a little about the essence. As noted, one of the fundamental advances of Task over previous models was the ability to provide work on a continuation (callback) after the operation has been initiated. We need a method that will allow us to do this, so let’s add ContinueWith:

public void ContinueWith(Action<MyTask> action)
{
    lock (this)
    {
        if (_completed)
        {
            ThreadPool.QueueUserWorkItem(_ => action(this));
        }
        else if (_continuation is not null)
        {
            throw new InvalidOperationException("Unlike Task, this implementation only supports a single continuation.");
        }
        else
        {
            _continuation = action;
            _ec = ExecutionContext.Capture();
        }
    }
}

If the task has already been marked as completed by the time ContinueWith is called, ContinueWith simply queues the execution of the delegate. Otherwise, the method saves the delegate so that the continuation can be queued when the task completes (it also saves something called the ExecutionContext and then uses that when the delegate is called later, but don’t worry about that part yet… we’re getting to it we’ll be back). Everything is quite simple.

We then need to be able to mark MyTask as completed, which means that the asynchronous operation it represents has completed. To do this, we implement two methods, one to mark success (“SetResult”) and the other to mark failure (“SetException”):

public void SetResult() => Complete(null);

public void SetException(Exception error) => Complete(error);

private void Complete(Exception? error)
{
    lock (this)
    {
        if (_completed)
        {
            throw new InvalidOperationException("Already completed");
        }

        _error = error;
        _completed = true;

        if (_continuation is not null)
        {
            ThreadPool.QueueUserWorkItem(_ =>
            {
                if (_ec is not null)
                {
                    ExecutionContext.Run(_ec, _ => _continuation(this), null);
                }
                else
                {
                    _continuation(this);
                }
            });
        }
    }
}

We save any error, mark the task as completed, and then, if a continuation was previously registered, we queue it to be called.

Finally, we need a way to propagate any exception that might have occurred in the task (and, if it were a generic MyTask, return its _result); to support certain scenarios, we also allow this method to block while waiting for the task to complete, which we can implement in terms of ContinueWith (the continue simply signals a ManualResetEventSlim, which the caller then blocks while waiting for completion).

public void Wait()
{
    ManualResetEventSlim? mres = null;
    lock (this)
    {
        if (!_completed)
        {
            mres = new ManualResetEventSlim();
            ContinueWith(_ => mres.Set());
        }
    }

    mres?.Wait();
    if (_error is not null)
    {
        ExceptionDispatchInfo.Throw(_error);
    }
}

And that’s basically it. Of course, a real Task is much more complex, with a much more efficient implementation, with support for any number of continuations, with a lot of tweaks on how it should behave (e.g. should continuations be queued, as is done here, or should they be called synchronously). as part of task completion), with the ability to store multiple exceptions instead of just one, with special knowledge about cancellation, with tons of helper methods to perform common operations (like Task.Run which creates a Task to represent a delegate queued to be invoked on the thread pool) and so on. But there is no magic in all this; basically it’s just what we’ve seen here.

You may also notice that my simple MyTask has public SetResult/SetException methods directly on it, while Task does not. In fact, Task has such methods, they are just internal, with the System.Threading.Tasks.TaskCompletionSource type serving as a separate “producer” for the task and its completion; this was not done out of technical necessity, but as a way to keep finalizers from being a consumption-only thing. You can submit a task without worrying that it will be completed out of your control; the completion signal is an implementation detail of what created the task, and also reserves the right to terminate it, leaving the source of the TaskCompletionSource to itself. (CancellationToken and CancellationTokenSource work in a similar fashion: CancellationToken is just a struct wrapper for CancellationTokenSource, providing only a public accessible scope associated with consuming the cancel signal, but without the ability to create one, which is a capability restricted to those who have access to the CancellationTokenSource).

Of course, we can implement combinators and helpers for this MyTask, similar to those provided by Task. Want a simple MyTask.WhenAll? Here you are:

public static MyTask WhenAll(MyTask t1, MyTask t2)
{
    var t = new MyTask();

    int remaining = 2;
    Exception? e = null;

    Action<MyTask> continuation = completed =>
    {
        e ??= completed._error; // just store a single exception for simplicity
        if (Interlocked.Decrement(ref remaining) == 0)
        {
            if (e is not null) t.SetException(e);
            else t.SetResult();
        }
    };

    t1.ContinueWith(continuation);
    t2.ContinueWith(continuation);

    return t;
}

Want MyTask.Run? You have it:

public static MyTask Run(Action action)
{
    var t = new MyTask();

    ThreadPool.QueueUserWorkItem(_ =>
    {
        try
        {
            action();
            t.SetResult();
        }
        catch (Exception e)
        {
            t.SetException(e);
        }
    });

    return t;
}

What about MyTask.Delay? Certainly:

public static MyTask Delay(TimeSpan delay)
{
    var t = new MyTask();

    var timer = new Timer(_ => t.SetResult());
    timer.Change(delay, Timeout.InfiniteTimeSpan);

    return t;
}

You get the idea.

With the advent of Task, all previous asynchronous patterns in .NET are gone. Wherever an asynchronous implementation was previously implemented using the APM or EAP pattern, there are new methods that return Task.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *