用一张图整理 WPF/WinForms 的 async 与 UI 线程

· 更新日期: · · C#, async/await, .NET, WPF, WinForms, UI, 线程

更新记录(2 条,最后更新 2026年09月03日)

本文的修改记录。已保存的更新前版本,可通过带有 DOI 的永久链接阅读。

本文此前是日文原文的节译,缺少大量章节、表格、Mermaid 图、图题、脚注与 FAQ。现已改写为日文原文的完整译文,技术主张与日文版一致,并补上了此前缺失的图与表,同时统一了全篇的术语译法。 查看更新前的版本 (DOI: 10.5281/zenodo.22276594)
补充了日文原文中已有的咨询引导(consultation_services)。正文内容没有改动。 查看更新前的版本 (DOI: 10.5281/zenodo.21615441)
首次发布
引用本文(DOI: 10.5281/zenodo.21615440)

本文保存于 Zenodo。以下同时提供始终指向最新版本的 DOI,以及固定于您正在阅读版本的 DOI。

小村 豪(2026)。《用一张图整理 WPF/WinForms 的 async 与 UI 线程》。小村软件有限公司。https://doi.org/10.5281/zenodo.21615440 https://comcomponent.com/zh-CN/blog/2026/03/12/000-wpf-winforms-ui-thread-async-await-one-sheet/

DOI(最新版本)
10.5281/zenodo.21615440
DOI(此版本)
10.5281/zenodo.22281950

在 WPF / WinForms 中使用 async / await 时,最容易困惑的是 await 之后会返回到哪个线程,以及 什么时候才可以操作 UI。 特别是当 Dispatcher、BeginInvoke、ConfigureAwait(false)、.Result / .Wait() 混在一起时,界面卡死或跨线程异常的原因就会变得很难看清。

本文只讨论 WPF / WinForms 中 UI 线程与 async / await 的关系。 async / await 的整体判断依据,与 C# async/await 实战判断表 - Task.Run 与 ConfigureAwait 一文相衔接。

在实务中真正让人头疼的,大致就是下面这些地方。

  • await 之后,不知道续行会在哪里执行
  • 用 Task.Run 包了一层之后,不确定能不能操作 UI
  • 不知道 ConfigureAwait(false) 应该加在哪里
  • .Result / .Wait() / .GetAwaiter().GetResult() 导致界面卡死
  • WPF 的 Dispatcher 与 WinForms 的 Invoke / BeginInvoke / InvokeAsync 在脑子里混成一团

WPF / WinForms 都是 以 UI 线程为中心的模型。 因此,整理 async / await 时最有效的做法,不是去谈论“异步到底是什么”这类偏哲学的问题,而是把 对 UI 线程和消息循环到底做了什么 弄清楚。

本文主要以 .NET 6 及以上版本的 WPF / WinForms 应用 为前提,按照实务中好用的顺序,依次追踪 await 之后的返回位置、Dispatcher、ConfigureAwait(false),以及 .Result / .Wait() 卡死的原因。

另外,WinForms 的 Control.InvokeAsync 是 .NET 9 以后 才有的。 在此之前的 WinForms 中,基本上使用 BeginInvoke / Invoke。

此外,本文中出现的代码,已经作为一套可以构建运行的示例(不依赖 UI 的库、WPF / WinForms 示例,以及复现 await 返回位置与死锁的单元测试)发布在 GitHub 上。

wpf-winforms-ui-thread-async-await-one-sheet - komurasoft-blog-samples (GitHub)

目录

  1. 先说结论(一句话)
  2. 先用一张图整理
    • 2.1. 整体图
    • 2.2. 先看判断表
  3. 本文使用的术语
    • 3.1. UI 线程与消息循环
    • 3.2. SynchronizationContext / Dispatcher / Invoke
  4. 典型模式
    • 4.1. 在 UI 事件处理程序中使用 plain await
    • 4.2. 只把繁重的 CPU 计算交给 Task.Run
    • 4.3. ConfigureAwait(false) 不是“保证不返回”,而是“不强制返回”
    • 4.4. .Result / .Wait() / .GetAwaiter().GetResult() 卡死的原因
  5. 什么时候使用 Dispatcher / Invoke
  6. 常见的反模式
  7. 代码评审时的检查清单
  8. 大致的使用区分
  9. 总结
  10. 参考资料

图中实线表示始终成立的关系,虚线表示带条件的关系(成立条件写在详情页各关系的说明中)。关系的完整列表(共 26 条,附依据与确信度)以及主要概念的定义,汇总在知识地图详情页(日文)。数据:JSON-LD / Turtle

1. 先说结论(一句话)

  • 在 WPF / WinForms 的 UI 事件处理程序 中使用 plain await 时,可以认为 await 之后的续行 基本上会返回到 UI 线程
  • Task.Run 是 用来把 CPU 计算从 UI 线程移出去的工具,不是用来包裹 I/O 等待的
  • 即使在 UI 处理程序中执行 await Task.Run(...),只要这个 await 是 plain await,续行通常也会 返回到 UI 线程
  • ConfigureAwait(false) 的意思是,这个 await 不强制返回到它捕获的 UI 上下文。加上它之后,在续行中直接操作 UI 是危险的
  • .Result / .Wait() / .GetAwaiter().GetResult() 会 阻塞 UI 线程。如果 await 的续行需要返回到 UI,卡死是相当常见的结果
  • 在 WPF 中显式返回 UI,用 Dispatcher.InvokeAsync
  • 在 WinForms 中显式返回 UI,传统上用 BeginInvoke,.NET 9 以后则是 InvokeAsync 与 async 流程更合拍
  • 最基本的方针是:UI 最外层保持 plain await,通用库可以考虑 ConfigureAwait(false),只在必要的地方显式返回 UI

