关于 C# 异步的总结和注意事项

这是一篇原创文章。在花许多时间浏览了 Google 上关于异步的说明之后得出了一些总结。网络上有不少关于 .NET 异步的文章,这里尝试以简洁的语言来呈现,因此几乎不包含代码和实例。Java 和其它语言也包含异步,但这里主要涉及 .NET 。

关键词 C# 异步 线程 死锁

概念

异步(Asynchronous)和同步(Synchronous)相比,在名称上只是增加了一个 A 字母。

死锁(Deadlock),死,没有活路的,没有出口的。锁,锁住的,不能操作的。死锁就是不能继续操作的状态,而且这种状态是没有解除的可能的。它有一个更中国化的翻译,叫「僵局」。我等你完成,你等我完成,无法继续下去。

同步上下文(SynchronizationContext),一种用于跨线程操作的上下文。如同一个交接地点,其它线程可以把方法交到此处,然后委托当前线程(通常是 UI 线程)执行这些方法,也可以理解为从其它线程暂时切换到当前线程(不使用委托直接跨线程操作 UI 控件是不允许的)

UI 线程在实例化控件的时候自动创建同步上下文,其它场合需要使用 SetSynchronizationContext() 方法手动创建。使用同步的 Send() 或异步的 Post() 方法可以把其它线程的方法委托给 UI 线程处理。使用 await/async 时已经在 await 关键字内部自动通过封装后续代码作为委托至 SynchronizationContext 解决了跨线程访问的问题,因此通常不需要开发人员额外处理。

线程阻塞(Thread block)指某个耗时的方法占用了线程,以至于后面的代码只能傻傻地等它完成。

异步有 3 种模式,早在 .NET Framework 1.x 就已经存在的异步编程模型(Asynchronous Programming Model, APM),基于事件的异步模式(Event-based Asynchronous Pattern, EAP)和在 .NET Framework 4 开始的基于任务的异步模式(Task-based Asynchronous Pattern, TAP)。现在一般用 TAP,特征在于使用 Task 类。

作用

异步用于防止线程阻塞,常出现在 UI 线程中,主要用于占用读写资源的(I/O-bound)操作和等待操作,这被称作自然异步(Naturally Asynchronous)或纯异步(Pure Asynchronous)。

它也可用于占用 CPU 资源的(CPU-bound)操作,这样即可在后台繁忙计算的同时做点别的事情。但它不会减少资源消耗,反而增加了一点多线程成本,取决于你的权衡——响应能力,还是内存占用。

命名

按照约定俗成的规则,异步方法的名字要以 Async 结尾,比如HttpClient.GetStringAsync()

APM 异步模式要求一对方法,开头分别以 BeginEnd 结尾,比如 HttpWebRequest.BeginGetResponse() 和 HttpWebRequest.EndGetResponse()

用法

在基于任务的异步模式中,异步方法是任务,它们的返回值都是 TaskTask<TResult>

调用一个异步方法时,通常在前面加 await 关键字。当然,你也可以在一开始声明一个 Task 变量,并获取异步方法返回的 Task 值(异步方法其实是一个返回 Task 类型的值的方法)。请注意,这个 Task 在返回的同时已经自动开始,称为热(Hot)任务,不要再使用它的 Start() 方法。

另外一种情况是使用 System.Threading.Tasks.Task 函数创建一个 Task ,这种方式的任务称为冷(Cold)任务,它不会在被创建时自动开始,需要手动使用 Task.Start() 方法。

在使用 await 之前,任务被创建之后,任务可能已经开始甚至完成await 表示如果此时任务没有完成,就把控制权先交还给调用方(比如 UI 线程),然后异步方法在这一步等待任务完成。如果任务完成了,就会自动通过回调函数继续后面的代码。

async 修饰符唯一的作用是声明这个方法内部可能(但不一定)有 await。要使用 await ,所在的方法必须要用 async 修饰符。

await 不同,使用异步方法的 Wait() (不需要返回值仅单纯等待)或 Result() (等待并获取返回值)方法。

把普通方法变为异步方法,需要添加 async 修饰符,并且如果是不返回值的 void 方法,把 void 修改为 Task;需要返回值的方法,把返回值类型修改为 Task<返回值类型>,比如 Task<string> 

给任务增加取消标识(CancelToken)可以中途终止任务,此时任务的 TaskStatus 属性为 Cancelled ,且 Result 中没有结果。

在异步方法内部调用异步实现的方法时前面加 await 。比如 await client.GetStringAsync(url);

