造了个轮子:go-bus,一个 206 行的 Go 进程内事件总线

先交代一下这是个轮子,而且是基于我自己的另一个轮子(go-pubsub)造的。如果你看到"事件总线"三个字想的是 NATS 或 Kafka,那这篇文章可以先帮你省点时间:go-bus 不管跨进程的事,一条消息从生到死都在同一个 Go 二进制里。

解决什么问题

进程内的模块解耦。配置热更新完了要通知几个模块,订单创建后要触发一串后续动作,这类需求几乎每个项目都有。最朴素的写法是直接函数调用,代价是模块互相 import,缠成一团。讲究一点的写法是自己搓 channel,搓过几次就知道,每个项目搓出来的东西都差不多,又都差那么一点。

我想要的其实很简单:发事件的人不用知道谁在听,监听的人不用 import 发的人。就这一件事。

API 就四个方法

On、Emit、Cancel、Close。核心代码 206 行,一个文件。完整用法长这样:

const ConfigReloaded = iota + 1

bus, _ := bus.NewBus()
reload := bus.NewEvent(ConfigReloaded)

listener := bus.NewListener(func(msg any) {
    fmt.Println("config reloaded:", msg)
})
bus.On(reload, listener)

bus.Emit(reload, "new.yaml")

// 不想监听了
listener.Cancel()

事件是一个带名字的整数,用 iota 常量定义;payload 是 any,高基数的业务数据(用户 ID、时间戳)都放 payload 里,别拿来定义事件。一次性监听器加个 WithOnetime(true),触发一次后自动摘下来,适合"第一次连接成功时做点啥"这类场景。

它不做什么

这部分我觉得比功能列表重要,README 里也花了同样多的篇幅写:

  • 不跨进程。没有网络协议,跨进程请用 NATS、Kafka。
  • 不持久化,不重投。消息只活在订阅者的缓冲 channel 里。
  • 没有背压。订阅者处理不过来、channel 满了,消息直接丢,而且是静默地丢。

最后一条是有意为之的取舍。Emit 永远不阻塞,调用方拿到的"成功"只代表 broker 收下了这条消息,不代表任何人处理了它。如果你需要的是"这条消息必须被处理",那这个库从根上就不合适,用它只会给自己埋雷。我宁愿把这句话写在最显眼的地方,也不想让人用着用着才发现。

还有一个边界值得一提:broker 默认最多 8192 个 topic,也就是 8192 个不同的事件值。所以事件应该是少量语义化的常量,别把 user-login-{uid} 这种东西当事件名,那是 payload 该干的活。

性能

发布热路径是零分配的。压测在 Intel Core Ultra 5 125H、Go 1.25 下跑的,数字看相对关系就好:

场景ns/opB/opallocs/op
1000 个监听者并行 emit22700
扇出到 10 个订阅者1,37300
扇出到 1000 个订阅者36,448100
一次性监听器的完整生命周期6,5954,14220

扇出成本随订阅者数量线性涨,符合预期,毕竟每个订阅者都要投递一次。有意思的是最后一行:唯一明显分配的路径是一次性监听器的注册,因为每次注册都要新建 Listener、context 和订阅句柄。真正反复执行的路径上反而什么都不分配,这要感谢 go-pubsub 里用 sync.Pool 复用扇出快照的设计,以及 go-bus 自己缓存了一个可复用的 publisher。

开发过程中值得一提的

这个库是去年 7 月底开的坑,主体很快就写完了,但真正花时间的不是写,是收拾。

一次是补测试的时候发现数据竞争,修完顺手把覆盖率拉到了 96.8%。测试里集成了 goleak,每次测试结束都检查有没有 goroutine 泄漏。事件总线这种每个监听器一个 goroutine 的设计,泄漏是最容易出也最难察觉的问题,goleak 这笔投入很值。

另一次是生命周期加固。Cancel 和消息投递之间存在竞态:如果取消恰好落在消息出队和回调执行之间,这条消息会被丢弃,保证取消立即生效。副作用是一次性监听器可能一次都不触发,如果它的第一条消息恰好撞上取消。这种行为我犹豫过要不要"修",最后决定不修,而是写进文档。取消语义要的是确定性,悄悄把消息投递完反而更意外。

适合谁

如果你有一个单体 Go 程序,里面的模块开始互相 import 到让你不舒服,可以试试看。安装一行的事:

go get github.com/F2077/go-bus

代码在 GitHub,MIT 协议。需要更细的控制(每个 topic 的 channel 大小、空闲超时、自省接口)的话,可以绕过 go-bus 直接用底下的 go-pubsub。用出问题或者有想法,欢迎来提 issue。