深入探讨 C# 和 .NET 中 async/await 的历史、背后的设计决策和实现细节

前言

.NET支持 async/await 已经十多年了。它改变了为 .NET 编写可扩展代码的方式。即使不深入了解底层的实现细节,也完全可以使用这项功能,并且这已成为一种非常普遍的做法。我们可以从如下所示的同步方法开始(这个方法是“同步”的,因为调用者在整个操作完成并将控制权返回给调用者之前,无法执行其他操作):

// Synchronously copy all data from source to destination.
public void CopyStreamToStream(Stream source, Stream destination)
{
    var buffer = new byte[0x1000];
    int numRead;
    while ((numRead = source.Read(buffer, 0, buffer.Length)) != 0)
    {
        destination.Write(buffer, 0, numRead);
    }
}

 

然后,只需要添加几个关键字,更改几个方法名,就可以将它变成下面的异步方法(这个方法是“异步的”,因为它会很快将控制权返回给了调用者,甚至可能在整个操作的相关工作完成之前):

// Asynchronously copy all data from source to destination.
public async Task CopyStreamToStreamAsync(Stream source, Stream destination)
{
    var buffer = new byte[0x1000];
    int numRead;
    while ((numRead = await source.ReadAsync(buffer, 0, buffer.Length)) != 0)
    {
        await destination.WriteAsync(buffer, 0, numRead);
    }
}

语法几乎相同,依然使用相同的控制流结构,但现在它的本质变成了非阻塞的,底层的执行模型发生了显著变化。而这一切的复杂工作,都由 C# 编译器和核心库在背后完成了。

虽然通常在不了解底层实现细节的情况下也能使用这些功能,但我坚信,了解某个功能的实际工作原理能够帮助我们更好地利用它。尤其对于 async/await 来说,理解其中涉及的机制特别有帮助,比如当你试图调试某些问题或提升性能时 尤为关键。因此,在本文中,我们将深入探讨 await 在语言、编译器和库层面的工作原理,以便能够充分利用这些宝贵的功能。

不过,要做好这些,我们需要回到 async/await 出现之前,了解没有async/await时的异步代码是什么样子的。需要提醒的是,那时的代码并不美观。

.NET Framework 1.0异步编程模型

早在 .NET Framework 1.0 时代,有一种异步编程模型模式(Asynchronous Programming Model,简称 APM),也被称为 Begin/End 模式或 IAsyncResult 模式。从高层次来看,这种模式很简单。对于一个同步操作 DoStuff

class Handler
{
    public int DoStuff(string arg);
}

在这种模式下,会有两个相应的方法:BeginDoStuff方法和EndDoStuff方法:

class Handler
{
    public int DoStuff(string arg);

    public IAsyncResult BeginDoStuff(string arg, AsyncCallback? callback, object? state);
    public int EndDoStuff(IAsyncResult asyncResult);
}

BeginDoStuff 方法接受与 DoStuff 相同的所有参数,另外还会接受一个 AsyncCallback 委托和一个不透明状态对象,这两者都可以为 null。

  • Begin 方法负责启动异步操作,并且如果提供了回调(通常被称为初始操作的“延续”),还需要确保在异步操作完成时调用该回调。
  • Begin 方法还会构造一个实现了 IAsyncResult 的类型实例,使用可选的状态来填充该 IAsyncResultAsyncState 属性:
namespace System
{
    public interface IAsyncResult
    {
        object? AsyncState { get; }
        WaitHandle AsyncWaitHandle { get; }
        bool IsCompleted { get; }
        bool CompletedSynchronously { get; }
    }

    public delegate void AsyncCallback(IAsyncResult ar);
}

这个 IAsyncResult 实例随后会从 Begin 方法返回,并在最终调用回调时传递给 AsyncCallback。当需要消费操作结果时,调用者会将该 IAsyncResult 实例传递给 End 方法,End 方法负责确保操作完成(如果未完成,会通过阻塞同步等待),然后返回操作的任何结果,包括传播可能发生的任何错误/异常。因此,与下面这种同步代码执行操作不同:

try
{
    int i = handler.DoStuff(arg); 
    Use(i);
}
catch (Exception e)
{
    ... // handle exceptions from DoStuff and Use
}

可以通过 Begin/End 方法以异步方式执行相同的操作,如下所示:

try
{
    handler.BeginDoStuff(arg, iar =>
    {
        try
        {
            Handler handler = (Handler)iar.AsyncState!;
            int i = handler.EndDoStuff(iar);
            Use(i);
        }
        catch (Exception e2)
        {
            ... // handle exceptions from EndDoStuff and Use
        }
    }, handler);
}
catch (Exception e)
{
    ... // handle exceptions thrown from the synchronous call to BeginDoStuff
}

对于使用过任何语言中基于回调的 API 的开发者,这种模式应该很熟悉。

 

然而,事情从此变得更加复杂。

例如,“栈深入”问题就是一个难题。

栈深入是指代码反复调用,导致栈越来越深,最终可能引发栈溢出。

Begin 方法允许在操作同步完成时同步调用回调。这意味着调用 Begin 的方法本身可能直接调用回调。而“异步”操作同步完成的情况实际上非常常见:它们并非保证异步完成,而只是允许异步完成。例如,从网络操作中异步读取数据,比如从套接字接收数据。如果你每次操作只需要少量数据(比如从响应中读取一些头部数据),可能会引入一个缓冲区以避免大量系统调用的开销。你可以执行较大的读取操作以填充缓冲区,然后从缓冲区中消费数据,直到缓冲区耗尽;这样可以减少与套接字交互所需的昂贵系统调用的次数。这样的缓冲区可能存在于你使用的任何异步抽象背后,导致你执行的第一个“异步”操作(填充缓冲区)确实异步完成,但直到缓冲区耗尽之前的所有后续操作实际上都无需执行 I/O,只需从缓冲区中获取数据,因此可以同步完成。

Begin 方法执行这些操作之一,并发现操作同步完成时,它可以同步调用回调。这意味着你有一个调用了 Begin 方法的栈帧,一个 Begin 方法自身的栈帧,以及一个用于回调的栈帧。

如果该回调又调用了 Begin 方法会发生什么?如果该操作同步完成,其回调又同步被调用,那么栈又会进一步加深。如此反复,直到最终栈溢出。

这种情况是真实存在的,并且很容易复现。你可以在 .NET Core 上尝试以下程序:

using System.Net;
using System.Net.Sockets;

using Socket listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
listener.Bind(new IPEndPoint(IPAddress.Loopback, 0));
listener.Listen();

using Socket client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
client.Connect(listener.LocalEndPoint!);

using Socket server = listener.Accept();
_ = server.SendAsync(new byte[100_000]);

var mres = new ManualResetEventSlim();
byte[] buffer = new byte[1];

var stream = new NetworkStream(client);

void ReadAgain()
{
    stream.BeginRead(buffer, 0, 1, iar =>
    {
        if (stream.EndRead(iar) != 0)
        {
            ReadAgain(); // uh oh!
        }
        else
        {
            mres.Set();
        }
    }, null);
};
ReadAgain();

mres.Wait();

这里,我设置了一个简单的客户端和服务端套接字,它们相互连接。服务器向客户端发送 100,000 字节的数据,客户端使用 BeginRead/EndRead “异步”逐字节地消费数据(这种方法效率很低,这么做只是为了演示目的)。传递给 BeginRead 的回调通过调用 EndRead 完成读取操作,如果成功读取了所需的字节(即尚未到达流的末尾),回调会通过递归调用本地函数 ReadAgain 再次发起 BeginRead。然而,在 .NET Core 中,套接字操作比 .NET Framework 快得多,如果操作系统能够同步完成操作(内核本身使用了缓冲区来满足套接字的接收操作),这些操作也会同步完成。这会导致堆栈溢出。

 

为了解决这个问题,APM(异步编程模型)中引入了两种可能的补偿方式:

  1. 不允许 AsyncCallback 同步调用:即使操作同步完成,回调也总是异步调用。这样可以避免堆栈深度问题,但会牺牲性能,因为许多操作会同步完成,强制它们排队回调会增加可观的开销
  2. 允许调用方而不是回调处理后续工作:如果操作同步完成,则由调用方处理后续工作,从而避免额外的方法帧,即避免增加堆栈深度。

 

APM 模式选择了第 2 种方式。为此,IAsyncResult 接口公开了两个相关但不同的成员:IsCompletedCompletedSynchronously

  • IsCompleted 表示操作是否已完成,随着时间推移,它会从 false 变为 true
  • CompletedSynchronously 永远不会改变(如果改变了,那是潜在的严重错误)。它用于在调用 Begin 方法的调用方和 AsyncCallback 之间传递信息,确定谁应该负责执行后续工作。
    • 如果 CompletedSynchronouslyfalse,则操作是异步完成的,后续工作应该由回调处理。
    • 如果 CompletedSynchronouslytrue,则回调处理后续工作可能导致堆栈溢出,因此调用方需要处理后续工作,而回调则不处理。

这种机制要求 CompletedSynchronously 的值始终保持一致,以确保无论是否存在竞争条件,后续工作只会被执行一次。

在之前的DoStuff示例中,这将导致类似以下代码:

try
{
    IAsyncResult ar = handler.BeginDoStuff(arg, iar =>
    {
        if (!iar.CompletedSynchronously)
        {
            try
            {
                Handler handler = (Handler)iar.AsyncState!;
                int i = handler.EndDoStuff(iar);
                Use(i);
            }
            catch (Exception e2)
            {
                ... // handle exceptions from EndDoStuff and Use
            }
        }
    }, handler);
    if (ar.CompletedSynchronously)
    {
        int i = handler.EndDoStuff(ar);
        Use(i);
    }
}
catch (Exception e)
{
    ... // handle exceptions that emerge synchronously from BeginDoStuff and possibly EndDoStuff/Use
}

这段内容信息量很大。而目前为止,我们只讨论了如何使用这种模式,还没涉及如何实现这种模式。虽然大多数开发者不需要关心底层操作(比如实现实际的 Socket.BeginReceiveEndReceive 方法,与操作系统进行交互),但许多开发者需要关心如何组合这些操作(例如将多个异步操作组合成一个更大的操作)。这不仅意味着要使用其他的 Begin/End 方法,还意味着需要自己实现这些方法,以便你的组合操作可以被其他地方使用。而且,你可能已经注意到,我之前举的 DoStuff 示例中没有任何控制流。如果引入多个操作,尤其是简单的控制流如循环等,这就会变成专家领域(通常只有喜欢自找麻烦的专家或者博客作者才愿意涉足)。

 

 

这种模式复杂且容易出错,尤其是需要实现组合操作(例如执行多个异步操作以完成一个更大的操作)时。这种情况下,不仅需要消费其他的 Begin/End 方法,还需要自己实现它们,才能让你的组合操作被其他地方使用。

此外,这种基于回调的异步模式复杂性较高,增加了实现和维护的难度。

 

为了更清晰地说明这个问题,我们来实现一个完整的例子。在文章开头,我展示了一个 CopyStreamToStream 方法,它可以将数据从一个流复制到另一个流(类似于 Stream.CopyTo,但为了说明问题,假设这个方法不存在)。

public void CopyStreamToStream(Stream source, Stream destination)
{
    var buffer = new byte[0x1000];
    int numRead;
    while ((numRead = source.Read(buffer, 0, buffer.Length)) != 0)
    {
        destination.Write(buffer, 0, numRead);
    }
}

实现起来很简单:我们重复从一个流中读取数据,然后将读取到的数据写入另一个流,直到没有更多的数据可以读取。现在,如果我们使用 APM 模式异步实现该功能,会是这样的:

public IAsyncResult BeginCopyStreamToStream(
    Stream source, Stream destination, AsyncCallback callback, object state)
{
    var ar = new MyAsyncResult(state);
    var buffer = new byte[0x1000];

    Action<IAsyncResult?> readWriteLoop = null!;
    readWriteLoop = iar =>
    {
        try
        {
            for (bool isRead = iar == null; ; isRead = !isRead)
            {
                if (isRead)
                {
                    iar = source.BeginRead(buffer, 0, buffer.Length, static readResult =>
                    {
                        if (!readResult.CompletedSynchronously)
                        {
                            ((Action<IAsyncResult?>)readResult.AsyncState!)(readResult);
                        }
                    }, readWriteLoop);

                    if (!iar.CompletedSynchronously)
                    {
                        return;
                    }
                }
                else
                {
                    int numRead = source.EndRead(iar!);
                    if (numRead == 0)
                    {
                        ar.Complete(null);
                        callback?.Invoke(ar);
                        return;
                    }

                    iar = destination.BeginWrite(buffer, 0, numRead, writeResult =>
                    {
                        if (!writeResult.CompletedSynchronously)
                        {
                            try
                            {
                                destination.EndWrite(writeResult);
                                readWriteLoop(null);
                            }
                            catch (Exception e2)
                            {
                                ar.Complete(e);
                                callback?.Invoke(ar);
                            }
                        }
                    }, null);

                    if (!iar.CompletedSynchronously)
                    {
                        return;
                    }

                    destination.EndWrite(iar);
                }
            }
        }
        catch (Exception e)
        {
            ar.Complete(e);
            callback?.Invoke(ar);
        }
    };

    readWriteLoop(null);

    return ar;
}

public void EndCopyStreamToStream(IAsyncResult asyncResult)
{
    if (asyncResult is not MyAsyncResult ar)
    {
        throw new ArgumentException(null, nameof(asyncResult));
    }

    ar.Wait();
}

private sealed class MyAsyncResult : IAsyncResult
{
    private bool _completed;
    private int _completedSynchronously;
    private ManualResetEvent? _event;
    private Exception? _error;

    public MyAsyncResult(object? state) => AsyncState = state;

    public object? AsyncState { get; }

    public void Complete(Exception? error)
    {
        lock (this)
        {
            _completed = true;
            _error = error;
            _event?.Set();
        }
    }

