4、Reactor模式:基于epoll的事件驱动模型,手写简易Reactor
好,咱们今天来聊聊Reactor模式。说实话,这个模式在网络编程里太重要了。你想想看,一个服务器要同时处理成千上万个连接,总不能每个连接开一个线程吧?那资源消耗太吓人了。Reactor模式就是解决这个问题的经典方案。
我个人习惯把Reactor理解成一个「事件分发中心」。它就像一个大堂经理,专门负责监听各种事件——谁发数据过来了、谁断开了、谁有新连接了。然后它把这些事件分发给对应的处理函数。嗯,说白了就是「来了活,分给对应的人干」。
为什么是epoll?
在Linux下做高并发,epoll几乎是唯一的选择。select和poll的瓶颈很明显——它们需要遍历所有fd,而且每次都要把fd集合从用户态拷贝到内核态。我曾在项目中遇到过一个问题,连接数到了5000左右,select就开始明显卡顿,CPU飙升得厉害。后来换成epoll,同样的硬件配置,轻松撑到几万连接。
epoll的优势在于:
- 事件驱动:只返回就绪的事件,不用遍历全部
- O(1)复杂度:不管多少连接,检查效率基本不变
- 内存映射:避免了用户态和内核态的数据拷贝
核心要点:epoll_wait返回的是「发生了什么事」,而不是「有哪些fd」。这个区别决定了性能的天壤之别。
Reactor的核心组件
一个简易的Reactor,说白了就三个东西:
- 事件循环(Event Loop):不断调用epoll_wait,拿到就绪事件
- 事件处理器(Handler):处理具体的事件逻辑,比如读数据、写数据
- 事件分发器(Dispatcher):把事件和处理器关联起来
你想想看,这三者配合起来,就是一个完整的Reactor。事件循环负责「等」,分发器负责「找」,处理器负责「干」。
手写简易Reactor
好,咱们直接上代码。我会用C++写一个最简版本,去掉所有无关细节,让你一眼看明白Reactor的本质。
// 事件处理器基类
class EventHandler {
public:
virtual void handleRead() = 0;
virtual void handleWrite() = 0;
virtual int getFd() = 0;
};
// Reactor核心类
class Reactor {
private:
int epollFd;
std::map<int, EventHandler*> handlers;
public:
Reactor() {
epollFd = epoll_create1(0);
if (epollFd == -1) {
perror("epoll_create1 failed");
exit(1);
}
}
void registerHandler(EventHandler* handler, uint32_t events) {
int fd = handler->getFd();
struct epoll_event ev;
ev.events = events;
ev.data.fd = fd;
epoll_ctl(epollFd, EPOLL_CTL_ADD, fd, &ev);
handlers[fd] = handler;
}
void eventLoop() {
const int MAX_EVENTS = 1024;
struct epoll_event events[MAX_EVENTS];
while (true) {
int nfds = epoll_wait(epollFd, events, MAX_EVENTS, -1);
for (int i = 0; i < nfds; i++) {
int fd = events[i].data.fd;
EventHandler* handler = handlers[fd];
if (events[i].events & EPOLLIN) {
handler->handleRead();
}
if (events[i].events & EPOLLOUT) {
handler->handleWrite();
}
}
}
}
};
个人经验:我刚开始写Reactor时,犯过一个低级错误——在handleRead里直接调用了epoll_ctl删除fd。结果导致事件循环里还在用这个fd,直接崩溃。后来我改成「先标记,后删除」的策略,在事件循环外面统一处理。这个坑,你一定要注意。
一个具体的例子:EchoServer
光有框架不行,咱们得跑起来。下面是一个简单的EchoServer,客户端发什么,它就回什么。
class EchoHandler : public EventHandler {
private:
int clientFd;
char buffer[1024];
public:
EchoHandler(int fd) : clientFd(fd) {}
int getFd() override { return clientFd; }
void handleRead() override {
int n = read(clientFd, buffer, sizeof(buffer) - 1);
if (n <= 0) {
// 客户端断开或出错
close(clientFd);
delete this;
return;
}
buffer[n] = '\0';
printf("收到: %s", buffer);
// 直接写回去
write(clientFd, buffer, n);
}
void handleWrite() override {
// 这里可以处理写事件,但EchoServer不需要
}
};
int main() {
Reactor reactor;
// 创建监听socket
int listenFd = socket(AF_INET, SOCK_STREAM, 0);
// ... 绑定、监听等代码省略 ...
// 注册监听socket的读事件
reactor.registerHandler(new ListenHandler(listenFd, &reactor), EPOLLIN);
// 启动事件循环
reactor.eventLoop();
return 0;
}
注意:上面的代码为了简洁,省略了错误处理和内存管理。实际项目中,你还需要考虑:
- 智能指针管理Handler生命周期
- 非阻塞socket的设置
- 边缘触发(ET)和水平触发(LT)的选择
- 线程安全问题
ET vs LT:一个绕不开的话题
epoll有两种触发模式,这个必须讲清楚。我曾经在面试中被问到过,当时没答好,后来专门研究了一下。
| 模式 | 特点 | 使用场景 |
|---|---|---|
| 水平触发(LT) | 只要缓冲区有数据,就会一直通知 | 简单、不容易漏事件,适合新手 |
| 边缘触发(ET) | 只有状态变化时才通知一次 | 高性能,但需要一次性读完所有数据 |
我个人建议:刚开始用LT就够了。ET虽然性能更好,但处理起来很麻烦——你必须用循环把数据读完,否则就丢了。我见过不少新手在ET模式下漏数据,排查半天才发现是没读完。
Reactor的变体
咱们这个简易Reactor是单线程的。实际生产环境中,Reactor有很多变体:
- 单Reactor单线程:就是咱们写的这个,适合CPU密集型任务少的场景
- 单Reactor多线程:事件循环在一个线程,业务处理丢到线程池
- 多Reactor多线程:每个CPU核心一个Reactor,比如Netty的boss/worker模型
你想想看,如果你的业务逻辑很重,比如要解密、解压缩、写数据库,那单线程肯定扛不住。这时候就得把业务处理丢到线程池里,Reactor只负责IO事件的分发。
一句话总结:Reactor模式的核心思想就是「把IO等待和业务处理解耦」。epoll负责高效地等,Reactor负责聪明地分,Handler负责专心地干。
好了,这一章的内容就到这里。下一章咱们会在这个基础上,加入线程池,实现一个真正能扛高并发的Reactor模型。到时候你会看到,单线程Reactor和线程池Reactor的性能差距有多大。