All about ExecutionContext and SynchronizationContext

分享
All about ExecutionContext and SynchronizationContext

前言

这篇文章深入探讨了ExecutionContext和SynchronizationContext, 这是大部分开发人员都不需要了解的 .NET 高级领域.

SynchronizationContext

我最早关于 SynchronizationContext 了解可能是在 WindowsForm 程序, 当我错误的在其它线程去更新 UI 控件时, 总会出现一个异常 Cross-thread operation not valid: Control accessed from a thread other than the thread it was created on 翻译过来即 跨线程操作无效:控件从创建它的线程以外的线程访问, 例如这个代码片段:

private void button_Click(object sender, EventArgs e)
{
    Task.Run(() =>
    {
        //异常: 跨线程操作无效:控件从创建它的线程以外的线程访问
        textBox.Text = "异步更新";
    });
}

我相信如果你写过 WindowsForm 程序, 肯定遇到过这个错误, 这是因为 WindowsForm 程序希望所有的 UI 操作都通过 UI 线程去更新, 当你在单线程中, 无论怎样操作, 都是可以的, 因为不管是按钮事件还是文本框事件, 都是通过 UI 线程触发的, 那么事件内代码也是通过 UI 线程执行的, 因此, 如果你没有其它线程去操作控件的话, 是不会出现此异常的. 我当时通过简单的禁止此检查来避免异常, 实际上正确的方式应当通过我们的主角之一 SynchronizationContext 去更新UI, 像这样:

private void button_Click(object sender, EventArgs e)
{
    var sc = SynchronizationContext.Current;
    Task.Run(() =>
    {
        sc.Post(delegate { textBox.Text = "异步更新"; }, null);
    });
}

这里发生了什么? 抓住我的手, 我们一步步进行探索. SynchronizationContext 是一个具有抽象功能的类, 主要的两个方法 Send 以及 Post, 这两个方法的动作一致, 仅仅是同步与非同步的区别, Post 接受一个委托, 用来将委托排队到线程上下文, 这里的上下文可能是多个线程的上下文, 也可能是单个线程的上下文, 这取决于具体的 SynchronizationContext 实现.

WindowsFormsSynchronizationContext 在 WindowsForm 程序启动时被设置到 UI 线程, 因此在这里, SynchronizationContext.Current, 实际上是 WindowsFormsSynchronizationContext, 它的上下文是单个 UI 线程, WindowsFormsSynchronizationContext 重写了 Post 及 Send, 它将委托传递给基础 Win32 消息循环, 而后 UI 线程接受消息并执行委托方法. 因此这段代码解释为: 在异步线程中将委托方法排队到 UI 线程, 随后 UI 线程更新 textBox.Text.

前面说到, SynchronizationContext 并不表示真正的线程同步上下文, 实际情况要看它具体的实现, 例如在 WPF 和 Silverlight 程序中, 它是 DispatcherSynchronizationContext, 它将委托以 Normal 优先级排列到 UI 线程的 Dispatcher. 当线程通过调用 Dispatcher.Run 开始其 Dispatcher 循环时, 此 SynchronizationContext 被设置为当前上下文. DispatcherSynchronizationContext 的上下文是单个 UI 线程.

默认的 SynchronizationContext 是 SynchronizationContext 实例, 如果线程的 SynchronizationContext.Current 为 null, 则认为具有默认的SynchronizationContext. 默认的 SynchronizationContext 将 Post 委托排队到线程池上, 而 Send 委托则使用调用 Send 方法的线程同步执行. 因此从这个角度来说, SynchronizationContext 上下文是整个 ThreadPool.

SynchronizationContext 的 Post 方法并不总是异步排队执行, 在 ASP.NET 中, SynchronizationContext 实际为 AspNetSynchronizationContext, 这种情况下即使使用 Post 方法, 也会立即执行委托.

SynchronizationContext 实现摘要

