sync.Map 不适合事件总线订阅表,因其 Range 非原子性易漏注册或 panic,且不支持 topic 分组与优雅取消;应改用 map[string][]func(interface{}) + sync.RWMutex。

为什么 sync.Map 不适合做事件总线的订阅表
因为事件总线的核心操作是「高频读 + 低频写 + 需要遍历」,而 sync.Map 的 Range 方法不保证原子性,遍历时若其他 goroutine 正在 Store 或 Delete,可能漏掉新注册的 handler 或 panic(尤其在 map 扩容时)。更麻烦的是它不支持按 topic 分组、无法优雅取消订阅。
实操建议:
立即学习“go语言免费学习笔记(深入)”;
- 用普通
map[string][]func(interface{})+sync.RWMutex:读多写少场景下,RWMutex的读锁几乎无开销,且Range可控 - 订阅/退订必须加写锁,触发事件用读锁 —— 别图省事全用写锁,会把并发打成串行
- 别在 handler 里直接修改订阅表(比如调用
Unsubscribe),否则可能死锁;应改用「标记待移除 + 异步清理」模式
如何安全地在跨包场景下传递事件结构体
Go 没有运行时类型反射式事件分发,硬塞 interface{} 容易丢类型信息,又不敢直接 import 循环依赖。常见错误是定义一个全局 Event 接口,结果每个包都实现一遍,最后 handler 收到的是空接口,还得靠 type switch 硬拆 —— 既难维护又慢。
实操建议:
立即学习“go语言免费学习笔记(深入)”;
- 事件类型统一定义在
event/包下,只暴露具体 struct(如event.UserCreated),不暴露接口;跨包直接 import 该包 - 总线的
Publish方法签名设为func(topic string, event interface{}),但内部用reflect.TypeOf(event).Name()做 topic 默认 fallback,避免调用方重复传字符串 - 如果真有循环依赖,用
func() interface{}延迟求值代替直接传 struct,把依赖解耦到运行时
goroutine 泄漏:为什么 go handler(e) 会吃光内存
典型现象是压测跑几分钟后,runtime.NumGoroutine() 持续上涨,pprof 显示大量阻塞在 channel send 或 handler 内部 IO。根本原因是没控制并发数,也没处理 handler panic —— 一个挂了,其余照常 spawn,且无超时机制。
实操建议:
立即学习“go语言免费学习笔记(深入)”;
- 给每个 topic 配置独立的 worker pool,用带缓冲的
chan func()控制并发上限,比如make(chan func(), 100) - 所有 handler 必须包一层
defer func(){ if r := recover(); r != nil { log.Printf("handler panic: %v", r) } }() - IO 类 handler(如发 HTTP、写 DB)必须显式设 context 超时,别依赖总线统一加 —— 不同事件容忍延迟差异很大
测试时怎么模拟异步事件流而不依赖真实 goroutine
单元测试里用 time.Sleep 等 handler 执行完?不稳定还慢。用 sync.WaitGroup?得改生产代码加回调,污染逻辑。最坑的是 mock channel 导致测试和实际调度行为不一致(比如 select default 分支在测试里永远不触发)。
实操建议:
立即学习“go语言免费学习笔记(深入)”;
- 总线初始化时接受一个可选的
executor func(func())参数,默认用go,测试时传func(f func()) { f() }强制同步执行 - 触发事件后,立刻调用
bus.Flush()(内部调runtime.Gosched()让所有 pending goroutine 跑完),再断言状态 - 别 mock handler 行为,而是用真实 handler + 内存 store(如
var logs []string),通过闭包捕获副作用
跨包通信真正难的不是发消息,是共识:topic 命名规则、事件生命周期、谁负责 recover、错误要不要重试 —— 这些得写进 internal 文档,而不是靠代码猜。










