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索引到对应的链表,只遍历该事件的订阅者,效率高得多。

我的经验:链表节点的内存池大小怎么定?我一般按「最大可能订阅者数量 × 2」来预分配。比如系统里最多有20个模块会订阅事件,那就预分配40个节点。多出来的20个是余量,防止动态注册时不够用。

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就访问了野指针,直接崩掉。

避坑指南:我曾经在一个项目中,回调函数里调用了event_unsubscribe,结果链表遍历到一半,当前节点被删了,next指针也丢了。程序跑飞,查了两天才找到原因。所以遍历链表执行回调时,一定要先保存next指针。

4.4 事件驱动框架的整体流程

我把整个框架的流程画了一张图,方便你理解:

事件驱动框架整体流程 模块A(发布者) 调用 event_publish() 事件管理器 根据事件ID查找链表 遍历链表执行回调 事件ID索引数组 [0] EVT_NONE [1] EVT_KEY_PRESS [2] EVT_NET_CONNECTED [...] 订阅者链表 节点1 -> 节点2 -> 节点3 每个事件ID一条链 节点1:模块B回调函数 节点2:模块C回调函数 节点3:模块D回调函数 模块B(订阅者) 处理事件 模块C(订阅者) 处理事件 模块D(订阅者) 处理事件 说明: 广播:事件发布后,遍历链表通知所有订阅者(模块B、C、D都收到) 定向发送:只通知链表中指定的某个订阅者(比如只通知模块B)

4.5 广播与定向发送的对比

我把广播和定向发送的区别整理成了表格,方便你对比:

特性 广播 定向发送
通知范围 所有订阅者 指定的单个订阅者
使用场景 全局事件(如网络断开、按键按下) 特定模块间的私有通信
性能开销 遍历整条链表 查找指定节点后直接调用
调试难度 容易,所有订阅者都会触发 稍难,需要确认目标订阅者是否存在
典型应用 系统状态变化通知 配置更新、调试命令
核心要点:广播是「一对多」,定向发送是「一对一」。定向发送本质上是对广播的一种精细化控制,让你能精确控制事件的分发目标。

4.6 实际项目中的注意事项

最后,分享几个我在项目中踩过的坑:

  • 回调函数不要做耗时操作。事件分发是在发布者的上下文中执行的,如果回调函数里做了大量计算或者阻塞操作,会拖慢发布者。我一般建议回调函数里只做消息入队操作,真正的处理交给任务队列。
  • 注意重入问题。如果事件是在中断服务函数里发布的,回调函数里不能调用可能引起阻塞的函数(比如printf、malloc)。我习惯在中断里只做事件标记,真正的发布放到任务级代码里做。
  • 订阅者链表的线程安全。如果系统用了RTOS,多个任务可能同时订阅或发布事件。这时候要给链表操作加锁。我一般用关中断或者互斥信号量来保护。
  • 事件数据的生命周期。发布事件时传递的数据指针,在回调函数执行完后是否还有效?如果数据是栈上分配的,回调函数异步执行时数据可能已经被销毁了。我习惯在事件结构里用data_len字段,如果数据需要跨上下文使用,就在事件管理器内部做一次数据拷贝。

嗯,发布-订阅模式说到这里,核心的东西基本都覆盖了。框架搭起来不难,难的是把细节处理好。链表管理、内存分配、线程安全、数据生命周期,每一个点都值得你花时间去打磨。