第26章 Reactor模式:从原理到epoll落地

Reactor模式,说白了就是网络编程里的「事件分发器」。你想想看,一个服务器要同时处理成千上万个连接,总不能每个连接开一个线程吧?那资源消耗太吓人了。Reactor的思路很简单——我等着,谁有事件来了,我就处理谁。

我在早期做高并发项目时,一开始用的是多线程阻塞模型。结果连接数一上千,线程切换开销直接让CPU飙到90%。后来换成Reactor模式,同样的机器,扛住了上万连接。嗯,这就是架构设计的力量。

26.1 Reactor设计模式原理

Reactor模式的核心思想就三个字:事件驱动。它把网络IO操作抽象成事件,然后由一个中央分发器来调度。

核心角色:

  • Reactor:事件循环,负责监听和分发事件
  • Handler:事件处理器,处理具体的IO操作
  • Event:事件类型,比如可读、可写、异常

我习惯把Reactor比作一个前台接待员。客户来了(事件发生),接待员喊一声「谁负责这个客户?」(事件分发),对应的业务员就过来处理(Handler执行)。

Reactor模式有两种变体:

模式 特点 适用场景
单Reactor单线程 一个线程跑事件循环,处理所有IO Redis、轻量级服务
单Reactor多线程 事件循环一个线程,业务处理交给线程池 大多数Web服务器
多Reactor多线程 多个Reactor实例,每个绑核运行 Netty、高性能网关

为什么会这样设计?因为IO操作和业务处理是两码事。IO是CPU密集型的轮询,业务可能是阻塞的数据库查询。把它们拆开,才能充分利用多核。

我的经验:单Reactor单线程模式虽然简单,但一旦Handler里做了耗时操作,整个事件循环就卡住了。我曾经在一个网关项目里踩过这个坑——一个慢查询导致所有连接超时。后来改成多线程Handler,问题解决。

26.2 基于epoll的Reactor框架实现

epoll是Linux下最高效的IO多路复用机制。它解决了select/poll的两个痛点:

  • 不用每次调用都拷贝全量fd集合
  • 不用遍历所有fd来找就绪事件

说白了,epoll就是「谁有事谁说话」,而不是「我挨个问一遍」。这在万级连接下性能差距巨大。

下面我给出一个精简但完整的Reactor框架实现。这个框架我在多个项目里用过,结构清晰,适合二次开发。

26.2.1 核心数据结构

// 事件处理器接口
typedef void (*event_callback)(int fd, void *arg);

// 事件结构体
typedef struct {
    int fd;                 // 文件描述符
    uint32_t events;        // 监听的事件类型 (EPOLLIN/EPOLLOUT)
    event_callback read_cb; // 可读回调
    event_callback write_cb;// 可写回调
    void *arg;              // 用户自定义参数
} event_t;

// Reactor结构体
typedef struct {
    int epfd;               // epoll实例
    event_t *events;        // 事件数组
    int max_events;         // 最大事件数
    int running;            // 运行标志
} reactor_t;

这里有个设计细节:我把回调函数和fd绑定在一起。这样事件触发时,直接调用对应的回调,不需要再查表。我在早期版本里用过哈希表来映射fd到回调,后来发现数组加下标更快——毕竟fd是连续的小整数。

26.2.2 初始化与事件注册

// 创建Reactor
reactor_t* reactor_create(int max_events) {
    reactor_t *r = (reactor_t*)malloc(sizeof(reactor_t));
    r->epfd = epoll_create(1);
    r->events = (event_t*)calloc(max_events, sizeof(event_t));
    r->max_events = max_events;
    r->running = 1;
    return r;
}

// 注册事件
int reactor_add_event(reactor_t *r, int fd, uint32_t events,
                      event_callback read_cb, event_callback write_cb, void *arg) {
    struct epoll_event ev;
    ev.events = events;
    ev.data.fd = fd;

    if (epoll_ctl(r->epfd, EPOLL_CTL_ADD, fd, &ev) == -1) {
        return -1;
    }

    // 保存回调信息
    r->events[fd].fd = fd;
    r->events[fd].read_cb = read_cb;
    r->events[fd].write_cb = write_cb;
    r->events[fd].arg = arg;
    return 0;
}

注意:epoll_create的参数在Linux 2.6.8之后被忽略,但必须大于0。我习惯传1,表示「我不关心这个值」。另外,epoll_ctl的EPOLL_CTL_ADD如果重复添加同一个fd,会返回EEXIST错误。记得先判断一下。

26.2.3 事件循环核心

