搞 Linux 后端和中间件的绕不过高级IO这道坎。刚开始写代码的时候谁都是read/write两把梭连接数一上来、延迟毛刺一出、CPU 全耗在 sys 态才发现之前那套写法根本撑不住。所谓高级说白了就是把等数据和搬数据这两件事从应用层手里接过来交给内核用更聪明的方式去做。它解决的问题很具体单机怎么扛住几万并发连接、大文件传输怎么不把内存带宽跑满、高吞吐场景怎么把 CPU 从无意义的系统调用里解放出来。这篇内容适合三类人写过 socket 但对epoll的触发模式始终心里没底的同学做文件服务、消息队列、网关这些偏底层模块需要把数据搬运成本压到最低的同行还有面试前想把 select/poll/epoll、零拷贝、io_uring 这些串成一条线的人。我不打算按教科书罗列概念而是按实际写代码时会遇到什么来走一遍——从一次read在内核里到底发生了什么开始到多路复用怎么选、零拷贝怎么用、异步 IO 值不值得上、生产上出问题怎么排查。1. 先把 IO 这件事想明白从 fd 到数据落地的完整路径很多人对 IO 的理解停在调个函数读写数据但高级 IO 的所有优化本质都在回答两个问题数据什么时候准备好、数据由谁搬到用户空间。不把这两个阶段分清楚后面看epoll和零拷贝只会觉得名词乱飞。1.1 一次 read 在内核里到底走了多少步拿read(fd, buf, len)来说它不是一个孤立动作。用户态发起syscall陷入内核后内核先通过VFS虚拟文件系统这一层做统一分发VFS 再根据 fd 找到对应的struct file最终调用具体文件系统或驱动注册进来的file_operations里的.read方法。驱动开发者做字符设备时最常见的操作就是把自己的read/write实现填进file_operations结构体再注册到系统里让上层调用能落到自己的代码上。真正的数据流动还要看缓存。读普通文件时内核会先查页缓存page cache命中就直接从内核内存拷到用户缓冲区没命中就触发磁盘 IO把数据从块设备读进页缓存再由 DMA 参与搬移。这里就有第一次等——等磁盘或等对端网络包到达然后是第二次搬——把内核缓冲区里的数据复制到用户给的buf。网络 IO 类似数据先到内核的 socket 接收缓冲区再拷给应用。把这个过程拆开看就能理解为什么阻塞 IO 会卡住整个线程它在等这个阶段就把调用者挂起了直到数据齐了才进入搬。而所谓高级 IO 的各种模型无非是在这两个阶段的处理上做文章——有的改成轮询、有的让内核通知、有的干脆让内核把数据直接送到目的地。1.2 五种 IO 模型的本质区别等和搬谁来负责讨论阻塞、非阻塞、多路复用、信号驱动、异步这五种模型时最清晰的抓手就是画一张两阶段的表看每个阶段阻塞发生在哪里。IO 模型等待数据阶段数据拷贝阶段典型接口阻塞 IO应用阻塞应用阻塞read非阻塞 IO应用轮询应用阻塞readO_NONBLOCKIO 多路复用内核阻塞在select/epoll应用阻塞epoll_waitread信号驱动 IO内核发信号应用继续干别的应用阻塞O_ASYNCSIGIO异步 IO不阻塞不阻塞io_uring、POSIX AIO看懂这张表很多困惑就解开了。IO 多路复用并不是异步它只是把等哪个 fd 可读这件事从挨个阻塞变成了一次性地等真正的read拷贝还是同步的、还是阻塞的。这也是为什么epoll再快碰到大文件传输或者海量数据拷贝时依然会看到明显的 CPU 消耗在 sys 态。信号驱动 IO 听起来很美但SIGIO信号是标准 Unix 信号多个 fd 就绪时会合并应用侧根本分不清是哪个 fd 触发实际项目里用得极少。真正意义上的异步只有一种——数据从内核到用户的拷贝也由内核完成完成后通知应用这才是io_uring想做的事。1.3 同步/异步、阻塞/非阻塞别混为一谈面试里最常见的一个坑就是把阻塞和同步划等号。严谨地说这两个维度描述的不是一件事。阻塞/非阻塞说的是调用发出去之后调用者会不会被挂起关注的是调用者线程的状态同步/异步说的是数据拷贝这一步是谁做的关注的是结果怎么产生。按这个标准阻塞read是同步阻塞epoll_wait加read是同步非阻塞因为read拷贝时可能阻塞只有io_uring这类把拷贝也托管出去的才叫真正的异步。我见过不少人以为把 fd 设成非阻塞、挂到epoll上系统就异步了然后在read里传一个特别大的 buffer 想一次读完结果小包场景下大量调用返回EAGAIN白白浪费 CPU。这就是把非阻塞当异步用的典型翻车。分清楚这两个维度后面选epoll的触发模式、判断该不该上io_uring思路会清楚很多。2. IO 多路复用选型select、poll、epoll 到底怎么挑多路复用是绝大多数 Linux 高并发服务的基石但会用epoll和用明白epoll之间差着一堆细节。三个接口不是简单的版本替代关系各自的定位和坑值得展开讲。2.1 三个接口的底层结构差异决定了性能上限select的问题很硬它用fd_set位图表示关注的 fd而FD_SETSIZE默认只有 1024超出就溢出每次调用都要把整个位图从用户态拷进内核返回时还要遍历一遍找就绪的 fd复杂度是 O(n)。fd 一多光是拷贝和遍历就把 CPU 吃掉了。poll换成了pollfd数组突破了 1024 的限制但每次全量拷贝 全量遍历这两点没变本质还是 O(n)。epoll换了个思路。它把注册关注和等待就绪拆成了两个动作epoll_ctl负责把 fd 挂到内核里的一个红黑树上只注册一次epoll_wait时内核只需检查一个就绪链表谁有事件谁进链表返回的就是真正就绪的 fd复杂度接近 O(1)。fd 数量越多这个设计优势越明显。所以选型上很直接连接数在几十上百、且不想引额外 APIpoll够用一旦目标是上千乃至上万并发连接epoll是默认答案。select现在基本只出现在跨平台兼容或者老旧代码里。2.2 epoll 的红黑树加就绪链表快在哪里很多人背过红黑树 就绪链表这个说法但没想清楚它为什么快。关键点在于事件的注册和等待被解耦了。你调epoll_ctl(EPOLL_CTL_ADD)时内核把 fd 对应的epitem插进红黑树同时给这个 fd 在底层socket 的等待队列等挂上回调。之后数据到达内核直接通过回调把对应的epitem扔进就绪链表不需要谁来主动扫描。epoll_wait要做的事只是看链表空不空非空就取出来返回。这里有个容易忽略的收益fd 越多epoll相对select/poll的优势越大但单次epoll_wait的返回量取决于实际就绪数。所以epoll并不是上不封顶万金油如果你的场景是几乎所有连接同时活跃比如全连接广播就绪链表会变得很长单次返回的数组得开得足够大否则会分多次取反而增加系统调用次数。我在压测里就遇到过maxevents设太小导致的吞吐下降把它调到和预期单次活跃连接数相当后曲线才起来。2.3 LT、ET、EPOLLONESHOT 三种模式选错就是灾难epoll的触发模式是踩坑重灾区。**水平触发LT默认**的行为是只要 fd 上还有数据没读完下次epoll_wait还会通知你写起来最省心漏读也不会丢事件。**边缘触发ET**只在状态从无到有变化的那一刻通知一次如果这次没把数据读干净以后就不会再收到通知缓冲区里的数据相当于被吞了。所以 ET the 模式的铁律是两条fd 必须设成非阻塞读的时候必须循环读到返回EAGAIN为止。少一条都可能出问题。我早期写 ET 的场景图省事只read一次就完事本地测试数据小没暴露上线后大包一来就丢消息排查了半天才反应过来是没读干净。ET 的好处是减少了epoll_wait的返回次数在高并发下能少一些系统调用但它把读干净的责任压给了应用代码复杂度上去了。还有一种EPOLLONESHOT用于多线程处理同一个 fd 的场景。比如你把连接分给线程池一个 fd 的事件可能被多个线程抢到。加了这个标志事件触发一次后该 fd 就被自动禁用处理完需要线程重新epoll_ctl加回来这样就保证了同一时刻只有一个线程在处理这个连接避免并发读写同一个 fd 导致的数据错乱。2.4 一份能直接跑的 epoll echo 服务理论说再多不如一段能跑的代码。下面这个 TCP echo server 用 LT 模式结构清晰可以直接拿去做二开。#define _GNU_SOURCE #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 #include arpa/inet.h #define MAX_EVENTS 1024 #define BUF_SIZE 4096 #define PORT 8888 #define BACKLOG 512 static 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(void) { int listen_fd socket(AF_INET, SOCK_STREAM, 0); if (listen_fd 0) { perror(socket); return 1; } int opt 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); set_nonblocking(listen_fd); struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_addr.s_addr htonl(INADDR_ANY); addr.sin_port htons(PORT); if (bind(listen_fd, (struct sockaddr *)addr, sizeof(addr)) 0) { perror(bind); return 1; } if (listen(listen_fd, BACKLOG) 0) { perror(listen); return 1; } int epfd epoll_create1(0); 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(echo server listening on %d\n, PORT); for (;;) { int n epoll_wait(epfd, events, MAX_EVENTS, -1); if (n 0) { if (errno EINTR) continue; perror(epoll_wait); break; } for (int i 0; i n; i) { int fd events[i].data.fd; if (fd listen_fd) { /* 一次 accept 可能拿到多个连接循环到 EAGAIN */ for (;;) { int conn accept4(listen_fd, NULL, NULL, SOCK_NONBLOCK); if (conn 0) { if (errno EAGAIN || errno EWOULDBLOCK) break; perror(accept); break; } ev.events EPOLLIN | EPOLLRDHUP; ev.data.fd conn; epoll_ctl(epfd, EPOLL_CTL_ADD, conn, ev); } } else { char buf[BUF_SIZE]; ssize_t r read(fd, buf, sizeof(buf)); if (r 0) { write(fd, buf, r); } else if (r 0 || (r 0 errno ! EAGAIN)) { epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); close(fd); } } } } close(listen_fd); close(epfd); return 0; }这段代码有几个刻意的设计值得点出来。listen_fd设成非阻塞accept用accept4带SOCK_NONBLOCK直接把新连接设成非阻塞省一次fcntl。accept 处用循环读到EAGAIN这是为了应对多个连接同时到达的情况只 accept 一次会残留连接在队列里。EPOLLRDHUP用来感知对端关闭比单纯靠read返回 0 更及时。提示如果切到 ET 模式read那段必须改成 while 循环一直读到r 0 errno EAGAIN才跳出并且连接 fd 一定要是非阻塞否则循环会永久卡死。这是 ET 最经典的翻车点。高并发下还有个参数容易忽略listen的 backlog。它决定内核已完成三次握手但还没被 accept 的连接队列长度。太小会导致新连接被丢弃或客户端收到SYN重传我一般在压测时把它设成和并发量同量级比如 512 到 1024配合/proc/sys/net/core/somaxconn一起调。3. 零拷贝与内存映射把数据搬运次数压到最低多路复用解决的是怎么等零拷贝解决的是怎么搬。在文件服务器、消息中间件、代理网关这些场景数据搬运开销往往比等待开销更致命因为拷贝是实打实吃 CPU 和内存带宽的。3.1 传统 read 加 write 到底拷了几次先算清楚账。一个典型的文件下载服务用readwrite数据从磁盘到网卡要经历DMA 把数据从磁盘拷到内核页缓存、CPU 把数据从页缓存拷到用户缓冲区、CPU 把数据从用户缓冲区拷到 socket 发送缓冲区、DMA 把数据从发送缓冲区拷到网卡。总共4 次拷贝其中 2 次是 CPU 拷贝。同时因为read和write各是一次系统调用、各自有用户态和内核态的往返切换次数是4 次上下文切换。这套流程对普通小文件无所谓但在几十 G 的大文件传输、或者每秒几十万个小包转发的代理里那 2 次 CPU 拷贝和 4 次切换就是瓶颈。零拷贝的思路很朴素能不拷就不拷能让 DMA 干的活就不要 CPU 出手。3.2 mmap 加 write省一次 CPU 拷贝mmap把内核的页缓存直接映射到用户地址空间应用拿到一个指针读写它就像读写普通内存。用来做文件转发的mmap write流程变成DMA 拷磁盘到页缓存、CPU 从页缓存拷到 socket 缓冲区、DMA 拷到网卡。CPU 拷贝从 2 次降到 1 次因为省掉了页缓存到用户缓冲区那一步。mmap的用法要注意几个点。映射时PROT_READ/PROT_WRITE决定权限MAP_SHARED表示修改会写回文件、MAP_PRIVATE是写时复制不影响原文件。映射大文件时别整个mmap进来按需分段映射更稳避免一次性占用过多虚拟地址空间。还有个隐藏坑mmap触发的缺页中断在写回时机不可控大量映射文件后可能堆积脏页突然集中回写造成 IO 抖动这时候得配合msync主动刷或者用madvise提示内核访问模式。3.3 sendfile 与 DMA gather把 CPU 拷贝也干掉sendfile是 Linux 专门为文件到 socket这类转发场景造的。它直接在内核里把页缓存的数据送进 socket 缓冲区CPU 拷贝从 2 次降到 1 次上下文切换从 4 次降到 2 次。用法上只要sendfile(out_fd, in_fd, offset, count)两个 fd 一个是对端 socket、一个是文件。再进一步如果网卡支持SG-DMA分散聚合 DMA还能配合 sendfile DMA gather 把最后那次 CPU 拷贝也省掉DMA 直接把页缓存里的数据描述符而不是数据本身聚合到网卡由网卡完成发送。这时候真正做到了全程 CPU 零拷贝只有 DMA 在搬。代价是这条路径要求数据不被应用修改且对文件系统、网卡的特性有依赖不是所有环境都能命中。注意sendfile有一个硬限制——in_fd必须支持mmap语义普通文件一般没问题而且旧内核对目标 fd 是 socket 还是文件有要求。做代理转发时如果需要修改数据内容sendfile就用不了得退回mmap或者用户态缓存方案。3.4 splice 与管道把任意两个 fd 接起来sendfile的局限是一端必须是文件、一端必须是 socket。splice更通用它通过一个管道做中转可以把任意两个 fd 连起来比如 socket 到 socket、文件到管道、管道到设备。数据在管道里以页的形式传递不走用户态也就省掉了 CPU 拷贝。典型用法是先建一个pipe然后splice(from_fd, NULL, pipefd[1], NULL, len, SPLICE_F_MOVE)再splice(pipefd[0], NULL, to_fd, NULL, len, SPLICE_F_MOVE)。splice适合做纯转发的网关、隧道类程序。但要留意它对 fd 类型的限制两个 fd 里至少有一个得是管道不能直接把两个 socket 用splice对接必须借道管道。管道本身有容量上限默认 64KB可调大块数据需要循环搬运。这几条限制决定了它更适合特定形态的转发器而不是通用替代品。3.5 各方案对比与实际选型建议到底用哪个看场景和数据是否需要被应用处理。下面这张表是我实际选型时经常对照的。方案CPU 拷贝次数上下文切换适用场景主要限制read write24需要处理数据内容开销最大mmap write14小文件多、随机读写缺页和脏页管理复杂sendfile12文件转 socket 静态分发不能改数据、fd 类型受限sendfile DMA gather02大文件高速转发依赖网卡与内核特性splice02网关、管道型转发必须借道管道我的经验是纯静态文件下载用sendfile收益最直接几行代码改完大文件传输的 CPU 占用肉眼可见下降需要在转发时做内容替换的比如注入 header、改编码mmap write更灵活做通用四层代理又追求极致吞吐splice值得试但要接受它借道管道的写法。别为了零拷贝三个字硬套数据只要经过应用层处理零拷贝就很难真正成立。4. 异步 IO从 POSIX AIO 的坑到 io_uring真正的异步 IO 是把数据拷贝也托管给内核。这一块 Linux 走过弯路POSIX AIO 的落地效果一直不理想直到io_uring出现才算是给了一条靠谱的路。4.1 POSIX AIO 为什么没流行起来aio_read/aio_write这套接口名声在外但实际用的人不多。原因是它在 Linux 上的实现分两套glibc 的用户态实现靠线程池模拟本质还是阻塞 IO 套了个壳性能上不去内核态实现限制又多只支持O_DIRECT打开的裸设备或文件普通缓冲文件异步提交后可能同步阻塞。也就是说你写了异步的代码跑起来该阻塞还是阻塞这种名不副实的体验劝退了大部分人。所以这些年在 Linux 上做高并发主流是epoll加线程池的伪异步或者上io_uring。POSIX AIO 基本只在跨平台代码或特定存储设备程序里还能见到新的项目没必要再往里投入。4.2 io_uring 的环形队列设计凭什么是它io_uring的核心是两个共享内存环形队列提交队列 SQ 和完成队列 CQ。应用把 IO 请求写进 SQ内核从 SQ 取走处理处理完把结果写进 CQ应用从 CQ 读结果。因为队列是在应用和内核之间共享的内存提交和收割都不需要系统调用这大幅减少了上下文切换。只有在队列满或者应用需要主动等待时才调用io_uring_enter。它还支持注册文件描述符和注册缓冲区把这些元数据一次性交给内核后续操作直接引用索引省掉每次的参数拷贝。这些设计让io_uring在高 IOPS 场景比如数据库、存储引擎、高性能代理里表现相当突出。它从内核 5.1 引入后续版本不断补齐功能比如 5.6 以后支持IORING_OP_*的更多操作类型5.11 加了io_uring的SQPOLL内核线程轮询模式。4.3 用 liburing 跑一个读写示例手撸io_uring的系统调用很麻烦liburing封装好了常用流程是上手首选。下面是一个读文件的简化示例。#include liburing.h #include fcntl.h #include stdio.h #include string.h #include unistd.h int main(void) { struct io_uring ring; /* 队列深度 8纯做示例 */ if (io_uring_queue_init(8, ring, 0) 0) { perror(queue_init); return 1; } char buf[4096] {0}; int fd open(test.txt, O_RDONLY); if (fd 0) { perror(open); return 1; } struct io_uring_sqe *sqe io_uring_get_sqe(ring); io_uring_prep_read(sqe, fd, buf, sizeof(buf), 0); /* 提交一个请求 */ io_uring_submit(ring); struct io_uring_cqe *cqe; /* 阻塞等待完成事件 */ io_uring_wait_cqe(ring, cqe); if (cqe-res 0) { fprintf(stderr, read failed: %d\n, cqe-res); } else { printf(read %d bytes: %s\n, cqe-res, buf); } io_uring_cqe_seen(ring, cqe); close(fd); io_uring_queue_exit(ring); return 0; }队列深度io_uring_queue_init的第一个参数很关键它决定 SQ/CQ 的容量。设太小会频繁触发io_uring_enter设太大又浪费内存我的经验是按单线程同时在途请求数的量级来定比如 NVMe 存储压测时常常设到 128 到 512。另外要注意io_uring的队列内存是页对齐的liburing已经处理好了自己手写系统调用时这点必须小心。注意io_uring对内核版本有要求且部分功能受安全策略影响可能被禁用。上线前先在目标环境上跑通版本检测别在开发机上调好、到生产才发现跑不起来。5. 内存映射、文件锁与直接 IO那些容易被忽略的周边能力除了多路复用和零拷贝高级 IO 里还有几块能力经常被忽视但在特定场景下非常关键mmap做进程间共享、文件锁做互斥、O_DIRECT绕过页缓存。5.1 mmap 共享内存与写时复制的区别mmap不只是文件 IO 的工具它还是最快的进程间共享内存方式。用MAP_SHARED映射同一个文件多个进程读写这块内存会直接反映到文件和其他进程省掉了read/write的拷贝。数据库的共享缓存、日志的追加写经常会用到这个特性。而MAP_PRIVATE是写时复制改的是自己的副本原文件不动适合加载只读配置或者做 fork 后的隔离。用共享映射做 IPC 有两个容易踩的点。一是同步问题mmap本身不提供任何互斥机制多进程同时写会互相覆盖得自己配信号量或者文件锁。二是落盘时机MAP_SHARED的修改最终会由内核刷回文件但什么时候刷不确定程序崩溃前如果没msync可能会丢一部分。做关键数据共享时我一般会定期msync(MS_SYNC)主动同步牺牲一点性能换可靠性。5.2 flock 和 fcntl 文件锁控住并发写多进程写同一个文件光靠mmap不行得加锁。Linux 有两种主要文件锁flock和fcntl。flock提供的是建议锁语义简单分共享锁和排他锁锁作用在整个文件上fcntl提供的是POSIX 记录锁可以锁文件的某个字节区间粒度更细但语义更复杂。对比项flockfcntl锁粒度整个文件可到字节区间锁类型共享/排他读锁/写锁跨 fork子进程继承状态复杂不自动继承典型场景简单的单文件互斥数据库页级锁需要提醒的是两者都是建议锁只能约束配合使用锁的程序挡不住不守规矩的进程直接写。做真正需要强隔离的日志文件时我习惯用flock加排他锁配O_APPEND简单可靠。fcntl用在需要精细控制的场景比如多个进程各写文件不同区段。5.3 O_DIRECT 绕过页缓存快还是坑O_DIRECT让读写直接对接磁盘跳过页缓存省掉一次数据从页缓存到用户态的拷贝同时避免缓存污染——大文件顺序扫描时它不会把有用的缓存挤出去。数据库这类自己管理缓存的应用经常开O_DIRECT避免双重缓存。但它坑也不少。首先对齐要求严格buffer 地址、读写长度、文件偏移通常都要按块大小512 或 4096 字节对齐不满足就返回EINVAL。其次它绕过了预读和合并小随机读性能可能反而更差因为没有缓存兜底每次都要实打实落到盘上。我的建议是只有你的应用已经有成熟的缓存层、并且做的是大块顺序 IO才值得开O_DIRECT否则老老实实吃页缓存的红利。6. 生产环境调优与问题排查实录理论讲完最后落到真刀真枪的排障。高级 IO 出的问题往往表现发散——可能是延迟高、可能是丢数据、可能是 CPU 飙高定位思路得清晰。6.1 先把观测工具用起来排查 IO 问题第一件事是看清楚现状别急着改代码。我常用的几把工具各有分工strace -f -T -tt -p pid跟系统调用看read/write/epoll_wait的耗时分布卡在哪一目了然-T显示每次调用耗时最实用。iostat -x 1看磁盘的%util、await、aqu-sz判断是不是 IO 打满在排队。pidstat -d 1按进程看读写速率和 IO 等待定位是哪个进程在狂读写。ss -s、ss -lnt看 socket 数量、监听队列和Send-Q/Recv-Q判断有没有连接堆积。perf top、perf record看内核态 CPU 热点是不是耗在拷贝或者网络栈上。提示strace本身有开销会拖慢进程生产上尽量短时间采样或者用perf trace这类更轻的方式替代别长时间挂着。6.2 常见问题速查表很多现象是重复出现的整理成表可以快速对照。现象可能原因排查方向处理办法ET 模式丢消息没读到 EAGAIN检查 read 是否循环改成循环读直到 EAGAIN新连接被拒backlog 太小ss -lnt看 Send-Q调大 backlog 和 somaxconnCPU 全在 sys 态拷贝或系统调用过多perf top看热点上 sendfile/splice 减少拷贝大文件传输慢readwrite 两次拷贝iostat看带宽换 sendfile 或 mmapepoll_wait 频繁返回maxevents 太小统计单次返回量调大 maxevents数据错乱多线程处理同一 fd检查是否用了 ONESHOT加 EPOLLONESHOTmmap 后 IO 抖动脏页集中回写看dirty_ratio主动 msync 或调内核参数6.3 我实际踩过的几个坑第一个坑是 ET 模式丢数据前面提过本质是心存侥幸。后来我养成习惯写 ET 就先写while(1)循环写完再想别的这个顺序不能反。第二个坑是epoll_wait的maxevents设成默认的 64低并发时正常压测一上来吞吐就上不去因为每次只能取 64 个就绪事件得反复调用。改成 1024 后曲线立刻起来了。这个参数和实际并发规模挂钩不是越大越好但绝对不能拍脑袋填个位数。第三个坑是sendfile传大文件时的 offset 处理。sendfile的 offset 是传入传出参数内核会更新它。我一开始没管它在循环里每次都传同一个变量结果第二次发送位置就错了。正确做法是每次循环用更新后的 offset或者直接传NULL让内核自己维护文件指针。第四个坑是多线程 EPOLLONESHOT忘 rearm。事件处理完如果没重新epoll_ctl把 fd 加回去这个连接就再也收不到事件了表现得像连接假死。这个和 ET 丢数据一样是机制决定的必然结果加回去就正常。第五个是io_uring的环境依赖。有次在容器里跑得好好的程序换了个内核版本稍旧的环境直接起不来报队列初始化失败。所以涉及新特性的方案部署前务必在目标环境做版本探测和降级预案别把宝全押在一个特性上。这块内容其实还能往深挖比如io_uring的SQPOLL模式和固定文件表怎么配合、splice在代理里怎么用环形管道做零拷贝转发。我自己在这几个方向上也是边用边学真要上手最好的办法还是拿一个小 demo 把epoll和sendfile先跑通有了手感后面的东西自然就串起来了。