    public void Wait()
    {
        WaitHandle? h = null;
        lock (this)
        {
            if (_completed)
            {
                if (_error is not null)
                {
                    throw _error;
                }
                return;
            }

            h = _event ??= new ManualResetEvent(false);
        }

        h.WaitOne();
        if (_error is not null)
        {
            throw _error;
        }
    }

    public WaitHandle AsyncWaitHandle
    {
        get
        {
            lock (this)
            {
                return _event ??= new ManualResetEvent(_completed);
            }
        }
    }

    public bool CompletedSynchronously
    {
        get
        {
            lock (this)
            {
                if (_completedSynchronously == 0)
                {
                    _completedSynchronously = _completed ? 1 : -1;
                }

                return _completedSynchronously == 1;
            }
        }
    }

    public bool IsCompleted
    {
        get
        {
            lock (this)
            {
                return _completed;
            }
        }
    }
}

这些代码复杂,乱七八糟,这不是一个好的实现。例如:

  1. IAsyncResult 的实现在每个操作中都使用了锁,而没有尽可能采用更无锁化的方式。
  2. 异常是以原始形式存储的,而不是使用 ExceptionDispatchInfo 来增强传播时的调用栈。
  3. 每次操作都有很多分配开销(例如,每次 BeginWrite 调用都会分配一个委托)。

现在,想象一下,如果你需要为每个想写的可复用方法都做这一切操作会怎样?每次你想写一个可以使用另一个异步操作的可复用方法,就需要完成这些繁琐的工作。而如果你想写一些可以高效操作多个离散 IAsyncResult 的可复用组合器(例如 Task.WhenAll),那更是难上加难。因为每个操作都有自己特定的 API,要实现通用的操作语言是非常困难的(尽管有些开发者通过增加另一层回调尝试简化这一点,但这通常带来更多复杂性)。

所有这些复杂性导致很少有人尝试这样做,而那些尝试的人也经常碰到各种 Bug。 这并不是对 APM 模式的批评,而是对基于回调的异步操作的一种批判。我们已经习惯了现代语言中控制流构造的强大和简单,而基于回调的方法通常会因为这些控制流的缺失而变得非常复杂。一些主流语言同样没有提供更好的替代方案。

 

我们需要一种改进的模式,能够吸取 APM 的优点,同时避免其缺陷。

值得注意的是,APM 只是一种模式;运行时、核心库和编译器在使用或实现这种模式时并没有提供任何支持。

.NET Framework 2.0 基于事件的异步模式(EAP)

在 .NET Framework 2.0 中引入了一些新的 API,它们实现了一种不同的异步操作处理模式,主要用于客户端应用程序的上下文中。这种模式被称为事件驱动异步模式(Event-based Asynchronous Pattern,简称 EAP),通常包含一对成员(有时可能更多),分别是一个用于启动异步操作的方法和一个用于监听操作完成的事件。因此,之前提到的 DoStuff 示例可能会以类似这样的成员形式暴露出来:

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; }
}

你可以通过 DoStuffCompleted 事件注册后续的处理逻辑,并调用 DoStuffAsync 方法启动异步操作。当操作完成时,DoStuffCompleted 事件会异步从调用者触发。事件处理程序可以运行后续的处理逻辑,通常会验证提供的 userToken 是否符合预期,以支持同时挂载多个处理程序。

这种模式在某些情况下确实简化了操作,但在其他用例中却显著增加了复杂性(尤其是考虑到之前的 APM 模式已经足够复杂)。因此,EAP 并未被广泛采用,仅在 .NET Framework 的单个版本中短暂存在。不过,它留下了一些在该期间添加的 API,例如 Ping.SendAsyncPing.PingCompleted

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

不过,EAP 模式的引入也带来了一个显著的进步——SynchronizationContext。这是 APM 模式(Asynchronous Programming Model)完全没有考虑到的,并且在今天采用的模型中得到了延续。

引入 SynchronizationContext

SynchronizationContext 是在 .NET Framework 2.0 中引入的,它是作为通用调度器的抽象存在的。具体来说,SynchronizationContext 最常用的方法是 Post,它用于将一个工作项排队到由该上下文表示的调度器。

例如,SynchronizationContext 的基本实现只是代表了ThreadPool,因此其 Post 方法的基本实现仅仅委托给 ThreadPool.QueueUserWorkItem,后者用于要求线程池调用提供的回调及其相关状态。然而,SynchronizationContext 的核心价值不仅仅是支持任意调度器,而是提供一种支持多种应用模型需求的调度方式。

以 Windows Forms 这样的 UI 框架为例。与大多数 Windows 上的 UI 框架类似,控件是与特定线程关联的,该线程运行一个消息循环,用于执行能够与这些控件交互的工作:只有该线程可以操作这些控件,其他线程如果想要与控件交互,必须通过向 UI 线程的消息循环发送消息来实现。Windows Forms 提供了像 Control.BeginInvoke 这样的方法,使得这一点变得简单。Control.BeginInvoke 会将提供的委托及其参数排队由与该控件关联的线程来执行。这使得我们可以编写类似如下代码:

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

这样就可以将 ComputeMessage() 的工作分配到线程池线程中完成(以保持 UI 的响应性),然后在该工作完成后,将一个委托排队回与 button1 关联的线程以更新 button1 的标签。非常简单。WPF 提供了类似的功能,但其使用的是 Dispatcher 类型:

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

.NET MAUI 也有类似的机制。那么,如果我们想把这个逻辑封装到一个辅助方法中会怎么样呢?例如:

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

然后我可以像这样使用它:

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

我们如何实现 ComputeMessageAndInvokeUpdate,以便它能够适用于任何应用?是否需要硬编码以支持所有可能的 UI 框架?这正SynchronizationContext 发挥作用的地方。我们可以这样实现方法:

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);
        }
    });
}

这样,我们使用 SynchronizationContext 作为抽象来定位所需的“调度器”,以返回到适合与 UI 交互的必要环境。每种应用模型都会确保它发布的 SynchronizationContext.Current 是一个 SynchronizationContext 派生类型,并执行“正确的操作”。例如,Windows Forms 提供了这样的实现

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

WPF 的实现 也类似:

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

ASP.NET 曾经也有自己的实现,它不关心工作在哪个线程上运行,而是确保与给定请求关联的工作是串行化的,以避免多个线程同时访问同一个 HttpContext

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

这种机制不仅限于主流应用模型。例如,xUnit 是一个流行的单元测试框架(.NET 的核心代码库也使用它进行单元测试),它也实现了多个自定义的 SynchronizationContext。例如,你可以允许测试并行运行,但限制并发运行的测试数量。这是如何实现的呢?通过 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();
    }
}

 

MaxConcurrencySyncContextPost 方法会将工作排队到其内部工作队列中,并在其工作线程中处理这些工作,根据所需的最大并发数控制线程数量。原理类似。

与事件异步模式(EAP)的关系

事件驱动异步模式(EAP)是如何与 SynchronizationContext 关联的?

EAP 和 SynchronizationContext 是同时引入的,而 EAP 的设计规定,完成事件应当被排队到异步操作启动时所处的 SynchronizationContext 中。为了稍微简化这个过程(尽管复杂度的降低可能并不足以完全证明其合理性),System.ComponentModel 中引入了一些辅助类型,特别是 AsyncOperationAsyncOperationManager。其中,AsyncOperation 实际上是一个元组,它封装了用户提供的状态对象以及捕获的 SynchronizationContext;而 AsyncOperationManager 则充当一个简单的工厂,用于捕获当前的 SynchronizationContext 并创建 AsyncOperation 实例。随后,EAP 的实现会利用这些类型。例如,Ping.SendAsync 调用了 AsyncOperationManager.CreateOperation 来捕获当前的 SynchronizationContext,并在操作完成时调用 AsyncOperationPostOperationCompleted 方法,从而触发存储的 SynchronizationContextPost 方法。

SynchronizationContext 提供了一些值得一提的附加功能,后面可能会再次出现。具体来说,它暴露了 OperationStartedOperationCompleted 方法。这些虚方法的基础实现是空的,没有任何实际操作,但派生实现可以重写这些方法,以便了解当前正在执行的操作。这意味着,EAP 的实现会在每个异步操作的开始和结束时调用 OperationStartedOperationCompleted,从而通知当前的 SynchronizationContext 并让其跟踪相关任务。对于 EAP 模式来说,这一点尤为重要,因为启动异步操作的方法通常是返回 void 的:你无法从中获取任何用于单独跟踪操作的东西。我们稍后会回到这个话题。

因此,我们需要一种比 APM 模式更好的方案,而紧随其后的 EAP 虽然引入了一些新特性,但并未真正解决我们面临的核心问题。我们仍然需要更好的方法。

 

 

.NET Framework 4.0 引入Task

.NET Framework 4.0 引入了 System.Threading.Tasks.Task 类型。从本质上来说,Task 是一个数据结构,用于表示某些异步操作最终的完成状态(在其他框架中,这类类型通常被称为“promise”或“future”)。Task 的创建是为了表示某个操作,而当该操作逻辑上完成时,结果会存储到 Task。这看起来很简单,但 Task 提供了一个关键特性,使其比 IAsyncResult 实用得多:它内置了“延续”(continuation)的概念。

这个特性允许我们对任何一个 Task 提出请求,以便在其完成时收到异步通知,并由 Task 自行处理同步工作,确保延续操作的触发无论是在 Task 已经完成、尚未完成还是在我们发出通知请求时正处于完成过程中,都能顺利进行。这为何如此重要?

回顾我们对旧 APM 模式的讨论,主要有两个核心问题:

  1. 你必须为每个操作实现一个自定义的 IAsyncResult 实现:没有内置的 IAsyncResult 实现可以通用。
  2. 你必须在调用 Begin 方法之前就知道操作完成时需要执行的内容。这使得实现组合器(combinators)以及其他通用的异步操作消费和组合例程变得非常困难。

Task 则完全不同。共享的 Task 表示允许你在异步操作启动后才提供延续,而无需在启动操作时就将延续绑定到方法上。任何拥有异步操作的人都可以生成一个 Task,而任何消费异步操作的人都可以使用一个 Task,两者之间无需任何定制化的连接操作Task 成为了异步操作生产者和消费者之间的“通用语言”,从而改变了 .NET 的生态环境。稍后我们会深入探讨这一点。

现在,让我们更好地理解其意义。与其深入探讨 Task 的复杂实现,我们采用教学方式,简单实现一个简化版。这并非一个完美的实现,仅仅是功能上足够完整,以帮助理解 Task 的核心本质。归根结底,Task 只是一个数据结构,用于协调完成信号的设置和接收。我们将从几个字段开始:

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

我们需要一个字段来标识任务是否已经完成(_completed),还需要一个字段来存储导致任务失败的任何错误(_error)。如果我们同时实现一个泛型的 MyTask<TResult>,还会有一个私有的 TResult _result 字段,用于存储操作成功的结果。到目前为止,这看起来与我们之前实现的自定义 IAsyncResult 很相似(当然,这并非巧合)。但真正的亮点是 _continuation 字段。在这个简单的实现中,我们仅支持一个延续(continuation),但对于解释概念已经足够了(真实的 Task 使用一个对象字段,该字段可以是单个延续对象,也可以是包含多个延续对象的 List<>)。这是一个委托,在任务完成时会被调用

接下来是一些基础方法。正如所述,Task 相较于之前的模型的一个根本进步是,允许在操作启动后提供延续工作(即回调)。因此,我们需要一个方法来支持这一点,让我们添加一个 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();
        }
    }
}
  • 如果在调用 ContinueWith 时任务已经标记为完成,那么 ContinueWith 只需将委托的执行排入队列。
  • 否则,该方法会存储委托,以便在任务完成时将延续排入队列(它还会存储一个叫做 ExecutionContext 的东西,并在稍后调用委托时使用它,不过暂时不用担心这个,我们稍后会讲到)。

逻辑很简单。

接着,我们需要能够将 MyTask 标记为完成,即它所表示的异步操作已经结束。为此,我们会暴露两个方法:一个用于成功完成任务(SetResult),另一个用于标记任务以错误结束(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);
                }
            });
        }
    }
}

这些方法会存储任何错误,标记任务为已完成,并在之前注册了延续时将其排入队列以执行。

最后,我们需要一种方式传播任务中可能发生的异常(如果是泛型 MyTask<T>,则还需要返回其 _result);为了支持某些场景,我们还允许该方法阻塞调用线程以等待任务完成,这可以通过 ContinueWith 来实现(延续只需发出一个 ManualResetEventSlim 信号,调用者随后会阻塞以等待完成)。

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);
    }
}

这就是基本实现。当然,真实的 Task 要复杂得多,

  • 提供了更高效的实现,
  • 支持任意数量的延续,
  • 允许通过多种方式配置其行为(例如,延续应该像这里一样被队列化还是同步触发),
  • 支持存储多个异常而不是单个异常,
  • 支持对取消的特殊处理,
  • 以及提供大量用于常见操作的辅助方法(例如 Task.Run,它创建一个 Task 来表示一个排入线程池队列的委托),等等。

但所有这些都不是“魔法”;从核心上看,它只是我们在这里实现的功能扩展。

你可能还注意到,我的简单 MyTask 直接公开了 SetResult/SetException 方法,Task 并没有。实际上,Task 确实有这些方法,只是它们是内部方法,且通过 System.Threading.Tasks.TaskCompletionSource 类型作为任务及其完成的单独“生产者”。这样做不是出于技术上的必要,而是为了让任务的完成方法与只用于消费的对象分离开来。这样,你可以分发一个 Task,而不必担心它会被别人“提前完成”;完成信号是创建任务的实现细节,并保留了通过持有 TaskCompletionSource 来完成它的权利(CancellationTokenCancellationTokenSource 遵循类似的模式:CancellationToken 只是 CancellationTokenSource 的一个结构包装,仅提供与消费取消信号相关的公共接口,而无法产生取消信号的能力,这一能力被限制在拥有 CancellationTokenSource 的人手中)。

当然,我们还可以为这个简单的 MyTask 实现类似 Task 提供的组合器和辅助方法。例如,实现一个简单的 MyTask.WhenAll 方法:

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;
}

