IO多路复用的Select、Poll和Epoll

select/poll/epoll 是如何获取网络事件的呢?

在获取事件时,先把所有连接(文件描述符)传给内核,再由内核返回产生了事件的连接,然后在用户态中再处理这些连接对应的请求即可。

Select、Poll 和 Epoll

Select

select 实现多路复用的方式是,将已连接的 Socket 都放到一个文件描述符集合,然后调用 select 函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件产生,检查的方式很粗暴,就是通过遍历文件描述符集合的方式,当检查到有事件产生后,将此 Socket 标记为可读或可写, 接着再把整个文件描述符集合拷贝回用户态里,然后用户态还需要再通过遍历的方法找到可读或可写的 Socket,然后再对其处理。

对于 select 这种方式,需要进行 2 次「遍历」文件描述符集合,一次是在内核态里,一个次是在用户态里 ,而且还会发生 2 次「拷贝」文件描述符集合,先从用户空间传入内核空间,由内核修改后,再传出到用户空间中。

select 使用固定长度的 BitsMap,表示文件描述符集合,而且所支持的文件描述符的个数是有限制的,在 Linux 系统中,由内核中的 FD_SETSIZE 限制, 默认最大值为 1024,只能监听 0~1023 的文件描述符。

执行原理:
  1. 将当前进程的所有文件描述符,一次性的从用户态拷贝到内核态。

  2. 在内核中快速的无差别的遍历每个 fd ,判断是否有数据到达。

  3. 将所有 fd 状态,从内核态拷贝到用户态,并返回已就绪 fd 的个数。

  4. 在用户态遍历判断具体哪个 fd 已就绪,然后进行相应的事件处理。

限制和不足:
  1. 文件描述符是 bitmap 结构,且有长度为 1024 的限制。

  2. fdset 无法做到重用,每次循环都必须重新创建。

  3. 频繁的用户态和内核态拷贝,性能开销较大。

  4. 需要对文件描述符表进行遍历,O(n) 的轮询时间复杂度。

Poll

poll 不再用 BitsMap 来存储所关注的文件描述符,取而代之用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制,当然还会受到系统文件描述符限制。

但是 poll 和 select 并没有太大的本质区别,都是使用「线性结构」存储进程关注的 Socket 集合,因此都需要遍历文件描述符集合来找到可读或可写的 Socket,时间复杂度为 O(n),而且也需要在用户态与内核态之间拷贝文件描述符集合,这种方式随着并发数上来,性能的损耗会呈指数级增长。

// pollfd 结构体原型
struct pollfd{
  int fd;//文件描述符
  short events;//注册的事件
  short revents;//实际发生的事件,由内核填充
}
执行原理:
  1. 将当前进程的所有文件描述符,一次性的从用户态拷贝到内核态。

  2. 在内核中快速的无差别的遍历每个 fd ,判断是否有数据到达。

  3. 将所有 fd 状态,从内核态拷贝到用户态,并返回已就绪 fd 的个数。

  4. 在用户态遍历判断具体哪个 fd 已就绪,然后进行相应的事件处理。

限制和不足:
  1. poll 模型采用的是 pollfd 结构数组,解决了 Select 的 1024 个文件描述符的限制。

  2. 仍然存在频繁的用户态和内核态拷贝,性能开销较大。

  3. 需要对文件描述符表进行遍历,O(n) 的轮询时间复杂度。

Epoll

如下的代码中,先用epoll_create 创建一个 epoll 对象 epfd,再通过 epoll_ctl 将需要监视的 socket 添加到 epfd 中,最后调用 epoll_wait 等待数据。

int s = socket(AF_INET, SOCK_STREAM, 0);
bind(s, ...);
listen(s, ...)

int epfd = epoll_create(...);
epoll_ctl(epfd, ...); //将所有需要监听的socket添加到epfd中

while(1) {
    int n = epoll_wait(...);
    for(接收到数据的socket){
        //处理
    }
}

epoll 通过两个方面,很好解决了 select/poll 的问题。

  • 第一点,epoll 在内核里使用红黑树来跟踪进程所有待检测的文件描述字,把需要监控的 socket 通过 epoll_ctl() 函数加入内核中的红黑树里,红黑树是个高效的数据结构,增删改一般时间复杂度是 O(logn)。而 select/poll 内核里没有类似 epoll 红黑树这种保存所有待检测的 socket 的数据结构,所以 select/poll 每次操作时都传入整个 socket 集合给内核,而 epoll 因为在内核维护了红黑树,可以保存所有待检测的 socket ,所以只需要传入一个待检测的 socket,减少了内核和用户空间大量的数据拷贝和内存分配。

  • 第二点, epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用 epoll_wait() 函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。

