BIO、NIO、AIO的本质梳理和总结
[TOC]
1. 基本背景和定义
最近项目中使用java来写一个tcp通讯的加解密的模块,目前暂定使用netty加国密算法的长连接来进行编写,同时研究了一下基于aio的smart-socket的项目,结合以前C语言的经验对于bio、nio、aio做一个整体的梳理和总结。
在总结之前,我们需要对一些本文中用到的概念做一个基本的定义描述。
1.1 阻塞的定义
正在执行的进程,由于期待的某些事件未发生,如等待某种操作的完成、新数据尚未到达或无新工作可做等情况,则由系统执行阻塞(Block),使自己由运行状态变为阻塞状态。当进程进入阻塞状态,是不占用CPU资源的。
1.2 IO中kernel的两个阶段的阐述
read在每次读取数据的时候,其实是执行了两个动作。
- 等待读取数据并将数据先copy到内存空间的缓存区中(page cache),我们后续简称:数据准备
- 从缓存区拷贝数据到用户空间地址中,我后续简称:数据读取
1.3 几种网络模式的方案与区分
现在网上经常提到的说法是,有5种网络模式的方案,他们分别是:
- 阻塞 I/O(blocking IO),简称BIO
- 非阻塞 I/O(nonblocking IO),简称NIO
- 信号驱动 I/O( signal driven IO),最常见的kill -9 杀进程就是信号驱动了进程退出。
- 异步 I/O(asynchronous IO),简称AIO
- I/O 多路复用( IO multiplexing),是一种监听多个fd的机制,跟fd的阻塞/非阻塞等属性毫无关系。
实际上这五种方案的划分本身就是存在一定的问题的,这其中的I/O多路复用,是一种具体的多fd监听的机制,而BIO、NIO、AIO等都是fd本身的属性,一般的网络实现上几乎都用到了IO多路复用与fd本身属性的组合。在下文我把他们进行了区分,防止再一步的传播和误导他人。
2. 几种fd的通讯阻塞模式
2.1 BIO 阻塞IO
默认的fd的读写都是阻塞的模式(blocking),最原始而常见的短连接通讯都会使用该模式,整个用户的线程/进程是完全阻塞在read的,而read实际上是阻塞和等待数据准备
和数据读取
两个阶段。
本质:用户进程阻塞等待在
数据准备
和数据读取
2. 2 NIO 非阻塞IO
可以通过设置fd使其变为non-blocking。当对一个non-blocking fd执行读操作时,数据读取
阶段会直接返回,如果数据准备
的数据没有收到数据,那么就会返回一个错误,比如EAGIN
,然后我们的进程需要不断的去主动调用read,检查数据准备
是否完成。
所以NIO实际上是不停的定期去查询数据准备
是否完成,在数据准备
未完成之前,它可以有时间去做其他的事情,比如做一些统计,管理线程等工作,然后再下次检测周期到来的时候,再做数据读取
。
因此我们其实可以有一个结论,在单个连接的模式下,NIO的效率实际上是BIO的效率要低的。因为它不能在数据准备
完成的第一时间获取到状态,然后调用数据读取
,必须在轮询的周期内进行下次读取,比如极端情况我们的轮询周期是10秒,那么没过10秒才能检查一下是否数据准备
完成,实际上io多路复用的实现(select/poll/epoll)会缩短这个轮训的周期。
flags = fcntl(fd, F_GETFL, 0); //获取当前的fd的属性
fcntl(fd, F_SETFL, flags | O_NONBLOCK); //将fd的属性增加O_NONBLOCK
//O_NDELAY是O_NONBLOCK的systemV的旧写法,无法判断是读完了还是读不到,所以POSIX进行了改进。
本质:用户进程不阻塞但是不停循环的主动查询
数据准备
是否完成,再用户进程时候的时候调用数据读取
来获取数据。
2.3 signal driven IO 信号驱动 I/O
信号驱动IO的处理其实很少使用,一般常用的是进程退出信号、终端断开信号、还有最常见的kill -9 信号。使用signal
或sigaction
这些方法来调用注册。
它的实现是提前注册一个信号在系统kernel中,当有一类固定的系统事件触发后,则通知用户进程进行事件函数的回调,回调内部可以做任何事情,不一定是IO读取。之所以提到这种IO模式,是因为它和一些aio的实现(网上也有一些实现叫事件驱动)机制有一些非常相似的地方。
本质:用户进程不阻塞,也不主动查询
数据读取
是否完成, 而是kernel在数据准备
完成后通过回调信号处理函数告知用户进程,用户进程再调用数据读取
获取数据。
2.4 AIO 异步IO
AIO的实现是用户进程发起read操作之后,立刻就可以开始去做其它的事。而另一方面,从kernel的角度,当它受到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block。然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成了。
本质:进程不阻塞,也不查询
数据读取
是否完成,而是kernel数据准备
完成后,kenrel调用数据读取
完成后,再通知用户进程直接获取消息。
异步 I/O 与信号驱动 I/O 很相似,它们的区别在于,异步 I/O 的信号是\通知应用进程 I/O 完成***,而信号驱动 I/O 的信号是*通知应用进程可以开始 I/O****。
3. 关于同步与异步概念的进一步梳理
3.1 同步IO(Sync IO)
调用端会一直等待服务端响应,直到返回结果。
3.2 异步IO(Async IO)
调用端发起调用之后不会立刻返回,不会等待服务端响应。服务端通过通知机制或者回调函数来通知客户端。
3.3 与阻塞模式的关系
从原生的机制上来说, BIO和NIO是原生同步的,因为是主进程来阻塞或者非阻塞循环读取。信号IO和AIO是原生异步的,因为这两种都是通过应答回调用户进程函数来实现读取的,此时回调的函数一定不在主进程的顺序流程中。
但是,同步与异步的IO,实际上是根据发起端的行为和具体实现来定义的,与前面介绍的fd的原生机制没有强关联。比如,我可以在使用BIO的时候,发送成功后,直接创建一个子线程来等待接收,然后主线程继续处理其他业务,子线程接收应答后直接开始后续处理或结束,这样的通讯实际上是异步的。再比如,我可以在使用的AIO的时候,主线程发送完成,一直等待回调的方法执行完成,那么这次通讯实际上是同步的。
因此,同步还是异步,要看具体的业务场景是否需要接收应答,与是否采用fd的阻塞模式属性,没有任何关联。
4. IO多路复用
4.1 selector的机制
IO多路复用,是操作系统提供的一种可以在同一个进程内,同时监听多个fd的机制,当这些文件描述符其中的任意一个或几个进入读/写就绪状态,就会返回,然后需要业务程序去获取哪些fd产生了哪些变化,并进行后续的业务处理。
因此selector的本质还是阻塞模式,只不过是在selector处进行了阻塞操作。
不同的系统提供的实现方式不同,在unix和早期linux版本是select、poll,在较新的linux上是epoll, 在macos上是kqueue。目前大部分的高效通讯机制,都是基于IO多路复用+BIO/NIO/AIO组合来实现的。
总的来说,selector机制的核心就是,减少阻塞过程中的循环执行次数以及检查次数。
4.2 epoll的优势
epoll和select、poll的机制都是一样的selector机制,具体实现上,它有三个地方的优势:
- 比起select和poll,它没有次数限制(select最大1024),而且通过触发队列的模式,不需要遍历整个的监听集合。
-
使用mmap,减少了准备好
数据读取
的一次内存拷贝。 - epoll增加了ET(边缘触发)的机制,可以只处理状态发生变化的fd,即如果有fd上次触发后未处理完成或被丢弃,下次不会再触发,而LT(水平触发)则每次都会触发,这是实现的细节,了解即可。
关于epoll的细节,网上资料也很多,其他的细节就不再赘述了。
4.3 redis的处理机制
redis能够高效并且单线程串行处理的核心,就是他使用了epoll的IO多路复用机制+NIO的非阻塞模型,具体的代码是ae.c中,后来的著名压测工具wrk也是完全使用了ae.c的使用。另外著名的c语言的事件库libevent、以及后来的轻量化的libev库,也是对IO多路复用的一种封装,通过epoll+NIO的机制,把库的表现趋近于AIO的效率,把数据准备
和数据读取
的时间尽量提前,让用户进程真正的减少读取的block时间。
当然,他们不是一种真正的AIO机制,但是可以实现AIO的功能。这些库是在库层面来把数据读取
的阻塞时间提前了,这时使用的还是用户进程的CPU时间。真正的AIO机制,应该是完全有kernel来实现的,这样的效率才是最高的,但是目前很遗憾,linux系统还未很好的实现。这也是为什么netty采用aio机制后与原来的nio机制相比,几乎没有任何提升。
4.4 netty的实现
netty是著名的java的nio的实现框架,也是它再不停的提起NIO这个词,导致目前NIO的概念已经被改变为:selector机制+fd的NIO操作组合起来实现的一种通讯架构。
netty也是用了复杂的机制,把c语言的裸连接的处理进一步封装成了channel,但是本质上和redis的处理模式是没有区别的。
4.5 smart-socket的实现
smart-socket是最近的国产开源项目,我也进行了初步的研究,代码简洁、是用java的aio机制。由于linux上aio的实现不太好,导致实际在linux上的运行效果和netty相差无几。
无论是netty还是smart-socket,如果要实现同步等待,基本必须要实现如下几个内容:
- 发送标记,发送标记需要server端原样返回,是判断应答报文还是判断对方主动请求的很重要要素。
- 同步等待队列,作用是在异步应答处理和扔到等待队列之前,一直阻塞等待。实现上可以使用独立的mq产品、systemV的IPC的Q、甚至是数据库、java的netty和smart-socket也常用completeFurturce来实现。
5. 总结
redis,wrk,还有netty都是使用IO多路复用和一个BIO、NIO或AIO的实现,同时增加了如下几个功能点:
- 对IO多路复用进行封装,将原来的select、poll、epoll、kqueue按不同的系统进行选择封装和跨平台选择调用。
- 对监听的fd,进行非阻塞或异步读取的设置,让read和write方法立刻返回,然后将部分返回的数据进行存储。
因此,目前最快的实现还是selector+nio的实现,那么后续最快的实现方式是selector+aio的实现。前提是aio的实现能够真正的满足数据读取
后异步通知用户进程来读取。
参考材料:
https://www.cnblogs.com/twoheads/p/10712094.html
https://www.cnblogs.com/Yunya-Cnblogs/p/13246517.html
https://smartboot.gitee.io/book/smart-socket/chapter-2/Interface/?q=