这样,我们就实现了一个简化版的 WhenAll 方法,用于等待多个任务全部完成。

想要使用 MyTask.Run ?没问题:

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

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

    return t;
}

 

那么MyTask.Delay 呢?当然:

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

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

    return t;
}

 

你懂的。有了 Task,.NET 中之前的所有异步模式都成为过去式。任何以前基于 APM 模式或 EAP 模式实现的异步功能,现在都提供了返回 Task 的新方法。

 

ValueTask

Task 到今天仍然是 .NET 中异步操作的主力,每个新版本都会引入返回 TaskTask<TResult> 的新方法,并且在生态系统中也被广泛使用。然而,Task 是一个类,这意味着创建一个 Task 会涉及到内存分配。通常情况下,对于一个长期运行的异步操作来说,额外的内存分配成本微不足道,对性能的影响可以忽略不计,除非是极端注重性能的场景。但正如前面提到的,异步操作同步完成的情况相当常见。例如,Stream.ReadAsync 返回的是一个 Task<int>,但如果你读取的是 BufferedStream,很多读取操作可能会因为只需要从内存缓冲区中提取数据而同步完成,而无需执行系统调用或真正的 I/O为了这种操作分配一个额外对象就显得有些不划算(需要注意的是,这种情况在 APM 模式下也存在)。对于非泛型的 Task 方法,可以直接返回一个已完成的单例任务(比如 Task.CompletedTask)。但对于 Task<TResult> 来说,不可能为每种可能的 TResult 结果缓存一个 Task。那么如何让这些同步完成的操作更高效呢?

缓存优化方案

某些情况下可以缓存 Task<TResult>。例如,

Task<bool> 很常见,而结果只有两种可能:truefalse,因此可以缓存一个结果为 trueTask<bool> 和一个结果为 falseTask<bool>。又或者,虽然缓存 40 亿个 Task<int> 来覆盖所有可能的 Int32 结果显然不现实,但一些小整数(比如 -18)非常常见,因此可以缓存这些小整数的 Task<int>

此外,对于任意类型,default 值通常比较常见,所以可以为每个相关类型缓存一个结果为 default(TResult)Task<TResult>。实际上,在最近的 .NET 版本中,Task.FromResult 就实现了这点:它使用一个小型缓存存储这些可复用的 Task<TResult> 单例,并在适当时返回它们,否则就为具体的结果值分配一个新的 Task<TResult>

除此之外,还可以设计其他方案来处理常见情况。例如,在使用 Stream.ReadAsync 时,通常会在同一个流上多次调用,并且每次调用的读取字节数 count 很可能相同。如果实现能够完全满足这个字节数的请求,那么 Stream.ReadAsync 很可能多次返回相同的 int 结果值。为了避免这种情况下的多次分配,某些流类型(比如 MemoryStream)会缓存上一次成功返回的 Task<int>,如果下一次读取同样是同步完成且结果相同,就可以直接返回之前的 Task<int>,而无需创建新对象。

但是其他情况呢?在性能开销真正重要的情况下,如何更普遍地避免同步完成的分配

ValueTask<TResult> 的引入

这就是ValueTask的作用(ValueTask的详细说明也可用)。ValueTask最初是一个TResult和Task的联合体。最终,忽略所有的花哨功能,这就是它的全部(或者更确切地说,曾经是的),即立即结果或将来某个时刻的结果承诺:

这就是 ValueTask<TResult> 的用途(可以查看更详细的 ValueTask<TResult>分析)。ValueTask<TResult> 最初的设计是一个区分联合(discriminated union),可以表示 TResult 或者 Task<TResult>。本质上(忽略所有附加功能),它就是一个 TResult 或未来某个时间点的 Task<TResult>

public readonly struct ValueTask<TResult>
{
   private readonly Task<TResult>? _task;
   private readonly TResult _result;
   ...
}

方法可以返回 ValueTask<TResult> 而不是 Task<TResult>,通过使用一个更大的返回类型和一点额外的间接访问开销,可以避免在结果已知的情况下分配 Task<TResult>

进一步优化异步完成场景

但在某些极端高性能场景中,即使异步完成时也希望避免 Task<TResult> 的分配。例如,Socket 位于网络栈的底层,其 SendAsyncReceiveAsync 方法是许多服务的关键路径,这些方法既经常同步完成,也经常异步完成(大多数发送操作是同步完成的,而许多接收操作因为数据已经缓存在内核中也会同步完成)。如果能在某个 Socket 上让这些发送和接收操作完全无分配,无论是同步还是异步完成,岂不是很好?

这就需要引入 System.Threading.Tasks.Sources.IValueTaskSource<TResult> 接口:

public interface IValueTaskSource<out TResult>
{
    TResult GetResult(short token);
    ValueTaskSourceStatus GetStatus(short token);
    void OnCompleted(Action<object?> continuation, object? state, short token, ValueTaskSourceOnCompletedFlags flags);
    
}

通过实现 IValueTaskSource<TResult>,可以为 ValueTask<TResult> 提供一个自定义的后备对象,实现 GetResult 方法来获取操作的结果,以及 OnCompleted 方法来将续延(continuation)与操作挂钩。

ValueTask<TResult> 的演化

IValueTaskSource<TResult> 接口允许实现提供自己的后备对象来支持 ValueTask<TResult>,使该对象可以实现 GetResult 方法以获取操作结果,并通过 OnCompleted 方法将续延操作挂钩到该操作。这样,ValueTask<TResult> 在定义上做了一个小调整,将其 Task<TResult>? _task 字段替换为 object? _obj 字段:

public readonly struct ValueTask<TResult>
{
   private readonly object? _obj;
   private readonly TResult _result;
   ...
}

_task 字段只能是 Task<TResult>null 不同,_obj 字段现在也可以是实现了 IValueTaskSource<TResult> 接口的对象。一旦 Task<TResult> 被标记为完成,它就会保持完成状态,永远不会再回到未完成的状态。与之不同,IValueTaskSource<TResult> 实现可以完全控制其状态,可以在完成与未完成之间双向转换。ValueTask<TResult> 的契约是一个实例只能被消费一次,因此在构建时不应观察到底层实例的后消费状态变化(这也是像 CA2012 这样的分析规则存在的原因)。这使得像 Socket 这样的类型可以池化 IValueTaskSource<TResult> 实例,以便重复使用。Socket 最多缓存两个这样的实例,一个用于读取,一个用于写入,因为 99.999% 的情况下同时只有一个接收和一个发送操作在进行中。

我提到过 ValueTask<TResult> 但没有提到 ValueTask。在只处理避免同步完成分配的场景时,非泛型的 ValueTask(代表没有结果的 void 操作)并不会带来显著的性能提升,因为可以通过 Task.CompletedTask 来表示相同的条件。但是一旦我们需要使用一个可池化的底层对象来避免异步完成时的分配,这对非泛型的 ValueTask 也同样适用。因此,在引入 IValueTaskSource<TResult> 时,也引入了 IValueTaskSourceValueTask

 

我们现在有了 TaskTask<TResult>ValueTaskValueTask<TResult>。我们可以通过多种方式与它们进行交互,表示任意的异步操作,并将续延挂钩到这些操作的完成上。是的,我们可以在操作完成之前或之后进行操作。

但是…这些续延仍然是回调!

我们仍然被迫采用续延传递风格来编码我们的异步控制流!!

它仍然非常难以正确实现!!!

C# 迭代器来解救(续延传递的风格)

事实上,解决方案的曙光在 Task 出现之前几年就已经出现,那就是 C# 2.0 中的迭代器支持。

“迭代器?你是说 IEnumerable<T> 吗?” 没错。迭代器让你编写一个方法,然后由编译器实现 IEnumerable<T> 和/或 IEnumerator<T>。例如,如果我想创建一个返回斐波那契数列的可枚举对象,我可能会这样写:

public static IEnumerable<int> Fib()
{
    int prev = 0, next = 1;
    yield return prev;
    yield return next;

    while (true)
    {
        int sum = prev + next;
        yield return sum;
        prev = next;
        next = sum;
    }
}

然后我可以用 foreach 枚举它:

foreach (int i in Fib())
{
    if (i > 100) break;
    Console.Write($"{i} ");
}

也可以通过 System.Linq.Enumerable 上的组合器将它与其他 IEnumerable 组合起来:

foreach (int i in Fib().Take(12))
{
    Console.Write($"{i} ");
}

或者可以直接通过 IEnumerator<T> 手动枚举它:

using IEnumerator<int> e = Fib().GetEnumerator();
while (e.MoveNext())
{
    int i = e.Current;
    if (i > 100) break;
    Console.Write($"{i} ");
}

以上所有结果都会产生下面的输出:

0 1 1 2 3 5 8 13 21 34 55 89

使用 C# 的 yield return 实现迭代器与异步编程的起源

有趣的是,要实现上述功能,我们需要能够多次进入和退出 Fib 方法。当我们调用 MoveNext 时,它会进入方法,方法会一直执行,直到遇到 yield return,这时 MoveNext 调用需要返回 true,随后对 Current 的访问应该返回当前的 yield 值。接着,我们再次调用 MoveNext,需要能够从 Fib 上次退出的地方继续执行,同时保留之前调用的所有状态。

迭代器本质上是 C# 语言和编译器提供的协程功能,编译器会将 Fib 迭代器扩展为一个完整的状态机:

public static IEnumerable<int> Fib() => new <Fib>d__0(-2);

[CompilerGenerated]
private sealed class <Fib>d__0 : IEnumerable<int>, IEnumerable, IEnumerator<int>, IEnumerator, IDisposable
{
    private int <>1__state;
    private int <>2__current;
    private int <>l__initialThreadId;
    private int <prev>5__2;
    private int <next>5__3;
    private int <sum>5__4;

    int IEnumerator<int>.Current => <>2__current;
    object IEnumerator.Current => <>2__current;

    public <Fib>d__0(int <>1__state)
    {
        this.<>1__state = <>1__state;
        <>l__initialThreadId = Environment.CurrentManagedThreadId;
    }

    private bool MoveNext()
    {
        switch (<>1__state)
        {
            default:
                return false;
            case 0:
                <>1__state = -1;
                <prev>5__2 = 0;
                <next>5__3 = 1;
                <>2__current = <prev>5__2;
                <>1__state = 1;
                return true;
            case 1:
                <>1__state = -1;
                <>2__current = <next>5__3;
                <>1__state = 2;
                return true;
            case 2:
                <>1__state = -1;
                break;
            case 3:
                <>1__state = -1;
                <prev>5__2 = <next>5__3;
                <next>5__3 = <sum>5__4;
                break;
        }
        <sum>5__4 = <prev>5__2 + <next>5__3;
        <>2__current = <sum>5__4;
        <>1__state = 3;
        return true;
    }

    IEnumerator<int> IEnumerable<int>.GetEnumerator()
    {
        if (<>1__state == -2 &&
            <>l__initialThreadId == Environment.CurrentManagedThreadId)
        {
            <>1__state = 0;
            return this;
        }
        return new <Fib>d__0(0);
    }

    IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable<int>)this).GetEnumerator();
    void IEnumerator.Reset() => throw new NotSupportedException();
    void IDisposable.Dispose() { }
}

现在,Fib 的所有逻辑都被包含在了 MoveNext 方法中,并通过一个跳转表来实现逻辑分支,以跟踪上次的执行位置,这些位置会被存储在枚举器类型生成的状态字段中。而我在代码中写的局部变量(如 prevnextsum)被“提升”为枚举器中的字段,以便在 MoveNext 的多次调用之间保持其状态。

注意:前面代码片段中显示了 C# 编译器如何生成这些实现,但无法直接编译。因为 C# 编译器会生成一些“不可言说的”名称(unspeakable names),即为类型和成员生成合法的 IL 名称,但这些名称在 C# 中是无效的,以避免与用户定义的类型和成员冲突。如果你想尝试编译代码,可以将这些名称改为合法的 C# 名称。

在前面的例子中,我展示的最后一种枚举形式是手动使用 IEnumerator<T>。在这个级别上,我们需要手动调用 MoveNext(),并决定何时重新进入协程(coroutine)。但是……如果我们能在下一次调用 MoveNext 时,将它作为异步操作完成后执行的延续部分,该怎么办?换句话说,如果我可以 yield return 一个代表异步操作的对象,并让使用方将延续逻辑与该对象关联起来,那么延续逻辑将调用 MoveNext。通过这种方法,我可以写出类似这样的辅助方法:

static Task IterateAsync(IEnumerable<Task> tasks)
{
    var tcs = new TaskCompletionSource();

    IEnumerator<Task> e = tasks.GetEnumerator();

    void Process()
    {
        try
        {
            if (e.MoveNext())
            {
                e.Current.ContinueWith(t => Process());
                return;
            }
        }
        catch (Exception e)
        {
            tcs.SetException(e);
            return;
        }
        tcs.SetResult();
    };
    Process();

    return tcs.Task;
}

使用迭代器与异步任务

这就有趣了。这里我们有一个任务(Task)的可枚举集合(IEnumerable<Task>),可以对其进行迭代。每次我们通过调用 MoveNext 获取下一个 Task,都会为其挂接一个延续(continuation)。当这个任务完成时,它会回调到相同的逻辑,调用 MoveNext,获取下一个任务,如此循环往复。这种方式基于 Task 的单一抽象模型,可用于表示任何异步操作,因此我们提供的可枚举对象可以是任何异步操作的序列。

这些异步操作的序列从哪里来呢?当然是迭代器!还记得我们之前讨论的 CopyStreamToStream 示例,以及它基于 APM 模型的恐怖实现吗?来看一个改进后的例子:

static Task CopyStreamToStreamAsync(Stream source, Stream destination)
{
    return IterateAsync(Impl(source, destination));

    static IEnumerable<Task> Impl(Stream source, Stream destination)
    {
        var buffer = new byte[0x1000];
        while (true)
        {
            Task<int> read = source.ReadAsync(buffer, 0, buffer.Length);
            yield return read;
            int numRead = read.Result;
            if (numRead <= 0)
            {
                break;
            }

            Task write = destination.WriteAsync(buffer, 0, numRead);
            yield return write;
            write.Wait();
        }
    }
}

