连接数一上来,多线程阻塞模型就顶不住了,这是我在做服务器开发时最深的体会。本系列前面十几篇都在聊TCP/IP、socket编程、TCP拥塞控制这些基础,但真正到了写高并发服务器的环节,我发现在Linux上绕不开的其实是两个东西:一个是IO多路转接(select/poll/epoll),另一个是建立在它之上的reactor编程模型。这两者合在一起,才是现代Linux后端服务器(nginx、redis、netty这类项目)真正的骨架。
这篇文章就以“网络和Linux网络-15”这个系列位置为背景,认真拆一拆IO多路转接到底是怎么回事,reactor模式为什么能扛住成千上万的并发连接,以及如何自己动手用epoll+reactor写一个真正能跑的服务器。适合正在学Linux网络编程的学生、刚入行的后端开发,以及那些看了源码却感觉云里雾里的朋友。文章会尽量做到能直接照着写、照着排查问题的程度。
1. 为什么我们需要reactor:从阻塞模型到事件驱动
1.1 从多线程阻塞模型说起
先回忆一下最朴素的服务器写法。你用socket()、bind()、listen()把监听socket建好,然后进入一个死循环,调用accept()等待客户端连接。来一个连接,就创建一个线程(或者扔进线程池),这个线程里循环调用read()读数据、处理、write()回数据。
这个模型在连接数少、每个连接发起的请求频率不高的场景下完全够用,代码也简单。但一旦把连接数推到几千、几万,问题就暴露得很明显。
一个阻塞线程通常要占8MB左右的栈内存(取决于线程栈配置),起一万个线程光内存就是80GB量级,这还只是线程本身的开销,没算上下文切换成本。更麻烦的是,绝大多数连接处于“空闲”状态——客户端连上来但很长时间不发数据,可线程却得一直卡在read()上等着。这就相当于雇了一万个服务员,每人盯着一张桌子,但绝大多数桌子根本没有客人说话,纯粹浪费人力。
我早期写的一个小服务,连接从两三百涨到两千的时候,在线线程数量直接让CPU打满,大部分时间都花在内核态切换线程上下文上,真正在干业务的没几个。那个阶段我意识到,服务器编程的核心矛盾不是“怎么处理得快”,而是“如何用有限的线程等待大量的连接”。
1.2 reactor模式的核心思路:事件就是一切
reactor这个单词直译是“反应堆”,在软件工程里它描述的就是一种“倒过来”的IO模型。传统阻塞模型的思路是“每个线程去等一个连接”,而reactor把思路反过来——“用一个线程(或少量线程)统一等所有连接,哪个连接有数据了,就处理哪个连接”。
对应到真实生活,你可以把传统的阻塞多线程模型想象成一家餐厅给每桌客人都配一个专属服务员,一直站在桌边等着客人举手。而reactor模式是前厅只留一个服务员,他负责来回巡视,哪桌客人喊“点菜”了,他就过去服务一下,服务完继续巡视。如果是主从reactor,就相当于门口再放一个引导员,客人进门他先带位,然后再交给前厅服务员来点菜。
这个思路落实在Linux系统上,依赖的关键能力就是IO多路转接——让一个线程能同时“盯着”成千上万个socket,内核负责告诉我们哪些socket可读、哪些可写。这个能力在select、poll里就已经有了雏形,但真正在工程上大放异彩的是epoll。
所以reactor不是某个库,更不是某个框架,它是一种组织代码的模式。它把网络程序中“等待事件”和“处理事件”这两个阶段解耦,把时间片留给真正有事可做的socket。理解了这一点,后面看redis、nginx的源码会顺畅很多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 地基:IO多路转接三兄弟怎么选
2.1 select:socket数组的暴力遍历
在Linux上,select是最早出现的IO多路转接方案,它的用法很直白:你把所有要监听的socket丢进一个“读集合”(fd_set),调用select()阻塞,等集合里任意一个socket可读/可写,select返回,然后你用FD_ISSET一个个检查到底哪个fd就绪了。
它的问题也是显而易见的。首先是fd_set大小默认被限制在1024,也就是说单个线程最多监听1024个fd,这在很多高并发场景下根本不够,即使可以重新编译内核调整FD_SETSIZE,也很别扭。其次是每次调用select都要把整个fd集合从用户态拷贝到内核态,而且返回后你得把整个集合重新遍历一遍,才能知道哪些fd有事。这个复杂度是O(n)的,连接越多,空跑越多。
更隐蔽的一个坑是select会“破坏”fd_set结构——内核会把未就绪的fd从集合中清掉,所以你每次调用之前都必须把fd重新加入集合,哪怕这些fd一次都没变化过。这一拷贝、一清空、一重建,在高频调用下开销非常可观。
2.2 poll:摆脱1024限制
poll解决的第一个问题就是FD_SETSIZE上限。它不再用位图,而是用一个struct pollfd数组,数组长度由你自己定,理论上只受系统fd数量限制。每个pollfd里用events表示你关心的事件,revents由内核返回实际发生的事件,不会破坏原始events,所以不用每次重新构造监听数组。
但poll和select的本质复杂度相同,都是每次调用时需要把所有fd复制到内核,返回后线性扫描所有fd检查revents。连接数量到上万之后,这个“每次都扫描全量”的代价是非常扎眼的。另外,poll没有提供类似“只返回活动fd”的机制,它只是把select的“小数组”换成了“大数组”,治标不治本。
2.3 epoll:就绪事件的红黑树与链表
epoll是Linux上有别于BSD的kqueue、Windows的IOCP的、真正为高并发设计的IO多路转接机制。它的核心变化有两个:内核帮你维护长期的事件表,并且只会把发生事件的fd放进就绪列表返回给你。
使用上分三步。第一步epoll_create创建一个epoll实例,内核里其实就是两个结构——一棵红黑树,用来存储你所有感兴趣监听的fd;一个就绪链表,用来存放已经触发事件的fd。第二步epoll_ctl用来向红黑树里添加、修改、删除fd及对应事件。第三步epoll_wait阻塞等待,一旦就绪链表不为空,就把就绪事件复制到用户态数组返回。
最关键的一点是:每次epoll_wait只返回真正有事件的fd,不需要遍历几千个无事件fd;而且添加、删除监听关系时,不需要每次重新拷贝全量fd集合。复杂度从select/poll的O(n)降到了接近O(1)(严格说是取决于就绪事件数量)。
用一张表总结更直观:
| 特性 | select | poll
