深度剖析Select和Epoll的工作原理

为避免陷入代码和细节的汪洋大海中,本文尽量只做文字描述,并忽略一些细枝末节,目的是对整体有个宏观上的把握。

背景

FD,文件描述符。Linux下,所有的操作都是对文件进行操作,这些操作是利用文件描述符来实现的。

每个FD,都有一组operations,比如 read(读)、write(写)、poll(轮询)等。

poll 操作有两个作用:

  • 返回fd状态,比如可读、可写等

  • 执行作为参数传入的 proc 处理函数

每个FD,还有一个等待队列,保存了数据(比如等待的进程)和 callback 回调函数。

Socket也是fd,也有对应的 poll 操作和等待队列。

以收数据为例。网卡有数据包到来时,会通过DMA将数据包写到指定内存地址,然后发硬件中断信号通知CPU。CPU执行中断程序,中断程序会调用网卡驱动的相应函数,先禁用网卡硬中断,再触发软中断。

软中断处理函数调用驱动里的相应函数处理数据包,转成内核网络模块可识别的skb格式,之后将数据包交给网络协议栈处理。所有数据包处理完成后,开启网卡硬中断。

网络协议栈自底向上,依次进行网络层IP层校验、IP分片与重组、路由检查与转发,传输层校验与处理,根据IP和端口找到对应的socket,将数据包放入socket接收队列。通知socket可读状态,执行 wakeup, wakeup 会调用socket fd上的等待队列中的 callback 回调函数。

正文

基于以上,Select和Epoll实现了各自的功能。

Select

Select相关重要函数:

1
int select(int nfds, fd_set* readfds, fd_set* writefds, fd_set* exceptfds, struct timeval* timeout)

Select最终会陷入内核,执行系统调用 select,3个fds从用户空间复制到内核空间。

系统调用 select 中,会遍历fds,调用fd上的 poll 函数,其会调用传入的 procproc 会将 select 所在进程 current 和回调函数 callback 保存到socket fd的等待队列。如果某些fd已经有关注的可读/写等状态,poll 返回这些状态,进而 select 可直接返回。否则,select 所在进程挂起。

当socket读写状态改变时,wakeup 调用 callback。回调函数 callback 会调用默认唤醒函数唤醒socket fd等待队列上保存的进程,即上面挂起的 select 所在进程。

select 当前进程继续执行。再次遍历fds,调用socket fd的 poll 函数,这次传入的 proc 为空,所以不会再次加入等待队列。可读写的socket会返回其状态,select将其加入3个result fds。移除之前加入的所有socket fd等待队列条目。3个result fds从内核空间复制到用户空间。

select 系统调用执行完成,返回用户空间。

观察上述过程,姑且不说 select 的socket fd数量限制问题,有3个方面影响了其性能:

  • 每次 select,都要重新设置socket列表到fds。

  • 每次 select,都要将fds从用户空间复制到内核空间,再从内核空间复制到用户空间。

  • 每次 select,都要遍历fds,加入socket fd等待队列1次,移出socket fd等待队列1次。

Epoll

有句名言:“计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决”。

面对Select的各种问题,出现了Epoll。Epoll可以认为是一个中间层,它的中间层体现在,应用进程不再与socket fd有强关系,而是通过Epoll来管理使用。

Epoll相关重要函数:

1
2
3
4
int eventpoll_init(void)
int epoll_create(int size)
int epoll_ctl(int epfd, int op, int fd, struct epoll_event* event)
int epoll_wait(int epfd, struct epoll_event* events, int maxevents, int timeout)

第一个函数 eventpoll_init 在系统初始化时由系统调用,目的是注册epoll文件系统,这里我们不关心。

很直观的,上面select只有1个,这里有了3个。epoll_create 只会调用1次,epoll_ctl 每个socket fd调用1次,epoll_wait 类似 select,频繁调用。3个函数最终都会陷入内核,执行系统调用。

Epoll 在epoll_create时, 返回一个epoll fd,没错,epoll对象也是个fd,是epoll文件系统的一个file节点,这就意味着它也有一组operations和等待队列。

Epoll使用红黑树来管理socket fd,epoll_ctl 时会将socket fd加入红黑树,这样保证了socket fd只会被加入一次,也只有一次从用户空间传递到内核空间,对比 select 的每次都加入、复制。

Epoll在 epoll_ctl 时,调用socket fd上的 poll 函数,其会调用传入的 procproc会将回调函数 callback 保存到socket fd的等待队列,对比 select 的每次都加入移除。如果某些socket fd已经有关注的可读/写等状态,poll 返回这些状态,将这些socket fd对应的eepitem加入到 epoll就绪列表

Epoll在 epoll_wait 时,会将 epoll_wait 所在进程 current 保存到epoll fd上的等待队列,对比 select 的加入每个socket fd等待队列。检查epoll就绪列表,若不空,epoll_wait 可直接返回。否则,epoll_wait 所在进程挂起。

当socket读写状态改变时,wakeup 调用 callback。回调函数 callback 会将socket fd对应的epitem添加到epoll就绪列表,并唤醒epoll fd等待队列上的进程,即上面挂起的 epoll_wait 所在进程。

epoll_wait 当前进程继续执行。移除之前加入的epoll fd等待队列条目,对比 select 的移除之前加入的每个socket fd等待队列条目。遍历epoll就绪列表,调用socket fd的 poll 函数,这次传入的 proc 为空,所以不会再次加入等待队列,对比 select 的调用所有socket fd的 poll 函数。可读写的socket会返回其状态,将其从内核空间复制到用户空间。

此处有一个点,即ET(边缘触发)和LT(水平触发)的区别。过程中,socket fd对应的epitem会从epoll就绪列表移除,LT模式下,最后会将其再加入到epoll就绪列表,而ET模式不会。

epoll_wait 系统调用执行完成,返回用户空间。

结语

对比Select和Epoll,Select每次都跟socket fd强交互,而Epoll只在第一次强交互,后续socket fd只是通过 callback 回调函数通知epoll,其余工作都由Epoll这个中间层接管了,所以它就可以采取各种手段,提高性能。