IO多路复用

非阻塞IO这一小节的开头,我们提到网络IO模型的演变都是围绕着---如何用尽可能少的线程去处理更多的连接这个核心需求开始展开的。

本小节我们来谈谈IO多路复用模型,那么什么是多路?,什么又是复用呢?

我们还是以这个核心需求来对这两个概念展开阐述:

  • 多路:我们的核心需求是要用尽可能少的线程来处理尽可能多的连接,这里的多路指的就是我们需要处理的众多连接。
  • 复用:核心需求要求我们使用尽可能少的线程尽可能少的系统开销去处理尽可能多的连接(多路),那么这里的复用指的就是用有限的资源,比如用一个线程或者固定数量的线程去处理众多连接上的读写事件。换句话说,在阻塞IO模型中一个连接就需要分配一个独立的线程去专门处理这个连接上的读写,到了IO多路复用模型中,多个连接可以复用这一个独立的线程去处理这多个连接上的读写。

好了,IO多路复用模型的概念解释清楚了,那么问题的关键是我们如何去实现这个复用,也就是如何让一个独立的线程去处理众多连接上的读写事件呢?

这个问题其实在非阻塞IO模型中已经给出了它的答案,在非阻塞IO模型中,利用非阻塞的系统IO调用去不断的轮询众多连接的Socket接收缓冲区

看是否有数据到来,如果有则处理,如果没有则继续轮询下一个Socket。这样就达到了用一个线程去处理众多连接上的读写事件了。

但是非阻塞IO模型最大的问题就是需要不断的发起系统调用去轮询各个Socket中的接收缓冲区是否有数据到来,频繁系统调用随之带来了大量的上下文切换开销。随着并发量的提升,这样也会导致非常严重的性能问题。

那么如何避免频繁的系统调用同时又可以实现我们的核心需求呢?

这就需要操作系统的内核来支持这样的操作,我们可以把频繁的轮询操作交给操作系统内核来替我们完成,这样就避免了在用户空间频繁的去使用系统调用来轮询

所带来的性能开销。

正如我们所想,操作系统内核也确实为我们提供了这样的功能实现,下面我们来一起看下操作系统对IO多路复用模型的实现。

select

select是操作系统内核提供给我们使用的一个系统调用,它解决了在非阻塞IO模型中需要不断的发起系统IO调用去轮询各个连接上的Socket接收缓冲区所带来的用户空间内核空间不断切换的系统开销

select系统调用将轮询的操作交给了内核来帮助我们完成,从而避免了在用户空间不断的发起轮询所带来的的系统性能开销。

  • 首先用户线程在发起select系统调用的时候会阻塞select系统调用上。此时,用户线程从用户态切换到了内核态完成了一次上下文切换
  • 用户线程将需要监听的Socket对应的文件描述符fd数组通过select系统调用传递给内核。此时,用户线程将用户空间中的文件描述符fd数组拷贝内核空间

这里的文件描述符

数组其实是一个BitMapBitMap下标为文件描述符fd,下标对应的值为:1表示该fd上有读写事件,0表示该fd上没有读写事件。

文件描述符fd其实就是一个整数值,在Linux中一切皆文件,Socket也是一个文件。描述进程所有信息的数据结构task_struct中有一个属性struct files_struct *files,它最终指向了一个数组,数组里存放了进程打开的所有文件列表,文件信息封装在struct file结构体中,这个数组存放的类型就是 struct file结构体,数组的下标则是我们常说的文件描述符fd

  • 当用户线程调用完select后开始进入阻塞状态内核开始轮询遍历fd数组,查看fd对应的Socket接收缓冲区中是否有数据到来。如果有数据到来,则将fd对应BitMap的值设置为1。如果没有数据到来,则保持值为0
注意这里内核会修改原始的fd数组!!
  • 内核遍历一遍fd数组后,如果发现有些fd上有IO数据到来,则将修改后的fd数组返回给用户线程。此时,会将fd数组从内核空间拷贝到用户空间
  • 当内核将修改后的fd数组返回给用户线程后,用户线程解除阻塞,由用户线程开始遍历fd数组然后找出fd数组中值为1Socket文件描述符。最后对这些Socket发起系统调用读取数据。