用于执行委托的特殊线程 一次执行一个委托 有序(按照排队顺序) Send方法直接调用委托 Post 方法直接调用委托
WindowsForm 是 是 是 仅限于UI线程调用 从不
WPF/Silverlight 是 是 是 仅限于UI线程调用 从不
Default 否 否 否 总是 从不
ASP.NET 否 是 否 总是 总是

ExecutionContext

执行上下文是绝大多数开发人员所不需要关心的, 它像空气一样, 它总是在那, 但是你从不关心, 当你关心它的时候, 那么一定是出了些什么问题. ExecutionContext 就像线程的环境, 可以看成是一个容器, 这个容器里面包含了所有线程的其它上下文信息, 例如 SecurityContext, 它维护诸如当前 主体 之类的信息以及有关代码访问安全性 (CAS) 拒绝和允许的信息. 在许多系统中, 这样的数据通常存储在 ThreadLocalStorage(TLS), 在同步世界中, 这样的存储就够了, 一切都发生在那个线程上, 任何数据都能从线程的本地存储中恢复.

但是当我们从同步世界转向异步世界时, 线程的本地存储就无法使用了, 你的代码可能运行在不同的线程中. 例如在同步世界中, 你执行 A 操作, 再执行 B 操作, 它们的线程环境都是相同的, 线程的本地存储数据也是共享的, 但是到了异步, A 操作与 B 操作可能是两个不同的线程执行, 因此, 它们的线程环境不同, 线程的本地存储数据也不会从一个线程流向另一个线程. 线程本地存储是特定于线程的, 而异步操作与特定线程无关. 然而, 通常存在一个逻辑控制流, 以便于将环境数据从一个线程 "流向" 另一个线程, 这就是 ExecutionContext 所存在的意义.

ExecutionContext 实际上只是一个状态包, 可用于从一个线程捕获所有这些状态, 然后在逻辑控制流继续时将其恢复到另一个线程. ExecutionContext 是使用静态 Capture 方法捕获的:

// 捕获环境状态
ExecutionContext ec = ExecutionContext.Capture();

它在通过静态 Run 方法调用委托期间恢复:

ExecutionContext.Run(ec, delegate
{
    ... // 此处的代码将 ec 的状态视为环境
}, null);

.NET Framework 中的所有异步操作都通过这种方式进行捕获和恢复 (除了那些以Unsafe开头的方法, 这些方法表示为不安全的, 因为它们明确不捕获和恢复环境数据). 例如, 当你使用 Task.Run 时, ExecutionContext首先捕获当前线程的环境数据, 然后将ExecutionContext存储到Task中, 之后当 Task.Run开始执行委托方法时, ExecutionContext.Run 将被调用恢复环境数据. 对于 Task.Run、ThreadPool.QueueUserWorkItem、Delegate.BeginInvoke、Stream.BeginRead、DispatcherSynchronizationContext.Post 以及你能想到的任何其他异步 API, 都是如此. 它们都捕获 ExecutionContext 并存储它, 然后在稍后执行委托的期间恢复环境上下文.

async/await 的情况

async/await 语法糖背后的框架支持自动捕获和恢复 ExecutionContext 以及 SynchronizationContext.

在异步开始等待时, 框架捕获 ExecutionContext 并将引用赋值给 Awaiter, Awaiter 在稍后恢复时, 将还原 ExecutionContext, 这意味着 ExecutionContext 能够流过异步await.

上述对 ExecutionContext 的支持内置在表示异步方法的 "构建器" 中 (例如 System.Runtime.CompilerServices.AsyncTaskMethodBuilder), 并且这些构建器确保 ExecutionContext 流过等待点,而不管使用的是哪种类型的等待. 相比之下, 对 SynchronizationContext 的支持内置于对等待 Task 和 Task 的支持中. 自定义等待器可以自己添加类似的逻辑,但它们不会自动获取, 这是设计使然, 因为能够自定义调用延续的时间和方式是自定义等待器有用的部分原因.

