Linux 阻塞与非阻塞 IO 机制简析
覆盖内核版本: 3.0 - 6.6+
目录
- [整体概览: Linux IO 模型的五重境界](#整体概览: Linux IO 模型的五重境界)
- [应用层视角: 开发者如何感知阻塞](#应用层视角: 开发者如何感知阻塞)
- [API 参考与使用示例](#API 参考与使用示例)
- [内核层视角: 从 VFS 到调度器的阻塞真相](#内核层视角: 从 VFS 到调度器的阻塞真相)
- [驱动层视角: 设备树时代的驱动与等待队列](#驱动层视角: 设备树时代的驱动与等待队列)
- [版本演进: 从 3.x 到 6.x 的关键节点](#版本演进: 从 3.x 到 6.x 的关键节点)
- [io_uring: 统一异步接口的架构革命](#io_uring: 统一异步接口的架构革命)
- 全链路数据流图解
- 生产实践、选型建议与注意事项
- [附录: 关键数据结构速查](#附录: 关键数据结构速查)
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) 时:
- 若内核缓冲区无数据,进程调用
schedule()主动放弃 CPU - 进程状态从
TASK_RUNNING切换为TASK_INTERRUPTIBLE - 进程被挂入该文件描述符对应的 等待队列 (wait_queue)
- 当硬件中断触发数据到达(或 DMA 完成),中断处理程序或软中断将进程从等待队列唤醒
- 进程恢复运行,内核将数据从内核空间拷贝至用户空间
buf 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()立即返回-1,errno = EAGAIN或EWOULDBLOCK - 若数据已就绪(如在 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) 设置信号驱动模式:
- 数据准备就绪时,内核向进程发送
SIGIO或SIGURG信号 - 进程在信号处理函数中执行
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 字节),否则返回EINVALO_DIRECT与O_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_APPEND、O_ASYNC、O_DIRECT、O_NOATIME、O_NONBLOCK,无法修改访问模式 (O_RDONLY 等)- 设置
O_NONBLOCK时,必须使用F_GETFL先读取原标志,再按位或,否则覆盖其他标志 F_SETOWN的arg为正数表示进程 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)
非阻塞模式行为:
- 数据未就绪 / 缓冲区满时,返回
-1,errno = EAGAIN或EWOULDBLOCK - 部分就绪时,返回实际可处理的字节数(可能小于
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_SETnfds参数决定了内核扫描范围,设置过大(如 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) POLLHUP和POLLERR可以在无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时,仅在缓冲区从满变非满时触发一次,避免频繁可写事件
- LT (默认) : 只要 fd 处于就绪状态,每次
- EPOLLONESHOT: 适用于多线程 accept 场景,防止多个线程同时处理同一事件
- EPOLLEXCLUSIVE: 多进程/多线程监听同一 listen fd 时,避免所有进程同时被唤醒
epoll_wait()返回的events数组中的data字段与注册时一致,通常用data.fd或data.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→ 处理cqe→cqe_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 满),需处理并先submitio_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 未就绪而需要睡眠时:
- 驱动或内核子系统定义等待队列头
wait_queue_head_t wq - 进程调用
wait_event_interruptible(wq, condition)或等价逻辑 - 当前进程被加入等待队列,状态设为
TASK_INTERRUPTIBLE - 调用
schedule()触发上下文切换,CPU 转交其他进程 - 中断处理程序或异步事件满足 condition 后,调用
wake_up(&wq) - 被唤醒进程重新进入
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 中断与下半部: 唤醒的触发源
驱动层通过中断机制将硬件事件传递至内核:
- 硬件中断 (Top Half): 响应硬件信号,执行最小必要操作(如读取状态寄存器、清除中断标志)
- 软中断/任务队列 (Bottom Half): 中断上下文外执行耗时操作(如数据处理、唤醒等待队列)
- 进程唤醒 : 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/deadline向mq-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 版本,引入 SQPoll 和 IOPoll 模式:
SQPoll: 内核线程轮询 Submission Queue,用户态可实现零 syscallIOPoll: 对完成路径进行忙轮询,适用于延迟敏感型存储 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 时,内核路径如下:
sys_io_uring_enter()→io_uring_submit()- 从 SQ 环消费
io_uring_sqe条目 - 根据
opcode分发至io_read()/io_write()/io_accept()等处理函数 - 若目标为文件系统,调用
vfs_read()→ 文件系统层 → Page Cache / 块层 - 若目标为 socket,调用
sock_recvmsg()→ TCP/UDP 协议栈 → 网卡驱动 - 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. 阻塞/非阻塞基础层
-
O_NONBLOCK对普通文件无效 : 普通文件的read()/write()总是同步完成,或使进程进入TASK_UNINTERRUPTIBLE(D-state)。O_NONBLOCK仅对 socket、FIFO、管道、终端、部分字符设备有意义。 -
read()/write()的短读/短写 : 系统调用不保证一次性完成请求的字节数。网络 IO 中尤其常见,必须循环处理或使用readn()/writen()封装。 -
EINTR重试策略 : 慢速系统调用(如read()阻塞在管道上)被信号中断后返回-1/EINTR。应用层应使用TEMP_FAILURE_RETRY()宏或手动重试,而非直接报错退出。 -
O_DIRECT对齐要求 : 缓冲区地址和长度必须按块大小(通常 512 字节或 4096 字节)对齐。未对齐会导致EINVAL。使用posix_memalign()分配内存。 -
close()与并发 : 多线程环境中,一个线程close(fd)而另一个线程正在epoll_wait()或read()该 fd,可能导致EBADF或竞争条件。建议先epoll_ctl(EPOLL_CTL_DEL)再close(),或使用EPOLLONESHOT。
B. IO 多路复用层
-
epoll 不支持普通文件 : 对普通文件调用
epoll_ctl(EPOLL_CTL_ADD)返回EPERM。若需异步文件 IO,使用 io_uring 或线程池。 -
ET 模式必须读到
EAGAIN: 边缘触发下,若应用只读一次就返回事件循环,剩余数据将永远不会再触发通知。accept()同理,必须循环到EAGAIN。 -
EPOLLHUP与EPOLLIN共存 : 对端关闭连接时,可能同时触发EPOLLHUP和EPOLLIN(剩余数据可读)。处理顺序应为:先读尽数据,再处理关闭。 -
select 的 fd_set 大小限制 : 默认
FD_SETSIZE = 1024,超过此值的 fd 无法加入fd_set。高并发场景必须使用poll()或epoll()。 -
epoll 的
EPOLLONESHOT与多线程 : 多线程共享 epoll 实例时,EPOLLONESHOT可防止多个线程同时处理同一事件。但处理完成后必须EPOLL_CTL_MOD重新启用,否则该 fd 永久静默。
C. io_uring 层
-
SQE 的
user_data必须唯一 : 它是关联请求与完成的唯一标识。若多个 SQE 使用相同user_data,CQE 收割时无法区分。 -
CQ 溢出风险 : 若应用消费 CQE 的速度慢于内核生产速度,CQ 环可能溢出。Kernel 5.5+ 引入了 CQ 溢出处理,但最佳实践是及时
io_uring_cqe_seen()。 -
SQPoll 的权限要求 : 创建
IORING_SETUP_SQPOLL需要CAP_SYS_NICE或 root。内核线程持续占用一个 CPU 核心轮询,需评估 CPU 开销。 -
io_uring 与 fork() 不兼容 :
fork()后子进程不应继续使用父进程的 io_uring 实例,因共享内存映射和内核状态会混乱。应在fork()后重新初始化。 -
Buffer Ring 的生命周期 : Kernel 5.19+ 的
IORING_SETUP_COOP_TASKRUN和 Buffer Ring 需配合io_uring_register_buffers()使用。缓冲区在注册期间必须保持有效,unregister 前不得释放。
D. 驱动层
-
中断上下文的限制 :
wake_up()可在中断上下文调用,但copy_to_user()绝对不可。中断 Top Half 应最小化,唤醒操作通常放在 tasklet / workqueue / 线程化中断中。 -
设备树匹配失败 = 无 IO 路径 : Kernel 3.7+ ARM 平台,若驱动未正确设置
of_match_table或设备树节点compatible不匹配,probe()不会被调用,设备节点不存在,上层任何open()都会返回ENODEV。 -
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 错误 |