引入 async/await

这几乎可以算是“可读”代码了。我们调用了 IterateAsync 辅助方法,并向它提供了一个由迭代器生成的可枚举对象,这个迭代器负责处理整个复制过程的控制流程。迭代器调用 Stream.ReadAsync,然后通过 yield return 返回这个 Task;这个被返回的任务会在调用 MoveNext 后传递给 IterateAsyncIterateAsync 会为这个任务挂接一个续延(continuation),当任务完成后,这个续延会再次调用 MoveNext,从上一次的 yield 点继续执行迭代器。此时,迭代器内部的逻辑会获取方法的结果,调用 WriteAsync,并再次返回生成的 Task,如此循环往复。

这就是 C# 和 .NET 中 async/await 的起源。事实上,支持迭代器和 async/await 的 C# 编译器逻辑大约有 95% 是共用的。虽然语法不同、涉及的类型不同,但它们本质上是相同的转换过程。如果你稍微眯起眼看这些 yield return,几乎能将它们视为 await

实际上,在 async/await 出现之前,一些有远见的开发者已经以这种方式利用迭代器进行异步编程了。而类似的转换也曾在实验性的 Axum 编程语言中被原型化,并成为 C# 异步支持的关键灵感来源。Axum 提供了一个 async 关键字,可以像现在 C# 中一样附加到方法上。当时 Task 尚未普及,因此在 async 方法中,Axum 编译器会启发式地将同步方法调用匹配到其 APM(异步编程模型)对应的方法。例如,如果它看到你调用 stream.Read,它会找到并使用对应的 stream.BeginReadstream.EndRead 方法,同时生成适当的委托以传递给 Begin 方法。此外,它还会为被定义的异步方法生成一个完整的 APM 实现,从而支持组合使用。它甚至与 SynchronizationContext 集成!虽然 Axum 最终被搁置,但它为后来的 C# async/await 提供了一个极具启发性和动力的原型。

 

async/await的背后

 

既然我们已经了解了它是如何发展到今天的,现在让我们深入研究它的实际工作原理。为了方便说明,以下是我们之前的同步方法示例:

public void CopyStreamToStream(Stream source, Stream destination)
{
    var buffer = new byte[0x1000];
    int numRead;
    while ((numRead = source.Read(buffer, 0, buffer.Length)) != 0)
    {
        destination.Write(buffer, 0, numRead);
    }
}

下面是使用 async/await 的相应方法的样子:

public async Task CopyStreamToStreamAsync(Stream source, Stream destination)
{
    var buffer = new byte[0x1000];
    int numRead;
    while ((numRead = await source.ReadAsync(buffer, 0, buffer.Length)) != 0)
    {
        await destination.WriteAsync(buffer, 0, numRead);
    }
}

清新的体验

与我们目前为止看到的一切相比,这种方式堪称耳目一新。方法的签名从 void 改为 async Task,我们分别调用 ReadAsyncWriteAsync 来代替 ReadWrite,并且在这两种操作前都添加了 await。仅此而已。其余的部分由编译器和核心库接管,彻底改变了代码的实际执行方式。接下来让我们深入了解其背后的原理。

 

编译器转换

如前所述,与迭代器一样,编译器将异步方法重写为基于状态机的实现。我们仍然有一个与开发者编写的签名相同的方法(例如:public Task CopyStreamToStreamAsync(Stream source, Stream destination)),但该方法的主体完全不同:

[AsyncStateMachine(typeof(<CopyStreamToStreamAsync>d__0))]
public Task CopyStreamToStreamAsync(Stream source, Stream destination)
{
    <CopyStreamToStreamAsync>d__0 stateMachine = default;
    stateMachine.<>t__builder = AsyncTaskMethodBuilder.Create();
    stateMachine.source = source;
    stateMachine.destination = destination;
    stateMachine.<>1__state = -1;
    stateMachine.<>t__builder.Start(ref stateMachine);
    return stateMachine.<>t__builder.Task;
}

private struct <CopyStreamToStreamAsync>d__0 : IAsyncStateMachine
{
    public int <>1__state;
    public AsyncTaskMethodBuilder <>t__builder;
    public Stream source;
    public Stream destination;
    private byte[] <buffer>5__2;
    private TaskAwaiter <>u__1;
    private TaskAwaiter<int> <>u__2;

    ...
}

注意,与开发者编写的代码相比,唯一的签名差异是缺少了 async 关键字。实际上,async 并不是方法签名的一部分;就像 unsafe 一样,当你在方法签名中添加它时,表达的是方法的实现细节,而不是方法契约的一部分。使用 async/await 来实现返回 Task 的方法只是一个实现细节。

状态机的生成

编译器生成了一个名为 <CopyStreamToStreamAsync>d__0 的结构体,并在栈上以零初始化了该结构体的一个实例。重要的是,如果异步方法同步完成,那么这个状态机永远不会离开栈空间。这意味着,只有当方法需要异步完成(即等待尚未完成的任务时),状态机才会涉及分配。稍后我们会详细探讨这一点。

这个结构体就是该方法的状态机,包含了开发者所编写的所有逻辑的转换结果,同时也包含了以下内容:

  1. 用于跟踪该方法当前位置的字段。
  2. 编译器从方法中“提升”出来的所有局部状态(需要在多次调用 MoveNext 之间存活的部分)。