归纳起来,在 WPF / WinForms 中,只要盯住

  1. 当前代码运行在哪个线程上
  2. await 之后的续行会返回到哪里
  3. 由谁来承担返回 UI 的责任

这 3 点,思路就会清晰很多。

让思路清晰的三个问题当前运行在哪个线程上、await之后的续行会返回到哪里、由谁承担返回UI的责任,把这三点弄清楚,WPF与WinForms的异步代码就会变得清晰。当前运行在哪个线程上异步UI代码的清晰度await之后的续行会返回到哪里由谁承担返回UI的责任

图1:拿不准时就回到这三个问题。把线程、返回位置、返回的责任分开来想。

2. 先用一张图整理

2.1. 整体图

先用下面这张图把握整体结构,是最快的方式。

UI事件处理程序(WPF / WinForms)plain awaitI/O API捕获 UI SynchronizationContextawait后在UI线程上恢复可以直接更新UIawait Task.Run(...)繁重的CPU处理计算主体在 ThreadPoolawait后在UI线程上恢复await SomeAsync().ConfigureAwait(false)不强制返回到UI续行在任意线程上执行直接更新UI很危险需要 Dispatcher / InvokeSomeAsync().Result / Wait()GetAwaiter().GetResult()阻塞UI线程续行无法返回到UI挂起 / 死锁 / 至少会冻结

图2:从 UI 处理程序看到的四种模式全景。plain await 与 Task.Run 会回到 UI,ConfigureAwait(false) 不强制返回,.Result / .Wait() 则阻塞 UI 线程。

实务中见得最多的,大致就是下面这 4 种模式。

  1. 在 UI 事件处理程序中使用 plain await
  2. 在 UI 事件处理程序中用 Task.Run 把 CPU 计算移出去
  3. 用 ConfigureAwait(false) 让续行不固定返回到 UI
  4. 用 .Result / .Wait() 阻塞 UI 线程

2.2. 先看判断表

情况 等待期间谁在运行 await 之后的续行 可以直接操作 UI 吗 首选做法
在 UI 处理程序中 await SomeIoAsync() 等待 I/O 完成。UI 线程本身能回到消息循环 基本是 UI 线程 可以 plain await
在 UI 处理程序中 await Task.Run(...) 繁重的 CPU 计算在 ThreadPool 上 基本是 UI 线程 可以 只把 CPU 部分交给 Task.Run
在 UI 处理程序中 await x.ConfigureAwait(false) 不把返回位置固定在 UI 任意线程 不建议 UI 代码中基本要避免
在 UI 线程上 x.Result / x.Wait() UI 线程被等待阻塞 续行本身就很难被执行 不建议 不要使用
在后台线程或 ConfigureAwait(false) 之后想更新 UI 运行在与 UI 不同的线程上 原样并不在 UI 上 不建议 Dispatcher.InvokeAsync / BeginInvoke / InvokeAsync
编写不依赖 UI 的通用库 不依赖调用方的具体情况 不强制返回到 UI 设计成不碰 UI 考虑 ConfigureAwait(false)
想从构造函数或同步属性中调用 async UI 线程容易进入等待 启动路径容易卡住 不建议 转移到 Loaded / Shown / InitializeAsync

这张表里最重要的一点是,plain await 在 UI 代码中反而是朋友。 敌人不是 await 本身,而是 以同步方式阻塞 UI 线程。

选择本身已经汇总在这张表里。后面各章的分工是:这张表为什么会是这样(第 3 章和第 4 章)、返回 UI 的工具怎么选(第 5 章)、评审时怎么发现问题(第 6 章和第 7 章)。

敌人不是await而是同步阻塞plain await在UI代码中反而是朋友,真正的敌人是以同步方式阻塞UI线程。plain await在UI代码中反而是朋友以同步方式阻塞UI线程冻结与死锁的根源

图3:判断表核心处的整理。敌人不是 await 本身,而是以同步方式阻塞 UI 线程。

3. 本文使用的术语

3.1. UI 线程与消息循环

WPF / WinForms 的 UI,基本上是 只有一条 UI 线程,由它来处理输入、绘制和事件 的形式。

这条 UI 线程的职责大致如下。

  • 处理按钮点击、按键输入、重绘等消息
  • 是唯一能够安全操作控件和 UI 对象的线程
  • 一旦在其中塞进过多处理,界面更新和输入响应就会停止

这里的关键是,UI 线程的工作就是“快速循环”。 一旦在这里长时间阻塞,鼠标、键盘、重绘都会卡住,用户看到的就是“界面冻住了”。

把这个印象画成图记在脑子里,就不容易混乱。

用户输入 / 重绘请求UI线程的消息循环执行事件处理程序更新界面较长的同步处理消息循环无法继续运行界面看起来卡死

图4:UI 线程的工作是让消息循环快速转起来,一旦插入较长的同步处理,循环就会停下,界面看起来就卡死了。

3.2. SynchronizationContext / Dispatcher / Invoke

把这里经常出现的术语按实务角度分开来看,是这样的。

