1️⃣ 同步阻塞 IO

造成的问题

  • 两处阻塞: 1. 进程刚调用 recv ; 2. 等待内核将 数据 拷贝到 用户缓冲区
  • 两处进程切换: 1. 连接持续没有数据到达; 2. 网卡将数据从 内核 复制到 socket等待队列,进程会被唤醒
  • 单对单连接:一个进程只能等待一条连接

2️⃣ 同步非阻塞 IO

优点

将数据从 网卡 拷贝到 socket 的内核空间 这一阶段变成 非阻塞模式

  • 用户进程调用 recvfrom() 会重复发出请求,检查数据是否到达 socket等待队列。如果没有到,会立即返回。

缺点

没有解决:当数据到达 socket等待队列,用户进程需要将数据从 内核空间 拷贝到 用户空间

3️⃣ 同步阻塞 IO 的数据接收流程

  • 服务端使用系统调用 socket 会陷入到内核态,内核就会创建 socket 内核对象,其主要包含两个重要结构体:
    • (进程)等待队列: 存放了进程的 进程描述符回调函数
    • (数据)接收队列: 存放了网卡接收到的需要该 socket 的数据
  • 服务端当通过 socket 调用 recv 函数时,会执行 recvfrom 系统调用。
    • 如果 socket接收队列 中没有数据,进程 A 会让出 CPU 执行权,进入阻塞状态。
    • 同时创建一个 等待队列项 (进程描述符 + 被唤醒时的回调函数) 放入到进程 A 的等待队列中
  • 如果服务端的网卡接收到了 客户端发送的数据
    • 网卡 先通过 DMA 将网络传输过来的数据复制到 内存环形缓冲区 RingBuffer;
    • 网卡 再向 CPU 发送 硬中断;
    • CPU 接收到 硬中断 ,就调用 网络驱动 注册的 中断处理函数内核中断线程 发送 软中断,并释放 CPU 执行其他任务;
    • 内核中断进程 接收到 软中断 后,将 网卡 复制到 内存 的数据,根据数据报文的 IP 和端口号,将其拷贝到对应 socket接收队列;
    • 内核中断进程 根据 socket接收队列 的数据,通过 等待队列 中的回调函数,唤醒要处理该数据的进程 A,此时进程 A 进入到 CPU 的运行队列,等待 CPU 的调度。
    • 进程 A 获取 CPU 后,会回到之前调用 recvfrom() 函数时的 阻塞位置 继续执行,这时发现 socket 内核空间的 等待队列 有数据,会在内核态 将内核空间的 socket 等待队列的数据拷贝到用户空间,然后才会回到用户态执行进程的用户程序,从而真的解除阻塞;

4️⃣ IO 多路复用

主要用于解决 两次进程切换,单进程对单连接

定义

  IO 多路复用 主要通过 一个进程 处理 多个 socket 连接,使得不需要在每个 socket 连接的数据到达时就 切换进程,使得进程可以一直运行并只处理有数据到达的连接。

如果要监听的所有连接都没有数据到达,进程还是会进入阻塞状态,直到某个连接有数据到达时被回调函数唤醒。

select 的实现原理

select 的定义

1
2
3
4
5
6
int select(
int nfds, // 监控的文件描述符集里最大文件描述符加1
fd_set *readfds, // 监控有读数据到达文件描述符集合,引用类型的参数
fd_set *writefds, // 监控写数据到达文件描述符集合,引用类型的参数
fd_set *exceptfds, // 监控异常发生达文件描述符集合,引用类型的参数
struct timeval *timeout); // 定时阻塞监控时间

  select 会遍历每个集合的前 nfds 个描述符,分别找到可以读取、可以写入、发生错误的描述符,统称为 “就绪” 的描述符。然后用找到的 子集 替换这三个引用参数中的对应集合,返回所有就绪描述符的数量

  timeout 参数表示调用 select 时的阻塞时长。

  • 如果所有 fd 文件描述符都未就绪,就阻塞调用进程,直到某个描述符就绪,或者阻塞超过设置的 timeout 后,返回。
  • 如果 timeout 参数设为 NULL,会无限阻塞直到某个描述符就绪;
  • 如果 timeout 参数设为 0,会立即返回,不阻塞。

fd_set

fd_set 结构体仅包含一个整形数组,该数组的每个元素的每一位标记一个文件描述符。
select 返回 fd_set = 00010011 时,表示文件描述符 1、2、5 已经就绪。

