目录
1.绪论
只要涉及网络传输,就一定需要IO,而高性能的网络IO也是保证框架性能的基石。比如redis性能如此高的原因主要有两个,一是高性能的网络组件,二是redis的几乎所有操作都是基于内存。以及大名顶顶的网络框架Netty和Tomcat,有如此高的性能,都离不开优秀的网络IO模型设计。
2.IO分类
在《Unix网络编程》这本书中将网络IO分为了5类,主要是同步阻塞IO,同步非阻塞IO,异步IO多路复用,信号驱动,异步非阻塞IO这5类。而在java中又有BIO、NIO、AIO这三种,那他们的关系是什么呢。
BIO:其实就是同步非阻塞IO。
NIO:NIO英文名称其实是New IO,它对应的其实是IO多路复用,而IO多路复用其实是对同步非阻塞IO的优化,所以将NIO称之为同步非阻塞IO也不无道理。
AIO:其实就是异步非阻塞IO。
3.用户空间和内核空间
在前面讲Mmap的时候,我们说过计算机为了保证内核安全,不允许用户直接操控驱动程序对硬件进行修改。而是操作系统向用户暴露接口,用户如果要操纵内存或者CPU需要调用操作系统提供的接口完成操作。
而计算机为了保证操作系统的运行不被用户程序访问到,所以将寻址空间(简单来说就是内存)分为两部分,分别是内核空间和用户空间。用户如果要读写数据,需要先将数据读入到内核空间的缓存中,然后拷贝到用户空间的缓存中。同理,写数据时,也需要先将数据写入到用户空间缓存,再拷贝到内核空间缓存,最后写入到磁盘或者网卡中。
4.同步阻塞IO
同步阻塞IO其实就是java中的BIO,它的步骤如图所示。
可以看出同步阻塞在用户进程调用内核的recvfrom方法后,会一直等待,直到结果返回。在内核缓冲区无数据并且拷贝期间,用户线程会一直等待。
同步阻塞IO的第一个阶段也是阻塞状态,第二个阶段也是阻塞状态。
5.同步非阻塞IO
可以看出同步非阻塞IO是会一直循环调用recvfrom方法,询问内核进程数据是否到达,他和同步阻塞IO的主要区别是在数据未就绪的时候,线程并不会阻塞,而是一直巡询问操作系统。
同步非阻塞IO尽管在数据未就绪的时候未阻塞,但是它在这段时间内并没有干其他事情,而是一直在与操作系统交互,这样其实读取数据的耗费时间和同步阻塞IO是一样的,而且会频繁与CPU交互,性能可能更低。那为什么还会出现这种IO模型呢?现在的同步非阻塞IO是一个线程读取数据的时候都会与操作系统进行交互,判断当前是否数据就绪。那我们可以不可以让一个专门的线程来替多个线程去询问操作系统是否就绪呢?答案是可以的,就是后面将要介绍的IO多路复用。
同步非阻塞IO的第一个阶段也是非阻塞状态,第二个阶段是阻塞状态。
6.IO多路复用
6.1 基本原理
可以看出IO多路复用的步骤如下:
1.进程调用操作系统提供的select函数,监听多个socket连接,如果所有socket都没有数据,进程便会阻塞等待;
2.如果某个socket数据就绪后,便会给进程返回readable,并唤醒进程;
3.进程调用操作系统的recvfrom方法,将数据从内核缓冲区拷贝到用户缓冲区;
4.给用户进程返回结果。
IO多路复用主要是利用select方法,可以监听多个socket,这也是其性能高的原因。
6.2 linux对IO多路复用的实现方式
在linux中,万物皆文件,而每个文件都有句柄-文件描述符fd来表示。而linux中的IO多路复用就是利用一个进程监听多个fd。一般有三种实现方式,select,poll,epoll。
6.3.1 select
1.实现原理
//类型别名,__fd_mask其实就是long int的别名
typedef long int __fd_mask;
typedef struct {
//fds_bits是一个长度为 1024/32 = 32 位的long数组
//c语言的long类型占32个字节,所以这个数组共有1024位
//如果位为0表未就绪,为1表示就绪
__fd_mask fds_bits[__FD_SETSIZE / __NFDBITS];
...
} fd_set;
//linux提供的select函数
int select(int nfds, //需要监听的最大的文件描述符值+1
fd_set *readfds, //需要监听的读事件的文件描述符位数组
fd_set *writefds, //需要监听写事件的文件描述符数组
fd_set *exceptfds, //需要监听异常时间的fd数组
struct timeval *timeout //如果超过改时间,还是没有数据,便返回,null:永不超时,0:不等待,大于0:固定等待时间
);
步骤如下:
1.进程如果想听多个fd,select提供了3个长度为32的long类型的数组(总共用1024位),将这个数组的需要监听的fd设置为1,并且拷贝到内核空间中,这里以读fd(readfds)数组为例子;
2.内核空间如果没有数据到达,便休眠,如果有fd到达,会将对应的readfds中对应位置设置为1,并且拷贝到用户空间;
3.用户空间变量readfds数组,得到哪些fd就绪;
4.针对这些fd,调用revcfrom函数,将对应fd在内核缓冲区中的数据拷贝到用户缓冲区。
2.缺点
其实从上面的实现我们也看出主要缺点如下:
1.select采用1024位的数组来存储哪些fd需要被监听,所以select最多只支持同时监听1024个fd;
2.select会频繁的将需要监听的fd数组从内核空间到用户空间相互拷贝;
3.select函数不会返回具体哪些fd已经就绪,而是整个fds数组,所以用户空间想要获取到哪些fd就绪,需要遍历整个数组。
6.3.2 poll
poll是对select的改进
1.实现原理
struct pollfd {
int fd; //需要监听的fd
short int events; //想要监听的事件类型
short int revents; //实际发生的事件类型
}
//poll函数
int poll(struct pollfd *fds, //需要监听的事件,采用链表存储
nfds_t nfds, //需要监听的pollfd个数
int timeout; 超时时间
);
poll相对于select其实就解决了上面的select采用1024位的数组来存储fd导致select每次最多只能监听1024个fd问题。poll采用链表存储,理论上监听的fd个数没有上限,但是如果返回的还是整个链表,所以用户进程想要获取到哪些fd被监控,还是需要遍历整个链表,若监听的fd数量很多,反而性能很差。
6.3.3 epoll
1.epoll数据结构
epoll在内存空间,采用红黑树来存储需要监听fd,用链表来存储就绪的fd。
2.epoll的函数
epoll向用户空间提供了几个函数来操作器内部的数据结构:
epoll_create:创建一个epoll实例,内部是event poll,返回对应的句柄epfd int epoll_create(int size);
int epoll_create(int size);
epoll_ctl:用户进程调用epoll_ctl可以将待监听的fd加到红黑树上去,并且为期其绑定一个回调函数,当该fd就绪的时候,会将这个fd加入到就绪链表头部;
// 2.将一个FD添加到epoll的红黑树中,并设置ep_poll_callback
// callback触发时,就把对应的FD加入到rdlist这个就绪列表中
int epoll_ctl(
int epfd, // epoll实例的句柄
int op, // 要执行的操作,包括:ADD、MOD、DEL
int fd, // 要监听的FD
struct epoll_event *event // 要监听的事件类型:读、写、异常等
);
epoll_wait:调用该函数会在用户态创建一个event数组,用于接收就绪的fd,如在监听的红黑树中有fd到达,会触发回调函数,并将其加入到就绪链表中。并将数据加入到events数组中,拷贝给用户进程,此时events数组中就是已经就绪的fd。
// 3.检查rdlist列表是否为空,不为空则返回就绪的FD的数量
int epoll_wait(
int epfd, // epoll实例的句柄
struct epoll_event *events, // 空event数组,用于接收就绪的FD
int maxevents, // events数组的最大长度
int timeout // 超时时间,-1用不超时;0不阻塞;大于0为阻塞时间
);
3.epoll的优点
其实上面epoll已经解决了select的三个问题:
1.存储长度问题:采用红黑树来存储需要监听的fd,理论上没有上限;
2.频繁拷贝问题:用户需要监听某个fd的时候只需要调用epoll_ctl方法将其加入到红黑树上即可;
3.结果遍历问题:epoll采用单独的就绪链表来存储就绪的fd,所以只会将就绪的fd拷贝大用户空间传过来的events数组中。
4. epoll的两种触发模式
当我们调用epoll_wait的时候,可以得到事件通知,epoll有两种事件通知方式:
-
LevelTriggered:简称LT,也叫做水平触发。只要某个FD中有数据可读,每次调用epoll_wait都会得到通知。
-
EdgeTriggered:简称ET,也叫做边沿触发。只有在某个FD有状态变化时,调用epoll_wait才会被通知。
主要区别是:如果内核缓冲区中数据较多,一次性不能完全拷贝到用户缓冲区中,如果是LT模式,下一次这个fd还会再就绪链表上面,而ET模式,下一次读取便不会再就绪链表上。我们一般采用LT模式。
6.3 reactor模型
reactor模型主要是将主要分成两部分,分别是selector和handler,selector其实就是调用前面的epoll_wait函数,等待客户端的建立连接请求或者读写请求,如果请求很多,selector也可以交给线程处理。handler主要根据对应的请求类型交给不同的线程池处理。这其实对应的就是netty中的boossGroup和workerGroup。步骤如下:
1.客户端发现建立连接请求给selector;
2.selector监听到客户端请求后,发现是建立连接,类型为accept,交给acceptor处理;
3.accptor会建立为该客户端建立一个chnnel并且注册到selector上去,监听事件为读写;
4.客户端发送读请求给selector,selector判断事件类型为read,将其给处理读IO操作的handler处理。
7.信号驱动IO
7.1 原理
信号驱动IO步骤如下:
1.用户进程调用sigaction函数,内核函数会监听对应fd,此时用户进程不用阻塞可以做其他操作。
2.当用数据就绪时,递交回调信号给用户进程;
3.用户进程收到回调信号过后,调用revcfrom函数将内核空间的数据拷贝到用户空间。
7.2 缺点
1.当IO操作过多时,SIGIO处理函数不能及时处理可能导致信号队列溢出;
2.内核空间与用户空间的频繁信号交互性能也较低。
8.异步IO
8.1 原理
1.用户进程调用aio_read函数,并且给信号绑定一个回调函数;
2.用户进程等待数据,如果数据到达,将数据从内核空间拷贝到用户空间,并且给用户进程返回信号;
3.触发用户进程中信号绑定回调函数,处理数据。
8.2 缺点
异步IO在等待数据和将数据从内核空间拷贝到用户空间这整个过程,用户进程都是不阻塞的。所以其性能特别高,但是和信号驱动一样,如果IO特别多,可能导致信号队列溢出等问题。
9. 同步 VS 异步 / 阻塞 VS 非阻塞
9.1 同步 VS 异步
其实从上面可以看出,同步和异步的最主要的区别是第二阶段从内核缓冲区拷贝到用户缓冲区这个过程是否需要阻塞等待。同步IO会阻塞等待,而异步IO不会。
9.2 阻塞 VS 非阻塞
阻塞和非阻塞的主要确保在第一阶段,等待数据就绪这个过程是用户进程是否会阻塞等待。
10.总结
本文主要介绍了网络中的几种IO模型,并且分析了他们的优缺点。在现在各种框架中,最常用的还是IO多路复用这一模型。同步阻塞IO和同步非阻塞IO在整个过程中,可以认为用户进程是阻塞的,这两种模型的吞吐量是相对较低的。信号驱动IO在等待数据的过程中是非阻塞的,异步IO在整个过程中都是非阻塞的。按理想情况,这两种IO模型应该是吞吐量很大的,但是如果在并发很高的场景下,可能导致内核进程为特别多的信号监听fd,导致吞吐量降低。所以IO多路复用是同步阻塞IO和异步IO的折中,它结合reactor模型,也能够拥有很好的吞吐量,成为现在的主流选择。