select不会告诉用户线程具体哪些fd上有IO数据到来,只是在IO活跃fd上打上标记,将打好标记的完整fd数组返回给用户线程,所以用户线程还需要遍历fd数组找出具体哪些fd上有IO数据到来。
  • 由于内核在遍历的过程中已经修改了fd数组,所以在用户线程遍历完fd数组后获取到IO就绪Socket后,就需要重置fd数组,并重新调用select传入重置后的fd数组,让内核发起新的一轮遍历轮询。

API介绍

当我们熟悉了select的原理后,就很容易理解内核给我们提供的select API了。

int select(int maxfdp1,fd_set *readset,fd_set *writeset,fd_set *exceptset,const struct timeval *timeout)

select API中我们可以看到,select系统调用是在规定的超时时间内,监听(轮询)用户感兴趣的文件描述符集合上的可读,可写,异常三类事件。

  • maxfdp1 : select传递给内核监听的文件描述符集合中数值最大的文件描述符+1,目的是用于限定内核遍历范围。比如:select监听的文件描述符集合为{0,1,2,3,4},那么maxfdp1的值为5
  • fd_set *readset:可读事件感兴趣的文件描述符集合。
  • fd_set *writeset:可写事件感兴趣的文件描述符集合。
  • fd_set *exceptset:可写事件感兴趣的文件描述符集合。
这里的fd_set就是我们前边提到的文件描述符数组,是一个BitMap结构。
  • const struct timeval *timeout:select系统调用超时时间,在这段时间内,内核如果没有发现有IO就绪的文件描述符,就直接返回。

上小节提到,在内核遍历完fd数组后,发现有IO就绪fd,则会将该fd对应的BitMap中的值设置为1,并将修改后的fd数组,返回给用户线程。

在用户线程中需要重新遍历fd数组,找出IO就绪fd出来,然后发起真正的读写调用。

下面介绍下在用户线程中重新遍历fd数组的过程中,我们需要用到的API

  • void FD_ZERO(fd_set *fdset):清空指定的文件描述符集合,即让fd_set中不在包含任何文件描述符。
  • void FD_SET(int fd, fd_set *fdset):将一个给定的文件描述符加入集合之中。
每次调用select之前都要通过FD_ZEROFD_SET重新设置文件描述符,因为文件描述符集合会在内核被修改
  • int FD_ISSET(int fd, fd_set *fdset):检查集合中指定的文件描述符是否可以读写。用户线程遍历文件描述符集合,调用该方法检查相应的文件描述符是否IO就绪
  • void FD_CLR(int fd, fd_set *fdset):将一个给定的文件描述符从集合中删除

性能开销

虽然select解决了非阻塞IO模型中频繁发起系统调用的问题,但是在整个select工作过程中,我们还是看出了select有些不足的地方。

  • 在发起select系统调用以及返回时,用户线程各发生了一次用户态内核态以及内核态用户态的上下文切换开销。发生2次上下文切换
  • 在发起select系统调用以及返回时,用户线程在内核态需要将文件描述符集合从用户空间拷贝到内核空间。以及在内核修改完文件描述符集合后,又要将它从内核空间拷贝到用户空间。发生2次文件描述符集合的拷贝
  • 虽然由原来在用户空间发起轮询优化成了内核空间发起轮询但select不会告诉用户线程到底是哪些Socket上发生了IO就绪事件,只是对IO就绪Socket作了标记,用户线程依然要遍历文件描述符集合去查找具体IO就绪Socket。时间复杂度依然为O(n)
大部分情况下,网络连接并不总是活跃的,如果select监听了大量的客户端

连接,只有少数的连接活跃,然而使用轮询的这种方式会随着连接数

的增大,效率会越来越低。
  • 内核会对原始的文件描述符集合进行修改。导致每次在用户空间重新发起select调用时,都需要对文件描述符集合进行重置
  • BitMap结构的文件描述符集合,长度为固定的1024,所以只能监听0~1023的文件描述符。
  • select系统调用 不是线程安全的。

以上select的不足所产生的性能开销都会随着并发量的增大而线性增长

很明显select也不能解决C10K问题,只适用于1000个左右的并发连接场景。

poll

poll相当于是改进版的select,但是工作原理基本和select没有本质的区别。

int poll(struct pollfd *fds, unsigned int nfds, int timeout)

struct pollfd {
    int   fd;         /* 文件描述符 */
    short events;     /* 需要监听的事件 */
    short revents;    /* 实际发生的事件 由内核修改设置 */
};

select中使用的文件描述符集合是采用的固定长度为1024的BitMap结构的fd_set,而poll换成了一个pollfd结构没有固定长度的数组,这样就没有了最大描述符数量的限制(当然还会受到系统文件描述符限制)

