高性能网络框架笔记三(IO模型)

在进行网络IO操作时,用什么样的IO默写来读写数据将在很大程度上决定了网络框架的IO性能。所以IO模型的选择是构建一个高性能框架的基础。

在《Unix网络编程》一书中介绍了五种IO模型,阻塞IO,非阻塞IO,IO多路复用、信号驱动IO,异步IO,每一种IO模型的出现都是对前一种的升级优化。

下面分别介绍这五种IO模型各种解决哪些问题,适用于哪些场景,各自的优缺点是什么?

1、阻塞IO(BIO)

经过前文对阻塞这个概念的介绍,我们已经理解了阻塞IO的概念和过程。既然这小节我们谈的是IO,那么下边我们来看下阻塞IO模型下,网络数据的读写过程。

阻塞读

当用户线程发起read系统调用,用户线程从用户态切换到内核态,在内核中去查看Socket接收缓冲区是否有数据到来。

  • Socket接收缓冲区中有数据,则用户线程在内核态将内核空间中的数据拷贝到用户空间,系统IO调用返回,
  • Socket接收缓冲区中午数据,则用户线程让出CPU进入阻塞状态。当数据到达Socket接收缓冲区后,内核唤醒阻塞状态中的用户线程进入就绪状态,随后经过CPU的调度获取到CPU quota进入运行状态,将内核控件的数据拷贝到用户空间,随后系统调用返回。

阻塞写

当用户线程发起send系统调用时,用户线程从用户态切换到内核态,将发送数据从用户空间拷贝到内核空间中的Socket发送缓冲区。

  • 当Socket发送缓冲区能够容纳下发送数据时,用户线程会将全部的发送数据写入Socket缓冲区,然后执行在《网络包发送流程》小节中的后续流程,然后返回。
  • 当Socket发送缓冲区空间不够,无法容纳下全部发送数据时,用户线程让出CPU,进入阻塞状态,直达Socket发送缓冲区能够容纳下全部发送数据时,内核唤醒用户线程,执行后续发送流程。

阻塞IO模型下的写操作做事风格比价硬刚,非得要不全部的发送数据写入发送缓冲区才肯善罢甘休。

阻塞IO模型

由于阻塞IO的读写特点,所以导致在阻塞IO模型下,每个请求都需要被一个独立的线程处理。一个线程在同一个时刻只能与一个连接绑定。来一个请求,服务端就需要创建一个线程来处理请求。

当客户端请求的并发量突然增大时,服务端在一瞬间就会创造出大量的线程,而创建线程是需要系统资源开销的,这样一来就会一瞬间占用大量的系统资源。

如果客户端创建好连接后,但是一直不发数据,大部分情况下,网络连接也并不总是有数据可读,那么在空闲的这段时间内,服务端线程就会一直处于阻塞状态,无法干其他的事情。CPU也无法得到充分的发挥,同时还会导致大量线程切换的开销。

适用场景

基于以上阻塞IO模型的特点,改模型只适用于连接数少,并发度低的业务场景。

比如公司内部的一些管理系统,通常请求数在100个左右,适用阻塞IO模型还是非常合适的,而且性能还不输NIO。该模型在C10K之前,是普遍被采用的一种IO模型。

2、非阻塞IO(NIO)

阻塞IO模型最大的问题就是一个线程只能处理一个连接,如果这个连接上没有数据的话,那么这个线程就只能阻塞在系统IO调用上,不能干其他的事情。这对系统资源来说,是一种极大的浪费。同时大量线程上下文切换,也是一个巨大的系统开销。

所以为了解决这个问题,我们就需要尽可能少的线程去处理更多的连接。网络IO模型的演变也是根据这个需求来一步一步演进的。

基于这个需求,第一种解决方案非阻塞IO就出现了。即前面介绍的非阻塞的概念,现在我们来看下网络读写操作在非阻塞IO下的特点:

非阻塞读

