《MMO 服务器开发》10.3 对照:Go channel 邮箱与 Erlang/Skynet 式 Actor

同样是「一个实体一个邮箱」,Go 的 channel 邮箱和 Erlang/Skynet 那种无界队列 + 单线程消费的 Actor,语义与代价并不相同。本节用 Go 实现两种变体,在本机同条件实测吞吐、验证 selective receive 的差异,并说清 Erlang 与 Skynet 的语义各自换来了什么。

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.121.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 请求幂等窗口 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标