接《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...
Enter
Exit
...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 时背后到底发生了什么。这里有很多变化,所有这些结合在一起,创建了一个高效的解决方案,可以编写可拓展的异步代码,而不必处理回调。然而归根结底,这些部分实际上是相对简单的:任何异步操作的通用表示,一种能够将普通控制流重写为协程的状态机实现的语言和编译器,以及将它们绑定在一起的模式。其他一切都是优化的额外收获。
编程愉快!
点我前往原博客~