状态机的逻辑等同于我们在迭代器中看到的 IEnumerable<T>/IEnumerator<T> 实现。(需要注意的是,这里展示的代码来自发布版本;而在调试版本中,C# 编译器实际上会将这些状态机类型生成为类,因为这样更有利于某些调试操作)。

初始化状态机

在初始化状态机后,我们可以看到对 AsyncTaskMethodBuilder.Create() 的调用。虽然我们当前专注于 Task 类型,但 C# 语言和编译器允许从异步方法返回任意类型(“类似任务的”类型)。例如,我可以编写一个方法 public async MyTask CopyStreamToStreamAsync,只要对我们之前定义的 MyTask 进行了适当扩展,这样的代码就可以成功编译。

这种适当扩展包括:

  1. 声明一个关联的“生成器”类型。
  2. 通过 AsyncMethodBuilder 属性将其与类型关联:
[AsyncMethodBuilder(typeof(MyTaskMethodBuilder))]
public class MyTask
{
    ...
}

public struct MyTaskMethodBuilder
{
    public static MyTaskMethodBuilder Create() { ... }

    public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine { ... }
    public void SetStateMachine(IAsyncStateMachine stateMachine) { ... }

    public void SetResult() { ... }
    public void SetException(Exception exception) { ... }

    public void AwaitOnCompleted<TAwaiter, TStateMachine>(
        ref TAwaiter awaiter, ref TStateMachine stateMachine)
        where TAwaiter : INotifyCompletion
        where TStateMachine : IAsyncStateMachine { ... }
    public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>(
        ref TAwaiter awaiter, ref TStateMachine stateMachine)
        where TAwaiter : ICriticalNotifyCompletion
        where TStateMachine : IAsyncStateMachine { ... }

    public MyTask Task { get { ... } }
}

在这个上下文中,“builder”是一个负责创建该类型实例(即 Task 属性)的方法,并能够根据需要完成任务:要么成功并返回结果(SetResult),要么抛出异常(SetException)。此外,它还负责处理对尚未完成的异步操作挂接后续操作(AwaitOnCompleted/AwaitUnsafeOnCompleted)。对于 System.Threading.Tasks.Task,默认情况下它与 AsyncTaskMethodBuilder 关联。通常,这种关联是通过在类型上应用 [AsyncMethodBuilder(...)] 属性实现的,但由于 Task 是 C# 特殊处理的类型,因此实际上并未使用此属性。

因此,编译器会查找用于此异步方法的生成器,并通过模式中的 Create 方法构造该生成器的实例。需要注意的是,与状态机一样,AsyncTaskMethodBuilder 也是一个结构体,因此此处也没有涉及分配操作。

 

初始化状态机

接下来,状态机会填充为该入口方法的参数。这些参数需要在被移动到 MoveNext 的方法体中可用,因此必须存储在状态机中,以便后续调用 MoveNext 时可以引用这些参数。同时,状态机也会初始化为初始状态 -1。如果调用 MoveNext 时状态为 -1,逻辑上会从方法的起始位置开始。

接下来是最不起眼但意义最重大的代码:调用生成器的 Start 方法。这是异步方法返回类型必须公开的模式的一部分,用于对状态机执行初始 MoveNext 操作。生成器的 Start 方法本质上等同于以下代码:

public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine
{
    stateMachine.MoveNext();
}

因此,调用 stateMachine.<>t__builder.Start(ref stateMachine); 实际上就是调用 stateMachine.MoveNext()。既然如此,为什么编译器不直接发出这个调用,而要有 Start 呢?答案是 Start 并非如此简单。为了解释这一点,我们需要简要了解 ExecutionContext 的作用。

ExecutionContext 执行上下文

我们都熟悉在方法间传递状态的方法。如果调用一个方法,而该方法有参数,你可以通过参数将数据显式传递给被调用方法。这是明确的参数传递方式。但是,也存在更隐式的方式。例如,一个方法可以是无参的,但可以约定在调用前某些特定的静态字段已被填充,该方法会从这些静态字段中获取状态。方法签名没有指示它接收参数,因为它确实不接收。这是调用方和被调用方之间的隐式契约:调用方可能填充某些内存位置,而被调用方可能读取这些位置。甚至在中间调用链中,A 方法设置静态字段,B 调用 C,C 调用 D,最终 E 读取这些静态值,而中间方法可能对此一无所知。这通常称为“环境数据”:它不是通过参数传递的,而是作为可用的数据悬浮在那里,供需要时消费。

 

线程局部状态(Thread-Local State)

进一步,我们可以使用线程局部状态(Thread-Local State)。在 .NET 中,这可以通过 [ThreadStatic] 标记的静态字段或 ThreadLocal<T> 类型实现。它的作用类似,但数据仅限于当前执行线程,每个线程都有自己隔离的副本。这样,你可以填充线程静态字段,调用方法,然后在方法完成时还原这些字段,从而实现一种完全隔离的数据传递方式。

 

异步中的问题

但异步方法怎么办?如果异步方法需要访问环境数据,该如何实现?

  • 如果数据存储在普通的静态字段中,异步方法可以访问,但在同一时间只能有一个方法进行访问,否则多个调用者可能会覆盖共享静态字段中的状态。
  • 如果数据存储在线程静态字段中,异步方法可以访问,但只能在同步运行在调用线程上时访问;一旦挂起并切换到另一个线程,就无法再访问线程静态数据了。即使继续运行在同一线程上,也可能因为其他操作覆盖或移除数据而导致问题。

因此,异步方法需要一种机制,允许环境数据跨异步操作点流动,这样在异步方法的整个逻辑中,无论运行在哪里、何时运行,都可以访问相同的数据。

 

进入 ExecutionContext

ExecutionContext 类型是实现环境数据在异步操作间流动的载体。它存在于 [ThreadStatic] 中,但在启动某个异步操作时,它会被“捕获”(从线程静态中读取副本),然后在异步操作的后续操作运行时,ExecutionContext 会首先被还原到将要运行操作的线程的 [ThreadStatic] 中。

ExecutionContextAsyncLocal<T> 的实现机制(实际上,在 .NET Core 中,ExecutionContext 完全是为 AsyncLocal<T> 服务的)。例如,如果将一个值存储到 AsyncLocal<T> 中,然后将某个工作项队列到线程池上,那么该值将在线程池上的工作项中可见:

var number = new AsyncLocal<int>();

number.Value = 42;
ThreadPool.QueueUserWorkItem(_ => Console.WriteLine(number.Value));
number.Value = 0;

Console.ReadLine();

这段代码每次运行都会打印出42。无论我们在调用QueueUserWorkItem后立即将AsyncLocal<int>的值重置为0,结果都不会改变,因为在调用QueueUserWorkItem时捕获了ExecutionContext,该捕获包含了AsyncLocal<int>在那个特定时刻的状态。我们可以通过实现一个简单的线程池来更详细地观察这一点:

using System.Collections.Concurrent;

var number = new AsyncLocal<int>();

number.Value = 42;
MyThreadPool.QueueUserWorkItem(() => Console.WriteLine(number.Value));
number.Value = 0;

Console.ReadLine();

class MyThreadPool
{
    private static readonly BlockingCollection<(Action, ExecutionContext?)> s_workItems = new();

    public static void QueueUserWorkItem(Action workItem)
    {
        s_workItems.Add((workItem, ExecutionContext.Capture()));
    }

    static MyThreadPool()
    {
        for (int i = 0; i < Environment.ProcessorCount; i++)
        {
            new Thread(() =>
            {
                while (true)
                {
                    (Action action, ExecutionContext? ec) = s_workItems.Take();
                    if (ec is null)
                    {
                        action();
                    }
                    else
                    {
                        ExecutionContext.Run(ec, s => ((Action)s!)(), action);
                    }
                }
            })
            { IsBackground = true }.UnsafeStart();
        }
    }
}

这里,MyThreadPool有一个BlockingCollection<(Action, ExecutionContext?)>,表示它的工作项队列,每个工作项由要调用的工作代理和与该工作相关联的ExecutionContext组成。线程池的静态构造函数会启动一组线程,每个线程在无限循环中从队列中取出下一个工作项并执行。如果某个代理没有捕获ExecutionContext,代理会直接调用。但如果捕获了ExecutionContext,则不会直接调用代理,而是使用ExecutionContext.Run方法:它会先将提供的ExecutionContext恢复为当前上下文,然后运行代理,最后将上下文重置为调用代理之前的状态。这个示例中包含了前面展示的使用AsyncLocal<int>的代码,但这次使用MyThreadPool代替了ThreadPool。每次运行结果仍然会打印42,因为线程池正确地传递了ExecutionContext

顺便提一下,你可能注意到在MyThreadPool的静态构造函数中我调用了UnsafeStart。启动一个新线程正是需要传递ExecutionContext的异步场景之一。实际上,ThreadStart方法会使用ExecutionContext.Capture来捕获当前上下文,将其存储在线程上,并在最终调用线程的ThreadStart代理时使用该捕获的上下文。但在这个示例中,我不希望线程捕获静态构造函数运行时的任何ExecutionContext(这样可能会使与ExecutionContext相关的演示更复杂),所以我使用了UnsafeStart方法。以Unsafe开头的线程相关方法与其对应的方法(例如Thread.Start)的行为完全相同,但不会捕获ExecutionContext。例如,Thread.StartThread.UnsafeStart的工作相同,但Start会捕获ExecutionContext,而UnsafeStart不会。

 

回到Start方法

我们在讨论AsyncTaskMethodBuilder.Start的实现时,做了一个关于ExecutionContext的插曲。之前提到AsyncTaskMethodBuilder.Start基本上是这样的:

public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine
{
    stateMachine.MoveNext();
}

然而,我之前的描述有所简化。实际上,Start方法需要将ExecutionContext考虑在内,因此更接近这样的实现:

public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine
{
    ExecutionContext previous = Thread.CurrentThread._executionContext; // [ThreadStatic] field
    try
    {
        stateMachine.MoveNext();
    }
    finally
    {
        ExecutionContext.Restore(previous); // internal helper
    }
}

在这里,我们并不是简单地调用stateMachine.MoveNext(),而是执行了一个“获取当前ExecutionContext、调用MoveNext、然后在其完成后恢复原始上下文”的流程。

这样做的原因是为了防止异步方法中的环境数据泄漏到调用者。例如,以下代码展示了为什么这很重要:

async Task ElevateAsAdminAndRunAsync()
{
    using (WindowsIdentity identity = LoginAdmin())
    {
        using (WindowsImpersonationContext impersonatedUser = identity.Impersonate())
        {
            await DoSensitiveWorkAsync();
        }
    }
}

“模拟身份”(Impersonation)是指将当前用户的环境信息更改为其他用户的信息;这让代码能够代表他人执行操作,使用他人的权限和访问控制。在.NET中,这种模拟身份可以跨异步操作流动,这意味着它是ExecutionContext的一部分。现在,假设Start没有恢复之前的上下文,考虑如下代码:

Task t = ElevateAsAdminAndRunAsync();
PrintUser();
await t;

ElevateAsAdminAndRunAsync方法内修改的ExecutionContext,在方法返回给其同步调用者之后(这发生在方法第一次遇到未完成的await时)可能会继续生效。这是因为在调用Impersonate后,我们调用了DoSensitiveWorkAsync并等待它返回的任务。假设这个任务尚未完成,这将导致ElevateAsAdminAndRunAsync的执行被挂起并返回给调用者,而模拟身份仍然在当前线程上生效。这并不是我们希望的结果。因此,Start方法引入了一种机制来确保对ExecutionContext的任何修改不会泄漏到同步方法调用之外,而只会随该方法后续的工作流动。

MoveNext方法

入口点方法被调用后,状态机结构被初始化,Start方法被调用,接着它调用了MoveNext。那么,什么是MoveNext?它是一个包含开发者方法中所有原始逻辑的函数,但经过了大量的修改。我们先来看一下方法的基本框架。以下是编译器为我们的方法生成的反编译版本,但去掉了生成的try块内部的所有内容:

private void MoveNext()
{
    try
    {
        ... // all of the code from the CopyStreamToStreamAsync method body, but not exactly as it was written
    }
    catch (Exception exception)
    {
        <>1__state = -2;
        <buffer>5__2 = null;
        <>t__builder.SetException(exception);
        return;
    }

    <>1__state = -2;
    <buffer>5__2 = null;
    <>t__builder.SetResult();
}

无论MoveNext执行了什么其他工作,它都有责任在所有工作完成后,完成从异步Task方法返回的Task。如果try块内的代码抛出了未处理的异常,那么返回的Task将被标记为故障状态并附带该异常。而如果异步方法成功执行到结束(相当于同步方法的返回),返回的任务将被标记为成功状态。在这两种情况下,状态机的状态都会被设置为完成状态。(一些开发者有时会推测,在异常处理中,抛出异常的位置(在第一个await之前或之后)会有不同的行为。但根据以上内容可以看出,无论异常发生在方法的哪个位置,也无论方法是否挂起,只要是异步方法中未处理的异常,都会进入上述的catch块中,并将捕获的异常存储到从异步方法返回的Task中。)

此外,请注意完成操作是通过构建器(builder)完成的,使用的是其SetExceptionSetResult方法。这些方法是编译器期望的构建器模式的一部分。如果异步方法之前已经挂起,构建器在处理挂起时会生成一个Task(稍后我们将看到如何和在哪里生成),这时调用SetExceptionSetResult会完成该任务。然而,如果异步方法之前从未挂起过,那么我们尚未创建Task或将任何内容返回给调用者,因此构建器在生成Task时会有更多的灵活性。如果你还记得在入口点方法中,最后一件事是将Task返回给调用者,这是通过返回构建器的Task属性的结果来实现的(是的,有很多地方叫“Task”,我知道):

public Task CopyStreamToStreamAsync(Stream source, Stream destination)
{
    ...
    return stateMachine.<>t__builder.Task;
}

构建器的行为

构建器能够判断方法是否曾经挂起过。如果挂起过,它已经创建了一个Task并直接返回该任务。如果方法从未挂起过,而构建器还没有任务,则它可以在此处生成一个已完成的任务。在成功完成的情况下,它可以直接使用Task.CompletedTask,而不是分配一个新的任务,从而避免任何分配操作。而对于泛型Task<TResult>,构建器可以使用Task.FromResult<TResult>(TResult result)

构建器还可以根据它正在创建的对象类型,进行适当的转换。例如,Task实际上有三种可能的最终状态:成功、失败和取消。AsyncTaskMethodBuilderSetException方法对OperationCanceledException进行了特殊处理,如果提供的异常是OperationCanceledException或其派生类,那么任务会进入TaskStatus.Canceled最终状态;否则,任务会以TaskStatus.Faulted结束。这种区分通常在消费代码中并不明显,因为无论任务是标记为取消还是出错,异常都会被存储到任务中,await这个任务的代码将无法区分这两种状态(原始异常会在两种情况下都被传播)。这种差异只影响直接与任务交互的代码,例如使用ContinueWith,后者提供了仅针对特定完成状态触发后续操作的重载。

理解生命周期

现在我们理解了生命周期的相关概念,接下来是MoveNext方法中try块内填充的所有内容:

private void MoveNext()
{
    try
    {
        int num = <>1__state;

        TaskAwaiter<int> awaiter;
        if (num != 0)
        {
            if (num != 1)
            {
                <buffer>5__2 = new byte[4096];
                goto IL_008b;
            }

            awaiter = <>u__2;
            <>u__2 = default(TaskAwaiter<int>);
            num = (<>1__state = -1);
            goto IL_00f0;
        }

        TaskAwaiter awaiter2 = <>u__1;
        <>u__1 = default(TaskAwaiter);
        num = (<>1__state = -1);
        IL_0084:
        awaiter2.GetResult();

        IL_008b:
        awaiter = source.ReadAsync(<buffer>5__2, 0, <buffer>5__2.Length).GetAwaiter();
        if (!awaiter.IsCompleted)
        {
            num = (<>1__state = 1);
            <>u__2 = awaiter;
            <>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
            return;
        }
        IL_00f0:
        int result;
        if ((result = awaiter.GetResult()) != 0)
        {
            awaiter2 = destination.WriteAsync(<buffer>5__2, 0, result).GetAwaiter();
            if (!awaiter2.IsCompleted)
            {
                num = (<>1__state = 0);
                <>u__1 = awaiter2;
                <>t__builder.AwaitUnsafeOnCompleted(ref awaiter2, ref this);
                return;
            }
            goto IL_0084;
        }
    }
    catch (Exception exception)
    {
        <>1__state = -2;
        <buffer>5__2 = null;
        <>t__builder.SetException(exception);
        return;
    }

    <>1__state = -2;
    <buffer>5__2 = null;
    <>t__builder.SetResult();
}

这种复杂性可能让你觉得有些熟悉。还记得我们基于APM手动实现的BeginCopyStreamToStream有多复杂吗?虽然这不至于那么复杂,但因为编译器帮我们完成了这些工作——以一种“延续传递”(continuation-passing)的形式重写了方法,同时确保了所有必要的状态都被保留用于这些延续操作——所以这种方式要好得多。尽管如此,我们仍然可以仔细观察并理解其过程。

还记得在入口点中,状态被初始化为-1。然后我们进入MoveNext方法,发现状态(现在存储在局部变量num中)既不是0也不是1,于是执行了创建临时缓冲区的代码,并跳转到标签IL_008b,在这里调用了stream.ReadAsync。请注意,此时我们仍然是同步运行,从MoveNext的调用开始同步运行,从Start同步运行,直到入口点代码的同步执行结束。换句话说,开发者调用了CopyStreamToStreamAsync,此时它仍在同步执行,还没有返回一个任务来表示此方法的最终完成状态。但这种情况可能很快就会改变……

我们调用Stream.ReadAsync并从中获取一个Task<int>。读取操作可能同步完成,也可能异步完成但速度非常快,以至于此时已经完成,或者它可能尚未完成。不管怎样,我们拿到了一个代表最终完成状态的Task<int>,然后编译器会生成代码来检查该任务并决定如何继续:如果Task<int>已经完成(无论是同步完成还是我们检查时刚好完成),那么此方法的代码可以继续同步运行……没有必要为处理方法后续执行而排队一个工作项,因为我们可以直接在这里继续运行。但为了处理Task<int>尚未完成的情况,编译器需要生成代码,将延续操作连接到该任务上。因此,它需要生成代码来询问任务“你完成了吗?”

编译器是否直接与任务交互来询问完成状态?

如果C#中只能await一个System.Threading.Tasks.Task,那会非常受限。同样,如果C#编译器必须知道每种可能被await的类型,也会显得局限。取而代之,C#像在其他类似情况下一样,采用了一种API模式。代码可以await任何符合适当模式的对象,这种模式被称为“awaiter”模式(正如你可以对任何提供适当“enumerable”模式的对象使用foreach)。例如,我们可以增强之前编写的MyTask类型,使其实现awaiter模式:

class MyTask
{
    ...
    public MyTaskAwaiter GetAwaiter() => new MyTaskAwaiter { _task = this };

    public struct MyTaskAwaiter : ICriticalNotifyCompletion
    {
        internal MyTask _task;

        public bool IsCompleted => _task._completed;
        public void OnCompleted(Action continuation) => _task.ContinueWith(_ => continuation());
        public void UnsafeOnCompleted(Action continuation) => _task.ContinueWith(_ => continuation());
        public void GetResult() => _task.Wait();
    }
}

一个类型如果暴露了GetAwaiter()方法,就可以被await,而Task就符合这个条件。这个方法需要返回一个对象,该对象进一步暴露了多个成员,其中包括IsCompleted属性,用于检查在调用IsCompleted时操作是否已经完成。你可以看到这一过程发生:在IL_008b处,ReadAsync返回的Task上调用了GetAwaiter,然后在该awaiter实例上访问了IsCompleted。如果IsCompleted返回true,那么代码会直接跳到IL_00f0,在那里调用了awaiter的另一个成员:GetResult()。如果操作失败,GetResult()负责抛出异常,以便将其传播出async方法中的await;否则,GetResult()负责返回操作的结果(如果有的话)。在这里,对于ReadAsync,如果结果是0,则会跳出读写循环,进入方法的最后部分,在那里调用SetResult,然后完成执行。

不过,稍微退一步,所有这些最有趣的部分是,如果IsCompleted检查实际返回false会发生什么。如果它返回true,我们就继续处理循环,类似于在APM模式中,当CompletedSynchronously返回true时,Begin方法的调用者(而不是回调)负责继续执行。但如果IsCompleted返回false,我们需要暂停异步方法的执行,直到await的操作完成。这意味着我们需要从MoveNext返回,而由于这部分是Start的一部分,我们仍然处于入口点方法中,因此我们需要将Task返回给调用者。但是在这些操作发生之前,我们需要将延续操作挂接到正在等待的Task上(需要注意的是,为了避免像APM模式中那样的堆栈深度问题,如果异步操作在IsCompleted返回false之后,但在我们挂接延续之前完成,延续操作仍然需要从调用线程异步调用,因此它将被排队)。由于我们可以await任何类型,我们不能直接与Task实例交互;相反,我们需要通过一些基于模式的方法来执行这一操作。

这是否意味着awaiter上有一个方法来挂接延续操作?这确实有道理;毕竟,Task本身支持延续操作,具有ContinueWith方法等等……难道不是GetAwaiter返回的TaskAwaiter暴露了一个方法,让我们设置延续操作吗?实际上,它确实如此。awaiter模式要求awaiter实现INotifyCompletion接口,该接口包含一个方法:void OnCompleted(Action continuation)awaiter还可以选择性地实现ICriticalNotifyCompletion接口,后者继承自INotifyCompletion并添加了一个方法:void UnsafeOnCompleted(Action continuation)。根据我们之前对ExecutionContext的讨论,你可以猜到这两个方法的区别:虽然OnCompleted会流动ExecutionContext,但UnsafeOnCompleted则不需要。之所以需要这两个不同的方法INotifyCompletion.OnCompletedICriticalNotifyCompletion.UnsafeOnCompleted,主要是历史原因,涉及代码访问安全(CAS)。在.NET Core中,CAS不再存在,并且在.NET Framework中默认关闭,只有当你选择启用遗留的部分信任功能时才会启用。启用部分信任时,CAS信息作为ExecutionContext的一部分流动,因此不流动它就是“不安全的”,这就是为什么不流动ExecutionContext的方法会以“Unsafe”开头的原因。这些方法也被标记为[SecurityCritical],而部分信任的代码不能调用标记为[SecurityCritical]的方法。因此,创建了两种OnCompleted的变体,编译器会优先使用UnsafeOnCompleted(如果提供),但会始终提供OnCompleted版本,以便支持部分信任的awaiter。然而,从异步方法的角度来看,构建器始终会在await点之间流动ExecutionContext,因此awaiter如果也流动它则是不必要且重复的工作。

好吧,那么awaiter确实暴露了一个方法来挂接延续操作。编译器本可以直接使用它,但有一个非常关键的环节:延续操作到底应该是什么?更进一步地说,应该与哪个对象关联?记住,状态机结构体位于栈上,而我们当前执行的MoveNext调用是该实例上的一个方法调用。我们需要保留状态机,以便在恢复时拥有所有正确的状态,这意味着状态机不能仅仅保存在栈上;它需要被复制到堆上的某个地方,因为栈最终会被用于此线程上执行的其他后续无关工作。然后,延续操作需要在堆上该状态机的副本上调用MoveNext方法。

 

此外,ExecutionContext在这里也很重要。状态机需要确保在挂起时捕获任何存储在ExecutionContext中的环境数据,并在恢复时应用这些数据,这意味着延续操作也需要包含该ExecutionContext。因此,仅仅创建一个指向状态机中MoveNext的委托是不够的,这也是不希望的额外开销。如果在挂起时我们创建一个指向状态机MoveNext的委托,每次这样做时我们都会将状态机结构体装箱(即使它已经作为其他对象的一部分存在于堆上),并为委托分配额外的内存(委托的this对象引用将指向一个新装箱的结构体副本)。因此,我们需要做一些复杂的操作,确保我们只在方法第一次挂起时将结构体从栈上提升到堆上,而在所有其他时候使用相同的堆对象作为MoveNext的目标,并且在此过程中确保我们已经捕获了正确的上下文,并在恢复时确保我们使用该捕获的上下文来调用操作。

这比我们希望编译器生成的逻辑要复杂得多……我们希望将其封装在一个帮助器中,原因有几个。首先,这是一段复杂的代码,应该避免被编译到每个用户的程序集里。其次,我们希望允许在实现构建器模式时自定义该逻辑(稍后在讨论池化时我们将看到为什么)。第三,我们希望能够发展和改进这段逻辑,并让现有的已编译二进制文件得到提升。这不是假设;在.NET Core 2.1中,这个支持的库代码已经完全重构,使得操作比在.NET Framework中更高效。我们将首先探索在.NET Framework中是如何工作的,然后看看现在在.NET Core中发生了什么。

你可以看到当我们需要挂起时,C#编译器生成的代码会发生什么:

if (!awaiter.IsCompleted) // we need to suspend when IsCompleted is false
{
    <>1__state = 1;
    <>u__2 = awaiter;
    <>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
    return;
}

我们将状态 ID 存储到状态字段中,该 ID 表示当方法恢复时应该跳转到的位置。然后我们将 awaiter 本身持久化到一个字段中,以便在恢复后可以调用 GetResult。在返回 MoveNext 调用之前,最后一件事是调用 <>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this),请求构建器为这个状态机的 awaiter 设置一个延续。(注意,它调用构建器的 AwaitUnsafeOnCompleted 而不是 AwaitOnCompleted,因为 awaiter 实现了 ICriticalNotifyCompletion;状态机处理执行上下文的流动,因此我们不需要要求 awaiter 也做这个……正如前面提到的,做这个只会导致重复和不必要的开销。)

