CodeGym /课程 /C# SELF /异步与同步代码的交互

异步与同步代码的交互

C# SELF
第 62 级 , 课程 4
可用

1. 介绍

在 C# 里异步是个很强的工具。但有时候你需要把异步代码从同步代码里调用(或者反过来),看起来应该“魔法般”正常工作,但实际会出现奇怪的卡死(deadlock)、性能损失,甚至把用户界面搞崩。很多 bug 只在真实数据/真实环境下出现:生产或用户机器上。问题通常出在同步和异步代码不恰当的交互,和 .NET 任务调度器的一些不明显的细节上。

另外,现代库和框架大量使用异步方法,你得懂得如何把异步代码“粘”进现有的同步调用链,或者相反,如何在异步方法里正确调用同步代码。

简短回顾:await 会发生什么

当你写:

await SomeAsyncMethod();

代码会被“断开”成两部分:await 之前和之后。第一部分在遇到第一个异步等待点(比如网络请求)之前执行,继续部分在异步操作完成后执行。问题是:这个“继续”会在哪儿执行?在同一线程吗?还是另一个?如果是桌面应用(比如 WPF 或 WinForms)会怎样,控制台又怎样?答案——视情况而定。这就引入了比如 ConfigureAwait

什么是 SynchronizationContext?

SynchronizationContext 是 .NET 的一个机制,用来“记住”异步操作的继续应该在哪里以及如何被调用。

  • 在经典的 WinForms/WPF 应用里,SynchronizationContext 可以保证在 await 之后方法的剩余部分在同一个 UI 线程上继续执行,这样就不会出现“从错误线程访问控件”的错误。
  • 在旧版 ASP.NET(不是 Core)里,SynchronizationContext 帮助恢复 HttpContext 并继续处理 HTTP 请求。
  • 在控制台程序和 ASP.NET Core 应用里,通常 SynchronizationContext 不存在(等于 null),继续会在线程池上执行。

什么是 TaskScheduler?

TaskScheduler 是更底层的机制。大多数情况下你用的是 TaskScheduler.Default,它使用 .NET 的线程池。它负责决定哪些任务什么时候在哪儿执行。

2. await 与 .Result/.Wait() 混用导致的 deadlock

这是 C# 里最著名的陷阱之一:

// 在 UI 代码的某处
var result = SomeAsyncMethod().Result;

或者

SomeAsyncMethod().Wait();

完了:应用“卡住”了。为什么?

这是怎么发生的?

  1. 你调用一个异步方法,然后立刻取 .Result 或者调用 .Wait() —— 也就是阻塞当前线程,等异步任务完成。
  2. SomeAsyncMethod 内部做了一个 await,并把继续(continuation)计划在同一线程上通过 SynchronizationContext 执行,但这个线程已经被等待 .Result/.Wait() 给堵住了。
  3. 因为线程被阻塞,无法执行 continuation(继续部分)。
  4. 然后就死锁了:线程在等它自己完成工作。

在 UI 应用里这很容易复现,因为大多数代码在 UI 线程上运行,所有的继续都等着这个线程。在控制台程序和 ASP.NET Core(如果没有同步上下文)里,这种 deadlock 就不常见了。

生活小趣:如果你成功用 .Result 做出 deadlock——恭喜,你离 Senior 又近了一步 :D

3. 如何把异步代码正确嵌入到同步代码中?

推荐一:从上到下的异步化

如果你有个异步方法,尽量把 async/await 往调用栈顶(比如 UI 或入口点)传上去。不要半路上“截断”异步化。

不好的做法(会阻塞线程):

// 同步方法通过 .Result 调用异步方法
public void DoStuff()
{
    var data = GetDataAsync().Result;
    // ...
}

好的做法(异步贯穿):

public async Task DoStuffAsync()
{
    var data = await GetDataAsync();
    // ...
}

如果可以——尽量用 await,不要用 .Result / .Wait()

4. 但有时你确实要从 sync 调用 async

把上层调用链全部改成 async(这是最好的选项,如果你可以的话)。

或者用一些特定的模式:比如把工作放到另一个线程上,通过 Task.Run 启动,然后在里面调用 async 方法。

public void DoStuff()
{
    var result = Task.Run(() => SomeAsyncMethod()).Result;
}

但是:在 UI 应用里这也有同步相关的细节,所以没有很必要别随便这么做。

5. ConfigureAwait(false) 是干嘛的?

有时候你不需要继续在原来的线程上执行 await 之后的代码(比如服务器端代码或库代码,那里不依赖 UI 的上下文)。相反,不把执行绑死在一个线程上通常更快。

语法和工作原理

await SomeAsyncMethod().ConfigureAwait(false);
  • ConfigureAwait(false) 的意思是:我不需要原来的 SynchronizationContext,继续可以在任何地方执行,哪怕是别的线程。
  • ConfigureAwait(true)(默认)——“尽量在调用 await 的那个地方继续执行,最好是在同一个 SynchronizationContext”。

示意图


        ┌─────────────────────────────────────────────────────┐
        │                     SynchronizationContext          │
        └─────────────────────────────────────────────────────┘
                      ↑                             ↑
       (UI 线程)   await SomeAsyncMethod()        Continuation(await 之后)
                  ──────────────────────────────>  (同一线程 — 如果 ConfigureAwait(true))
                      ↓
                    (任意线程 — 如果 ConfigureAwait(false))

