16. 事件驱动架构模式:发布-订阅模式、观察者模式、反应器模式、前摄器模式
聊到事件驱动架构,很多初学者容易把几个模式搞混。我当年刚接触嵌入式系统时,也踩过这个坑。说白了,这些模式都是在解决同一个问题——如何让系统对“发生的事”做出响应。但它们的侧重点和适用场景完全不同。
今天咱们就把这四个模式掰开揉碎了讲清楚。我会结合自己实际项目中的经验,告诉你什么时候该用哪个,什么时候千万别用哪个。
核心观点:事件驱动架构的本质,是让系统的控制流由外部事件来驱动,而不是由程序主动轮询。这能极大降低模块间的耦合度。
16.1 发布-订阅模式(Pub-Sub)
这个模式我用的最多。它的核心思想是:发布者不直接发送消息给订阅者,而是通过一个“消息代理”来中转。发布者和订阅者之间完全解耦,互相不知道对方的存在。
我在做一个物联网网关项目时,设备上报的数据需要同时送给日志模块、存储模块和告警模块。如果用传统方式,每个新模块加入都要改发送端的代码。后来改用发布-订阅模式,发送端只管往主题里扔数据,谁想订阅谁自己注册,清爽多了。
// 简化的发布-订阅实现
typedef struct {
char topic[32];
void (*handler)(void* data);
struct Subscriber* next;
} Subscriber;
typedef struct {
char topic[32];
Subscriber* head;
} TopicNode;
// 发布消息到指定主题
void publish(const char* topic, void* data) {
TopicNode* node = find_topic(topic);
if (!node) return;
Subscriber* sub = node->head;
while (sub) {
sub->handler(data); // 通知所有订阅者
sub = sub->next;
}
}
// 订阅主题
void subscribe(const char* topic, void (*handler)(void*)) {
// 注册回调函数到主题列表
}
我的经验:在资源受限的MCU上,不要用动态内存分配来管理订阅者列表。我习惯用静态数组+位图的方式,效率高且可预测。
16.2 观察者模式(Observer)
观察者模式和发布-订阅很像,但有个关键区别:观察者模式中,被观察对象(Subject)直接持有观察者的引用。没有中间的消息代理。
说白了,发布-订阅是“通过中介找房子”,观察者是“直接盯着房东看”。
我举个例子你就明白了。在嵌入式GUI系统中,一个温度数值变化了,需要更新数字显示、进度条和曲线图。这时候用观察者模式就很自然——温度数据对象直接通知所有注册的观察者。
// 观察者模式典型实现
typedef struct Observer {
void (*update)(int new_value);
struct Observer* next;
} Observer;
typedef struct {
int value;
Observer* observers;
} Subject;
void subject_notify(Subject* sub) {
Observer* obs = sub->observers;
while (obs) {
obs->update(sub->value);
obs = obs->next;
}
}
注意:观察者模式容易造成循环依赖。我曾经在一个项目中,A观察B,B又观察A,结果更新时死循环了。后来我加了一个“正在更新”的标志位来防止重入。
16.3 反应器模式(Reactor)
反应器模式是处理I/O多路复用的利器。它的核心是:一个事件循环(Event Loop)不断检查哪些文件描述符就绪了,然后调用对应的处理函数。
你想想看,在嵌入式网络服务器中,同时要处理多个客户端的连接请求、数据收发。如果用多线程,资源开销太大。反应器模式用一个线程就能搞定。
// 反应器模式核心:事件循环
void reactor_run() {
while (1) {
// 使用select/poll/epoll等待事件
int ready = poll(fds, nfds, timeout);
for (int i = 0; i < nfds; i++) {
if (fds[i].revents & POLLIN) {
// 调用注册的事件处理器
handlers[i].on_readable(fds[i].fd);
}
if (fds[i].revents & POLLOUT) {
handlers[i].on_writable(fds[i].fd);
}
}
}
}
我记得在做一个智能家居网关时,需要同时处理Zigbee、WiFi和串口的数据。用反应器模式,一个事件循环就搞定了所有外设的I/O。代码量比用多线程少了至少一半。
16.4 前摄器模式(Proactor)
前摄器模式和反应器模式是“镜像关系”。反应器是“我准备好了,你来读”,前摄器是“你读完了,告诉我结果”。
说白了,反应器是同步非阻塞,前摄器是异步非阻塞。前摄器模式中,操作系统或者底层框架帮你完成了I/O操作,然后通过回调通知你。
在嵌入式Linux中,使用POSIX AIO(异步I/O)就是典型的前摄器模式。我曾在视频采集项目中用过这个模式——采集卡采集完一帧图像后,通过信号或回调通知应用层处理,应用层不用干等着。
// 前摄器模式示意:异步读操作
void start_async_read(int fd, void* buf, size_t len) {
struct aiocb* cb = malloc(sizeof(struct aiocb));
cb->aio_fildes = fd;
cb->aio_buf = buf;
cb->aio_nbytes = len;
cb->aio_sigevent.sigev_notify = SIGEV_THREAD;
cb->aio_sigevent.sigev_notify_function = read_complete_handler;
aio_read(cb); // 发起异步读,立即返回
}
// 读完成后自动调用
void read_complete_handler(sigval_t sigval) {
struct aiocb* cb = sigval.sival_ptr;
// 处理读取完成的数据
process_data(cb->aio_buf);
}
选型建议:
- 模块间解耦、一对多通知 → 发布-订阅
- 对象状态变化、一对一或少量观察者 → 观察者模式
- I/O多路复用、单线程处理多连接 → 反应器模式
- 高性能异步I/O、不想阻塞主线程 → 前摄器模式
16.5 四种模式对比
| 特性 | 发布-订阅 | 观察者 | 反应器 | 前摄器 |
|---|---|---|---|---|
| 耦合度 | 完全解耦 | 松耦合 | 中等 | 中等 |
| 事件传递 | 通过代理 | 直接通知 | 事件循环 | 异步回调 |
| 适用场景 | 系统级消息 | GUI更新 | 网络服务器 | 高性能I/O |
| 资源开销 | 中等 | 低 | 低 | 高 |
| 实现复杂度 | 中等 | 简单 | 中等 | 复杂 |
避坑指南:我曾经在一个项目中强行用前摄器模式处理简单的按键事件,结果异步回调的上下文切换开销比按键处理本身还大。小项目别用大炮打蚊子,合适才是最好的。
嗯,这四个模式讲完了。其实在实际项目中,它们经常混合使用。比如反应器的事件循环里,收到网络消息后通过发布-订阅分发给各个业务模块。灵活组合,才是架构师的功力所在。