IO多路复用详解

假如你想了解IO多路复用,那本文或许可以帮助你
本文的最大目的就是想要把select、epoll在执行过程中干了什么叙述出来,所以具体的代码不会涉及,毕竟不同语言的接口有所区别。

基础知识

IO多路复用涉及硬件、操作系统、应用程序三个层面,了解这些知识是很有帮助的。
假如已经了解,可直接跳过

Linux系统中断

中断是指计算机在执行期间,系统内发生任何非寻常的或非预期的急需处理事件,使得CPU暂时中断当前正在执行的程序而转去执行相应的事件处理程序,待处理完毕后又返回原来被中断处继续执行或调度新的进程执行的过程。

硬中断

通过硬件产生相应的中断请求,称为硬中断。
我们的硬件设备如:鼠标、键盘、网卡、磁盘等,假如想要让CPU处理它们的数据(如按下键盘、移动鼠标、处理网卡缓冲区的报文数据等)都需要通过中断控制器(一个硬件设备)向数据总线中发送中断请求(IRQ Interrupt ReQuest的缩写),CPU收到IRQ后会将当前进程信息保存到进程描述符中,然后在中断向量表中找到对应中断处理程序的地址,然后执行中断处理程序,在执行完处理程序后,从进程描述符中恢复原进程。

简化以上过程:外设 ==> 中断控制器 ==> CPU ==> 挂起当前进程 ==> 中断向量表 ==> 中断处理程序 ==> 恢复原进程。

软中断

软中断是在通信进程之间通过模拟硬中断而实现的一种通信方式。软中断仅在当前运行的进程中产生。
我们经常用到的系统调用就是一个软中断,因为中断向量号为0x80故又称80中断。

下面会解释系统调用到底做了什么

延伸阅读:
详解操作系统中断,该文章介绍了8259A中断控制器以及中断触发和处理的过程。

系统调用

上面说过,系统调用是一种软中断。那么操作系统为什么要给我们提供系统调用呢?以及系统调用的实现过程又是如何的?

用户态和内核态

我们知道操作系统本身也是一个程序,我们平时写的程序都跑在操作系统之上,计算机的硬件资源都是由操作系统内核进行管理的。假如我们需要使用某一硬件的资源,是不能直接访问的。因为为了提高操作系统的稳定性和安全性,应用程序需要和系统程序分开。CPU将程序执行的状态分为了不同的级别,从0到3,数字越小,访问级别越高。0代表内核态,在该特权级别下,所有内存上的数据都是可见的,可访问的。3代表用户态,在这个特权级下,程序只能访问一部分的内存区域,只能执行一些限定的指令。这就把内存分为了用户态和内核态。
由于内存分为用户态和内核态,当我们需要访问操作系统的内部函数时,就需要使用系统调用了,为了规范操作系统提供的系统调用,IEEE制定了一个标准接口族,被称为POSIX(Portable Operating System Interface of Unix)。比如一些常用的接口:fork、pthread_create、open等。

系统调用过程

下面叙述一下系统调用的过程是怎样的,要求知道大概流程。

  1. 进程A请求系统调用(80中断),此过程会将系统调用号放入eax寄存器,并往ebx、ecx、edx、esi,、edi寄存器放入参数

  2. 栈切换:当前栈从用户栈切换到内核栈(用户态和内核态使用的是不同的栈),关于Linux的栈可以看此文:Linux 中的各种栈:进程栈 线程栈 内核栈 中断栈

    当前栈指的是ESP寄存器的值所指向的栈,ESP的值位于用户栈的范围,那程序的当前栈就是用户栈,反之亦然。
    寄存器SS的值指向当前栈所在的页。因此,将用户栈切换到内核栈的过程是:

    将当前ESP、SS等寄存器的值存到内核栈上。
    将ESP、SS等值设置为内核栈的相应值。

  3. 通过中断向量表找到system_call的地址(0x80的地址)

  4. <开始system_call>将用户态的一些寄存器信息保存在自己的堆栈即内核堆栈上(system_call 中的save_all实现)

    save_all是一个宏,它将依次压入: %es %ds %eax %ebp %edi %esi %edx %ecx %ebx

  5. system_call根据eax寄存器的调用号找到特定的系统函数指针,并在寄存器中读取参数

  6. 执行特定的系统函数

  7. 将执行结果保存到eax寄存器中

  8. 恢复之前保存的寄存器

  9. 执行iret,从中断程序返回<system_all结束>

    iret是汇编指令,将原来用户态保存的现场恢复回来,包含代码段、指令指针寄存器等。这时候用户态进程恢复执行。

  10. 栈切换:当前栈要从内核栈切换回用户栈

  11. 运行进程A,进程A往eax寄存器中读返回数据

