网络通信的基石:IO模型与零拷贝
中间件作为现代软件架构的基石,扮演着承上启下的关键角色,它不仅衔接了多样化的服务与系统,还极大地促进数据的流动与处理
而这一切高效运作的背后,网络通信是各大中间件中不可或缺的一环
如常见的WEB服务器(tomcat、jetty、undertow),数据库(MySQL、Redis),MQ...
它们都需要进行网络通信,那么如何才能高效的进行网络通信呢?
在聊这个话题前,我们需要先聊聊IO模型
什么是IO模型呢?
IO即输入/输出,IO模型的提出主要是解决计算机CPU在内存与磁盘/网卡等外部设备速度不匹配的问题
当CPU想要读取磁盘/网卡上的数据时,数据拷贝到内存是需要时间的,那这时CPU是去等待数据拷贝完成,还是先去执行其他任务呢?
举个简单的例子:
精通CRUD的小菜快速完成简单的CRUD,但还需要等待其他部门提供的接口,由于其他部门的业务比较复杂,接口文档可能要几天后才能给出,小菜想趁着这段时间休息一会
可是作为小菜上司的我可不乐意了,还有这么多开发任务呢,我再分一个任务给小菜做,让小菜不能空闲下来,等到后续其他部门的接口写好了,再通知小菜完成这个开发任务(提升小菜的“吞吐量”)
这个案例中小菜就是CPU,完成简单的CRUD可以看成在内存上操作(速度快),等待其他部门的接口可以看成等待外部设备把数据拷贝到内存(速度慢)
为了解决这个问题,我提出一种“IO模型”:让小菜先去干别的活,等其他部门的接口好了再通知小菜回来完成这个开发任务
步入正题,常见的IO模型分为五种:同步阻塞IO模型、同步非阻塞IO模型、多路复用IO模型、信号驱动IO模型、异步IO模型
处理流程
为了更好的理解IO模型,先举个体现总体流程的下载文件案例:
下载文件案例中,客户端先向服务端发送请求,而服务器收到请求后,读取磁盘中的文件数据,发送到网卡上再响应给客户端
在这个过程中,以服务端的视角可以看成先从磁盘中读取数据,再往网卡上写数据
由于磁盘、网卡属于外部设备,由于外部设备速度慢,不会使用CPU进行拷贝数据,而是通过DMA进行数据拷贝(这样就不需要占用CPU的资源)
操作系统分为用户态和内核态,其中应用程序处于用户态
由于操作系统中的重要资源不能被用户态的应用程序直接访问,需要先切换到内核态再进行访问
用传统的阻塞IO(BIO)举例,当服务端接收到请求后:
- 读取磁盘要先切换为内核态,然后由DMA进行拷贝(将磁盘上的数据拷贝到内核缓冲区),此时用户线程一直阻塞
- DMA拷贝完成后,由CPU将数据从内核缓冲区拷贝到用户态内存上的用户缓冲区,此时用户线程才被唤醒
- 状态切换为用户态后,用户线程从用户态中内存的缓冲区将数据拷贝到JVM的堆内存缓冲区中
- Java程序处理数据(已经读完),开始写数据,写数据的流程与读数据流程类似,只不过是相反的
图中的直接内存为用户态的内存,而堆内存为JVM的(属于JVM管理)
在这个过程中:用户线程一直阻塞,读数据与写数据存在大量重复拷贝、状态切换,都会导致性能被大大浪费
通过IO模型、零拷贝等优化方式能够优化这个过程,提升响应速度
IO模型
同步阻塞
在同步阻塞IO模型下,当用户线程请求读取外部设备的数据时,会一直阻塞直到数据拷贝完成,再去使用数据
具体流程如下:
- 用户线程发起系统调用请求读取外部设备数据(进入阻塞)
- 用户态切换为内核态准备数据:使用DMA将外部设备数据拷贝到内核缓冲区
- 内核进行数据拷贝:将内核缓冲区的数据拷贝到用户缓冲区
- 内核态切换为用户态,使用数据
阻塞指的是:在用户线程发起系统调用时,并没有立即返回,而是等到数据拷贝完成被唤醒再使用
同步指的是:在数据拷贝阶段,用户线程是阻塞等待数据完成拷贝的(也可以理解为同步是用户线程主动请求内核进行数据拷贝的)
在同步阻塞IO模型下,由于准备、拷贝数据阶段都会阻塞等待,因此性能并不理想
如果高并发的请求打进来,让等比例的线程数量来等待,资源开销是非常大的,因此现代中间件一般不会采取这种模型
同步非阻塞
同步非阻塞IO模型会频繁发起系统调用来判断数据是否已就绪,如果已就绪则同步阻塞进行拷贝
在这个过程中,准备数据阶段是通过轮询非阻塞的方式实现的,当响应数据就绪时,再发起系统调用同步阻塞进行数据拷贝
同步非阻塞IO模型虽然在数据准备的阶段不需要阻塞,但会通过轮询的方式一直进行系统调用,产生一定的开销
要求网络通信高效的中间件也不会使用这种模型
多路复用
在多路复用模型中,内核线程能够同时监听多个网络请求的通道
使用前,会将数据通道注册到select上,当使用select时会进行阻塞,直到select监听到数据通道上数据已就绪,此时再请求读取数据,使用read系统调用,同步阻塞直到拷贝完数据
在多路复用模型中实现还分为三种方式:select、poll、epoll
select就是上述举例流程,缺点是最多监听1024个数据通道,并且阻塞到数据就绪时需要遍历处理O(n)
poll在select基础上,动态调整只要内存够理论上无监听通道数量的上限,但数据就绪时还需要遍历处理
epoll使用事件回调的方式,当数据就绪时不需要再轮询,并且内核维护不再需要将数据拷贝到用户态
在多路复用模型中,由于一个内核线程可以监听多个数据通道,这样即使维护大量的网络数据通道,开销也不会太大
而且有epoll事件回调、不用拷贝的优化性能非常好,大部分的中间件都会选择多路复用模型实现网络通信
信号驱动
在信号驱动模型中,会先发送信号的系统调用(立即返回 非阻塞),当数据准备好后通知,再发送读数据的系统调用(阻塞),让内核完成拷贝数据
信号驱动避免准备数据时的阻塞,并且不需要轮询发起系统调用,但在数据拷贝时依旧需要同步阻塞
异步
在异步IO模型中,发起请求的系统调用时会携带回调函数,发起系统调用后立即返回(非阻塞)
当数据就绪后,不需要用户线程同步触发,而是由内核主动将数据拷贝到用户缓冲区
在异步IO(AIO)中完全没有阻塞也不再需要同步
在要求高效的高并发网络通信中,一般使用多路复用模型NIO和异步IO(AIO)
JDK中的NIO指的就是多路复用模型,而NIO2指的就是AIO,后续讲解中间件如何高效处理网络通信时都会出现它们的身影~
零拷贝
聊完IO模型后,我们能够知道使用NIO、AIO能够加快处理流程的速度
处理流程中还存在大量的CPU拷贝,在Linux内核逐步升级后,网卡支持的情况下还可以实现零拷贝
零拷贝指的是不再需要使用CPU进行数据拷贝,而是直接通过DMA进行拷贝
为什么无法从内核直接拷贝到JVM堆内存?
在传统的流程处理中,需要先从内核拷贝到直接内存,再从直接内存拷贝到JVM堆内存
既然直接内存和JVM堆内存都处于用户态的内存,为什么不从内核直接拷贝到JVM堆内存呢?
这是由于JVM堆内存会发生GC,可能改变位置,内核拷贝到堆内存时无法保证不会GC
而本地内存拷贝到JVM堆内存,HotSpot虚拟机保证不在安全点上,因此不会GC
JVM安全点相关知识感兴趣的同学可以查看这篇文章
总结
IO模型的提出是为了解决CPU在内存的速度与外部设备加载到内存速度的差异
在操作系统中为了安全使用系统资源,IO时会涉及到用户态、内核态的切换
IO阶段通常分为准备数据和拷贝数据,准备数据主要由DMA将外部设备数据拷贝到内核缓冲区,拷贝数据是将内核缓冲区拷贝到用户缓冲区
同步阻塞IO模型(BIO)发起系统调用后会阻塞到数据拷贝完成,不适合处理高并发网络通信的场景
同步非阻塞IO模型使用轮询的方式判断数据是否就绪,就绪再同步阻塞等待数据拷贝
信号驱动模型中数据就绪后通过信号通知应用发起系统调用读取数据,避免同步非阻塞下轮询的开销
多路复用IO模型使用select时,监听多个通道,select阻塞直到监听到通道上数据就绪,再通知应用进行读取发起同步阻塞直到数据拷贝结束
使用select,当多个通道数据同时就绪时,只能轮询处理,并且只能监听1024个通道;使用poll进行优化能够监听无上限通道数量
使用epoll 事件回调的方式避免轮询处理,并且内核维护不需要再进行数据拷贝
异步IO模型使用回调的方式避免数据就绪时同步阻塞进行数据拷贝,Linux下也是使用epoll模拟实现
当网卡支持时使用sendfile零拷贝可以避免大量CPU数据拷贝
最后(不要白嫖,一键三连求求拉~)
本篇文章被收入专栏 后端的网络基石,感兴趣的同学可以持续关注喔
本篇文章笔记以及案例被收入 Gitee-CaiCaiJava、 Github-CaiCaiJava,除此之外还有更多Java进阶相关知识,感兴趣的同学可以starred持续关注喔~
有什么问题可以在评论区交流,如果觉得菜菜写的不错,可以点赞、关注、收藏支持一下~
关注菜菜,分享更多技术干货,公众号:菜菜的后端私房菜