前言

内容来源:本文章为阅读《Linux高性能服务器编程》第九章.I/O复用所记录的部分笔记。

I/O 复用技术是提升程序性能、解决高并发问题的关键手段,它允许单个程序进程同时监听多个文件描述符,从而高效地处理客户端的多连接请求、用户输入与网络交互,以及服务器端的跨协议(TCP/UDP)和多端口服务。尽管 I/O 复用能感知多个事件的就绪状态,但其系统调用本身具有阻塞性,且在处理多个就绪事件时默认是串行执行的;因此,在实际开发中,常需结合多线程或多进程来实现真正的并行并发。在 Linux 环境下,这一技术主要通过 selectpoll 以及基于内核事件表的更高性能的 epoll 系统调用来实现。

1.select系统调用

1.1 select API

select的核心作用:在指定时间内,同时监听多个文件描述符(如 socket中的listenfd和connfd)的 “可读、可写、异常” 事件 ,实现单进程 / 线程处理多个 I/O 操作。
image.png
参数说明

  1. nfds:被监听的文件描述符的最大值 + 1(因为文件描述符从 0 开始计数);
  2. readfds/writefds/exceptfds:分别指向 “关注可读、可写、异常事件” 的文件描述符集合(fd_set类型);
    fd_setselect用于管理文件描述符的结构:
    image.png
    fd_set结构体仅包含一个整型数组,该数组每个元素的每一位标记一个文件描述符,其所能容纳的文件描述符数量由FD_SETSIZE指定,这就限制了select能同时处理的文件描述符的总量,例如:假设__fd_mask是 8 字节→64 位,FD_SETSIZE=1024,我们想要存储fd=3和fd=70两个文件描述符,fd_set的内部状态为:
    image.png
    image.png
    举一个例子
#include <stdio.h>
#include <sys/select.h>

int main() {
    // 1. 定义一个fd_set集合
    fd_set my_fd_set;

    // 2. 清空集合(必须先清空,否则初始值是随机的)
    FD_ZERO(&my_fd_set);

    // 3. 装载(添加)fd到集合中
    int fd1 = 3;  // 假设是listenfd
    int fd2 = 5;  // 假设是connfd
    FD_SET(fd1, &my_fd_set);
    FD_SET(fd2, &my_fd_set);

    // 4. 判断某个fd是否在集合中
    if (FD_ISSET(fd1, &my_fd_set)) {
        printf("fd=%d 已被装载到集合中\n", fd1);
    }
    if (FD_ISSET(fd2, &my_fd_set)) {
        printf("fd=%d 已被装载到集合中\n", fd2);
    }
    if (!FD_ISSET(4, &my_fd_set)) {
        printf("fd=4 不在集合中\n");
    }

    // 5. 从集合中移除fd
    FD_CLR(fd2, &my_fd_set);
    if (!FD_ISSET(fd2, &my_fd_set)) {
        printf("fd=%d 已从集合中移除\n", fd2);
    }

    return 0;
}
  1. timeout:设置select函数的超时时间
    image.png
    • timeout的成员变量均设0:立即返回;
    • timeout设为NULL:永久阻塞,直到有某个文件描述符就绪。
      select成功时返回就绪文件描述符的总数,如果在超过时间内没有任何文件描述符就绪,select返回0,select失败会返回-1

1.2 文件描述符就绪条件

一、socket “可读” 的条件(readfds就绪)
当满足以下任意一种情况时,select会标记该 socket 为 “可读”:

  1. 接收缓存区有数据
    socket 内核接收缓存区的字节数 ≥ 低水位标记SO_RCVLOWAT(默认通常是 1 字节),此时调用read/recv可以无阻塞地读到数据(返回字节数 > 0)。
  2. 对方关闭连接
    通信对方关闭连接(发了 FIN 包),此时调用read/recv会返回 0(表示连接已关闭)。
  3. 监听 socket 有新连接
    listenfd对应的监听 socket 上有新客户端完成三次握手,此时调用accept可以取出新连接。
  4. socket 有未处理错误
    socket 发生错误(如连接失败),此时可通过getsockopt读取并清除错误。

