浅谈 select、poll 和 epoll

selectpollepoll都是 IO 多路复用的机制,能够监听多个文件描述符的读/写事件。一旦某个描述符就绪(一般是读或写事件发生了),就能够将发生的事件通知给关心的应用程序去处理该事件。

本质上,selectpollepoll都是同步 I/O 。

下面从基础开始总结一下三者的区别和联系。

1、什么是流、I/O和阻塞?

1.1 流

系统中的流一般是指stream,与网络流flow要区别开。通常包括文件流、管道流、套接字流等。

是对一种有序连续且具有方向性的数据的抽象描述,是一个包含数据源、数据目的地和数据传输的过程。

下面是一个形象的比喻

假设我们有个大水缸,水缸里灌满了水,为了能够让水从大水缸里拿出来使用,就需要接一个水龙头,打开水龙头,水就出来了。
可是大水缸除了放水,我们还希望能不断的蓄水,于是就需要有另外一个口可以给水缸蓄水,蓄水口默认是关闭的,每次都要打开蓄水口。
假设我们往大水缸里蓄水,水有 N 杯,我们只能一杯一杯的往里灌,每次灌入就要打开蓄水口,
为了提高蓄水速度,我们想到了一个办法,准备一个盆,每个盆只能装512杯水,当盆装满后,再从盆里往水缸里灌入。
水就相当于计算机里的数据,而数据是有顺序而且以字节方式存在的,出水口和入水口对应输入输出流,stdin 和 stdout,而后面加的盆就是内部缓冲区,大水缸就是我们的磁盘,磁盘操作相对于 cpu 的处理速度来说非常慢,所以为了提高效率,引入了内部缓冲区。

流和文件描述符(fd)的关系:

流给用户程序提供了更高一级的 I/O 接口,它处在文件描述符的上层。也就是说,流函数是通用文件描述符函数来实现的。

1.2 I/O操作

所有对流的读写操作,都可以被称为 I/O 操作。

在流(主要指缓冲区)中,在没有数据可读的时候,或者说向一个已经写满了数据的流再写数据时,IO 操作就会被挂起等待,这个挂起等待就是阻塞

1.3 阻塞和非阻塞

以送快递为例的场景举例:

  • 你有一份快递和一个手机,快递送达时会打电话通知你,而在此之前,你一直休息,就是阻塞。
  • 但你是急性子,每分钟都要打电话问快递到没到,而快递员接电话和运输只能二选一,快递员在接电话时就会停止运输,这样很耽误快递员的运输速度,这是非阻塞、忙轮询场景。

阻塞时,不会占用 CPU 的时间片,因为时间片资源很宝贵。

非阻塞、忙轮询时,会占用 CPU 时间片,浪费系统资源。

2、解决阻塞死等待

在你只有一个人、一部手机(单线程)时,只能同时接一个快递员的电话或签收一个快递,其他快递员只能等待,这不仅浪费自己的时间,也浪费快递员的时间。

所以你需要多找一些人和一些手机(多线程或多进程)同时处理多个快递,这样就能提高效率。

2.1 非阻塞、忙轮询

非阻塞、忙轮询的方式,可以让用户分别与每个快递员取得联系,虽然可以与多个快递员进行沟通(并发),但是快递员与用户沟通时会停止运输去接电话(浪费 CPU )。

2.2 select

如果开设一个代收点,让快递员把所有的快递全都送到这个代收点,当有快递时,代收点会给你打电话。但代收员不负责记录快递单号和数量,只会告诉你有快递到了,并且只能处理1024个快递信息。

以读取 fd 为例:

最朴素的需求就是关心 N 个 fd 中是否有数据可读,也就是我们期待“可读”事件的通知,而不是盲目地对每个fd调用接收函数(recv)来尝试接收数据。我们应该阻塞在等待事件,当阻塞解除的时候,就意味着,一定有一个或多个fd中有可读的数据。但我们不知道哪个 fd 中会有读事件发生,所以当我们知道有可读事件时,还是要遍历所有的 fd 才查找哪个 fd 是可读的。

伪码:

while true {
  select(fds[...]); // 阻塞
  
  // 有消息送达
  for fd in fds[...] {
    if fd has 数据 {
      处理数据
    }
  }
}

当用户进程(或线程)调用 select 的时候, select 会将需要监控的 read_fds 集合拷贝到内核空间(假设仅监控可读fd),然后遍历自己监控的 fd ,挨个调用 fd 的 poll 逻辑以便检查 fd 是否有可读事件。遍历完所有的 fd 后,如果没有任何一个 fd 可读,那么 select 就会调用schedule_timeout进入延时唤醒状态,使用户进程进入睡眠。如果在timeout时间内某个 fd 有数据可读,或者睡眠时间到达timeout了,用户进程就会被唤醒,开始遍历它监控 fd 集合,挨个收集可读事件返回给用户。

