何为套路
【套路】一词,并非贬义,世间一切,皆有【规律】,而【规律】二字,以通俗化的方式表述即为【套路】,我们对事物进行发现并加以总结而成的经验,便是【套路】。
了解网络IO中的套路有什么用
网络IO,其作为网络通信重要的一环,在当下各类自研或开源的微服务框架的背景下,了解其原理,有助于更深入理解时下各类RPC框架的底层实现,同时,了解网络io中的套路,也是对学习各类以网络为媒介的中间件,如redis、nginx、mq等等的前提。
网络IO的分类
IO目前主要分为三类,分别为BIO(block io)、NIO(no/no block io)、AIO(async io)。
说说BIO里的套路
BIO作为一种同步阻塞IO,从服务端的角度看,其网络的阻塞性主要体现在接收(accept)阶段、read(读取)和write(写回)阶段上,当客户端没有请求到服务端时,服务器端将会因为调用了accept方法,而一直处于阻塞状态,直到有客户端连接进来后,才会往下执行接下来的逻辑,而当服务端调用read方法要读取客户端数据时,当客户端此时还未将数据准备好发送给服务端,将导致服务端再次进入阻塞状态,同理,在调用write写回数据也是如此。对于BIO来说,由于其接收和读写都会导致阻塞,这样便导致了在并发场景下的性能问题,也因此,在NIO未出现前,为了支持并发场景,使用了多线程的技术,当有连接进来时,服务端接收了客户端的socket(在linux系统中一切皆文件,socket在系统中的具象化表示便是一个fd文件,至于这块,将在后续文章中细讲),为每一个socket开辟一个线程,有子线程去进行对客户端的读取并写回操作,不过对于任何系统而言,其线程数量都是无法无限增长的,过多的线程数将导致CPU频繁的调度和上下文切换,使得系统负载过高,导致性能的急剧下降乃至不可用,这便是BIO在支持客户连接数上的先天不足。目前,BIO的最大使用在于数据库上,不过,这一方面是由于JDBC标准制定的时候IO还只有BIO,使得目前各大基于该标准的数据库都是BIO,这也是数据库连接池出现的原因之一。
说说NIO里的套路
NIO有两种说法,一种是No block io,即非阻塞同步io,一种是no io,即不是io(这种说法在于与之前的BIO相比,从代码编写到包括底层操作系统内核的实现都有了非常大的变化)。NIO相比起BIO,消除了之前实现非阻塞带来的高昂成本,之前BIO如果需要实现非阻塞,需要为每个客户端分别分配一个线程,对于资源和服务器性能的占用是巨大的。NIO简单地说,其大致原理为服务端将调用accept或read时,当服务端这边没有可接送或可读的事件时,会继续开始下一次的轮询,不会进行阻塞操作,操作系统有三类模型实现了NIO,分别是select、poll和epoll,在Java中,其NIO就是基于JDK实现的Selector(多路复用器)来的,当Selector监听到对应的事件时,就会采用对应的方式进行处理。从Selector的底层实现来看,JDK针对不同系统所支持的NIO模型进行了适配,比如Linux采用的是epoll,Windows操作系统则是使用了select,因为windows没支持epoll模型(这也是redis官方并没有windows版本的原因)。对于select模型,其机制用户态用一个List维护了服务端接收到的socket,每次轮询时,都需要将用户态的这个list传入到系统的内核态,由操作系统去筛选list中有哪些socket已经有事件可以进行处理,然后在讲可以处理的socket填充到list后返回给到用户态,由用户态以循环的方式判断是accept还是read等操作后,依次调用对应的逻辑去处理。(关于用户态和内核态这个也是也是个需要长篇大论的知识,这里可以先简单的理解为,程序在执行我们自己写的代码的时候就是处于用户态,而程序执行操作系统内核函数的时候便是处于内核态)select模型的弊端有两处,一方面它每次轮询是否有事件发生都需要将用户态的socket拷贝到内核态,相对而言比较低效,另一方面,select使用的是数组来存储这些socket,当客户端连接比较多时,而select对该数组的容量在操作系统中默认设置为了1024,也就是默认情况下最多只能管理1024个socket,这也导致了其客户端连接数受限;对于poll而言,其可以说是对select的一种改进,总体上与select实现一致,主要是将select原有的数组存储改为了链表存储,使其理论上可以支持更多的连接数,但仍没有改变每次轮询操作都要进行用户态到内核态的拷贝;epoll模型对于select和poll有了较大的变化,首先,它相比每次socket在select和poll模型下都需要从用户态到内核态的【全量】拷贝,在epoll中变为了【增量】插入,在服务端接收到新的socket后,直接将该socket的fd传给系统内核,由内核去负责维护这些socket,当有事件时,由内核返回对应的数据给到用户态,再由用户态去进行处理。在epoll模型下,内核存储socket的模型也发生了变化,采用的时红黑树+双向链表的组合,当有新的socket产生时,将该socket插入到该红黑树的节点下,当socket关闭时,则将该socket从红黑树的节点中删除,当操作系统监听到有事件时,则将有事件的socket放置入双向链表中(该双向链表存在于用户态和内核态的共享空间中),当双向链表中有数据时,以java为例,用户态这一层将接触阻塞,因为链表已经有数据了,所以会根据双向链表中的事件去执行对应的用户代码逻辑。目前,在java中的NIO框有很多,比如像netty,netty解决了java传统nio需要用户自己实现拆包和粘包的问题(根据TCP/IP协议,为了节约传输的频率,在较短的事件内,对于网络传输有时候会出现几个包一起发送,即粘包,并且考虑到带宽问题,需要将较大的数据包拆分为较小的包进行发送,即拆包),而且netty还解决了java的nio中关于空轮询的问题(空轮询问题在于JDK中没处理好nio的异常情况,因为poll和epoll在socket连接意外中断时会将返回的eventSet事件集置为POLLHUP或者POLLERR,让eventSet事件集合发生了变化,使得Selector会被唤醒,最后导致CPU 100%问题,目前国内最广泛使用的JDK1.8也仍有这情况)。
说说AIO里的套路
AIO,是一种异步非阻塞IO,由于之前不管是BIO或是NIO,当内核的buffer把数据准备好后,都需要由对应的线程【主动】调用read或者write方法去进行处理,此时这种【主动】便是一种同步的操作,AIO便是对这种同步进行了异步化,当数据准备好后,不再是由服务端主动调用,而是直接由系统使用服务端在一开始传入的回调函数,以此通知服务端数据已经处理完成。目前,AIO实现比较成熟的是在Windows系统,Windows提供了一种称之为【I/O Completion Ports】的方案,简称为 IOCP,其性能表现较为优异,所以在JDK的Windows版本中便是直接采用了 IOCP 作为Java的AIO支持。而对于 Linux来说,其虽然有基于kernel内核级别的AIO实现,但Linux主要该AIO是针对与磁盘文件的IO,而非网络socket层面的IO,从使用上看,限制比较多,性能也一般,在Linux版本的JDK中,并没有采用kernel内核的AIO实现,而是使用自建线程池的方式,其实底层最后还是使用的epoll,算是一种伪AIO。
用生活场景说下这三种套路
我们去市场买肉,肉摊负责卖肉和切肉的人都是同一个,此时摊主只能为一个顾客服务,顾客去到摊位,就需要按先后顺序先排队,如果购买人数较多,顾客等待时间就会变长,并且由于有些顾客并不是一开始就确定好买什么肉,需要在摊位前先挑选和思考,这就容易进一步加剧排后面人的等待时间,造成阻塞,这时摊主为了减少顾客们等待的时间,对卖肉这个流程进行分工,摊主负责对顾客进行分流,分到不同的员工那里,由员工去为顾客服务,可是肉店老板需要考虑经济成本和空间位置,这就使得同样的环境下工人没法一味的增长,最后人多时还是会照成前面的情况,这种场景下对应的就是BIO的模型。这时后摊主觉得不行,顾客在挑菜的时候后面的顾客都接待不上,这样容易让顾客排队等太久而离开,还是要改进,为了更进一步优化顾客体验,摊主决定直接升级,在市场外租了间铺子,现在顾客来买菜后,只需要有一个店员在门口接待指引,顾客进店后可以自己慢慢挑选肉类,无需像之前从一开始就要排队等候,等到挑完了再和员工说下要去结账,由员工看工作情况适时安排走结账流程即可,这便是NIO的模式。但是因为生意实在太好,毕竟店里能容纳的人还是有限,当人多的时候,接待员也还会应接不暇,顾客进店和结账都需要等待,老板觉得,这种体验还能更进一步优化,老板灵机一动,接入外卖平台,顾客现在只需要动动手指,在app上选定菜品,填好地址,然后下单,在店铺按订单上的准备好后,直接就照订单的地址安排送货到家,这就是AIO的模式。
总结
因为篇幅有限,上述内容无法面面俱到,并且IO的体系从编程语言到最后的内核实现,是个及其复杂的过程,本人也还在持续学习,以上其实也算是带了个入门,大致讲了下三种网络IO的【套路】,之后将会在后续的文章中针对每种IO其实现细节进行更深入的讨论,请各位看官多多支持。