system_call的部分代码

// system_call的开头部分
......
SAVE_ALL	// 保存寄存器的值到栈中,以免被覆盖
......
cmpl $(nr_syscalls), %eax	// 比较eax寄存器中的值和系统调用号大1的值(验证系统调用号的有效性)
jae syscall_badsys	// 如果系统调用无效,指向syscall_badsys


// 如果系统调用号有效,则会执行以下代码
syscall_call:
	call *sys_call_table(0, %eax, 4)	// 查找中断服务程序并执行, sys_call_table其实就是系统调用表
	.....
	RESTORE_REGS	// 恢复之前保存的寄存器
	......
	iret	// 从中断程序返回

在网上找来的大致流程图:
IO多路复用详解

Socket基础

本文是以socket分析的,所以需要了解一下socket的基础知识。

Socket API

以TCP为例,其一般使用模式如下:
IO多路复用详解

  • socket: 创建socket 对象。这个 socket 对象包含了输入缓冲区输出缓冲区等待队列等成员。
  • bind: 绑定ip和端口
  • listen: 设置backlog,简单来说就是设置能连多少个客户端,想要进一步了解的朋友可以看此文:TCP/IP协议中backlog参数
  • accept: 等待客户端连接(阻塞),得到一个与客户端建立连接的socket
  • read: 从socket输入缓冲区中读取数据,缓冲区为空时阻塞
  • wiret: 向socket输出缓冲区中写入数据,缓冲区空间不够时阻塞

    只要将全数据放到缓冲区就可以返回了,至于如何发送及保证数据完整性,就不是它的事了。

Socket 缓冲区读写机制

下面详细说一下,socket缓冲区的读写机制,分BIO和NIO两种情况

每个 socket 被创建后,都会分配两个缓冲区,输入缓冲区和输出缓冲区 。
我们调用write()/send()时,操作系统并不立即向网络中传输数据 ,而是先将数据拷贝到输出缓冲区中,然后根据网络协议和阻塞模式进行处理。
我们调用read()/recv()时,假如对应socket的输入缓冲区没有数据时,会根据阻塞模式进行不同的处理。

IO多路复用详解

BIO

  • 数据发送

    1. 输出缓冲区的可用长度大于待发送的数据,则数据将全部被拷贝到输出缓冲区,返回。
    2. 输出缓冲区的长度小于待发送的数据长度,则数据能拷贝多少就先拷贝多少(分批拷贝),一直等待直到数据可以全部被拷贝到输出缓冲区,返回
  • 数据接收

    1. 输入缓冲区没数据时,程序就会一直阻塞等待,直到有数据可读为止。读buffer大小的数据。返回值是成功读取到的数据的长度。
    2. 输入缓冲区有数据时,读buffer大小的数据,返回,返回值是成功读取到的数据的长度。

NIO

  • 数据发送

    1. 输出缓冲区剩余大小大于待发送的数据大小,那数据将完整拷贝到输出缓冲区,返回。
    2. 输出缓冲区剩余大小小于待发送的数据大小,那本次write()/send()则为尽可能拷贝,有多少空间就拷贝多少数据,返回,而且返回值为成功拷贝到输出缓冲区的数据长度。
  • 数据接收

    1. 输入缓冲区没数据时,马上返回,此时的返回值为0。
    2. 输入缓冲区有数据时,读buffer大小的数据,返回,返回值是成功读取到的数据的长度。