当用户线程发起非阻塞读read系统调用时,用户线程从用户态转为内核态,在内核中去查看Socket接收缓冲区是否有数据到来。

  • Socket接收缓冲区中无数据,系统调用立刻返回,并带一个EWOULDBLOCK或EAGAIN错误,这个阶段用户线程不会阻塞,也不会让出CPU,而是会继续轮询直到Socket接收缓冲区中有数据为止。
  • Socket接收缓冲区中有数据,用户线程在内核态会将内核空间中的数据拷贝到用户空间,注意这个数据拷贝阶段,应用程序是阻塞的,当数据拷贝完成,系统调用返回。

非阻塞写

前面我们在介绍阻塞写的时候提到阻塞的风格特别的硬刚,头比较铁,非要把全部数据一次性都写到Socket的发送缓冲区中才返回,如果发送缓冲区中没有足够的空间容纳,那么就一直阻塞死等。

相比较而言非阻塞写的特点就比较佛系,当发送缓冲区中内有足够的空间容纳全部发送数据时,非阻塞写的特点是能写多少写多少,写不下了就立即返回。并将写入到发送缓冲区的字节数返回给应用程序,方便用户线程不断的轮询尝试将剩下的数据写入发送缓冲区。

非阻塞IO模型

基于以上非阻塞的特点,我们就不必像阻塞IO那样为每个请求分配一个线程去处理连接上的读写操作了。

我们可以利用一个线程或者很少的线程,去不断地轮询每个Socket的接收缓冲区是否有数据到达,如果没有数据,不必阻塞线程,而是接着去轮询下一个Socket缓冲区,知道轮询到数据后,处理连接上的读写操作,或者交给业务线程池去处理,轮询线程则继续轮询其它的Socket接收缓冲区。

这样一个非阻塞IO模型就实现了我们在本小节开始时提出的需求:我们需要用尽可能少的线程去处理更多的连接。

适用场景

虽然非阻塞IO模型与阻塞IO模型相比,减少了很大一部分的资源消耗和系统开销。 

但是它仍然有很大的性能的,因为在非阻塞IO模型下,需要用户线程去不断地发起系统调用去轮询Socket接收缓冲区,这就需要用户线程不断地从用户态切换到内核态,内核态切换到用户态。随着并发量的增大,这个上下文切换的开销也是巨大的。

所以单纯的非阻塞IO模型还是无法适用于高并发的场景。只用适用于C10K以下的场景。

3、IO多路复用

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

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

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

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

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

这个问题其实在非阻塞IO模型中已经给出了它的答案,在非阻塞IO模型中,利用非阻塞的系统IO调用去不断轮询众多连接的Socket接收缓冲区检测是否有数据到来,如果有则处理,如果没有则继续轮询下一个Socket。这样就达到了用一个线程去处理众多连接上的读写事件了。

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

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

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

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

3.1 select

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

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

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

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

  • 当用户线程调用完select后开始进入阻塞状态,内核开始轮询遍历fd数组,查看fd对应对Socket接收缓冲区中是否有数据到来。如果有数据到来,则将fd对应BitMap的值设为1。如果没有数据到来,则保持值为0。注意:这里内核会修改原始的fd数组。
  • 当内核修改后的fd数组返回给用户线程后,用户线程解除阻塞,由用户线程开始遍历fd数组然后找出fd数组中值为1的Socket文件描述符。最后对这些Socket发起系统调用读取数据。

select不会告诉用户线程具体哪些fd上有IO数据到来,只是在IO活跃的fd上打上标记,将打好标记的完整fd数组返回给用户线程,所以用户线程还需要遍历fd数组找出具体哪些fd上有IO数据到来。

  • 由于内核在遍历的过程中已经修改了fd数组,所以在用户线程遍历完fd数组后获取到IO就绪的Socket后,就需要重置fd数组,并重新调用select传入重置后的fd数组,让内核发起新的一轮遍历轮询。

select API介绍

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

 1 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:对可读事件感兴趣的文件描述符集合。
  • fs_set *writeset:对可写事件感兴趣的文件描述符集合。
  • fs_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_ZERO和FD_SET重新设置文件描述符,因为文件描述符集合会在内核中被修改。

  • int FD_ISSET(int fd,fd_set *fsset):检查集合中指定的文件描述符是否可以读写。用户线程遍历文件描述符集合,调用该方法检查相应的文件描述符是否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个左右的并发连接场景。