当你等待一个 Task 时, 默认将捕获当前线程的 SynchronizationContext, 如果不为空, 则会将等待完成后的后续代码使用 SynchronizationContext.Post 发送到 SynchronizationContext 上下文执行, 而不是在当前线程执行, 作为样例, 考虑一个常见的 WindowsForm 程序线程死锁代码:

static async Task<string> LoadStringAsync()
{
    string firstName = await GetFirstNameAsync();// 线程发生死锁
    string lastName = await GetLastNameAsync();
    return firstName + ” ” + lastName;
}

private void button1_Click(object sender, RoutedEventArgs e)
{
    Task<string> s = LoadStringAsync();
    textBox1.Text = s.Result; 
}

为什么? 当 await GetFirstNameAsync 时, SynchronizationContext 被捕获, 用来在异步方法完成后使用 SynchronizationContext.Post 发送委托方法给 UI 线程继续执行 GetFirstNameAsync, 然而, 当异步方法完成时, UI 线程正在 button1_Click 方法同步等待异步执行完成, 而异步线程在等待 UI 线程继续执行 GetFirstNameAsync, 因此死锁发生.

ExecutionContext中的SynchronizationContext

实际上, SynchronizationContext 也是 ExecutionContext 的一员. 当 ExecutionContext.Capture 捕获时, SynchronizationContext 也会被捕获并且设置为 SynchronizationContext.Current, 这会带来一些问题, 一个是 SynchronizationContext.Current 意义开始变得不明确模糊起来, 在线程中拿到的 SynchronizationContext.Current 可能是前面线程的同步上下文, 有可能是当前的线程同步上下文, 这导致了混乱, 作为这可能会出现问题的一个示例, 请考虑以下代码:

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

这有什么问题? 我期望的是, Task.Run 执行时, ExecutionContext.Capture 捕获当前线程环境, 并在 Task 实际执行是恢复环境信息, 当执行 await DownloadAsync() 时, 如果 SynchronizationContext.Current 为 null, 则 DownloadAsync 之后的 Compute 代码会作为线程池线程执行, 而如果 SynchronizationContext.Current 随着环境信息流动下来, 则 DownloadAsync 之后的 Compute 代码会被 SynchronizationContext.Post 发送到 UI 线程进行执行, 这会可能会导致 UI 界面有些阻塞.

幸运的是, ExecutionContext.Capture 实际上有一个重载方法, 但是这个重载方法并不公开 (在mscorlib内部), 它允许可选的阻止 SynchronizationContext 作为环境的一部分向下流动, 与此相对应, 还有一个 ExecutionContext.Run 的重载方法, 它支持忽略存储在 ExecutionContext 中的 SynchronizationContext, 由 mscorlib 公开的大部分异步 API 都是使用的重载方法, 因此 SynchronizationContext 不会随着环境信息向下流动. 但是任何独立于 mscorlib 之外的异步实现 API 都将流动 SynchronizationContext. 上面所说的异步方法构建器(AsyncTaskMethodBuilder), 是实现于 mscorlib 的 API, 且确实使用了重载方法, 因此它不会随着环境信息流动.

简单来说, SynchronizationContext.Current 不会随着 await Task 向下流动, ExecutionContext 会随着 await Task 向下流动.

Reference

阅读更多

以太坊黑暗森林-抢跑(front running)

以太坊黑暗森林-抢跑(front running)