poll只是改进了select只能监听1024个文件描述符的数量限制,但是并没有在性能方面做出改进。和select上本质并没有多大差别。

  • 同样需要在内核空间用户空间中对文件描述符集合进行轮询,查找出IO就绪Socket的时间复杂度依然为O(n)
  • 同样需要将包含大量文件描述符的集合整体在用户空间内核空间之间来回复制无论这些文件描述符是否就绪。他们的开销都会随着文件描述符数量的增加而线性增大。
  • select,poll在每次新增,删除需要监听的socket时,都需要将整个新的socket集合全量传至内核

poll同样不适用高并发的场景。依然无法解决C10K问题。

epoll

通过上边对select,poll核心原理的介绍,我们看到select,poll的性能瓶颈主要体现在下面三个地方:

  • 因为内核不会保存我们要监听的socket集合,所以在每次调用select,poll的时候都需要传入,传出全量的socket文件描述符集合。这导致了大量的文件描述符在用户空间内核空间频繁的来回复制。
  • 由于内核不会通知具体IO就绪socket,只是在这些IO就绪的socket上打好标记,所以当select系统调用返回时,在用户空间还是需要完整遍历一遍socket文件描述符集合来获取具体IO就绪socket
  • 内核空间中也是通过遍历的方式来得到IO就绪socket

下面我们来看下epoll是如何解决这些问题的。在介绍epoll的核心原理之前,我们需要介绍下理解epoll工作过程所需要的一些核心基础知识。

Socket的创建

服务端线程调用accept系统调用后开始阻塞,当有客户端连接上来并完成TCP三次握手后,内核会创建一个对应的Socket作为服务端与客户端通信的内核接口。

在Linux内核的角度看来,一切皆是文件,Socket也不例外,当内核创建出Socket之后,会将这个Socket放到当前进程所打开的文件列表中管理起来。

下面我们来看下进程管理这些打开的文件列表相关的内核数据结构是什么样的?在了解完这些数据结构后,我们会更加清晰的理解Socket在内核中所发挥的作用。并且对后面我们理解epoll的创建过程有很大的帮助。

进程中管理文件列表结构

struct tast_struct是内核中用来表示进程的一个数据结构,它包含了进程的所有信息。本小节我们只列出和文件管理相关的属性。

其中进程内打开的所有文件是通过一个数组fd_array来进行组织管理,数组的下标即为我们常提到的文件描述符,数组中存放的是对应的文件数据结构struct file。每打开一个文件,内核都会创建一个struct file与之对应,并在fd_array中找到一个空闲位置分配给它,数组中对应的下标,就是我们在用户空间用到的文件描述符

对于任何一个进程,默认情况下,文件描述符 0表示 stdin 标准输入,文件描述符 1表示stdout 标准输出,文件描述符2表示stderr 标准错误输出

进程中打开的文件列表fd_array定义在内核数据结构struct files_struct中,在struct fdtable结构中有一个指针struct fd **fd指向fd_array

由于本小节讨论的是内核网络系统部分的数据结构,所以这里拿Socket文件类型来举例说明:

用于封装文件元信息的内核数据结构struct file中的private_data指针指向具体的Socket结构。

struct file中的file_operations属性定义了文件的操作函数,不同的文件类型,对应的file_operations是不同的,针对Socket文件类型,这里的file_operations指向socket_file_ops

我们在用户空间Socket发起的读写等系统调用,进入内核首先会调用的是Socket对应的struct file中指向的socket_file_ops比如:对Socket发起write写操作,在内核中首先被调用的就是socket_file_ops中定义的sock_write_iterSocket发起read读操作内核中对应的则是sock_read_iter
static const struct file_operations socket_file_ops = {
  .owner =  THIS_MODULE,
  .llseek =  no_llseek,
  .read_iter =  sock_read_iter,
  .write_iter =  sock_write_iter,
  .poll =    sock_poll,
  .unlocked_ioctl = sock_ioctl,
  .mmap =    sock_mmap,
  .release =  sock_close,
  .fasync =  sock_fasync,
  .sendpage =  sock_sendpage,
  .splice_write = generic_splice_sendpage,
  .splice_read =  sock_splice_read,
};

Socket内核结构

