Loading... ### 前言 这篇文章深入探讨了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` 翻译过来即 `跨线程操作无效:控件从创建它的线程以外的线程访问`, 例如这个代码片段: ```csharp private void button_Click(object sender, EventArgs e) { Task.Run(() => { //异常: 跨线程操作无效:控件从创建它的线程以外的线程访问 textBox.Text = "异步更新"; }); } ``` 我相信如果你写过 WindowsForm 程序, 肯定遇到过这个错误, 这是因为 WindowsForm 程序希望所有的 UI 操作都通过 UI 线程去更新, 当你在单线程中, 无论怎样操作, 都是可以的, 因为不管是按钮事件还是文本框事件, 都是通过 UI 线程触发的, 那么事件内代码也是通过 UI 线程执行的, 因此, 如果你没有其它线程去操作控件的话, 是不会出现此异常的. 我当时通过简单的禁止此检查来避免异常, 实际上正确的方式应当通过我们的主角之一 SynchronizationContext 去更新UI, 像这样: ```csharp 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 方法捕获的: ```csharp // 捕获环境状态 ExecutionContext ec = ExecutionContext.Capture(); ``` 它在通过静态 Run 方法调用委托期间恢复: ```csharp 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<TResult> 的支持中. 自定义等待器可以自己添加类似的逻辑,但它们不会自动获取, 这是设计使然, 因为能够自定义调用延续的时间和方式是自定义等待器有用的部分原因. 当你等待一个 Task 时, 默认将捕获当前线程的 SynchronizationContext, 如果不为空, 则会将等待完成后的后续代码使用 SynchronizationContext.Post 发送到 SynchronizationContext 上下文执行, 而不是在当前线程执行, 作为样例, 考虑一个常见的 WindowsForm 程序线程死锁代码: ```csharp 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 可能是前面线程的同步上下文, 有可能是当前的线程同步上下文, 这导致了混乱, 作为这可能会出现问题的一个示例, 请考虑以下代码: ```csharp 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 * [ExecutionContext vs SynchronizationContext](https://devblogs.microsoft.com/pfxteam/executioncontext-vs-synchronizationcontext/) * [Await, and UI, and deadlocks! Oh my!](https://devblogs.microsoft.com/pfxteam/await-and-ui-and-deadlocks-oh-my/) * [Parallel Computing - It's All About the SynchronizationContext](https://docs.microsoft.com/en-us/archive/msdn-magazine/2011/february/msdn-magazine-parallel-computing-it-s-all-about-the-synchronizationcontext#the-need-for-synchronizationcontext) * [ASP.NET Core SynchronizationContext](https://blog.stephencleary.com/2017/03/aspnetcore-synchronization-context.html) 最后修改:2021 年 09 月 06 日 © 允许规范转载 赞 0 如果觉得我的文章对你有用,请随意赞赏
2 条评论
> 对于 Task.Run、ThreadPool.QueueUserWorkItem、Delegate.BeginInvoke、Stream.BeginRead、DispatcherSynchronizationContext.Post 以及你能想到的任何其他异步 API, 都是如此. 它们都捕获 ExecutionContext 并存储它, 然后在稍后执行委托的期间恢复环境上下文.
经测试,在.NET6环境中的`Task.Run`默认调用的是`ThreadPoolTaskScheduler`的`UnsafeQueueWorkItem`方法,并不会捕获`ExecutionContext`
查看源码:https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/ThreadPoolTaskScheduler.cs#L44
可以看到使用了 ThreadPool.UnsafeQueueUserWorkItemInternal, 确实对于新版 .NET 来说,这里的情况发生了变化