在不需要切换回原线程的场合(比如更新进度条必须切换回 UI 线程),建议加上 ConfigureAwait(false),变成  await client.GetStringAsync(url).ConfigureAwait(false);  阻止封装回调用方的同步上下文造成死锁(调用方等待异步方法完成,异步方法等待调用方所在线程的上下文完成,上下文完成需要等待调用方完成,参见注意事项的死锁部分)。

注意事项

异步方法内部应该尽可能全程调用异步,即便某些步骤可能能够迅速完成。并且尽可能把每一个步骤分割得更细,使其能够尽可能快地完成,一方面增加响应性(比如突然从 0% 增加到 100% 的进度条和根据实际进度逐渐增加的进度条相比),另一方面也可以充分发挥异步并发的优势。

等待不是大量消耗 CPU 的操作,按照异步思想,应该使用异步的 Thread.Delay() 而不是 Thread.Sleep()。后者会阻塞线程。

对于不需要返回值的方法,不要使用 async void 而使用 async Task,前者可以通过编译,但仅出于对早期代码的兼容性的考虑而保留。

异步方法内必须有 await ,否则编译器警告。能通过编译,但此方法将一直驻留在内存中。

异步是步骤上的异步而非线程上的异步,换句话说异步不等于多线程,异步不是指在另一个线程上同时执行,甚至绝大部分都不需要消耗线程资源,但是异步在结束的时候需要把结果放到线程池的队列中消耗一个线程处理。异步是目的,而多线程是实现的途径之一,异步操作也可能是在同一线程上。使用 await/async 调用异步方法时,默认在当前线程运行,并且会适时委托操作系统处理。Task.Run() 会显式(强制)地让方法在独立线程运行。

不要在异步方法内部使用 return await Task.Run(() => 某个同步方法) ,这意味着新的多线程资源的开销,以及阻塞了当前整个线程,异步方法的外表下实际上仍然是同步方法,违背了异步的初衷。

另外,尽可能不要把异步方法包装为同步方法,它可能导致一系列问题包括死锁。

你以外的开发人员并不知道这个从名字上看起来是同步(没有 Async 结尾)的方法实质上是一个异步方法,因此在需要异步的场合使用了 Task.Run() 调用(参见下方在同步方法中使用异步方法的正确方式),而被调用的同步方法阻塞了分配给它的线程。对于线程较少的设备,如果过度地调用,线程池的所有线程都将会被这些同步方法阻塞,以至于没有线程处理它们的结果,需要处理结果则需要调用方(同步方法)结束以释放主线程,而调用方要想结束就需要这些异步方法完成,这些异步方法要想完成就要处理它们的结果,但现在没有线程处理它们,于是形成死锁。

另一种情况是上面提到的。在主线程中调用了同步方法封装,同步方法封装调用并等待异步方法完成。异步方法完成尝试进入调用方线程的上下文,但是此时调用方线程正停留在调用同步方法的地方,要想执行上下文的委托必须先结束调用同步方法,然而同步方法又在等待异步方法完成……形成死锁。

最好的,最安全的方式是对异步方法版本全程使用异步,同步方法版本全程使用同步。

如果必须在同步方法内使用异步方法,应当在实际设备上多次尝试,确定一个可能的最小的线程数量,设定合适的线程池数量上限,以避免线程被全部占用的问题。

另外,应当在异步方法内所有 await 的方法后加上 ConfigureAwait(false) ,防止死锁和在某些场合提升性能,因为这样可以告诉异步方法不进入调用方线程的同步上下文(SynchronizationContext)而直接完成。即便在平时调用异步方法时也应该尽量这么做。

如果你不拥有异步方法(比如调用 Sqlite 类库的异步方法,一般开发人员无法修改其中的代码),则在主线程中直接使用 Task.Run() 并在其委托内部调用异步方法,如 var result = Task.Run(url => client.GetStringAsync(url)).Result; ,来让异步方法在独立线程中运行。上文中已经提到,只有 UI 线程会在控件创建时自动创建同步上下文,其它线程中默认不存在同步上下文,因此不会产生死锁。

参考链接

Microsoft Docs – 异步概述

ASP.NET WebBlog – Understanding C# async / await (3) Runtime Context by Dixin

MSDN Blog – Should I expose synchronous wrappers for asynchronous methods? by Stephen Toub – MSFT

Task.Run Etiquette Examples: Don’t Use Task.Run in the Implementation by Stephen Cleary

Task.Run Etiquette Examples: Don’t Use Task.Run for the Wrong Thing by Stephen Cleary

发表评论

电子邮件地址不会被公开。 必填项已用*标注