c#: 异步代码是如何解决高并发问题的?async/await、Task、IOCP/epoll

   日期:2024-12-27    作者:cosmetickorea 移动:http://oml01z.riyuangf.com/mobile/quote/60621.html

环境

  • window 10
  • centos 8.2
  • .netframework
  • .netcore

参考
《MSDN:异步编程模型 (APM)》
《MSDN:异步编程模式》
《MSDN:I/o 完成端口》
《Linux下的I/O复用与epoll详解》
《浅谈async、await关键字 => 深谈async、await关键字》
《重新认识 async/await 语法糖》

和同步相反,指在处理任务时可以不用等待任务结果而继续向后执行,这样就能减少等待耗费的时间,从而达到高效利用服务器资源的效果。

异步的目的:合理规划任务的执行顺序,避免不必要的任务等待,减少服务器资源消耗,从而在服务器性能以及用户的交互体验上达到满意的效果。

多线程和异步的区别
多线程是异步的主要实现方式,事实上几乎所有的异步都有多线程的影子。但异步是从任务执行效果的角度看的,而多线程是现代计算机的一个基础功能。

同步的问题根源:阻塞当前线程、占用CPU和内存资源。

  • 多个代码块的串行:将代码块并行

     
  • winform界面的按钮事件:耗时代码采用异步调用

     

如果传统web服务器对网卡的读写采用同步调用的话,在高并发下很容易卡死,同样,如果我们程序中同步读写数据库,在高并发下也很容易卡死。

linux中的epoll(事件-响应)模型就是为了解决这种问题出现的。在window中叫做IOCP(I/O Completion Ports,也叫作完成端口)。

下面用一个场景类比epoll和IOCP的处理模型(epoll和IOCP在原理上是相同的)。

场景是这样的

