4、发布-订阅模式:事件驱动框架搭建、订阅者链表管理、广播与定向发送
发布-订阅模式,说白了就是让模块之间不再「面对面」说话,而是通过一个中间人喊话。谁想听什么消息,先到中间人那里登记。谁要发消息,只管往中间人那里一丢,中间人负责通知所有登记过的人。
我早年做的一个车载娱乐系统项目,就是被模块间的直接调用坑惨了。音频模块要通知显示模块切歌,得直接调用显示模块的 API。后来需求一变,要再加一个蓝牙模块也监听切歌事件,好家伙,音频模块得改代码。耦合度太高,改一处牵全身。后来重构成了发布-订阅模式,世界清净了。
4.1 事件驱动框架的核心设计
事件驱动框架,核心就三个东西:事件管理器、订阅者链表、事件分发器。
我习惯把事件管理器设计成单例。整个系统只有一个事件中心,所有模块都通过它来订阅和发布。这样好管理,也好调试。
先看事件管理器的核心结构:
/* event_manager.h */
#ifndef EVENT_MANAGER_H
#define EVENT_MANAGER_H
#include <stdint.h>
/* 事件ID定义 */
typedef enum {
EVT_NONE = 0,
EVT_KEY_PRESS, /* 按键按下 */
EVT_KEY_RELEASE, /* 按键释放 */
EVT_NET_CONNECTED, /* 网络连接 */
EVT_NET_DISCONNECTED, /* 网络断开 */
EVT_SENSOR_DATA_READY, /* 传感器数据就绪 */
EVT_UI_REFRESH, /* UI刷新 */
EVT_MAX
} event_id_t;
/* 事件数据结构 */
typedef struct {
event_id_t id; /* 事件ID */
uint32_t timestamp; /* 时间戳 */
void* data; /* 事件数据指针 */
uint32_t data_len; /* 数据长度 */
} event_t;
/* 订阅者回调函数类型 */
typedef void (*event_callback_t)(const event_t* evt);
/* 事件管理器初始化 */
void event_manager_init(void);
/* 订阅事件 */
int event_subscribe(event_id_t id, event_callback_t cb);
/* 取消订阅 */
int event_unsubscribe(event_id_t id, event_callback_t cb);
/* 发布事件(广播) */
int event_publish(const event_t* evt);
/* 定向发送 */
int event_send_to(event_id_t id, event_callback_t cb, const event_t* evt);
#endif /* EVENT_MANAGER_H */
嗯,这里要注意。事件ID用枚举,别用魔数。我见过有人直接用整数0、1、2来定义事件,后来维护的人根本不知道0代表什么。枚举自带可读性,调试的时候一眼就能看出是哪个事件。
4.2 订阅者链表管理
订阅者怎么存?数组?链表?
我个人习惯用单向链表。数组虽然访问快,但订阅者数量不确定,数组大小不好定。大了浪费内存,小了又怕不够用。链表动态分配,灵活多了。
不过要注意,嵌入式系统里动态分配要谨慎。我一般会做一个静态内存池,链表节点从池子里取,避免频繁malloc/free造成碎片。
看订阅者链表节点的设计:
/* subscriber_node.h */
#ifndef SUBSCRIBER_NODE_H
#define SUBSCRIBER_NODE_H
#include "event_manager.h"
/* 订阅者节点 */
typedef struct subscriber_node {
event_id_t event_id; /* 订阅的事件ID */
event_callback_t callback; /* 回调函数 */
struct subscriber_node* next; /* 下一个节点 */
} subscriber_node_t;
/* 订阅者链表(每个事件一条链) */
typedef struct {
subscriber_node_t* head; /* 链表头 */
uint32_t count; /* 节点数量 */
} subscriber_list_t;
/* 初始化订阅者链表 */
void subscriber_list_init(subscriber_list_t* list);
/* 添加订阅者 */
int subscriber_list_add(subscriber_list_t* list,
event_id_t id,
event_callback_t cb);
/* 移除订阅者 */
int subscriber_list_remove(subscriber_list_t* list,
event_id_t id,
event_callback_t cb);
/* 查找订阅者(用于定向发送) */
subscriber_node_t* subscriber_list_find(subscriber_list_t* list,
event_id_t id,
event_callback_t cb);
/* 遍历链表执行回调 */
void subscriber_list_dispatch(subscriber_list_t* list,
const event_t* evt);
#endif /* SUBSCRIBER_NODE_H */
这里有个关键点:每个事件ID对应一条独立的链表。为什么?因为事件ID多了之后,如果所有订阅者都挂在一根链上,每次发布事件都要遍历整条链,效率太低了。
我习惯用一个数组来管理这些链表头:
/* 事件管理器内部实现 */
#define MAX_EVENT_ID EVT_MAX
static subscriber_list_t s_subscriber_lists[MAX_EVENT_ID];
void event_manager_init(void) {
for (int i = 0; i < MAX_EVENT_ID; i++) {
subscriber_list_init(&s_subscriber_lists[i]);
}
}
这样,发布事件时直接根据事件ID索引到对应的链表,只遍历该事件的订阅者,效率高得多。
4.3 广播与定向发送
广播好理解,事件发布后,所有订阅了该事件的模块都会收到通知。定向发送呢?就是只通知某个特定的订阅者,其他订阅了同一事件的人不通知。
什么时候用定向发送?我举个例子。系统里有个日志模块,它订阅了EVT_SENSOR_DATA_READY事件,但只想在调试模式下才处理这个事件。其他模块(比如显示模块)正常处理。这时候就可以用定向发送,只把事件发给显示模块,日志模块跳过。
看广播和定向发送的实现:
/* 广播:通知所有订阅者 */
int event_publish(const event_t* evt) {
if (evt == NULL || evt->id >= MAX_EVENT_ID) {
return -1;
}
subscriber_list_dispatch(&s_subscriber_lists[evt->id], evt);
return 0;
}
/* 定向发送:只通知指定的订阅者 */
int event_send_to(event_id_t id, event_callback_t cb, const event_t* evt) {
if (id >= MAX_EVENT_ID || cb == NULL || evt == NULL) {
return -1;
}
subscriber_node_t* node = subscriber_list_find(
&s_subscriber_lists[id], id, cb);
if (node != NULL && node->callback != NULL) {
node->callback(evt);
return 0;
}
return -1; /* 没找到指定的订阅者 */
}
注意看subscriber_list_dispatch的实现,这里有个坑:
void subscriber_list_dispatch(subscriber_list_t* list,
const event_t* evt) {
subscriber_node_t* current = list->head;
while (current != NULL) {
/* 先保存下一个节点指针 */
subscriber_node_t* next = current->next;
/* 执行回调 */
if (current->callback != NULL) {
current->callback(evt);
}
/* 移动到下一个节点 */
current = next;
}
}
为什么要先保存next?我曾经在这里栽过跟头。回调函数执行过程中,有可能这个订阅者把自己给取消订阅了(从链表中移除)。如果不先保存下一个节点,当前节点被释放后,current->next就访问了野指针,直接崩掉。
4.4 事件驱动框架的整体流程
我把整个框架的流程画了一张图,方便你理解:
4.5 广播与定向发送的对比
我把广播和定向发送的区别整理成了表格,方便你对比:
| 特性 | 广播 | 定向发送 |
|---|---|---|
| 通知范围 | 所有订阅者 | 指定的单个订阅者 |
| 使用场景 | 全局事件(如网络断开、按键按下) | 特定模块间的私有通信 |
| 性能开销 | 遍历整条链表 | 查找指定节点后直接调用 |
| 调试难度 | 容易,所有订阅者都会触发 | 稍难,需要确认目标订阅者是否存在 |
| 典型应用 | 系统状态变化通知 | 配置更新、调试命令 |
4.6 实际项目中的注意事项
最后,分享几个我在项目中踩过的坑:
- 回调函数不要做耗时操作。事件分发是在发布者的上下文中执行的,如果回调函数里做了大量计算或者阻塞操作,会拖慢发布者。我一般建议回调函数里只做消息入队操作,真正的处理交给任务队列。
- 注意重入问题。如果事件是在中断服务函数里发布的,回调函数里不能调用可能引起阻塞的函数(比如printf、malloc)。我习惯在中断里只做事件标记,真正的发布放到任务级代码里做。
- 订阅者链表的线程安全。如果系统用了RTOS,多个任务可能同时订阅或发布事件。这时候要给链表操作加锁。我一般用关中断或者互斥信号量来保护。
- 事件数据的生命周期。发布事件时传递的数据指针,在回调函数执行完后是否还有效?如果数据是栈上分配的,回调函数异步执行时数据可能已经被销毁了。我习惯在事件结构里用
data_len字段,如果数据需要跨上下文使用,就在事件管理器内部做一次数据拷贝。
嗯,发布-订阅模式说到这里,核心的东西基本都覆盖了。框架搭起来不难,难的是把细节处理好。链表管理、内存分配、线程安全、数据生命周期,每一个点都值得你花时间去打磨。