缺点

  1. 性能开销大
    • 调用 select 时会陷入内核,这时需要将参数中的 fd_set 从用户空间拷贝到内核空间,select 执行完后,还需要将 fd_set 从内核空间拷贝回用户空间;(epoll 优化为不拷贝)
    • 进程被唤醒后,不知道哪些连接已就绪即收到了数据,需要遍历传递进来的所有 fd_set 的每一位,不管它们是否就绪;(epoll 优化为异步事件通知)
    • select 只返回就绪文件的个数,具体哪个文件可读还需要遍历;(epoll 优化为只返回就绪的文件描述符,无需做无效的遍历)
  2. 同时能够监听的文件描述符数量太少。受限于 sizeof(fd_set) 的大小,在编译内核时就确定了且无法更改。一般是 32 位操作系统是 102464 位是 2048

poll 的实现原理

select 类似,只是描述 fd 集合的方式不同,poll 使用 pollfd 结构而非 selectfd_set 结构。

poll 的定义

1
int poll( struct pollfd* fds,nfds_t nfds,int timeout )
1
2
3
4
5
struct pollfd {
int fd; // 要监听的文件描述符
short events; // 要监听的事件
short revents; // 文件描述符fd上实际发生的事件
};

epoll 的实现原理

epoll 是对 selectpoll 的改进。解决了以下这两个缺点,是性能最高、并发量最大的多路复用实现方式。

  • 性能开销大
  • 文件描述符数量少

特点

  • 使用 红黑树 存储文件描述符集合,每个文件描述符只需在添加时传入一次,无需用户每次都重新传入;
  • 通过 异步 IO 事件找到就绪的文件描述符,而不是通过轮询的方式;
  • 使用 就绪队列 存储 已经就绪 的文件描述符,且会按需返回就绪的文件描述符,无须再次遍历;

主要函数

1
2
3
4
5
6
7
8
// 创建一个 eventpoll 内核对象
int epoll_create(int size);

// 将连接到socket对象添加到 eventpoll 对象上,epoll_event是要监听的事件
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

// 等待连接 socket 的数据是否到达
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);

epoll_create

epoll_create 函数会创建一个 struct eventpoll 的内核对象,主要包含以下三个字段:

  • 等待队列 wq 如果 当前进程没有数据需要处理,会把 当前进程描述符回调函数 default_wake_functon 构造一个等待队列项,放入当前 wq 对待队列;
  • 红黑树 rbr 管理用户进程下添加进来的所有 socket 连接 fd;
  • 就绪链表 rdllistsocket 连接数据就绪的时候,内核会把就绪的 socket 连接放到 rdllist 链表里。这样应用进程只需要判断链表就能找出就绪进程,而不用去遍历整棵树。
    1
    2
    3
    4
    5
    6
    7
    8
    struct eventpoll {
    wait_queue_head_t wq; // 等待队列链表,存放阻塞的进程

    struct list_head rdllist; // 数据就绪的文件描述符都会放到这里

    struct rb_root rbr; // 红黑树,管理用户进程下添加进来的所有 socket 连接
    ......
    }

epoll_ctl

epoll_ctl 函数主要负责把 服务端客户端 建立的 socket 连接注册到 eventpoll 对象里,内核会做三件事:

  • 分配一个红黑树节点对象 epitem

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    struct epitem {

    //红黑树节点
    struct rb_node rbn;

    //socket文件描述符信息
    struct epoll_filefd ffd;

    //所归属的 eventpoll 对象
    struct eventpoll *ep;

    //等待队列
    struct list_head pwqlist;
    }
  • 添加等待事件到 socket 的等待队列中,其回调函数是 ep_poll_callback;

    这里添加的 socket 的进程等待队列结构中,只有回调函数,没有设置进程描述符,因为在 epoll 中,进程是放在 eventpoll 的等待队列中,等待被 epoll_wait 函数唤醒,而不是放在 socket 的进程等待队列中;

  • epitem 插入到 epoll 对象的红黑树里

epoll_wait

epoll_wait 的逻辑比较简单:

  • 它先检查 eventpoll 对象的就绪链表 rdllist 中是否有就绪事件;
    • 如果没有,就为当前进程构造一个等待队列项,并将其加入 eventpoll 的等待队列中,然后阻塞当前进程。
    • eventpoll 监控的某个连接上 有数据到达 时,内核大致会按照下面的流程唤醒阻塞在 epoll_wait 上的进程:
      • 数据到达 socket 的接收缓冲区后,会触发该 socket 等待队列上的回调函数 ep_poll_callback。
      • ep_poll_callback 会找到对应的 epitem,并将其加入 eventpoll 对象的就绪队列 rdllist 中。
      • 随后,ep_poll_callback 会检查 eventpoll 的等待队列上是否有因调用 epoll_wait 而阻塞的进程;如果有,就通过 default_wake_func 将其唤醒。
      • 进程被唤醒后,会从 epoll_wait 挂起的位置继续执行。此时,epoll_wait 会将 rdllist 中的就绪事件返回给用户进程。
      • 用户进程拿到就绪事件后,再调用 recv 等接口,把已经到达内核 socket 接收缓冲区中的数据拷贝到用户空间,供业务代码处理。

参考文章