10.3 对照:Go channel 邮箱与 Erlang/Skynet 式 Actor
前两节的邮箱是用 Go 的 channel 实现的:有界、阻塞发送、先进先出、不能跳过任何一条。但 Actor 模型不是只有这一种实现。Erlang 的进程邮箱是无界的,允许 selective receive——消费者可以只挑它想要的那类消息,把不匹配的留在队里;Skynet 的服务同样是一条消息队列 + 单线程消费,消息按类型分发。
这两种取向看起来只是实现细节,实际决定了一整套工程习惯:背压靠谁做、队列暴涨时会发生什么、能不能在队列里「等一条特定消息」。
本节不靠翻译文档来对比,而是用 Go 把两种邮箱变体都实现出来,在同一台机器、同一份负载下实测。说明一句前提:本机没有安装 Erlang/Elixir,所以下面不会出现「我跑了 Erlang」——所有数字都来自 Go 里复刻出的两种语义,Erlang 与 Skynet 的部分是语义分析,不是实测。
两种变体:channel 邮箱与显式队列
变体 A 直接拿 channel 当邮箱;变体 B 用「切片 + 条件变量 + 单消费者」复刻 Erlang/Skynet 那种无界邮箱。两者都保证单消费者、都保证 FIFO,差别全在边界与能否选择性接收。
变体 A:有界 channel 邮箱 变体 B:无界队列 + 单消费者
生产者 ─Send─▶ [ M M M ] ─▶ 消费者 生产者 ─Send─▶ [ M M M M M ... ] ─▶ 消费者
▲ 满则阻塞 ▲ 永不阻塞,可无限增长
└ 背压自动回传 └ 背压必须由业务自己做
下面这段是 L1,go test 可以直接跑:
// file: internal/actor/actor.go
// tier: L1
// verify: go test ./internal/actor/ -run 'TestSelectiveReceiveIsQueueOnly|TestUnboundedQueueGrowsUnderSlowConsumer|TestBothVariantsApplySameCount'
package actor
import "sync"
// ChannelActor is variant A: the mailbox *is* a bounded Go channel and the
// consumer ranges over it. This is the idiomatic Go actor: blocking Send gives
// backpressure for free, but there is no selective receive and no way to peek.
type ChannelActor struct {
ch chan Msg
}
// NewChannelActor returns a channel actor with the given mailbox capacity.
func NewChannelActor(capacity int) *ChannelActor {
if capacity <= 0 {
capacity = 1
}
return &ChannelActor{ch: make(chan Msg, capacity)}
}
// Send blocks when the mailbox is full.
func (a *ChannelActor) Send(msg Msg) { a.ch <- msg }
// Close stops the consumer after the queue drains.
func (a *ChannelActor) Close() { close(a.ch) }
// Run consumes until Close, applying every message in arrival order. A message
// cannot be skipped: Go channels have no selective receive.
func (a *ChannelActor) Run(apply func(Msg)) {
for msg := range a.ch {
apply(msg)
}
}
// QueueActor is variant B: an explicit FIFO slice guarded by a mutex, with a
// condition variable waking a single consumer. It mirrors the Erlang/Skynet
// mailbox more closely — the queue can grow without a bound, and a consumer may
// scan it for a matching message instead of taking the head.
type QueueActor struct {
mu sync.Mutex
cond *sync.Cond
q []Msg
closed bool
peak int
}
// NewQueueActor returns an unbounded queue actor.
func NewQueueActor() *QueueActor {
a := &QueueActor{}
a.cond = sync.NewCond(&a.mu)
return a
}
// Send appends to the queue and wakes the consumer. It never blocks, which is
// exactly why an unbounded mailbox needs some other backpressure signal.
func (a *QueueActor) Send(msg Msg) {
a.mu.Lock()
a.q = append(a.q, msg)
if len(a.q) > a.peak {
a.peak = len(a.q)
}
a.mu.Unlock()
a.cond.Signal()
}
// Close stops the consumer after the queue drains.
func (a *QueueActor) Close() {
a.mu.Lock()
a.closed = true
a.mu.Unlock()
a.cond.Broadcast()
}
// Peak returns the high-water mark of the queue. An unbounded mailbox has no
// cap, so this is the only backpressure signal an operator ever sees.
func (a *QueueActor) Peak() int {
a.mu.Lock()
defer a.mu.Unlock()
return a.peak
}
func (a *QueueActor) pop() (Msg, bool) {
a.mu.Lock()
defer a.mu.Unlock()
for len(a.q) == 0 && !a.closed {
a.cond.Wait()
}
if len(a.q) == 0 {
return Msg{}, false
}
msg := a.q[0]
a.q = a.q[1:]
return msg, true
}
// Run consumes until Close, applying every message in arrival order.
func (a *QueueActor) Run(apply func(Msg)) {
for {
msg, ok := a.pop()
if !ok {
return
}
apply(msg)
}
}
// ReceiveFirst scans the queue for the first message matching pred, leaving the
// rest in place. This is Erlang-style selective receive, and it is exactly what
// a Go channel cannot do.
func (a *QueueActor) ReceiveFirst(pred func(Msg) bool) (Msg, bool) {
a.mu.Lock()
defer a.mu.Unlock()
for i, msg := range a.q {
if pred(msg) {
a.q = append(a.q[:i], a.q[i+1:]...)
return msg, true
}
}
return Msg{}, false
}
ReceiveFirst 是变体 B 独有的能力,也是两种模型最本质的分歧点。下面这段 snippet 验证它:队列里依次是「移动、聊天、移动」,消费者只要聊天,ReceiveFirst 把中间那条聊天取走,两条移动原地保留、顺序不变。
// snippet: internal/actor/actor_test.go(节选)
func TestSelectiveReceiveIsQueueOnly(t *testing.T) {
qa := NewQueueActor()
qa.Send(Msg{Cmd: CmdMoveReq, Seq: 1})
qa.Send(Msg{Cmd: CmdChatSend, Seq: 2})
qa.Send(Msg{Cmd: CmdMoveReq, Seq: 3})
got, ok := qa.ReceiveFirst(func(m Msg) bool { return m.Cmd == CmdChatSend })
if !ok || got.Seq != 2 {
t.Fatalf("selective receive = %+v ok=%v, want seq 2", got, ok)
}
// The two moves are still queued, in order.
first, _ := qa.ReceiveFirst(func(m Msg) bool { return m.Cmd == CmdMoveReq })
second, _ := qa.ReceiveFirst(func(m Msg) bool { return m.Cmd == CmdMoveReq })
if first.Seq != 1 || second.Seq != 3 {
t.Fatalf("leftovers = %d,%d want 1,3", first.Seq, second.Seq)
}
}
用 channel 做这件事只有一条路:先把不匹配的读出来、自己缓存,处理完再塞回去——也就是把 selective receive 的开销从运行时挪到业务代码。对 MMO 来说这通常不值得,所以本书的 aegis 选了变体 A。
Erlang 与 Skynet 的语义与代价
把两种变体的特性对应到真实运行时:
| 维度 | Go channel 邮箱(变体 A) | Erlang/Skynet 式邮箱(变体 B) |
|---|---|---|
| 容量 | 有界,创建时定死 | 默认无界,靠别的机制兜底 |
| 发送方 | 满则阻塞,天然背压 | 永不阻塞,压力不向上游传 |
| selective receive | 不支持,必须按序消费 | 支持,可扫描队列挑消息 |
| 消息传递 | 传引用(Go 里通常是值或指针) | Erlang 按值深拷贝到收方堆 |
| 队列暴涨 | 内存有硬上界 | 需要监控 Peak,否则 OOM |
| 单实体并发 | 单 goroutine 消费 | 单线程消费(Skynet 每服务一个) |
三点需要展开。
背压的归属不同。 变体 A 把背压做进了 Send:队列满,发送方就停,压力自动沿调用链回传(10.2 实测过)。变体 B 的 Send 永不阻塞,背压必须由业务显式做——要么给队列设软上限、超了丢弃,要么把 Peak 当指标报警。Erlang 生态里这通常落到「消息洪水导致进程内存暴涨」这类事故上,是同一个取舍的另一面。
selective receive 是双刃剑。 它让「等一条登录确认再处理后续」写起来极自然,代价是扫描队列是 O(n),队列越长越慢;而 Go 的 channel 用「必须按序消费」换来了 O(1) 的取出。Skynet 站内专题 Skynet 消息传递 与 Skynet 服务模型 里对「每服务一队列、按消息类型分发」有更完整的描述,想深入 Skynet 的服务组织可以顺着读;本书这里只借用它的结构取向做对照,不复述它的具体 API。
Erlang 的「按值深拷贝」不是浪费。 它换来的是进程之间绝对无共享内存,因此也不存在「谁持有谁的状态」这类问题——代价是每条消息都要复制一次,大 payload 的拷贝成本必须算进预算。Go 传指针更省,但也就必须靠 10.1 的句柄纪律来保证「没人偷看别人状态」。
同机实测对照
两种变体跑同一份负载:单生产者连发 b.N 条 CmdMoveReq,单消费者只做计数。go test -bench 各跑 3 轮、每轮 30 万次,本机结果(Go 1.26,macOS arm64,Apple M1 Pro):
| 变体 | ns/op(3 轮) | 相对量级 |
|---|---|---|
BenchmarkChannelActor(有界 channel) | 39.07 / 42.75 / 44.12 | 1.0×(基线) |
BenchmarkQueueActor(无界队列 + Cond) | 101.8 / 111.9 / 116.9 | 约 2.5–2.7× |
同机相对量级,绝对数值不可移植。 差距的来源很具体:变体 A 的 Send 是一次 channel 入队(编译器已内联的 chansend),变体 B 的 Send 是「加锁 → 切片 append(可能扩容)→ 解锁 → cond.Signal」四步,多出来的成本几乎全在锁与唤醒上。
无界队列的隐患用另一个测试量化:一次性灌 20000 条、消费者还没启动时,Peak() 直接等于 20000——内存里躺着两万条消息,没有任何东西阻止它继续涨。换成有界 channel,第 1025 条就会把生产者顶住。断言长这样:
// snippet: internal/actor/actor_test.go(节选)
func TestUnboundedQueueGrowsUnderSlowConsumer(t *testing.T) {
qa := NewQueueActor()
const n = 20000
for i := 0; i < n; i++ {
qa.Send(Msg{Cmd: CmdMoveReq, Seq: uint32(i)})
}
// Nothing consumed yet: the high-water mark is the whole burst.
if got := qa.Peak(); got != n {
t.Fatalf("peak = %d, want %d", got, n)
}
}
这组数字不能推出「channel 一定更好」。如果业务真的需要 selective receive(比如一个「先等配置到达、再处理登录」的启动序列),变体 B 省下的业务代码复杂度远超这 60ns/条;而 MMO 的高频路径几乎全是「按序处理」,所以 aegis 的默认选择是有界 channel 邮箱,把无界队列留作特例。
结论:什么时候选哪一个
- 默认用有界 channel 邮箱:
Send阻塞即背压,内存有硬上界,-race下干净。这是 aegis 的主路径。 - 需要 selective receive 时才上显式队列,并明确接受「无界 → 必须自己监控
Peak并做丢弃/降级」的代价;不要把无界邮箱当成默认。 - Erlang/Skynet 的取向不是「更先进」,而是把背压责任外移:换来了消息模型的统一与 selective receive 的表达力,代价是队列治理要自己做。
- 本节的对照全部是 Go 内实测;Erlang/Elixir 本机未安装,涉及它们的部分是语义分析而非运行数据。
阅读导航:上一节:10.2 邮箱、顺序保证与背压 · 下一节:11.1 请求幂等窗口 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。