补充:
socket关闭时,若输出缓冲区中的数据仍有数据,这些数据依然会被系统发送过去;若输入缓冲区中的数据仍有数据,这部分数据将被丢弃。

延伸阅读:
谈谈socket缓冲区,该文章介绍了TCP、UDP在阻塞和非阻塞下的收发情况,以及在收发过程中的一些常见情景。

BIO时socket接收数据过程

下面通过叙述socket等待recv过程,将前面的内容串联一下。

上面说过,调用socket会在内核态创建socket 对象。这个 socket 对象包含了输入缓冲区输出缓冲区等待队列等成员,如下图。
IO多路复用详解

当我们调用recv读取输入缓冲区中的数据时,由于缓冲区中没有数据,进程A就会从工作队列中移除,也就是说进程A处于阻塞态了。同时,进程A创建的socket的等待队列加入进程A的地址,用于唤醒进程A。如图:

IO多路复用详解

之后的流程是这样的:

  1. 进程A会一直阻塞,直到网卡收到对端发来的数据,由网卡的DMA设备接收数据,将数据放到内存中的网卡缓冲区
  2. 然后网卡向中断控制器发送信号,而中断控制器会在条件允许的情况下发送中断请求(IRQ)
  3. CPU收到IRQ后,挂起当前程序,执行中断
  4. CPU根据中断向量表找到网卡的中断处理程序,CPU执行该中断处理程序
  5. 中断处理程序根据报文数据的端口,将数据从网卡缓冲区复制到进程A的socket的输入缓冲区中
  6. 然后根据socket的等待队列唤醒进程A,将进程A加入到工作队列中,即进程A变为就绪态。

整个过程如图:

IO多路复用详解

IO多路复用详解

IO多路复用详解

将上述过程简化一下,就大概是下图了

IO多路复用详解

这是BIO的情况,一个进程只能监听一个socket,即使使用多进程或多线程也很难解决c10k的问题,因此需要IO多路复用技术。

补充:网卡DMA设备
DMA是指外部设备不通过CPU而直接与系统内存交换数据的接口技术。
网卡DMA设备的处理流程:

  1. 网卡收到对端socket发来的数据
  2. 网卡的DMA设备取数据
  3. 将DMA中读到的数据放到RAM中的网卡缓冲区
    更多关于DMA设备的内容,可查看:DMA(直接存储器存取)

IO多路复用

正文开始

I/O多路复用就是通过一种机制,让一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。常用的IO多路复用的实现有:select、poll、epoll。select、poll、epoll是系统调用,在调用的过程中会阻塞,在读数据的时候也会阻塞,但它可以同时监听多个文件描述符。

select

基本使用

select函数原型:

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
  1. 参数
    • nfds:有效位(见下面解释)
    • 要监听的文件描述符,以读、写、异常的顺序传
    • timeout,设置为大于0的数,等待多少秒后返回;设置为0,立即返回;设置为NULL,阻塞直到可用;
  2. 返回
    • 就绪文件描述符个数

      返回前,原监听的文件描述符会被标记,然后从内核态覆盖到用户态(下面流程部分的第9步)

源码刨析:Linux select内核源码剖析

关于fd_set

fd_set在Linux下是bitmap,长度大小为1024(在linux源码中定义的)。

linux提供了一组宏,可以对fd_set进行操作,这里不过多介绍了,想要了解的可以看这里:select机制内核源码剖析-fd_set部分

有效位解释:
假如现在要监听5、6、7文件描述符,那么其bitmap应该为000001110000.....,但为了提高效率,可以把后面没用的0去掉,把有效位设为8(最大的文件描述符加1)变为:00000111。至于是如何实现的,这里可以列出源码:

