直接用 channel 实现跨模块事件会出问题,因其不支持一对多广播、无生命周期管理、强依赖类型定义、无缓冲导致事件丢失、无法按类型过滤且逻辑分散难维护。

为什么直接用 channel 实现跨模块事件会出问题
Go 里最容易想到的是用 chan 传递事件,但实际一用就卡住:模块 A 发送事件后阻塞,模块 B 还没启动监听;或者多个订阅者共用一个 chan 导致 panic —— 因为 chan 不支持一对多广播,也不自带生命周期管理。
更麻烦的是,模块间强依赖 chan 类型定义,改个事件结构就得全量编译,根本谈不上“跨模块”解耦。
- 别把
chan当事件总线用,它只是同步/异步通信原语,不是事件系统 - 订阅者注册时若模块未初始化,事件会丢失——
chan没有缓冲或重放能力 - 无法按事件类型过滤,所有监听者都得自己做
switch分发,逻辑分散难维护
用 sync.Map + interface{} 实现轻量级事件中心
不需要引入第三方库,用标准库就能搭出可运行的事件机制。核心是用 sync.Map 存不同事件类型的回调列表,用 interface{} 统一事件数据,靠类型断言做安全分发。
关键点在于:每个事件类型对应一个 []func(interface{}) 切片,注册时追加函数,触发时遍历调用——没有锁竞争,sync.Map 自带并发安全。
- 注册用
Subscribe("user.created", func(e interface{}) { ... }),字符串作事件键,避免类型硬编码 - 发送用
Publish("user.created", User{ID: 123}),任意结构体都行,接收方自行断言 - 取消订阅必须传回原函数指针,
sync.Map不支持值比较,所以得用 map[func(interface{})]struct{} 辅助去重
如何避免 goroutine 泄漏和重复订阅
事件监听器常驻内存,但模块卸载时若不清理,goroutine 就会一直等着永远不会来的事件。常见错误是只存函数却不记录归属模块,导致无法批量注销。
解决办法很简单:给每个订阅加唯一标识,比如模块名+事件名组合成 key,用 map[string]func(interface{}) 管理,注销时按 key 删除即可。
- 别用匿名函数注册,否则无法准确注销——
func() {} != func() {} - 监听函数内部启动 goroutine 时,务必用带超时的
context.WithTimeout,防止阻塞主事件循环 - 测试时用
runtime.NumGoroutine()对比注册前后,确认无泄漏
什么时候该换 EventBus 库而不是手写
手写能控制细节,但真到了需要事件溯源、重放、持久化或跨进程分发时,sync.Map 就撑不住了。这时候不是“要不要优雅”,而是“能不能扛住”。
观察两个信号:一是你开始往事件里塞 context.Context 做传播,二是日志里频繁出现 select { case 做兜底——说明已超出轻量级范畴。
- 推荐
github.com/ThreeDotsLabs/watermill(专注消息流)或github.com/asaskevich/EventBus(API 简洁) - 注意 EventBus 默认不保证顺序,高并发下同一事件可能被多次投递,业务代码必须幂等
- 所有第三方库都会侵入模块初始化流程,比如要求提前调用
bus.Init(),这点比手写更难测试
真正难的不是怎么实现,是决定哪些模块该暴露事件、哪些该封装在方法里——事件太多等于接口失控,太少又导致模块间硬调用。边界画在哪,比代码怎么写更花时间。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











