[翻译]ExecutionContext vs SynchronizationContext

我最近几次被问到关于 ExecutionContext 和 SynchronizationContext 的各种问题,例如它们之间的区别是什么,“传播(Flow)”它们意味着什么,以及它们与 C# 和 Visual Basic 中新的 async/await 关键字的关系。我想我会尝试在这里解决其中的一些问题。

警告:这篇文章深入到 .NET 的一个高级领域,大多数开发人员永远不需要考虑。

什么是ExecutionContext,使它“传播”是什么含义?

ExecutionContext 是绝大部分开发人员不需要考虑的事情之一。它有点像空气:它存在很重要,但除非在某些关键时刻(例如当它出了问题时),我们才会想到它的存在。实际上,ExecutionContext只是其他上下文的容器。其中一些上下文是辅助性的,而另一些则对.NET的执行模型非常重要,但它们都遵循我在描述 ExecutionContext 时所描述的相同哲学:如果你必须知道它们的存在,要么你正在做超级高级的事情,要么出了问题。

ExecutionContext 与“环境”或“上下文”有关的信息,这意味着它存储与当前运行环境或“上下文”相关的数据。在许多系统中,这种环境信息是在线程本地存储(TLS)中维护的,例如在 ThreadStatic 字段或 ThreadLocal<T> 中。在同步世界中,这种线程本地信息已经足够:一切都在那个线程上发生,因此无论您在该线程上处于哪个堆栈帧中,正在执行哪个函数等等,该线程上运行的所有代码都可以看到并受到特定于该线程的数据的影响。例如,ExecutionContext 包含的上下文之一是 SecurityContext,它维护诸如当前“主体”和代码访问安全性(CAS)拒绝和允许等信息。这样的信息可以与当前线程关联,因此如果一个堆栈帧拒绝访问某个权限,然后调用另一个方法,那么被调用的方法仍将受到线程上设置的拒绝权限的限制:当它尝试执行需要该权限的操作时,CLR将检查当前线程的拒绝权限以查看该操作是否被允许,并且会找到调用者放置在那里的数据。

当你从同步世界转到异步世界时,情况变得更加复杂。突然间,线程局部存储(TLS)变得相当不相关。在同步世界中,如果我执行操作A、B和C,那么这三个操作都在同一线程上执行,因此所有三个操作都受到存储在该线程上的环境数据的影响。但在异步世界中,我可能会在一个线程上启动A,并在另一个线程上完成它,使得操作B可能在不同于A的线程上启动或运行,类似地,C也可能在不同于B的线程上启动或运行。这意味着我们曾经依赖于控制执行细节的这种环境上下文现在不再可行,因为TLS不会“流”穿越这些异步点。线程局部存储是针对线程特定的,而这些异步操作并没有绑定到特定的线程上。但是,通常存在逻辑控制流,我们希望这种环境数据能够随着控制流而传播,使得环境数据从一个线程移动到另一个线程。这就是 ExecutionContext 的作用。

ExecutionContext实际上只是一个状态包,可以用来捕获来自一个线程的所有状态,并在逻辑控制流继续时将其恢复到另一个线程上。可以使用静态的 Capture 方法来捕获 ExecutionContext:

// ambient state captured into ec
ExecutionContext ec = ExecutionContext.Capture();

并在通过静态运行方法调用委托期间恢复:

ExecutionContext.Run(ec, delegate
{
    … // code here will see ec’s state as ambient
}, null);

.NET框架中所有派生异步工作的方法都以这种方式捕获和恢复 ExecutionContext(除了以“Unsafe”为前缀的方法,这些方法是不安全的,因为它们明确不传播ExecutionContext)。例如,当你使用Task.Run时,Run方法从调用线程中捕获ExecutionContext,将该ExecutionContext实例存储到Task对象中。当以后作为该Task执行的一部分调用Task.Run提供的委托时,将使用存储的上下文通过ExecutionContext.Run进行调用。对于Task.Run、ThreadPool.QueueUserWorkItem、Delegate.BeginInvoke、Stream.BeginRead、DispatcherSynchronizationContext.Post和任何其他你能想到的异步API都是如此。它们都捕获 ExecutionContext,存储它,然后在调用某些代码的执行期间后使用存储的上下文。