3.2 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结构fs_set,而poll换成了一个pollfd结构没有固定长度的数组,这样就没有了最大描述符数量的限制(当然还会受到系统文件描述符限制)

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

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

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

3.3 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 task_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中的prvate_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_iter。socket发起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进行bind,listen,我们首先将这个socket称作为监听socket。

1、当我们调用accept后,内核会基于监听socket创建出来一个新的socket专门用于与客户端之间的网络通信。并将监听socket中的socket操作函数集合(inet_stream_ops)ops赋值到新的socket的ops属性中。

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。

2、接着内核会为已连接的socket创建struct file并初始化,并把socket文件操作函数集合(socket_file_ops)赋值给struct file中的f_ops指针。然后将struct socket中的file指针指向这个新分配申请的struct file结构体。

内核会维护两个队列:

  • 一个是已经完成TCP三次握手,连接状态出于established的连接队列。内核中为icsk_accept_queue。
  • 一个是还没有完成TCP三次握手,连接状态出于syn_rcvd的半连接队列。

3、然后调用socket->ops->accept,从socket内核结构体中我们可以看到其实调用的是inet_accept,该函数会在icsk_accept_queue中查找是否有已经简历好的连接,如果有的话,直接从isck_accept_queue中获取已经创建好的struct sock。并将这个struct sock对象赋值给struct socket中的sock指针。

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

根据创建socket时发起的系统调用sock_create中的protocol参数(对于TCP协议这里的参数值为SOCK_STREAM)查找到对于tcp定义的操作方法实现集合inet_stream_ops和tcp_prot。并把它们分别设置到socket->ops和sock->sk_prot上。

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_ops socket操作函数,最终调用到struct中sk_prot指针指向的tcp_prot内核协议栈操作函数接口集合。

  • 将struct sock对象中的sk_data_ready函数指针设置为sock_def_readable,在socket数据就绪的时候内核会对调改函数。
  • struct sock中的等待队列中存放的是系统IO调发生阻塞的进程fd,以及相应的回调函数。记住这个地方,后面介绍epoll的时候我们还会提到。
  • 当struct file,struct socket ,struct 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_operations,read系统调用对应的是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调用在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上的用户进程fd。func用来关联等待项上注册的回调函数。这里注册的是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_readble函数。
  • 在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 rdlist;

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

    ......
}
  • wait_quue_head_t wq:epoll中的等待队列,队列里存放的是阻塞在epoll上的用户进程。在IO就绪的时候epoll可以通过这个队列找到这些阻塞进程并唤醒它们,从而执行IO调用读写socket上的数据。

这里注意与socket中的等待队列区分。

  • struct list_head rdlist:epoll中的就绪队列,队列里存放的都是IO就绪的socket,被唤醒的用户进程可以直接读取这个队列获取IO活跃的socket。无需再次遍历整个socket集合。

这里正是epoll比select,poll高效之处,select,poll返回的是全部的socket连接,我们需要在用户空间再次遍历找出真正活跃的socket连接。而epoll只是返回IO活跃的socket连接。用户进程可以直接进行IO操作。

  • struct rb_root rbr:由于红黑树在查找、插入、删除等综合性能方面是最优的,所以epoll内部使用一颗红黑树来管理海量的socket连接。

select用数组管理连接,poll用链表管理连接。

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

当我们调用epoll_create在内核中创建出epoll对象struct eventpoll后,我们就可以利用epoll_ctl向epoll中添加我们需要管理的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成员,后面我们会用到。

2、在内核中创建完表示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_callback。在ep_poll_callback中需要找到epitem,将IO就绪的epitem放入epoll中的就绪队列中。

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

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注册的func是ep_poll_callback而不是autoremove_wake_function,阻塞进程并不需要autoremove_wake_function来唤醒,所以这里设置private为null。

3、当在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->rdlist是否有IO就绪的epitem。epitem里封装了socket的信息。如果就绪队列中有就绪的epitem,就将就绪的socket信息封装到epoll_event返回。

