前言:对于传统的BIO(同步阻塞)模型,当有客户端连接达到服务端,服务端在对改连接进行连接建立,和数据传输过程中,是无法响应其他客户端的,只有当服务端完成对一个客户端处理后,才能去处理其他客户端的连接,管道的读写请求;如果只有几个客户端连接还好,如果现在需要多个客户端都连接到服务端,就很有可能造成多个客户端的阻塞,虽然可以引入多线程技术,每个客户端进入都交由一个线程进行处理,如果有成千上万个客户端就需要维护n多个线程,显然线程并不是越多越好,它除了会占用大量资源,也会造成上下文的频繁切换;那么有没有其它方式来解决呢。
2 CPU 对于任务的处理:
我们知道系统中进程的运行依赖于从CPU获得时间片,只有在工作队列中的进程才有获的CPU时间片的机会:
当一个进程发生阻塞时,会从工作队列中进行移除,并将改进程放入到阻塞队列中:
当发生中断时,会将阻塞队列中满足条件的进程从阻塞队列中移除,并将其重新加入到工作队列中;如果我们将CPU看做是一个服务端,将多个进程看做是与其连接的客户端,是否也可以只有一个线程来管理多个客户端,我们将客户端统一放入到一个地方,只有当客户端满足一定的条件,可以将其看做是可以进行数据处理的客户端,并将其放入一个有效队列中,然后程序中只与有效队列中的客户端进行通信;
3 NIO 多路复用IO:
IO多路复用是指内核一旦发现进程指定的一个或者多个IO条件准备读取,它就通知该进程进行处理;多路复用通过将关心的socket 都注册到一个Select 上,由Select 完成对多个socket 的监听,当发现有通道准备就绪,可读或者可写,就告诉进程来处理;
3.1 select,poll 的实现方式:
本文中socket 内核对象 ≈ fd 文件描述符 ≈ TCP连接,服务端通过将连接到的socket 的文件描述符放入到一个集合中,然后将改socket集合拷贝到内核空间,内核空间通过遍历文件描述符集合的方式,当有事件发生,将对应的socket 标记为可读或可写,然后将socket 集合拷贝到用户空间,用户空间遍历socket 集合找到可读或者可写的socket 进行处理;
过程:
1)当进程A调用select语句的时候,会将socket 集合拷贝到内核空间,并将进程A添加到多个监听socket的等待队列中:
2)当网卡接收到数据,然后网卡通过中断信号通知cpu有数据到达,执行中断程序,中断程序主要做了两件事:
3)工作队列的线程A 获取cpu时间片,将数据从内核的socket 数据接收队列中将数据拷贝到用户空间,用户空间的进程从用户空间获取数据进行处理;
3.2 select,poll 区别:
select和poll在内部机制方面并没有太大的差异。相比于select机制,poll只是取消了最大监控文件描述符数限制,并没有从根本上解决select存在的问题。
select,poll 问题:
3.3 epoll 的实现方式:
3.3.1 epoll 在原有的select,poll 主要解决,socket多次遍历问题;
1) epoll 中放弃了数组使用红黑树来存放关心的socket ,这样每次添加socket和移除socket 只需要遍历红黑树 时间复杂度 O(logn);epoll 在内核里使用红黑树来跟踪进程所有待检测的文件描述字,把需要监控的 socket 通过 epoll_ctl() 函数加入内核中的红黑树里,红黑树是个高效的数据结构,增删查一般时间复杂度是 O(logn),通过对这棵黑红树进行操作,这样就不需要像 select/poll 每次操作时都传入整个 socket 集合,只需要传入一个待检测的 socket,减少了内核和用户空间大量的数据拷贝和内存分配。
2) epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用 epoll_wait() 函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。
epoll 的方式即使监听的 Socket 数量越多的时候,效率不会大幅度降低,能够同时监听的 Socket 的数目也非常的多了,上限就为系统定义的进程打开的最大文件描述符个数.
3.3.2 epoll 的事件触发机制:
epoll 支持两种事件触发模式,分别是边缘触发(edge-triggered,ET)和水平触发(level-triggered,LT);
使用边缘触发模式时,当被监控的 Socket 描述符上有可读事件发生时,服务器端只会从 epoll_wait 中苏醒一次,即使进程没有调用 read 函数从内核读取数据,也依然只苏醒一次,因此我们程序要保证一次性将内核缓冲区的数据读取完;
使用水平触发模式时,当被监控的 Socket 上有可读事件发生时,服务器端不断地从 epoll_wait 中苏醒,直到内核缓冲区数据被 read 函数读完才结束,目的是告诉我们有数据需要读取;
1)如果使用水平触发模式,当内核通知文件描述符可读写时,接下来还可以继续去检测它的状态,看它是否依然可读或可写。所以在收到通知后,没必要一次执行尽可能多的读写操作。
2)如果使用边缘触发模式,I/O 事件发生时只会通知一次,而且我们不知道到底能读写多少数据,所以在收到通知后需要一次性的把缓冲区的数据读完为止,也就是一直读,直到读到EGAIN(EGAIN说明缓冲区已经空了)为止,否则可能出现读取数据不完整的问题。因此,我们会循环从文件描述符读写数据,那么如果文件描述符是阻塞的,没有数据可读写时,进程会阻塞在读写函数那里,程序就没办法继续往下执行。所以,边缘触发模式一般和非阻塞 I/O 搭配使用,程序会一直执行 I/O 操作,直到系统调用(如 read 和 write)返回错误,错误类型为 EAGAIN 或 EWOULDBLOCK。
3)一般来说,边缘触发的效率比水平触发的效率要高,因为边缘触发可以减少 epoll_wait 的系统调用次数,系统调用也是有一定的开销的的,毕竟也存在上下文的切换。
4)select/poll 只有水平触发模式,epoll 默认的触发模式是水平触发,但是可以根据应用场景设置为边缘触发模式。
5)多路复用 API 返回的事件并不一定可读写的(在Linux下,select() 可能会将一个 socket 文件描述符报告为 “准备读取”,而后续的读取块却没有。例如,当数据已经到达,但经检查后发现有错误的校验和而被丢弃时,就会发生这种情况。也有可能在其他情况下,文件描述符被错误地报告为就绪),如果使用阻塞 I/O, 那么在调用 read/write 时则会发生程序阻塞,因此最好搭配非阻塞 I/O,以便应对极少数的特殊情况。
3 总结:
参考:
1 Select、Poll、Epoll详解;
2 深入学习IO多路复用select/poll/epoll实现原理;
3 这次答应我,一举拿下 I/O 多路复用!