术语 在本文中的含义
UI 线程 创建 UI 对象的线程。基本上只有它能安全操作 UI
消息循环 UI 线程依次处理消息的机制
SynchronizationContext 用于“把处理送回该执行位置”的抽象
Dispatcher WPF 中 UI 线程专用的队列
Invoke / BeginInvoke / InvokeAsync 用于把处理投递到 UI 线程的 API

把续行位置的决定方式写得更准确一些,是这样的。await(相当于默认的 ConfigureAwait(true))首先捕获的是 SynchronizationContext.Current。只有当它为 null 时,才去看 TaskScheduler.Current,如果它 不是 TaskScheduler.Default,就把续行送回那个 TaskScheduler。如果两者都不成立,也就是 SynchronizationContext.Current 为 null 且 TaskScheduler.Current 是默认值,续行就在 ThreadPool 上运行。 在 WPF / WinForms 的 UI 线程上属于前一种情况,即 UI 的 SynchronizationContext 是存在的,所以在实务中可以直接认为 UI 的 SynchronizationContext 在起作用。

await续行位置的决定方式默认的await首先捕获SynchronizationContext.Current,若为null则查看TaskScheduler.Current,若不是默认值就送回该TaskScheduler,两者都不成立时在ThreadPool上运行续行。有null不是默认值是默认值默认的await有SynchronizationContext吗?送回该上下文TaskScheduler是默认值吗?送回该TaskScheduler在ThreadPool上执行续行UI线程上是UI的上下文

图5:续行位置的决定方式。UI 线程上有 UI 的 SynchronizationContext,所以续行会回到 UI。

按框架整理成表,会更容易看明白。

框架 UI 侧的上下文 显式返回 UI 的代表 API
WPF DispatcherSynchronizationContext Dispatcher.InvokeAsync / Dispatcher.BeginInvoke / Dispatcher.Invoke
WinForms WindowsFormsSynchronizationContext Control.BeginInvoke / Control.Invoke / .NET 9+ Control.InvokeAsync

WPF 以 Dispatcher 为中心。 WinForms 则以控件的句柄与消息循环为中心,浮到表面上的是 BeginInvoke / Invoke。

在实务中,把抽象与具体实现的关系记到这个程度,就不容易混淆。

当前代码SynchronizationContextWPF: DispatcherSynchronizationContextWinForms: WindowsFormsSynchronizationContextDispatcher.InvokeAsync / BeginInvoke / InvokeControl.BeginInvoke / Invoke / InvokeAsync(.NET 9+)

图6:SynchronizationContext 这个抽象,其实体在 WPF 中连到 Dispatcher 系列,在 WinForms 中连到 Control 的 Invoke 系列 API。

4. 典型模式

4.1. 在 UI 事件处理程序中使用 plain await

这是最直接的形式。

private async void LoadButton_Click(object sender, RoutedEventArgs e)
{
    LoadButton.IsEnabled = false;
    StatusText.Text = "正在加载...";

    try
    {
        string text = await File.ReadAllTextAsync(FilePathTextBox.Text);
        PreviewTextBox.Text = text;
        StatusText.Text = "完成";
    }
    catch (Exception ex)
    {
        StatusText.Text = ex.Message;
    }
    finally
    {
        LoadButton.IsEnabled = true;
    }
}

在这段代码中,LoadButton_Click 从 UI 线程上开始执行。 而 await File.ReadAllTextAsync(...) 是 plain await,所以通常会捕获当时的 UI 上下文。

因此就变成了这样的形式。

  • 文件 I/O 等待期间不会占用 UI 线程
  • 读取完成后的续行基本上会返回到 UI 线程
  • 可以直接写 PreviewTextBox.Text = text;

这里不需要多余的 Dispatcher。 只要是在 UI 处理程序中用 plain await,通常就可以直接操作 UI。

这个处理程序之所以写成 async void,是因为 UI 事件处理程序的签名要求返回 void,这里的 async void 是作为例外被允许的。正因如此,把 try / catch 放在里面就有了明确的理由。如果是 async Task,异常会挂在返回值 Task 上,调用方 await 时就能接到。而 async void 没有那个 Task,所以逃到外面的异常会 被重新抛回该处理程序开始时的 SynchronizationContext,也就是 UI 线程。UI 线程上的未处理异常,在 WPF 中会出现在 Application.DispatcherUnhandledException,在 WinForms 中会出现在 Application.ThreadException,如果在那里不做处理,应用就会崩溃。

也就是说,在 async void 的处理程序中 基本原则是在处理程序内部把异常接住,像上面的例子那样“把失败转成状态显示,并在 finally 中恢复按钮”,就能作为 UI 自然地收尾。应用整体的兜底(DispatcherUnhandledException 等),终究只是作为最后一道防线放在那里的。

async void异常的去向async Task的异常会挂在返回值Task上由调用方await接收,而async void中逃到外面的异常会被重新抛回开始时的SynchronizationContext也就是UI线程,如果在整体兜底处不处理应用就会崩溃。async Taskasync void逃出处理程序的异常是async Task还是async void挂在返回值Task上由await的调用方接收被重新抛回UI线程出现在整体兜底处不处理应用就会崩溃

图7:async void 没有可以承载异常的 Task,所以基本原则是在处理程序内部把异常接住。

WinForms 中的看法也一样。 只要在 Click 处理程序中一直用 plain await,续行基本都会返回到 UI 一侧。

画成图,就是这样的流程。

UI SynchronizationContext异步I/OUI线程UI SynchronizationContext异步I/OUI线程等待期间返回消息循环开始执行 Click 处理程序await ReadAllTextAsync预约把续行送回UII/O 完成在UI线程上恢复续行更新 TextBox / Label