// 事件循环
void reactor_loop(reactor_t *r) {
    struct epoll_event *events = (struct epoll_event*)
        malloc(sizeof(struct epoll_event) * r->max_events);

    while (r->running) {
        int nfds = epoll_wait(r->epfd, events, r->max_events, -1);
        if (nfds == -1) {
            if (errno == EINTR) continue;  // 被信号中断,继续
            break;
        }

        for (int i = 0; i < nfds; i++) {
            int fd = events[i].data.fd;
            event_t *ev = &r->events[fd];

            // 可读事件
            if (events[i].events & EPOLLIN) {
                if (ev->read_cb) {
                    ev->read_cb(fd, ev->arg);
                }
            }

            // 可写事件
            if (events[i].events & EPOLLOUT) {
                if (ev->write_cb) {
                    ev->write_cb(fd, ev->arg);
                }
            }

            // 错误或挂断
            if (events[i].events & (EPOLLERR | EPOLLHUP)) {
                // 清理资源
                close(fd);
                epoll_ctl(r->epfd, EPOLL_CTL_DEL, fd, NULL);
            }
        }
    }

    free(events);
}

这段代码我写了很多遍。有几个细节你一定要注意:

  • EINTR处理:epoll_wait被信号中断时,errno会设为EINTR。如果不处理,程序会意外退出。我曾经在调试时被SIGINT打断,结果服务直接挂了,排查了半天才发现是这个问题。
  • EPOLLERR和EPOLLHUP:这两个事件不需要显式注册,epoll会自动上报。如果连接异常断开,你不处理的话,fd会一直留在epoll里,造成资源泄漏。
  • 边缘触发 vs 水平触发:我习惯用水平触发(默认),代码简单,不容易丢事件。边缘触发虽然性能更好,但需要循环读取直到EAGAIN,逻辑复杂不少。

26.2.4 完整的Reactor流程图

下面我用SVG画一张图,把整个Reactor的工作流程串起来。你一看就明白。

Reactor模式工作流程 Reactor 事件循环 epoll_wait 等待事件 有事件? 无事件,继续循环 遍历就绪事件列表 调用对应回调函数 关键说明 1. epoll_wait 是阻塞调用 2. 返回就绪事件数量 nfds 3. 通过 data.fd 找到对应事件 4. 根据事件类型调用回调 5. 处理完继续下一轮循环 注意: 回调中不要做耗时操作 否则会阻塞事件循环

26.2.5 使用示例

// 一个简单的echo服务器
void on_accept(int fd, void *arg) {
    reactor_t *r = (reactor_t*)arg;
    struct sockaddr_in client;
    socklen_t len = sizeof(client);
    int client_fd = accept(fd, (struct sockaddr*)&client, &len);
    if (client_fd > 0) {
        // 注册客户端可读事件
        reactor_add_event(r, client_fd, EPOLLIN, on_read, NULL, NULL);
    }
}

void on_read(int fd, void *arg) {
    char buf[1024];
    int n = read(fd, buf, sizeof(buf));
    if (n > 0) {
        write(fd, buf, n);  // echo回去
    } else {
        close(fd);  // 连接关闭
    }
}

int main() {
    // 创建监听socket
    int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    // ... bind, listen ...

    reactor_t *r = reactor_create(1024);
    reactor_add_event(r, listen_fd, EPOLLIN, on_accept, NULL, r);
    reactor_loop(r);

    return 0;
}

避坑指南:我曾经在on_read里直接调用write,没考虑写缓冲区满的情况。结果客户端收得慢,服务端write阻塞,整个Reactor卡死了。后来我改成注册EPOLLOUT事件,在可写回调里发送数据。记住——不要在回调里做任何可能阻塞的操作

26.3 性能优化要点

Reactor框架搭好了,但性能好不好,还得看细节。我总结几个关键点:

  • epoll使用ET模式:边缘触发可以减少epoll_wait的调用次数,但需要配合非阻塞IO和循环读取。我建议新手先用LT,稳定后再切ET。
  • 避免内存分配:事件循环里频繁malloc/free会导致性能抖动。我习惯用内存池或者预分配缓冲区。
  • 合理设置max_events:这个值不是越大越好。一般设为连接数的1.5倍左右,避免epoll_wait返回太多事件导致遍历开销。
  • 使用epoll的EPOLLONESHOT:对于多线程Reactor,这个标志可以保证一个事件只被一个线程处理,避免竞态。

嗯,Reactor模式就讲到这里。这个框架虽然简单,但它是理解Netty、libevent等高级网络库的基础。你把这个吃透了,后面看那些源码会轻松很多。

公众号:蓝海资料掘金营,微信 deep3321