I/O多路复用的三种实现
为什么需要I/O多路复用
TCP Socket只能实现一对一的通信,它采取了同步阻塞的方式。它有着一个很大的缺点:当服务端在还没处理完一个客户端的网络 I/O 时,或者读写操作发生阻塞时,其他客户端是无法与服务端连接的。(下图是TCP协议的Socket 程序的调用过程)
这样就意味着,不采取什么特殊的方法的话,一个服务器就仅仅只能服务一个客户。这样的话就太浪费资源了,于是我们就需要改进这种网络I/O模型,让服务器能够同时支持更多的客户端。
IO多路复用是为了高效实现服务更多的用户。在正式讲解IO多路复用前,先来讲一下,如果没有IO多路复用,我们可以如何做到服务更多的用户。
我们很容易想到,在一个请求过来的时候,就创建一个线程来处理请求。那这样又有什么问题呢?
如果使用了线程池,能处理的连接数就是线程池的线程数量,这样还是太过于浪费资源且低效。如果不用线程池呢?连接数量上来之后,可能会导致程序直接崩溃或者上下文切换的开销太大导致程序运行效率很低。这样也是太过于浪费资源而且低效。
I/O多路复用
既然为每个请求分配一个线程的方式并不合理,那有没有可能只使用一个线程来维护多个Socket呢?答案是有的,就是I/O多路复用技术。
这种模式下,一个进程虽然任一时刻只能处理一个请求。但是,假如处理每个请求的时间耗时控制在 1 毫秒以内,这样 1 秒内就可以处理上千个请求。把时间拉长来看,多个请求复用了一个进程,这就是多路复用,所以也叫做时分多路复用。
Linux操作系统给我们提供了 select/poll/epoll 内核提供给用户态的多路复用系统调用,进程可以通过一个系统调用函数从内核中获取多个事件。
select/poll/epoll 是如何获取网络事件的呢?在获取事件时,先把所有连接(文件描述符)传给内核,再由内核返回产生了事件的连接,然后在用户态中再处理这些连接对应的请求即可。
select/poll
select 实现多路复用的方式是,将已连接的 Socket 都放到一个文件描述符集合,然后调用 select 函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件产生。检查的方式很粗暴,就是通过遍历的方式,当检查到有事件产生后,将此 Socket 标记为可读或可写, 接着再把整个文件描述符集合拷贝回用户态里,然后用户态再通过遍历的方法找到可读或可写的 Socket,然后再对其处理。
我们来总结一下select这种方式需要多少次遍历和多少次拷贝。select需要进行2次遍历文件描述符集合,一次是在内核态里(将有事件产生的 Socket 标记为可读或可写),一次是在用户态里(取出可读或可写的 Socket )。需要进行2次拷贝文件描述符集合,先从用户空间传入内核空间,由内核修改后,再传回用户空间。
select的缺点是存文件操作符集合的是一个固定长度的BitsMap,默认大小为1024,也就意味着最多只能监听1024个文件描述符。
poll使用了动态数组,用链表进行组织,突破了能放入的文件操作符个数限制。但是 poll和select并没有太大的本质区别,都是使用「线性结构」存储进程关注的 Socket 集合,因此都需要遍历文件描述符集合来找到可读或可写的 Socket,而且也需要在用户态与内核态之间拷贝整个文件描述符集合。这种方式的I/O多路复用,不适合太大规模的并发量,因为随着并发量上来。性能会越来越差。
epoll
先来一段伪代码讲述一下应用程序代码中epoll如何使用
int s = socket(...); // 创建一个socket
bind(s, ...);
listen(s, ...);
int epfd = epoll_create(...); // 创建一个epoll实例
epoll_ctl(epfd, ...); // 将所有需要监听的socket添加到epfd中
while(1) {
int n = epoll_wait(...);
for(接收到数据的socket){
handle(...) // 处理操作的函数
}
}
先用epoll_create 创建一个 epoll实例,再通过 epoll_ctl 将需要监视的 socket 添加到epoll实例中,最后调用 epoll_wait 等待数据,最后进行处理,处理完后再重新进入等待。
那epoll是如何解决select/poll中遍历的数量多,拷贝的数量多这两个问题呢?
- epoll在内核中使用了红黑树跟踪所有待检测的文件描述符。红黑树是一种优秀的数据结构。一般来说,它的增删改的时间复杂度为
O(logn)
。当需要添加一个新的需要检测的socket时,仅需要调用epoll_ctl
这个函数,即可仅将需要检测的文件描述符传入内核,不用传递整个线性表。这无疑大大减少了数据拷贝的数量。 - epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,内核会调用回调函数将其加入到这个就绪事件列表中,当用户调用
epoll_wait()
函数时,只返回有事件发生的文件描述符的个数。这种机制,使得epoll不需要轮询扫描整个 socket 集合,大大提高了效率。
epoll这种I/O多路复用的方式随着监听的socket变多,效率也不会大幅度降低。所以epoll常被我们在需要I/O多路复用的场景中使用。
边缘触发和水平触发
epoll支持两种事件触发,分别是边缘触发和水平触发。
- 使用边缘触发模式,当被监控的socket有可读事件发生时,服务器端只会从epoll_wait中苏醒一次,即使是进程没有读取数据,它也只是苏醒这一次。因此用这个模式时,必须保证一次性将内核缓冲区数据全部读取完。
- 使用水平触发模式,当被监控的socket有可读事件发生时,服务器端不断地从epoll_wait苏醒,直到内核缓冲区被读完才结束。这样能保证让我们知道有哪些数据需要读取。
说一句题外话,Java中的NIO使用的是水平触发模式的epoll。
使用epoll时,最好使用非阻塞I/O(这似乎是在Linux手册中提到的)。在Java中,如果selector中的socketChannel不是非阻塞I/O时,会直接抛异常。
希望我的文章对你有所帮助。
转载自:https://juejin.cn/post/7243609307856683067