
🔥个人主页:爱和冰阔乐
📚专栏传送门:《数据结构与算法》 、C++、 《Linux操作系统》
🐶学习方向:C++方向学习爱好者
⭐人生格言:得知坦然 ,失之淡然

🏠博主简介

文章目录
-
- 前言
- [一、从阻塞 I/O 到 select / poll](#一、从阻塞 I/O 到 select / poll)
-
- 最朴素的阻塞服务器,问题出在哪
- 一连接一线程,能解决吗
- [I/O 多路复用到底"复用"了什么](#I/O 多路复用到底"复用"了什么)
- [select:先把"等多个 fd"这件事做起来](#select:先把"等多个 fd"这件事做起来)
-
- [一个容易忽略的细节:select 会修改集合](#一个容易忽略的细节:select 会修改集合)
- [select 的几个限制](#select 的几个限制)
- poll:换了种写法,但仍然要扫
- [二、epoll 的基本模型与事件注册](#二、epoll 的基本模型与事件注册)
-
- [epoll 的思路发生了什么变化](#epoll 的思路发生了什么变化)
- [创建 epoll](#创建 epoll)
- [注册监听 socket](#注册监听 socket)
- 等待事件
- [select / poll / epoll 放一起看](#select / poll / epoll 放一起看)
- [epoll 内部可以怎么理解](#epoll 内部可以怎么理解)
- [监听 fd 可读以后为什么要 accept](#监听 fd 可读以后为什么要 accept)
- [一个最小 LT epoll 服务器](#一个最小 LT epoll 服务器)
- [三、LT、ET 与非阻塞 I/O](#三、LT、ET 与非阻塞 I/O)
-
- LT:只要"条件还成立",就会继续提醒
- ET:更像"状态发生变化时提醒你一下"
- [LT 和 ET 的直接对比](#LT 和 ET 的直接对比)
- [为什么 ET 强烈要求非阻塞 fd](#为什么 ET 强烈要求非阻塞 fd)
- [监听 socket 在 ET 下也要循环 accept](#监听 socket 在 ET 下也要循环 accept)
- 四、写事件、缓冲区与协议处理
-
- [EPOLLOUT 为什么不能一直注册](#EPOLLOUT 为什么不能一直注册)
- [非阻塞 write 为什么会"只写一部分"](#非阻塞 write 为什么会"只写一部分")
- [epoll 只告诉你"可以 I/O",不会替你处理协议](#epoll 只告诉你"可以 I/O",不会替你处理协议)
- [五、从 epoll 到 Reactor 与线程模型](#五、从 epoll 到 Reactor 与线程模型)
-
- [从 epoll 到 Reactor](#从 epoll 到 Reactor)
- [一个简化 Reactor 结构](#一个简化 Reactor 结构)
- [单 Reactor 单线程能不能做业务](#单 Reactor 单线程能不能做业务)
- [六、epoll 的边界、常见坑与连接状态机](#六、epoll 的边界、常见坑与连接状态机)
-
- [epoll 并不意味着"所有场景都比 poll 快"](#epoll 并不意味着"所有场景都比 poll 快")
- 几个非常容易写错的地方
- [EPOLLONESHOT 是干什么的](#EPOLLONESHOT 是干什么的)
- [accept 惊群是什么思路上的问题](#accept 惊群是什么思路上的问题)
- 为什么高并发服务器经常强调状态机
- 七、把高并发网络模型完整串起来
-
- 从一个客户端到大量连接,模型是怎样一步步变的
- [如果自己练 epoll,建议按什么顺序写](#如果自己练 epoll,建议按什么顺序写)
- 总结
前言
第一次写 TCP 服务器,代码通常很直观:
cpp
int clientfd = accept(listenfd, nullptr, nullptr);
char buf[1024];
int n = read(clientfd, buf, sizeof(buf));
write(clientfd, buf, n);
一个连接进来,读数据,再写回去。客户端少的时候,这套逻辑完全够用。
连接一多,问题就一个接一个冒出来:
- 一个连接一直不发数据,
read会不会把整个线程卡住? - 一个连接一个线程,几万个连接是不是就要几万个线程?
select、poll、epoll到底差在哪?- epoll 所谓的"事件通知",到底通知了什么?
- LT 和 ET 只是触发方式不同,为什么代码写法差那么多?
- ET 为什么总强调非阻塞、强调一直读到
EAGAIN? - Reactor 和 epoll 又是什么关系?
很多文章会把这些压成一句话:
epoll 比 select 快,因为 epoll 是 O(1)。
这句话太粗,而且容易把重点带偏。O(1) 这个说法本身就不太准确,后面会具体讲。
真正想分清这几种模型,别从复杂度口诀开始,盯住同一个问题就行:
程序到底怎么知道"现在该处理哪个 fd"?
下面就从最朴素的阻塞服务器开始,看这个问题是怎么一步步演变到 epoll 和 Reactor 的。
一、从阻塞 I/O 到 select / poll
最朴素的阻塞服务器,问题出在哪
先写一个非常简单的服务端:
cpp
int listenfd = socket(AF_INET, SOCK_STREAM, 0);
bind(listenfd, ...);
listen(listenfd, 128);
while (true)
{
int clientfd = accept(listenfd, nullptr, nullptr);
char buffer[1024];
int n = read(clientfd, buffer, sizeof(buffer));
if (n > 0)
write(clientfd, buffer, n);
close(clientfd);
}
流程大概是这样:
text
accept
↓
等客户端连接
↓
read
↓
等客户端发数据
↓
write
↓
close
问题很明显。客户端 A 连上来却一直不发数据,服务器就卡在这一行:
cpp
read(clientfd, ...);
这时候客户端 B 就算已经发起连接,主线程也回不到 accept。
阻塞 I/O 最大的问题不是"阻塞"本身,而是一个执行流阻塞在某个连接上之后,就没法再照顾其他连接。
一连接一线程,能解决吗
最直接的办法:主线程只负责 accept,每个 clientfd 丢给一个线程处理。
cpp
while (true)
{
int clientfd = accept(listenfd, nullptr, nullptr);
std::thread([clientfd]() {
handleClient(clientfd);
close(clientfd);
}).detach();
}
这样 A 卡在 read 只卡自己的线程,B 照样能被新线程处理。连接数不高的服务,这个模型完全能工作。
不是线程慢,是规模上来后成本开始明显
线程要有自己的栈、调度状态,要参与上下文切换,还要考虑同步和生命周期管理。
麻烦的是这个场景:10000 个连接里,同一时刻真有数据的可能只有 100 个。为了这 100 个活跃连接长期养着 10000 个线程,就不太划算了。
于是问题变成:
能不能用少量线程管住大量 fd,只处理当前真正就绪的那几个?
这就是 I/O 多路复用要解决的问题。
I/O 多路复用到底"复用"了什么

这张图看的就是一件事:一个执行流,怎么同时盯住多个 fd 的状态变化。
"多路复用"听起来抽象,其实就两层意思:
- 一个执行流
- 同时等待多个 fd 的 I/O 状态
不是"线程阻塞在 fd1 上",而是:
- 我这儿有 fd1、fd2、fd3、fd4......
- 你告诉我现在谁可读、谁可写
内核返回 fd2 可读、fd7 可写、fd9 可读,程序就只处理这三个。
I/O 多路复用的重点,是把"等待某一个 fd"变成"等待一批 fd 中的事件"。
Linux 上常见的接口有三个:select、poll、epoll。思路一脉相承,差别主要在"怎么表达关注集合"和"怎么拿到就绪事件"。
select:先把"等多个 fd"这件事做起来
核心接口:
cpp
int select(int nfds,
fd_set *readfds,
fd_set *writefds,
fd_set *exceptfds,
struct timeval *timeout);
先准备读集合:
cpp
fd_set readfds;
FD_ZERO(&readfds);
FD_SET(listenfd, &readfds);
FD_SET(client1, &readfds);
FD_SET(client2, &readfds);
然后等待:
cpp
select(maxfd + 1, &readfds, nullptr, nullptr, nullptr);
返回以后再逐个判断:
cpp
if (FD_ISSET(client1, &readfds))
{
// client1 可读
}
一个容易忽略的细节:select 会修改集合
select 返回后,集合里只剩下就绪的 fd,所以通常要保留一份主集合:
cpp
fd_set master;
fd_set ready;
每轮重新拷一份再传进去:
cpp
ready = master;
select(..., &ready, ...);
也就是说,每一轮都要重新准备关注集合。这点后面会和 epoll 形成明显对比。
select 的几个限制
- fd 数量受
FD_SETSIZE限制(Linux/glibc 上通常是 1024,这是fd_set的实现限制,不是 POSIX 规定死的通用上限) - 每次调用都要把集合从用户态拷进内核,返回时再拷回来
- 返回后集合被改写,下一轮必须重新填充
- 哪些 fd 就绪要自己遍历找出来,连接多时扫描成本明显
但不能因此说 select 没用。连接数不大、又要求跨平台,它依然是最直观的一个选择。
poll:换了种写法,但仍然要扫
poll 用结构体数组代替 fd_set:
cpp
struct pollfd
{
int fd;
short events; // 我关心的事件
short revents; // 实际发生的事件
};
用起来像这样:
cpp
std::vector<pollfd> fds;
fds.push_back({listenfd, POLLIN, 0});
fds.push_back({client1, POLLIN, 0});
fds.push_back({client2, POLLIN, 0});
poll(fds.data(), fds.size(), -1);
返回以后同样要遍历:
cpp
for (auto &p : fds)
{
if (p.revents & POLLIN)
{
// 可读
}
}
数组由调用者自己分配,所以不再受 FD_SETSIZE 那种固定上限约束,动态增删也更自然。
但核心问题没变:
text
每次 poll 返回
↓
程序遍历整个数组
↓
找 revents != 0 的项
10000 个连接里只有 20 个活跃,应用还是得把整个数组扫一遍,而且每轮调用同样要把整个数组交给内核。
二、epoll 的基本模型与事件注册
epoll 的思路发生了什么变化
epoll 常见的三个接口:
cpp
epoll_create1(...)
epoll_ctl(...)
epoll_wait(...)
逻辑上可以拆成四步:
text
创建一个 epoll 实例
↓
把关心的 fd 注册进去
↓
等待事件
↓
直接拿到当前发生事件的 fd
关键变化在第一步和第二步:关注集合是长期注册在内核里的,不用每轮重新递交。 而 select/poll 每调用一次,都得把整份集合搬一遍。
创建 epoll
cpp
int epfd = epoll_create1(0);
得到一个 epoll 实例对应的 fd。
注册监听 socket
cpp
struct epoll_event ev{};
ev.events = EPOLLIN;
ev.data.fd = listenfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev);
等于告诉内核:我关心 listenfd 的可读事件。
对监听 socket 来说,"可读"不是收到业务数据,而是:
连接队列里有连接可以
accept了
等待事件
cpp
struct epoll_event events[1024];
int n = epoll_wait(
epfd,
events,
1024,
-1
);
返回以后:
cpp
for (int i = 0; i < n; ++i)
{
int fd = events[i].data.fd;
// 这里处理真正发生事件的 fd
}
这就是 epoll 在使用体验上最重要的变化。
应用循环的主要工作,从"扫描所有 fd 找谁就绪",变成了"遍历 epoll_wait 返回的就绪事件"。
select / poll / epoll 放一起看
三个接口放到一起对比更清楚:
| select | poll | epoll | |
|---|---|---|---|
| 关注集合的表达 | fd_set 位图 |
pollfd 数组 |
内核维护,用 epoll_ctl 增删改 |
| 数量上限 | 受 FD_SETSIZE 限制(Linux/glibc 通常 1024) |
无此固定限制,取决于内存和 fd 上限 | 无此固定限制 |
| 每轮是否重新递交集合 | 是,且返回后被改写 | 是 | 否,注册一次长期有效 |
| 怎么拿到就绪 fd | 遍历全部被监控的 fd | 遍历整个数组 | epoll_wait 直接返回就绪事件 |
| 就绪判定的主要成本 | O(N),N 是被监控 fd 总数 | O(N) | 与这一轮就绪的数量相关,与 N 基本无关 |
| 可移植性 | POSIX,跨平台 | POSIX,跨平台 | Linux 专有 |
顺便把开头那句"epoll 是 O(1)"说清楚。准确一点的表达应该是:
epoll_wait拿到就绪事件的成本,和你监控了多少个 fd 基本无关;而select/poll每轮都要把全部 fd 过一遍。
差别不在常数大小,而在于扫描对象从"全部 fd"变成了"就绪事件"。
这也解释了为什么 epoll 的优势要在"连接多、活跃少"的场景才明显------如果每轮几乎所有 fd 都活跃,那应用本来就要处理大量 I/O,事件通知本身只占总成本的一小部分。
epoll 内部可以怎么理解
不追具体内核版本源码,先建立一个够用的模型。一个 epoll 实例至少要解决两个问题:
1. 我关心哪些 fd
用户通过 epoll_ctl(...) 注册:
text
fd A → EPOLLIN
fd B → EPOLLOUT
fd C → EPOLLIN | EPOLLET
内核得维护这份"关注关系"。
2. 当前哪些 fd 已经就绪
当某个 fd 状态变化,内核需要让 epoll 实例知道:
这个 fd 现在有你关心的事件
epoll_wait 返回的就是当前就绪事件的集合。
所以理解 epoll 时,不必把重点放在背一句"红黑树 + 就绪链表"。真正要分清的是两个角色:
- interest set:长期关注谁
- ready set:当前谁有事件
这比单纯记内部数据结构更容易接到编程模型上。
不同内核版本的具体实现会有差异,上面只是帮助理解的逻辑模型,不是源码结构。
监听 fd 可读以后为什么要 accept
主循环里:
cpp
if (fd == listenfd)
{
int clientfd = accept(listenfd, ...);
}
因为监听 socket 上的 EPOLLIN 意味着:"有连接可取",而不是"收到普通数据"。
accept 拿到新 clientfd 后,还要把它注册进 epoll:
cpp
struct epoll_event clientEv{};
clientEv.events = EPOLLIN;
clientEv.data.fd = clientfd;
epoll_ctl(
epfd,
EPOLL_CTL_ADD,
clientfd,
&clientEv
);
这样后续客户端数据到达时,epoll_wait 才会通知这个 clientfd。
一个最小 LT epoll 服务器

这张图对应下面这份最小实现,重点看主循环:epoll_wait 返回之后,只处理返回数组里的那 n 个事件。
先写 Level Triggered,也就是 LT,它是 epoll 的默认模式。
cpp
#include <sys/epoll.h>
#include <sys/socket.h>
#include <unistd.h>
#include <fcntl.h>
#include <cerrno>
#include <cstring>
void loop(int listenfd)
{
int epfd = epoll_create1(0);
epoll_event ev{};
ev.events = EPOLLIN;
ev.data.fd = listenfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev);
epoll_event events[1024];
while (true)
{
int n = epoll_wait(epfd, events, 1024, -1);
for (int i = 0; i < n; ++i)
{
int fd = events[i].data.fd;
if (fd == listenfd)
{
int clientfd = accept(listenfd, nullptr, nullptr);
epoll_event cev{};
cev.events = EPOLLIN;
cev.data.fd = clientfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, clientfd, &cev);
}
else if (events[i].events & EPOLLIN)
{
char buf[4096];
ssize_t len = read(fd, buf, sizeof(buf));
if (len > 0)
{
write(fd, buf, len);
}
else if (len == 0)
{
// 对端关闭,读到了 EOF
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, nullptr);
close(fd);
}
else
{
if (errno == EINTR)
{
// 被信号打断,不是错误。
// LT 模式下下一轮还会再通知,这轮跳过即可
continue;
}
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, nullptr);
close(fd);
}
}
}
}
}
这里有个容易写错的地方:read 返回 -1 时不能直接当连接出错处理,得先判断 errno == EINTR。被信号打断是正常现象,直接 close 会把一个好连接关掉。上面代码里单独处理了。
这份代码只是为了看模型,里面的 fd 还是阻塞的,真实网络程序还要处理:
- 各种错误码
- 半包 / 粘包
- 部分写(
write返回值小于期望长度) - 非阻塞 fd
- 信号
- 连接关闭与对象生命周期
- 事件组合(
EPOLLIN | EPOLLOUT | EPOLLERR | EPOLLHUP)
三、LT、ET 与非阻塞 I/O

这张图对比的就是 LT 和 ET 的提醒时机。看的时候注意区分:LT 看的是"条件是否成立",ET 看的是"状态是否变化"。
LT:只要"条件还成立",就会继续提醒
LT 可以理解成:
text
fd 现在仍然可读?
是 → epoll_wait 还会继续告诉你
假设 socket 接收缓冲区有 1000 字节,你只读了 100 字节,还剩 900 字节。
下一轮 epoll_wait 仍然可能返回这个 fd,因为它还是"可读"的。
所以 LT 的容错相对高:一次没读完,后面还有机会继续处理。这也是为什么刚上手时先用 LT 更稳。
ET:更像"状态发生变化时提醒你一下"
ET 需要显式注册:
cpp
ev.events = EPOLLIN | EPOLLET;
可以近似理解成:
text
从"没数据"变成"有数据"
→ 通知一次
这里有个地方容易想岔: 不是说缓冲区里剩着数据就永远不通知了。如果之后又有新数据到达,还是会产生一次新的通知。区别在于,你不能指望 epoll 因为你"没读完"就主动再提醒一次------它提醒的是"发生了新事件",不是"你还有活没干完"。
所以 ET 的经典写法是:
text
收到可读事件
↓
不停 read
↓
直到 read 返回 -1 且 errno == EAGAIN / EWOULDBLOCK
ET 下最危险的错误,就是收到一次事件只 read 一次,然后以为剩余数据以后还会自动再提醒。
LT 和 ET 的直接对比
| LT(水平触发,默认) | ET(边沿触发) | |
|---|---|---|
| 通知依据 | 条件成立就通知 | 状态发生变化才通知 |
| 一次没读完 | 下一轮还会再通知 | 不会再通知,除非有新事件 |
| 对阻塞 fd 的容忍度 | 相对高(但依然不推荐) | 极低,必须非阻塞 |
| 代码复杂度 | 低,适合入门 | 高,读写都要 drain |
| 是否需要读到 EAGAIN | 不强制 | 强制 |
| 典型使用场景 | 连接数不大、追求稳妥 | 高并发、配合非阻塞 Reactor |
为什么 ET 强烈要求非阻塞 fd
假设 ET 里这样写:
cpp
while (true)
{
int n = read(fd, buf, sizeof(buf));
if (n > 0)
handle(buf, n);
}
如果 fd 是阻塞的:
text
第一次 read → 有数据
第二次 read → 还有数据
第三次 read → 暂时没数据 → 阻塞在这里
第三次直接把整个事件循环卡死了。
所以先设非阻塞:
cpp
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
然后配合循环读:
cpp
while (true)
{
char buf[4096];
ssize_t n = read(fd, buf, sizeof(buf));
if (n > 0)
{
handle(buf, n);
}
else if (n == 0)
{
// 对端关闭写方向,读到 EOF
closeConnection(fd);
break;
}
else
{
if (errno == EAGAIN || errno == EWOULDBLOCK)
{
// 当前数据已经读干净
break;
}
if (errno == EINTR)
{
continue;
}
closeConnection(fd);
break;
}
}
这几个 errno 的含义要分清:
| 返回值 / errno | 含义 | ET 下该怎么做 |
|---|---|---|
n > 0 |
读到了 n 字节 | 处理,然后继续循环读 |
n == 0 |
对端关闭,读到 EOF | 关闭连接,退出循环 |
n < 0 且 EAGAIN / EWOULDBLOCK |
当前非阻塞下无数据可读 | 正常退出循环,不是错误 |
n < 0 且 EINTR |
被信号打断 | continue 重试 |
n < 0 且其他 |
真正的错误 | 关闭连接 |
在 ET 模式里,EAGAIN 不是"读取失败",它是在告诉你:当前可立即读取的数据已经被你处理干净了。
这和 Linux epoll(7) 文档强调的行为一致:使用 EPOLLET 时应该用非阻塞 fd,并且只在 read/write 返回 EAGAIN 之后再等待下一次事件。
监听 socket 在 ET 下也要循环 accept
很多人只记得客户端 fd 要读到 EAGAIN,却忘了监听 fd。
假设一瞬间有多个连接进入 backlog,而 ET 只通知一次。如果只写:
cpp
int clientfd = accept(listenfd, ...);
拿一个就结束,队列里可能还剩连接。更合适的是:
cpp
while (true)
{
int clientfd = accept4(
listenfd,
nullptr,
nullptr,
SOCK_NONBLOCK
);
if (clientfd >= 0)
{
addToEpoll(clientfd);
continue;
}
if (errno == EAGAIN || errno == EWOULDBLOCK)
break;
if (errno == EINTR)
continue;
break;
}
accept4是 Linux 专有接口(glibc 2.10+,编译时需要_GNU_SOURCE),好处是拿到的 fd 直接就是非阻塞的,省一次fcntl。
核心还是同一个原则:
把当前已经准备好的东西处理到"暂时没有"为止。
无论是 read 还是 accept,ET 下的处理方式是一样的。
四、写事件、缓冲区与协议处理
EPOLLOUT 为什么不能一直注册

这张图看的是发送缓冲区的状态:发送缓冲区有空闲空间时,socket 通常长期处于"可写"状态 ,这就是 EPOLLOUT 满天飞的原因。
很多初学代码给所有 clientfd 注册:
cpp
EPOLLIN | EPOLLOUT
然后发现 epoll_wait 不停返回可写事件。原因是:TCP socket 只要发送缓冲区有空间,就基本一直是可写的。没数据要发却一直关注 EPOLLOUT,事件循环会被大量无意义通知占满,CPU 空转。
更常见的做法是:
- 默认只关注
EPOLLIN - 当
write/send没写完、遇到EAGAIN时,把剩余数据放进发送缓冲区,再注册EPOLLOUT - 可写时继续发,全部发完就取消
EPOLLOUT
EPOLLOUT 更适合按需开启,而不是所有连接永久监听。
顺带一提,ET 模式下 EPOLLOUT 的处理原则和读是对称的:可写之后也要一直写,直到写完或者 send 返回 EAGAIN,不能写一次就撒手。
非阻塞 write 为什么会"只写一部分"
假设要发 100KB:
cpp
send(fd, data, 100 * 1024, 0);
返回值可能不是 100KB,比如返回 16384,意思是当前只成功写入了一部分。
剩下的数据不能直接丢,所以要给每个连接维护一个输出缓冲区:
cpp
struct Connection
{
int fd;
std::string inBuffer;
std::string outBuffer;
};
发送逻辑变成:
text
能写多少写多少
↓
写不完
↓
剩余放 outBuffer
↓
关注 EPOLLOUT
↓
下次可写继续发
网络服务器的复杂度,基本就是从这里开始明显上升的。
epoll 只告诉你"可以 I/O",不会替你处理协议
假设 TCP 先收到:
text
GET /api/u
下一次才收到:
text
sers HTTP/1.1\r\n...
epoll 不知道这个 HTTP 请求有没有完整。它只知道:
socket 现在有数据可以读
所以"消息边界"这件事得应用层自己解决,还需要:
- 输入缓冲区
- 协议解析器
- 消息边界判断(长度字段 / 分隔符 / 固定长度)
- 业务处理
- 输出缓冲区
这也是为什么真正的网络库都会维护一个 Connection 对象,而不是直接在事件循环里 read 完就 write。
五、从 epoll 到 Reactor 与线程模型
从 epoll 到 Reactor

这张图的重点是分层:EventLoop 只管分发事件,业务处理在下面各层。
现在主循环已经变成:
text
epoll_wait
↓
拿到事件
↓
根据 fd 找到对应对象
↓
根据事件类型调用对应处理函数
这已经非常接近 Reactor 了。可以把每个 fd 封装成一个 Channel:
cpp
class Channel
{
public:
void handleEvent();
void setReadCallback(std::function<void()> cb);
void setWriteCallback(std::function<void()> cb);
void setCloseCallback(std::function<void()> cb);
private:
int _fd;
uint32_t _events;
std::function<void()> _readCallback;
std::function<void()> _writeCallback;
std::function<void()> _closeCallback;
};
epoll 返回:
text
fd=10, EPOLLIN
EventLoop 找到:
text
Channel(fd=10)
然后:
cpp
channel->handleEvent();
再进入:
cpp
_readCallback();
这样网络事件和具体业务就解耦了。
Reactor 不是 epoll 的另一个名字。epoll 是 Linux 提供的事件等待机制,Reactor 是程序如何围绕事件组织对象、回调和业务处理的一种架构方式。
一个简化 Reactor 结构
可以画成这样:
text
┌───────────────┐
│ EventLoop │
│ epoll_wait() │
└──────┬────────┘
↓
Active Events
↓
┌──────┴──────┐
│ Channel │
└──────┬──────┘
┌─────────────┼─────────────┐
↓ ↓ ↓
onRead onWrite onClose
↓
Connection
↓
Protocol
↓
Business
EventLoop 不需要知道:
- 这是聊天业务
- 这是 HTTP
- 这是游戏服务器
它只负责事件分发。这是 Reactor 最核心的价值:把"什么时候处理"和"怎么处理"分开。
单 Reactor 单线程能不能做业务
可以,但取决于业务耗不耗时。
假设 EventLoop 收到请求后直接:
cpp
queryBigDatabase();
runHeavyCalculation();
耗时 2 秒。这 2 秒里 EventLoop 无法及时处理其他 fd,所有连接的响应都会跟着抖。
所以 Reactor 最怕的是:
事件循环线程里做长时间阻塞任务
常见拆法
一种是把业务丢给线程池:
text
I/O 线程
↓
解析请求
↓
投递到业务线程池
↓
业务完成
↓
把响应交回 I/O 线程
↓
发送
另一种是拆成多 Reactor:
text
Main Reactor
负责 accept
↓
Sub Reactor 1
Sub Reactor 2
Sub Reactor 3
负责连接 I/O
具体选哪种要看业务规模,不是线程越多越高级。单 Reactor 单线程在很多内部服务里已经完全够用。
六、epoll 的边界、常见坑与连接状态机
epoll 并不意味着"所有场景都比 poll 快"
如果总共只有 5 个 fd,select/poll 和 epoll 的实际差距大概率不是项目瓶颈。
epoll 的优势更容易在这些条件下体现:
- 大量连接
- 活跃连接只占一小部分
- 事件长期注册、反复等待
如果每轮几乎所有 fd 都活跃,那应用本来就要处理大量 I/O,事件通知本身只是总成本的一部分。
所以别把 epoll 神化成"用了就自动百万并发"。连接规模同时还受这些限制:
- fd 数量限制(
ulimit -n和系统级上限) - 内存
- socket 收发缓冲区大小
- 网卡带宽和 PPS
- 协议处理效率
- 业务线程数
- 数据库等下游依赖
- 内核参数
想看当前 shell 的 fd 上限,可以直接查:
bash
ulimit -n
几个非常容易写错的地方
这些都是写 epoll 时高频踩的坑,整理成表方便对照:
| 坑 | 会发生什么 | 正确做法 |
|---|---|---|
只读 EPOLLIN |
连接出错 / 对端异常断开时收不到通知 | 一并处理 EPOLLERR、EPOLLHUP |
忽略 read() == 0 |
对端已关闭,连接却还留在 epoll 里空转 | 收到 0 就走关闭流程 |
把 EINTR 当错误 |
被信号打断就误关连接 | 判断 errno == EINTR 后重试 |
ET 不读到 EAGAIN |
数据长期留在 socket buffer,却没有新的边沿通知 | 循环读到 EAGAIN 为止 |
| 在事件循环里做阻塞业务 | 一个慢查询让所有连接响应一起抖 | 业务丢线程池,I/O 线程只做 I/O |
| fd 关闭后对象没清理 | 悬垂指针、事件仍指向已关闭的 fd | 从 epoll 和映射表里同步移除 |
EPOLLONESHOT 是干什么的
多线程同时处理同一个 fd 时,可能出现:
- 线程 A 正在处理 fd=10
- 线程 B 又拿到了 fd=10 的事件
EPOLLONESHOT 让事件触发一次后暂时失效,处理完再通过 epoll_ctl MOD 重新激活:
cpp
ev.events = EPOLLIN | EPOLLONESHOT;
处理完重新武装:
cpp
epoll_ctl(epfd, EPOLL_CTL_MOD, fd, &ev);
注意:用了 EPOLLONESHOT 之后如果忘记重新 MOD,这个 fd 就再也收不到事件了。
它不是所有程序都必须用,但在多线程 Reactor 设计里很有价值,常和 EPOLLET 一起出现。
accept 惊群是什么思路上的问题
如果多个线程/进程同时等待同一个监听 socket,一个新连接到来时,如何避免不必要地唤醒大量等待者,是高并发服务器必须考虑的问题。
Linux 内核 4.5 之后提供了 EPOLLEXCLUSIVE,用于多个 epoll 实例监听同一个 fd 的场景:有事件时只唤醒其中一个(文档措辞是 "one or more",并不严格保证只有一个)。
这里最重要的不是背具体 flag,而是理解一件事:
- 并发越高,"唤醒谁"本身也会变成成本
所以事件系统除了"通知就绪",还要考虑并发调度。
为什么高并发服务器经常强调状态机
非阻塞 I/O 下,一个请求不会在一次函数调用里全部完成。例如:
- 状态 1:读取请求头
- 状态 2:读取 body
- 状态 3:处理业务
- 状态 4:准备响应
- 状态 5:发送响应
- 状态 6:等待继续写
一次 EPOLLIN 可能只推进一部分,一次 EPOLLOUT 又推进另一部分。所以每个 Connection 实际上都在维护自己的协议状态。
这也是事件驱动编程和传统"一个线程从头跑到尾"最大的思维差异之一:代码不再是一个线性流程,而是被切成一段段由事件驱动的状态转移。
七、把高并发网络模型完整串起来
从一个客户端到大量连接,模型是怎样一步步变的
把前面的演进收一下。
阻塞单线程
text
accept → read → write
问题:一个连接阻塞,所有连接都等着。
一连接一线程
text
accept
├→ thread A
├→ thread B
└→ thread C
问题:大量长连接带来线程资源和调度成本。
select / poll
text
一个线程等待多个 fd
问题:fd 多了以后仍要频繁传递集合、扫描全部 fd。
epoll
text
注册关注 fd → 等待就绪事件 → 只处理返回的事件
Reactor
text
EventLoop
↓
Event Demultiplexer (epoll)
↓
Channel / Handler
↓
Connection / Business
到这里,一条高并发网络服务器的基本主线就接起来了。
如果自己练 epoll,建议按什么顺序写
不要第一份代码就上"ET + 多线程 + Reactor + 定时器 + HTTP",那样很容易连 bug 在哪都分不清。
- 阻塞 echo server ------ 先把 socket 那一套跑通。
- select echo server ------ 真正体会"一个线程等多个 fd"是什么感觉。
- epoll LT ------ 先保证连接的建立、读取、关闭都正确。
- 全部 fd 改非阻塞 ------ 处理
EAGAIN、EINTR、部分写。 - 切 ET ------ 验证
read和accept是不是都 drain 到了EAGAIN。 - 封装 Connection / Channel / EventLoop ------ 最后自然形成 Reactor。
这样每一步都知道自己为什么要改,而不是照着一份"最终版"抄完却不知道哪行是干嘛的。
总结
从阻塞 I/O 到 select/poll,再到 epoll,真正变化的是程序等待和管理大量 fd 的方式。
epoll 把"长期关注哪些 fd"和"这一次哪些 fd 已经就绪"拆开了,应用循环主要处理 epoll_wait 返回的事件,不再每轮从头扫描全部连接。
LT 和 ET 决定的是提醒方式:LT 在条件仍成立时会继续提醒,ET 更强调状态变化后的主动处理。
ET 不是"更高级就一定更快"的开关。用 ET 时,fd 通常要配合非阻塞,并把 accept/read/write 的可处理数据推进到 EAGAIN。
再往上,Reactor 解决的是程序结构:EventLoop 等事件,Channel/Connection 保存连接状态,业务逻辑只处理已经被分发到自己的事件。
epoll 负责高效告诉你"谁现在有事",而非阻塞 I/O、缓冲区、状态机和对象生命周期,决定"这件事能不能被正确处理完"。
所以练 epoll 时,与其一直背接口,更值得反复检查三个细节:
- 可读以后,是否读干净了
- 可写以后,是否处理了部分写
- 连接关闭后,状态和对象是否同步释放
如果你在写 epoll 的时候踩过别的坑,欢迎在评论区补充,我再整理进这篇里。
资源分享:
【Vue3 + TypeScript】接口报错别再到处 try/catch:从请求封装、错误分类到全局兜底,搭一套可维护的错误处理体系