.NET 实现并行的几种方式(四)
本随笔续接:.NET 实现并行的几种方式(三)
八、await、async - 异步方法的秘密武器
1) 使用async修饰符 和 await运算符 轻易实现异步方法
前三篇随笔已经介绍了多种方式、利用多线程、充分利用多核心CPU以提高运行效率。但是以前的方式在WebAPI和GUI系统上、
使用起来还是有些繁琐,尤其是在需要上下文的情况下。而await/async就是在这样的情况下应运而生,并且它可以在理论上让CPU跑到100%。
async修饰符:它用以修饰方法、lambda表达式、匿名方法,以标记方法为异步方法。异步方法必须遵循的规范如下:
1、返回值仅且仅有三种: void、Task、Task<T>.
2、方法参数不可以使用 ref、out类型参数。
await运算符:它用以标记一个系统可在其上恢复执行的挂起点。该运算符会告诉computer不会再往下继续执行该方法、直到等待的异步方法执行完毕为止。同时会将程序的控制权return给其调用者。await表达式不阻止正在执行它的线程。 而是让编译器将异步方法剩余部分注册为等待任务的延续任务。 当等待任务完成时,它会调用其延续任务,如同在挂起点上恢复执行。
2)简单Demo
// Three things to note in the signature: // - The method has an async modifier. // - The return type is Task or Task<T>. (See "Return Types" section.) // Here, it is Task<int> because the return statement returns an integer. // - The method name ends in "Async." async Task<int> AccessTheWebAsync() { // You need to add a reference to System.Net.Http to declare client. HttpClient client = new HttpClient(); // GetStringAsync returns a Task<string>. That means that when you await the // task you'll get a string (urlContents). Task<string> getStringTask = client.GetStringAsync("http://msdn.microsoft.com"); // You can do work here that doesn't rely on the string from GetStringAsync. DoIndependentWork(); // The await operator suspends AccessTheWebAsync. // - AccessTheWebAsync can't continue until getStringTask is complete. // - Meanwhile, control returns to the caller of AccessTheWebAsync. // - Control resumes here when getStringTask is complete. // - The await operator then retrieves the string result from getStringTask. string urlContents = await getStringTask; // The return statement specifies an integer result. // Any methods that are awaiting AccessTheWebAsync retrieve the length value. return urlContents.Length; }
3)直观的顺序图
该图出自: https://msdn.microsoft.com/zh-cn/library/mt674882.aspx
4) await async编程最佳做法
1、异步方法尽量少用 void类型返回值、替代方案 使用Task类型,特例:异步事件处理函数使用void类型
原因1、async void 无法使用try ... catch进行异常捕获,它的异常会在上下文中引发。捕获该种异常的方式为在GUI或web系统中使用
AppDomain.UnhandledException 进行全局异常捕获。对于需要进队异常进行处理的地方、这将是个灾难。
原因2、async void 方法、不可以“方便”的知道其什么时候完成,这对于超过50%的异步方法而言、将是灭顶之灾。而 async Task
可以配合 await、await Task.WhenAny、await Task.WhenAll、await Task.Delay、await Task.Yield 方便的进行后续的任务处理工作。
特例、因为事件本身是不需要返回值的,并且事件的异常也会在上下文中引发、这是合理的。所以异步的事件处理函数使用void类型。
2、推荐一直使用async,而不要混合使用阻塞和异步(async)避免死锁, 特例:Main方法
使用混合编程的死锁demo
public static class DeadlockDemo { private static async Task DelayAsync() { await Task.Delay(1000); } // This method causes a deadlock when called in a GUI or ASP.NET context. public static void Test() { // Start the delay. var delayTask = DelayAsync(); // Wait for the delay to complete. delayTask.Wait(); } }
当在GUI或者web上执行(具有上下文的环境中),会导致死锁。这种死锁的根本原因是 await 处理上下文的方式。 默认情况下,当等待未完成的 Task 时,会捕获当前“上下文”,在 Task 完成时使用该上下文恢复方法的执行。 此“上下文”是当前 SynchronizationContext(除非它是 null,这种情况下则为当前 TaskScheduler)。 GUI 和 ASP.NET 应用程序具有 SynchronizationContext,它每次仅允许一个代码区块运行。 当 await 完成时,它会尝试在捕获的上下文中执行 async 方法的剩余部分。 但是该上下文已含有一个线程,该线程在(同步)等待 async 方法完成。 它们相互等待对方,从而导致死锁。
特例:Main方法是不可用async修饰符进行修饰的(编译不通过)。
执行以下操作… | 阻塞式操作… | async的替换操作 |
检索后台任务的结果 | Task.Wait 或 Task.Result | await |
等待任何任务完成 | Task.WaitAny | await Task.WhenAny |
检索多个任务的结果 | Task.WaitAll | await Task.WhenAll |
等待一段时间 | Thread.Sleep | await Task.Delay |
3、如果可以,请用ConfigureAwait 忽略上下文
上文也说过了,当异步任务完成后、它会尝试在之前的上下文环境中恢复执行。这样带来的问题是时间片会被切分成更多、造成更多的线程调度上的性能损耗。
一旦时间片被切分的过多、尤其是在GUI和Web具有上下文环境中运行,影响会更大。
另外,使用ConfigureAwait忽略上下文后、可避免死锁。 因为当等待完成时,它会尝试在线程池上下文中执行 async 方法的剩余部分,不会存在线程等待。
5)疑问:关于 await的使用次数 和 使用的线程数量 之间的关系
使用一个await运算符,就一定会使用一个新的线程吗? 答案:不是的。
前文已经介绍过,await运算符是依赖Task完成异步的、并且将后续代码至于Task的延续任务之中(这一点是编译器搞得怪、生成了大量的模板代码来实现该功能)。
因此,编译器以await为分割点,将前一部分的等待任务和后一部分的延续任务分割到两个线程之中。
前一部分的等待任务:该部分是Task依赖调度器(TaskScheduler)、从线程池中分配的工作线程。
而后一部分的延续任务:该部分所运行的线程取决于两点:第一点,Task等待任务在运行之前捕获的上下文环境,第二点:是否使用ConfigureAwait (false)
忽略了之前捕获的上下文。如果没有忽略上下文并且之前捕获的上下文环境为:SynchronizationContext(即 GUI UI线程 或 Web中具有HttpContext的线程环境)
则 延续任务继续在 SynchronizationContext 上下文环境中运行,否则 将使用调度器(TaskScheduler)从线程池中获取线程来运行。
另外注意:调度器从线程池中获取的线程、并不一定是新的,即使在循环连续使用多次(如果任务很快完成),那么也有可能多次都使用同一个线程。
测试demo:
/// <summary> /// 在循环中使用await, 观察使用的线程数量 /// </summary> /// <returns></returns> public async Task ForMethodAsync() { // 休眠 // await Task.Delay(TimeSpan.FromMilliseconds(100)).ConfigureAwait(false); // await Task.Delay(TimeSpan.FromMilliseconds(100)); for (int i = 0; i < 5; i++) { await Task.Run(() => { // 打印线程id PrintThreadInfo("ForMethodAsync", i.ToString()); }); } }
上述demo在运行多次后,可能会得到上述结果:5次循环使用的是同一个线程,线程id为16,UI线程id为10。
结论:await的使用次数 大于 使用的线程数量,也有可能、多次使用await 只会 使用一个线程。
6)await/async 的缺点
1、由于编译在搞怪、会生成大量的模板代码、使得单个异步方法 比 单个同步方法 运行得要慢,与之相对应的获取到的性能优势是、充分利用了多核心CPU,提高了任务并发量。
2、掩盖了线程调度、使得系统开发人员无意识的忽略了该方面的性能损耗。
3、如果使用不当,容易造成死锁
附,Demo : https://files.cnblogs.com/files/08shiyan/ParallelDemo.zip
参见更多:随笔导读:同步与异步
(未完待续...)