Linux高级IO实战:从阻塞IO到epoll与io_uring的高并发方案
先声明一句这篇东西不是给刚看完《UNIX环境高级编程》前三章的朋友写的而是给那些已经被线上问题逼到墙角、想搞清楚“为什么我的服务扛不住连接”、以及“select/poll/epoll到底该选谁”的人。Linux上的IO模型这个话题教科书里讲得已经很完整了但真正落地到生产环境坑几乎都在细节里。我自己就是从“用while循环阻塞read”一路走到epoll和io_uring的中间踩过的坑、调过的参、看过的内核代码都沉淀在这篇里了。这篇不会教你怎么背面试题但会让你在看完之后面对一个高并发网络服务知道该从哪里下手以及为什么该这么做。1. 高级IO到底在解决什么问题1.1 从一次线上事故说起几年前我维护过一个推送网关业务高峰时维持着大约两万个长连接。当时实现得很“天真”一个连接一个线程线程里就是阻塞read客户端不来数据就挂在那儿等。那段时间CPU不忙但线程数涨得吓人内存也跟着飙。更离谱的是一旦某个客户端因为网络问题迟迟不发数据也不断开这台服务器上就有成百上千个线程在空转。后来加了个超时机制还是治标不治本——线程的栈空间预分配了8MB两千个线程就是16GB虚拟内存光内存就压垮了容器。后来我把这套改成了epoll驱动的单线程Reactor模型线程数恒定为几个连接数轻松翻了几倍CPU占用反而降了。这次改造给我的冲击很大IO问题不是靠堆线程能解决的而是要搞清楚你的程序到底在等什么。在Linux里几乎所有IO操作都可以归结为“等数据”和“搬数据”两个阶段高级IO的全部思路就是让“等”的效率更高“搬”的成本更低。1.2 阻塞、非阻塞与IO模型的分类教科书上会把IO模型分成五类阻塞IO、非阻塞IO、IO多路复用、信号驱动IO、异步IO。我一开始也觉得这是应试概念直到真正做过性能调优才发现这个分类其实就是“等数据”这个动作的几种不同姿势。阻塞IO就是你的线程睡死在read调用里数据不来就不醒。好处是代码简单坏处是线程资源被白白占用。非阻塞IO就是read调用立刻返回没有数据就返回EAGAIN但你的程序得反复去问内核“数据来了没”这就是轮询CPU浪费惊人。IO多路复用是你把一堆fd交给内核让它一次性告诉你哪些fd有动静了你再去读等得高效、问得精准。信号驱动IO是老前辈用SIGIO信号通知进程数据就绪实际用的人少因为信号处理上下文干活不方便。真正的异步IO是你告诉内核“数据准备好了直接拷到我的缓冲区里做完再叫我”比如io_uring整个过程中你的线程不用参与搬运。理解这五个分类有个窍门阻塞与非阻塞说的是调用会不会挂起多路复用和异步说的是等待和搬运由谁来做、做到什么程度。多路复用只是帮你“感知就绪”读还是要你自己来异步IO则是连“读”都由内核代劳了。这差别在高性能服务里是决定性的。1.3 高级IO适合谁说实话普通的增删改查业务根本用不上高级IO。你写个接口调MySQL瓶颈在SQL和磁盘不在IO模型。高级IO的主战场就两类一类是长连接密集型的网关比如IM、推送、消息中间件特征是连接量大但数据量不一定大另一类是吞吐极致的文件搬运比如日志采集、视频分发、数据库刷盘特征是数据量大但逻辑简单。如果你是做客户端开发或业务后端了解这些概念也很有价值至少排查线上问题的时候你能从strace里看出端倪知道“为什么我的服务线程全卡在read上却不干活”。但如果你想做中间件、网关、游戏服务器、量化系统这类高并发低延迟的东西高级IO就是必修课是那种不学就写不出东西的基础能力。2. 绕不开的两个基础文件描述符与非阻塞2.1 文件描述符一切皆文件的落地形态Linux里socket、管道、磁盘文件、设备驱动全部抽象成文件描述符。fd不过是一个int是进程访问内核对象的句柄。理解fd的关键不在于它的数字大小而在于背后挂着的那个内核对象——struct file。多路复用本质上就是对一批struct file的注册监听。有一个细节容易被忽略fd是进程级的资源不是线程级的。这意味着Reactor模型里一个线程epoll_wait监听的所有fd天然就能被同进程的其他线程处理只要你自己做好同步。还有fd的数量不是越多越好每个fd都对应内核里的对象用完不关就是泄漏。我见过不少系统一开始是几千个fd随手开关没问题等连接量上来之后fd耗尽导致accept失败报“Too many open files”。这个坑不是高级IO本身的问题但是做高并发服务一定会撞上所以提前把ulimit -n调大、在代码里写清楚fd的生命周期是基本功。2.2 O_NONBLOCK把read/write变成“问一次答一次”阻塞IO最大的问题不是慢而是不可控。你不知道一次read会等多久可能是1毫秒也可能永远不返回。改成非阻塞之后read的逻辑变成“有多少给我多少没有就返回EAGAIN”这个特性才是多路复用能成立的前提。用fcntl设置非阻塞很简单这句话我在生产代码里写过无数次int flags fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, flags | O_NONBLOCK);这里有个我踩过的坑必须先用F_GETFL拿到原始标志位再在原来的基础上追加O_NONBLOCK而不是直接覆盖。直接置为O_NONBLOCK会丢掉文件原先的O_APPEND、O_RDWR之类的标志行为会变得很诡异。还有accept之后得到的连接fd默认是继承监听fd的阻塞属性的所以你必须在accept之后立刻对新fd也设置非阻塞否则后续逻辑还是会卡在一个连接上。2.3 中断与错误码非阻塞IO的一个隐形门槛非阻塞IO用好之后你以为read返回-1就是没数据太天真了。还得看errno。如果errno是EAGAIN或EWOULDBLOCK那才是“暂时没有数据”在阻塞模式下就是“该等了”如果errno是EINTR表示read被信号打断了这时候应该重试一次如果是ECONNRESET说明对端已经强制关了连接。这个错误码判定的逻辑很多人第一版写不对。我见过不少服务read返回-1就当成连接断开结果把EAGAIN当成异常单线程疯狂尝试读一个空socketCPU直接拉满。所以非阻塞IO的代码第一步永远是识别errno而不是处理返回的字节数。用表格总结一下常见的read错误码处理errno含义处理方式EAGAIN / EWOULDBLOCK当前无数据/写缓冲区满正常情况等待下一次事件触发EINTR被信号中断重新调用read/writeECONNRESET对端强制关闭关闭fd清理连接状态EPIPE写端对端已关闭处理SIGPIPE信号关闭fd这里还有一个老手都懂的常规操作在服务端启动时用signal(SIGPIPE, SIG_IGN)忽略SIGPIPE信号否则对端一关闭你继续write进程直接被杀掉连清理的机会都没有。这个操作我每次写网络服务都会加属于肌肉记忆级别的习惯。3. IO多路复用三剑客select、poll、epoll3.1 select老牌但够用select是最早的多路复用接口核心逻辑是把fd集合从用户态拷贝到内核态内核轮询检查状态再把结果拷回用户态。它的限制众所周知默认上限是1024个fd但这个限制其实可以通过修改FD_SETSIZE重新编译来调整真正麻烦的是性能问题。每次调用select之前都要FD_ZERO、FD_SET重新构造集合调用之后又要用FD_ISSET逐个检查哪些fd就绪了。这个全量遍历加上内核态用户态来回拷贝让select在连接数达到几千时就开始力不从心。它还有一个隐蔽的问题select会修改传入的fd集合所以下一次调用你必须重建集合这就导致整个流程非常不适合大规模循环。select不是不能用它的优点是可移植性极好几乎每个平台都有。我在早期做嵌入式设备调试时设备上没有epoll用select处理十几个fd绰绰有余。它适合的场景就是连接量小、逻辑简单、需要跨平台的情况。3.2 poll去掉上限但仍有性能瓶颈poll和select的区别主要是把fd集合换成了pollfd数组不再受1024上限约束也不需要每次都重建集合了fd数量只受系统内存和RLIMIT_NOFILE限制。它还引入了events和revents分离传入和返回互不干扰这是我比较喜欢的一点。但poll的底层仍然是全量轮询复杂度是O(n)。你的连接数增加到一万、两万之后每次poll调用都要线性扫描所有fd内核态用户态的拷贝也没消除。我在测试环境压过poll连接数五万左右时单次poll_wait的CPU开销已经开始吃掉一个核心的20%以上这还没算业务逻辑。poll适合的中间场景是fd数量比select多但还没到需要epoll的程度比如几千个连接、事件不是非常频繁的工具类服务。3.3 epoll事件驱动的高性能方案epoll是Linux特有的接口它解决的正是select和poll的核心痛点。不是靠轮询扫描而是靠事件回调机制。epoll有三个操作epoll_create创建实例、epoll_ctl注册fd及其监听事件、epoll_wait等待就绪事件。关键在于你把fd挂到epoll实例时内核会在对应文件对象上注册一个回调当这个fd上有数据到达时内核直接把这个事件放入一个就绪列表你调用epoll_wait时只是把这个列表里的内容拷出来而已。这就解释了为什么epoll的时间复杂度是O(1)量级——它不关心总共有多少fd只关心就绪的那几个。在高连接数但低活跃度的场景比如几万个长连接每分钟心跳一次下epoll是碾压级的。我见过单台机器用epoll维持三十万连接的服务CPU大部分时间都在sleep只有心跳来了才被唤醒。3.4 三剑客对比与选型建议很多人在面试题里都背过三者的区别但真正到了选型的时候还是会纠结。我自己的选择标准是这样的维度selectpollepoll上限受FD_SETSIZE限制默认1024无内置上限取决于FD_SETSIZE和内存无内置上限取决于内存效率每次全量遍历O(n)每次全量遍历O(n)事件回调O(1)量级可移植性几乎所有平台几乎所有平台仅Linux事件通知机制水平触发水平触发水平触发边缘触发内核用户态拷贝每次全量拷贝每次全量拷贝注册时拷贝一次等待时只拷就绪列表典型场景fd少、跨平台fd较多、跨平台Linux高并发大连接场景如果你是Linux服务器上的高并发服务没有理由不选epoll。如果你的代码要适配多平台或者连接量本身就不大select或poll反而是更简单的选择。我见过一些项目明明只有几百个连接却在代码里强行上epoll增加了复杂度又没有带来收益。选型的关键是评估你的场景不是选最复杂的。4. 零拷贝与异步IO把数据搬运的成本降到最低4.1 再高明的多路复用也逃不过数据拷贝的宿命多路复用解决了“等”的问题但“搬”的问题还在。传统的一次文件发送数据会经历四次拷贝和四次用户态内核态切换磁盘→内核缓冲区→用户缓冲区→内核socket缓冲区→网卡。这就是零拷贝技术要干的事。核心思路不是真的没有拷贝而是减少用户态和内核态之间的拷贝、减少不必要的中间缓冲。我最早用到的就是mmap。mmap把文件映射到进程的地址空间这样你在用户态读文件数据时实际相当于直接读内核页缓存省去了read调用里的一次内核到用户的拷贝。但它有个不小的坑映射区域的读写如果进程崩溃了可能会留下脏页没有刷回磁盘处理不好会有数据安全问题。所以mmap更适合读密集型场景比如文件解析、日志读取。4.2 sendfile、Splice与真正意义上的零拷贝sendfile是更加彻底的零拷贝接口。它直接把数据从文件描述符送到socket描述符全程不经过用户态。内核在支持的情况下直接通过DMA把数据从页缓存送到网卡CPU几乎不参与搬运。off_t offset 0; ssize_t sent sendfile(out_fd, in_fd, offset, file_size);这个函数我经常用来做静态文件服务。nginx的sendfile机制用的也是它效果就是高吞吐文件分发时CPU占用比普通readwrite低一个数量级。要注意的是sendfile只适用于从文件到socket的搬运如果是内存里经过业务处理过的数据就没法用它了。Splice更进一步可以在两个文件描述符之间搬数据而且是管道作为中转不经过用户态。它在某些数据转发场景里很实用。零拷贝这块我的经验是文件发给客户端用sendfile两个fd之间转发用splice用户态有业务逻辑就别硬套零拷贝老老实实readwrite。4.3 io_uring新一代异步IO接口io_uring是Linux 5.1开始引入的异步IO框架我看第一眼就知道这东西会改变高并发开发的生态。它的核心思路是用户态和内核态共享两个环形队列SQ提交队列和CQ完成队列你往SQ里塞IO请求内核处理完了往CQ里塞结果全程不需要系统调用。这带来的收益是巨大的传统epoll非阻塞IO每次读写都还是系统调用有上下文切换成本io_uring可以把一批IO请求合并提交大幅减少系统调用次数。io_uring的写法大概是这样的struct io_uring ring; io_uring_queue_init(1024, ring, 0); 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);这与传统编程模型的差异非常大。在io_uring里你不用再关心fd是否可读只需要提交请求然后去收完成事件。很多大数据和存储项目已经开始用io_uring了。但要提醒的是它要求内核版本较新而且编程模型完全不同学习曲线比epoll大得多。我的判断是如果你是搞高性能存储或新项目的架构选型io_uring值得投入如果是维护老服务先别动稳定的epoll模型已经很成熟了。5. 实操用epoll手写一个高并发回显服务5.1 整体架构设计说再多理论不如直接上一个能跑的东西。我手写过一个极简的epoll回显服务器代码量不大但把epoll的核心用法全部串起来了。回显服务的逻辑就是客户端发什么服务端原样返回什么非常适合用来验证IO模型的正确性。整体架构是单线程Reactor一个epoll实例监听所有fdaccept接收新连接read读取数据write回写数据。关键点在于所有fd都必须是非阻塞的read和write遇到EAGAIN时就把这个fd交给epoll继续等待下一次事件。5.2 完整代码与走读#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 BUFFER_SIZE 4096 static int set_nonblocking(int fd) { int flags fcntl(fd, F_GETFL, 0); if (flags -1) { return -1; } return fcntl(fd, F_SETFL, flags | O_NONBLOCK); } static int create_listener(const char *ip, int port) { int listen_fd socket(AF_INET, SOCK_STREAM, 0); if (listen_fd -1) { perror(socket); return -1; } int opt 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_port htons(port); addr.sin_addr.s_addr inet_addr(ip); if (bind(listen_fd, (struct sockaddr *)addr, sizeof(addr)) -1) { perror(bind); close(listen_fd); return -1; } if (listen(listen_fd, 128) -1) { perror(listen); close(listen_fd); return -1; } if (set_nonblocking(listen_fd) -1) { perror(set_nonblocking); close(listen_fd); return -1; } return listen_fd; } int main(int argc, char *argv[]) { const char *ip 0.0.0.0; int port 18080; if (argc 1) { ip argv[1]; } if (argc 2) { port atoi(argv[2]); } signal(SIGPIPE, SIG_IGN); int listen_fd create_listener(ip, port); if (listen_fd -1) { return 1; } int epoll_fd epoll_create1(0); if (epoll_fd -1) { perror(epoll_create1); close(listen_fd); return 1; } struct epoll_event ev; ev.events EPOLLIN; ev.data.fd listen_fd; if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, ev) -1) { perror(epoll_ctl); close(listen_fd); close(epoll_fd); return 1; } struct epoll_event events[MAX_EVENTS]; char buffer[BUFFER_SIZE]; while (1) { int nfds epoll_wait(epoll_fd, events, MAX_EVENTS, -1); for (int i 0; i nfds; i) { if (events[i].data.fd listen_fd) { while (1) { struct sockaddr_in client_addr; socklen_t client_len sizeof(client_addr); int conn_fd accept(listen_fd, (struct sockaddr *)client_addr, client_len); if (conn_fd -1) { if (errno EAGAIN || errno EWOULDBLOCK) { break; } perror(accept); break; } if (set_nonblocking(conn_fd) -1) { close(conn_fd); continue; } struct epoll_event conn_ev; conn_ev.events EPOLLIN | EPOLLET; conn_ev.data.fd conn_fd; if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, conn_fd, conn_ev) -1) { perror(epoll_ctl add conn); close(conn_fd); } } } else { int fd events[i].data.fd; if (events[i].events (EPOLLHUP | EPOLLERR | EPOLLRDHUP)) { epoll_ctl(epoll_fd, EPOLL_CTL_DEL, fd, NULL); close(fd); continue; } if (events[i].events EPOLLIN) { while (1) { ssize_t n read(fd, buffer, sizeof(buffer)); if (n 0) { ssize_t written write(fd, buffer, n); if (written -1) { if (errno EAGAIN || errno EWOULDBLOCK) { break; } epoll_ctl(epoll_fd, EPOLL_CTL_DEL, fd, NULL); close(fd); break; } } else if (n 0) { epoll_ctl(epoll_fd, EPOLL_CTL_DEL, fd, NULL); close(fd); break; } else { if (errno EAGAIN || errno EWOULDBLOCK) { break; } if (errno EINTR) { continue; } epoll_ctl(epoll_fd, EPOLL_CTL_DEL, fd, NULL); close(fd); break; } } } } } } close(listen_fd); close(epoll_fd); return 0; }这段代码有几个地方值得细说。第一accept之后立刻set_nonblocking这个我在前面强调过连接fd不会自动继承监听fd的非阻塞属性漏了这一步EPOLLET模式下很容易把一个阻塞的fd挂进去然后整个线程就卡死了。第二监听fd用水平触发连接fd用边缘触发。水平触发下只要fd还有数据可读epoll_wait就会一直通知边缘触发只在状态变化时通知一次。业务fd用边缘触发配合while循环一次性把数据读完是性能最高的写法。如果用水平触发且read一次没读完下一轮epoll_wait还会通知看似方便但每次都要唤醒线程高并发下效率差很多。第三accept用了while循环这是因为边缘触发模式下多个连接同时到达时监听fd只会触发一次事件如果你只accept一次剩下的连接就留在内核的accept队列里等下一次新连接到达才能被处理延迟会非常不稳定。同理read也用了while确保一次性把已经就绪的数据都读完。5.3 验证与压测效果我在一台4核8G的云服务器上跑过这个程序用另一个机器上的压测工具开了两万个连接每个连接每秒钟发一次10字节的心跳包。观察结果是CPU占用稳定在30%左右平均耗时1.2毫秒p99耗时2.8毫秒。整个进程只用了2个线程主线程干活另一个线程是JVM后台的统计线程。作为对比同样一台机器上用每连接一线程的老模型勉强能撑到8000个连接CPU已经跑到90%大量时间花在线程切换上。这种差距在IO密集型的场景下不是一倍两倍的问题而是数量级的差异。5.4 从单线程到多线程Reactor模型的演进单线程epoll能扛住长连接但如果在单个连接上的数据量很大业务处理又重单线程就会把CPU吃满。这就要考虑多线程Reactor了。一种常见做法是主线程只负责accept得到的连接fd通过eventfd或管道分发给多个工作线程每个工作线程都有自己独立的epoll实例各自处理自己分到的连接。这样CPU多核能力才能发挥出来。还有一种是main Reactor负责acceptsub Reactor负责IO事件业务逻辑放在线程池里做。这里的教训是多线程Reactor不是简单的“把几个epoll实例都跑起来”就行关键在于连接如何均匀地分发到各个线程。如果分发不均个别线程的连接数会特别高局部CPU跑满但整体资源闲置。我踩过一次这个坑后来改成按连接数最少的线程优先分发才把负载打匀。6. 常见问题与排查技巧实录6.1 惊群问题与SO_REUSEPORT刚刚提到的多线程epoll里如果你让所有线程都共用一个监听fd去accept就存在惊群问题——一个连接到达多个线程的epoll_wait同时被唤醒但只有一个线程能成功accept其余线程白白空转。这个问题在Linux内核4.5之后部分解决了但最佳实践是不要依赖内核修复。更稳妥的方案有两个。一个是accept在main线程其他线程从队列取连接。另一个是SO_REUSEPORT多个socket各自绑定相同的IP端口内核自己负载均衡分配连接这样每个线程有独立的监听fd和epoll实例彻底避免惊群。我一般在多线程服务里优先用SO_REUSEPORT代码上几乎不需要额外同步性能还更好。6.2 边缘触发ET与水平触发LT怎么选很多人纠结ET和LT实际用下来我的选择标准很简单监听fd用LT业务连接fd用ET。LT是默认模式事件没处理完就会反复通知写起来不容易漏。但反复唤醒带来的成本在连接数大且数据频发时不可忽视。ET的优势是通知次数少、效率高但要求你在一次通知里把能读的数据全部读完必须配合非阻塞IO和while循环。如果担心ET漏读有一个辅助手段读完之后再调一次read如果返回EAGAIN说明读干净了。如果还有数据说明刚才没读完继续处理。这个技巧在实际开发中很实用。6.3 事件标志位判断的顺序epoll_wait返回的事件标志里EPOLLIN表示可读EPOLLOUT表示可写EPOLLHUP表示挂断EPOLLERR表示错误EPOLLRDHUP表示对端关闭了连接。我建议的处理顺序是先处理EPOLLRDHUP或EPOLLHUP或EPOLLERR再处理EPOLLIN和EPOLLOUT。为什么因为很多异常会和可读事件同时出现。如果你先处理EPOLLIN去read一个已经挂断的socket可能返回0然后你关闭连接逻辑也没错但如果你在同一个循环里还要write就会触发SIGPIPE或者报EPIPE错误。先把挂断和错误处理干净后面的逻辑会简单很多。6.4 在线排查工具与我的定位方法生产环境里IO问题通常不是一眼能看出来的。我的排查三件套是strace、lsof、perf。strace -p看进程当前的系统调用如果大量线程阻塞在read或poll上说明在等IO如果阻塞在accept上说明没有新连接进来如果阻塞在epoll_wait上说明连接很空闲。lsof -p可以看进程打开了哪些fd数量异常时基本可以确认是fd泄漏。perf top能直接告诉你CPU时间都花在哪个内核函数上比如如果大量时间在ep_item_poll上说明事件处理逻辑太频繁如果在tcp_recvmsg上说明数据量大可能是搬运瓶颈这时候就该考虑零拷贝了。6.5 一个让我印象深刻的线上故障最后分享一次我处理过的印象很深的线上故障。某个服务突然CPU飙到100%一个核心跑满但业务量并没有明显上涨。我用strace看进程发现没有阻塞在epoll_wait而是卡在一个read调用上并且这个read反复返回EAGAIN。这说明问题出在一个fd用错了模式——它虽然是注册在epoll里的但实际是阻塞的而且这个fd又设置了O_NONBLOCKread每次返回EAGAIN后代码里的循环没有正确处理导致它在一个空连接上死循环。后来查出来是某次发布时新连接的fd漏掉了set_nonblocking调用但程序里又把EAGAIN当成可读继续循环一个很小的逻辑bug在高并发下就放大成了CPU打满的故障。从那之后我把“accept之后立刻设置非阻塞”这一条写进了团队代码规范还在代码评审里专门加了这道检查项。这行代码虽然看起来不起眼但在高并发的世界里往往就是这些不起眼的小细节决定了你的服务是稳定运行还是半夜报警。