近些年,.NET增加了很多新特性,其中令人兴奋的一个特性就是异步编程,因为.NET的异步编程模型把复杂的异步编程变得简单易用,使得开发人员可以轻松开发出更高性能的应用程序。
需要注意的是,在异步编程中有一个”同步上下文”(SynchronizationContext)的概念,不同类型的框架有不同类型的同步上下文,不同类型的同步上下文的异步代码的行为也不同。
同样,有的框架还不存在同步上下文,它们的行为也会不同。
在WinForms和WPF中是存在同步上下文的,但是在ASP.NET Core和控制台项目中,则是不存在同步上下文的。
1. 为什么要用异步编程
大家都有过到餐馆点餐的经历,有的餐馆的服务员在客人翻看菜单点菜餐的时候,一直在桌子旁边等待。
客人点一道菜,他们就记一下,一直到客人点完了,他们才带着记录好的点菜单离开。
如果餐馆客人比较多而服务员比较少的话,就可能会造成很多客人等待点餐而服务员忙不过来的情况。
我们称这种方式为”同步点餐”。
而有的餐馆则是客人来了之后,服务员把菜单和点菜单给客人留下,然后就招待别的客人去了。
这桌的客人自己翻看菜单和写点菜单,写完之后再把点菜单给服务员,服务员再把点菜单传给后厨去备菜。
我们称这种方式为”异步点餐”。
在这种点餐方式下,服务员不用一直等待客人点餐,在客人翻看菜单的时候他们可以去招待别的客人,因此服务员可以同时服务更多的客人。
“异步点餐”的优点是服务员可以同时服务更多的客人;缺点是单个客人点餐的时间变长。
因为在”同步点餐”模式下,点餐的时候服务员一直在客人的旁边等待,客人点完餐后可以立即把点菜单给服务员。
而在”异步点餐”模式下,客人在写完点菜单后再呼叫服务员,这时服务员可能正在招呼别的客人,服务员可能会说”稍等一下”,也许要过上一两分钟才能过来拿点菜单,这样单个客人单次点餐的时间就变长了。
“异步点餐”可以让服务员同时服务更多客人,但是不会使得服务单个客人的时间变短,甚至有的情况下还可能变长。
对于网站也有类似的情况。比如:我们需要实现一个对用户上传的图片进行美化的功能
internal void BeautifuPic(File photo,Response response)
{
byte[] bytes = 美化图片(photo);
response.write(bytes);
}
“美化图片”是一个将给定的参数指向的图片进行美化的方法,这个方法会把美化之后的图片内容以byte[]返回。
由于”美化图片”方法里有很复杂的图片美化逻辑,它需要3s才能处理完一张图片,那么当Web服务器接收到客户端上传的一张图片之后,程序就会把这张图片转给BeautifyPic方法,等图片美化完毕后,BeautiftPic方法再把美化后的内容返回给客户端。
由于Web服务器能够同时”接待”的请求数量是有上限的,而有很多请求都是在”等待美化图片方法执行完成”,因此Web服务器可能只能同时接待500个请求。
接下来,用异步编程改造上面的伪代码
internal void BeautifuPic(File photo,Response response)
{
美化图片(photo,bytes=> response.write(bytes));
}
上面代码的意思是:开始美化图片的任务时,方法就执行结束了,其实并不会等待图片美化任务结束。
在图片美化任务执行完毕后,response.Write(bytes)代码才会被调用,从而让Web服务器的线程把bytes返回给客户端。
在图片美化期间,负责接待我们的Web服务器的线程就可以去处理别的请求了,这样Web服务器可能同时接待2000个请求。
对照”异步点餐”的例子,异步编程的优点就是可以提高服务器接待请求的数量,但不会使得单个请求的处理效率变高,甚至有可能略有降低。
2. 轻松上手await、async
微软很早就在.NET Framework中加入了异步编程的技术,但是由于很多类不支持异步执行,而且异步调用的代码也很复杂,因此异步编程技术在.NET中一直没有流行起来。
直到.NET4.5中很多类开始支持异步执行,并且C#中引入了async和await关键字,这些让.NET中的异步编程变得非常简单,异步编程才在.NET开发中流行起来,并且在.NET Core中成为主流用法。
用async关键字修饰方法后,这个方法就成了”异步方法”。
异步方法有如下几点需要注意:
- 异步方法的返回值一般是Task<T>泛型类型,其中的T是真正的返回值类型,比如方法想要返回int类型,返回值就要写成Task<int>。Task类型定义在System.Threading.Tasks命名空间下。
- 按照约定,异步方法的名字以Async结尾,虽然这不是语法的强制要求,但是方法以Async结尾可以让开发人员一眼就看出来它是异步方法(当然全部用 异步就不用遵守了)
- 如果异步方法没有返回值,可以把返回值声明为void,这在语法上是成立的。但这样的代码在使用的时候有很多的问题,而且很多框架都要求异步方法的返回值不能为为void,因此即使方法没有返回值,也最好把返回值声明为非泛型的Task类型。
- 调用泛型方法的时候,一般在方法前加上await关键字,这样方法调用的返回值就是泛型指定的T类型的值。
- 一个方法中如果有await调用,这个方法也必须修饰为async,因此可以说异步方法是有传染性的。
对上面提到需要注意的点进行演示
//不使用顶级语句
static async Task Main(string[] args)
{
await Console.Out.WriteLineAsync("before write file");
await File.WriteAllTextAsync("D:\\1.txt", "fxxxk async");
await Console.Out.WriteLineAsync("before read file");
string a = await File.ReadAllTextAsync("D:\\1.txt");
await Console.Out.WriteLineAsync(a);
}
C#9.0中新增了顶级语句允许直接在入口代码中使用await关键字,如果在不使用顶级语句的项目中,我们需要用async修饰Main方法,然后把方法的返回值修改为Task类型。
在调用异步方法的时候,除非我们真的不想等异步方法结束就继续,否则一定要使用await关键字。
总结:在调用异步方法的时候,一般都要加上await关键字。一个方法中如果有await关键字,则该方法也必须修饰为async。
await关键字让我们可以用类似于同步的方法调用异步方法,从而简化异步编程。
3. 如何编写异步方法
我们可以编写一个自定义的异步方法。
下面封装一个方法,这个方法会向指定网页发出HTTP请求,并且把响应报文体写入文件,然后把响应报文体的字符数作为方法的返回值。
//不使用顶级语句
static async Task Main(string[] args)
{
await Console.Out.WriteLineAsync("开始ICHIBLOG首页");
int il = await downLoadAsync("https://ichistudio.cn","d:/ichi.html");
await Console.Out.WriteLineAsync($"下载完成,长度{il}");
}
static async Task<int> downLoadAsync(string url,string destFilePath)
{
//跳过ssl验证
var handler = new HttpClientHandler();
handler.ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator;
using HttpClient? client = new (handler);
string body = await client.GetStringAsync(url);
await File.WriteAllTextAsync(destFilePath, body);
return body.Length;
}
需要说明的是,这里用new HttpClient()创建HttpClient类的对象不是推荐的做法,推荐的做法是使用HttpClientFactory,不过这会涉及”依赖注入”技术。为了简化问题,这里暂时用new HttpClient()创建HttpClient类对象。
4. 异步方法不等于多线程
很多开发人员对异步方法有一个非常大的误解,那就是异步方法中的代码一定是在新线程中执行的。
很多开发人员把异步方法和多线程画上了等号。
其实异步方法中的代码并不会自动在新线程中执行,除非把代码放到新线程中执行。
那么如何让代码在新线程中执行呢?常用的就是调用Task.Run方法,我们可以把要执行的代码以委托的形式传递给Task.Run,这样系统就会从线程池中取出一个线程执行我们的代码了。
Task.Run也可以执行有返回值的代码块,它会把代码块的返回值直接作为Run方法的返回值
用Task.Run方法执行代码
public async Task<decimal> CalcAsync(int m)
{
await Console.Out.WriteLineAsync("CalcAsync"+Thread.CurrentThread.ManagedThreadId);
return await Task.Run(async () =>
{
await Console.Out.WriteLineAsync("Task.Run:"+Thread.CurrentThread.ManagedThreadId);
decimal result = 1;
Random rand = new();
for (int i = 0; i < m; i++)
{
result = result+(decimal)rand.NextDouble();
}
return result;
});
}
把代码放到新线程中执行可以用Task.Run方法,也可以用Task.Factory.StartNew方法。
Task.Factory.StartNew方法提供的参数更多,可以对执行的线程做更精细化的控制,其实Task.Run就是对Task.Factory.StartNew方法的封装而已。
5. 为什么有的异步方法没有async
对于async方法,编译器会把代码根据await调用分成若干片段,然后对不同片段采用状态机的方式切换执行。
不过这个语法糖有时候反而是一个负担,这时我们就可以编写不用async修饰的异步方法。
省流:
如果一个异步方法只是对别的异步方法进行简单的调用,并没有太多复杂的逻辑,比如获取异步方法的返回值后再做进一步处理,就可以去掉async、await关键字。
6. 异步编程的几个重要问题
在使用异步编程的时候,还需要注意以下问题。
(1).NET Core 的类库已经全面拥抱异步了,大部分耗时的操作都提供了异步方法。有的类为了兼容旧API,也提供了非异步的方法。建议开发人员使用异步方法,因为这样能提升系统的并发处理能力。
(2)如果由于框架的限制,我们编写的方法不能标注为async,那么在这个方法中就不能使用await调用异步方法。对于返回值为Task<T>类型对象的异步方法,可以在Task<T>类型对象上调用Result属性或者GetAwaiter().GetResult来等待异步执行结束获取返回值;对于返回值为Task类型对象的异步方法,也可以在Task类型对象上调用Wait方法来调用异步方法并且等待任务执行结束
string s1 = File.ReadAllTextAsync("d:/1.txt").Result;
string s2 = File.ReadAllTextAsync("d:/2.txt").GetAwaiter().GetResult();
File.WriteAllTextAsync("d:/1.txt", "hello").Wait();
不过,这样的调用方式不推荐使用,因为这样会阻塞调用线程,导致系统的并发处理能力下降,甚至会引起程序死锁。
在.NET Framework时代,由于历史遗留问题,还是有一些地方不能使用async,但是在.NET Core时代,我们很少会遇到这种情况,因此请大家统一用await调用异步方法。
(3)异步暂停的方法。如果想在异步方法中暂停一段时间再继续执行,那么不要用Thread.Sleep,因为它会阻塞调用线程,要使用await Task.Delay。
using HttpClient client = new HttpClient();
string s1 = await client.GetStringAsync("https://www.ptpress.com.cn");
await Task.Delay(3000);
string s2 = await client.GetStringAsync($"https://www.rymooc.com");
(4)读者可能注意到,.NET Core的很多异步方法中都有一个CancellationToken类型的参数,我们可以通过CancellationToken类型的对象让异步方法提前终止。比如在用ASP.NET Core开发的网站中,一个操作比较耗时,如果我们希望用户能提前终止这个操作,就可以通过CancellationToken对象进行。
(5)可以使用Task.WhenAll同时等待多个Task的执行结束
using HttpClient client = new HttpClient();
Task<string> s1 = client.GetStringAsync("https://www.ptpress.com.cn");
Task<string> s2 = client.GetStringAsync($"https://www.rymooc.com");
string[] results = await Task.WhenAll(s1, s2);
string z1 = results[0];
string z2 = results[1];
这种用法可以应用到如下的场景下:有一个任务需要拆分成多个子任务,然后放到多个线程中执行,并且在所有子任务执行完毕后,再进行汇总处理。
.NET 中还提供了一个Task.WhenAny方法用于等待多个任务,只要其中任何一个任务执行完成,代码就会继续向下执行。
(6)由于async用于提示编译器为异步方法中的await代码进行分段处理,而且一个异步方法是否用async修饰对于方法的调用者来说是没有区别的,因此对于接口中的方法或者抽象方法是不能修饰为async的,但是这些方法仍然可以把返回值设置为Task类型,在实现类中再根据需要为实现方法添加async关键字的修饰。