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 模式 vs ET 模式:事件触发流程对比 LT 模式:水平触发 数据到达缓冲区 epoll_wait 返回该 fd 只读了部分数据 下次 epoll_wait 再次返回 ET 模式:边缘触发 数据到达缓冲区 epoll_wait 返回该 fd(仅一次) 只读了部分数据 剩余数据「沉默」,不再通知 LT 模式:只要还有数据,就会持续通知 ET 模式:只在状态变化时通知一次,必须一次性读完

从这张图可以看得很清楚:LT 模式像一位耐心的老师,你没学会它就反复讲;ET 模式像一位严格的教练,只示范一次,你必须一次做对。

嗯,LT 和 ET 的选择没有绝对的对错。关键看你的场景和团队能力。如果团队经验丰富,追求极致性能,ET 是不错的选择。如果求稳,LT 更靠谱。

我个人建议:先从 LT 开始,把业务逻辑跑通。性能瓶颈出现时,再针对热点连接切换到 ET。不要一开始就「全盘 ET」。