使用图表和示例解释 Async/Await






4.99/5 (58投票s)
解释了在使用 await 的各种场景下的程序流程
引言
本文档解释了 Async/Await 的工作原理。这可能是一个令人困惑的主题。我们将从一些简单的基本概念开始,然后逐步深入到更高级的概念。希望这些视觉图表能帮助那些视觉学习者。
以下讨论主要从 WPF 的角度进行,尽管我偶尔也会提到 WinForms。
目录
- 术语
- 同步调用同步
- 异步等待异步
- 同步调用 Task.Run()
- 异步等待 Task.Run()
- 异步调用同步
- 同步调用异步 = ☠
- 返回值
- 传递参数
- 在任何线程上完成
- 将 CancellationToken 与 Task.Run() 结合使用
- 返回 UI 线程 (消息队列, 消息循环, SynchronizationContext)
- Await 的工作原理
- Async、方法签名和接口
- Async/Await 的正确使用
- 将代码转换为 Async
- 修复在各个地方散布 Async 的代码
- 参考文献
- 历史
术语
我们将首先定义一些术语。
同步 (Sync) 方法
同步 (sync
) 方法是一个常规方法,它没有标记 async
并且其中没有 await
。例如
private void Foo()
{
...
}
异步 (Async) 方法
异步 (async
) 方法是一个标记为 async
且其中包含 await
的方法。例如
private async Task FooAsync()
{
await BarAsync();
}
异步方法的名称通常以“…Async()
”结尾。
异步方法应返回一个 Task
。唯一可以使 async
方法返回 void
的地方是在事件处理程序中,例如按钮点击事件处理程序
private async void Button_Click(object sender, RoutedEventArgs e)
{
await FooAsync();
}
同步调用
同步调用是不包含 await
的调用。它可能返回也可能不返回一个值。例如
Bar();
OR
int x = Bar();
异步调用
异步调用是使用 await
的调用。它可能返回也可能不返回一个值。例如
await BarAsync();
或者
int x = await BarAsync();
请注意,await
不会“启动”对 BarAsync()
的调用;相反,await
决定如何处理 BarAsync()
的_结果_;该结果可能是一个未完成的 Task 或一个已完成的 Task。
一个类比是以下语句中的 return
return Bar();
在这里,我们不会说 return
“启动”了 Bar()
;相反,return
决定了如何处理 Bar()
的_结果_;
同步调用同步
常规调用
Foo()
调用 Bar()
。Bar()
运行然后返回到 Foo()
。
对 stream.Read() 的常规调用
Foo()
调用 stream.Read()
。线程等待 stream.Read()
完成,然后继续。
异步等待异步
await stream.ReadAsync()
ButtonClick()
调用 await stream.ReadAsync()
。线程没有等待读取完成,而是返回到 ButtonClick()
的调用者,允许线程执行其他操作。
通常 ButtonClick()
是从 UI 线程的_消息循环_(如下所述)调用的,并在 UI 线程上运行。通过在 await 期间返回,UI 线程能够处理_消息队列_中的其他消息并更新屏幕。
稍后,当 stream.ReadAsync()
完成时,ButtonClick()
方法的其余部分在 UI 线程(深蓝色)上运行。
如何在 stream.ReadAsync()
完成后让 UI 线程运行 ButtonClick()
的其余部分有些复杂,稍后将进行解释。
用一个线程同时做两件事
在上述场景中,ButtonClick()
调用 await stream.ReadAsync()
。UI 线程没有等待读取完成,而是返回到 ButtonClick()
的调用者,允许 UI 线程执行其他操作。
此时,可以说我们同时在做两件事
- 我们正在等待
ReadAsync()
调用完成 - UI 线程正在处理_消息队列_中的消息
“等待”是否算作做事情是一个语义问题(通常不算)。我们确实在等待,并且我们已经设置好,当这个等待完成时,await stream.ReadAsync()
之后的代码将执行;然而,这是一种被动的等待,在此等待期间我们没有占用 UI 线程。UI 线程在被动等待 await stream.ReadAsync()
完成的同时,可以自由地做其他事情。请注意,仍然只有一个线程,即 UI 线程,并且该线程仍在完成所有工作。
ButtonClick 调用 FooAsync 调用 stream.ReadAsync()
ButtonClick()
调用 await FooAsync()
。FooAsync()
调用 await stream.ReadAsync()
。未等待读取完成,而是将一个未完成的 Task 返回给 FooAsync()
的调用者。await FooAsync()
看到返回给它的 Task 未完成,因此它返回到其调用者,即 UI 线程的_消息循环_。这允许 UI 线程处理_消息队列_中的其他消息并更新屏幕。
稍后,当 ReadAsync()
完成时,FooAsync()
的其余部分运行(深蓝色)。当 FooAsync()
到达末尾时,它将一个已完成的 Task 返回给 await FooAsync()
,然后 ButtonClick()
的其余部分运行。
此示例中的所有操作都发生在 UI 线程上。UI 线程在等待读取完成时被释放。当读取完成时,FooAsync()
的其余部分在 UI 线程上运行,当 FooAsync()
返回到 ButtonClick()
时,ButtonClick()
的其余部分在 UI 线程上运行。实现此目的的详细信息将在后面解释。
同步调用 Task.Run()
同步 → Task.Run() → 同步
Foo()
将 Bar()
排队到 ThreadPool
线程上运行。Foo()
继续执行,不等待 Bar()
完成。Bar()
独立地在 ThreadPool
线程上运行。
同步 → Task.Run() → 同步并等待
Task.Run()
将Bar()
排队到ThreadPool
线程上运行。Foo()
等待任务t
完成。- 运行
Foo()
的线程通过将其执行状态设置为“WaitSleepJoin
”(阻塞状态)进入等待状态,并放弃其剩余的处理器时间片。(这释放了 CPU 来运行其他线程。)在满足其阻塞条件之前,该线程不消耗处理器时间。 - 稍后,当
Bar()
完成时,运行Foo()
的线程的执行状态被设置回“Running
”,并在线程管理器有可用时间片时恢复运行。
- 运行
在 UI 线程上执行 .Wait()
是不明智的,因为这会使程序无响应。我们不希望 UI 线程什么都不做而被占用。(考虑将 Foo()
转换为异步方法,并改用 await Task.Run(()=>Bar());
。)
如果 Foo()
正在 ThreadPool
线程上运行,那么执行 .Wait()
再次是不明智的,因为现在我们正在阻塞一个 ThreadPool
线程,等待_另一个_ ThreadPool
线程运行 Bar()
。当你自己可以完成这项工作时,为什么要启动另一个线程然后只等待它完成呢?
这给我们带来了一个关于等待 Tasks
完成的通用规则
避免使用 Task.Wait 和 Task.Result
“正确使用 Task.Result
和 Task.Wait
的方法非常少,因此一般的建议是完全避免在代码中使用它们。”
David Fowler,微软合作软件架构师
—https://github.com/davidfowl/AspNetCoreDiagnosticScenarios/blob/master/AsyncGuidance.md
异步等待 Task.Run()
异步等待 Task.Run() 启动同步方法
FooAsync()
将Bar()
排队到ThreadPool
线程上运行并返回给其调用者。(如果FooAsync()
在 UI 线程上运行,则 UI 线程未被阻塞,这是好事。)Bar()
运行(红色)。- 当
Bar()
完成时,运行Bar()
的任务完成。然后FooAsync()
继续使用它开始的相同SynchronizationContext
(蓝色)。这意味着如果FooAsync()
在 UI 线程上运行,那么FooAsync()
继续在 UI 线程上运行;如果FooAsync()
在ThreadPool
线程上运行,那么FooAsync()
继续使用_任何_ThreadPool
线程。
请注意,FooAsync()
并非等待方法 Bar()
完成,而是等待运行 Bar()
的_任务_完成。
异步等待 Task.Run() 启动异步方法
await Task.Run(async () => await BarAsync())
将BarAsync()
排队到ThreadPool
线程上运行并返回给其调用者。(如果FooAsync()
在 UI 线程上运行,则 UI 线程未被阻塞,这是好事。)BarAsync()
运行(红色)。- 当
BarAsync()
遇到await stream.ReadAsync(buffer)
语句时,它不会等待读取完成,而是返回,运行BarAsync()
的ThreadPool
线程被释放以运行其他任务。 - 当
stream.ReadAsync(buffer)
完成时,BarAsync()
的其余部分在_任何_可用的ThreadPool
线程上运行。 - 当方法
BarAsync()
完成时,运行BarAsync()
的任务完成,并且FooAsync()
的其余部分继续使用它开始的相同SynchronizationContext
(蓝色)。这意味着如果FooAsync()
在 UI 线程上运行,那么FooAsync()
继续在 UI 线程上运行;如果FooAsync()
在ThreadPool
线程上运行,那么FooAsync()
的其余部分继续使用_任何_可用的ThreadPool
线程。
请注意,FooAsync()
并非等待方法 BarAsync()
完成,而是等待运行 BarAsync()
的_任务_完成。
异步调用同步
一般来说,异步方法可以调用同步方法。异步方法只是暂时假装它是一个调用同步方法的同步方法。例如,异步代码可以调用一个简单的同步函数,该函数将两个数字相乘并返回结果。
在某些情况下,使用 await
异步调用返回 Task
的同步方法可能会导致问题。在另一些情况下,这完全可以接受。这取决于返回的 Task
的细节。这个问题将在关于 async
/await
的后续文章中进一步讨论。
同步调用异步 = ☠
术语“Sync over Async”指的是同步代码调用异步代码。同步代码无法等待异步代码,因此很难知道异步代码何时完成。更糟糕的是,在某些情况下,等待异步代码完成可能会导致死锁。这引出了我们关于同步代码调用异步代码的一般规则
Sync over Async 是不好的。不要这样做。
下面,我们探讨从同步代码调用异步代码时可能发生的危险。
同步 → 异步
非异步 Foo()
调用 BarAsync()
。当 BarAsync()
调用 await stream.ReadAsync(buffer)
时,它返回到 Foo()
,Foo()
继续执行。
稍后,在 stream.ReadAsync(buffer)
完成后,BarAsync()
的其余部分运行(深蓝色)。
请注意,我们不能等待对 BarAsync()
的调用,因为 Foo()
是一个不支持 await
的同步方法。我们无法知道 BarAsync()
的其余部分何时或是否运行。它可能永远不会运行,而我们永远不会知道。
同步 → 异步 → .Wait() ☠
警告:可能导致死锁。
- 在 UI 线程上运行的
ButtonClick()
调用BarAsync()
。 BarAsync()
调用await stream.ReadAsync(buffer)
,该调用在某个时刻返回一个未完成的任务,该任务作为Task t
存储在ButtonClick()
中。ButtonClick()
然后调用t.Wait()
。UI 线程现在被占用,等待任务t
完成。- 稍后,当
ReadAsync
完成时,它将BarAsync()
的其余部分排队到 UI 线程上运行。不幸的是,ButtonClick()
正在占用 UI 线程,等待BarAsync()
完成。这导致了死锁:ButtonClick()
正在等待BarAsync()
,而BarAsync()
正在等待ButtonClick()
。由于ButtonClick()
正在阻塞 UI 线程,整个程序冻结,无法响应键盘按键或鼠标点击。
请注意,在某些情况下代码可能不会死锁:如果将 stream.ReadAsync()
替换为 Task.Delay(0)
,那么 await
将跳过耗时的“向调用者返回一个未完成的任务”的麻烦,并直接继续运行。但是,如果将该 Task.Delay(0)
替换为 Task.Yield()
,则代码将始终死锁。
让我们看看当我们尝试通过使用 Task.Run()
调用异步方法来修复此问题时会发生什么。
同步 → Task.Run() → 异步
在 UI 线程上运行的 ButtonClick()
创建任务 t
运行 BarAsync()
。然后 ButtonClick()
继续执行,不再进一步检查任务 t
。
另外,任务 t
在 ThreadPool
线程上运行 BarAsync()
。(使用 Task.Run()
启动的任务在 ThreadPool
线程上运行。)当 BarAsync()
遇到 await stream.ReadAsync(buffer)
时,它返回并释放 ThreadPool
线程,以便该线程可以处理其他事情。
稍后,当 ReadAsync(buffer)
完成时,BarAsync()
的其余部分在_任何_可用的 ThreadPool
线程上运行。它可以在任何可用的 ThreadPool
线程上完成的原因是,从哲学上讲,所有 ThreadPool
线程都是相同的。(更技术性的解释是,因为 ThreadPool
线程没有 SynchronizationContext
,所以使用了“默认”的 SynchronizationContext
,这意味着“任何 ThreadPool
线程”。)
我们仍然不知道异步任务何时完成的问题。现在,如果我们引入 .Wait()
来等待任务完成,看看会发生什么。
UI 线程 → Task.Run() → 异步 → .Wait()
在 UI 线程上运行的非异步 ButtonClick()
使用 Task.Run()
创建任务 t
运行 BarAsync()
。然后 ButtonClick()
调用 t.Wait()
并等待任务 t
完成,阻塞 UI 线程。
同时,任务 t
在 ThreadPool
线程上运行 BarAsync()
。(使用 Task.Run()
启动的任务在 ThreadPool
线程上运行。)当 BarAsync()
遇到 await stream.ReadAsync(buffer)
时,它返回并释放 ThreadPool
线程以处理其他事情。
稍后,当 ReadAsync(buffer)
完成时,BarAsync()
的其余部分在_任何_可用的 ThreadPool
线程上运行。
当方法 BarAsync
完成时,运行 BarAsync
的任务 t
完成,t.Wait()
完成,并且 ButtonClick
的其余部分继续在 UI 线程上运行。
尽管这不会死锁,但它会用 .Wait()
占用 UI 线程,导致我们的程序在等待时对用户输入无响应。更好的做法是将 ButtonClick()
转换为 async
方法并改为 await
任务 t
。
ThreadPool → Task.Run() → 异步 → .Wait ☠
假设 Foo()
正在 ThreadPool
线程上运行。Foo()
调用 Task.Run()
创建一个任务 t
运行 BarAsync()
。然后 Foo()
调用 t.Wait()
并等待任务 t
完成,阻塞它正在运行的 ThreadPool
线程。
同时,任务 t
在另一个 ThreadPool
线程上运行 BarAsync()
。(使用 Task.Run()
启动的任务在 ThreadPool
线程上运行。)当 BarAsync()
遇到 await stream.ReadAsync(buffer)
时,它返回并释放 ThreadPool
线程以处理其他事情。
稍后,当 ReadAsync(buffer)
完成时,BarAsync()
的其余部分在_任何_可用的 ThreadPool
线程上运行。
线程池饥饿
这里潜在的问题是 Foo()
正在阻塞一个 ThreadPool
线程,并且我们需要_另一个_ ThreadPool
线程来在 await
之后完成 BarAsync()
。有可能构造一个场景,我们启动 Foo()
的多个实例,占用 ThreadPool
线程,直到没有更多的 ThreadPool
线程可用。_所有_ ThreadPool
线程都被阻塞,等待_另一个_ ThreadPool
线程完成 BarAsync()
的运行。
此时,操作系统发现需要更多的 ThreadPool
线程,因此它创建了一个新的 ThreadPool
线程。这个新的 ThreadPool
线程可能会运行 BarAsync()
的其余部分;或者,它可能会运行 Foo()
的_另一个_实例。新的 ThreadPool
线程运行哪个方法取决于程序细节和 ThreadPool
队列的管理方式。如果新的 ThreadPool
线程总是运行 BarAsync()
的其余部分,系统将开始恢复;但是,如果新的 ThreadPool
线程总是运行 Foo()
的另一个实例,那么我们就注定失败了:Foo()
将阻塞新的 ThreadPool
线程,我们将回到 ThreadPool
饥饿状态,只是 ThreadPool
的大小会增加。系统可能永远无法恢复,ThreadPool
的大小无限期地缓慢增加,所有 ThreadPool
线程都被阻塞,每个线程都永远等待另一个 ThreadPool
线程来拯救它。
这种类型的 ThreadPool
饥饿的示例可以在此链接找到。
返回值
同步调用同步返回值
int x = Bar();
异步等待异步返回值
int x = await BarAsync();
这是调用 async
方法的通常方式
FooAsync()
调用BarAsync()
BarAsync()
遇到await Task.Delay(2000);
并向FooAsync()
返回一个未完成的任务,FooAsync()
又将未完成的任务返回给其调用者。- 稍后,
BarAsync()
完成并向FooAsync()
返回 7,FooAsync()
将 7 存储在变量x
中。 FooAsync()
现在有了x
的值后继续运行。
Task.Run() 返回值
同步调用 Task.Run() 等待同步方法返回值
Foo()
启动任务,等待任务完成,从任务获取结果。
Task<int> t = new Task<int>(Bar);
t.Start();
t.Wait();
int x = t.Result;
或者
Task<int> t = Task.Run( () => Bar() );
t.Wait();
int x = t.Result;
或者
Task<int> t = Task.Run( () => Bar() );
int x = t.Result;
或者
int x = Task.Run( () => Bar() ).Result;
异步等待 Task.Run() 返回值
int x = await Task.Run on sync method returning value
这是等待耗时同步代码的标准方式。
FooAsync()
将Bar()
排队到ThreadPool
线程上运行并返回给其调用者。(如果FooAsync()
在 UI 线程上运行,则 UI 线程未被阻塞,这是好事。)Bar()
运行(红色)。- 当
Bar()
完成时,运行Bar()
的任务完成,并将 7 存储在变量x
中。 - 然后
FooAsync()
继续执行:如果FooAsync()
在 UI 线程上运行,则FooAsync()
继续在 UI 线程上运行;如果FooAsync()
在ThreadPool
线程上运行,则FooAsync()
继续在_任何_ThreadPool
线程上运行。
int x = await Task.Run on async method returning value
int x = await Task.Run(async () => await BarAsync())
将BarAsync()
排队到PoolThread
线程上运行并返回给其调用者。(如果FooAsync()
在 UI 线程上运行,则 UI 线程未被阻塞,这是好事。)BarAsync()
运行(红色)。- 当
BarAsync()
遇到await stream.ReadAsync(buffer)
语句时,它不会等待读取完成,而是返回,运行BarAsync()
的ThreadPool
线程被释放以运行其他任务。 - 当
stream.ReadAsync(buffer)
完成时,BarAsync()
的其余部分在_任何_可用的ThreadPool
线程上运行。 - 当方法
BarAsync()
完成时,运行BarAsync()
的任务完成,并将 7 存储在变量 x 中。 - 然后
FooAsync()
继续执行:如果FooAsync()
在 UI 线程上运行,则FooAsync()
继续在 UI 线程上运行;如果FooAsync()
在ThreadPool
线程上运行,则FooAsync()
继续在_任何_可用的ThreadPool
线程上运行。
在这种情况下,可以考虑移除 Task.Run
部分,只使用 int x = await BarAsync();
await BarAsync() 与 await Task.Run(async () => await BarAsync())
await BarAsync()
直接运行 BarAsync()
。如果 BarAsync()
需要一些时间并且没有等待耗时的代码(例如,它可能正在进行一些 CPU 密集型计算,因此它不能等待,因为它正在工作而不是等待),那么调用者必须等待 BarAsync()
完成其耗时的工作。
await Task.Run(async ()=> await BarAsync())
在这里,我们正在等待正在运行 BarAsync
的 Task。这使得调用者在等待 Task
完成时可以执行其他操作。该任务在后台 ThreadPool
线程上运行。
同步调用异步返回值
无法完成,因为 BarAsync()
返回的是 Task<int>
,而不是 int
。
private async Task<int> BarAsync()
{
await Task.Delay(2000);
return 7;
}
<s>int x = BarAsync();</s> “Cannot implicitly convert Task<int> to int.”
调用者需要是一个 async
方法。
同步调用 Task.Run() 启动异步任务并等待返回值
可能导致死锁!(参见线程池饥饿)
不要对异步任务执行 .Wait()
。相反,对异步任务使用 await
(或者移除 Task.Run()
并直接等待方法)。
异步调用同步返回值
int x = Bar();
这通常是没问题的,只要 Bar()
不返回一个我们稍后等待完成的 Task。有时,这是可以的,有时可能会导致问题。这种情况的细节将在另一篇关于 async/await 的后续文章中讨论。
传递参数
int x = await Task.Run(() => Bar(a, b, c));
int x = await Task.Run(async () => await BarAsync(a, b, c));
也可以这样做
int x = Task.Run(() => Bar(a, b, c)).Result;
尽管在这种情况下可以直接省略 Task.Run
并这样做
int x = Bar(a, b, c);
不要等待异步方法或任务。
不要这样做
int x = Task.Run(async () => await BarAsync(a, b, c)).Result;
因为这可能导致线程池饥饿死锁。(参见线程池饥饿。)
在任何线程上完成
添加 .ConfigureAwait(false)
允许 await 之后的延续在任何可用线程上运行(深红色)。通常,这将是一个 ThreadPool
线程。当我们知道 ButtonClick()
的其余部分不需要在 UI 线程上运行时,这很方便。
两个线程不同时做两件事
在上面的例子中,由于 .ConfigureAwait(false)
,我们有两个线程在运行,它允许 ButtonClick()
的其余部分在后台 ThreadPool
线程上运行,而开始运行 ButtonClick()
的 UI 线程则可以自由地做其他事情。然而,我们并没有同时做两件事,因为即使 ButtonClick()
的其余部分在不同的线程上运行,它仍然要等到对 ReadAsync(buffer)
的调用完成后才会运行。我们从不同时做两件事,即使我们使用了两个线程。
异步调用异步在任何线程上完成
ButtonClick()
在 UI 线程上启动。ButtonClick()
调用await FooAsync()
。FooAsync()
调用await stream.ReadAsync()
。stream.ReadAsync()
返回一个未完成的任务给ButtonClick()
,ButtonClick()
又返回给其调用者(_消息循环_)。- 当
stream.ReadAsync()
完成时,FooAsync()
的其余部分在_任何_可用线程上运行,很可能是一个ThreadPool
线程,因为使用了.ConfigureAwait(false);
(这只是.ConfigureAwait(continueOnCapturedContext:false);
的简写)。 FooAsync()
完成并返回到ButtonClick()
。ButtonClick()
的其余部分在 UI 线程上运行(因为对FooAsync()
的调用没有附加.ConfigureAwait(false);
)。
将 CancellationToken 与 Task.Run() 结合使用
由于我有许多使用 Task.Run()
的示例,我应该包含一个与 Task.Run()
一起使用 CancellationToken
的示例,这总是一个好主意。
来自微软文档
“当拥有对象调用CancellationTokenSource.Cancel()
时,取消令牌的每个副本上的IsCancellationRequested
属性都被设置为true
。”
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
CancellationTokenSource cts;
Task<int> task;
private async void Button_Click(object sender, RoutedEventArgs e)
{
this.cts?.Cancel();
this.cts = new CancellationTokenSource();
this.task = Task.Run(() => SomeTask(cts.Token), cts.Token);
int x = await this.task;
this.task.Dispose();
this.task = null;
}
private async Task<int> SomeTask(CancellationToken ct)
{
for(int i=0; i < 20; ++i)
{
ct.ThrowIfCancellationRequested();
await Task.Delay(500);
}
return 5;
}
请注意取消令牌是如何两次传递给 Task.Run()
的。
- 它作为参数传递给
SomeTask(cts.Token)
将取消令牌传递给SomeTask()
允许SomeTask()
定期检查Token
的状态,以查看它是否已设置为“Cancel
”状态,以便在需要时中止过程。 - 它作为第二个参数传递给
Task.Run(…, cts.Token);
将取消令牌作为第二个参数传递给Task.Run()
允许Task.Run()
在调用Task.Run()
时取消令牌已设置为“Cancel
”的情况下跳过运行SomeTask()
。
请注意,微软开发人员Stephen Toub认为任务不需要处置,因此上面处置任务的代码可能过度。参见:https://devblogs.microsoft.com/pfxteam/do-i-need-to-dispose-of-tasks/
返回 UI 线程
为了理解 await
如何在需要时返回 UI 线程,我们需要解释_消息队列_、_消息循环_和_SynchronizationContext_的概念。
消息队列
具有 GUI(图形用户界面)的 Windows 程序为每个创建了窗口的线程都有一个单独的_消息队列_。通常,只有初始线程创建窗口并维护程序的_消息队列_。此线程称为“UI 线程”(用户界面线程)或 GUI 线程(图形用户界面线程)。它们是同一回事。
事件,例如按钮点击或键盘按键,都会被放入此_消息队列_中。
这是一个_消息队列_的视觉图。这被称为“先进先出”(FIFO)队列。消息按照它们进入的顺序被检索。想象每个蓝色矩形都是一个“消息”,例如按钮点击事件消息,或“键盘键被按下”消息。
可以放入消息队列的事件包括
WM_KEYDOWN
WM_KEYUP
WM_LBUTTONDBLCLK
WM_MOUSEMOVE
(搜索文件 WinUser.h 以查看 WM_*
消息的完整列表。)
Windows 操作系统负责将事件消息发送到消息队列。它确定桌面上的哪个窗口具有焦点,并将消息发送到与创建该窗口的线程关联的_消息队列_。
有关消息和消息队列的更多信息,请参阅此处
https://docs.microsoft.com/en-us/windows/win32/winmsg/about-messages-and-message-queues
发布消息
将消息排队到_消息队列_中称为“发布”消息。
通过调用操作系统的 PostMessage()
库例程将消息添加到_消息队列_中。
消息循环
UI 线程运行称为_消息循环_(也称为_消息泵_)的代码。这是从_消息队列_中移除消息并处理它们的代码。代码是一个无休止的循环,在程序运行期间一直运行,看起来像这样
while(frame.Continue)
{
if (!GetMessage(ref msg, IntPtr.Zero, 0, 0))
break;
TranslateAndDispatchMessage(ref msg);
}
如果消息队列中没有消息,GetMessage()
会阻塞;也就是说,GetMessage()
会等待直到有消息返回,然后返回该消息。
这个无限循环直到程序请求终止才会退出。
关于窗口和消息的 PowerPoint 演示文稿
https://www.slideserve.com/finn/windows-and-messages
注意事项:WM_TIMER 消息
WM_TIMER
消息以一种特殊的非“先进先出”(FIFO)方式处理。SetTimer
系统函数设置一个系统计时器,其中包含一个计数器,该计数器包含计时器到期前的适当“刻度”数。在每个硬件计时器刻度上,Windows 递减计时器的计数器。当计数器达到 0 时,Windows 在相应应用程序的消息队列头中设置一个计时器到期标志。当消息循环调用 GetMessage
时,如果消息队列为空,它会检查计时器到期标志,如果设置了,函数将返回一个 WM_TIMER
消息并重置该标志。这意味着如果 CPU 繁忙并且清空消息队列存在延迟,WM_TIMER
消息可能会延迟。它也意味着多个 WM_TIMER
消息不能“堆积”。多个计时器可能会到期,多次设置计时器到期标志,但只会在重置标志之前生成一个 WM_TIMER
消息。GetMessage
也可能在一个时间段到期之前返回一个 WM_TIMER
消息,随后几乎在第一个消息之后立即返回第二个 WM_TIMER
消息。
WPF 中的消息循环
每个 WPF 程序都有一个 Main()
入口点,可以通过在 Visual Studio 的 Solution Explorer 中查看 App.xaml 并选择 Main()
来查看
代码可以是显式的,或者在这种情况下,它是隐式的,自动生成的(因此文件名 App.g.i.cs 中有 'g')。
UI 线程是运行此 Main()
方法的线程。Main()
创建 App
类的一个实例,该实例又指定要创建窗口 MainWindow
。
然后,Main()
方法调用 app.InitializeComponent();
,最后调用 app.Run();
。这就是_消息循环_所在的位置。Main()
方法直到程序退出才返回。它将其生命作为 UI 线程运行_消息循环_。
类 App
继承自 System.Windows.Application
。System.Windows.Application
的代码可以在此链接查看。
在那里找到 Run()
方法并将其追踪到_消息循环_。
Run
→ RunInternal
→ RunDispatcher
→ Dispatcher.Run()
→ PushFrame
→ dispatcher.PushFrame
→ _消息循环_
返回 UI 线程
WinForms
在 WinForms 中,后台线程可以使用以下方法启动要在 UI 线程上运行的代码
control.BeginInvoke(delegate)
其中 delegate
是我们希望在 UI 线程上执行的代码。这会将委托发布到创建控件的线程(通常是 UI 线程)的_消息队列_。
WPF
在 WPF 中,后台线程可以使用以下方法启动要在 UI 线程上运行的代码
Dispatcher.CurrentDispatcher.BeginInvoke(delegate)
其中 delegate
是我们希望在 UI 线程上执行的代码。该委托被发布到 Dispatcher,最终在那里运行。(Dispatcher
是线程和_消息队列_的组合。通常,该线程是程序的 UI 线程。)
同步上下文
这就是我们如何判断我们是否在 UI 线程上。
如果我们在进入 await 时正在 UI 线程上运行,则 SynchronizationContext
允许代码返回 UI 线程。
在 WPF 中,SynchronzationContext
有两个版本
SynchronizationContext
有一个Post()
方法,它将方法排队到ThreadPool
线程上运行。(这个基类实际上没有被使用。它可能是早期设计迭代的遗留物。)DispatcherSynchronizationContext
继承自SynchronizationContext
并使用一个将方法排队到 UI 线程上运行的方法覆盖Post()
。
要获取 SynchronizationContext
,可以调用 SynchronizationContext.Current
。例如
SynchronizationContext sc = SynchronizationContext.Current;
如果我们当前在 UI 线程上,那么对于 WPF 项目,这会返回 DispatcherSynchronizationContext
的一个实例(同样,对于 WinForms 项目,这会返回 WindowsFormsSynchronizationContext
的一个实例,它也继承自 SynchronizationContext
)。另一方面,如果我们正在 ThreadPool
线程上,那么 SynchronizationContext.Current
返回 null
。
DispatcherSynchronizationContext
的代码可以在此链接查看。
DispatcherSynchronizationContext
的 Post()
方法是
public override void Post(SendOrPostCallback d, Object state)
{
_dispatcher.BeginInvoke(_priority, d, state);
}
DispatcherSynchronizationContext
的构造函数将 _dispatcher = Dispatcher.CurrentDispatcher
Await 的工作原理
既然我们已经讨论了 UI 线程如何处理_消息队列_和_消息循环_,以及 SynchronizationContext
如何将我们带回 UI 线程,我们现在可以回答 await
如何工作的问题了。
当我们进入 await
时,如果我们在 UI 线程上,那么我们希望在 await
之后恢复到 UI 线程。另一方面,如果我们在进入 await
时在 ThreadPool
线程上,那么我们希望在 await
之后恢复到 ThreadPool
线程上。
现在考虑以下代码
private async void ButtonClick()
{
await stream.ReadAsyc(this.buffer);
UpdateGUI(this.buffer);
}
这里,ButtonClick()
从 UI 线程调用。该方法首先调用 ReadAsync()
以将一些数据获取到 this.buffer
中。此读取需要很长时间,因此 ReadAsync()
返回一个未完成的任务给 await
,await
又返回给 ButtonClick()
的调用者,即_消息循环_。这释放了 UI 线程以处理_消息循环_中的其他消息。
稍后,在 ReadAsync()
完成后,我们希望在 UI 线程上继续运行 ButtonClick()
的其余部分。
为了实现这一点,当编译器遇到 await
关键字时,它会在调用 stream.ReadAsync(this.buffer)
之前生成捕获 SynchronizationContext
的代码。代码看起来像这样
SynchronizationContext sc = SynchronizationContext.Current;
在运行时,如果我们在 UI 线程上,那么 sc
就会成为 DispatcherSynchronizationContext
的一个实例(它继承自 SynchronizationContext
,所以它是一个 SynchronizationContext
);否则,如果我们在 ThreadPool
线程上,那么 sc
将被设置为 null
。
在 await
之后,编译器生成的代码看起来像这样
if (sc == null)
RestOfMethod();
else
sc.Post(delegate { RestOfMethod(); }, null);
这可以解释为:“如果我们没有 SynchronizationContext
,那么就用我们当前所在的任何线程运行其余的代码;否则,使用 SynchronizationContext
将我们带回 UI 线程。”
Await 的其余部分如何工作
关于 await
关键字,我还有一些没有在这里解释。当编译器遇到 await
关键字时,它会创建一个状态机来处理将代码分成不同部分的所有细节:await
之前的代码部分和 await
之后的代码部分。这会有点混乱,可以想象,我很高兴让编译器处理这些细节。如果读者感兴趣,互联网上有很多优秀的文章解释了此状态机背后的细节。
Async、方法签名和接口
(摘自 Alex Davies 的《C# 5.0 中的 Async》一书)
https://www.oreilly.com/library/view/async-in-c/9781449337155/ch04.html
async
关键字出现在方法的声明中,就像public
或static
关键字一样。尽管如此,async
并不是方法签名的一部分,因为它不涉及重写其他方法、实现接口或被调用。
async
关键字唯一的作用是影响其所应用方法的编译,而不像应用于方法的其他关键字那样改变它与外部世界的交互方式。因此,重写方法和实现接口的规则完全忽略了async
关键字。class BaseClass { public virtual async Task<int> AlexsMethod() { ... } } class SubClass : BaseClass { // This overrides AlexsMethod above public override Task<int> AlexsMethod() { ... } }接口不能在方法声明中使用
async
,仅仅是因为没有必要。如果接口要求方法返回Task
,则实现可以选择使用async
,但这是否使用async
是实现方法的选择。接口不需要指定是否使用async
。
方法返回 Task 的一个问题是,有时方法期望通过同步的 task.Wait();
来等待该 Task,而其他时候方法期望通过异步的 await task;
来等待该 Task。对 Task 使用错误的等待类型可能会导致死锁和其他问题。这将在另一篇后续文章中进一步讨论。
Async/Await 的正确使用
不要阻塞异步代码。(参见:https://blog.stephencleary.com/2012/07/dont-block-on-async-code.html)
名称中带有 Async
的方法在调用时应在其前面加上 await
。
async
方法最终需要调用异步 I/O 例程。
从 .NET Framework 4.5 开始,I/O 类型包含异步方法,以简化异步操作。异步方法的名称中包含 Async
,例如 ReadAsync()
、WriteAsync()
、CopyToAsync()
、FlushAsync()
、ReadLineAsync()
和 ReadToEndAsync()
。这些异步方法在流类(如 Stream
、FileStream
和 MemoryStream
)以及用于从流中读取或写入流的类(如 TextReader
和 TextWriter
)上实现。
除非整个调用堆栈都是异步的,否则努力实现 async
将毫无意义。(从 async
按钮点击事件处理程序开始,一直到异步 I/O 系统调用,所有方法都是 async
。)
如果代码中没有原生的异步方法,则没有理由将任何内容转换为 async
,最终只会导致代码中某处出现“async over sync”或“sync over async”。也就是说,某个 async
方法会调用同步方法,或者同步方法会调用异步方法。
此外,不要将 List<T>.ForEach()
与异步方法混合使用(或确实是 Parallel.ForEach
,它也有完全相同的问题)。参见:C# 异步反模式:反模式 #5:将 ForEach
与 async
方法混合使用,网址为:https://markheath.net/post/async-antipatterns
将代码转换为 Async
- 识别可以更改为异步 I/O 调用的原生 I/O 调用。例如,
Read()
→ReadAsync()
。 - 将原生 I/O 调用转换为异步 I/O 调用。[例如,将
Read()
→ReadAsync()
。] 将该方法指定为async
方法。 - 所有调用此异步方法的方法现在都需要转换为异步方法。(编译器是您的朋友。让它告诉您需要修复什么。)
- 重复上一步,直到没有更多方法需要转换为异步方法。调用链中的所有方法都将转换为异步方法,直到达到事件处理程序,例如按钮点击事件处理程序。
不要保留同步方法调用异步方法。(Sync over async。)
修复已在多处散布 Async 的代码
异步方法最终是否调用原生异步 I/O 方法,例如 ReadAsync
或 WriteAsync
?异步方法是否由调用链中一直到事件处理程序的所有异步方法调用?是否存在同步方法调用异步方法的实例(“Sync over Async”)?如果有,同步方法能否更改为调用链中的异步方法?或者,也许异步方法并非必需,可以转换为同步方法?
参考文献
- 异步编程指南 – David Fowler,微软合作软件架构师https://github.com/davidfowl/AspNetCoreDiagnosticScenarios/blob/master/AsyncGuidance.md
- Windows 操作系统中的进程、线程和作业 作者:Kate Chase 和 Mark E. Russinovich 2009年6月17日
https://www.microsoftpressstore.com/articles/article.aspx?p=2233328 - Windows 内核,第 1 部分(开发者参考)作者:Pavel Yosifovich、Mark E. Russinovich 等 | 2017年5月15日
- Windows 内核,第 2 部分(第 7 版)(开发者参考)作者:Mark E. Russinovich、Andrea Allievi 等 | 2020年7月16日
历史
- 2021年4月9日:初始版本
- 2021年4月15日:更正“FooAsync 的其余部分运行” → “ButtonClick 的其余部分运行”