2、如果eventpoll->rdlist就绪队列中没有IO就绪的epitem,则会创建等待项wait_queue_t,将用户进程的fd关联到wait_queue_t->private上,并在等待项wait_queue_t->func上注册回调函数default_wake_function。最后将等待项添加到epoll的等待队列中。用户进程让出CPU进入阻塞状态。

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中的就绪队列rdlist中。
  • 随后查看epoll中的等待队列中是否有等待项,也就是说查看是否有进程阻塞在epoll_wait上等待IO就绪的socket。如果没有等待项,则软中断处理完成。
  • 如果有等待项,则回到注册在等待项中的回调函数default_wake_function,在回调函数中唤醒阻塞进程,并将就绪队列rdlist中的epitem的IO就绪socket信息封装到struct epoll_event中返回。
  • 用户进程拿到epoll_event获取IO就绪的socket,发起系统IO调用读取数据。

4、水平触发和边缘触发

这里可以结合epoll的工作过程来理解两种工作模式的异同。

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

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

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

如果你在边缘触发模式下,处理了部分socket上的数据,那么想要处理剩下的部分数据,就只能等到这个socket上再次有网络数据到达。

注意:在netty中实现的EpollSocketChannel 默认的就是边缘触发模式。JDKd的NIO默认的是水平触发模式。

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,无疑是性能底下的核心原因。

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

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

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

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

5、信号驱动IO

在信号驱动IO模型下,用户进程操作通过系统调用sigaction发起一个IO请求,在对应的socket注册一个信号回调,此时不阻塞用户进程,进程会继续工作。当内核数据就绪时,内核就为该进程生成一个SIGIO信号,通过信号回调通知进程进行相关IO操作。

这里需要注意的是:信号驱动IO模型依然是同步IO,因为它虽然可以在等待数据的时候不被阻塞,也不会频繁的轮询,但是当数据就绪,内核信号通知后,用户进程依然要自己去读取数据,在数据拷贝阶段发生阻塞。

信号驱动IO模型相比前三种IO模型,实现了在等待数据就绪时,进程不被阻塞,主循环可以继续工作,所以理论上性能 更佳。

但是实际上,使用TCP协议通信时,信号驱动IO模型几乎不会被采用。原因如下:

  • 信号IO在大量IO操作时可能会因为信号队列溢出导致没法通知。
  • SIGIO是一种Unix信号,信号没有附加信息,如果一个信号源有多种查询信息号的原因,信号接收者就无法确定究竟发送了什么。而TCP socket生产的信号事件有七种之多,这样应用程序收到SIGIO,根本就无从区分处理。

但信号驱动IO模型可以用在UDP通信上,因为UDP只有一个数据请求事件,这也就意味着在正常情况下UDP进程只要捕获SIGIO信号,就能调用read系统调用读取到达的数据。如果出现异常,就返回一个异常错误。

6、异步IO(AIO)

以上介绍的四种IO模型(阻塞IO模型,非阻塞IO模型,多路复用IO模型,信号驱动IO模型)均为同步IO,他们都会阻塞在第二阶段数据拷贝阶段。

通过前面小节《同步与异步》中的介绍,相信大家很容易就会理解异步IO模型,在异步IO模型下,IO操作在数据准备阶段和数据拷贝阶段均是由内核来完成,不会对应于程序造成任何阻塞。应用进程只需在指定的数组引用数据即可。

异步IO与信号驱动IO的主要区别在于:信号驱动IO由内核通知何时可以开始一个IO操作,而异步IO由内核通知IO操作何时已经完成。

异步IO的系统调用需要操作系统内核来支持,目前只有windwos中的IOCP实现了非常成熟的异步IO机制。

而Linux系统对异步IO机制实现的不够成熟,且与NIO的性能相比提升也不明显。

但在Linux kernel在5.1版本由Facebook的大神Jens Axboe引入了新的异步IO库io_uring改善了原来Linux native AIO的一些性能问题。性能相比Epoll以及之前原生的AIO提高了不少,值得关注。

再加上信号驱动IO模型不适应TCP协议,所以目前大部分采用的还是IO多路复用模型。

posted @ 2022-03-20 12:52  钟齐峰  阅读(233)  评论(0编辑  收藏  举报