二、socket “可写” 的条件(writefds就绪)
当满足以下任意一种情况时,select会标记该 socket 为 “可写”:

  1. 发送缓存区有空间
    socket 内核发送缓存区的可用字节数 ≥ 低水位标记SO_SNDLOWAT(默认通常是 1 字节),此时调用write/send可以无阻塞地发送数据(返回字节数 > 0)。
  2. 写操作被关闭
    socket 的写端被关闭(如调用shutdown(sockfd, SHUT_WR)),此时执行写操作会触发SIGPIPE信号。
  3. 非阻塞 connect 完成
    用非阻塞connect发起的连接,无论成功或失败(超时),都会标记 socket 为 “可写”。
  4. socket 有未处理错误
    同 “可读” 的第 4 条,socket 发生错误时也会标记为 “可写”。

三、socket “异常” 的条件(exceptfds就绪)
select中 socket 的异常事件只有一种场景

1.3 处理带外数据

一、核心逻辑:区分普通数据与带外数据的就绪状态
select中,socket 接收普通数据会触发 “可读事件(readfds)”,接收 带外数据(OOB) 会触发 “异常事件(exceptfds)”—— 通过同时监听这两个事件集合,就能分别处理两类数据。
二、代码实现步骤

  1. 初始化监听集合
    定义read_fds(监听普通数据)和except_fds(监听带外数据),并通过FD_ZERO清空集合。
  2. 循环监听事件
    • 每次select前,都要重新用FD_SETconnfd加入read_fdsexcept_fds(因为select会修改集合,需重新设置);
    • 调用select阻塞等待事件(仅监听read_fdsexcept_fds)。
  3. 处理事件
    • connfdread_fds中:用普通recv(..., 0)读取普通数据
    • connfdexcept_fds中:用recv(..., MSG_OOB)读取带外数据
      三、关键细节

2.poll系统调用

poll 与 select 类似,用于在指定时间内轮询一定数量的文件描述符(fd),以测试其中是否有就绪事件(如可读、可写、异常等)。
image.png

核心数据结构:struct pollfd
这是 poll 与 select 最显著的区别。它通过结构体而非位图(bitmask)来管理事件,更加清晰直观。
image.png

常用事件类型(poll 事件表)

代码案例:

struct pollfd fds[2];
// 监听socket的可读事件(比如是否有客户端连接、是否有数据发送过来)
fds[0].fd = sockfd;
fds[0].events = POLLIN;

// 监听文件描述符的可写+错误事件
fds[1].fd = filefd;
fds[1].events = POLLOUT | POLLERR;

int ret = poll(fds, 2, 5000); // 监听2个fd,超时5秒
if(ret > 0){
    // 检查第一个fd是否触发了可读事件
    if(fds[0].revents & POLLIN){
        // 执行socket读操作,比如accept或者recv
        handle_socket_read(fds[0].fd);
    }
    // 检查第二个fd是否触发了可写事件
    if(fds[1].revents & POLLOUT){
        // 执行文件写操作
        handle_file_write(fds[1].fd);
    }
    // 检查是否出现错误
    if(fds[1].revents & POLLERR){
        // 处理错误逻辑
        handle_error(fds[1].fd);
    }
}

3.epoll系统调用

内核事件表

一、 epoll 概述 (epoll Overview)

二、 核心 API:epoll_create
用于创建一个内核事件表。

#include <sys/epoll.h>
int epoll_create(int size);

三、 核心 API:epoll_ctl
用于操作(添加、修改、删除)内核事件表中的事件。

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
  1. 操作类型 (op)
  1. 重要结构体:struct epoll_event
struct epoll_event {
    __uint32_t events;    /* epoll 事件类型 */
    epoll_data_t data;    /* 用户数据 */
};
  1. 用户数据联合体:epoll_data_t
typedef union epoll_data {
    void* ptr;
    int fd;
    uint32_t u32;
    uint64_t u64;
} epoll_data_t;

