第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的工作流程串起来。你一看就明白。
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等高级网络库的基础。你把这个吃透了,后面看那些源码会轻松很多。