Linux 阻塞与非阻塞 IO 机制简析

Linux 阻塞与非阻塞 IO 机制简析

覆盖内核版本: 3.0 - 6.6+


目录

  1. [整体概览: Linux IO 模型的五重境界](#整体概览: Linux IO 模型的五重境界)
  2. [应用层视角: 开发者如何感知阻塞](#应用层视角: 开发者如何感知阻塞)
  3. [API 参考与使用示例](#API 参考与使用示例)
  4. [内核层视角: 从 VFS 到调度器的阻塞真相](#内核层视角: 从 VFS 到调度器的阻塞真相)
  5. [驱动层视角: 设备树时代的驱动与等待队列](#驱动层视角: 设备树时代的驱动与等待队列)
  6. [版本演进: 从 3.x 到 6.x 的关键节点](#版本演进: 从 3.x 到 6.x 的关键节点)
  7. [io_uring: 统一异步接口的架构革命](#io_uring: 统一异步接口的架构革命)
  8. 全链路数据流图解
  9. 生产实践、选型建议与注意事项
  10. [附录: 关键数据结构速查](#附录: 关键数据结构速查)

1. 整体概览: Linux IO 模型的五重境界

Linux 的 IO 模型可归纳为五种经典范式,其核心差异体现在 "数据准备""数据拷贝" 两个阶段对进程阻塞行为的不同处理:

IO 模型 数据准备阶段 数据拷贝阶段 典型系统调用 内核版本
阻塞 IO (Blocking) 进程阻塞等待 进程阻塞拷贝 read() / write() 全版本
非阻塞 IO (Non-blocking) 立即返回 EAGAIN 数据就绪后拷贝 read() + O_NONBLOCK 全版本
IO 多路复用 (Multiplexing) 阻塞在 select/poll/epoll 就绪后 read/write select() / poll() / epoll_wait() 2.5.44+
信号驱动 IO (Signal-driven) 不阻塞,SIGIO 通知 收到信号后 read/write sigaction(SIGIO) 2.4+
异步 IO (Async) 不阻塞 不阻塞,内核完成后通知 io_uring_enter() 5.1+

关键区分 : 前四种模型在 数据拷贝阶段 均会阻塞用户进程(或主动发起 read/write),属于 同步 IO;只有异步 IO 在数据准备和拷贝两个阶段均不阻塞进程,由内核完成后通过回调或完成队列通知。


2. 应用层视角: 开发者如何感知阻塞

2.1 阻塞 IO: 最直观的默认行为

在 Linux 中,默认情况下所有 socket 和文件描述符均为 阻塞模式 。当用户进程调用 read(fd, buf, len) 时:

  1. 若内核缓冲区无数据,进程调用 schedule() 主动放弃 CPU
  2. 进程状态从 TASK_RUNNING 切换为 TASK_INTERRUPTIBLE
  3. 进程被挂入该文件描述符对应的 等待队列 (wait_queue)
  4. 当硬件中断触发数据到达(或 DMA 完成),中断处理程序或软中断将进程从等待队列唤醒
  5. 进程恢复运行,内核将数据从内核空间拷贝至用户空间 buf
  6. read() 返回实际读取字节数
c 复制代码
// 典型阻塞读
int fd = open("/dev/mydevice", O_RDONLY);
char buf[1024];
ssize_t n = read(fd, buf, sizeof(buf));  // 若无数据,进程在此睡眠

2.2 非阻塞 IO: 轮询与 EAGAIN

通过 fcntl(fd, F_SETFL, O_NONBLOCK)open() 时传入 O_NONBLOCK 标志,可将文件描述符设为非阻塞模式:

  • 若数据未就绪,read() 立即返回 -1errno = EAGAINEWOULDBLOCK
  • 若数据已就绪(如在 Page Cache 中),立即完成拷贝并返回正数
  • 用户进程需通过 轮询 (polling) 或配合 select/poll/epoll 使用
c 复制代码
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);

while ((n = read(fd, buf, sizeof(buf))) < 0) {
    if (errno == EAGAIN) {
        // 数据未就绪,可执行其他任务或短暂休眠
        usleep(1000);
        continue;
    }
    // 处理其他错误
}

应用层陷阱: 纯轮询非阻塞 IO 在空转时会消耗大量 CPU,因此生产环境中通常与 IO 多路复用结合使用。

2.3 IO 多路复用: 从 select 到 epoll

接口 内核版本 数据结构 时间复杂度 限制
select() 全版本 bitmap (fd_set) O(maxfd) 默认 1024 fd 上限
poll() 全版本 链表 (pollfd\[\]) O(N) 无硬上限,但线性扫描
epoll() 2.5.44+ 红黑树 + 就绪链表 O(1) 每事件 Linux 专属,不支持普通文件

epoll 的核心优势在于 事件驱动而非轮询 : 内核通过红黑树维护所有注册的文件描述符兴趣集,仅当 fd 状态变化时将其推入就绪链表。epoll_wait() 只需消费就绪链表,无需遍历全部 fd。

c 复制代码
int epfd = epoll_create1(0);
struct epoll_event ev = { .events = EPOLLIN, .data.fd = fd };
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev);

struct epoll_event events[10];
int nfds = epoll_wait(epfd, events, 10, -1);  // 阻塞等待事件
for (int i = 0; i < nfds; i++) {
    read(events[i].data.fd, buf, sizeof(buf));
}

2.4 信号驱动 IO: 基于 SIGIO 的异步通知

通过 fcntl(fd, F_SETOWN, getpid())fcntl(fd, F_SETFL, O_ASYNC) 设置信号驱动模式:

  • 数据准备就绪时,内核向进程发送 SIGIOSIGURG 信号
  • 进程在信号处理函数中执行 read() 完成数据拷贝
  • 数据拷贝阶段仍阻塞(同步),但数据准备阶段不阻塞

此模型在 Linux 中应用较少,主要因信号处理的开销与可重入问题。

2.5 io_uring: 应用层的终极异步接口 (Kernel 5.1+)

io_uring 通过 共享内存环形缓冲区 实现用户态与内核态的高效通信:

  • Submission Queue (SQ): 用户态写入待执行的 IO 操作(read/write/accept/send/recv/openat/fsync...)
  • Completion Queue (CQ): 内核态写入操作完成结果
  • 批量提交 (io_uring_submit) 可将多个 IO 操作一次性送入内核,大幅减少系统调用次数
  • SQPoll 模式 : 内核线程持续轮询 SQ,用户态在稳态下可实现 零系统调用
c 复制代码
struct io_uring ring;
io_uring_queue_init(256, &ring, 0);

struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, len, offset);
io_uring_submit(&ring);  // 批量提交

struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);  // 等待完成

3. API 参考与使用示例

3.1 基础文件操作 API

open() --- 打开文件并设置 IO 模式
c 复制代码
#include <fcntl.h>
#include <sys/types.h>
#include <sys/stat.h>

int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
参数 说明
pathname 文件路径或设备节点
flags O_RDONLY / O_WRONLY / O_RDWR + O_NONBLOCK / O_ASYNC / O_DIRECT
mode 创建文件时的权限位 (如 0644)

关键标志位:

  • O_NONBLOCK: 以非阻塞模式打开。对 FIFO、管道、socket、部分字符设备有效
  • O_ASYNC: 启用信号驱动 IO,配合 fcntl(F_SETOWN) 使用
  • O_DIRECT: 绕过 Page Cache,用户缓冲区与设备直接 DMA 传输。要求缓冲区按页对齐
  • O_CLOEXEC: 设置 close-on-exec 标志,防止子进程继承 fd (推荐始终使用)

返回值 : 成功返回文件描述符 (>= 0),失败返回 -1 并设置 errno

注意事项:

  • O_NONBLOCK 对普通文件无效 ,普通文件的 read() 总是同步完成(或进入 D-state)
  • O_DIRECT 要求缓冲区地址和长度均按块大小对齐(通常为 512 字节),否则返回 EINVAL
  • O_DIRECTO_SYNC 不同:O_DIRECT 绕过缓存,O_SYNC 保证数据落盘
fcntl() --- 修改已打开文件的属性
c 复制代码
#include <fcntl.h>

int fcntl(int fd, int cmd, ... /* arg */ );
cmd 作用 示例
F_GETFL 获取当前文件状态标志 int flags = fcntl(fd, F_GETFL);
F_SETFL 设置文件状态标志 `fcntl(fd, F_SETFL, flags
F_GETOWN 获取接收 SIGIO/SIGURG 的进程/进程组 ID fcntl(fd, F_GETOWN);
F_SETOWN 设置接收 SIGIO/SIGURG 的进程/进程组 ID fcntl(fd, F_SETOWN, getpid());
F_SETPIPE_SZ 设置管道缓冲区大小 (Kernel 2.6.35+) fcntl(fd, F_SETPIPE_SZ, 1048576);

注意事项:

  • F_SETFL 只能修改 O_APPENDO_ASYNCO_DIRECTO_NOATIMEO_NONBLOCK,无法修改访问模式 (O_RDONLY 等)
  • 设置 O_NONBLOCK 时,必须使用 F_GETFL 先读取原标志,再按位或,否则覆盖其他标志
  • F_SETOWNarg 为正数表示进程 ID,为负数表示进程组 ID (-pgid)
read() / write() --- 基础 IO 操作
c 复制代码
#include <unistd.h>

ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);

阻塞模式行为:

  • read(): 若内核缓冲区有数据,返回实际读取字节数;若无数据,进程进入 TASK_INTERRUPTIBLE 等待
  • write(): 若内核缓冲区有空间,拷贝数据并返回;若空间不足,阻塞等待
  • 返回 0 表示对端关闭 (EOF)

非阻塞模式行为:

  • 数据未就绪 / 缓冲区满时,返回 -1errno = EAGAINEWOULDBLOCK
  • 部分就绪时,返回实际可处理的字节数(可能小于 count

返回值与 errno:

返回值 含义
> 0 实际读/写字节数
0 读到 EOF (read) / 对端关闭
-1 出错,检查 errno
errno 触发条件
EAGAIN / EWOULDBLOCK 非阻塞模式下数据未就绪或缓冲区满
EINTR 系统调用被信号中断,通常需要重试
ECONNRESET 对端强制关闭连接 (socket)
EPIPE 向已关闭的管道/socket 写数据
EFAULT buf 指针无效

注意事项:

  • read() / write() 不保证一次性完成 count 字节,需循环处理或配合 readn() / writen() 封装
  • 被信号中断 (EINTR) 时,应根据应用逻辑决定是否重试。对于慢速设备,建议使用 TEMP_FAILURE_RETRY()
  • 管道/ socket 的 write() 在阻塞模式下,若写入量超过 PIPE_BUF (通常 4KB/64KB),可能非原子性

3.2 IO 多路复用 API

select() --- 最古老的 fd 集合监控
c 复制代码
#include <sys/select.h>

int select(int nfds, fd_set *readfds, fd_set *writefds,
           fd_set *exceptfds, struct timeval *timeout);

void FD_ZERO(fd_set *set);
void FD_SET(int fd, fd_set *set);
void FD_CLR(int fd, fd_set *set);
int  FD_ISSET(int fd, fd_set *set);
参数 说明
nfds 三个 fd_set 中最大 fd 值 + 1
readfds 监控可读事件的 fd 集合
writefds 监控可写事件的 fd 集合
exceptfds 监控异常事件的 fd 集合
timeout NULL 永久阻塞;{0,0} 非阻塞轮询;{tv_sec, tv_usec} 超时阻塞

返回值:

  • > 0: 就绪的 fd 总数
  • 0: 超时返回
  • -1: 出错,errno 可能为 EINTR / EBADF

注意事项:

  • fd_set 大小由 FD_SETSIZE 定义,默认 1024,无法动态扩展。超过 1024 的 fd 会导致未定义行为或编译错误
  • select() 返回后,会修改传入的 fd_set ,仅保留就绪的 fd。每次调用前必须重新 FD_SET
  • nfds 参数决定了内核扫描范围,设置过大(如 10000)会导致性能劣化,因为内核仍需遍历 0~nfds-1
  • 时间精度为微秒级,但受内核调度粒度限制,实际精度通常为毫秒级
poll() --- 基于数组的 fd 监控
c 复制代码
#include <poll.h>

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

struct pollfd {
    int   fd;         // 文件描述符
    short events;     // 请求监控的事件掩码
    short revents;    // 实际发生的事件掩码 (内核回填)
};
events / revents 标志 含义
POLLIN 可读(普通数据或优先级数据)
POLLPRI 紧急/带外数据可读
POLLOUT 可写
POLLERR 错误 (仅用于 revents)
POLLHUP 挂起/对端关闭 (仅用于 revents)
POLLNVAL fd 未打开 (仅用于 revents)

返回值 : 同 select()

注意事项:

  • 无 fd 数量硬上限(受限于进程内存),但每次调用需将 pollfd[] 数组从用户态拷贝至内核态,fd 数量大时开销显著
  • 内核每次调用仍需线性扫描整个 pollfd[] 数组,时间复杂度 O(N)
  • POLLHUPPOLLERR 可以在无 events 请求的情况下在 revents 中返回
  • 普通文件 fd 的 poll() 总是立即返回 POLLIN / POLLOUT,因为普通文件"始终就绪"
epoll 系列 --- 事件驱动的高效多路复用
c 复制代码
#include <sys/epoll.h>

int epoll_create1(int flags);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events,
               int maxevents, int timeout);

struct epoll_event {
    uint32_t     events;      // 事件掩码
    epoll_data_t data;        // 用户数据 (联合体)
};

union epoll_data {
    void        *ptr;
    int          fd;
    uint32_t     u32;
    uint64_t     u64;
};

epoll_create1(flags):

flags 说明
0 默认行为
EPOLL_CLOEXEC 设置 close-on-exec (强烈推荐)

epoll_ctl()op 参数:

op 作用
EPOLL_CTL_ADD 将 fd 注册到 epoll 实例
EPOLL_CTL_MOD 修改已注册 fd 的事件掩码
EPOLL_CTL_DEL 从 epoll 实例中移除 fd

events 标志位:

标志 说明
EPOLLIN 可读
EPOLLOUT 可写
EPOLLERR 错误 (默认监控,无需显式设置)
EPOLLHUP 挂起 (默认监控)
EPOLLET 边缘触发模式 (Edge Triggered)
EPOLLONESHOT 单次触发,触发后自动禁用,需 EPOLL_CTL_MOD 重新启用
EPOLLEXCLUSIVE 排他唤醒,避免 thundering herd (Kernel 4.5+)

epoll_wait() 参数:

  • timeout = -1: 永久阻塞
  • timeout = 0: 非阻塞轮询
  • timeout > 0: 阻塞至多 timeout 毫秒

返回值: 就绪事件数,0 表示超时,-1 表示出错

注意事项:

  • epoll 不支持普通文件 : 普通文件没有"就绪"概念,epoll_ctl(EPOLL_CTL_ADD) 对普通文件返回 EPERM
  • ET (边缘触发) vs LT (水平触发) :
    • LT (默认) : 只要 fd 处于就绪状态,每次 epoll_wait() 都会返回该 fd
    • ET : 仅在状态变化时触发一次。要求应用必须一次性读完所有数据(循环 read() 直到 EAGAIN),否则下次就绪不会通知
    • ET 模式配合 EPOLLOUT 时,仅在缓冲区从满变非满时触发一次,避免频繁可写事件
  • EPOLLONESHOT: 适用于多线程 accept 场景,防止多个线程同时处理同一事件
  • EPOLLEXCLUSIVE: 多进程/多线程监听同一 listen fd 时,避免所有进程同时被唤醒
  • epoll_wait() 返回的 events 数组中的 data 字段与注册时一致,通常用 data.fddata.ptr 存储上下文
  • fd 被 close() 后,会自动从所有 epoll 实例中移除,但显式 EPOLL_CTL_DEL 更规范

3.3 信号驱动 IO API

sigaction() --- 注册信号处理函数
c 复制代码
#include <signal.h>

int sigaction(int signum, const struct sigaction *act,
              struct sigaction *oldact);

struct sigaction {
    void     (*sa_handler)(int);
    void     (*sa_sigaction)(int, siginfo_t *, void *);
    sigset_t   sa_mask;
    int        sa_flags;
    void     (*sa_restorer)(void);
};

配合信号驱动 IO 的 fcntl() 调用序列:

c 复制代码
// 1. 设置接收 SIGIO 的进程
fcntl(fd, F_SETOWN, getpid());

// 2. 启用异步通知
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_ASYNC);

// 3. 注册信号处理函数
struct sigaction sa;
sa.sa_handler = sigio_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
sigaction(SIGIO, &sa, NULL);

注意事项:

  • 信号处理函数中只能调用 async-signal-safe 函数(read()write()close() 是安全的,malloc()printf() 不安全)
  • 多线程程序中,信号会递交给任意一个未屏蔽该信号的线程 ,需使用 pthread_sigmask() 统一管理
  • 高并发场景下,信号驱动 IO 的性能通常不如 epoll,因信号处理开销与内核调度成本较高
  • O_ASYNC 对普通文件同样无效

3.4 io_uring API (liburing)

io_uring 的用户态接口通过 liburing 库封装,也可直接通过三个系统调用 (io_uring_setup, io_uring_enter, io_uring_register) 操作。

io_uring_queue_init() --- 初始化 io_uring 实例
c 复制代码
#include <liburing.h>

int io_uring_queue_init(unsigned entries, struct io_uring *ring, unsigned flags);
参数 说明
entries SQ 和 CQ 的条目数,必须是 2 的幂次,范围 1~4096 (受内核限制)
ring struct io_uring 结构体指针,由函数填充
flags IORING_SETUP_IOPOLL / IORING_SETUP_SQPOLL / IORING_SETUP_SQ_AFF

关键 flags:

标志 说明 内核版本
0 默认模式,用户态提交 SQE,内核异步处理 5.1+
IORING_SETUP_IOPOLL IO 轮询模式,完成路径忙轮询,低延迟 5.1+
IORING_SETUP_SQPOLL 内核线程轮询 SQ,用户态稳态零 syscall 5.10+
IORING_SETUP_SQ_AFF 绑定 SQPoll 线程到特定 CPU 5.10+
IORING_SETUP_COOP_TASKRUN 协作任务运行,降低延迟 5.19+
IORING_SETUP_SINGLE_ISSUER 单提交者优化,减少锁竞争 6.0+

返回值 : 0 成功,负数为 -errno

注意事项:

  • entries 若不为 2 的幂次,io_uring_queue_init() 会将其向上取整到最近的 2 的幂次
  • IORING_SETUP_SQPOLL 需要进程具备 CAP_SYS_NICE 能力或 root 权限(因创建内核线程)
  • IORING_SETUP_IOPOLL 要求文件系统/设备支持轮询,通常仅对 NVMe 等高速块设备有效
io_uring_get_sqe() --- 获取 SQE 条目
c 复制代码
struct io_uring_sqe *io_uring_get_sqe(struct io_uring *ring);
  • 从 SQ 环中获取一个空闲的 io_uring_sqe 指针
  • 若 SQ 已满,返回 NULL
  • 非线程安全 : 多线程需外部加锁或使用 IORING_SETUP_SINGLE_ISSUER
io_uring_prep_* 系列 --- 填充操作码
c 复制代码
// 文件 IO
void io_uring_prep_read(struct io_uring_sqe *sqe, int fd, void *buf,
                        unsigned nbytes, off_t offset);
void io_uring_prep_write(struct io_uring_sqe *sqe, int fd, const void *buf,
                         unsigned nbytes, off_t offset);
void io_uring_prep_readv(struct io_uring_sqe *sqe, int fd,
                         const struct iovec *iovecs, unsigned nr_vecs, off_t offset);
void io_uring_prep_writev(struct io_uring_sqe *sqe, int fd,
                          const struct iovec *iovecs, unsigned nr_vecs, off_t offset);

// 网络 IO
void io_uring_prep_recv(struct io_uring_sqe *sqe, int fd, void *buf,
                        size_t len, int flags);
void io_uring_prep_send(struct io_uring_sqe *sqe, int fd, const void *buf,
                        size_t len, int flags);
void io_uring_prep_accept(struct io_uring_sqe *sqe, int fd,
                          struct sockaddr *addr, socklen_t *addrlen, int flags);
void io_uring_prep_connect(struct io_uring_sqe *sqe, int fd,
                           const struct sockaddr *addr, socklen_t addrlen);

// 文件系统
void io_uring_prep_openat(struct io_uring_sqe *sqe, int dfd, const char *path,
                          int flags, mode_t mode);
void io_uring_prep_close(struct io_uring_sqe *sqe, int fd);
void io_uring_prep_fsync(struct io_uring_sqe *sqe, int fd, unsigned flags);

// 链接操作 (chained operations)
void io_uring_prep_nop(struct io_uring_sqe *sqe);

关键 sqe->flags:

标志 说明
IOSQE_IO_LINK 当前 SQE 与下一个 SQE 链接,前一个失败则后续取消
IOSQE_IO_HARDLINK 强链接,前一个失败不影响后续执行
IOSQE_ASYNC 强制异步执行,即使数据在 Page Cache 中
IOSQE_BUFFER_SELECT 使用预注册缓冲区池 (Buffer Ring, Kernel 5.19+)
io_uring_submit() --- 批量提交 SQE
c 复制代码
int io_uring_submit(struct io_uring *ring);
  • 将当前已填充但未提交的 SQE 批量刷入内核
  • 返回实际提交的 SQE 数量
  • SQPoll 模式下,此调用可能为 0(内核线程自动轮询),但仍需周期性调用以推进 tail
io_uring_wait_cqe() / io_uring_peek_cqe() --- 收割完成事件
c 复制代码
int io_uring_wait_cqe(struct io_uring *ring, struct io_uring_cqe **cqe_ptr);
int io_uring_peek_cqe(struct io_uring *ring, struct io_uring_cqe **cqe_ptr);
函数 行为
io_uring_wait_cqe() 阻塞等待至少一个 CQE 可用
io_uring_peek_cqe() 非阻塞检查,若无 CQE 返回 -EAGAIN

CQE 字段解析:

字段 说明
cqe->user_data 与对应 SQE 的 user_data 一致,用于关联请求与响应
cqe->res 操作结果。read 对应返回字节数,-errno 表示错误
cqe->flags 完成标志,如 IORING_CQE_F_BUFFER (使用 Buffer Ring)
io_uring_cqe_seen() --- 标记 CQE 已消费
c 复制代码
void io_uring_cqe_seen(struct io_uring *ring, struct io_uring_cqe *cqe);
  • 必须调用,否则 CQ 环的头指针不前进,导致 CQ 溢出
  • 典型模式: wait_cqe → 处理 cqecqe_seen
io_uring_queue_exit() --- 清理资源
c 复制代码
void io_uring_queue_exit(struct io_uring *ring);
  • 释放与 io_uring 实例关联的所有资源,包括 mmap 的内存区域

3.5 完整示例代码

示例 1: 非阻塞 TCP Echo 客户端
c 复制代码
/* nonblock_client.c
 * 编译: gcc -o nonblock_client nonblock_client.c
 * 演示: 非阻塞 connect + select 监控可写事件判断连接成功
 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <fcntl.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/select.h>

int set_nonblocking(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    if (flags < 0) return -1;
    return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

int main(int argc, char *argv[]) {
    if (argc < 3) {
        fprintf(stderr, "Usage: %s <ip> <port>\n", argv[0]);
        return 1;
    }

    int sock = socket(AF_INET, SOCK_STREAM, 0);
    if (sock < 0) { perror("socket"); return 1; }

    if (set_nonblocking(sock) < 0) {
        perror("set_nonblocking");
        close(sock);
        return 1;
    }

    struct sockaddr_in addr = {
        .sin_family = AF_INET,
        .sin_port   = htons(atoi(argv[2])),
    };
    inet_pton(AF_INET, argv[1], &addr.sin_addr);

    // 非阻塞 connect: 立即返回 EINPROGRESS
    int rc = connect(sock, (struct sockaddr *)&addr, sizeof(addr));
    if (rc < 0 && errno != EINPROGRESS) {
        perror("connect");
        close(sock);
        return 1;
    }

    // 使用 select 监控连接完成 (可写事件)
    fd_set wfds;
    FD_ZERO(&wfds);
    FD_SET(sock, &wfds);
    struct timeval tv = { .tv_sec = 5, .tv_usec = 0 };

    rc = select(sock + 1, NULL, &wfds, NULL, &tv);
    if (rc <= 0) {
        fprintf(stderr, "connect timeout or error\n");
        close(sock);
        return 1;
    }

    // 检查 SO_ERROR 确认连接真正成功
    int so_error;
    socklen_t len = sizeof(so_error);
    getsockopt(sock, SOL_SOCKET, SO_ERROR, &so_error, &len);
    if (so_error != 0) {
        fprintf(stderr, "connect failed: %s\n", strerror(so_error));
        close(sock);
        return 1;
    }

    printf("Connected! Enter text (Ctrl+D to quit):\n");

    char buf[1024];
    while (fgets(buf, sizeof(buf), stdin)) {
        size_t total = strlen(buf);
        size_t sent = 0;
        while (sent < total) {
            ssize_t n = write(sock, buf + sent, total - sent);
            if (n < 0) {
                if (errno == EAGAIN) {
                    usleep(1000);
                    continue;
                }
                perror("write");
                close(sock);
                return 1;
            }
            sent += n;
        }

        // 非阻塞读回显
        memset(buf, 0, sizeof(buf));
        ssize_t n = read(sock, buf, sizeof(buf) - 1);
        if (n > 0) {
            printf("Echo: %s", buf);
        } else if (n == 0) {
            printf("Server closed connection\n");
            break;
        } else if (errno != EAGAIN) {
            perror("read");
            break;
        }
    }

    close(sock);
    return 0;
}

注意事项:

  • 非阻塞 connect() 返回 EINPROGRESS 是正常行为,不代表失败
  • 必须通过 select()/poll() 监控可写事件,再用 getsockopt(SO_ERROR) 确认连接结果
  • 非阻塞 write() 可能只写入部分数据,必须循环处理
示例 2: epoll LT/ET 模式 Echo 服务器
c 复制代码
/* epoll_server.c
 * 编译: gcc -o epoll_server epoll_server.c
 * 演示: epoll 水平触发(LT)与边缘触发(ET)的差异处理
 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <fcntl.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>

#define MAX_EVENTS 1024
#define BUF_SIZE   4096

int set_nonblocking(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    if (flags < 0) return -1;
    return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

int main(int argc, char *argv[]) {
    int port = (argc > 1) ? atoi(argv[1]) : 8080;
    int use_et = (argc > 2 && strcmp(argv[2], "et") == 0);  // ./epoll_server 8080 et

    int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_fd < 0) { perror("socket"); return 1; }

    int reuse = 1;
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));

    struct sockaddr_in addr = {
        .sin_family = AF_INET,
        .sin_port   = htons(port),
        .sin_addr   = { INADDR_ANY },
    };
    if (bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
        perror("bind"); return 1;
    }
    if (listen(listen_fd, 128) < 0) { perror("listen"); return 1; }

    int epfd = epoll_create1(EPOLL_CLOEXEC);
    if (epfd < 0) { perror("epoll_create1"); return 1; }

    struct epoll_event ev, events[MAX_EVENTS];
    ev.events = EPOLLIN;
    ev.data.fd = listen_fd;
    epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);

    printf("Server listening on port %d, mode=%s\n",
           port, use_et ? "ET" : "LT");

    while (1) {
        int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
        if (nfds < 0) {
            if (errno == EINTR) continue;
            perror("epoll_wait");
            break;
        }

        for (int i = 0; i < nfds; i++) {
            int fd = events[i].data.fd;

            if (fd == listen_fd) {
                // 接受新连接
                while (1) {  // ET 模式下需循环 accept
                    struct sockaddr_in client_addr;
                    socklen_t len = sizeof(client_addr);
                    int conn_fd = accept4(listen_fd, (struct sockaddr *)&client_addr,
                                          &len, SOCK_NONBLOCK | SOCK_CLOEXEC);
                    if (conn_fd < 0) {
                        if (errno == EAGAIN || errno == EWOULDBLOCK)
                            break;  // 无更多连接
                        perror("accept4");
                        break;
                    }
                    ev.events = EPOLLIN | EPOLLET;  // 默认 ET 模式
                    ev.data.fd = conn_fd;
                    epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, &ev);
                    printf("New connection: fd=%d\n", conn_fd);
                }
            } else {
                // 处理客户端数据
                if (use_et) {
                    // ET 模式: 必须一次性读完所有数据
                    while (1) {
                        char buf[BUF_SIZE];
                        ssize_t n = read(fd, buf, sizeof(buf));
                        if (n > 0) {
                            write(fd, buf, n);  // echo back
                        } else if (n == 0) {
                            printf("Client fd=%d closed\n", fd);
                            epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
                            close(fd);
                            break;
                        } else {  // n < 0
                            if (errno == EAGAIN || errno == EWOULDBLOCK)
                                break;  // 读完了
                            perror("read");
                            epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
                            close(fd);
                            break;
                        }
                    }
                } else {
                    // LT 模式: 读一次即可,epoll_wait 会再次报告剩余数据
                    char buf[BUF_SIZE];
                    ssize_t n = read(fd, buf, sizeof(buf));
                    if (n > 0) {
                        write(fd, buf, n);
                    } else if (n == 0) {
                        printf("Client fd=%d closed\n", fd);
                        epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
                        close(fd);
                    } else {
                        perror("read");
                        epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
                        close(fd);
                    }
                }
            }
        }
    }

    close(epfd);
    close(listen_fd);
    return 0;
}

LT vs ET 关键差异:

特性 LT (水平触发) ET (边缘触发)
触发条件 fd 就绪状态持续存在即触发 状态变化时仅触发一次
read() 要求 读一次即可 必须循环读到 EAGAIN
accept() 要求 调用一次 必须循环 accept 到 EAGAIN
事件丢失风险 若未读尽,后续数据不通知
CPU 开销 略高(重复通知) 更低
编程复杂度
示例 3: io_uring 批量读写
c 复制代码
/* io_uring_demo.c
 * 编译: gcc -o io_uring_demo io_uring_demo.c -luring
 * 演示: 使用 io_uring 批量读取文件
 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <liburing.h>
#include <fcntl.h>
#include <unistd.h>

#define QUEUE_DEPTH 64
#define BLOCK_SIZE  4096

int main(int argc, char *argv[]) {
    if (argc < 2) {
        fprintf(stderr, "Usage: %s <file>\n", argv[0]);
        return 1;
    }

    struct io_uring ring;
    if (io_uring_queue_init(QUEUE_DEPTH, &ring, 0) < 0) {
        perror("io_uring_queue_init");
        return 1;
    }

    int fd = open(argv[1], O_RDONLY);
    if (fd < 0) { perror("open"); return 1; }

    // 获取文件大小
    off_t file_size = lseek(fd, 0, SEEK_END);
    lseek(fd, 0, SEEK_SET);

    // 分配对齐缓冲区 (O_DIRECT 需要页对齐)
    char *buf;
    if (posix_memalign((void **)&buf, BLOCK_SIZE, file_size) != 0) {
        perror("posix_memalign");
        return 1;
    }

    // 批量提交 read 请求
    off_t offset = 0;
    int pending = 0;
    while (offset < file_size) {
        struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
        if (!sqe) {
            // SQ 已满,先提交一批
            io_uring_submit(&ring);
            sqe = io_uring_get_sqe(&ring);
        }

        size_t to_read = (file_size - offset < BLOCK_SIZE)
                         ? (file_size - offset) : BLOCK_SIZE;
        io_uring_prep_read(sqe, fd, buf + offset, to_read, offset);
        sqe->user_data = offset;  // 用 user_data 标识请求
        offset += to_read;
        pending++;
    }

    // 提交剩余请求
    io_uring_submit(&ring);

    // 收割所有完成事件
    int completed = 0;
    while (completed < pending) {
        struct io_uring_cqe *cqe;
        int ret = io_uring_wait_cqe(&ring, &cqe);
        if (ret < 0) {
            fprintf(stderr, "io_uring_wait_cqe: %s\n", strerror(-ret));
            break;
        }

        if (cqe->res < 0) {
            fprintf(stderr, "IO error at offset %llu: %s\n",
                    (unsigned long long)cqe->user_data, strerror(-cqe->res));
        } else {
            printf("Read %d bytes at offset %llu\n",
                   cqe->res, (unsigned long long)cqe->user_data);
        }

        io_uring_cqe_seen(&ring, cqe);
        completed++;
    }

    printf("Total: %d/%d requests completed\n", completed, pending);

    free(buf);
    close(fd);
    io_uring_queue_exit(&ring);
    return 0;
}

io_uring 编程注意事项:

  • user_data 是关联 SQE 与 CQE 的唯一手段,必须设置且保证唯一性
  • io_uring_get_sqe() 可能返回 NULL(SQ 满),需处理并先 submit
  • io_uring_wait_cqe() 阻塞等待,若需非阻塞收割使用 io_uring_peek_cqe()
  • 每个 cqe 必须通过 io_uring_cqe_seen() 消费,否则 CQ 环溢出
  • 使用 O_DIRECT 时,缓冲区必须通过 posix_memalign() 按页对齐

4. 内核层视角: 从 VFS 到调度器的阻塞真相

4.1 VFS 层: 统一的文件操作入口

无论应用层使用何种 IO 模型,所有文件操作均通过 VFS (Virtual File System) 统一入口进入内核:

系统调用 VFS 入口 核心行为
read() sys_read()vfs_read() 检查 file->f_flags & O_NONBLOCK,调用 file->f_op->read_iter()
write() sys_write()vfs_write() 同上,调用 file->f_op->write_iter()
poll() sys_poll()do_sys_poll() 遍历 pollfd[],调用每个 fd 的 f_op->poll()
epoll_ctl() sys_epoll_ctl() 将 fd 注册到 epoll 实例的红黑树
epoll_wait() sys_epoll_wait()ep_poll() 等待就绪链表,超时或事件到达后返回
io_uring_enter() sys_io_uring_enter() 提交 SQ 条目,等待 CQ 完成事件

4.2 等待队列: 阻塞的本质数据结构

阻塞 IO 的核心机制是 等待队列 (wait_queue)。当进程因 IO 未就绪而需要睡眠时:

  1. 驱动或内核子系统定义等待队列头 wait_queue_head_t wq
  2. 进程调用 wait_event_interruptible(wq, condition) 或等价逻辑
  3. 当前进程被加入等待队列,状态设为 TASK_INTERRUPTIBLE
  4. 调用 schedule() 触发上下文切换,CPU 转交其他进程
  5. 中断处理程序或异步事件满足 condition 后,调用 wake_up(&wq)
  6. 被唤醒进程重新进入 TASK_RUNNING,参与调度竞争
c 复制代码
// 内核中典型的阻塞读实现 (以字符设备为例)
static ssize_t my_read(struct file *filp, char __user *buf, size_t len, loff_t *off)
{
    struct my_device *dev = filp->private_data;

    wait_event_interruptible(dev->wq, dev->data_ready);  // 阻塞等待条件

    if (copy_to_user(buf, dev->buffer, len))
        return -EFAULT;
    return len;
}

// 中断处理程序中唤醒
static irqreturn_t my_irq_handler(int irq, void *dev_id)
{
    struct my_device *dev = dev_id;
    dev->data_ready = 1;
    wake_up_interruptible(&dev->wq);  // 唤醒等待队列上的进程
    return IRQ_HANDLED;
}

4.3 进程状态机: 阻塞的精确语义

Linux 内核中进程与 IO 阻塞相关的状态:

状态 宏定义 含义 典型场景
TASK_RUNNING 0 可运行 / 正在运行 进程正常执行
TASK_INTERRUPTIBLE 1 可中断睡眠 阻塞 IO 等待数据,可被信号唤醒
TASK_UNINTERRUPTIBLE 2 不可中断睡眠 等待磁盘 IO(如 __GFP_FS 分配),忽略信号
TASK_KILLABLE 3.12+ 可杀睡眠 类似 UNINTERRUPTIBLE,但可被致命信号终止
TASK_IDLE 4.14+ 空闲任务 空闲 CPU 的 idle 线程

Kernel 3.12 关键改进 : 引入 TASK_KILLABLE 状态,解决了大量进程因 TASK_UNINTERRUPTIBLE 等待磁盘 IO 而无法被 kill -9 终止的痛点( infamous "D-state" 问题)。

4.4 Page Cache 与阻塞的解耦

对于文件 IO,数据可能已存在于 Page Cache 中:

  • 缓存命中 : read() 直接从 Page Cache 拷贝数据到用户空间,不触发阻塞,无需访问硬件
  • 缓存未命中 : 触发缺页异常 → 发起块设备 IO → 进程进入 TASK_UNINTERRUPTIBLE 等待页读取完成
  • O_DIRECT 标志: 绕过 Page Cache,用户缓冲区与设备直接 DMA 传输,减少一次数据拷贝

5. 驱动层视角: 设备树时代的驱动与等待队列

5.1 设备树 (Device Tree) 与驱动模型的分离

Kernel 3.7 起,ARM 架构 强制使用设备树 (Device Tree) 启动,标志着 Linux 驱动模型进入 "分离式" 时代:

  • 硬件描述 由设备树 (.dts / .dtb) 承担,不再硬编码于内核
  • 驱动代码 通过 of_match_table 与设备树节点匹配,实现 "一份驱动,多处复用"
  • 此变革与 IO 模型演进并行,使得驱动开发者更专注于 IO 路径优化 而非板级适配
c 复制代码
static const struct of_device_id my_driver_dt_match[] = {
    { .compatible = "vendor,mydevice" },
    { }
};
MODULE_DEVICE_TABLE(of, my_driver_dt_match);

static struct platform_driver my_driver = {
    .probe = my_probe,
    .remove = my_remove,
    .driver = {
        .name = "mydevice",
        .of_match_table = my_driver_dt_match,
    },
};

5.2 驱动中的阻塞/非阻塞实现

驱动层通过 file_operations 结构体的 .read / .write / .poll 方法决定 IO 行为:

c 复制代码
static const struct file_operations my_fops = {
    .owner = THIS_MODULE,
    .read = my_read,
    .write = my_write,
    .poll = my_poll,           // 支持 poll/select/epoll
    .fasync = my_fasync,       // 支持信号驱动 IO
};

阻塞读实现要点:

  • 检查 filp->f_flags & O_NONBLOCK,若置位则数据未就绪时立即返回 -EAGAIN
  • 否则调用 wait_event_interruptible() 将进程挂入等待队列
  • 中断/DMA 完成回调中调用 wake_up() 唤醒

poll 实现要点:

  • 实现 .poll 方法,返回 POLLIN / POLLOUT / POLLERR 等掩码
  • 通过 poll_wait(filp, &my_wait_queue, wait) 将 poll 结构注册到等待队列
  • 当数据就绪时,内核自动通知 epoll 实例

5.3 中断与下半部: 唤醒的触发源

驱动层通过中断机制将硬件事件传递至内核:

  1. 硬件中断 (Top Half): 响应硬件信号,执行最小必要操作(如读取状态寄存器、清除中断标志)
  2. 软中断/任务队列 (Bottom Half): 中断上下文外执行耗时操作(如数据处理、唤醒等待队列)
  3. 进程唤醒 : Bottom Half 中调用 wake_up(),将等待队列上的进程状态改为 TASK_RUNNING

在 RTOS 中,中断下半部通常通过软中断、tasklet、工作队列或内核线程实现;Linux 在 6.12+ 后进一步强化了内核线程化下半部的趋势。


6. 版本演进: 从 3.x 到 6.x 的关键节点

6.1 Kernel 3.x: 设备树时代与基础 IO 成熟

  • 3.0 (2011): 设备树正式支持 ARM,驱动模型开始解耦
  • 3.7 (2012): ARM 强制 DTB 启动,标志嵌入式 Linux 进入标准化时代
  • 3.19 (2015) : DIRECT IO (O_DIRECT) 完善,数据库等应用可绕过 Page Cache 直接访问块设备
  • 核心特征: 阻塞/非阻塞 IO + select/poll/epoll 体系已完全成熟,成为应用开发的标准范式

6.2 Kernel 4.x: 零拷贝与 IO 调度器革新

  • 4.0 (2015) : O_DIRECT 零拷贝增强,减少用户态-内核态数据拷贝
  • 4.19 (2018): LTS 版本,epoll 在生产环境中广泛验证;多队列块层 (blk-mq) 成熟,替代传统单队列 elevator
  • 核心特征 : IO 调度器从 cfq / deadlinemq-deadline / kyber / bfq 演进,NVMe SSD 性能得到释放

6.3 Kernel 5.x: io_uring 的诞生与成熟

  • 5.1 (2019) : io_uring 首次合并 (Jens Axboe),引入 SQ/CQ 环形缓冲区机制,统一网络与存储异步 IO
  • 5.10 (2020) : LTS 版本,引入 SQPollIOPoll 模式:
    • SQPoll: 内核线程轮询 Submission Queue,用户态可实现零 syscall
    • IOPoll: 对完成路径进行忙轮询,适用于延迟敏感型存储 IO
  • 5.15 (2021) : io_uring 生产可用,支持 accept / recv / send 等网络操作,liburing 库成熟
  • 核心特征: io_uring 开始挑战 epoll 在网络 IO 领域的统治地位,但 epoll 仍是最稳妥的默认选择

6.4 Kernel 6.x: 零拷贝与生产级完善

  • 6.0 (2022) : 引入 IORING_OP_SEND_ZC(Zero-Copy Send),网络数据包可直接从用户缓冲区经 NIC DMA 发送,无需内核拷贝
  • 6.1 (2022) : LTS 版本,Buffer Ring 机制引入,支持预注册缓冲区池,进一步减少内存映射开销;成为 io_uring 生产部署的推荐基线
  • 6.6 (2023) : io_uring 全面优化,多 shot accept/recv、链接操作 (IOSQE_IO_LINK)、超时与取消机制完善
  • 核心特征: io_uring 从 "高性能玩具" 进化为 "生产级基础设施",统一了磁盘 IO、网络 IO、定时器、文件系统操作的异步接口

7. io_uring: 统一异步接口的架构革命

7.1 架构设计: 共享内存环形缓冲区

io_uring 的核心创新在于 消除用户态/内核态边界上的系统调用开销:

复制代码
用户空间                    内核空间
┌─────────────┐            ┌─────────────┐
│  liburing   │            │  io_uring   │
│  (辅助库)    │            │  (内核模块)  │
└──────┬──────┘            └──────┬──────┘
       │                         │
       ▼                         ▼
┌─────────────┐            ┌─────────────┐
│  SQ Ring    │ ────────→ │  内核线程   │
│ (提交队列)   │  共享内存  │ (SQPoll)    │
└─────────────┘            └──────┬──────┘
                                   │
┌─────────────┐            ┌──────┴──────┐
│  CQ Ring    │ ←───────── │  完成处理   │
│ (完成队列)   │  共享内存  │             │
└─────────────┘            └─────────────┘
  • SQ (Submission Queue) : 用户态通过 io_uring_get_sqe() 获取条目,填充操作码与参数,调用 io_uring_submit() 批量提交
  • CQ (Completion Queue) : 内核完成 IO 后将结果写入 CQ,用户态通过 io_uring_wait_cqe() / io_uring_peek_cqe() 收割
  • 共享内存 : SQ 与 CQ 通过 mmap() 映射到用户空间,避免了传统 io_submit / io_getevents 的系统调用开销

7.2 与传统接口的对比

特性 epoll io_uring (5.1+) io_uring (6.1+)
网络 socket 就绪通知 ✅ (IORING_OP_POLL_ADD)
网络 socket 读写 需额外 syscall ✅ 内置 RECV/SEND ✅ + SEND_ZC
普通文件 IO ❌ (总是"就绪") READ/WRITE
批量提交
批量完成收割
零系统调用 (SQPoll)
零拷贝发送 ✅ (6.0+)
链接操作 (chained)
注册缓冲区/文件描述符 ✅ (Buffer Ring)

7.3 内核实现路径

当用户态提交 IORING_OP_READ 时,内核路径如下:

  1. sys_io_uring_enter()io_uring_submit()
  2. 从 SQ 环消费 io_uring_sqe 条目
  3. 根据 opcode 分发至 io_read() / io_write() / io_accept() 等处理函数
  4. 若目标为文件系统,调用 vfs_read() → 文件系统层 → Page Cache / 块层
  5. 若目标为 socket,调用 sock_recvmsg() → TCP/UDP 协议栈 → 网卡驱动
  6. IO 完成后,将结果写入 CQ 环,若用户态在等待则发送事件通知

8. 全链路数据流图解

8.1 阻塞读全链路

复制代码
应用层: read(fd, buf, len)
    ↓ 系统调用 (用户态 → 内核态)
VFS层: sys_read() → vfs_read() → file->f_op->read_iter()
    ↓
文件系统层: 检查 Page Cache
    ├── 缓存命中 → 直接拷贝 → 返回
    └── 缓存未命中 → 发起块设备 IO
        ↓
块层: 构造 bio / request,加入调度队列
    ↓
驱动层: 设备驱动将请求下发至硬件 (DMA)
    ↓
硬件层: 磁盘/NVMe 执行物理读取
    ↓
中断: DMA 完成触发中断
    ↓
下半部: 软中断 / 任务队列处理完成
    ↓
唤醒: wake_up(&wait_queue) → 进程状态 TASK_RUNNING
    ↓
返回: 数据从 Page Cache 拷贝至用户 buf → sys_read 返回

8.2 非阻塞读全链路

复制代码
应用层: read(fd, buf, len)  [fd 已设 O_NONBLOCK]
    ↓
VFS层: vfs_read() → file->f_op->read_iter()
    ↓
驱动层: 检查数据就绪状态
    ├── 就绪 → 拷贝数据 → 返回字节数
    └── 未就绪 → 检查 filp->f_flags & O_NONBLOCK
        └── 置位 → 返回 -EAGAIN

8.3 io_uring 全链路

复制代码
应用层: io_uring_prep_read(sqe, fd, buf, len, offset)
        io_uring_submit(&ring)  [批量提交]
    ↓ 共享内存 (无系统调用,若 SQPoll)
内核层: io_uring 内核线程消费 SQ
    ↓
VFS/FS/块层: 执行实际 IO (同阻塞路径,但异步执行)
    ↓
完成: 结果写入 CQ Ring
    ↓
应用层: io_uring_wait_cqe(&ring, &cqe)  [收割完成事件]
        io_uring_cqe_seen(&ring, cqe)      [标记已消费]

9. 生产实践、选型建议与注意事项

9.1 选型决策矩阵

场景 推荐方案 内核版本要求 理由
简单串口/传感器读取 阻塞 read + 独立线程 全版本 代码简单,线程数少
高并发网络服务器 (C10K) epoll + 边缘触发 2.6+ 生态成熟,调试工具完善
数据库/存储引擎 (NVMe) io_uring + 注册缓冲区 5.10+ 消除 syscall 开销,批量 IO
低延迟金融交易 io_uring + IOPoll 5.10+ 忙轮询完成路径,微秒级延迟
嵌入式 MCU 级 Linux 阻塞 IO + select 3.x+ 资源受限,避免复杂异步框架
统一网络+文件异步 io_uring (liburing) 6.1+ LTS 单一事件循环,零拷贝网络

9.2 版本兼容性注意

  • Kernel < 5.1 : 无法使用 io_uring,epoll 是唯一高效选择;Linux-AIO (io_submit) 仅限 O_DIRECT 文件,实用性受限
  • Kernel 5.1 - 5.9: io_uring 可用但功能有限,不建议生产环境直接用于网络 IO
  • Kernel 5.10+: io_uring 功能基本完整,SQPoll/IOPoll 可用,可谨慎用于生产
  • Kernel 6.1+ LTS: io_uring 生产推荐基线,Buffer Ring / SEND_ZC / 多 shot 等高级特性稳定
  • Kernel 6.6+: io_uring 全面优化,新开发的高性能服务建议以此为目标版本

9.3 通用注意事项与陷阱

A. 阻塞/非阻塞基础层
  1. O_NONBLOCK 对普通文件无效 : 普通文件的 read()/write() 总是同步完成,或使进程进入 TASK_UNINTERRUPTIBLE(D-state)。O_NONBLOCK 仅对 socket、FIFO、管道、终端、部分字符设备有意义。

  2. read()/write() 的短读/短写 : 系统调用不保证一次性完成请求的字节数。网络 IO 中尤其常见,必须循环处理或使用 readn()/writen() 封装。

  3. EINTR 重试策略 : 慢速系统调用(如 read() 阻塞在管道上)被信号中断后返回 -1/EINTR。应用层应使用 TEMP_FAILURE_RETRY() 宏或手动重试,而非直接报错退出。

  4. O_DIRECT 对齐要求 : 缓冲区地址和长度必须按块大小(通常 512 字节或 4096 字节)对齐。未对齐会导致 EINVAL。使用 posix_memalign() 分配内存。

  5. close() 与并发 : 多线程环境中,一个线程 close(fd) 而另一个线程正在 epoll_wait()read() 该 fd,可能导致 EBADF 或竞争条件。建议先 epoll_ctl(EPOLL_CTL_DEL)close(),或使用 EPOLLONESHOT

B. IO 多路复用层
  1. epoll 不支持普通文件 : 对普通文件调用 epoll_ctl(EPOLL_CTL_ADD) 返回 EPERM。若需异步文件 IO,使用 io_uring 或线程池。

  2. ET 模式必须读到 EAGAIN : 边缘触发下,若应用只读一次就返回事件循环,剩余数据将永远不会再触发通知。accept() 同理,必须循环到 EAGAIN

  3. EPOLLHUPEPOLLIN 共存 : 对端关闭连接时,可能同时触发 EPOLLHUPEPOLLIN(剩余数据可读)。处理顺序应为:先读尽数据,再处理关闭。

  4. select 的 fd_set 大小限制 : 默认 FD_SETSIZE = 1024,超过此值的 fd 无法加入 fd_set。高并发场景必须使用 poll()epoll()

  5. epoll 的 EPOLLONESHOT 与多线程 : 多线程共享 epoll 实例时,EPOLLONESHOT 可防止多个线程同时处理同一事件。但处理完成后必须 EPOLL_CTL_MOD 重新启用,否则该 fd 永久静默。

C. io_uring 层
  1. SQE 的 user_data 必须唯一 : 它是关联请求与完成的唯一标识。若多个 SQE 使用相同 user_data,CQE 收割时无法区分。

  2. CQ 溢出风险 : 若应用消费 CQE 的速度慢于内核生产速度,CQ 环可能溢出。Kernel 5.5+ 引入了 CQ 溢出处理,但最佳实践是及时 io_uring_cqe_seen()

  3. SQPoll 的权限要求 : 创建 IORING_SETUP_SQPOLL 需要 CAP_SYS_NICE 或 root。内核线程持续占用一个 CPU 核心轮询,需评估 CPU 开销。

  4. io_uring 与 fork() 不兼容 : fork() 后子进程不应继续使用父进程的 io_uring 实例,因共享内存映射和内核状态会混乱。应在 fork() 后重新初始化。

  5. Buffer Ring 的生命周期 : Kernel 5.19+ 的 IORING_SETUP_COOP_TASKRUN 和 Buffer Ring 需配合 io_uring_register_buffers() 使用。缓冲区在注册期间必须保持有效,unregister 前不得释放。

D. 驱动层
  1. 中断上下文的限制 : wake_up() 可在中断上下文调用,但 copy_to_user() 绝对不可。中断 Top Half 应最小化,唤醒操作通常放在 tasklet / workqueue / 线程化中断中。

  2. 设备树匹配失败 = 无 IO 路径 : Kernel 3.7+ ARM 平台,若驱动未正确设置 of_match_table 或设备树节点 compatible 不匹配,probe() 不会被调用,设备节点不存在,上层任何 open() 都会返回 ENODEV

  3. poll_wait() 的调用时机 : 驱动 .poll() 方法中,无论当前是否就绪,必须 调用 poll_wait() 将当前 poll 表注册到等待队列。否则 epoll 无法感知后续事件。


10. 附录: 关键数据结构速查

10.1 用户态 API 速查

等待队列 (内核驱动)
c 复制代码
wait_queue_head_t wq_head;           // 等待队列头
wait_queue_entry_t wq_entry;         // 等待队列条目
init_waitqueue_head(&wq_head);       // 初始化
wait_event_interruptible(wq, cond); // 可中断等待
wake_up_interruptible(&wq_head);     // 唤醒
file_operations (驱动层)
c 复制代码
struct file_operations {
    ssize_t (*read)(struct file *, char __user *, size_t, loff_t *);
    ssize_t (*write)(struct file *, const char __user *, size_t, loff_t *);
    __poll_t (*poll)(struct file *, struct poll_table_struct *);
    int (*fasync)(int, struct file *, int);
};
io_uring SQE / CQE
c 复制代码
struct io_uring_sqe {
    __u8  opcode;       // IORING_OP_READ, IORING_OP_WRITE, ...
    __u8  flags;        // IOSQE_IO_LINK, IOSQE_IO_HARDLINK
    __u16 ioprio;
    __s32 fd;           // 目标文件描述符
    __u64 off;          // 偏移量
    __u64 addr;         // 用户缓冲区地址
    __u32 len;          // 长度
    __u64 user_data;    // 用户自定义标识
};

struct io_uring_cqe {
    __u64 user_data;    // 与 sqe 关联的标识
    __s32 res;          // 操作结果 (字节数或 -errno)
    __u32 flags;
};

10.2 常用操作码 (io_uring opcode)

操作码 说明 适用对象
IORING_OP_NOP 空操作,用于测试或占位 任意
IORING_OP_READV / IORING_OP_WRITEV 向量读/写 文件/socket
IORING_OP_READ / IORING_OP_WRITE 固定缓冲区读/写 文件/socket
IORING_OP_RECV / IORING_OP_SEND 网络收发 socket
IORING_OP_ACCEPT / IORING_OP_CONNECT 连接管理 socket
IORING_OP_OPENAT / IORING_OP_CLOSE 文件打开/关闭 文件系统
IORING_OP_FSYNC 强制同步落盘 文件
IORING_OP_POLL_ADD / IORING_OP_POLL_REMOVE 注册/移除 poll 任意 fd
IORING_OP_SEND_ZC 零拷贝发送 (6.0+) socket

10.3 errno 速查表

errno 触发场景
EAGAIN / EWOULDBLOCK 11 / 11 非阻塞 IO 未就绪
EINTR 4 系统调用被信号中断
ECONNRESET 104 TCP 对端发送 RST
EPIPE 32 向已关闭的管道/socket 写
EPERM 1 权限不足;epoll 添加普通文件
EINVAL 22 参数无效;O_DIRECT 未对齐
ENFILE 23 系统级 fd 数量上限
EMFILE 24 进程级 fd 数量上限 (ulimit -n)
EBADF 9 无效的 fd;fd 已 close
EIO 5 底层 IO 错误