/ *  do_select函数,作用是遍历所有监听的文件描述符,调用对应驱动程序的poll函数 */


/ * ..... */


/ * 此为监听文件文件描述符过程,n为有效位 */

for (i = 0; i < n; ++rinp, ++routp, ++rexp)

/ * ..... */

例子

使用select比较简单,如下面这段伪代码:

int s = socket(AF_INET, SOCK_STREAM, 0);  
bind(s, ...);
listen(s, ...);
int fds[] =  存放需要监听的socket;

&rset = 根据fds构建出的位图; // 读监听
while(1){
    int n = select(..., &rset, ...)
    for(int i=0; i < fds.length; i++){
		// FD_ISSET是fd_set的宏,可以判断该位置上的bitmap是否为1
        if(FD_ISSET(fds[i], &rset)){
            // fds[i]的数据处理
			read(fds[i], buffer);
			doSomething(buffer);
        }
}}

下面以这段伪代码为例子,描述select的流程。

流程

  1. 进程A创建多个socket对象(调用socket或accpet函数)
  2. 调用select,进行系统调用(80中断),将bitmap即描述符数据复制到内核态
  3. 进程A从运行队列中移除
  4. select:
    • ①. 如果 fds 中的所有 socket 都没有数据,select 会阻塞
    • ②. 遍历监听每个socket
    • ③.将进程A加入到socket等待队列中
  5. 网卡收到对端socket的数据
  6. 网卡通过DMA将报文保存到RAM中的网卡缓冲区
  7. 网卡发起硬中断IRQ
    • ①. 修改CPU寄存器,将堆栈指针指向内核态堆栈
    • ②. 保存进程用户态堆栈信息到进程描述符
    • ③. 根据IRQ到中断向量表找到中断处理程序
    • ④. 执行网卡的中断处理程序
      • a. 将数据从网卡缓冲区转移到对应的socket的读缓冲区(根据socket端口)
      • b. 将进程A从socket等待队列出队,并将进程A放到运行队列中
  8. CPU根据调度算法执行进程A
  9. select:
    • ①. select遍历所有socket,找到就绪的,并设置标记( 把bitmap中已经就绪的不变为1,未就绪的变为0)

      如:监听描述符为3、4、5,那么它的bitmap数据应该是000111,假如描述符3和5已经就绪,那么bitmap变为000101

    • ②. 将内核空的bitmap覆盖到进程A中的bitmap,并返回就绪的socket数

  10. 进程A拿到就绪的socket数,遍历bitmap数据,找到就绪的描述符
  11. 进行读写等操作

整个过程的前部分和使用BIO监听一个socket时一样,其核心部分就是select把不同的socket的等待队列都指向了进程A,所以当执行中断处理程序时,进程A就会被唤醒(如图),从而实现了可以监听多个文件描述符(socket)的效果。

IO多路复用详解

缺点

根据上面的流程的叙述,我们很容易就可以发现select的缺点

  1. 传参时,bitmap需要从用户态复制到内核态
  2. 返回时,修改(标记)后的bitmap需要从内核态复制到用户态
  3. 由于每次返回都会修改原bitmap,所以每次都要把bitmap重新置位,不能复用
  4. 有三次遍历(监听时、标记时、进程找就绪socket时),十分浪费资源
  5. 在linux下,bitmap的长度不能超过1024,可以修改linux源代码并重新编译内核解决问题,但是由于bitmap需要在用户态与内核态之间传来传去,而且需要遍历,效果可能不太理想。

poll

poll的机制与select类似,与select在本质上没有多大差别,所以在这里只做简单介绍。poll和select一样,管理多个描述符也是进行轮询,根据描述符的状态进行处理。poll以链表的形式存储文件描述符,而且最大文件描述符数量没有限制。但poll和select同样存在一个缺点:包含大量文件描述符的数组被整体复制于用户态和内核的地址空间之间,而不论这些文件描述符是否就绪,它的开销随着文件描述符数量的增加而线性增大。所以,监听fd很多的时候建议用epoll。

