因为sync.Map仅保证并发读写安全,不支持事件生命周期管理、订阅去重、调用顺序控制和错误隔离,易导致漏触发、重复注册或panic中断发布流程,需用带锁map配合状态管理实现轻量总线。

为什么不用标准库的 sync.Map 直接存事件处理器?
因为 sync.Map 只解决并发读写安全,不处理事件生命周期、订阅去重、调用顺序或错误传播。直接拿它当事件中心,容易在高并发下漏触发、重复注册、或 panic(比如 handler 里 panic 未 recover 导致整个发布流程中断)。
真正需要的是带状态管理的轻量总线:支持动态增删 handler、按 topic 分组、发布时隔离失败 handler、可选同步/异步执行。
- 必须用
map[Topic][]Handler结构,但读写需加锁——sync.RWMutex比sync.Map更可控 - handler 类型建议定义为
type Handler func(Event) error,统一错误返回便于发布端决策是否继续 - 避免在 map value 中存指针切片(如
*[]Handler),会导致竞态;每次订阅都应拷贝 handler 列表供发布使用
Subscribe 和 Unsubscribe 怎么保证线程安全且不阻塞发布?
订阅/退订是低频操作,但发布是高频路径。不能让 Subscribe 持有写锁太久,尤其不能在锁内调用用户 handler(可能阻塞或死锁)。
关键点:锁只保护 map 结构本身,不涉及 handler 执行。
立即学习“go语言免费学习笔记(深入)”;
-
Subscribe(topic Topic, h Handler):加mu.Lock()→ 查 topic 对应切片 → 追加 handler → 解锁。不校验重复,由上层业务决定是否去重 -
Unsubscribe(topic Topic, h Handler):加mu.RLock()先查是否存在(只读)→ 再mu.Lock()做切片过滤 → 解锁。过滤用append(dst[:0], src...)避免内存泄漏 - 绝对不要在锁内做
h(event),哪怕只是测试 handler 是否有效——这会让锁持有时间不可控
发布时 handler panic 了怎么办?
Go 的 goroutine panic 默认终止整个程序,但事件发布必须“局部失败不影响其他 handler”。常见错误是直接 go bus.publishOne(topic, event, h) 然后不管 recover。
正确做法是在每个 handler 调用栈起始处做独立 recover:
func (b *Bus) publishOne(h Handler, e Event) {
defer func() {
if r := recover(); r != nil {
log.Printf("event handler panic: %v", r)
}
}()
_ = h(e)
}
- 不要在
Publish主函数里用一个defer包住全部 handler 调用——那样只能捕获第一个 panic 后就退出循环 - 如果需要区分 handler 错误类型(比如
ErrDropped表示临时不可用),应在Handler返回 error 后判断,而非依赖 panic - 异步发布(
go publishOne(...))时,recover 依然有效,但日志需带 goroutine ID 或 topic 上下文,否则无法定位问题 handler
如何支持一次性事件(one-shot)和通配符 topic?
标准实现只支持精确 topic 匹配。但实际中常需要:user.* 订阅所有用户事件,或 config.loaded 只触发一次。
扩展成本低,但要注意匹配性能和内存管理:
- 通配符用
strings.HasPrefix+strings.HasSuffix实现最简单,避免引入path.Match(会编译 glob 模式,开销大);topic 设计成点分层级(db.query.slow)就足够 - 一次性事件在
publish后立即调用Unsubscribe,但注意:必须在 handler 执行完后才退订,否则可能漏掉并发发布的同一事件 - 不要为通配符单独维护 map,而是在
Publish时遍历所有注册 topic,用if strings.HasPrefix(topic, pattern)过滤——只要 topic 总数不过千,性能无压力
最易被忽略的是 handler 的内存泄露:如果 handler 是闭包且引用了大对象(比如整个 *http.Request),又没及时 Unsubscribe,GC 就无法回收。务必检查 handler 的捕获变量。


