AwaitUnsafeOnCompleted工作原理

AwaitUnsafeOnCompleted 方法的实现太复杂,无法在这里复制,所以我将总结它在 .NET Framework 中的工作原理:

  1. 它使用 ExecutionContext.Capture() 来抓取当前上下文。
  2. 然后它分配一个 MoveNextRunner 对象,用来包装捕获的上下文以及装箱的状态机(如果这是方法第一次挂起,我们还没有装箱状态机,所以我们就用 null 作为占位符)。
  3. 它接着创建一个指向 MoveNextRunnerRun 方法的 Action 委托;这就是它如何在捕获的 ExecutionContext 上下文中获取一个委托来调用状态机的 MoveNext
  4. 如果这是方法第一次挂起,我们还没有装箱的状态机,那么此时它会对状态机进行装箱,在堆上创建一个副本,将实例存储到类型为 IAsyncStateMachine 接口的本地变量中。然后,这个盒子会存储在之前分配的 MoveNextRunner 中。
  5. 接下来是一个让人有些困惑的步骤。如果你回顾状态机结构体的定义,它包含构建器 public AsyncTaskMethodBuilder <>t__builder;,而如果你查看构建器的定义,它包含 internal IAsyncStateMachine m_stateMachine;。构建器需要引用装箱的状态机,以便在随后的挂起中能够看到它已经装箱了状态机,并且不需要再次进行装箱。但是我们刚刚装箱了状态机,而那个状态机包含了一个其 m_stateMachine 字段为 null 的构建器。我们需要修改这个装箱状态机的构建器的 m_stateMachine,使其指向它的父盒子。为了实现这一点,编译器生成的状态机结构体实现的 IAsyncStateMachine 接口中包括了一个 void SetStateMachine(IAsyncStateMachine stateMachine); 方法,而状态机结构体中也实现了该接口方法:
private void SetStateMachine(IAsyncStateMachine stateMachine) =>
    <>t__builder.SetStateMachine(stateMachine);

因此,构建器将状态机装箱,然后将该装箱传递给装箱的 SetStateMachine 方法,该方法调用构建器的 SetStateMachine 方法,然后将装箱存储到字段中。呼。

  1. 最后,我们有一个代表延续的 Action,它被传递给 awaiter 的 UnsafeOnCompleted 方法。在 TaskAwaiter 的情况下,任务会将该 Action 存储到任务的延续列表中,以便在任务完成时,它会调用该 Action,通过 MoveNextRunner.Run 回调,经过 ExecutionContext.Run 回调,最终调用状态机的 MoveNext 方法,以重新进入状态机并从中断的地方继续执行。

这就是在 .NET Framework 中发生的事情,你可以通过分析器见证这一结果,例如运行一个分配分析器来查看每次 await 时分配了什么。让我们来看这个愚蠢的程序,我写它只是为了突出显示其中涉及的分配成本(allocation costs involved):

using System.Threading;
using System.Threading.Tasks;

class Program
{
    static async Task Main()
    {
        var al = new AsyncLocal<int>() { Value = 42 };
        for (int i = 0; i < 1000; i++)
        {
            await SomeMethodAsync();
        }
    }

    static async Task SomeMethodAsync()
    {
        for (int i = 0; i < 1000; i++)
        {
            await Task.Yield();
        }
    }
}

这个程序创建了一个 AsyncLocal<int> 来在所有后续的异步操作中传递值 42。然后,它调用了 1000 次 SomeMethodAsync,每次都会挂起/恢复 1000 次。在 Visual Studio 中,我使用 .NET 对象分配追踪分析器运行这个程序,结果如下:

 

 

那是…很多的分配!让我们逐一检查这些分配,以了解它们的来源。

  • ExecutionContext。这里分配了超过一百万个 ExecutionContext。为什么?因为在 .NET Framework 中,ExecutionContext 是一个可变的数据结构。由于我们希望传递异步操作分叉时的数据,并且不希望看到分叉之后的数据发生变化,我们需要复制 ExecutionContext。每一个分叉的操作都需要这样的副本,所以对于 1000 次调用 SomeMethodAsync,每次挂起/恢复 1000 次,我们就会有一百万个 ExecutionContext 实例。真是疼痛。
  • Action。类似地,每次我们等待某些尚未完成的操作(这是我们一百万次 await Task.Yield() 的情况),我们最终会分配一个新的 Action 委托,将其传递给 awaiter 的 UnsafeOnCompleted 方法。
  • MoveNextRunner。同样的情况;这里有一百万个 MoveNextRunner,因为在之前步骤的概述中,每次我们挂起时,我们都会分配一个新的 MoveNextRunner 来存储 ActionExecutionContext,以便在后续使用 ExecutionContext 执行 Action
  • LogicalCallContext。又是一百万个。这些是 .NET Framework 上 AsyncLocal<T> 的实现细节;AsyncLocal<T> 将其数据存储到 ExecutionContext 的“逻辑调用上下文”中,简单来说就是随着 ExecutionContext 流动的通用状态。所以,如果我们创建了一百万个 ExecutionContext 副本,我们也会创建一百万个 LogicalCallContext 副本。
  • QueueUserWorkItemCallback。每次 Task.Yield() 都会将一个工作项排入线程池,这导致了用于表示这百万个操作的工作项对象的分配。
  • Task<VoidResult>。这些有一千个,所以至少我们不再是“百万俱乐部”的一员。每个异步 Task 调用完成时都需要分配一个新的 Task 实例,以表示该调用最终的完成。
  • <SomeMethodAsync>d__1。这是编译器生成的状态机结构的装箱。1000 个方法挂起时,1000 次装箱发生。
  • QueueSegment/IThreadPoolWorkItem[]。这些有几千个,它们与异步方法没有直接关系,而是与工作项排入线程池的过程有关。在 .NET Framework 中,线程池的队列是一个非循环链表的多个段。这些段不会被重用;对于长度为 N 的段,一旦 N 个工作项被排入并从该段出队,该段就会被丢弃并等待垃圾回收。

那是 .NET Framework 的情况。接下来是 .NET Core

 

太漂亮了!在 .NET Framework 中,对于这个示例,分配超过了 500 万次,总共约 145MB 的内存。而在 .NET Core 中,相同的示例只进行了约 1000 次分配,总计仅约 109KB。为什么差异如此之大?

ExecutionContext。在 .NET Core 中,ExecutionContext 现在是不可变的。缺点是,每次修改上下文,例如通过设置 AsyncLocal<T> 中的值,都需要分配一个新的 ExecutionContext。然而,好处是,流动上下文的情况远远比修改它的情况更常见,由于 ExecutionContext 现在是不可变的,我们不再需要在流动时克隆它。“捕获”上下文实际上只是从字段中读取它,而不是读取它并克隆其内容。所以,流动上下文的操作不仅比修改上下文要常见得多,而且也便宜得多。

LogicalCallContext。在 .NET Core 中,LogicalCallContext 不再存在。在 .NET Core 中,ExecutionContext 唯一存在的目的是存储 AsyncLocal<T>。曾经在 ExecutionContext 中有自己专门位置的其他东西,现在都通过 AsyncLocal<T> 来建模。例如,在 .NET Framework 中,冒充信息会作为 SecurityContext 的一部分随 ExecutionContext 流动;而在 .NET Core 中,冒充信息通过一个 AsyncLocal<SafeAccessTokenHandle> 来流动,使用 valueChangedHandler 对当前线程进行适当的更改。

QueueSegment/IThreadPoolWorkItem[]。在 .NET Core 中,线程池的全局队列现在实现为 ConcurrentQueue<T>,而 ConcurrentQueue<T> 被重写为一个由多个非固定大小的循环段组成的链表。一旦一个段的大小足够大,以至于该段永远不会填满,因为持续的出队操作能够跟上持续的入队操作,就不需要分配额外的段,那个足够大的段就会被不断使用。

那么,其他的分配呢,比如 ActionMoveNextRunner<SomeMethodAsync>d__1?理解剩下的分配是如何被移除的,需要深入了解 .NET Core 上现在是如何工作的。

 

让我们回顾一下我们讨论的挂起时发生的事情:

if (!awaiter.IsCompleted) // we need to suspend when IsCompleted is false
{
    <>1__state = 1;
    <>u__2 = awaiter;
    <>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
    return;
}

这里生成的代码在目标平台表面无论是 .NET Framework 还是 .NET Core 都是相同的,因此无论是 .NET Framework 还是 .NET Core,挂起时的生成 IL 都是相同的。然而,改变的是 AwaitUnsafeOnCompleted 方法的实现,在 .NET Core 中它有很大不同:

  1. 一开始的步骤是一样的:该方法调用 ExecutionContext.Capture() 来获取当前的执行上下文。
  2. 然后事情就与 .NET Framework 分开了。在 .NET Core 中,构建器只有一个字段:
public struct AsyncTaskMethodBuilder
{
    private Task<VoidTaskResult>? m_task;
    ...
}

在捕获 ExecutionContext 后,它检查 m_task 字段是否包含一个 AsyncStateMachineBox<TStateMachine> 类型的实例,其中 TStateMachine 是编译器生成的状态机结构体的类型。这个 AsyncStateMachineBox<TStateMachine> 类型是“魔法”。它定义如下:

private class AsyncStateMachineBox<TStateMachine> :
    Task<TResult>, IAsyncStateMachineBox
    where TStateMachine : IAsyncStateMachine
{
    private Action? _moveNextAction;
    public TStateMachine? StateMachine;
    public ExecutionContext? Context;
    ...
}

与其使用一个单独的 Task,这里的任务就是那个任务(注意它的基类型)。与其将状态机装箱,结构体仅作为这个任务上的强类型字段存在。并且与其使用一个单独的 MoveNextRunner 来存储 ActionExecutionContext,它们就作为字段存在于这个类型上,且因为这是存储到构建器的 m_task 字段中的实例,我们可以直接访问它,不需要在每次挂起时重新分配东西。如果 ExecutionContext 发生变化,我们可以直接用新的上下文覆盖该字段,而不需要分配任何其他内容;我们持有的任何 Action 仍然指向正确的地方。所以,在捕获 ExecutionContext 后,如果我们已经有了一个 AsyncStateMachineBox<TStateMachine> 实例,这不是该方法第一次挂起,我们可以直接将新捕获的 ExecutionContext 存储到它里面。如果我们还没有 AsyncStateMachineBox<TStateMachine> 实例,那么我们就需要分配它:

var box = new AsyncStateMachineBox<TStateMachine>();
taskField = box; // important: this must be done before storing stateMachine into box.StateMachine!
box.StateMachine = stateMachine;
box.Context = currentContext;