有一个小镇,里面的人们要去镇外采购食物(表示:客户端向服务器发起请求,需要经过一条不宽的河流(表示:服务器的CPU、内存等资源有限,河流的管理者负责组织船只运输往返的人们(表示:CPU在时间切片内调度线程,每一个船只表示一个线程)。

改革后的方案也称之为epoll模型(linux,当然在window中叫做IOCP。

通过上面的故事,我们知道了epoll的机制,显然它是高并发下最有效的请求响应模型。
然而,当epoll出现之前,linux为了解决高并发问题还制定了select和poll模型,不过它们两个都有着很明显的缺点,可以认为它们是残次品吧。
但是,在并发程度不高的情况下,select和poll的性能和epoll也不相上下,所以nginx也是支持select和poll的)。

IOCP的原理和epoll一致。不过仍有些不同

在.net framewok时代,线程池中的线程被分为两类,和。两者的区别是 “完成端口线程专门为I/O设备读写完成后回调使用的。”

对于为什么要把线程人为的划分为两类,我还没找到准确的答案,大概是当时的设计者担心线程池内的线程被用光而导致没有线程用于回调,进而导致无限等待了吧。。。

在.net core时代,设计者有意弱化IOCP的概念,即:不想再区分和了。所以在.net framework中的I/O回调代码中,我们能明显看到可用数量的减少,但是在.net core中同样的I/O回调代码下,我们仅能观察到的可用数量减少,而的可用数量始终保持不变。

另外,当我们代码中强制调用线程池中的时(.net core,window下能观察到的可用数量减少,但是在linux下抛出异常,测试代码如下

 
 

centos下运行报错

 

那么,IOCP的概念究竟应不应该去掉呢?有没有发挥到它的作用呢
我认为:IOCP可以去掉,发挥的作用有限。

试想一下,I/O设备回调用的是,如果回调中再发起I/O读写,那么回调就又使用,这样下去,我们只要发起一次回调,之后的代码都将运行在上了。。。
听起来很恐怖的样子,不过我没有试验,不知道真正效果如何。

另外,epoll中也没有专门给I/O回调配置线程不也运行的挺好的嘛。

实际上,我认为,只要代码中涉及到I/O读写的代码调用处都使用异步的话应对高并发就没有问题了。不用再去区分什么和了,反正代码运行的都很快,但凡慢一点的都交给I/O回调了,不用担心I/O回调时没有线程可用。

异步的作用是将串行的代码改成并行的,当代码并行后,当前线程是释放回线程池还是阻塞以等待异步执行完毕,可以将异步作用分为两个

  • 异步后,当前线程不发生阻塞,释放回线程池,这适合高并发编程
  • 异步后,当前线程需要等待异步执行完毕,在并发不高的情况下,这没什么问题
  • 在.net 1.0的时候提出了

    以IAsyncResult接口为核心,支持异步的接口命名规则为: BeginXXXX、EndXXXX

  • 在net 2.0的时候提出了

    支持异步的方法以XXXXAsync命名,外加XXXXCompleted事件

  • 在.net 4.0的时候提出了

    是我们现在常用的task模式,await/async task

1. 异步编程模型(APM)

当我们调用BeginXXXX异步调用时,虽然没有立刻返回结果,但是给了我们一个信物(就是,凭借这个信物,我们可以轮训是否异步完成,或者直接阻塞当前线程以等待异步结束)。

下面是模拟的APM模式代码

 
 

2. 基于事件实现的异步编程(EAP)

根据上面介绍,使用APM方式已经可以实现异步编程了,但是使用起来比较麻烦,因为要想不阻塞当前线程就必须利用回调,而回调后会发生线程切换,这对Winform线程来说是致命的(默认,winform中只允许UI线程访问控件)。
所以,基于事件的异步编程应运而生,它不仅简化了异步代码的编写还能让回调代码运行在主线程中。
典型的组件示例是,新建一个winform工程,在窗体上拖拽一个Lable标签、一个PictureBox控件、一个Button按钮,代码如下

 
 

除了PictureBox控件,最能代表EAP模型的还有组件,它的示例MSDN上已经有详细的介绍,请参照:《MSDN:BackgroundWorker 类》

3. 基于任务实现的异步编程(TAP)

虽然有了APM和EPM两种异步模型,但在高并发时编写代码还是有难度

  • 像APM一样多层嵌套回调会形成回调地狱,不好看也不易阅读
  • EPM的事件触发调用并不适合高并发场景

从上面的分析中,我们知道:具有async/await修饰的Task代码块会被自动切割,这里就来看一下它是怎么被切割的。

以下面代码示例

 
 

为了能更好的阅读,我们将代码拷贝到VS中,剔除掉代码中的<>符号并修复掉其他报错信息,最终如下

 

这里可以自行调试运行一下,观察下运行的顺序就明白了vs是怎么将切分的代码块按顺序运行的。
主要逻辑如下

  • 第一步:创建状态机,继承IAsyncStateMachine,设置其状态为-1
  • 第二步:执行状态机的MoveNext()方法,因为状态为-1,所以执行了切割的第一块代码,并使用Task开启了第二块代码的调用
  • 第三步:第二块代码调用完成后,状态机构造器设置状态机状态为0,并调用状态机的MoveNext()方法
  • 第四步:执行状态机的MoveNext()方法,因为状态为0,所以使用Task开启了第三块代码的调用
  • 第五步:第三块代码调用完成后,状态机构造器设置状态机状态为1,并调用状态机的MoveNext()方法
  • 第六步:执行状态机的MoveNext()方法,因为状态为1,所以使用Task开启了第四块代码的调用
  • 第七步:第四块代码调用完成后,状态机构造器设置状态机状态为2,并调用状态机的MoveNext()方法
  • 第八步:执行状态机的MoveNext()方法,因为状态为2,所以直接执行了第五块代码

从分析过程可以看出,vs对async/await标记的方法生成了状态机,并按照await位置将方法中的代码块切割,最后使用状态机的回调机制按顺序执行代码,达到了最终的效果。

如果让我们自己优化上面的代码,我们会怎么生成呢
最简单的写法如下

 
 

在书写异步代码时,我们经常看到如下代码
await httpClient.GetAsync(“https://www.baidu.com”).ConfigureAwait(false);
这里的的作用是什么呢

:告诉task运行时不捕获当前的上下文。也可以简单的理解为:告诉Task调度程序,后面的代码没必要在原来的线程上运行。这就等于解放了Task调度器的手脚,Task调度时将更加迅速,因为调度器没必要等待原有线程是否空闲。

以下面代码分别在.net core3.1和.net framework平台下运行的效果说明其差别

 
 
 

关于的更多解释:《理解C#中的ExecutionContext vs SynchronizationContext》


特别提示:本信息由相关用户自行提供,真实性未证实,仅供参考。请谨慎采用,风险自负。


举报收藏 0评论 0
0相关评论
相关最新动态
推荐最新动态
点击排行
{
网站首页  |  关于我们  |  联系方式  |  使用协议  |  隐私政策  |  版权隐私  |  网站地图  |  排名推广  |  广告服务  |  积分换礼  |  网站留言  |  RSS订阅  |  违规举报  |  鄂ICP备2020018471号