当我们谈论“传播ExecutionContext”时,我们正是谈论这个过程,即在一个线程上恢复之前的环境状态,并在稍后的某个时间点将该状态恢复到另一个线程中,而该线程将执行所提供的委托。

什么是SynchronizationContext,以及如何捕获和使用它?

在软件开发中,我们喜欢抽象化。我们很少满足于硬编码到特定的实现上;相反,在编写高级系统时,我们会将特定实现的细节抽象出来,以便稍后可以插入不同的实现,而无需更改高级系统。这就是为什么我们有接口、抽象类、虚方法等等。

SynchronizationContext 只是一个抽象,表示您要在其中执行某些工作的特定环境。以 Windows Forms 应用程序为例,它们有一个UI线程(虽然可能存在多个线程,但在本文中不重要),任何需要使用UI控件的工作都必须在该线程上执行。对于在 ThreadPool 线程上运行代码并需要将工作传回UI以便于对UI控件进行操作的情况,Windows Forms 提供了 Control.BeginInvoke 方法。您将委托传递给 Control 的 BeginInvoke 方法,该委托将在与该控件关联的线程上调用。

因此,如果我正在编写一个组件,需要安排一些工作到ThreadPool,然后继续在UI线程上进行一些工作,我可以编写我的组件来使用 Control.BeginInvoke。但是,现在如果我决定在WPF应用程序中使用我的组件呢?WPF也具有与 Windows Forms 相同的UI线程约束,但它具有不同的机制来传递回UI线程:而不是在与正确线程相关联的控件上使用 Control.BeginInvoke,您可以在与正确线程相关联的 Dispatcher 实例上使用 Dispatcher.BeginInvoke(或InvokeAsync)。

现在我们有两个不同的API可以实现相同的基本操作,那么我如何编写我的组件以使其不依赖于UI框架?通过使用 SynchronizationContext。SynchronizationContext 提供了一个虚拟的Post方法;这个方法简单地接受一个委托并在 SynchronizationContext 实现认为合适的地方、时间和方式下运行它。Windows Forms 提供了 WindowsFormSynchronizationContext 类型,它重写了 Post 方法来调用 Control.BeginInvoke。WPF 提供了 DispatcherSynchronizationContext 类型,它重写了 Post 方法来调用 Dispatcher.BeginInvoke。以此类推。因此,我现在可以编写我的组件使用 SynchronizationContext 而不是将其绑定到特定的框架上。

如果我是为了针对Windows Forms而编写我的组件,我可能会实现类似以下的“去线程池,然后回到UI线程”的逻辑:

public static void DoWork(Control c)
{
    ThreadPool.QueueUserWorkItem(delegate
    {
        … // do work on ThreadPool
        c.BeginInvoke(delegate
        {
            … // do work on UI
        });
    });
}

如果我改为编写我的组件以使用 SynchronizationContext,我可能会把它写成:

public static void DoWork(SynchronizationContext sc)
{
    ThreadPool.QueueUserWorkItem(delegate
    {
        … // do work on ThreadPool
        sc.Post(delegate
        {
            … // do work on UI
        }, null);
    });
}

  

当然,需要传递目标上下文才能回到当前环境很麻烦(并且对于某些期望的编程模型是禁止的),因此 SynchronizationContext 提供了 Current 属性,它允许你从当前线程发现能够让你回到当前环境的上下文,如果有的话。这允许你“捕获它”(即从 SynchronizationContext.Current 读取引用并将该引用存储以供以后使用):

public static void DoWork()
{
    var sc = SynchronizationContext.Current;
    ThreadPool.QueueUserWorkItem(delegate
    {
        … // do work on ThreadPool
        sc.Post(delegate
        {
            … // do work on the original context
        }, null);
   });
}

  