注意那一行,源代码注释为“重要”。这取代了在 .NET Framework 中复杂的 SetStateMachine 操作,因此在 .NET Core 中根本不使用 SetStateMachine。你看到的 taskField 是对 AsyncTaskMethodBuilderm_task 字段的引用。我们分配了 AsyncStateMachineBox<TStateMachine>,然后通过 taskField 将这个对象存储到构建器的 m_task 中(这个构建器位于堆栈上的状态机结构体中),然后将这个基于堆栈的状态机(现在已经包含了对该盒子的引用)复制到堆上的 AsyncStateMachineBox<TStateMachine> 中,这样 AsyncStateMachineBox<TStateMachine> 会适当地递归地引用自己。仍然是让人头晕的思维过程,但效率更高的思维过程。

  1. 接下来,我们可以获取一个 Action,指向这个实例上的方法,该方法将调用它的 MoveNext 方法,在调用状态机的 MoveNext 之前做适当的 ExecutionContext 恢复。然后,这个 Action 可以缓存到 _moveNextAction 字段中,这样任何后续使用都可以重用同一个 Action。然后这个 Action 会传递给 awaiter 的 UnsafeOnCompleted 方法,以挂接继续操作。

 

正如解释所述,绝大多数其余的分配消失了:<SomeMethodAsync>d__1 不再被装箱,而是作为任务本身的一个字段存在,且 MoveNextRunner 不再需要,因为它之前只用于存储 ActionExecutionContext。但根据这个解释,我们仍然应该看见 1000 个 Action 分配,每个方法调用一个,但实际上并没有看到。为什么呢?那那些 QueueUserWorkItemCallback 对象呢……我们仍然在执行 Task.Yield() 时将它们排队,为什么它们没有显示出来?

正如我所指出的,把实现细节推给核心库的一个好处是它可以随着时间的推移演进实现,我们已经看到它从 .NET Framework 到 .NET Core 的演变。它还在最初为 .NET Core 重写之后进一步演进,做了一些优化,这些优化受益于可以内部访问系统中的关键组件。特别是,异步基础设施了解像 TaskTaskAwaiter 这样的核心类型。由于它知道这些类型并且拥有内部访问权限,它不必遵循公开定义的规则。C# 语言遵循的 awaiter 模式要求 awaiter 必须有 AwaitOnCompletedAwaitUnsafeOnCompleted 方法,这两个方法都将继续操作作为 Action,这意味着基础设施需要能够创建一个 Action 来表示继续操作,以便与它无法了解的任意 awaiter 一起工作。但如果基础设施遇到它已经知道的 awaiter,它没有义务走相同的代码路径。因此,对于所有在 System.Private.CoreLib 中定义的核心 awaiter,基础设施有一条更加精简的路径,它不需要 Action。这些 awaiter 都了解 IAsyncStateMachineBox,并能够将盒子对象本身作为继续操作。所以,例如,Task.Yield 返回的 YieldAwaitable 能够直接将 IAsyncStateMachineBox 排入线程池作为工作项,而在等待 Task 时使用的 TaskAwaiter 能够将 IAsyncStateMachineBox 本身直接存储到 Task 的继续操作列表中。不需要 Action,也不需要 QueueUserWorkItemCallback

 

因此,在非常常见的情况下,当一个异步方法只等待来自 System.Private.CoreLib 的内容(如 TaskTask<TResult>ValueTaskValueTask<TResult>YieldAwaitable 和它们的 ConfigureAwait 变体)时,最坏的情况是整个异步方法生命周期中只会有一次与开销相关的分配:如果方法曾经挂起,它会分配一个单一的继承自 Task 的类型来存储所有其他所需的状态;如果方法从未挂起,则不会产生任何额外的分配。

如果需要,我们甚至可以去掉最后的分配,至少在摊销的情况下。正如之前所示,Task 有一个默认的构建器(AsyncTaskMethodBuilder),类似地,Task<TResult> 有一个默认的构建器(AsyncTaskMethodBuilder<TResult>),而 ValueTaskValueTask<TResult> 也各自有默认的构建器(AsyncValueTaskMethodBuilderAsyncValueTaskMethodBuilder<TResult>)。对于 ValueTask/ValueTask<TResult>,这些构建器其实非常简单,因为它们只处理同步且成功完成的情况,在这种情况下,异步方法在没有挂起的情况下完成,构建器可以直接返回一个 ValueTask.Completed 或一个包装结果值的 ValueTask<TResult>。对于其他情况,它们只是委托给 AsyncTaskMethodBuilder/AsyncTaskMethodBuilder<TResult>,因为将返回的 ValueTask/ValueTask<TResult> 只是包装了一个 Task,它可以共享所有相同的逻辑。但 .NET 6 和 C# 10 引入了允许方法基于每个方法单独覆盖使用的构建器的能力,并引入了一些专门的构建器,用于 ValueTask/ValueTask<TResult>,这些构建器能够池化 IValueTaskSource/IValueTaskSource<TResult> 对象来表示最终完成,而不是使用 Task

我们可以在我们的示例中看到这种影响。让我们稍微修改我们之前分析的 SomeMethodAsync,使它返回 ValueTask 而不是 Task

static async ValueTask SomeMethodAsync()
{
    for (int i = 0; i < 1000; i++)
    {
        await Task.Yield();
    }
}

 

这将产生以下生成的入口点:

[AsyncStateMachine(typeof(<SomeMethodAsync>d__1))]
private static ValueTask SomeMethodAsync()
{
    <SomeMethodAsync>d__1 stateMachine = default;
    stateMachine.<>t__builder = AsyncValueTaskMethodBuilder.Create();
    stateMachine.<>1__state = -1;
    stateMachine.<>t__builder.Start(ref stateMachine);
    return stateMachine.<>t__builder.Task;
}

 

现在,我们在 SomeMethodAsync 的声明中添加了 [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))]

[AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))]
static async ValueTask SomeMethodAsync()
{
    for (int i = 0; i < 1000; i++)
    {
        await Task.Yield();
    }
}

编译器会输出如下内容:

[AsyncStateMachine(typeof(<SomeMethodAsync>d__1))]
[AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))]
private static ValueTask SomeMethodAsync()
{
    <SomeMethodAsync>d__1 stateMachine = default;
    stateMachine.<>t__builder = PoolingAsyncValueTaskMethodBuilder.Create();
    stateMachine.<>1__state = -1;
    stateMachine.<>t__builder.Start(ref stateMachine);
    return stateMachine.<>t__builder.Task;
}

整个实现的实际 C# 代码生成,包括整个状态机(未显示),几乎是相同的;唯一的区别是创建和存储的构建器的类型,因此在我们之前看到的构建器引用的地方都会使用这个新的构建器。如果你查看 PoolingAsyncValueTaskMethodBuilder 的代码,你会发现它的结构与 AsyncTaskMethodBuilder 几乎相同,包括使用一些完全相同的共享方法来处理已知的等待器类型。关键的区别在于,当方法第一次挂起时,它不会执行 new AsyncStateMachineBox<TStateMachine>(),而是执行 StateMachineBox<TStateMachine>.RentFromCache(),当异步方法(SomeMethodAsync)完成并且返回的 ValueTask 完成时,租用的状态机框会被返回到缓存中。这样,最终实现的是(摊销后)零分配:

对象池本身是一个很有趣的话题。对象池化可以是个好主意,也可能是个坏主意。一个对象创建的成本越高,池化它就越有价值;例如,池化非常大的数组比池化非常小的数组更有价值,因为较大的数组不仅需要更多的 CPU 周期和内存访问来清零,还会给垃圾回收器带来更多的压力,需要更频繁地进行垃圾回收。对于非常小的对象,池化它们可能会带来负面效果。对象池就像垃圾回收器一样,都是内存分配器,所以当你使用对象池时,你实际上是在用另一个分配器的成本来换取当前分配器的成本,而垃圾回收器在处理大量小的、短生命周期的对象时非常高效。如果你在一个对象的构造函数中做了大量工作,避免这些工作可能会大大降低分配器本身的成本,从而使得对象池化变得有价值。但如果你在对象的构造函数中几乎没有做任何工作,而且你仍然池化它,你就等于在赌你的分配器(对象池)对于访问模式的效率比垃圾回收器更高,而这个赌注往往是失败的。还有其他成本涉及其中,在某些情况下,你可能会实际上与垃圾回收器的启发式规则作对;例如,垃圾回收器是基于假设高代(如 gen2)对象对低代(如 gen0)对象的引用是相对少见的,但池化对象可能会破坏这一假设。

现在,异步方法创建的对象并不小,并且它们可能出现在非常热的路径上,因此对象池化是合理的。但为了最大化池化的价值,我们还需要尽量避免任何不必要的开销。因此,池(Pools)的设计非常简单,目的是让租用和归还操作非常快速,几乎没有争用,即使这意味着它可能会比更积极地缓存更多对象时分配更多对象。对于每种状态机类型,池实现池化每个线程和每个核心最多一个状态机框,这使得租用和归还操作能够以最小的开销和最小的争用进行(没有其他线程能同时访问线程特定的缓存,并且其他线程同时访问核心特定缓存的情况也很少)。尽管这看起来像是一个相对较小的池,但它在显著减少稳定状态下的分配方面非常有效,因为池只负责存储当前未使用的对象;你可能有百万个异步方法在同一时间运行,尽管池每个线程和每个核心只能存储最多一个对象,但它仍然能避免丢失大量对象,因为它只需要存储一个对象,足够将其从一个操作转移到另一个操作,而不是在操作过程中保持对象存储。

 

SynchronizationContext and ConfigureAwait

我们之前在讨论 EAP 模式时提到了 SynchronizationContext,并且也提到它会再次出现。SynchronizationContext 使得调用可重用的帮助器函数成为可能,并且在调用环境认为合适时,自动将执行排队回去。因此,我们自然会期望它与 async/await 配合使用时“自动工作”,实际上,它的确如此。让我们回顾一下之前的按钮点击处理程序:

ThreadPool.QueueUserWorkItem(_ =>
{
    string message = ComputeMessage();
    button1.BeginInvoke(() =>
    {
        button1.Text = message;
    });
});

使用 async/await 后,我们希望能够像这样编写代码:

button1.Text = await Task.Run(() => ComputeMessage());

ComputeMessageAsync 的调用会被 offload 到线程池上,方法执行完成后,执行会返回到与按钮相关联的 UI 线程,Text 属性的设置也会在该线程上进行。

SynchronizationContextasync/await 的集成是由 awaiter 实现的(生成的状态机代码对 SynchronizationContext 并不了解),因为正是 awaiter 负责在表示的异步操作完成时实际调用或排队提供的后续操作。虽然自定义的 awaiter 不一定要尊重 SynchronizationContext.Current,但 TaskTask<TResult>ValueTaskValueTask<TResult>awaiter 都会遵循这一点。这意味着,默认情况下,当你 await 一个 TaskTask<TResult>ValueTaskValueTask<TResult>,甚至是 Task.Yield() 调用的结果时,awaiter 会查找当前的 SynchronizationContext,如果成功获取到一个非默认的上下文,它最终会将后续操作排队到该上下文中。

我们可以通过查看 TaskAwaiter 中相关代码来了解这一点。以下是来自 Corelib 的代码片段

internal void UnsafeSetContinuationForAwait(IAsyncStateMachineBox stateMachineBox, bool continueOnCapturedContext)
{
    if (continueOnCapturedContext)
    {
        SynchronizationContext? syncCtx = SynchronizationContext.Current;
        if (syncCtx != null && syncCtx.GetType() != typeof(SynchronizationContext))
        {
            var tc = new SynchronizationContextAwaitTaskContinuation(syncCtx, stateMachineBox.MoveNextAction, flowExecutionContext: false);
            if (!AddTaskContinuation(tc, addBeforeOthers: false))
            {
                tc.Run(this, canInlineContinuationTask: false);
            }
            return;
        }
        else
        {
            TaskScheduler? scheduler = TaskScheduler.InternalCurrent;
            if (scheduler != null && scheduler != TaskScheduler.Default)
            {
                var tc = new TaskSchedulerAwaitTaskContinuation(scheduler, stateMachineBox.MoveNextAction, flowExecutionContext: false);
                if (!AddTaskContinuation(tc, addBeforeOthers: false))
                {
                    tc.Run(this, canInlineContinuationTask: false);
                }
                return;
            }
        }
    }

    ...
    }

这是一个方法的一部分,用于确定将什么对象存储到 Task 作为后续操作。它接收 stateMachineBox,如前所述,这个 stateMachineBox 可以直接存储到 Task 的继续操作列表中。然而,这段特定的逻辑可能会包装 IAsyncStateMachineBox,以便如果存在调度器,它也会被包含进去。它检查当前是否有一个非默认的 SynchronizationContext,如果有,它会创建一个 SynchronizationContextAwaitTaskContinuation 作为将被存储为继续操作的实际对象;该对象会包装原始的状态机框和捕获的 SynchronizationContext,并知道如何将状态机的 MoveNext 方法作为一个工作项排队到 SynchronizationContext。这就是你能够在 UI 应用程序中的事件处理程序中进行 await 操作,并在 await 完成后继续在正确的线程上执行代码的原因。接下来需要注意的一个有趣点是,这不仅仅关注 SynchronizationContext,如果它找不到一个自定义的 SynchronizationContext,它还会检查 Task 使用的 TaskScheduler 类型是否有一个自定义的调度器需要考虑。与 SynchronizationContext 类似,如果有一个非默认的调度器,那么它会与原始状态机框一起被包装在 TaskSchedulerAwaitTaskContinuation 中,作为继续操作对象。

但最有趣的事情可能是在方法体的第一行看到的:if (continueOnCapturedContext)。我们只在 continueOnCapturedContexttrue 时才执行这些针对 SynchronizationContext/TaskScheduler 的检查;如果为 false,实现则表现得像这两个都是默认的,并且忽略它们。那么,是什么将 continueOnCapturedContext 设置为 false 呢?你可能已经猜到:使用广泛流行的 ConfigureAwait(false)

