概述
当我们要编写一个echo服务器程序的时候,需要对用户从标准输入键入的交互命令做出响应。在这种情况下,服务器必须响应两个相互独立的I/O事件:1)网络客户端发起网络连接请求,2)用户在键盘上键入命令行。我们先等待哪个事件呢?没有哪个选择是理想的。如果在acceptor中等待一个连接请求,我们就不能响应输入的命令。类似地,如果在read中等待一个输入命令,我们就不能响应任何连接请求。针对这种困境的一个解决办法就是I/O多路复用技术。基本思路就是使用select函数,要求内核挂起进程,只有在一个或多个I/O事件发生后,才将控制返回给应用程序。 --《UNIX网络编程》
mysql线程池,就是I/O多路复用的体现。
参考:https://blog.csdn.net/wangxindong11/article/details/78591308
一、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 *exceptfds, struct timeval *timeout)
可以监听三类文件描述符,writefds(写状态), readfds(读状态), exceptfds(异常状态)。
我们在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; };
pollfd结构包括了events(要监听的事件)和revents(实际发生的事件)。而且也需要在函数返回后遍历pollfd来获取就绪的描述符。
相对于select,poll已不存在最大文件描述符限制。
五、epoll
epoll针对以上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注册回调函数,也需要不断调用epoll_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他们的第二阶段都相同,也就是都会阻塞到recvfrom调用上面就是图中“发起”的动作。异步式I/O两个阶段都要处理。这里我们重点对比阻塞式I/O(也就是我们常说的传统的BIO)和I/O复用之间的区别。
阻塞式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,比如jdk中的NIO,当然Tomcat8以后的NIO也是指的基于I/O复用的NIO。注意,使用NIO != 高性能,当连接数<1000,并发程度不高或者局域网环境下NIO并没有显著的性能优势。如果放到线上环境,网络情况在有时候并不稳定的情况下,这种基于I/O复用技术的NIO的优势就是传统BIO不可同比的了。那么使用select的优势在于我们可以等到网络事件就绪,那么用少量的线程去轮询Selector上面注册的事件,不就绪的不处理,就绪的拿出来立即执行真正的I/O操作。这样我们就能够用极少量的线程去HOLD住大量的连接。