在我们进行网络程序的编写时会首先创建一个Socket,然后基于这个Socket进行bindlisten,我们先将这个Socket称作为监听Socket

  1. 当我们调用accept后,内核会基于监听Socket创建出来一个新的Socket专门用于与客户端之间的网络通信。并将监听Socket中的Socket操作函数集合inet_stream_opsops赋值到新的Socketops属性中。
const struct proto_ops inet_stream_ops = {
  .bind = inet_bind,
  .connect = inet_stream_connect,
  .accept = inet_accept,
  .poll = tcp_poll,
  .listen = inet_listen,
  .sendmsg = inet_sendmsg,
  .recvmsg = inet_recvmsg,
  ......
}
这里需要注意的是,监听的 socket和真正用来网络通信的 Socket,是两个 Socket,一个叫作监听 Socket,一个叫作已连接的Socket
  1. 接着内核会为已连接的Socket创建struct file并初始化,并把Socket文件操作函数集合(socket_file_ops)赋值给struct file中的f_ops指针。然后将struct socket中的file指针指向这个新分配申请的struct file结构体。
内核会维护两个队列:
  • 一个是已经完成TCP三次握手,连接状态处于established的连接队列。内核中为icsk_accept_queue
  • 一个是还没有完成TCP三次握手,连接状态处于syn_rcvd的半连接队列。
  1. 然后调用socket->ops->accept,从Socket内核结构图中我们可以看到其实调用的是inet_accept,该函数会在icsk_accept_queue中查找是否有已经建立好的连接,如果有的话,直接从icsk_accept_queue中获取已经创建好的struct sock。并将这个struct sock对象赋值给struct socket中的sock指针。

struct sockstruct socket中是一个非常核心的内核对象,正是在这里定义了我们在介绍网络包的接收发送流程中提到的接收队列发送队列等待队列数据就绪回调函数指针内核协议栈操作函数集合

  • 根据创建Socket时发起的系统调用sock_create中的protocol参数(对于TCP协议这里的参数值为SOCK_STREAM)查找到对于 tcp 定义的操作方法实现集合 inet_stream_opstcp_prot。并把它们分别设置到socket->opssock->sk_prot上。
这里可以回看下本小节开头的《Socket内核结构图》捋一下他们之间的关系。

socket相关的操作接口定义在inet_stream_ops函数集合中,负责对上给用户提供接口。而socket与内核协议栈之间的操作接口定义在struct sock中的sk_prot指针上,这里指向tcp_prot协议操作函数集合。
struct proto tcp_prot = {
  .name      = "TCP",
  .owner      = THIS_MODULE,
  .close      = tcp_close,
  .connect    = tcp_v4_connect,
  .disconnect    = tcp_disconnect,
  .accept      = inet_csk_accept,
  .keepalive    = tcp_set_keepalive,
  .recvmsg    = tcp_recvmsg,
  .sendmsg    = tcp_sendmsg,
  .backlog_rcv    = tcp_v4_do_rcv,
   ......
}
之前提到的对Socket发起的系统IO调用,在内核中首先会调用Socket的文件结构struct file中的file_operations文件操作集合,然后调用struct socket中的ops指向的inet_stream_opssocket操作函数,最终调用到struct socksk_prot指针指向的tcp_prot内核协议栈操作函数接口集合。

  • struct sock 对象中的sk_data_ready 函数指针设置为 sock_def_readable,在Socket数据就绪的时候内核会回调该函数。
  • struct sock中的等待队列
  • 中存放的是系统IO调用发生阻塞的进程fd,以及相应的回调函数记住这个地方,后边介绍epoll的时候我们还会提到!
  1. struct filestruct socketstruct sock这些核心的内核对象创建好之后,最后就是把socket对象对应的struct file放到进程打开的文件列表fd_array中。随后系统调用accept返回socket的文件描述符fd给用户程序。

阻塞IO中用户进程阻塞以及唤醒原理

在前边小节我们介绍阻塞IO的时候提到,当用户进程发起系统IO调用时,这里我们拿read举例,用户进程会在内核态查看对应Socket接收缓冲区是否有数据到来。

  • Socket接收缓冲区有数据,则拷贝数据到用户空间,系统调用返回。
  • Socket接收缓冲区没有数据,则用户进程让出CPU进入阻塞状态,当数据到达接收缓冲区时,用户进程会被唤醒,从阻塞状态进入就绪状态,等待CPU调度。