传播 ExecutionContext vs 使用SynchronizationContext

现在,我们有一个非常重要的观察结果:传播 ExecutionContext 在语义上与捕获和发布到 SynchronizationContext 非常不同。

当您传播 ExecutionContext 时,您正在捕获来自一个线程的状态,然后将该状态还原,以便在提供的委托执行期间成为环境状态。这与捕获和使用 SynchronizationContext 时发生的情况不同。捕获部分是相同的,因为您正在从当前线程中获取数据,但您然后以不同的方式使用该状态。使用SynchronizationContext.Post 而不是在调用委托期间使该状态成为当前状态,您只是使用捕获的状态来调用委托。委托在哪里、何时以及如何运行完全取决于Post方法的实现。

这如何适用于异步/等待(async/await)?

async 和 await 关键字后面的框架支持会自动与 ExecutionContext 和 SynchronizationContext 进行交互。

每当代码等待一个awaitable,而其awaiter指示其尚未完成(即awaiter的IsCompleted返回false)时,该方法需要暂停,并将通过继续使用awaiter来恢复执行。这是我之前提到的异步点之一,因此ExecutionContext需要从发出await代码的代码流经到继续委托的执行。这由框架自动处理。当异步方法即将暂停时,基础结构会捕获ExecutionContext。传递给awaiter的委托引用此ExecutionContext实例,并在恢复方法时使用它。这就是使ExecutionContext代表的重要“环境”信息在await点之间传播的原因。

框架还支持SynchronizationContext。前面提到的对ExecutionContext的支持已经集成到表示异步方法的“构建器”中(例如System.Runtime.CompilerServices.AsyncTaskMethodBuilder),这些构建器确保在使用任何类型的awaitable时都流经ExecutionContext。相反,支持等待Task和Task<TResult>的SynchronizationContext是内置的。自定义awaiter可以自行添加类似的逻辑,但它们不会自动获得;这是设计

当你使用 await 关键字等待一个任务时,默认情况下,等待器将捕获当前的同步上下文,如果存在的话,当任务完成时,它会将提供的继续委托( continuation delegate) Post 回到该上下文,而不是在任务完成时在任何线程上运行委托,也不是将其安排在线程池上运行。如果开发人员不想要这种调度行为,可以通过更改使用的 awaitable/awaiter 来控制。当您等待 Task 或 Task<TResult> 时,始终使用此行为,但您可以等待调用 task.ConfigureAwait(...) 的结果。ConfigureAwait 方法返回一个可等待项,使得可以抑制此默认的调度行为。是否抑制由传递给 ConfigureAwait 方法的布尔值控制。如果 continueOnCapturedContext 为 true,则会获得默认行为;如果为 false,则等待器不会检查同步上下文,就好像没有它一样。请注意,当等待的任务完成时,不管是不是使用 ConfigureAwait,运行时都可能会检查在恢复线程上的当前上下文,以确定是否可以在那里同步运行继续委托,或者是否必须从该点异步调度继续委托。

请注意,尽管 ConfigureAwait 提供了明确的与 await 相关的编程模型支持,用于更改与同步上下文相关的行为,但是没有与 await 相关的编程模型支持来抑制 ExecutionContext 的传播。这是有意的。ExecutionContext 不是编写异步代码的开发人员应该关心的东西;它是基础设施级别的支持,在异步世界中帮助模拟同步语义(即 TLS)。大多数人可以完全忽略它的存在(并且应该避免使用 ExecutionContext.SuppressFlow 方法,除非他们确实知道自己在做什么)。相反,代码在哪里运行是开发人员应该注意的事情,因此同步上下文上升到需要显式编程模型支持的级别。(实际上,正如我在其他文章中所述,大多数库实现者应该在任务的每个 await 上考虑使用 ConfigureAwait(false)。)

SynchronizationContext 难道不是 ExecutionContext 的一部分吗?

到目前为止,我已经忽略了一些细节,但我不能再继续忽略它们了。