图8:plain await 等待期间 UI 线程会回到消息循环,I/O 完成后的续行则在 UI 线程上恢复。

4.2. 只把繁重的 CPU 计算交给 Task.Run

Task.Run 起作用的场景,是 想把繁重的 CPU 计算从 UI 线程移出去的时候。

private async void HashButton_Click(object sender, RoutedEventArgs e)
{
    HashButton.IsEnabled = false;
    ResultText.Text = "正在计算...";

    try
    {
        byte[] data = await File.ReadAllBytesAsync(InputPathTextBox.Text);

        string hash = await Task.Run(() =>
        {
            using SHA256 sha256 = SHA256.Create();
            byte[] digest = sha256.ComputeHash(data);
            return Convert.ToHexString(digest);
        });

        ResultText.Text = hash;
    }
    catch (Exception ex)
    {
        ResultText.Text = ex.Message;
    }
    finally
    {
        HashButton.IsEnabled = true;
    }
}

这段代码中发生的事情,大致是这样的。

  1. UI 线程上开始执行事件处理程序
  2. File.ReadAllBytesAsync 的 I/O 等待以异步方式流转
  3. 只把繁重的哈希计算通过 Task.Run 交给 ThreadPool
  4. await Task.Run(...) 之后是 plain await,因此续行返回 UI 线程
  5. 可以直接写 ResultText.Text = hash;

也就是说,只有 Task.Run 内部才在另一个线程上。 并不是说过了 await 之后就永久地跑到“已经不是 UI 的地方”去了。

把这一点用一张图看清楚,就不容易误解。

ThreadPool异步I/OUI线程ThreadPool异步I/OUI线程await Task.Run(...) 之后在UI恢复await ReadAllBytesAsync因为是 plain await 所以在UI恢复用 Task.Run 把繁重的CPU处理投递出去返回计算结果把结果更新到界面

图9:只有 Task.Run 内部在 ThreadPool 上运行,await 之后的续行会回到 UI 线程,所以界面更新可以直接写。

这里需要注意的有 2 点。

  • 不要用 Task.Run 包裹 I/O 等待
  • 把 Task.Run 理解成“制造一个 CPU 的转移目的地”,而不是“使其异步化”

像 Task.Run(async () => await File.ReadAllTextAsync(...)) 这样的写法,只是把 I/O 等待多余地重新丢回 ThreadPool,没有多少好处。

Task.Run的适用场景Task.Run的作用是把繁重的CPU计算转移到ThreadPool,而包裹I/O等待只是把等待多余地重新丢回ThreadPool,没有好处。繁重的CPU计算I/O等待想转移出去的是什么用Task.Run转移出去不要用Task.Run包裹只是把等待重新丢一次,没有好处

图10:Task.Run 不是“使其异步化”的工具,而是制造 CPU 转移目的地的工具。

4.3. ConfigureAwait(false) 不是“保证不返回”,而是“不强制返回”

这里是最容易被误解的地方。

首先,ConfigureAwait(false) 适合用在 不依赖 UI 或特定应用模型的通用库代码 中。

public sealed class DocumentRepository
{
    public async Task<string> LoadNormalizedTextAsync(string path, CancellationToken cancellationToken)
    {
        string text = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
        return text.Replace("\r\n", "\n", StringComparison.Ordinal);
    }
}

这个方法不会操作 UI。 无论是 WPF、WinForms、ASP.NET Core 还是 worker,都能使用这种形式。 这样的代码加上 ConfigureAwait(false) 是很自然的。

而 UI 一侧的调用,用 plain await 就可以了。

private readonly DocumentRepository _repository = new();

private async void OpenButton_Click(object sender, RoutedEventArgs e)
{
    OpenButton.IsEnabled = false;
    StatusText.Text = "正在加载...";

    try
    {
        string text = await _repository.LoadNormalizedTextAsync(
            PathTextBox.Text,
            CancellationToken.None);

        PreviewTextBox.Text = text;
        StatusText.Text = "完成";
    }
    catch (Exception ex)
    {
        StatusText.Text = ex.Message;
    }
    finally
    {
        OpenButton.IsEnabled = true;
    }
}

这里重要的是,库内部的 ConfigureAwait(false),不会把调用方的 await 也强制变成 false。

也就是说,可以做到这样的分离。

  • 库内部不返回到 UI
  • UI 处理程序对它使用 plain await 时,调用方的续行会返回到 UI
库与UI的分离通用库内部用ConfigureAwait〔false〕不要求恢复到UI上下文,而UI处理程序对它使用plain await时调用方的续行会返回到UI,二者由此分离。库内部的awaitConfigureAwait〔false〕不要求恢复到UIUI处理程序的plain await调用方的续行返回到UI内侧的指定不会波及外侧

图11:库内部的 ConfigureAwait(false),不会把调用方的 await 也强制变成 false。

反过来,如果在 UI 处理程序自身里这样写,就很危险。

private async void OpenButton_Click(object sender, RoutedEventArgs e)
{
    string text = await _repository.LoadNormalizedTextAsync(
        PathTextBox.Text,
        CancellationToken.None).ConfigureAwait(false);

    PreviewTextBox.Text = text;
}

在这种情况下,OpenButton_Click 中 这个 await 的续行 不会被强制返回到 UI。 因此 PreviewTextBox.Text = text; 可能会变成 跨线程访问。

