概述
当我们要编写一个echo服务器程序的时候,需要对用户从标准输入键入的的交互命令做出响应。在这种情况下,服务器必须响应两个相互独立的I/O事件:
- 网络客户端发起网络连接请求
- 用户在键盘上键入命令行。我们先等待哪个事件?没有哪个选择是理想的。如果在acceptor中等待一个连接请求,我们就不能响应输入的命令。类似的,如果在read中等待一个输入命令,我们就不能响应任何连接请求。针对这种困境的一个解决办法就是I/O多路复用技术。基本思想就是使用select函数,要求内核挂起进程,只有在一个或多个I/O事件发生后,才将控制返回给应用程序。—《UNIX网络编程》
mysql线程池,就是I/O多路复用的体现
一、I/O多路复用概述
I/O多路复用,I/O就是指的我们网络I/O,多路指多个TCP连接(或多个Channel),复用指复用一个或少量线程。串起来理解就是很多个网络I/O复用一个或少量的线程来处理这些连接。
多路复用的本质是同步非阻塞I/O,多路复用的优势并不是单个连接处理的更快,而是在于能处理更多的连接
I/O编程过程中,需要同时处理多个客户端接入请求时,可以利用多线程或者I/O多路复用技术进行处理
I/O多路复用技术通过把多个I/O的阻塞复用到同一个select阻塞上,一个进程监视多个描述符,一旦某个描述符就位,就能够通知程序进行读写操作。因为多路复用本质上是同步I/O,都需要应用程序在读写事件就绪后自己负责读写。
最大优势就是系统开销小,不需要创建和维护额外的线程或进程
应用场景
- 服务器需要同时处理多个处于监听状态或多个连接状态的套接字
- 需要同时处理多种网络协议的套接字
- 一个服务器处理多个服务或协议
目前支持多路复用的系统调用有select,poll,epoll
二、几种常用I/O模型
BIO
阻塞同步I/O模型,服务器需要监听端口号,客户端通过IP和端口与服务器建立TCP连接,以同步阻塞的方式传输数据。服务端设计一般都是 客户端-线程模型,新来一个客户端连接请求,就新建一个线程处理连接和数据传输
当客户端连接较多时就会大大消耗服务器资源,线程数量可能会超过最大承受量
伪异步I/O
与BIO类似,只是将客户端-线程 的模式换成了线程池,可以灵活设置线程池大小。只是对BIO的一种优化手段,并没有解决线程连接的阻塞问题
NIO
同步非阻塞I/O模型,利用selector 多路复用器轮询为每一个用户创建连接,这样就不用阻塞用户线程,也不用每个线程忙等待。只使用一个线程轮询I/O事件,比较适合高并发,高负载的网络应用,充分利用系统资源快速处理请求返回响应消息,适合连接较多,连接时间I/O任务较短
AIO
异步非阻塞,需要操作系统内核线程支持,一个用户线程发起一个请求后就可以继续执行,内核线程执行完系统调用后会根据回调函数完成处理工作。比较适合较多I/O任务较长的场景
三、select
监视多个文件句柄的状态变化,程序会阻塞在select 处等待,直到有文件扫描符就绪或超时
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *execptfds, struct timeval *timeout)
可以监听三类文件描述符,writefds(写状态),readfds(读状态),execptfds(异常状态)
我们在select函数中告诉内核需要监听的不同状态的文件描述符以及能接受的超时时间,函数会返回所有状态下就绪的描述符的个数,并且可以通过遍历fdset,来找到就绪的描述符。
缺陷
- 每次调用select,都需要把待监控的fd集合从用户态拷贝到内核态,当fd很大时,开销很大
- 每次调用select,都需要轮询一遍所有的fd,查看就绪状态
- select支持的最大文件描述符数量有限,默认1024
四、poll
与select轮询所有待监听的描述符机制类似,但poll使用pollfd结构表示要监听的描述符
int poll(struct pollfd *fds,nfds_t nfds, int timeout)
struct pollfd
{
short events;
short revents;
}
poll结构包括了events(要监听的事件)和revents(实际发生的事件)。而且也需要在函数返回后遍历pollfd来获取就绪的描述符。
相对于select,poll已不存在最大文件描述符限制。
五、epoll
epool针对以上select 和 poll 的主要缺点做出来改进
主要包括三个主要函数,epoll_create,epoll_ctl,epoll_wait。
epoll_create
创建epoll句柄,会占用一个fd值,使用完成以后,要关闭。
int epoll_create(int size)
epoll_ctl
提前注册好要监听的事件类型,监听事件(文件可读、可写、挂断、错误)。不用每次都去轮询一遍注册的fd,而只是通过epoll_ctl把所有fd拷贝进内核一次,并为每一个fd指定一个回调函数。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event`)
当就绪,会调用回调函数,把就绪的文件描述符和事件加入一个就绪链表,并拷贝到用户空间内存,应用程序不用亲自从内核拷贝。类似于信号中注册所有的发送者和接受者,或者Task中注册所有任务的handler
epoll_wait
- 监听epoll_ctl注册的文件描述符和事件,在就绪链表中查看有没有就绪的fd,不用去遍历所有fd。
- 相当于直接去遍历结果集合,而且百分百命中,不用每次都去重新查找所有的fd,用户索引文件的事件复杂度为O(1)
int epoll_wait(int epfd,struct epoll_event * events, int maxevents, int timeout)
六、select & poll & epoll比较
- 每次调用select 都需要把所有要监听的文件描述符拷贝到内核空间一次,fd很大时开销会很大。epoll会在epoll_ctl()中注册,只需要将所有的fd拷贝到内核事件表一次,不用再每次epoll_wait()时重复拷贝。
- 每次select 需要在内核中遍历所有监听的fd,指导设备就绪;epoll通过epoll_ctl注册回调函数,也需要不断调用epolll_wait轮询就绪链表,当fd或者事件就绪,会调用回调函数,将就绪结果加入到就绪链表
- select能监听的文件描述符数量有限,默认是1024;epoll能支持的fd 数量是最大可打开文件的数目,具体数目可以在/proc/sys/fs/file-max查看
- select,poll在函数返回后需要查看所有监听的fd,看那些就绪,而epoll只返回就绪的描述符,所以应用程序只需要就绪fd的命中率是百分百
表面上看epoll的性能最好,但是在连接数少且连接都十分活跃的情况下,select 和 poll的性能可能比 epoll好,毕竟epoll 的通知机制需要很多函数回调。
select 效率低是因为每次都需要轮询,但效率低也是相对的,也可通过良好的设计改善。
七、阻塞、非阻塞
阻塞式I/O和I/O复用,两个阶段都阻塞,那区别在哪?
虽然第一阶段都是阻塞,但是阻塞是I/O如果要接收更多的连接,就必须创建更多的线程。I/O复用模式下在第一个阶段大量的连接统统都可以过来直接注册到Selector复用器上面,同时只要单个或少量的线程来循环处理这些连接事件就可以了,一旦达到“就绪”的条件,就可以立即执行真正的I/O操作。这就是I/O复用与传统的阻塞式I/O最大的不同。也正是I/O复用的精髓所在。
从应用进程的角度去理解始终是阻塞的,等待数据和将数据复制到用户进程这两个阶段都是阻塞的。这一点我们从应用程序是可以清楚的得知,比如我调用一个以I/O复用为基础的NIO应用服务。调用端是一直阻塞等待返回结果的。
从内核的角度等待Selector上面的网络时间就绪,是阻塞的,如果没有任何一个网络时间就绪则一直等待,直到有一个或多个网络事件就绪。但是从内核的角度考虑,有一点是不阻塞的,就是复制数据,因为内核不用等待,当有就绪条件满足的时候,它直接复制,其余时间在处理别的就绪的条件。这也是大家一直说的非阻塞I/O。实际上就是指的这个地方的非阻塞。
总结
我们通常说的NIO 大多数场景下都是基于I/O 复用技术的NIO。
注意
使用NIO != 高性能,当连接数 < 1000,并发成都不高或者局域网环境下,NIO并没有明显的性能优势。