我忽略的主要内容是所有的 ExecutionContext 都能够传播(例如SecurityContext,HostExecutionContext,CallContext等),SynchronizationContext 当然也可以。我个人认为这是API设计中的一个错误,自从很多版本以前.NET引入它以来,这个问题已经引起了一些问题。尽管如此,这是我们现在拥有并且已经拥有很长时间的设计,现在更改它将是一个破坏性的变化。

当调用公共的ExecutionContext.Capture()方法时,它会检查当前的SynchronizationContext,如果存在,则将其存储到返回的ExecutionContext实例中。然后,当使用公共的ExecutionContext.Run方法时,在提供的委托执行期间,捕获的SynchronizationContext将作为当前值进行恢复。

这为什么有问题呢?将SynchronizationContext作为ExecutionContext的一部分传播会改变SynchronizationContext.Current的含义。SynchronizationContext.Current应该是一种您可以访问的方式,以返回到您当前在访问Current时所处的环境,因此,如果SynchronizationContext传播到另一个线程的当前环境,您不能相信SynchronizationContext.Current的含义。在这种情况下,它既可能是返回到当前环境的方式,也可能是返回到在流程中先前发生的某个环境的方式。

作为说明,以下是如何造成问题的示例代码:

private void button1_Click(object sender, EventArgs e)
{
    button1.Text = await Task.Run(async delegate
    {
        string data = await DownloadAsync();
        return Compute(data);
    });
}

  

这段代码会发生以下事情。用户单击button1按钮,导致UI框架在UI线程上调用button1_Click方法。代码然后启动一个工作项在线程池上运行(通过Task.Run)。该工作项开始进行一些下载工作并异步等待其完成。随后在线程池上的另一个工作项对该下载的结果进行了一些计算密集型操作,并返回结果,导致在UI线程上等待的Task完成。此时,UI线程处理此button1_Click方法的剩余部分,并将计算结果存储到button1的Text属性中。

如果SynchronizationContext不作为ExecutionContext的一部分传播,我的预期是有效的。然而,如果它传播了,我将非常失望。Task.Run在调用时会捕获ExecutionContext,并使用它来运行传递给它的委托。这意味着,在调用DownloadAsync并等待结果的过程中,UI SynchronizationContext会流到Task中,并在调用时处于当前状态。然后,await将看到当前的SynchronizationContext,并将异步方法的剩余部分作为继续项发送到UI线程上运行。这意味着我的Compute方法很可能在UI线程上运行,而不是在线程池中,导致应用程序响应性问题。

现在的情况有点混乱:ExecutionContext实际上有两个Capture方法,但只有一个是公共的。内部的方法(内部于mscorlib)是大多数从mscorlib公开的异步功能使用的方法,并且它可选地允许调用者在ExecutionContext中捕获SynchronizationContext;相应的,也有一个内部重载的Run方法,支持忽略存储在ExecutionContext中的SynchronizationContext,实际上假装没有捕获到(这是mscorlib中大多数功能使用的重载)。这意味着,在mscorlib中实现核心的任何异步操作都不会将SynchronizationContext作为ExecutionContext的一部分传播,但是在任何其他地方实现核心的异步操作将将SynchronizationContext作为ExecutionContext的一部分传播。我之前提到过异步方法的“生成器”是负责在异步方法中传播ExecutionContext的类型,这些生成器确实存在于mscorlib中,并且它们确实使用内部重载...因此,SynchronizationContext不会随着等待而作为ExecutionContext的一部分传播(这与任务等待程序支持捕获SynchronizationContext并Post回到其中的方式是不同的)。为了帮助处理ExecutionContext传播SynchronizationContext的情况,异步方法基础结构尝试忽略由于传播而设置为当前的SynchronizationContext。

简而言之,SynchronizationContext.Current不会“传播”等待点。

 

原文链接:https://devblogs.microsoft.com/pfxteam/executioncontext-vs-synchronizationcontext/

posted @ 2023-04-12 18:47  yonlin  阅读(100)  评论(0编辑  收藏  举报