我在《ConfigureAwait FAQ》中详细讨论了 ConfigureAwait,所以我鼓励你阅读那篇文章以获取更多信息。简而言之,ConfigureAwait(false)await 中所做的唯一事情就是将其布尔参数传递给这个方法(以及类似的方法)作为 continueOnCapturedContext 值,从而跳过对 SynchronizationContext/TaskScheduler 的检查,并表现得像它们不存在一样。对于 Task 来说,这意味着它可以根据需要在任何地方调用其后续操作,而不必强制将其排队到某个特定的调度器上执行。

我之前提到过 SynchronizationContext 的另一个方面,并且说过我们会再次看到它:OperationStartedOperationCompleted。现在是时候讨论它们了。这些出现在每个人都爱恨交加的特性中:async void。抛开 ConfigureAwaitasync void 可能是 async/await 中最具争议的特性之一。它的添加只有一个原因:事件处理程序。在 UI 应用程序中,你希望能够编写如下代码:

button1.Click += async (sender, eventArgs) =>
{
  button1.Text = await Task.Run(() => ComputeMessage());  
};

但是,如果所有的异步方法都必须有像 Task 这样的返回类型,你就无法做到这一点了。Click 事件的签名是 public event EventHandler? Click;,其中 EventHandler 定义为 public delegate void EventHandler(object? sender, EventArgs e);,因此,为了提供一个与该签名匹配的方法,方法必须返回 void

有多种原因为什么 async void 被认为是一个不好的实践,为什么文章建议尽可能避免使用它,为什么分析工具会标记它的使用。一个最大的原因是与委托推断相关的问题。比如这个程序:

using System.Diagnostics;

Time(async () =>
{
    Console.WriteLine("Enter");
    await Task.Delay(TimeSpan.FromSeconds(10));
    Console.WriteLine("Exit");
});

static void Time(Action action)
{
    Console.WriteLine("Timing...");
    Stopwatch sw = Stopwatch.StartNew();
    action();
    Console.WriteLine($"...done timing: {sw.Elapsed}");
}

有人可能会期望程序输出至少 10 秒的运行时间,但如果你运行它,你会发现输出如下:

Timing...
Enter
...done timing: 00:00:00.0037550

咦?当然,基于我们在本文中讨论的内容,这个问题的原因应该很容易理解。

async lambda 实际上是一个 async void 方法。异步方法在遇到第一个挂起点时会立即返回给调用方。如果这是一个 async Task 方法,此时会返回一个 Task。但对于 async void 来说,什么都不会返回。Time 方法只知道它调用了 action();,而委托调用已经返回;它根本不知道异步方法实际上仍在“运行”,并且稍后会异步完成。

这就是 OperationStartedOperationCompleted 的作用所在。这样的 async void 方法在本质上与之前讨论的 EAP 方法类似:这些方法的启动是 void 的,因此需要某种其他机制来跟踪所有正在进行的操作。因此,EAP 的实现会在操作启动时调用当前 SynchronizationContextOperationStarted,并在操作完成时调用 OperationCompleted,而 async void 的行为也是如此。与 async void 关联的构造器是 AsyncVoidMethodBuilder。记得在异步方法的入口点,编译器生成的代码会调用构造器的静态 Create 方法来获取适当的构造器实例吗?AsyncVoidMethodBuilder 利用这一点来钩住创建过程,并调用 OperationStarted

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

类似地,当构造器通过 SetResultSetException 标记为完成时,它会调用相应的 OperationCompleted 方法。这也是为什么像 xUnit 这样的单元测试框架能够支持 async void 测试方法,并在并发测试执行中实现最大程度的并发性,例如通过 xUnit 的 AsyncTestSyncContext

有了这些知识,我们现在可以重写之前的计时示例:

using System.Diagnostics;

Time(async () =>
{
    Console.WriteLine("Enter");
    await Task.Delay(TimeSpan.FromSeconds(10));
    Console.WriteLine("Exit");
});

static void Time(Action action)
{
    var oldCtx = SynchronizationContext.Current;
    try
    {
        var newCtx = new CountdownContext();
        SynchronizationContext.SetSynchronizationContext(newCtx);

        Console.WriteLine("Timing...");
        Stopwatch sw = Stopwatch.StartNew();
        
        action();
        newCtx.SignalAndWait();

        Console.WriteLine($"...done timing: {sw.Elapsed}");
    }
    finally
    {
        SynchronizationContext.SetSynchronizationContext(oldCtx);
    }
}

sealed class CountdownContext : SynchronizationContext
{
    private readonly ManualResetEventSlim _mres = new ManualResetEventSlim(false);
    private int _remaining = 1;

    public override void OperationStarted() => Interlocked.Increment(ref _remaining);

    public override void OperationCompleted()
    {
        if (Interlocked.Decrement(ref _remaining) == 0)
        {
            _mres.Set();
        }
    }

    public void SignalAndWait()
    {
        OperationCompleted();
        _mres.Wait();
    }
}

我创建了一个 SynchronizationContext,用于跟踪待处理操作的计数,并支持阻塞等待所有操作完成。当我运行该程序时,我得到类似这样的输出:

Timing...
Enter
Exit
...done timing: 00:00:10.0149074

 

状态机字段

到目前为止,我们已经看到了生成的入口方法,以及 MoveNext 实现中一切如何运作。我们还瞥见了状态机中定义的一些字段。现在让我们更深入地看看它们。

对于前面展示的 CopyStreamToStream 方法:

public async Task CopyStreamToStreamAsync(Stream source, Stream destination)
{
    var buffer = new byte[0x1000];
    int numRead;
    while ((numRead = await source.ReadAsync(buffer, 0, buffer.Length)) != 0)
    {
        await destination.WriteAsync(buffer, 0, numRead);
    }
}

我们得到了以下字段:

private struct <CopyStreamToStreamAsync>d__0 : IAsyncStateMachine
{
    public int <>1__state;
    public AsyncTaskMethodBuilder <>t__builder;
    public Stream source;
    public Stream destination;
    private byte[] <buffer>5__2;
    private TaskAwaiter <>u__1;
    private TaskAwaiter<int> <>u__2;

    ...
}

 

这些字段分别是什么?

  • <>1__state
    这是“状态机”中的“状态”。它定义了当前状态机的状态,最重要的是下一次调用 MoveNext 时应该执行什么。如果状态是 -2,操作已经完成。如果状态是 -1,要么我们即将第一次调用 MoveNext,要么 MoveNext 代码当前正在某个线程上运行。如果你在调试异步方法时看到状态为 -1,这意味着某个线程正在执行该方法的代码。如果状态是 0 或更大,表示方法被挂起,而状态值告诉你它在哪个 await 被挂起。尽管这不是一成不变的规则(某些代码模式可能混淆编号),但通常分配的状态对应于源代码从上到下顺序中 await 的 0 基编号。例如,如果一个异步方法的主体是:
await A();
await B();
await C();
await D();

并且你发现状态值是 2,几乎可以肯定这意味着异步方法当前在等待 C() 返回的任务完成时挂起。

  • <>t__builder
    这是状态机的构造器,例如 TaskAsyncTaskMethodBuilderValueTask<TResult>AsyncValueTaskMethodBuilder<TResult>async void 方法的 AsyncVoidMethodBuilder,或者通过 [AsyncMethodBuilder(...)] 声明的构造器(在异步返回类型上,或者通过此类特性覆盖的异步方法)。正如前面讨论的,构造器负责异步方法的生命周期,包括创建返回任务、最终完成任务以及作为挂起的中介,异步方法中的代码请求构造器挂起,直到特定的 awaiter 完成。
  • source/destination
    这些是方法参数。你可以看出它们并没有被名称混淆,编译器完全按照参数名称指定的方式命名它们。正如之前提到的,所有由方法体使用的参数都需要存储到状态机中,以便 MoveNext 方法能够访问它们。注意这里的关键是“使用”。如果编译器发现某个参数未被异步方法的主体使用,它可以优化掉存储该字段的需要。例如,对于方法:
public async Task M(int someArgument)
{
    await Task.Yield();
}

编译器生成的状态机字段如下:

private struct <M>d__0 : IAsyncStateMachine
{
    public int <>1__state;
    public AsyncTaskMethodBuilder <>t__builder;
    private YieldAwaitable.YieldAwaiter <>u__1;
    ...
}

注意,缺少名为 someArgument 的字段。但如果我们修改异步方法,使其以任何方式使用该参数:

public async Task M(int someArgument)
{
    Console.WriteLine(someArgument);
    await Task.Yield();
}

那么该字段就会出现:

private struct <M>d__0 : IAsyncStateMachine
{
    public int <>1__state;
    public AsyncTaskMethodBuilder <>t__builder;
    public int someArgument;
    private YieldAwaitable.YieldAwaiter <>u__1;
    ...
}
  • <buffer>5__2
    这是被提升为字段的“本地变量”,以便它可以在多个 await 点之间保持。编译器会尽可能避免不必要地提升状态。例如,在源代码中还有另一个本地变量 numRead,但状态机中并没有对应的字段。这是因为没有必要。numRead 的值是在调用 ReadAsync 时设置的,然后在调用 WriteAsync 时使用。两者之间没有 await,也就无需跨越 await 保存 numRead 的值。类似于同步方法中的 JIT 编译器可能将这样的值完全存储在寄存器中而不实际溢出到堆栈中,C# 编译器也可以避免将这个本地变量提升为字段,只要它能证明其值不需要跨越 await 保留。
  • <>u__1<>u__2
    异步方法中有两个 await:一个是 ReadAsync 返回的 Task<int>,另一个是 WriteAsync 返回的 TaskTask.GetAwaiter() 返回一个 TaskAwaiterTask<TResult>.GetAwaiter() 返回一个 TaskAwaiter<TResult>,两者是不同的结构类型。由于编译器需要在 await 之前获取这些 awaiterIsCompletedUnsafeOnCompleted),并且需要在 await 之后访问它们(GetResult),这些 awaiter 需要被存储。而由于它们是不同的结构类型,编译器需要维护两个单独的字段来存储它们(另一种选择是将它们装箱,并为 awaiter 使用一个 object 字段,但这会导致额外的分配成本)。编译器会尽量重用字段。例如,对于以下方法:
public async Task M()
{
    await Task.FromResult(1);
    await Task.FromResult(true);
    await Task.FromResult(2);
    await Task.FromResult(false);
    await Task.FromResult(3);
}

虽然有五个 await,但只有两种不同类型的 awaiter:三个是 TaskAwaiter<int>,两个是 TaskAwaiter<bool>。因此,状态机中只有两个 awaiter 字段:

private struct <M>d__0 : IAsyncStateMachine
{
    public int <>1__state;
    public AsyncTaskMethodBuilder <>t__builder;
    private TaskAwaiter<int> <>u__1;
    private TaskAwaiter<bool> <>u__2;
    ...
}

如果我们将示例改为:

public async Task M()
{
    await Task.FromResult(1);
    await Task.FromResult(true);
    await Task.FromResult(2).ConfigureAwait(false);
    await Task.FromResult(false).ConfigureAwait(false);
    await Task.FromResult(3);
}

尽管仍然只有 Task<int>Task<bool>,但实际上用了四种不同的结构 awaiter 类型,因为通过 ConfigureAwait 调用的 GetAwaiter() 返回的 awaiter 类型不同于 Task.GetAwaiter() 返回的类型。这再次反映在编译器生成的 awaiter 字段中:

private struct <M>d__0 : IAsyncStateMachine
{
    public int <>1__state;
    public AsyncTaskMethodBuilder <>t__builder;
    private TaskAwaiter<int> <>u__1;
    private TaskAwaiter<bool> <>u__2;
    private ConfiguredTaskAwaitable<int>.ConfiguredTaskAwaiter <>u__3;
    private ConfiguredTaskAwaitable<bool>.ConfiguredTaskAwaiter <>u__4;
    ...
}

如果你希望优化与异步状态机相关的大小,可以研究是否可以合并等待的事务类型,从而合并这些 awaiter 字段。

 

状态机中可能还会定义其他类型的字段,尤其是那些包含“wrap”字样的字段。来看下面这个简单的例子:

public async Task<int> M() => await Task.FromResult(42) + DateTime.Now.Second;

这个代码会生成一个状态机,其中包含以下字段:

private struct <M>d__0 : IAsyncStateMachine
{
    public int <>1__state;
    public AsyncTaskMethodBuilder<int> <>t__builder;
    private TaskAwaiter<int> <>u__1;
    ...
}

目前看起来并没有什么特别之处。现在将两个表达式相加的顺序对调:

public async Task<int> M() => DateTime.Now.Second + await Task.FromResult(42);

这次生成的状态机字段变成了:

private struct <M>d__0 : IAsyncStateMachine
{
    public int <>1__state;
    public AsyncTaskMethodBuilder<int> <>t__builder;
    private int <>7__wrap1;
    private TaskAwaiter<int> <>u__1;
    ...
}

可以看到,这里多了一个字段:<>7__wrap1

为什么会有这个字段?因为在计算表达式时,先计算了 DateTime.Now.Second 的值,然后需要等待 Task.FromResult(42) 的结果。而由于第一个表达式的值需要保留,以便稍后与第二个表达式的结果相加,因此编译器需要将第一个表达式的临时计算结果存储起来。这是通过 <>7__wrap1 字段来实现的。

 

如果你希望对异步方法的实现进行极致优化,以减少内存分配量,可以关注这些字段,分析是否可以通过对源代码的微调避免“溢出”(spilling)的需求,从而消除这些临时字段的存在。

 

 

总结

希望这篇文章能够帮助大家了解在使用 async/await 时底层究竟发生了什么,幸运的是,通常你不需要知道或在意这些细节。在这里有许多复杂的运作机制共同作用,为编写可扩展的异步代码提供了高效的解决方案,而无需陷入“回调地狱”的困境。

归根结底,这些机制其实相对简单:

  • 一个用于表示任何异步操作的通用模型;
  • 一种能够将正常控制流重写为协程(coroutines)状态机实现的语言和编译器;
  • 以及将这些部分结合在一起的设计模式。

其他的一切,都是锦上添花的优化。

posted @ 2023-10-13 14:04  wxlevel  阅读(620)  评论(2编辑  收藏  举报