还有一点,虽然不起眼但很重要。 即使加上 ConfigureAwait(false),也不一定必然转移到 ThreadPool,如果那个 await 没有等待就直接完成,续行有时会就这样在当前线程上继续跑下去。 如果读成“一定会跑到别的线程”“从这里开始就永远不是 UI 了”,就是问题的根源,它的含义始终只是 不强制把这个 await 的续行送回原来的 UI 上下文,仅此而已。

画成图看就是这样。

不加加在UI处理程序中 await要加 ConfigureAwait(false) 吗?续行基本在UI线程可以直接更新UI续行不固定在UI可能在任意线程上恢复更新UI需要 Dispatcher / Invoke

图12:加不加 ConfigureAwait(false),改变的只是“是否强制把续行送回 UI”。

4.4. .Result / .Wait() / .GetAwaiter().GetResult() 卡死的原因

这里是最常见的问题。

private void LoadButton_Click(object sender, RoutedEventArgs e)
{
    string text = LoadTextAsync().Result;
    PreviewTextBox.Text = text;
}

private async Task<string> LoadTextAsync()
{
    string text = await File.ReadAllTextAsync(FilePathTextBox.Text);
    return text.ToUpperInvariant();
}

乍一看只是同步地取一下结果,但在 UI 线程上这样做是很危险的。

把流程画成图,是这样的。

UI SynchronizationContext异步I/OUI线程UI SynchronizationContext异步I/OUI线程但UI正被 .Result 占用续行无法执行,因此无法完成开始执行 LoadButton_Click调用 LoadTextAsync()返回尚未完成的 Task用 .Result 等待并被阻塞I/O 完成,想把续行送回UI想要执行续行

图13:续行无法回到被 .Result 阻塞的 UI 线程,于是 Task 一直无法完成的流程。

把发生的事情用语言描述出来,是这样的。

  1. UI 线程调用 LoadTextAsync()
  2. LoadTextAsync() 内部的 await 捕获了 UI 上下文
  3. UI 线程用 .Result 一直等了下去
  4. I/O 完成
  5. LoadTextAsync() 的续行想要返回到 UI 线程
  6. 但 UI 线程正被 .Result 占用
  7. 续行无法运行,所以 LoadTextAsync() 无法完成
  8. .Result 也就永远不会结束

也就是说,UI 说“我要等你结束”,异步那一侧说“我能回到 UI 才能结束”,两边互相等待。 实在是很难受的局面。

互相等待的结构UI线程用.Result等待异步处理完成,而异步侧的续行需要等UI线程空出来,双方互相等待对方而无法推进。UI线程用.Result等待需要异步侧完成续行需要返回到UI需要UI线程空出来互相等待,无法推进

图14:“等你结束”和“回到 UI 才能结束”撞在一起,双方互相等待。

这里常见的一个误解是,以为换成 GetAwaiter().GetResult() 就安全了。 但 阻塞 UI 线程 这个本质是一样的。不同之处主要在于异常的包装方式。

因此,在 UI 中把下面这 3 个视为同一类危险信号来对待,更为安全。

  • .Result
  • .Wait()
  • .GetAwaiter().GetResult()

另外,对 WPF 的 Dispatcher.InvokeAsync(...) 返回的 DispatcherOperation 的 Task,从 UI 线程调用 Task.Wait() 也同样危险,原因是一样的。InvokeAsync 只是把传入的委托放进 Dispatcher 的队列,实际运行是在 UI 线程转动那个队列的时候。如果 UI 线程被 Wait() 停住,队列就不会转,那个 Task 也就永远不会完成。 同样的事情在 DispatcherOperation 一侧也存在,DispatcherOperation.Wait() 的文档明确写着,等待在同一线程上正在执行的操作会抛出 InvalidOperationException。也就是说,阻塞等待这条路径本身就不在设计考虑之内。 在 UI 的语境下,“把投递出去的东西用同步方式等回来”这个方向本身 就容易卡住。关于卡住方式的详细讲解,Await, and UI, and deadlocks! Oh my! 写得比较好读。

同步等待InvokeAsync的危险Dispatcher.InvokeAsync只是把委托放进队列,实际执行要等UI线程转动该队列,所以UI线程被Wait停住时队列不会转,Task也就永远不会完成。用InvokeAsync放进队列UI线程转动队列时才执行UI线程被Wait停住队列不会转Task永远不会完成

图15:UI 线程自己同步等待自己投递出去的东西,作为执行场所的队列就转不起来,于是永远结束不了。

要说“是不是一定会死锁”,答案未必是肯定的。 如果续行恰好不需要返回 UI,也可能 不发生死锁,只是单纯让 UI 冻住。 但那同样很难受,所以在 UI 中基本上不要这样做。

5. 什么时候使用 Dispatcher / Invoke

综合前面的内容来看,在 使用 plain await 的 UI 处理程序 中,平时并不需要显式的 Dispatcher / Invoke。

需要用到它们的,是下面这类场合。

  • 想在 ConfigureAwait(false) 的续行中操作 UI
  • 在 Task.Run 内部,或者其外层也被设计成不返回 UI 的结构
  • Socket 接收、定时器、事件回调等,通知一开始就不在 UI 线程上到来
  • 在有意分离 UI 与非 UI 的分层里,只想把最后一次 UI 更新显式写出来

在 WPF 中,代表性的做法是 Dispatcher.InvokeAsync。

