> 文章列表 > async/await 在 C# 语言中是如何工作的?(下)

async/await 在 C# 语言中是如何工作的?(下)

async/await 在 C# 语言中是如何工作的?(下)

接《async/await 在 C# 语言中是如何工作的?(上)》、《async/await 在 C# 语言中是如何工作的?(中)》,今天我们继续介绍 SynchronizationContext 和 ConfigureAwait。

 

▌SynchronizationContext 和 ConfigureAwait

我们之前在 EAP 模式的上下文中讨论过 SynchronizationContext,并提到它将再次出现。SynchronizationContext 使得调用可重用的辅助函数成为可能,并自动被调度回调用环境认为合适的任何地方。因此,我们很自然地认为 async/await 能“正常工作”,事实也的确如此。回到前面的按钮单击处理程序:

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

使用 async/await,我们可以这样写:

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

对 ComputeMessage 的调用被转移到线程池中,这个方法执行完毕后,执行又转移回与按钮关联的 UI 线程,设置按钮的 Text 属性就是在这个线程中进行的。

与 SynchronizationContext 的集成由 awaiter 实现(为状态机生成的代码对 SynchronizationContext 一无所知),因为当所表示的异步操作完成时,是 awaiter 负责实际调用或将所提供的 continuation 排队。而自定义 awaiter 不需要考虑 SynchronizationContext。目前,Task、Task<TResult>、ValueTask、ValueTask<TResult> 的等待器都是 do。这意味着,默认情况下,当你等待一个任务,一个 Task<TResult>,一个 ValueTask,一个 ValueTask<TResult>,甚至 Task. yield() 调用的结果时,awaiter 默认会查找当前的 SynchronizationContext,如果它成功地获得了一个非默认的同步上下文,最终会将 continuation 排队到该上下文。

如果我们查看 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;            }        }    }
    ...}

这是一个方法的一部分,用于确定将哪个对象作为 continuation 存储到任务中。它被传递给 stateMachineBox,如前所述,它可以直接存储到任务的 continuation 列表中。但是,这个特殊的逻辑可能会将 IAsyncStateMachineBox 封装起来,以合并一个调度程序(如果存在的话)。它检查当前是否有非默认的 SynchronizationContext,如果有,它会创建一个 SynchronizationContextAwaitTaskContinuation 作为实际的对象,它会被存储为 continuation;该对象依次包装了原始的和捕获的 SynchronizationContext,并知道如何在与后者排队的工作项中调用前者的 MoveNext。这就是如何在 UI 应用程序中作为事件处理程序的一部分等待,并在等待完成后让代码继续在正确的线程上运行。这里要注意的下一个有趣的事情是,它不仅仅关注一个 SynchronizationContext:如果它找不到一个自定义的 SynchronizationContext 来使用,它还会查看 Tasks 使用的 TaskScheduler 类型是否有一个需要考虑的自定义类型。和 SynchronizationContext 一样,如果有一个非默认值,它就会和原始框一起包装在 TaskSchedulerAwaitTaskContinuation 中,用作 continuation 对象。

但这里最值得注意的可能是方法主体的第一行:if (continueOnCapturedContext)。我们只在 continueOnCapturedContext 为 true 时才对 SynchronizationContext/TaskScheduler 进行这些检查;如果这个值为 false,实现方式就好像两者都是默认值一样,会忽略它们。请问是什么将 continueOnCapturedContext 设置为 false?你可能已经猜到了:使用非常流行的 ConfigureAwait(false)。

可以这样说,作为 await 的一部分,ConfigureAwait(false) 做的唯一一件事是将它的参数布尔值作为 continueOnCapturedContext 值提供给这个函数(以及其他类似的函数),以便跳过对 SynchronizationContext/TaskScheduler 的检查,表现得好像它们都不存在一样。对于进程来说,这允许 Task 在它认为合适的地方调用其 continuation,而不是强制将它们排队在某个特定的调度器上执行。

我之前提到过 SynchronizationContext 的另一个方面,我说过我们会再次看到它:OperationStarted/OperationCompleted。现在是时候了。这是没那么受欢迎的特性:异步 void。除了 configureawait 之外,async 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-returning。

有各种各样的理由认为 async void 是不好的,为什么文章建议尽可能避免使用它,以及为什么出现了各种 analyzers 来标记使用 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 实际上是一个异步 void 方法。异步方法会在遇到第一个暂停点时返回调用者。如果这是一个异步 Task 方法,Task 就会在这个时间点返回。但对于 async void,什么都不会返回。Time 方法只知道它调用了 action();委托调用返回;它不知道 async 方法实际上仍在“运行”,并将在稍后异步完成。

这就是 OperationStarted/OperationCompleted 的作用。这种异步 void 方法本质上与前面讨论的 EAP 方法类似:这种方法的初始化是 void,因此需要一些其他机制来跟踪所有此类操作。因此,EAP 实现在操作启动时调用当前 SynchronizationContext 的 OperationStarted,在操作完成时调用 OperationCompleted,async void 也做同样的事情。与 async void 相关的构建器是 AsyncVoidMethodBuilder。还记得在 async 方法的入口,编译器生成的代码如何调用构建器的静态 Create 方法来获得适当的构建器实例吗?AsyncVoidMethodBuilder 利用了这一点来挂钩创建和调用 OperationStarted:​​​​​​​

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