从下图你可以看到 epoll 相关的接口作用:

在这里插入图片描述

epoll 的方式即使监听的 Socket 数量越多的时候,效率不会大幅度降低,能够同时监听的 Socket 的数目也非常的多了,上限就为系统定义的进程打开的最大文件描述符个数。因而,epoll 被称为解决 C10K 问题的利器

epoll_wait 实现的内核代码中调用了 __put_user 函数,这个函数就是将数据从内核拷贝到用户空间。

// epoll_event 结构体原型
struct pollfd{
  __uint32_t events;
  epoll_data_t data;
}

typedef union epoll_data{
  void* ptr;
  int fd;
  __uint32_t u32;
  __uint64_t u64;
}epoll_data_t;
Epoll 的优势:
  1. 在 epoll_ctl() 函数中,为每个文件描述符都指定了回调函数,基于回调函数把就绪事件放到就绪队列中,因此,把时间按复杂度从 O(n) 降到了 O(1)。

  2. 只需要在 epoll_ctl() 时传递一次文件描述符, epoll_wait() 不需要再次传递文件描述符。

  3. epoll 基于红黑树 + 双链表存储事件,没有最大连接数的限制,不存在 C10K 问题。

  4. Epoll 并没有使用 MMAP 零拷贝技术。

边缘触发和水平触发

epoll 支持两种事件触发模式,分别是边缘触发(edge-triggered,ET)和水平触发(level-triggered,LT

  • 使用边缘触发模式时,当被监控的 Socket 描述符上有可读事件发生时,服务器端只会从 epoll_wait 中苏醒一次,即使进程没有调用 read 函数从内核读取数据,也依然只苏醒一次,因此我们程序要保证一次性将内核缓冲区的数据读取完;

  • 使用水平触发模式时,当被监控的 Socket 上有可读事件发生时,服务器端不断地从 epoll_wait 中苏醒,直到内核缓冲区数据被 read 函数读完才结束,目的是告诉我们有数据需要读取;

举个例子,你的快递被放到了一个快递箱里,如果快递箱只会通过短信通知你一次,即使你一直没有去取,它也不会再发送第二条短信提醒你,这个方式就是边缘触发;如果快递箱发现你的快递没有被取出,它就会不停地发短信通知你,直到你取出了快递,它才消停,这个就是水平触发的方式。

这就是两者的区别,水平触发的意思是只要满足事件的条件,比如内核中有数据需要读,就一直不断地把这个事件传递给用户;而边缘触发的意思是只有第一次满足条件的时候才触发,之后就不会再传递同样的事件了。

如果使用水平触发模式,当内核通知文件描述符可读写时,接下来还可以继续去检测它的状态,看它是否依然可读或可写。所以在收到通知后,没必要一次执行尽可能多的读写操作。

如果使用边缘触发模式,I/O 事件发生时只会通知一次,而且我们不知道到底能读写多少数据,所以在收到通知后应尽可能地读写数据,以免错失读写的机会。因此,我们会循环从文件描述符读写数据,那么如果文件描述符是阻塞的,没有数据可读写时,进程会阻塞在读写函数那里,程序就没办法继续往下执行。所以,边缘触发模式一般和非阻塞 I/O 搭配使用,程序会一直执行 I/O 操作,直到系统调用(如 readwrite)返回错误,错误类型为 EAGAINEWOULDBLOCK

一般来说,边缘触发的效率比水平触发的效率要高,因为边缘触发可以减少 epoll_wait 的系统调用次数,系统调用也是有一定的开销的的,毕竟也存在上下文的切换。

select/poll 只有水平触发模式,epoll 默认的触发模式是水平触发,但是可以根据应用场景设置为边缘触发模式。

另外,使用 I/O 多路复用时,最好搭配非阻塞 I/O 一起使用。

多路复用 API 返回的事件并不一定可读写的,如果使用阻塞 I/O, 那么在调用 read/write 时则会发生程序阻塞,因此最好搭配非阻塞 I/O,以便应对极少数的特殊情况。

水平触发(默认)
  1. 只要底层有事件就绪,只要不被取走就一直通知上层。

  2. 类似于数字电路的高电平触发,只要一直处于高电平,就会一直触发。

在这里插入图片描述

边缘触发
  1. 只要底层就绪事件的数量,从无到有或由少到多时,epoll 才会通知用户,并且只通知这一次。

  2. 类似于数字电路的上升沿触发,只有当电平由低变高的那一瞬间才会触发。

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值