private async Task RefreshPreviewAsync(string path, CancellationToken cancellationToken)
{
    string text = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);

    await Dispatcher.InvokeAsync(() =>
    {
        PreviewTextBox.Text = text;
        StatusText.Text = "完成";
    });
}

WinForms 在 .NET 9 以上时,InvokeAsync 与 async 流程配合得很自然。

private async Task RefreshPreviewAsync(string path, CancellationToken cancellationToken)
{
    string text = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);

    await previewTextBox.InvokeAsync(() =>
    {
        previewTextBox.Text = text;
        statusLabel.Text = "完成";
    });
}

WinForms 的传统模式则使用 BeginInvoke。 Invoke 是同步发送,会让调用方等待;BeginInvoke 投递之后立即返回。 在 async 流程中,基本上 不阻塞的那一侧 配合得更好。

Invoke与BeginInvoke的区别Invoke是同步发送会让调用方等待,BeginInvoke投递后立即返回,所以在async流程中不阻塞的一侧配合得更好。Invoke〔同步发送〕让调用方等待BeginInvoke〔投递〕立即返回与async流程配合

图16:同样是“投递到 UI”,会让人等待的 Invoke 与立即返回的 BeginInvoke 性质并不相同。

不过,Control.BeginInvoke 返回的是 IAsyncResult,就这样是没法 await 的。在没有 Control.InvokeAsync 的环境(.NET Framework 4.8、.NET 6 / 8 等)里想把它放进 async 流程,用 TaskCompletionSource 包成 Task 是最直接的做法。

using System;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;

public static class ControlUiExtensions
{
    // 为了在 .NET Framework 4.8 上也能直接使用,这里用的是泛型版的
    // TaskCompletionSource。.NET 5 以后也可以用非泛型版来写。
    //
    // 这里没有把 cancellationToken 设成可选参数。BeginInvoke 受理之后如果
    // 控件被释放,投递出去的委托就会在没有执行的情况下被丢弃,
    // TaskCompletionSource 里既不会有结果也不会有异常。如果不准备一个中止的出口,
    // await 的那一侧就会一直永久等待下去
    public static Task InvokeOnUiAsync(
        this Control control, Action action, CancellationToken cancellationToken)
    {
        if (control is null)
        {
            throw new ArgumentNullException(nameof(control));
        }

        if (action is null)
        {
            throw new ArgumentNullException(nameof(action));
        }

        if (!control.IsHandleCreated)
        {
            throw new InvalidOperationException("窗口句柄尚未创建。");
        }

        if (!control.InvokeRequired)
        {
            action();
            return Task.CompletedTask;
        }

        // 为了不让 await 一侧的续行就这样在 UI 线程上跑起来,
        // 这里明确指定续行以异步方式流转。
        var tcs = new TaskCompletionSource<bool>(
            TaskCreationOptions.RunContinuationsAsynchronously);

        // 让取消和执行去争夺同一个“一次性的执行权”。
        // 只有先用 Interlocked.Exchange 写入 1 的那一方才能继续往下走。
        // 如果写成先看标志再调用 action(),就会残留这样一条路径:
        // 刚看完标志就被取消,于是“调用方已经收到取消并开始了下一个操作,
        // 而旧的委托却在之后才去改写界面”
        int claimed = 0;   // 0 = 未定 / 1 = 已被某一方取得

        // 一旦被取消,即使委托没有执行,Task 也会被收尾。
        // 注册在 Task 完成时一定要注销(不注销的话,只要令牌还活着
        // 就会一直抓着 tcs)。CancellationTokenRegistration.Dispose
        // 是线程安全的,从哪个线程调用都可以
        CancellationTokenRegistration registration = cancellationToken.Register(() =>
        {
            if (Interlocked.Exchange(ref claimed, 1) == 0)
            {
                tcs.TrySetCanceled(cancellationToken);
            }
        });

        tcs.Task.ContinueWith(
            _ => registration.Dispose(),
            CancellationToken.None,
            TaskContinuationOptions.ExecuteSynchronously,
            TaskScheduler.Default);

        try
        {
            control.BeginInvoke(new Action(() =>
            {
                // 从投递出去到 UI 线程真正开始运行之间,是有可能被取消的。
                // 如果在这里拿不到权利,就说明取消一侧先拿到了,
                // 那就一点也不碰界面,直接返回
                if (Interlocked.Exchange(ref claimed, 1) != 0)
                {
                    return;
                }

                try
                {
                    action();
                    tcs.TrySetResult(true);
                }
                catch (Exception ex)
                {
                    tcs.TrySetException(ex);
                }
            }));
        }
        catch (Exception ex)
        {
            // BeginInvoke 本身也可能抛异常(例如句柄已经不存在)。
            // 如果不在这里收尾,同样会一直等下去。
            // 委托不会运行,所以这里也是先取得权利再收尾
            if (Interlocked.Exchange(ref claimed, 1) == 0)
            {
                tcs.TrySetException(ex);
            }
        }

        return tcs.Task;
    }
}

调用方的写法,与 InvokeAsync 的例子几乎相同。请把令牌与窗体的生命周期绑在一起。

// 窗体的字段。关闭时执行取消
private readonly CancellationTokenSource _formClosing = new();

protected override void OnFormClosed(FormClosedEventArgs e)
{
    // 即使已投递的委托在没有执行的情况下被丢弃,
    // 也要能在这里把 await 一侧收尾
    _formClosing.Cancel();
    base.OnFormClosed(e);
}