需要改进的三个问题:
  • 被监控和 fds 集合限制在 1024,而 1024 太小,在高并发场景中不够用,需要增加
  • fds 集合需要从用户空间考贝到内核空间,如果不拷贝就会节省内存空间和时间
  • 当被监控的 fds 中某些有些数据可读时,我们希望能得到所有有可读事件的 fds 列表,而不是遍历整个 fds

2.3 poll

你是一个大客户,同时要处理的快递数量远超 1024 个,select的代收员就不能满足你的需求了,需要换一个能接收更多快递信息的代收员。新的代收员同时能接收上万个快递信息。当处理 1024 个快递时,你还能挨个问一遍快递员,但当快递增长到 102400 时,你再挨个问一遍快递员,所耗时间也线性增加,最后你会发现还不如每次只问 1024 个快递员。

select 的三个需要改进的地方中,第一个是用法限制问题,第二和第三个是性能问题。

poll 和 select 非常相似, poll 并没有解决性能问题,只解决了 select 的第一个问题,扩大了 1024 的限制。

poll 改变了 fds 集合的描述方式,使用的 poll_fd 结构而不是 select 中的 fd_set 结构,使用 poll 支持的 fds 集合限制远大于 1024。虽然 poll 解决了1024的问题,但它并没有改变大量 fds 用户态复制到内核态的地址空间的问题,以及因个别 fd 事件遍历整个 fds 的低效问题。

poll 随着监控的 fds 集合的增加性能呈线性下降,这一点还不如 select ,所以 poll 不适用于大并发场景。

2.4 epoll

epoll 是对 select 的正确改进。

代收员不仅会通知我们有快递到了,还会告诉我们有几个快递到了,快弟员是谁,快递单号都是什么。我们只需要根据代收员给的信息从指定的快递员手里拿到指定的快递进行处理。

epoll 只关心有可读事件的 fd ,不需要遍历全部 fds 集合。

伪码:

while true {
	可处理的流[] = epoll_wait(epoll_fd); //阻塞

  //有消息抵达,全部放在 “可处理的流[]”中
	for i in 可处理的流[] {
		读 或者 其他处理
	}
}
2.4.1 fds 集合问题拷贝问题的解决

select 和 poll 会重复地准备整个需要监听的 fds 集合,这是没有必要的。

select/poll 都只有一个方法, epoll 操作过程有3个方法,分别是epoll_create()epoll_ctl()epoll_wait()

epoll 引入了epoll_ctl系统调用,将高频调用的epoll_wait和低频的epoll_ctl隔离开。同时,epoll_ctl通过EPOLL_CTL_ADDEPOLL_CTL_MODEPOLL_CTL_DEL三个操作来分散对需要监听的 fds 集合的修改,做到了有变化才变更,将 select 和 poll 中高频、大块内存拷贝(集中处理)变成epoll_ctl的低频、小块内存的拷贝(分散处理),避免了大量的内存拷贝。

对于高频epoll_wait的可读就绪的 fd 集合返回的拷贝问题,epoll 通过内核与用户空间mmap(内存映射)同一块内存来解决。mmap将用户空间的一块地址和内核空间的一块地址映射到相同的一块物理内存地址(用户空间和内核空间都是虚拟地址,最后都要映射到物理地址),使得这块物理内存对内核和用户均可见,减少用户态和内核态之间的数据交换。

epoll 通过epoll_ctl来对监控的 fds 集合来进行增、删、改,那么必须涉及到 fd 的快速查找问题,于是,一个低时间复杂度的增、删、改、查的数据结构来组织被监控的 fds 集合是必不可少的了。在linux 2.6.8之前的内核,epoll 使用 hash 来组织 fds 集合,于是在创建 epoll fd 的时候,epoll 需要初始化 hash 的大小。于是epoll_create(int size)有一个参数size,以便内核根据size的大小来分配 hash 的大小。在linux 2.6.8以后的内核中,epoll使用红黑树来组织监控的 fds 集合,于是epoll_create(int size)的参数size实际上已经没有意义了。

2.4.2 按需遍历就绪的 fds 集合

调用epoll_create(int size)返回的特殊的文件描述符epfd,这个文件描述符表示的就是创建的 epoll 实例eventpolleventpoll对象也是文件系统中的一员,也有等待队列single_epoll_wait_list,同时还有一个就绪链表ready_list。用户进程被放入single_epoll_wait_list

当通过epoll_ctl添加、删除或修改所要监听的 fd 时,内核会在eventpollsingle_epoll_wait_list中添加、删除或修改这些 fd 。

没有事件发生时,用户进程会睡眠。当 fd 上有事件发生时,会通过ep_poll_callback将fd添加到ready_list(过程可能是并发的),因为ready_list不为空,用户进程被唤醒,执行epoll_wait,从中single_epoll_wait_list中移除当前进程,再将ready_list传输到用户空间,用户进程遍历ready_list即可。

posted @ 2021-03-25 23:07  thepoy  阅读(195)  评论(0编辑  收藏  举报