Linux 网络IO模型
同步和异步,阻塞和非阻塞
同步和异步
请求线程是否需要等待结果返回。
同步:请求线程需要等待调用结果的返回。
异步:不需要等待结果,而是通过其他手段比如,状态通知,回调函数等。
阻塞和非阻塞
请求线程请求后能否做其它事。
阻塞:是指结果返回之前,当前线程被挂起,不做任何事。
非阻塞:是指结果在返回之前,线程可以做一些其他事,不会被挂起。
五种I/O模型
阻塞I/O模型
下面图中的进程都可以理解为线程,Linux 中的线程就是用进程实现的。(个人理解)
需要先等待内核准备好数据(阻塞),再等待数据复制完成返回(同步)。
同步阻塞:进程调用方法后,不能做其它事,且需要等待结果返回。
非阻塞IO模型
可以看到整个等待数据部分都是系统调用,没有 recvfrom 的时候,系统可以安排进程做其它事。虽然在人类看来这个时间很短做不了什么,但是在计算机的世界可以做很多事了。(个人理解)
每隔一段时间调用方法检查内核数据准备状态,再等待数据复制完成返回(同步)。
同步非阻塞:进程调用方法后,可以做其它事,但还是需要等待结果返回。
IO复用模型
主要是select和epoll两个系统调用,能实现同时对多个IO端口进行监听。
I/O复用模型会用到select、poll、epoll函数,这几个函数也会使进程阻塞,但是和阻塞I/O所不同的是,这两个函数可以同时阻塞多个I/O操作。而且可以同时对多个读操作,多个写操作的I/O函数进行检测,直到有数据可读或可写时,才真正调用I/O操作函数。
当用户进程调用了select,那么整个进程会被block;而同时,kernel会“监视”所有select负责的socket;当任何一个socket中的数据准备好了,select就会返回。这个时候,用户进程再调用read操作,将数据从kernel拷贝到用户进程。
select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。
NIO 是基于 I/O 复用的,Tomcat(8.5) 是基于 NIO 的。但 Tomcat 的 I/O 模型和 NIO 又有区别。
1个线程 Acceptor 负责接收连接 Channel 到 accept 队列。2个线程负责轮询。轮询到就绪的读/写创建 SocketProcessor 交给线程池 Executor 去执行。在 Executor 的线程中,会完成从 socket 中读取 HttpRequest 解析成 HttpServletRequest 对象,分派到相应的 servlet 并完成逻辑,然后将 Response 通过 socket 发回 client。在从 socket 中读数据和往 socket中写数据的过程,并没有像典型的非阻塞的 NIO 的那样,注册 OP_READ 或 OP_WRITE 事件到主 Selector,而是直接通过 socket 完成读写,这时是阻塞完成的。
信号驱动IO(了解)
两次调用,两次返回。
异步IO模型(了解)
用户进程发起aio_read操作之后,给内核传递描述符、缓冲区指针、缓冲区大小等,告诉内核当整个操作完成时,如何通知进程,当内核收到aio_read后,会立刻返回。然后内核开始等待数据准备,数据准备好以后,直接把数据拷贝到用户控件,然后再通知进程本次IO已经完成。
五个I/O模型的比较
什么是 select、poll、epoll
服务端需要管理多个客户端连接,而 recv 只能监视单个 socket,这种矛盾下,人们开始寻找监视多个socket的方法。
select
int s = socket(AF_INET, SOCK_STREAM, 0);
bind(s, ...)
listen(s, ...)
int fds[] = 存放需要监听的socket
while(1){
int n = select(..., fds, ...)
for(int i=0; i < fds.count; i++){
if(FD_ISSET(fds[i], ...)){
//fds[i]的数据处理
}
}
}
用一个数组fds存放所有需要监视的socket。然后调用select,当前进程A阻塞(进程A进入所有socket的等待队列),直到有一个socket接收到数据(中断程序唤醒A,也就是从所有socket等待队列中移除,放入内核空间中的工作队列),select返回。用户可以遍历fds,判断具体哪个socket收到数据,然后做出处理。
注意:当程序调用select时,内核会先遍历一遍socket,如果有一个以上的socket接收缓冲区有数据,那么select直接返回,不会阻塞。这也是为什么select的返回值有可能大于1的原因之一。如果没有socket有数据,进程才会阻塞。
缺点:
1,每次调用select都需要将进程加入到所有监视socket的等待队列,每次唤醒都需要从每个队列中移除。这里涉及了两次遍历,而且每次都要将整个fds列表传递给内核,有一定的开销。正是因为遍历操作开销大,出于效率的考量,才会规定select的最大监视数量,默认只能监视1024个socket。
2,进程被唤醒后,程序并不知道哪些socket收到数据,还需要遍历一次。
poll
和 select 基本一样,有少量改进。
epoll
epoll是select和poll的增强版本。epoll通过以下一些措施来改进效率。
int s = socket(AF_INET, SOCK_STREAM, 0);
bind(s, ...)
listen(s, ...)
int epfd = epoll_create(...);
epoll_ctl(epfd, ...);
while(1){
int n = epoll_wait(...)
for(接收到数据的socket){
//处理
}
}
措施一:功能分离
select低效的原因之一是将“维护等待队列”和“阻塞进程”两个步骤合二为一。每次调用select都需要这两步操作,然而大多数应用场景中,需要监视的socket相对固定,并不需要每次都修改。epoll将这两个操作分开,先用epoll_ctl维护等待队列,再调用epoll_wait阻塞进程。显而易见的,效率就能得到提升。
对比两段代码可以发现,select 在循环里面;而 epoll 将它分为2步,epoll_ctl 在循环外面,epoll_wait在循环里面。循环里面做的事少了,效率自然就高了。
措施二:就绪列表
select低效的另一个原因在于程序不知道哪些socket收到数据,只能一个个遍历。如果内核维护一个“就绪列表”,引用收到数据的socket,就能避免遍历。
当某个进程调用epoll_create方法时,内核会创建一个eventpoll对象(也就是程序中epfd所代表的对象),就绪列表 rdlist 是 eventpoll 的成员。
当程序执行到epoll_ctl方法时,所有需要监听的socket将eventpoll添加到自己的等待队列中。
当程序执行到epoll_wait时,如果rdlist已经引用了socket,那么epoll_wait直接返回;如果rdlist为空,阻塞进程(将当前进程A放入eventpoll的等待队列)。
当socket接收到数据,中断程序一方面修改rdlist,添加socket引用;另一方面唤醒eventpoll等待队列中的进程,进程A再次进入运行状态(放入工作队列)。也因为rdlist的存在,进程A可以知道哪些socket发生了变化。
select、poll、epoll的区别(大厂面试)
1、支持一个进程所能打开的最大连接数
2、FD剧增后带来的IO效率问题
3、消息传递方式
综上,在选择select,poll,epoll时要根据具体的使用场合以及这三种方式的自身特点:
-
表面上看epoll的性能最好,但是在连接数少并且连接都十分活跃的情况下,select和poll的性能可能比epoll好,毕竟epoll的通知机制需要很多函数回调。
-
select低效是因为每次它都需要轮询。但低效也是相对的,视情况而定,也可通过良好的设计改善。
补充知识点:
Level_triggered(水平触发):当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。如果这次没有把数据一次性全部读写完(如读写缓冲区太小),那么下次调用 epoll_wait()时,它还会通知你在上没读写完的文件描述符上继续读写,当然如果你一直不去读写,它会一直通知你!!!如果系统中有大量你不需要读写的就绪文件描述符,而它们每次都会返回,这样会大大降低处理程序检索自己关心的就绪文件描述符的效率!!!
Edge_triggered(边缘触发):当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。如果这次没有把数据全部读写完(如读写缓冲区太小),那么下次调用epoll_wait()时,它不会通知你,也就是它只会通知你一次,直到该文件描述符上出现第二次可读写事件才会通知你!!!这种模式比水平触发效率高,系统不会充斥大量你不关心的就绪文件描述符!!
select(),poll()模型都是水平触发模式,信号驱动IO是边缘触发模式,epoll()模型即支持水平触发,也支持边缘触发,默认是水平触发。