示例:库级代码里如何使用 ConfigureAwait(false)

假设你在写一个被各种程序(WinForms、WPF、ASP.NET、控制台)共用的库……

你不应该依赖调用方的线程模型。所以在库的异步方法里通常应该用 ConfigureAwait(false)

public async Task<string> LoadDataFromUrlAsync(string url)
{
    using var client = new HttpClient();
    string content = await client.GetStringAsync(url).ConfigureAwait(false);
    return content;
}

这样你的方法就不会“要求”在某个特定的 SynchronizationContext 上运行,更安全,性能也更好(少了线程切换)。

示例:没有 ConfigureAwait 的 await 会发生什么

看一个 WPF 程序:

private async void Button_Click(object sender, RoutedEventArgs e)
{
    Button1.Content = "加载...";
    await Task.Delay(2000);  // 模拟耗时操作
    Button1.Content = "完成!";
}

Task.Delay 内部做了一个 await。默认情况下,await 之后会把控制权带回 UI 线程,这样你可以继续更新 UI。

如果在耗时操作内部你用了 ConfigureAwait(false)

await Task.Delay(2000).ConfigureAwait(false);
Button1.Content = "完成!";  // 错误!

会抛出异常:InvalidOperationException: "The calling thread cannot access this object because a different thread owns it."
因为继续现在在别的线程上执行,不能直接访问 UI。

结论:只有在不需要访问 UI/上下文的地方才用 ConfigureAwait(false)

6. 有用的细节

何时/何地使用 ConfigureAwait

场景 需要使用 .ConfigureAwait(false)? 为什么?
库代码 会在任意上下文中被调用,不依赖 UI
在 ASP.NET Core 代码内部 是(上下文几乎不存在) 能提升性能
在 WinForms/WPF 中访问 UI 时 需要把控制权返回到 UI 线程
同步方法 没有意义 没有 SynchronizationContext
在控制台应用中 可以,但看不出效果 没有上下文

怎么知道是否有 SynchronizationContext?

可以直接从代码里检查:

Console.WriteLine(SynchronizationContext.Current == null
    ? "没有上下文"
    : "存在上下文");
  • 在控制台和 ASP.NET Core 应用会输出 "没有上下文"。
  • 在 WinForms/WPF 会输出 "存在上下文"。

线程切换可视化

sequenceDiagram
    participant MainThread as 主线程 (UI/Console)
    participant ThreadPool as 线程池线程

    MainThread->>SomeAsyncMethod: 调用方法
    SomeAsyncMethod->>MainThread: await(无 ConfigureAwait)
    Note right of MainThread: await 后返回同一线程
    SomeAsyncMethod->>ThreadPool: await(使用 ConfigureAwait(false))
    Note right of ThreadPool: await 后可以在任意线程上工作

简短使用规则

  • 不要在带有异步方法的 UI 代码里使用 .Result.Wait()
  • 在库代码中,所有的 await 应尽量加上 ConfigureAwait(false)
  • 在 UI 应用里,只有在不会访问界面元素的方法内部才用 ConfigureAwait(false)
  • 尽量别混用同步和异步代码,除非非常必要。
  • 如果必须从 sync 调用 async —— 先想想能否把整条调用链都改成 async

7. 新手在异步编程中常犯的错误

错误 1:到处使用 .Result.Wait()
很多开发者看了几篇关于异步的文章后,会在各处加上 .Result.Wait(),想把异步调用“同步化”。表面上看着方便,但在 UI 应用里这是通向 deadlock 的必经之路。尤其是当异步方法内部没有用 ConfigureAwait(false) 时,UI 线程会被阻塞,无法执行继续部分。

错误 2:错误或过度使用 ConfigureAwait(false)
有些初学者把 ConfigureAwait(false) 到处塞,甚至在需要访问 UI 的代码里也用。结果在 UI 应用里经常会抛出控件访问错误(InvalidOperationException),因为继续在非 UI 线程上执行。

错误 3:忘记 ConfigureAwait 只对 awaitable 对象有效。
很多人认为可以对任何方法用 ConfigureAwait(false),但实际上它只对返回 TaskTask<T>ValueTask 等 awaitable 类型生效。对同步方法没效果,等待同步方法不会变成异步。

Error #4: Mixing synchronous and asynchronous code unnecessarily.
尝试在同步代码里调用异步方法而没想好策略(比如靠 .Result.Wait()Task.Run)常会导致难排查的 deadlock、性能问题和微妙的 bug。即使某个方法看起来短小,也可能破坏整条调用链。

错误 5:低估 SynchronizationContext 的影响。
新手常常忘了:如果不使用 ConfigureAwait(false),await 之后的继续可能会在同一个线程(UI)上执行。这在混合 UI 和库代码时会带来不可预期的行为,尤其是当等待和继续交错在一起时。

2
任务
C# SELF, 第 62 级, 课程 4
已锁定
从同步方法调用异步方法
从同步方法调用异步方法
1
调查/小测验
异步数据流第 62 级,课程 4
不可用
异步数据流
深入了解异步
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION