搞过 TCP 并发服务器的朋友应该都有过这种体验:并发量小的时候,怎么写都能跑,一连接一线程也香;等连接数爬到几千甚至上万,原来那套代码突然就变成泥潭,CPU 飙高、内存膨胀、延迟抖动,查半天也找不到一个明确的“要害”。老实说,这不是你的代码不够快,而是你用的 I/O 模型在并发规模上来之后,已经把内核资源和 CPU 时间浪费在“等待”上了。这篇文章我想把 TCP 并发服务器设计里最核心的 I/O 模型问题掰开揉碎讲清楚:五种 I/O 模型到底差在哪、我在真实项目里从“阻塞 + 线程”迁到“非阻塞 + epoll”的完整过程,以及事件驱动模式下那些不踩一遍根本意识不到的坑。适合正在写网络服务、想搞懂并发模型本质、或者被 C10K 问题砸过脸的开发者参考。
1. 并发规模一上来,问题不在端口而在线程
1.1 线程是资源,不是接入量
很多初学者对“并发”的理解就是“数量”,觉得只要线程开得多就能处理更多连接。这句话在小规模下没错,但它忽略了一个前提:线程是个被操作系统调度和管理的资源,它要占用内存、要有自己的栈、要被调度器来回切换。
Linux 下默认的线程栈大小是 8MB 虚拟内存。按照这个算,1 万个线程需要 80GB 的虚拟地址空间,虽然物理内存是按页分配的,不会一下就全部占满,但你的进程地址空间和页表开销会被迅速吃干。更关键的是,一个线程哪怕在阻塞等待,它的内核栈、线程控制块、各种资源也不会少。我见过一个实际案例:某个设备接入网关,峰值时有 3000 多个在线连接,对应的服务用了 3200 多个线程,进程占用物理内存达到 15GB,而这只是个逻辑非常简单的转发服务。
上下文切换的开销更是被严重低估。线程切换意味着内核要保存和恢复寄存器、栈指针、程序计数器,还要切换地址空间相关的缓存、TLB 等。当一个 CPU 核心上排队等待的线程数量远超过核心数时,大部分时间都花在切换上,而不是执行你的业务逻辑。实测里,超过 1000 个可运行线程堆积在一台 8 核机器上时,业务线程连 10% 的有效 CPU 时间都拿不到,其余时间都喂给了调度器。
1.2 C10K 问题的根源
“C10K”这个词说的是单台服务器并发支撑 1 万个连接的问题。它并不是一个需要背下来的数字,而是一道分水岭:连接数到万这个量级,传统“一个连接一个线程”的模型必然崩溃。
原因是复合的:线程内存开销大、上下文切换频繁、锁竞争导致关键路径变长。而且这里还有“惊群”问题——多个线程同时阻塞在同一个监听 socket 的 accept 上,内核唤醒所有线程,最后只有一个线程能成功 accept,其余线程被唤醒后白忙一场回到睡眠。连接数越多,这种无谓唤醒越频繁。
应当明确的是:问题从来不是“端口不够”或者“带宽不够”,而是你把本应由内核统一管理的事件分散到了一个又一个线程的阻塞等待里。要解决它,思路必须从“多线程并发”转向“事件驱动”和“I/O 模型的合理选择”。
2. 五种 I/O 模型的全景扫描:阻塞等待与异步通知的差异
2.1 一次 read() 调用的两个阶段
要理解 I/O 模型,第一步是拆掉“读数据”这个动作。以 TCP 为例,一次 read() 调用实际上包含两个阶段:
- 阶段一:等待数据从对端到达本机内核的 socket 接收缓冲区;
- 阶段二:把内核缓冲区中的数据复制到用户态缓冲区。
每个 I/O 模型,本质上是在这两个阶段中采用不同的策略:谁在等、等的过程中用户在干什么、等完以后数据怎么被读走。
2.2 五种模型逐一拆解
按照教科书上的分法,Unix 环境下的五种 I/O 模型分别是:
| 模型 | 阶段一 | 阶段二 | 用户线程能否做其他事 |
|---|---|---|---|
| 阻塞 I/O | 线程挂起 | 阻塞等待拷贝完成 | 不能 |
| 非阻塞 I/O | 立即返回 EAGAIN | 阻塞等待拷贝 | 轮询期间可做其他事 |
| I/O 多路复用 | 内核监听多事件 | 阻塞等待拷贝 | 可同时管理多连接 |
| 信号驱动 I/O | 内核 SIGIO 通知就绪 | 阻塞等待拷贝 | 可做其他事 |
| 异步 I/O | 内核完成事件处理 | 内核完成后通知 | 全程不参与 |
先说阻塞 I/O。这是最直观的写法:read(fd, buf, n),没有数据就挂着。它的优点是代码完全顺序化,业务逻辑怎么写都不会串;缺点就是线程被一个连接钉死,一个线程只能服务一个连接。
非阻塞 I/O 把阶段一的“等待”解开了:内核发现没有数据,直接返回 EAGAIN。但问题也明显——你得自己反复回来问内核“数据到了没”,这叫做轮询。轮询期间线程确实可以去做其他逻辑,但如果它只是一直在轮询,CPU 就在空转,高并发时这是不可接受的浪费。
I/O 多路复用是目前最主流的方案。select、poll、epoll 都属于这一类。它们的核心思想是:由一个线程去同时等待多个 fd 的事件,内核通知“哪些 fd 就绪了”,你再从这些就绪的 fd 里读取数据。这样等待的成本被摊薄到一批连接上,而不是每个连接一个线程。
信号驱动 I/O 是内核在数据就绪时向你发一个 SIGIO 信号。看起来比多路复用更被动,但信号处理函数里有大量限制,不能调用很多非异步安全的函数,还要考虑重入问题,实际网络服务器里很少用它。
异步 I/O 是最终形态:你把缓冲区地址交给内核,内核负责把数据从 socket 读到缓冲区,全部完成后通知你。整个读写过程用户线程都不参与。Linux 下的 io_uring 就是这类模型。
2.3 把模型放进类比里记
有种很生活化的类比方式:阻塞 I/O 是你在餐厅厨房窗口死等,菜不上你就一直站着;非阻塞 I/O 是你每隔两分钟跑到窗口问一次“好了没”;I/O 多路复用是你把想吃的菜一次报给服务员,服务员看着所有窗口,哪道菜好了喊你端;异步 I/O 是厨师直接把菜端到你桌子上,连窗口都不用你靠近。
这个类比对实际选型很有用:如果你只是一个人吃饭,窗口死等也没问题,无非浪费点时间;如果你要同时招呼一千桌客人,没有服务员(多路复用)根本顾不过来。TCP 服务器同理——连接少时阻塞模型毫无问题,并发一上来,就必须把“等待”交给更高效的角色去处理。
3. 我的重构实录:阻塞 + 线程 → 非阻塞 + epoll
3.1 最初版本:一个连接一个线程
我自己动手写过一个设备接入网关,最初版本非常简单粗暴:监听 socket,accept 到连接就开一个线程处理,处理线程里阻塞读。
void* client_handler(void* arg) { int fd = (intptr_t)arg; char buf[4096]; ssize_t n; while ((n = read(fd, buf, sizeof(buf))) > 0) { // 这里做业务处理,然后回写 write(fd, buf, (size_t)n); } close(fd); return NULL; } int main() { int lfd = socket(AF_INET, SOCK_STREAM, 0); // bind + listen ... for (;;) { int cfd = accept(lfd, NULL, NULL); pthread_t tid; pthread_create(&tid, NULL, client_handler, (void*)(intptr_t)cfd); pthread_detach(tid); } }这段代码跑 100 个连接一点问题都没有,跑 500 个就偶尔出现卡顿,跑到 2000 个时基本属于不可用状态。当时的症状是:内存占用飙升、CPU 长时间跑满、连接反复超时断开。原因分析下来和第一节说的完全一致——线程数成了瓶颈,而不是逻辑本身。
3.2 select 版本:方向对但天花板太低
第一次重构我选择了 select。它的思路是一次性把所有 socket 放到一个集合里,然后阻塞等待内核通知“哪些 fd 可读”。
fd_set allfds, readfds; FD_ZERO(&allfds); FD_SET(lfd, &allfds); int maxfd = lfd; for (;;) { readfds = allfds; // select 会修改集合,每次都从备份拷贝 int nready = select(maxfd + 1, &readfds, NULL, NULL, NULL); if (nready <= 0) continue; if (FD_ISSET(lfd, &readfds)) { int cfd = accept(lfd, NULL, NULL); FD_SET(cfd, &allfds); if (cfd > maxfd) maxfd = cfd; nready--; } for (int fd = 0; fd <= maxfd && nready > 0; fd++) { if (FD_ISSET(fd, &readfds)) { ssize_t n = recv(fd, buf, sizeof(buf), 0); if (n > 0) { send(fd, buf, (size_t)n, 0); } else if (n == 0) { close(fd); FD_CLR(fd, &allfds); } } } }select 确实比“一连接一线程”前进了一大步,一个线程就能盯住几百个 fd。但它的天花板太低:
fd_set是位图,FD_SETSIZE 默认是 1024,超过这个数就得改内核相关配置重新编译;- 每次调用 select 都要把整个 fd_set 从用户态拷贝到内核态,返回时再拷回来,连接越多成本越高;
- 内核只知道有几个 fd 就绪,不知道具体是哪一个,用户代码必须遍历 0 到 maxfd 的所有 fd。1 万个连接里就绪 3 个,你要遍历一大圈;
所以 select 版本在连接数爬到 2000 以上时,同样开始吃力,这时候我去看了 epoll。
3.3 epoll 版本:事件驱动真正落地
epoll 把 select 的三大痛点全解决了。它的核心结构是:内核里维护一棵红黑树,用来保存你注册的所有 fd;fd 有事件时就绪时,内核把它放进一个就绪链表;epoll_wait 返回时直接把就绪链表里的 fd 给你。你不再需要遍历整个连接集合,而是只处理就绪的那几个,复杂度从 O(n) 降到了 O(k),k 是实际就绪数。
#define MAX_EVENTS 1024 int epfd = epoll_create(1); struct epoll_event ev; ev.events = EPOLLIN; ev.data.fd = lfd; epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev); struct epoll_event events[MAX_EVENTS]; for (;;) { int n = epoll_wait(epfd, events, MAX_EVENTS, -1); for (int i = 0; i < n; i++) { int fd = events[i].data.fd; if (fd == lfd) { int cfd = accept(lfd, NULL, NULL); set_nonblocking(cfd); // 新连接一定要非阻塞 ev.events = EPOLLIN; ev.data.fd = cfd; epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev); continue; } if (events[i].events & EPOLLIN) { ssize_t n = read(fd, buf, sizeof(buf)); if (n > 0) { // 处理数据... } else if (n == 0) { epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); close(fd); } else { // EAGAIN:非阻塞模式下,本次已读完所有数据 } } } }这段代码是个骨架,但它把 epoll 的思维方式展示得很清楚:你维护的是一个“事件表”,不是“连接数组”。新连接、可读、可写、断开,都是内核推送给你的事件,程序只需要响应事件,而不是逐个检查连接。
这里有个细节必须单独强调:客户端 fd 注册进 epoll 前,一定要设置成非阻塞。原因是 epoll 虽然能在数据到达时通知你,但你调用 read 的时候,如果缓冲区里恰好没有数据(例如收到了空包、或者数据被其他逻辑读走),阻塞模式下这个 read 会把整个事件循环卡死。非阻塞模式下 read 会立刻返回 EAGAIN,你继续处理下一个事件即可。这也是“非阻塞 + I/O 多路复用”这个组合为什么是黄金搭档的原因。
3.4 重构前后的实际对比
重构完以后,我把原来的千级线程池直接砍成固定 4 个线程(对应 4 核 CPU),网络读写全部在事件循环里完成,业务计算放到线程池。同样的一批设备连接,进程内存从 15GB 降到 800MB 左右,CPU 利用率反而比之前低了 60% 以上,延迟也稳定很多。
这个对比不是个例。对大多数长连接型 TCP 服务器来说,从“阻塞 + 线程”切到“非阻塞 + epoll 事件驱动”,不是优化,而是刚需。尤其当连接数超过一千,这个切换几乎能决定服务能不能继续跑下去。
4. 事件驱动最容易翻车的四个细节:缓冲、半包、ET/LT、非阻塞写
4.1 每个连接都要有自己的应用层缓冲区
跑通 epoll 只是第一步,真正的考验在后面。TCP 是字节流协议,没有消息边界。一次 read 可能读到半条消息,也可能读到多条消息拼接在一起。如果你直接对着收到的字节去解析业务,协议一定会乱。
所以事件驱动模式下,我习惯给每个连接都维护一个应用层缓冲区结构:
struct conn { int fd; char *rbuf; // 接收缓冲区 size_t rlen; // 当前有效数据长度 size_t rcap; // 缓冲区容量 char *wbuf; // 发送缓冲区 size_t wlen; // 待发送数据长度 size_t wcap; // 发送缓冲区容量 };EPOLLIN 触发时,把读到的新数据 append 到 rbuf 尾部,然后从 rbuf 头部开始尝试解析:有没有完整的业务包?有就取出来处理,没有就继续等下一段数据。这样做的好处是,数据无论被切成了几片,你的解析逻辑都不需要关心它到底在哪次 read 里到达,只需要面对一个连续的缓冲区。
4.2 半包与粘包处理
半包,就是一条消息因为网络分段或 MTU 限制,被拆成多次发送;粘包,是因为 TCP 没有边界,多条消息一次到达。应对方法只有两种:
- 定长消息:每条消息固定 N 字节,读满 N 字节算一条;
- 变长消息:消息头里放消息长度字段,比如前 4 个字节是一个大端整数,表示消息体长度。
我强烈推荐变长消息方案,几乎所有公开的 RPC 协议也是这么设计的。解析流程非常机械:读前 4 字节 → 解析长度值 → 继续读够对应长度的消息体 → 处理业务 → 回到读 4 字节头的状态。只要缓冲区容量够大,逻辑就完全不用考虑“这条消息被切在哪个位置”的问题。
4.3 LT 和 ET,谁更适合生产环境
epoll 支持两种触发模式。水平触发(LT)只要 fd 上有数据没读完,每次 epoll_wait 都会通知你;边缘触发(ET)只在 fd 从无数据变成有数据的那个瞬间通知一次,剩下的数据如果不读完,内核不再提醒。
我做生产代码时默认都用 LT。原因很简单:LT 模式下你读完一部分就返回事件循环也没关系,下一轮 epoll_wait 还会通知你;ET 模式下你必须循环读到 EAGAIN 为止,否则就会漏数据,代码写得稍微不严谨就是线上事故。ET 的优点是少一次重复通知、在高性能场景下能减少一定的事件唤醒量,但它要求你非常严格地处理非阻塞读循环。对大多数 TCP 服务器来说,LT 才是最稳的默认选择。
在这里也要提一下 EPOLLRDHUP。它表示对端关闭了连接,能让你在 epoll_wait 层面就感知连接断开,不用等 read 返回 0。加上它之后,连接清理的时效性会好很多,避免连接对象残留和资源耗尽。
4.4 写事件管理:EPOLLOUT 的正确注册与注销
事件驱动服务器里非常容易出错的一点:你收到了一个消息,然后就迫不及待地直接write(fd, buf, n)。如果 socket 的发送缓冲区满了,write 可能只写了一半就返回,剩下的一半如果不保存,数据就永久丢了。
正确流程是:
- 需要发送数据时,先把数据 append 到 conn 的 wbuf;
- 尝试直接把 wbuf 里的数据写出去;
- 如果没写完,保留剩余数据,同时给该 fd 注册 EPOLLOUT 事件;
- 等 EPOLLOUT 触发后继续写,写完了立刻注销 EPOLLOUT。
最后一个步骤很容易忽略。因为大部分时间里 socket 都是可写状态,如果你一直监听 EPOLLOUT,epoll_wait 会频繁返回可写事件,但你并没有数据要发,白白消耗 CPU。所以,正确做法是“有数据要发但发不完时才注册 EPOLLOUT,发完立刻摘除”。
我第一次实现这个逻辑时偷懒了,直接 write,设备侧在弱网环境下一上报数据就丢几包,排查了很久才定位到是发送缓冲区写满导致的。那之后我所有的服务器代码都坚持这个“先试发、发不完注册写事件”的流程。
5. 按业务场景选择 I/O 模型:我的决策框架与踩坑心得
5.1 连接数、消息粒度和计算耗时是选型三要素
学了这么多模型,最后要解决的是“我的项目里到底该用哪种”。我现在的决策框架只看三个变量:
- 连接规模:预估同时在线多少连接。
- 消息粒度:每个业务包的大致频率和大小。
- 计算耗时:处理一个业务包时,需要花多久,是否涉及磁盘、数据库、外部 RPC 等阻塞操作。
这三个变量基本决定了 I/O 模型的选择。
| 场景 | 推荐模型 | 理由 |
|---|---|---|
| 连接数 < 500,短连接为主 | 阻塞 I/O + 线程池 | 简单,好排查,维护成本低 |
| 连接数 500 ~ 1 万,长连接为主 | 非阻塞 + epoll 事件驱动 | 单线程可扛万级连接,成本低 |
| 连接数 > 1 万,且需要高吞吐 | 事件驱动 + 固定工作线程池 | 网络与计算分离,可扩展性最好 |
| 业务依赖慢服务,阻塞严重 | 事件循环 + 阻塞业务线程池 | 避免网络事件循环被计算和 IO 拖死 |
5.2 场景举例
如果你只是给几十个内部系统做数据转发,连接数几百,阻塞 + 线程池完全够。强行上 epoll 反而要处理事件循环、缓冲管理、半包黏包这些问题,维护成本上涨。
如果你的服务像很多 IoT 网关一样,连接数可能上万,但每个连接的数据很小,epoll 的优势就能完全释放。单核都未必用满,CPU 主要是被收包唤醒和协议解析吃掉的。
如果你的业务里要对每个连接做复杂的加解密、压缩、大对象组装,绝不能把这些计算放在事件循环里,否则一个连接的计算会拖住所有其他连接。正确做法是:epoll 事件循环只负责收包解包,把计算任务丢给固定线程池,完成后再把响应注册到 EPOLLOUT。
5.3 我会反复提醒的几条实操准则
第一条:epoll_wait 的超时时间不建议设成无限制阻塞。设成 100ms 左右,这样定时器任务到期时事件循环能及时醒过来处理,而不是必须等新数据进来。连接超时剔除就靠它。
第二条:事件循环里严禁做磁盘操作、sleep、加锁等待。我有一次在事件循环里打了条日志到磁盘,日志量大时把读事件延迟从毫秒级拉到秒级,整个服务就像被卡住了一样。
第三条:连接对象生命周期要严格对应事件。建议在 accept 之后创建连接对象,在 EPOLLRDHUP 或 read 返回 0 时释放。用哈希表以 fd 为 key 保存连接对象,事件回调里拿到 fd 就能找到对象,也能主动剔除异常连接。
第四条:如果用了边缘触发,一定记得非阻塞循环读到 EAGAIN。如果没有十足的把握,老老实实选 LT,别为了省一点事件通知把自己搭进去。
最终总结起来,我对 I/O 模型这件事最大的体会是:它不是一套娱乐算法,而是关于“你如何分配等待这个成本”的决策。多路复用让你用一次等待把成百上千连接都“看着”,异步让你连等待这两阶段都不需要参与。关键不在于模型本身的高级程度,而在于你的业务匹配度、你的团队能维护的程度、以及你踩过多少细节坑之后沉淀下来的工程手感。
如果这篇文章建议只留一句,我会说:先写一个基于 epoll 的回显服务器,用一千个以上连接去压一次,亲眼看到线程模型和事件驱动模型在 CPU 曲线上的差别,你将比读任何理论都更快地理解并发服务器设计是怎么回事。