epoll

epoll是select和poll出现后被开发出来的,既然是新的东西,那当然不能走select和poll的老路子。在上文也说过select和poll的主要问题是每次文件描述符都要从用户态复制到内核态,然后监听的已经就绪后再复制回来。在这个过程中,没有就绪的描述符也会返回,所有需要在进程中轮询查看每个描述符的状态,浪费资源。为此,epoll会在内核空间中开辟一片空间,用于存放文件描述符等数据,返回时只需要返回就绪的文件描述符即可。这样未就绪的文件描述符可以继续监听,进程也不需要遍历查看哪个文件描述符就绪了。

基本使用

epoll常用的有三个接口,epoll_createepoll_ctlepoll_wait

  1. int epoll_create(int size);
    在内核区创建一个eventpoll结构(该结构包含:监听事件列表就绪队列等待队列 等),并且将一个句柄fd返回给用户态。

    监听事件列表是用红黑树实现的。epoll 通过 socket 句柄来作为 key,把 socket 保存在红黑树中。
    红黑树是一种自平衡二叉查找树,搜索、插入和删除时间复杂度都是O(log(N)),效率较好。
    而就绪队列是双向链表

  2. int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
    进程通过之前返回的fd,添加/修改/删除文件的监听事件,这个接口操作的是监听事件列表

  3. int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
    等待事件的产生,比较像调用select(),不过返回的是就绪队列

关于三个接口的参数详情,以及调用它们后,是如何用代码实现的,可以看此文章:epoll内核源码分析

例子

同样用一段伪代码说明一下epoll的大概的使用流程。

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

//将所有需要监听的socket添加到epfd中
epoll_ctl(epfd, ...); 
 
while(1){
    int n = epoll_wait(...)
    for(接收到数据的socket){
        //处理
    }
}


流程

  1. 进程A创建多个socket对象(调用socket或accpet函数)
  2. 调用epoll_create,在内核中创建eventpoll结构,返回fd
  3. 调用epoll_ctl将socket加入到eventpoll的监听事件列表中(通过参数指定监听读/写就绪、水平/边缘触发等)
  4. 调用epoll_wait,假如eventpoll的就绪队列中有数据,则返回,否则阻塞(可以指定timeout参数不让其一直阻塞,但这里不展开)
  5. 进程A从运行队列中移除
  6. epoll:
    • ①. 将进程A的地址加入到eventpoll的等待队列
    • ②. 将eventpoll的地址加入每个socket的等待队列中
      IO多路复用详解
  7. 网卡收到对端socket的数据
  8. 网卡通过DMA将报文保存到RAM中的网卡缓冲区
  9. 网卡发起硬中断IRQ
    • ①. 修改CPU寄存器,将堆栈指针指向内核态堆栈
    • ②. 保存进程用户态堆栈信息到进程描述符
    • ③. 根据IRQ到中断向量表找到中断处理程序
    • ④. 执行网卡的中断处理程序
      • a. 将数据从网卡缓冲区转移到对应的socket的读缓冲区(根据socket端口)
      • b. 从socket等待队列中找到eventpoll,调用ep_poll_callback函数处理。
        IO多路复用详解
  10. epoll的ep_poll_callback函数:
  • ①. 将当前socket添加到eventpoll的就绪队列
  • ②. 唤醒等待队列中的进程,即进程A
    IO多路复用详解
  1. CPU根据调度算法执行进程A
  2. epoll_wait将eventpoll中的就绪列表从内核态复制到用户态
    IO多路复用详解
  3. 进程A拿到就绪列表
  4. 进行读写等操作

延伸阅读:
Epoll 如何工作的?
该文章讲解了epoll 的实现原理、在实现过程中调用了哪些函数,会产生怎样的效果。

上一篇:打卡面试题-day06(javaSE)


下一篇:epoll原理及应用