3. Reactor与Proactor模式
上一节我们把 epoll 的创建、事件注册、等待循环讲透了。今天聊一个更核心的话题——LT 和 ET 模式。
说实话,我见过不少新手甚至工作两三年的同事,在这上面栽跟头。我自己早期做网关项目时,也踩过 ET 模式的坑,差点导致线上丢包。今天咱们就把这两种模式掰开揉碎,讲清楚。
3.1 LT 模式:水平触发
LT 是 epoll 的默认模式。全称 Level Triggered,翻译过来就是「水平触发」。
什么意思呢?
只要文件描述符上有数据没读完,epoll_wait 就会一直通知你。就像水龙头没关紧,一直在滴水,操作系统就一直在提醒你:「还有水!还有水!」
举个例子:
// LT 模式下,读缓冲区有 100 字节
// 你只读了 50 字节
// 下次 epoll_wait 还会返回这个 fd
// 直到你把剩下的 50 字节读完
LT 模式的好处很明显——不容易丢数据。你漏读了,操作系统会再通知你一次。容错性高,编程简单。
我个人习惯在大多数业务场景下用 LT。尤其是当你的业务逻辑比较复杂,或者处理线程可能被其他任务打断时,LT 能给你兜底。
3.2 ET 模式:边缘触发
ET 模式就完全不一样了。Edge Triggered,边缘触发。
它只在状态发生变化时通知你一次。从「没有数据」变成「有数据」的那一刻,触发一次。之后哪怕缓冲区里还有数据没读完,也不会再通知。
还是那个水龙头的比喻:ET 模式只在你第一次拧开水龙头时提醒你。之后水一直流,它就不管了。你必须一次性把水接完,否则就浪费了。
// ET 模式下,读缓冲区有 100 字节
// 你只读了 50 字节
// 下次 epoll_wait 不会再返回这个 fd
// 剩下的 50 字节就「沉默」了
ET 模式的优点是什么?
高效。减少了系统调用次数,事件通知更精准。在高并发场景下,ET 模式能显著降低 CPU 开销。
但代价也很明显——你必须保证每次都能把数据读完。否则就会丢数据。
3.3 LT vs ET:核心对比
| 对比维度 | LT 模式 | ET 模式 |
|---|---|---|
| 触发条件 | 只要缓冲区有数据 | 仅状态变化时 |
| 通知次数 | 可能多次通知 | 只通知一次 |
| 编程难度 | 低,容错性好 | 高,必须读完 |
| 系统调用次数 | 相对较多 | 较少 |
| 适用场景 | 业务逻辑复杂、低并发 | 高并发、高性能场景 |
| 数据丢失风险 | 低 | 高(处理不当) |
核心结论:LT 模式适合大多数业务场景,ET 模式适合追求极致性能的场景。不要为了「炫技」而用 ET,除非你清楚自己在做什么。
3.4 边缘触发的坑:我曾经踩过的雷
ET 模式最大的坑,就是「读不干净」。
我曾经做一个即时通讯网关,用的 ET 模式。上线第一天,发现部分消息丢失了。查了半天,问题出在 read 操作上。
ET 模式下,你必须用循环读,直到 read 返回 EAGAIN。但有个细节——每次 read 的缓冲区大小怎么定?
如果设得太小,循环次数太多,性能下降。
如果设得太大,浪费内存。
更隐蔽的问题是:如果你在读循环中处理业务逻辑,可能会阻塞,导致其他 fd 得不到及时处理。
ET 模式避坑指南:
- 读操作必须用 while 循环,直到 read 返回 EAGAIN
- 写操作同理,必须循环 write 直到 EAGAIN
- 建议将 fd 设置为非阻塞模式,否则 read/write 可能阻塞住整个线程
- 不要在 ET 模式的读循环里做耗时操作,否则会饿死其他连接
还有一个坑——accept 操作。ET 模式下,如果有新连接到来,epoll_wait 只会通知一次。如果你只 accept 了一个连接,但实际来了多个,剩下的连接就「悬空」了。
解决办法:accept 也要用 while 循环,直到返回 EAGAIN。
// ET 模式下正确的 accept 写法
while (1) {
conn_fd = accept(listen_fd, (struct sockaddr *)&client_addr, &addr_len);
if (conn_fd == -1) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
break; // 没有新连接了
} else {
// 真正的错误
break;
}
}
// 处理新连接
set_nonblocking(conn_fd);
epoll_add_event(epoll_fd, conn_fd, EPOLLIN | EPOLLET);
}
3.5 实战:用 epoll 实现一个简易 Reactor
理论讲完了,咱们动手写个东西。用 epoll 实现一个简易的 Reactor 模式。
Reactor 的核心思想:把事件分发和处理逻辑分离。epoll 负责事件监听,回调函数负责具体处理。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <fcntl.h>
#include <errno.h>
#define MAX_EVENTS 1024
#define BUFFER_SIZE 4096
// 事件处理函数指针类型
typedef void (*event_handler)(int fd, void *arg);
// Reactor 结构体
typedef struct {
int epoll_fd;
struct epoll_event events[MAX_EVENTS];
event_handler handlers[MAX_EVENTS];
void *args[MAX_EVENTS];
} Reactor;
// 初始化 Reactor
Reactor* reactor_create() {
Reactor *r = (Reactor *)malloc(sizeof(Reactor));
r->epoll_fd = epoll_create1(0);
memset(r->events, 0, sizeof(r->events));
memset(r->handlers, 0, sizeof(r->handlers));
memset(r->args, 0, sizeof(r->args));
return r;
}
// 注册事件和对应的处理函数
void reactor_register(Reactor *r, int fd, uint32_t events,
event_handler handler, void *arg) {
struct epoll_event ev;
ev.events = events;
ev.data.fd = fd;
epoll_ctl(r->epoll_fd, EPOLL_CTL_ADD, fd, &ev);
r->handlers[fd] = handler;
r->args[fd] = arg;
}
// 事件循环
void reactor_loop(Reactor *r) {
while (1) {
int n = epoll_wait(r->epoll_fd, r->events, MAX_EVENTS, -1);
for (int i = 0; i < n; i++) {
int fd = r->events[i].data.fd;
if (r->handlers[fd]) {
r->handlers[fd](fd, r->args[fd]);
}
}
}
}
// 示例:读事件处理函数
void handle_read(int fd, void *arg) {
char buf[BUFFER_SIZE];
int n = read(fd, buf, sizeof(buf) - 1);
if (n > 0) {
buf[n] = '\0';
printf("收到数据: %s\n", buf);
} else if (n == 0) {
close(fd);
printf("连接关闭: %d\n", fd);
}
}
int main() {
// 创建监听 socket
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
// ... 绑定、监听等代码省略 ...
Reactor *r = reactor_create();
reactor_register(r, listen_fd, EPOLLIN, handle_read, NULL);
reactor_loop(r);
return 0;
}
这个例子虽然简单,但骨架是完整的。实际生产环境中,你还需要考虑线程安全、内存管理、超时处理等。
我的建议:刚开始用 epoll 做 Reactor 时,先用 LT 模式。等业务稳定了,再考虑切换到 ET 模式做性能优化。不要一上来就追求「极致性能」,稳定第一。
3.6 一张图看懂 LT 与 ET
下面这张图展示了 LT 和 ET 在数据到达时的行为差异:
从这张图可以看得很清楚:LT 模式像一位耐心的老师,你没学会它就反复讲;ET 模式像一位严格的教练,只示范一次,你必须一次做对。
嗯,LT 和 ET 的选择没有绝对的对错。关键看你的场景和团队能力。如果团队经验丰富,追求极致性能,ET 是不错的选择。如果求稳,LT 更靠谱。
我个人建议:先从 LT 开始,把业务逻辑跑通。性能瓶颈出现时,再针对热点连接切换到 ET。不要一开始就「全盘 ET」。