private async Task RefreshPreviewAsync(string path, CancellationToken cancellationToken)
{
    using var linked = CancellationTokenSource.CreateLinkedTokenSource(
        cancellationToken, _formClosing.Token);

    string text = await File.ReadAllTextAsync(path, linked.Token).ConfigureAwait(false);

    await previewTextBox.InvokeOnUiAsync(() =>
    {
        previewTextBox.Text = text;
        statusLabel.Text = "完成";
    }, linked.Token);
}

这种形式下,UI 一侧发生的异常也能在 await 所在位置的 try / catch 中接住。要记住的有 4 点。

  • 取消和执行,靠“先看标志再动作”是不够的。 在确认完“是否已取消”之后、调用 action() 之前,有可能发生取消。就在这一瞬间 tcs 变成已取消,await 着的调用方继续往前走并 开始下一个操作。在那之后,还留在队列里的旧委托才动起来去改写界面 ── 这就是 新的显示被旧的显示覆盖 这种很难复现、很难查的故障。上面的代码之所以用 Interlocked.Exchange 让双方争夺“一次性的执行权”,正是为此,而且 没抢到的一方什么也不做就返回
  • 句柄创建之前(Load 之前),或者窗体关闭之后调用 BeginInvoke 都会抛异常。请注意调用方的生命周期
  • 投递之后如果控件被释放,委托有可能不被执行就被丢弃。那种情况下 TaskCompletionSource 里既不会有结果也不会有异常,所以 await 着的一侧会永久等待。请像上面的例子那样,务必传入与窗体结束绑定的令牌。收尾的结果会以 OperationCanceledException 的形式抛出
  • File.ReadAllTextAsync 是 .NET Core 2.0 以后的 API。如果要在 .NET Framework 4.8 上写成同样的形式,请替换成 StreamReader.ReadToEndAsync 等
取消与执行争夺权利取消一侧与执行一侧用Interlocked.Exchange争夺一次性的执行权,先抢到的一方才继续,没抢到的一方什么也不做就返回,从而避免旧委托改写界面的竞态。一次性的执行权取消一侧先抢到执行一侧先抢到把Task收尾为已取消旧的委托什么也不做就返回执行action并写入结果

图17:不是先看标志再动作,而是让双方争夺一次性的执行权,没抢到的一方直接返回。

至于怎么区分,做到这个程度就足够了。

想做的事 WPF WinForms
同步地进入 UI Dispatcher.Invoke Control.Invoke
异步地投递到 UI Dispatcher.InvokeAsync / Dispatcher.BeginInvoke Control.BeginInvoke / .NET 9+ Control.InvokeAsync
想和 async / await 自然配合 Dispatcher.InvokeAsync .NET 9+ Control.InvokeAsync,之前的版本用 BeginInvoke

从实务上的感觉来说,

  • 只是在 UI 处理程序中用 plain await 的话,不需要
  • 想从非 UI 的地方操作 UI 时才用
  • 不要在 async 流程中过多地增加同步 Invoke

这样就能明显减少出问题的情况。

拿不准的时候,用这种程度的判断图就够了。

是否否是是这段续行写在UI线程上吗?是吗?保持plain await,可以直接更新UI想操作UI吗?直接继续处理WPF: Dispatcher.InvokeAsyncWinForms: BeginInvoke / InvokeAsync

图18:根据续行所在的位置是不是 UI 线程,判断是保持 plain await 就好,还是需要 Dispatcher / Invoke 系列。

6. 常见的反模式

反模式 问题在哪里 首选替代方案
在 UI 处理程序中写 LoadAsync().Result 阻塞 UI 线程。容易死锁 await LoadAsync()
在 UI 处理程序中写 LoadAsync().Wait() 同上。消息循环会停止 await LoadAsync()
在 UI 处理程序中写 LoadAsync().GetAwaiter().GetResult() 只是异常的表现方式不同,阻塞是一样的 await LoadAsync()
在 UI 代码上机械地加 ConfigureAwait(false) await 之后的 UI 更新容易出问题 UI 最外层保持 plain await
Task.Run(async () => await IoAsync()) 把 I/O 白白重新投递了一次 await IoAsync()
库代码直接持有 Dispatcher 或 Control UI 依赖变深。难以复用 库只返回数据,由 UI 一侧负责 marshal
在 async 流程中大量使用 Dispatcher.Invoke / Control.Invoke 容易形成阻塞的环 考虑 Dispatcher.InvokeAsync / BeginInvoke / InvokeAsync
在构造函数或属性 getter 中把 async 同步化 会成为启动时挂起的温床 转移到 Loaded / Shown / InitializeAsync

其中遇到频率特别高的有 3 个。

  1. 在 UI 线程上使用 .Result / .Wait()
  2. 机械地给 UI 代码加上 ConfigureAwait(false)
  3. 库与 UI 的职责混在一起,导致 Dispatcher 一直侵入到深处

光是去掉这 3 个,代码就会安稳不少。

遭遇频率特别高的三个在UI线程上使用.Result或.Wait、给UI代码机械地加ConfigureAwait(false)、职责混在一起导致Dispatcher侵入到库的深处,光是去掉这三个代码就会安稳下来。在UI线程上用.Result或.Wait去掉这三个机械地加ConfigureAwaitDispatcher侵入到深处代码会安稳不少

图19:反模式当中遭遇频率高的就是这三个,去掉它们效果最大。

7. 代码评审时的检查清单

