《Async in C# 5.0》第八章 我的代码运行在哪个线程上?
摘要:本文深入探讨了C#异步编程的线程机制,指出第一个await前的代码始终运行在调用线程上,澄清了异步不会自动切换线程的常见误解。文章详细分析了异步操作生命周期中UI线程与IOCP线程的协作过程,强调SynchronizationContext在确保UI线程安全恢复中的关键作用。同时指出ConfigureAwait可优化性能但需谨慎使用,并警告同步代码调用异步方法可能导致死锁。最后通过堆栈调用示
正如我之前所说,异步编程的核心在于线程。在C#中,这意味着我们需要了解在程序的不同时间点,我们的代码都会运行在哪个.NET线程上,以及在执行耗时操作时线程上会发生什么。
第一个await之前
在你编写的每个异步方法里,总会有一些代码位于第一次出现的 await 关键字之前。同样,一些代码位于被等待的表达式中。这样的代码始终会在调用线程中运行(在第一个await之前不会发生任何有趣的事情)。
![]() |
这是关于异步最常见的误解之一。异步永远不会将你的方法调度到后台线程上运行。想达到这个目的,唯一的方法就是使用类似 Task.Run 之类的方法,它是专门为此目的而设计的。 |
对于UI应用程序来说,这意味着第一个await之前的代码会在UI线程上运行。同样,在 ASP.NET Web 应用程序中,这部分代码运行在 ASP.NET 工作线程上。通常,你可能会调用另一个异步方法,并将其放在首个 await 所在的行作为等待的表达式(关键点在于,这个表达式与第一个 await 在同一条语句中)。因为该表达式在第一个 await 之前执行,所以它同样运行于调用线程中。这意味着调用线程要继续执行应用程序更深层次的代码,直到某个方法返回一个 Task为止——这个方法可能是框架提供的方法,也可能是使用 TaskCompletionSource 构造 Puppet Task 的方法。该方法才是你的程序具备异步性的根源,并且所有的异步方法都会传播这种异步性。
在到达第一个真正的异步点之前,代码的执行时间可能相当长,在 UI 应用程序中,所有这些代码都由 UI 线程运行,因此这种情况 UI 会失去响应(但愿这些代码不会执行太长时间🙏)。重要的是要记住:仅仅使用 async 并不能保证你的 UI 有响应。如果感觉程序很慢,请使用性能分析器(Performance Profiler)找出时间消耗在哪里。
在异步操作期间
到底是哪个线程执行了异步操作呢?
这是一道陷阱题。这是异步代码,对于网络请求等典型操作,根本没有线程阻塞等待操作完成。
![]() |
当然,如果您使用异步来等待一个耗时计算,例如使用 Task.Run,则计算发生在线程池中的线程上。 |
实际存在一个线程等待网络请求完成,但这个线程是所有网络请求共享的。在 Windows 上,它被称为 I/O完成端口线程(即 I/O completion port thread,简称IOCP线程,详细解释见[1])。当网络请求完成时,操作系统的中断处理程序会将一个作业添加到 I/O 完成端口的队列中。假如要执行 1000 个网络请求,所有的请求(request)都会启动,然后随着回应(response)的到达,它们会依次被 I/O完成端口进行处理。
![]() |
实际上,为了利用多个 CPU 核心,操作系统通常会存在一些(而不是"一个") I/O 完成端口线程,但是,无论当前有 10 个还是 1000 个未完成的网络请求,IOCP线程总数都是不变的。 |
详细了解同步上下文
SynchronizationContext 是 .NET Framework 提供的一个类,它支持在特定类型的线程中执行代码。.NET 使用各种同步上下文,其中最重要的是被 WinForm 和 WPF 使用的 UI 线程上下文。
SynchronizationContext 的实例本身并没有什么实际用途,因此它的所有实例往往都是基于子类。它还拥有静态方法让你读取和控制当前的 SynchronizationContext —— 当前的 SynchronizationContext 是当前线程的一个属性。其理念是,当你在运行特殊线程时,你总是可以获取当前的 SynchronizationContext 并将其存储下来。之后,你就可以借助它的帮助在启动线程上继续执行代码。这些都不需要确切地知道你是在哪个线程中启动的,只要可以使用 SynchronizationContext,就可以返回到该线程。
SynchronizationContext 上的重要方法是 Post,它可以确保委托在正确的上下文中运行。
一些同步上下文会封装单个线程,例如 UI 线程。一些会封装特定类型的线程(例如线程池),但它可以选择其中任何一个线程来执行委托操作。还有一些同步上下文并不会改变代码运行的线程,而仅用于监视,例如 ASP.NET 同步上下文。
await 和同步上下文
我们知道第一个 await 之前的代码由调用线程运行,但是当 await 之后,方法恢复执行时,它运行在哪个线程上呢?
事实上,大多数情况下它仍旧由调用线程运行,尽管调用线程可能在此期间执行了其他操作。对于编程者来说,这让事情变得非常简单。
C# 使用 SynchronizationContext 来实现这一点。正如我们在第五章“上下文”部分看到的,当等待(await)一个 Task 时,当前的 SynchronizationContext 会作为暂停方法的一部分被存储起来。然后,当需要恢复该方法时,await 关键字的内部实现会使用 Post 在捕获的 SynchronizationContext 上恢复该方法。
现在说一些注意事项,如果出现以下情况,该方法可能会在其它线程上恢复(不同于启动线程):
- SynchronizationContext 有多个线程,例如线程池;
- SynchronizationContext 本身不会执行切换线程(译者注:但是异步方法恢复时依然可能因为线程池调度等原因跑到不同线程上);
- 在执行到 await 时并没有当前的 SynchronizationContext信息,例如在控制台应用程序中。
- Task 被配置为不使用 SynchronizationContext 进行恢复。
幸运的是,对于 UI 应用程序以上情况均不适用,因此你可以在 await 之后安全地操作 UI(对于UI程序来说,确保在同一个线程上恢复至关重要)。
异步操作的生命周期
让我们看一下网站图标的示例,来弄清楚代码都运行在哪些线程上。我编写了两个异步方法:
async void GetButton_OnClick(...)
async Task<Image> GetFaviconAsync(...)
事件处理方法 GetButton_OnClick 调用 GetFaviconAsync,后者又调用 Web Client.DownloadDataTaskAsync。下图是方法执行时的事件顺序图(图 8-1)。
1. 用户点击按钮,事件处理方法 GetButton_OnClick 被加入到队列中。
2. UI 线程执行 GetButton_OnClick 的前半部分,包括对方法 GetFaviconAsync 的调用。
3. UI 线程继续执行 GetFaviconAsync 的前半部分,包括对 DownloadDataTaskAsync 的调用。
4. UI 线程继续执行 DownloadDataTaskAsync,该函数启动下载并返回一个 Task。
5. UI 线程离开 DownloadDataTaskAsync,到达 GetFaviconAsync 方法中的 await。
6. 捕获当前 UI 线程上的 SynchronizationContext 。
7. GetFaviconAsync 因 await 而暂停,DownloadDataTaskAsync 产生的 Task 被告知在完成后恢复(使用捕获的 SynchronizationContext)。
8. UI 线程离开方法 GetFaviconAsync (此方法返回一个 Task),并到达 GetButton_OnClick 中的 await。
9. 同样,GetButton_OnClick 也因 await 而暂停。
10. UI 线程离开 GetButton_OnClick,并可以处理其他用户操作。
![]() |
此时,我们正在等待图标下载。这可能需要几秒钟。请注意,UI 线程可以自由地处理其他用户操作,此时 IO 完成端口线程尚未参与进来。此操作期间被阻塞的线程总数为零。 |
11. 下载完成,IO 完成端口将 DownloadDataTaskAsync 中的逻辑放入队列以处理该操作。
12. IO 完成端口线程将从 DownloadDataTaskAsync 返回的 Task 设置为完成。
13. IO 完成端口线程在 Task 内部运行代码来处理完成情况,该代码会调用捕获的 SynchronizationContext(UI 线程)上的 Post 方法来继续执行。
14. IO 完成端口线程被释放以处理其他 IO。
15. UI 线程找到 Post 的指令并恢复执行 GetFaviconAsync,执行其后半部分直至结束。
16. 当 UI 线程离开 GetFaviconAsync 时,它将 GetFaviconAsync 返回的 Task 设置为完成。
17. 因为此时当前的 SynchronizationContext 与捕获的相同,所以不需要 Post,UI 线程以同步的方式继续执行。
![]() |
此逻辑在 WPF 中不可靠,因为 WPF 通常会创建新的 SynchronizationContext 对象。尽管它们是等效的,但这会让 TPL 认为它需要再次 Post。 |
18. UI 线程恢复 GetButton_OnClick,执行其后半部分直至结束
这个过程相当复杂,但我认为值得把每个步骤都详细地说明出来。请注意,上面的每一行代码都是由 UI 线程执行的。IO 完成端口线程只是运行了一段时间,然后向 UI 线程发送一条 Post 指令,UI 线程则执行了我的两个方法的后半部分。
不使用 SynchronizationContext
SynchronizationContext 的每种实现都以不同的方式执行 Post,其中大多数开销相对较高。为了避免这种开销,当捕获的 SynchronizationContext 与 Task 完成时所在的线程上的 SynchronizationContext 相同时,.NET 将不会使用 Post 方法。发生这种情况时,如果你使用调试器查看,调用堆栈将上下颠倒(忽略框架代码)——从程序员视角本应由其他方法调用的最深层的那个方法,反而在自身完成时调用了其他方法(译者:详细解释见文章底部[2])。
但是,当同步上下文不同时,就需要执行开销较大的 Post 操作。对于性能要求较高,或者不关心到底运行在哪个线程上的库代码来说,你可能不想承担这种性能损失。那么你可以通过在等待Task 之前调用 ConfigureAwait 来实现。如果这样做,恢复时就不会向原始的同步上下文发送 Post 请求。
byte[] bytes = await client.DownloadDataTaskAsync(url).ConfigureAwait(false);
不过,ConfigureAwait 并不总是按照你的预期工作。它的设计目的是向 .NET 暗示你并不介意方法将来在哪个线程上恢复执行。它的实际作用取决于哪个线程完成了你正在等待的任务。如果该线程不重要(例如来自线程池),则它应该继续执行你的代码。但如果它是某种重要的线程,.NET 会优先将其释放以执行其他操作,你的方法将在线程池中恢复执行(.NET 使用线程的当前 SynchronizationContext 来判断它是否重要)。
与同步代码交互
当你处理的应用程序是一个既有系统时,你可以使用TAP编写新的异步代码,你也需要与这个系统既有的同步代码进行交互,这样做通常会失去异步的优势,但你需要考虑以异步方式编写新代码,这样才可能在将来的某个时候进行切换。
从异步代码中使用同步代码很容易。如果给定一个阻塞式 API,你可以使用 Task.Run 直接在线程池上运行它,并等待它完成。你使用了一个线程,但这不可避免。
var result = await Task.Run(() => MyOldMethod());
从同步代码中使用异步代码,或实现同步的API,看起来也很容易,但可能存在隐蔽的问题。Task 有一个名为 Result 的属性,它会阻塞式等待 Task 完成。你可以在使用 await 的地方使用它,但方法不需要标记为 async 或返回 Task。同样,一个线程被浪费了。这次调用线程被用于阻塞。
var result = AlexsMethodAsync().Result;
不过,需要提醒一点:当从只有一个线程(例如 UI 线程)的同步上下文中使用此技术时,它会出问题。想想 UI 线程被要求做什么,它被阻塞了,因为它在等待 AlexsMethodAsync 中的 Task 完成。AlexsMethodAsync 很可能已经调用了另一个 TAP 方法,并正在等待它。当操作完成时,捕获的同步上下文(UI 线程)将用于向 AlexsMethodAsync 发送 Post 恢复指令。但是 UI 线程永远不会收到该消息,因为它仍然处于阻塞状态——你编写了一个死锁。幸运的是,这种导致死锁的错误时有发生,因此不难调试。
谨慎起见,你可以在启动异步代码之前将其移动到线程池来规避死锁问题,这样捕获的 SynchronizationContext 就是线程池的而不是 UI 线程的。但这也非常糟糕,最好还是花时间将调用代码改为异步方式。
var result = Task.Run(() -> AlexsMethodAsync()).Result;
<正文完>
[1] IOCP是 Windows 提供的一种高性能异步 I/O 模型,而 IOCP 线程则是专门用来处理 I/O 完成通知的工作线程,它负责从 IOCP 队列 中获取 I/O 完成包,并执行相应回调或处理逻辑的线程。
[2] 如何理解callstack上下颠倒?
为了清晰解释这个现象,我们用最简化的两方法嵌套示例:
async Task Caller() // 调用方
{
await Callee(); // 等待 Callee 完成
Console.WriteLine("Caller 恢复");
}
async Task Callee() // 被调用方
{
await Task.Delay(1); // 等待 1ms
Console.WriteLine("Callee 恢复");
}
- Task.Delay 在 ThreadPool 线程完成。
- 若捕获的上下文与当前上下文相同(例如同为 ThreadPool 上下文),.NET 跳过 Post,直接在 ThreadPool 线程上同步执行 Callee 的回调。
- Callee 回调 → 输出 "Callee 恢复" → Callee 完成 → 立即同步执行 Caller 的回调 → 输出 "Caller 恢复"。
堆栈:
> Console.WriteLine("Caller 恢复") ← 栈顶
Caller+MoveNext() ← Caller 回调
Callee+MoveNext() ← Callee 回调
Task.Delay+完成回调() ← Timer 回调
[ThreadPool 调度循环]
颠倒之处:
- 从静态代码看:Caller 调用了 Callee,Callee 调用了 Task.Delay。
- 从动态堆栈看:Task.Delay 完成回调调用了 Callee 回调,Callee 回调又调用了 Caller 回调。
- 调用关系在堆栈上完全反转:最底层(Task.Delay)调用了上层(Callee),上层(Callee)又调用了更上层(Caller)。
- 这就是原文所说的 “最深的方法(此处为 Task.Delay 回调)反而在自身完成时调用了其他方法(Callee、Caller)”,堆栈呈现“从下往上调用”的假象。
核心差异:
- 使用 Post 时,完成线程将回调“外包”给其他线程,自己立即退出,所以堆栈总是从目标线程的调度循环开始,看起来干净、自然。
- 跳过 Post 时,完成线程自己扮演了“调度者”,直接嵌套调用所有后续回调,导致在同一个线程上形成了深度嵌套的回调调用链,且调用方向与代码静态调用关系完全相反,从而让调试者感到“上下颠倒”。
更多推荐




所有评论(0)