epoll_wait函数

一、 epoll_wait 函数原型
epoll_wait 是 epoll 系列系统调用的核心接口,用于在一段超时时间内等待一组文件描述符上的事件。

#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event* events, int maxevents, int timeout);
  1. 参数详解 (从后往前)
  1. 返回值

LT和ET模式

一、 基本概念
epoll 对文件描述符的操作有两种工作模式,决定了内核何时以及如何通知应用程序事件。

  1. LT 模式 (Level Trigger, 电平触发)
  1. ET 模式 (Edge Trigger, 边沿触发)
特性 LT 模式 (Level Trigger) ET 模式 (Edge Trigger)
通知频率 只要满足条件就会重复触发 仅在状态改变时触发一次
编程难度 较低,允许只处理部分数据 较高,必须一次性完成处理
性能效率 性能不错,但系统调用次数可能较多 极高,大幅降低了重复触发次数
I/O 模式 阻塞/非阻塞均可 必须使用非阻塞 I/O

三、 编程实现要点 (基于代码清单 9-3)

  1. 非阻塞设置 (setnonblocking)
    在 ET 模式下,必须通过 fcntl 将文件描述符设置为非阻塞模式,否则读或写操作将会因为没有后续的事件而一直处于阻塞状态
int old_option = fcntl(fd, F_GETFL);
int new_option = old_option | O_NONBLOCK;
fcntl(fd, F_SETFL, new_option);
  1. ET 模式下的读取逻辑
    由于 ET 模式只通知一次,如果读缓存中数据很多,一次 recv 读不完,epoll_wait 就不会再报了。
  1. LT 模式下的读取逻辑

EPOLLONESHOT事件

一、 I/O 模式:阻塞 vs 非阻塞

  1. 设置非阻塞fcntl(fd, F_SETFL, O_NONBLOCK)
  2. 注册事件event.events = EPOLLIN | EPOLLET | EPOLLONESHOT
  3. 循环读取:工作线程接收到通知后,用 while 循环 recv
  4. 识别结束
    • ret > 0:继续读。
    • ret == 0:对端关闭连接,执行 close(fd)
    • ret < 0 && errno == EAGAIN:读完了,调用 reset_oneshot() 恢复监控。

4.三组I/O复用函数的比较

一、 I/O 复用技术大比拼 (select, poll, epoll)

特性 select poll epoll
数据结构 3个 fd_set(位图) pollfd 结构体数组 内核事件表
索引复杂度 O(n):需遍历整个 fd 集合 O(n):需遍历整个 fd 集合 O(1) :直接返回就绪事件
最大连接数 有限制(通常 1024) 无限制(65535+) 无限制(65535+)
内核实现 轮询方式扫描所有 fd 轮询方式扫描所有 fd 回调方式(Callback)
工作模式 仅支持 LT (水平触发) 仅支持 LT (水平触发) 支持 ET (边沿触发) 和 LT
参数传递 每次调用需重置 fd 集合 无需重置事件参数 无需重复传入事件,仅需添加/修改

二、 epoll 的进阶神器:EPOLLONESHOT

  1. 核心背景:多线程并发冲突
    在多线程环境下,即便使用 ET(边沿触发)模式,如果一个线程在处理某个 Socket 期间又有新数据到达,内核可能会唤醒另一个线程来处理同一个 Socket。
  1. 核心机制:重置(Reset)
  1. 注册:主线程将 Socket 注册为 EPOLLIN | EPOLLET | EPOLLONESHOT
  2. 分发epoll_wait 收到通知,唤醒一个工作线程处理该 Socket。
  3. 处理
    • 由于 EPOLLONESHOT,其他线程绝不会抢占该 Socket。
    • 工作线程循环读取数据直到返回 EAGAIN
  4. 复位:处理完成后,工作线程调用 reset_oneshot(即 epoll_ctl(MOD))。
  5. 循环:内核重新监控,等待下一次事件。
    四、 关键结论与建议