内容与 2.2 的判断表和第 6 章的反模式相同,但这里写成了 打开代码时按顺序确认的问题 的形式。

  • UI 事件处理程序或 UI 初始化路径中,是否还残留 .Result / .Wait() / .GetAwaiter().GetResult()
  • Task.Run 是否只用在 CPU 计算 上。有没有拿它包裹 I/O
  • ConfigureAwait(false) 是否被机械地加进了 UI 代码
  • 反过来,通用库中是否还拖着对 UI 上下文的依赖
  • await 之后直接操作 UI 的地方,能不能确实说那里就在 UI 上下文之上
  • 需要显式返回 UI 的地方,是否使用了 Dispatcher.InvokeAsync / BeginInvoke / InvokeAsync
  • 像 Dispatcher.Invoke / Control.Invoke 这样的同步 marshal,有没有不必要地增多
  • 有没有在构造函数、同步属性、同步事件中强行把 async 同步化
  • 库层有没有直接引用 Window / Control / Dispatcher

这份检查清单,也很适合团队用来统一“哪里属于 UI 的职责”这一认识。

8. 大致的使用区分

各种情况下的选择已经汇总在 2.2 的判断表里,所以这里只放一些便于带走的记忆要点。

  • UI 最外层保持 plain await。await 之后能直接操作 UI,正是因为守住了这一点
  • Task.Run 是 CPU 的转移目的地。它不是用来包裹 I/O 等待的工具
  • ConfigureAwait(false) 是通用库的工具。不要机械地加到 UI 代码上
  • Dispatcher / BeginInvoke / InvokeAsync,只在从非 UI 的地方操作 UI 时使用
  • 不要使用在 UI 线程上等待的那 3 个(.Result / .Wait() / .GetAwaiter().GetResult())。想同步化的时候,就连调用方一起改成 async

理由的部分在第 4 章,Dispatcher / Invoke 的选法在第 5 章,在实际代码中找出问题的观察角度在第 6 章和第 7 章。

9. 总结

在 WPF / WinForms 的 async / await 中真正重要的, 不是“异步很难”这种氛围,而是把

  • 现在是从哪里开始的
  • await 之后的续行会返回到哪里
  • 由谁承担返回 UI 的责任

分开来思考。

作为最基本的规则,守住下面这些就足以应战。

  1. UI 最外层用 plain await
  2. 只把繁重的 CPU 交给 Task.Run
  3. 通用库中考虑使用 ConfigureAwait(false)
  4. 只有需要返回 UI 时才用 Dispatcher / BeginInvoke / InvokeAsync
  5. UI 线程上不使用 .Result / .Wait() / .GetAwaiter().GetResult()

async / await 本身并不是那么难伺候的机制。 只是 如果不以 UI 线程为中心去看待就直接使用,它会突然变成一片泥泞。

反过来说,

  • 把 UI 的外侧与内侧分开
  • 留意续行的返回位置
  • 不要把阻塞带进来

只要守住这 3 点,WPF / WinForms 的异步代码就会安静很多。 界面卡死的代码,大多不是“异步不好”,而只是 欠 UI 线程的债欠得太粗糙。

安静的异步UI代码三原则把UI的外侧与内侧分开、留意await的返回位置、不要把阻塞带进来,只要守住这三点,WPF与WinForms的异步代码就会安静下来。把UI的外侧与内侧分开异步代码会安静下来留意返回位置不要把阻塞带进来

图20:总结的三原则。分开、留意返回位置、不阻塞,界面就不会卡死。

10. 参考资料

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。

本文与以下服务页面相关联,欢迎从最接近的入口查看。

常见问题

汇总了咨询这一主题时常见的问题。

await 之后会返回到哪个线程?
在 WPF / WinForms 的 UI 事件处理程序中使用 plain await(不带 ConfigureAwait)时,await 之后的续行基本上会返回到 UI 线程。这是因为 await 会捕获当时的 UI SynchronizationContext,并把续行放回该上下文,所以 await 之后可以直接写 TextBox 或 Label 的更新代码。即使是 await Task.Run(...),计算本身在 ThreadPool 上执行,但只要是 plain await,续行同样会在 UI 线程上恢复。
为什么在 UI 线程上使用 .Result 或 .Wait() 会导致界面卡死?
因为 UI 线程在用 .Result 等待期间,异步操作的续行想要返回到它捕获的 UI 上下文,但 UI 线程正被 .Result 占用而无法执行该续行,于是双方互相等待,形成死锁。GetAwaiter().GetResult() 也是同样的本质,只是异常的包装方式不同,同样会阻塞 UI 线程。在 UI 代码中,.Result、.Wait()、GetAwaiter().GetResult() 这三者都应该避免,改用 await。
ConfigureAwait(false) 应该加在 UI 代码上吗?
不建议这样做。ConfigureAwait(false) 的含义是不强制返回到捕获的 UI 上下文,因此续行可能在任意线程上恢复,紧接着的 UI 更新就可能变成跨线程访问。它更适合用在不依赖 UI 的通用库代码中,UI 最外层的代码应该保持 plain await 的方针。
Task.Run 应该在什么时候使用?
只应该在想把繁重的 CPU 计算从 UI 线程移出去的时候使用。如果用 Task.Run 包裹 I/O 等待,只是把等待多余地重新丢回 ThreadPool,没有任何好处。Task.Run 内部才是在另一个线程上执行,只要 await Task.Run(...) 之后是 plain await,续行通常仍会返回到 UI 线程,因此可以直接把结果写回界面。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表