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

· · C#, async/await, .NET, WPF, WinForms, UI, 线程

在 WPF / WinForms 中使用 async / await 时,最容易困惑的是 await 之后会返回到哪个线程,以及 什么时候才可以触碰 UI。 特别是当 DispatcherBeginInvokeConfigureAwait(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 后的返回位置、DispatcherConfigureAwait(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. 参考资料

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 点,理解起来就会顺畅很多。

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挂起 / 死锁 / 至少会冻结

实务中常见的,基本上就是下面这 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

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

3. 本文使用的术语

3.1. UI 线程与消息循环

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

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

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

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

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

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

3.2. SynchronizationContext / Dispatcher / Invoke

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

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

细说的话,await 在决定续行位置时,会优先使用 当前的 SynchronizationContext,如果没有的话,也会参考 非默认的 TaskScheduler。 不过在 WPF / WinForms 的实务中,只要认为 UI 的 SynchronizationContext 在起作用 就足够了。

按框架整理成表,会更容易理解:

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

WPF 以 Dispatcher 为中心。 WinForms 则以控件的 handle 与消息循环为中心,BeginInvoke / Invoke 是表面上出现的 API。

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

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

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。

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

画成图就是这样的流程:

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

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处理投递出去返回计算结果把结果反映到界面

这里有两个需要注意的地方:

  • 不要用 Task.Run 包裹 I/O 等待
  • Task.Run 不是「使其异步化」,而是创造一个「CPU 的转移目的地」

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

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 处理程序自己写成这样就危险了:

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

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想要执行续行

用语言描述发生的事情,是这样的:

  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 才能结束」,两边互相等待。 确实是很讨厌的情况。

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

因此,在 UI 中最好把下面这 3 个当作同一类危险信号:

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

另外,对 WPF 的 Dispatcher.InvokeAsync(...) 返回的 Task 调用 Task.Wait() 同样危险。 WPF 的官方文档中也提到,对 DispatcherOperation 返回的 Task 调用 Task.Wait 会导致死锁。 在 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 传统模式中,使用 BeginInvokeInvoke 是同步发送,会让调用方等待;BeginInvoke 投递之后立即返回。 在 async 流程中,基本上 不阻塞的一侧 配合得更好。

区分的方式,大致到这个程度就足够了:

想做的事 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

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()
库代码直接持有 DispatcherControl 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 个问题,代码就会明显稳定不少。

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

在评审 WPF / WinForms 的 async / await 时,可以按下面的顺序依次检查:

  • 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. 大致的使用区分

想做的事 首选方案
在 UI 处理程序中等待 HTTP / DB / 文件 I/O plain await
不想卡住 UI 的繁重 CPU 计算 await 一个 Task.Run
ConfigureAwait(false) 之后或从后台线程更新 UI WPF: Dispatcher.InvokeAsync / WinForms: BeginInvoke.NET 9+ InvokeAsync
编写通用库 考虑使用 ConfigureAwait(false)
想在 UI 中把 async 同步化 基本不要这样做,让调用方也一路改为 async
想做启动时初始化 Loaded / Shown / 明确的 InitializeAsync
await 之后想直接操作 UI UI 最外层保持 plain await

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 线程的债还得太粗糙

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

返回博客列表