类似地,当通过 SetResult 或 SetException 将构建器标记为完成时,它会调用相应的 OperationCompleted 方法。这就是像 xunit 这样的单元测试框架如何能够具有异步 void 测试方法,并仍然在并发测试执行中使用最大程度的并发,例如在 xunit 的 AsyncTestSyncContext 中。

有了这些知识,现在可以重写我们的 timing 示例:​​​​​​​

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...EnterExit...done timing: 00:00:10.0149074

 

▌State Machine Fields

至此,我们已经看到了生成的入口点方法,以及 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 代码正在某个线程上运行。如果你正在调试一个 async 方法的处理过程,并且你看到状态为-1,这意味着在某处有某个线程正在执行包含在方法中的代码。如果状态大于等于0,方法会被挂起,状态的值会告诉你在什么时候挂起。虽然这不是一个严格的规则(某些代码模式可能会混淆编号),但通常情况下,分配的状态对应于从0开始的 await 编号,按照源代码从上到下的顺序排列。例如,如果 async 方法的函数体完全是:​​​​​​​

await A();await B();await C();await D();

你发现状态值是2,这几乎肯定意味着 async 方法当前被挂起,等待从 C() 返回的任务完成。

< > t__builder。这是状态机的构建器,例如用于 Task 的 AsyncTaskMethodBuilder,用于 ValueTask 的 AsyncValueTaskMethodBuilder<TResult>,用于 async void 方法的 AsyncVoidMethodBuilder,或用于 async 返回类型的 AsyncMethodBuilder(…)] 或通过 async 方法本身的属性覆盖的任何构建器。如前所述,构建器负责 async 方法的生命周期,包括创建 return 任务,最终完成该任务,并充当暂停的中介,async 方法中的代码要求构建器暂停,直到特定的 awaiter 完成。

编译器完全按照参数名称的指定来命名它们。如前所述,所有被方法主体使用的参数都需要被存储到状态机中,以便 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 的参数。但是,如果我们改变 async 方法,让它以任何方式使用实参:​​​​​​​

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;。这是缓冲区的 "局部",它被提升为一个字段,这样它就可以在等待点上存活。编译器相当努力地防止状态被不必要地提升。注意,在源码中还有一个局部变量 numRead,在状态机中没有相应的字段。为什么?因为它没有必要。这个局部变量被设置为 ReadAsync 调用的结果,然后被用作 WriteAsync 调用的输入。在这两者之间没有 await,因此 numRead 的值需要被存储。就像在一个同步方法中,JIT 编译器可以选择将这样的值完全存储在一个寄存器中,而不会真正将其溢出到堆栈中,C# 编译器可以避免将这个局部变量提升为一个字段,因为它不需要在任何等待中保存它的值。一般来说,如果 C# 编译器能够证明局部变量的值不需要在等待中保存,它就可以省略局部变量的提升。

<>u__1和<>u__2。async 方法中有两个 await:一个用于 ReadAsync 返回的 Task<int>,另一个用于 WriteAsync 返回的 Task。Task. getawaiter() 返回一个 TaskAwaiter,Task<TResult>. getawaiter() 返回一个 TaskAwaiter<TResult>,两者都是不同的结构体类型。由于编译器需要在 await (IsCompleted, UnsafeOnCompleted) 之前获取这些 awaiter,然后需要在 await (GetResult) 之后访问它们,因此需要存储这些 awaiter。由于它们是不同的结构类型,编译器需要维护两个单独的字段来做到这一点(另一种选择是将它们装箱,并为 awaiter 提供一个对象字段,但这会导致额外的分配成本)。不过,编译器会尽可能地重复使用字段。如果我有:​​​​​​​

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

有五个等待,但只涉及两种不同类型的等待者:三个是 TaskAwaiter<int>,两个是 TaskAwaiter<bool>。因此,状态机上最终只有两个等待者字段:

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>s 和 Task<bool>s,但实际上我使用了四个不同的 struct 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 的值。其次,只有在计算完它之后,我们才需要等待一些东西,并且需要保留第一个表达式的值,以便将其与第二个表达式的结果相加。因此,编译器需要确保第一个表达式的临时结果可以添加到 await 的结果中,这意味着它需要将表达式的结果溢出到临时中,它使用 <>7__wrap1 字段做到了这一点。如果你发现自己对异步方法的实现进行了超优化,以减少分配的内存量,你可以寻找这样的字段,并查看对源代码的微调是否可以避免溢出的需要,从而避免这种临时的需要。

我希望这篇文章有助于解释当你使用 async/await 时背后到底发生了什么。这里有很多变化,所有这些结合在一起,创建了一个高效的解决方案,可以编写可拓展的异步代码,而不必处理回调。然而归根结底,这些部分实际上是相对简单的:任何异步操作的通用表示,一种能够将普通控制流重写为协程的状态机实现的语言和编译器,以及将它们绑定在一起的模式。其他一切都是优化的额外收获。

编程愉快!

点我前往原博客~