前言 鸽了很久之后的今天突然心血来潮,准备写一个系列:以太坊黑暗森林,它介绍以太坊生态上的各种奇思妙想和逆天的攻击方式,会从简单的、常见的攻击方式开始介绍。取这个名字是因为我接触以太坊不久后看的一篇文章 Ethereum is a Dark Forest ,让我想起了《三体》小说中刘慈欣描述的黑暗森林,以太坊是一个弱肉强食的、没有规则的世界,猎人们总是躲在背后监听所有的交易,一旦发现猎物,它们会把它的血给吸干。 开盘抢币 相信进入以太坊生态的韭菜们,一定有过在 uniswap 上买刚开盘新币的经历,新开盘的币,一般会上涨几倍甚至十几倍,越早买入则越能低价买入。你守着时间,等着项目方添加流动性后第一时间买入代币,但是你发现,无论你的手速多块,总是看到一开盘,价格已经飚了几倍,你骂骂咧咧,开始不断拉高 gas 费用,尝试继续买入,但是你眼睁睁的看着代币涨到十倍,自己的交易却一直失败,你开始怀疑项目方自己抢跑,怀疑项目方捣鬼:肯定是项目方吃相难看,用老鼠仓提前买了。另一些聪明人,研究了以太坊的基本技术,他们在 ethscan

By FatTiger
C#:IDisposable 和 析构函数

C#:IDisposable 和 析构函数

C# 中有两种释放资源的方式:实现 IDisposable 或使用析构函数。通常,必须在特定时间释放资源的场景中,我们实现 IDisposable,像这样: public class ExampleDispose : IDisposable { // 非托管资源 private IntPtr _handle; // 使用的其它托管资源 private readonly Stream _stream; private bool disposed = false; public ExampleDispose(Stream stream, IntPtr handle) { this._stream = stream; this._handle = handle; } public void Dispose() { if (disposed) { return; } disposed = t

By FatTiger
ThreadLocal引发的灾难

ThreadLocal引发的灾难

在 Java 里有个称之为线程本地变量的类型叫做 ThreadLocal,它与 ThreadLocal 之于 C# 中是一样的作用,可以在线程范围内设置变量,这个变量只会在当前线程可被访问,但是它们有一点不同的是,在 Java 中,当你设置好变量后,在线程使用完毕回到线程池之前,需要手动调用 ThreadLocal.remove() 方法去清除线程本地变量,否则变量随着线程回到线程池,并且在下次使用此线程时此变量继续存在,而在 C# 中,线程回到线程池时会自动清除本地变量,因此无需手动去清除。 我们的业务有这样一个场景:某个业务 UserService 类中,具有多个方法会频繁(甚至循环)调用一个获取用户标签的接口,具体原因是因为某些方法会进行递归,数据结构有个树状结构,因此,为了优化接口响应时间以及看起来不那么蠢,我使用 ThreadLocal 将用户标签接口的返回数据存储到当前线程,因为在单个请求中,多次调用此接口获取数据是不必要的,它看起来像这样: /** * 此静态变量ThreadLocal会为每个线程创建本地副本, 因此USER_TAGS_THREAD_

By FatTiger
我在币安智能链的日子-区块链基础

我在币安智能链的日子-区块链基础

区块和链 无论是比特币还是以太坊,都是具有一个个区块(称之为Block)的链式结构,学过<数据结构>的肯定明白链表,区块链就像一个链表,每个区块都存储上一个区块哈希。 链(称之为Chain),有非常多的链,他们的协议不同,技术也不尽相同,比特币网络是一个链,以太坊网络是另一个链,每个链都有自己的目标(甚至目标只是为了圈钱),每个链也都有自己的代币,比特币网络的代币是比特币,每次交易都需要比特币作为手续费,以太坊网络代币是以太币,每次在以太坊网络的交易都需要以太币作为手续费。所以,链实际上作为基础设施,非常多的团队喜欢创建新的链,但是一个链光有网络光有代币不行,没有生态,很难成功。 币安智能链(Binance Smart Chain:BSC) 我的主要操作都是在BSC上,没有其它原因,只因为一个穷字。在BTC网络交易,需要BTC用作手续费,这个我可用不起,在以太坊(Ethereum)网络交易,需要以太币(ETH)作为用作手续费,按照以太币目前(

By FatTiger