C# Async/Await 最佳实践
异步不是魔法,也不需要另一个线程等待
很多不理解异步的人总觉得异步是一种魔法操作,认为使用了异步能够增加程序的处理速度。这是一个常见的误解,异步不是魔法,无法加速你程序的运行速度,如果你的方法以前需要一秒钟返回结果使用异步后一样需要一秒钟返回结果,这些人会觉得奇怪,说好的异步会使程序性能提升呢?另一种常见的误解则是,异步也需要线程来执行,只不过使用了线程池线程,会有一个后台线程继续等待异步结果,但事实总是出乎意料:没有额外线程在等待。
让我们一步步解剖异步的线程无需等待到底是什么,假设我们现在需要对文件进行读取:
async Task MyMethodAsync() {
//异步读取文件
var text = await System.IO.File.ReadAllTextAsync(@"file.text");
//输出文件内容到控制台
System.Console.WriteLine(text);
}
当线程执行到 await System.IO.File.ReadAllTextAsync(@"file.text")时,线程会调用操作系统一个标准的异步I/O接口,此时线程返回线程池,并未在此等待。疑惑的地方在此,读文件的操作是谁来完成的?操作系统会调用硬件驱动的异步 I/O方法,如果是个机械硬盘,此时机械硬盘的磁头移动到正确的磁道对数据进行读取,这里是 CPU所不需要参与的,操作系统没有任何线程在等待。而当磁盘数据读取后,通过中断通知 CPU,CPU响应中断,操作系统通知应用程序 I/O完成,程序从线程池取出一个线程继续执行 await后的代码既 System.Console.WriteLine(text),过程中是没有任何线程在等待 I/O操作的。
认为异步能够使程序获得额外处理速度的人对异步的理解就好像是给程序加了个 Buff,这个 Buff能够使得 CPU主频瞬间提高以提高程序的速度。这其实是对概念有误解,异步使得线程无需等待,并不代表能提高运行速度,而是能提高程序对线程的利用率。这里有两个概念:
I/O Bound(操作受到I/O限制,I/O速度越快则操作速度越快)CPU Bound(操作受到CPU限制,CPU速度越快则操作速度越快)
对于 I/O Bound的操作,使用异步编程的方式编写非阻塞代码。对于 CPU Bound的操作,使用 Task.Run来运行多个任务。
异步到底
异步到底的意思是:从底层代码一直到程序调用的入口点都为异步,没有同步与异步代码的混合,没有阻塞的调用异步方法。
如果程序入口点的方法是非异步的,则底层的异步都会失效,对于 Web项目,需要从控制器开始一路异步,对于控制台程序,则从 Main方法开始就得异步,否则即使调用异步方法,也会以阻塞的方式执行:
static void Main (string[] args) {
//这里的异步方法将会以阻塞方式执行
MyMethodAsync();
}
static async Task MyMethodAsync() {
//异步读取文件
var text = await System.IO.File.ReadAllTextAsync(@"file.text");
//输出文件内容到控制台
System.Console.WriteLine(text);
}
异步/同步混搭风
一个复杂的大型项目往往没法一次将所有方法都改为异步,一些人则会混合使用异步和同步代码,例如一个同步方法调用异步方法,通过 Task.Wait()或者 Rask.Result来使异步代码变为同步代码执行,这对于那些只将应用程序的一小部分转换为异步代码并将其包装在同步方法中的程序员来说,这是个特别常见的做法,使得应用程序的其余部分不受异步的更改所影响。不幸的是,阻塞异步代码可能会造成死锁,考虑下面 ASP.NET WebAPI(Framework)代码:
public class ValuesController : ApiController
{
public string Get()
{
//使用Task.Result阻塞异步代码,同步返回结果
return GetStringAsync().Result;
}
private async Task<string> GetStringAsync()
{
//等待1秒
await Task.Delay(1000);
return "Successful";
}
}
一个编写异步代码的新手很可能写出上面的代码,并且在进行接口测试时会惊讶的发现死锁问题。其根本原因在于,当使用 await等待时,将捕获当前线程上下文,用来在任务完成后恢复该方法(继续执行 await后的代码),此上下文默认是 SynchronizationContext,如果没有 SynchronizationContext则使用 TaskScheduler分配线程,而 ASP.NET Framework具有 SynchronizationContext,当等待完成时,尝试使用 SynchronizationContext继续执行异步方法的剩余部分,然而 SynchronizationContext正在(同步)等待异步方法返回,异步方法的剩余部分也在等待 SynchronizationContext执行,这就导致死锁的发生。
此问题常在 GUI和 ASP.NET应用发生(仅为 Framework程序,NET Core程序不具有 SynchronizationContext),因为它们都具有 SynchronizationContext,而 Console程序则具有 Thread Pool SynchronizationContext,因此当等待完成时,将调度线程池的线程执行异步方法的剩余部分,所以当你进行单元测试时,你能够通过测试,但是部署到 ASP.NET或 GUI程序中,则会发生死锁的情况,这种行为上的差异会让人觉得困惑。
解决此问题的最佳方案自然是上文所说的 异步到底,在控制器方法中也使用异步等待,则当异步任务返回时,SynchronizationContext并未在等待异步任务,可以用来执行异步方法的剩余部分,自然不会发生死锁。但是这意味着要对应用程序进行大量的改进工作使得整个应用程序完全成为异步,而一次更改整个应用程序工作太过庞大且容易变得不可控,这种时候我们可能需要使用 Task.Result或 Task.Wait()进行部分转换。那么如果避免同步、异步代码混合的死锁问题?
ConfigureAwait
为了避免在同步方法中阻塞异步代码时发生死锁,可以使用 ConfigureAwait方法配置还原异步方法的剩余部分时,不是非要使用原始上下文线程进行还原:
public string Get()
{
//使用Task.Result阻塞异步代码,同步返回结果
return GetStringAsync().Result;
}
private async Task<string> GetStringAsync()
{
//配置ConfigureAwait(false),当等待完成时不必须使用原始上下文执行此方法的剩余部分
await Task.Delay(1000).ConfigureAwait(false);
return "Successful";
}
这能够避免死锁问题,且当我们在编写一个公共的 Standard或特定平台的类库时,也应当在 await的地方使用 ConfigureAwait来避免第三方进行同步调用时的死锁问题。
但并不是所有情况下都一定要使用 ConfigureAwait,如果 await后的代码依赖于原始上下文的数据,则应当避免使用 ConfigureAwait,因为每个异步方法的上下文都是独立的,例如在 ASP.NET中如果在使用 ConfigureAwait后的代码调用 HttpContext.Current则获取不到数据,因为这是存在于原始线程的数据:
public string Get()
{
//使用Task.Result阻塞异步代码,同步返回结果
return GetStringAsync().Result;
}
private async Task<string> GetStringAsync()
{
//配置ConfigureAwait(false),当等待完成时不必须使用原始上下文执行此方法的剩余部分
await Task.Delay(1000).ConfigureAwait(false);
//所得到的httpContext为null
var httpContext = HttpContext.Current;
return "Successful";
}
幸运的是,大部分情况下异步代码都不需要原始上下文中的数据,如果在某些情况下需要使原始线程上下文的数据流向配置了 ConfigureAwait后的代码,可以通过 AsyncLocal等进行线程间的数据同步,具体操作可以查看我的另一篇文章ThreadStaticAttribute vs ThreadLocal vs AsyncLocal。
结束语
异步是一个很棒的功能,请记住没有线程在等待,也没有任何魔法。