本小节我们就来看下用户进程是如何阻塞Socket上,又是如何在Socket上被唤醒的。理解这个过程很重要,对我们理解epoll的事件通知过程很有帮助

  • 首先我们在用户进程中对Socket进行read系统调用时,用户进程会从用户态转为内核态
  • 在进程的struct task_struct结构找到fd_array,并根据Socket的文件描述符fd找到对应的struct file,调用struct file中的文件操作函数结合file_operationsread系统调用对应的是sock_read_iter
  • sock_read_iter函数中找到struct file指向的struct socket,并调用socket->ops->recvmsg,这里我们知道调用的是inet_stream_ops集合中定义的inet_recvmsg
  • inet_recvmsg中会找到struct sock,并调用sock->skprot->recvmsg,这里调用的是tcp_prot集合中定义的tcp_recvmsg函数。
整个调用过程可以参考上边的《系统IO调用结构图》

熟悉了内核函数调用栈后,我们来看下系统IO调用在tcp_recvmsg内核函数中是如何将用户进程给阻塞掉的

int tcp_recvmsg(struct kiocb *iocb, struct sock *sk, struct msghdr *msg,
  size_t len, int nonblock, int flags, int *addr_len)
{
    .................省略非核心代码...............
   //访问sock对象中定义的接收队列
  skb_queue_walk(&sk->sk_receive_queue, skb) {

    .................省略非核心代码...............

  //没有收到足够数据,调用sk_wait_data 阻塞当前进程
  sk_wait_data(sk, &timeo);
}

int sk_wait_data(struct sock *sk, long *timeo)
{
 //创建struct sock中等待队列上的元素wait_queue_t
 //将进程描述符和回调函数autoremove_wake_function关联到wait_queue_t中
 DEFINE_WAIT(wait);

 // 调用 sk_sleep 获取 sock 对象下的等待队列的头指针wait_queue_head_t
 // 调用prepare_to_wait将新创建的等待项wait_queue_t插入到等待队列中,并将进程状态设置为可打断 INTERRUPTIBLE
 prepare_to_wait(sk_sleep(sk), &wait, TASK_INTERRUPTIBLE);
 set_bit(SOCK_ASYNC_WAITDATA, &sk->sk_socket->flags);

 // 通过调用schedule_timeout让出CPU,然后进行睡眠,导致一次上下文切换
 rc = sk_wait_event(sk, timeo, !skb_queue_empty(&sk->sk_receive_queue));
 ...
  • 首先会在DEFINE_WAIT中创建struct sock中等待队列上的等待类型wait_queue_t
#define DEFINE_WAIT(name) DEFINE_WAIT_FUNC(name, autoremove_wake_function)

#define DEFINE_WAIT_FUNC(name, function)    \
 wait_queue_t name = {      \
  .private = current,    \
  .func  = function,    \
  .task_list = LIST_HEAD_INIT((name).task_list), \
 }

等待类型wait_queue_t中的private用来关联阻塞在当前socket上的用户进程fdfunc用来关联等待项上注册的回调函数。这里注册的是autoremove_wake_function

  • 调用sk_sleep(sk)获取struct sock对象中的等待队列头指针wait_queue_head_t
  • 调用prepare_to_wait将新创建的等待项wait_queue_t插入到等待队列中,并将进程设置为可打断 INTERRUPTIBL
  • 调用sk_wait_event让出CPU,进程进入睡眠状态。

用户进程的阻塞过程我们就介绍完了,关键是要理解记住struct sock中定义的等待队列上的等待类型wait_queue_t的结构。后面epoll的介绍中我们还会用到它。

下面我们接着介绍当数据就绪后,用户进程是如何被唤醒的

在本文开始介绍《网络包接收过程

》这一小节中我们提到:

  • 当网络数据包到达网卡时,网卡通过DMA的方式将数据放到RingBuffer中。
  • 然后向CPU发起硬中断,在硬中断响应程序中创建sk_buffer,并将网络数据拷贝至sk_buffer中。
  • 随后发起软中断,内核线程ksoftirqd响应软中断,调用poll函数sk_buffer送往内核协议栈做层层协议处理。
  • 在传输层tcp_rcv 函数中,去掉TCP头,根据四元组(源IP,源端口,目的IP,目的端口)查找对应的Socket
  • 最后将sk_buffer放到Socket中的接收队列里。

上边这些过程是内核接收网络数据的完整过程,下边我们来看下,当数据包

接收完毕后,用户进程是如何被唤醒的。

  • 当软中断将sk_buffer放到Socket的接收队列上时,接着就会调用数据就绪函数回调指针sk_data_ready,前边我们提到,这个函数指针在初始化的时候指向了sock_def_readable函数。
  • sock_def_readable函数中会去获取socket->sock->sk_wq等待队列。在wake_up_common函数中从等待队列sk_wq中找出一个等待项wait_queue_t,回调注册在该等待项上的func回调函数(wait_queue_t->func),创建等待项wait_queue_t是我们提到,这里注册的回调函数是autoremove_wake_function
即使是有多个进程都阻塞在同一个 socket 上,也只唤醒 1 个进程。其作用是为了避免惊群。
  • autoremove_wake_function函数中,根据等待项wait_queue_t上的private关联的阻塞进程fd调用try_to_wake_up唤醒阻塞在该Socket上的进程。
记住wait_queue_t中的func函数指针,在epoll中这里会注册epoll的回调函数。

现在理解epoll所需要的基础知识我们就介绍完了,唠叨了这么多,下面终于正式进入本小节的主题epoll了。

epoll_create创建epoll对象

epoll_create是内核提供给我们创建epoll对象的一个系统调用,当我们在用户进程中调用epoll_create时,内核会为我们创建一个struct eventpoll对象,并且也有相应的struct file与之关联,同样需要把这个struct eventpoll对象所关联的struct file放入进程打开的文件列表fd_array中管理。

熟悉了Socket的创建逻辑,epoll的创建逻辑也就不难理解了。

struct eventpoll对象关联的struct file中的file_operations 指针指向的是eventpoll_fops操作函数集合。
static const struct file_operations eventpoll_fops = {
     .release = ep_eventpoll_release;
     .poll = ep_eventpoll_poll,
}

struct eventpoll {

    //等待队列,阻塞在epoll上的进程会放在这里
    wait_queue_head_t wq;

    //就绪队列,IO就绪的socket连接会放在这里
    struct list_head rdllist;

    //红黑树用来管理所有监听的socket连接
    struct rb_root rbr;

    ......
}
  • wait_queue_head_t wq:epoll中的等待队列,队列里存放的是阻塞epoll上的用户进程。在IO就绪的时候epoll可以通过这个队列找到这些阻塞的进程并唤醒它们,从而执行IO调用读写Socket上的数据。
这里注意与Socket中的等待队列区分!!!
  • struct list_head rdllist:epoll中的就绪队列,队列里存放的是都是IO就绪Socket,被唤醒的用户进程可以直接读取这个队列获取IO活跃Socket。无需再次遍历整个Socket集合。
这里正是epollselect ,poll高效之处,select ,poll返回的是全部的socket连接,我们需要在用户空间再次遍历找出真正IO活跃Socket连接。 而epoll只是返回IO活跃Socket连接。用户进程可以直接进行IO操作。
  • struct rb_root rbr : 由于红黑树在查找插入删除等综合性能方面是最优的,所以epoll内部使用一颗红黑树来管理海量的Socket连接。
select数组管理连接,poll链表管理连接。

epoll_ctl向epoll对象中添加监听的Socket

当我们调用epoll_create在内核中创建出epoll对象struct eventpoll后,我们就可以利用epoll_ctlepoll中添加我们需要管理的Socket连接了。

  1. 首先要在epoll内核中创建一个表示Socket连接的数据结构struct epitem,而在epoll中为了综合性能的考虑,采用一颗红黑树来管理这些海量socket连接。所以struct epitem是一个红黑树节点。

struct epitem
{
      //指向所属epoll对象
      struct eventpoll *ep; 
      //注册的感兴趣的事件,也就是用户空间的epoll_event     
      struct epoll_event event; 
      //指向epoll对象中的就绪队列
      struct list_head rdllink;  
      //指向epoll中对应的红黑树节点
      struct rb_node rbn;     
      //指向epitem所表示的socket->file结构以及对应的fd
      struct epoll_filefd ffd;                  
  }
这里重点记住struct epitem结构中的rdllink以及epoll_filefd成员,后面我们会用到。
  1. 在内核中创建完表示Socket连接的数据结构struct epitem后,我们就需要在Socket中的等待队列上创建等待项wait_queue_t并且注册epoll的回调函数ep_poll_callback

通过阻塞IO中用户进程阻塞以及唤醒原理

小节的铺垫,我想大家已经猜到这一步的意义所在了吧!当时在等待项wait_queue_t中注册的是autoremove_wake_function回调函数。还记得吗?

epoll的回调函数ep_poll_callback正是epoll同步IO事件通知机制的核心所在,也是区别于select,poll采用内核轮询方式的根本性能差异所在。

这里又出现了一个新的数据结构struct eppoll_entry,那它的作用是干什么的呢?大家可以结合上图先猜测下它的作用!

我们知道socket->sock->sk_wq等待队列中的类型是wait_queue_t,我们需要在struct epitem所表示的socket的等待队列上注册epoll回调函数ep_poll_callback

这样当数据到达socket中的接收队列时,内核会回调sk_data_ready,在阻塞IO中用户进程阻塞以及唤醒原理这一小节中,我们知道这个sk_data_ready函数指针会指向sk_def_readable函数,在sk_def_readable中会回调注册在等待队列里的等待项wait_queue_t -> func回调函数ep_poll_callbackep_poll_callback中需要找到epitem,将IO就绪epitem放入epoll中的就绪队列中。

socket等待队列中类型是wait_queue_t无法关联到epitem。所以就出现了struct eppoll_entry结构体,它的作用就是关联Socket等待队列中的等待项wait_queue_tepitem

struct eppoll_entry { 
   //指向关联的epitem
   struct epitem *base; 

  // 关联监听socket中等待队列中的等待项 (private = null  func = ep_poll_callback)
   wait_queue_t wait;   

   // 监听socket中等待队列头指针
   wait_queue_head_t *whead; 
    .........
  };

这样在ep_poll_callback回调函数中就可以根据Socket等待队列中的等待项wait,通过container_of宏找到eppoll_entry,继而找到epitem了。

container_of在Linux内核中是一个常用的宏,用于从包含在某个结构中的指针获得结构本身的指针,通俗地讲就是通过结构体变量中某个成员的首地址进而获得整个结构体变量的首地址。

这里需要注意下这次等待项wait_queue_t中的private设置的是null,因为这里Socket是交给epoll来管理的,阻塞在Socket上的进程是也由epoll来唤醒。在等待项wait_queue_t注册的funcep_poll_callback而不是autoremove_wake_function阻塞进程并不需要autoremove_wake_function来唤醒,所以这里设置privatenull
  1. 当在Socket的等待队列中创建好等待项wait_queue_t并且注册了epoll的回调函数ep_poll_callback,然后又通过eppoll_entry关联了epitem后。 剩下要做的就是将epitem插入到epoll中的红黑树struct rb_root rbr中。
这里可以看到epoll另一个优化的地方,epoll将所有的socket连接通过内核中的红黑树来集中管理。每次添加或者删除socket连接都是增量添加删除,而不是像select,poll那样每次调用都是全量socket连接集合传入内核。避免了频繁大量内存拷贝

epoll_wait同步阻塞获取IO就绪的Socket

  1. 用户程序调用epoll_wait后,内核首先会查找epoll中的就绪队列eventpoll->rdllist是否有IO就绪epitemepitem里封装了socket的信息。如果就绪队列中有就绪的epitem,就将就绪的socket信息封装到epoll_event返回。
  2. 如果eventpoll->rdllist就绪队列中没有IO就绪epitem,则会创建等待项wait_queue_t,将用户进程的fd关联到wait_queue_t->private上,并在等待项wait_queue_t->func上注册回调函数default_wake_function。最后将等待项添加到epoll中的等待队列中。用户进程让出CPU,进入阻塞状态

这里和阻塞IO模型中的阻塞原理是一样的,只不过在阻塞IO模型中注册到等待项wait_queue_t->func上的是autoremove_wake_function,并将等待项添加到socket中的等待队列中。这里注册的是default_wake_function,将等待项添加到epoll中的等待队列上。

  1. 前边做了那么多的知识铺垫,下面终于到了epoll的整个工作流程了:

  • 当网络数据包在软中断中经过内核协议栈的处理到达socket的接收缓冲区时,紧接着会调用socket的数据就绪回调指针sk_data_ready,回调函数为sock_def_readable。在socket的等待队列中找出等待项,其中等待项中注册的回调函数为ep_poll_callback
  • 在回调函数ep_poll_callback中,根据struct eppoll_entry中的struct wait_queue_t wait通过container_of宏找到eppoll_entry对象并通过它的base指针找到封装socket的数据结构struct epitem,并将它加入到epoll中的就绪队列rdllist中。
  • 随后查看epoll中的等待队列中是否有等待项,也就是说查看是否有进程阻塞在epoll_wait上等待IO就绪socket。如果没有等待项,则软中断处理完成。
  • 如果有等待项,则回到注册在等待项中的回调函数default_wake_function,在回调函数中唤醒阻塞进程,并将就绪队列rdllist中的epitemIO就绪socket信息封装到struct epoll_event中返回。
  • 用户进程拿到epoll_event获取IO就绪的socket,发起系统IO调用读取数据。

再谈水平触发和边缘触发

网上有大量的关于这两种模式的讲解,大部分讲的比较模糊,感觉只是强行从概念上进行描述,看完让人难以理解。所以在这里,笔者想结合上边epoll的工作过程,再次对这两种模式做下自己的解读,力求清晰的解释出这两种工作模式的异同。

经过上边对epoll工作过程的详细解读,我们知道,当我们监听的socket上有数据到来时,软中断会执行epoll的回调函数ep_poll_callback,在回调函数中会将epoll中描述socket信息的数据结构epitem插入到epoll中的就绪队列rdllist中。随后用户进程从epoll的等待队列中被唤醒,epoll_waitIO就绪socket返回给用户进程,随即epoll_wait会清空rdllist

水平触发边缘触发最关键的区别就在于当socket中的接收缓冲区还有数据可读时。epoll_wait是否会清空rdllist

  • 水平触发:在这种模式下,用户线程调用epoll_wait获取到IO就绪的socket后,对Socket进行系统IO调用读取数据,假设socket中的数据只读了一部分没有全部读完,这时再次调用epoll_waitepoll_wait会检查这些Socket中的接收缓冲区是否还有数据可读,如果还有数据可读,就将socket重新放回rdllist。所以当socket上的IO没有被处理完时,再次调用epoll_wait依然可以获得这些socket,用户进程可以接着处理socket上的IO事件。
  • 边缘触发: 在这种模式下,epoll_wait就会直接清空rdllist,不管socket上是否还有数据可读。所以在边缘触发模式下,当你没有来得及处理socket接收缓冲区的剩下可读数据时,再次调用epoll_wait,因为这时rdlist已经被清空了,socket不会再次从epoll_wait中返回,所以用户进程就不会再次获得这个socket了,也就无法在对它进行IO处理了。除非,这个socket上有新的IO数据到达,根据epoll的工作过程,该socket会被再次放入rdllist中。
如果你在边缘触发模式下,处理了部分socket上的数据,那么想要处理剩下部分的数据,就只能等到这个socket上再次有网络数据到达。

Netty中实现的EpollSocketChannel默认的就是边缘触发模式。JDKNIO默认是水平触发模式。

epoll对select,poll的优化总结

  • epoll在内核中通过红黑树管理海量的连接,所以在调用epoll_wait获取IO就绪的socket时,不需要传入监听的socket文件描述符。从而避免了海量的文件描述符集合在用户空间内核空间中来回复制。
select,poll每次调用时都需要传递全量的文件描述符集合,导致大量频繁的拷贝操作。
  • epoll仅会通知IO就绪的socket。避免了在用户空间遍历的开销。
select,poll只会在IO就绪的socket上打好标记,依然是全量返回,所以在用户空间还需要用户程序在一次遍历全量集合找出具体IO就绪的socket。
  • epoll通过在socket的等待队列上注册回调函数ep_poll_callback通知用户程序IO就绪的socket。避免了在内核中轮询的开销。
大部分情况下socket上并不总是IO活跃的,在面对海量连接的情况下,select,poll采用内核轮询的方式获取IO活跃的socket,无疑是性能低下的核心原因。

根据以上epoll的性能优势,它是目前为止各大主流网络框架,以及反向代理中间件使用到的网络IO模型。

利用epoll多路复用IO模型可以轻松的解决C10K问题。

C100k的解决方案也还是基于C10K的方案,通过epoll 配合线程池,再加上 CPU、内存和网络接口的性能和容量提升。大部分情况下,C100K很自然就可以达到。

甚至C1000K的解决方法,本质上还是构建在 epoll多路复用 I/O 模型上。只不过,除了 I/O 模型之外,还需要从应用程序到 Linux 内核、再到 CPU、内存和网络等各个层次的深度优化,特别是需要借助硬件,来卸载那些原来通过软件处理的大量功能(去掉大量的中断响应开销以及内核协议栈处理的开销)。

转自:https://www.zhihu.com/question/306267779

posted @ 2022-03-09 10:26  甜菜波波  阅读(131)  评论(0编辑  收藏  举报