C#中异步编程的使用建议
异步模型的基本概述
异步编程的核心是 Task
和 Task<T>
对象,这两个对象对异步操作建模。 它们受关键字 async
和 await
的支持。 在大多数情况下模型十分简单:
- 对于 I/O 绑定代码,当你
await
一个操作,它将返回async
方法中的一个Task
或Task<T>
。 - 对于 CPU 绑定代码,当你
await
一个操作,它将在后台线程通过Task.Run()
方法启动。
识别 CPU 绑定和 I/O 绑定工作
确定所需执行的操作是 I/O 绑定或 CPU 绑定是关键,因为这会极大影响代码性能,并可能导致某些构造的误用。
以下是编写代码前应考虑的两个问题:
- 如果代码会“等待”某些内容,那么此操作是 I/O 绑定的。
- 如果代码要执行开销巨大的计算,那么操作是 CPU 绑定的。
如果你的工作为 I/O 绑定,请使用 async
和 await
(而不使用 Task.Run()
),不应使用任务并行库。 相关原因在深入了解异步的文章中说明。
如果你的工作为 CPU 绑定,并且你重视响应能力,请使用 async
和 await
,并在另一个线程上使用 Task.Run()
生成工作。 如果该工作同时适用于并发和并行,则应考虑使用任务并行库。
此外,应始终对代码的执行进行测量。 例如,你可能会遇到这样的情况:多线程处理时,上下文切换的开销高于 CPU 绑定工作的开销。 每种选择都有折衷,应根据自身情况选择正确的折衷方案。
重要信息和建议
尽管异步编程相对简单,但应记住一些可避免意外行为的要点。
async
方法需在其主体中具有 await
关键字,否则它们将永不暂停
这一点需牢记在心。 如果 await
未用在 async
方法的主体中,C# 编译器将生成一个警告,但此代码将会以类似普通方法的方式进行编译和运行。 请注意这会导致效率低下,因为由 C# 编译器为异步方法生成的状态机将不会完成任何任务。
应将 Async 作为后缀添加到所编写的每个异步方法名称中
这是 .NET 中的惯例,以便更轻松区分同步和异步方法。 请注意,未由代码显式调用的某些方法(如事件处理程序或 Web 控制器方法)并不一定适用。 由于它们未由代码显式调用,因此对其显式命名并不重要。
async void
应仅用于事件处理程序
async void
是允许异步事件处理程序工作的唯一方法,因为事件不具有返回类型(因此无法利用 Task
和 Task<T>
)。 其他任何对 async void
的使用都不遵循 TAP 模型,且可能存在一定使用难度,例如:
async void
方法中引发的异常无法在该方法外部被捕获。- 十分难以测试
async void
方法。 - 如果调用方不希望
async void
方法是异步方法,则这些方法可能会产生不好的副作用。
在 LINQ 表达式中使用异步 lambda 时请谨慎
LINQ 中的 Lambda 表达式使用延迟执行,这意味着代码可能在你并不希望结束的时候停止执行。 如果编写不正确,将阻塞任务引入其中时可能很容易导致死锁。 此外,此类异步代码嵌套可能会对推断代码的执行带来更多困难。 Async 和 LINQ 的功能都十分强大,但在结合使用两者时应尽可能小心。
采用非阻止方式编写等待任务的代码
将阻止当前线程作为等待任务完成的方法可能导致死锁和已阻止的上下文线程,且可能需要更复杂的错误处理。 下表提供了关于如何以非阻止方式处理等待任务的指南:
要执行的操作 | 应该使用的方式 | 不该使用的方式 |
---|---|---|
检索后台任务的结果 | await |
Task.Wait 或 Task.Result |
等待任何任务完成 | await Task.WhenAny |
Task.WaitAny |
等待所有任务完成 | await Task.WhenAll |
Task.WaitAll |
等待一段时间 | await Task.Delay |
Thread.Sleep |
编写无状态的代码
请勿依赖全局对象的状态或某些方法的执行,请仅依赖方法的返回值。因为:
- 这样更容易推断代码。
- 这样更容易测试代码。
- 混合异步和同步代码更简单。
- 通常可完全避免争用条件。
- 通过依赖返回值,协调异步代码可变得简单。
- 它非常适用于依赖关系注入。
建议的目标是实现代码中完整或接近完整的引用透明度。 这么做能获得高度可预测、可测试和可维护的基本代码。