《MMO 服务器开发》8.3 广播与扇出

把同一条消息发给一万个玩家,成本到底花在哪?本节给 aegis 写一个扇出器:证明「编一次发 N 次」比「编 N 次」快得多,用批量写把 K 次系统调用压成一次,并说清广播时锁该锁多细,最后给出本机实测的两组基准对比数字,解释为什么只能报相对量级。

8.3 广播与扇出

前两节都在处理「一条消息」:它去哪(8.1),它算不算数(8.2)。这一节把视角拉到一个数量级之上——同一条消息要发给一万个人。

这在 MMO 里到处都是:全服公告、公会频道刷屏、副本里十个队友的同步、跨服战结果广播。这些场景的共同点是把一份内容复制到很多条连接上,而复制的方式决定了服务器的天花板。一个全服公告如果每条连接都重新编码一遍、每个 socket 各写一次、每次都加锁遍历整张表,一万个在线就是几毫秒起步,还会把别的线程全堵在锁上。

这一节把扇出拆成三个可以分别量化的决定:编码几次、写几次、锁多细。然后给 aegis 写一个 Broadcaster,用一组基准数字回答「每个决定省了多少」。

成本拆解:编码与写各占多少

一次扇出,对每个接收者要做两件事:把命令号和 payload 编码成 wire 字节,再把字节写进 socket。天真的实现对每个接收者各做一次:

for conn in conns:
    buf = EncodeFrame(cmd, seq, flags, payload)   # 每个接收者都编码一次
    conn.WriteFrame(buf)                           # 每个接收者都写一次

问题出在第一行。EncodeFrame 要做一次 append、写 11 字节头、复制 payload。payload 是 128 字节时,一万个接收者就是一万次分配 + 一万次 139 字节的复制,而这些字节完全一样。编码是「内容的函数」,与接收者无关;把与接收者无关的工作放进接收者循环里,就是扇出最典型的浪费。

修法只有一句话:编一次,然后复用同一份字节。下面这段是 L1,go test 可以直接跑:

// file: internal/router/fanout.go
// tier: L1
// verify: go test ./internal/router/

package router

import "sync"

// WireConn accepts an already-encoded frame. A real session.Session from
// chapter 7 satisfies it; tests and benchmarks use a counting fake. Working at
// the byte level here is deliberate: a fanout path must be able to encode once
// and reuse the same buffer for every recipient.
type WireConn interface {
	ID() uint64
	WriteFrame(buf []byte) error
}

// Broadcaster fans one logical message out to a set of connections. The mutex
// guards only the membership map, never the writes: a broadcast takes a snapshot
// under the read lock and then writes outside it, so one stuck socket cannot
// stall Add/Remove or the next broadcast.
type Broadcaster struct {
	mu    sync.RWMutex
	conns map[uint64]WireConn
}

// NewBroadcaster returns an empty broadcaster.
func NewBroadcaster() *Broadcaster {
	return &Broadcaster{conns: make(map[uint64]WireConn)}
}

// Add registers a connection.
func (b *Broadcaster) Add(c WireConn) {
	b.mu.Lock()
	b.conns[c.ID()] = c
	b.mu.Unlock()
}

// Remove drops a connection by id. Removing an absent id is a no-op.
func (b *Broadcaster) Remove(id uint64) {
	b.mu.Lock()
	delete(b.conns, id)
	b.mu.Unlock()
}

// Count reports how many connections are registered.
func (b *Broadcaster) Count() int {
	b.mu.RLock()
	defer b.mu.RUnlock()
	return len(b.conns)
}

// snapshot copies the recipient slice under the read lock. The writes that
// follow hold no lock at all.
func (b *Broadcaster) snapshot() []WireConn {
	b.mu.RLock()
	defer b.mu.RUnlock()
	out := make([]WireConn, 0, len(b.conns))
	for _, c := range b.conns {
		out = append(out, c)
	}
	return out
}

// BroadcastEncoded encodes the frame exactly once and writes the same bytes to
// every recipient. Encoding cost is O(1) in the number of recipients; only the
// socket writes scale with N. This is the shape every hot fanout path should
// have.
func (b *Broadcaster) BroadcastEncoded(cmd Cmd, seq uint32, flags uint8, payload []byte) (sent int) {
	buf := EncodeFrame(nil, cmd, seq, flags, payload)
	for _, c := range b.snapshot() {
		if err := c.WriteFrame(buf); err == nil {
			sent++
		}
	}
	return sent
}

// BroadcastNaive re-encodes the frame once per recipient. It exists only so the
// benchmark can quantify what BroadcastEncoded saves; production code should
// never call it.
func (b *Broadcaster) BroadcastNaive(cmd Cmd, seq uint32, flags uint8, payload []byte) (sent int) {
	for _, c := range b.snapshot() {
		buf := EncodeFrame(nil, cmd, seq, flags, payload)
		if err := c.WriteFrame(buf); err == nil {
			sent++
		}
	}
	return sent
}

// BatchBuffer concatenates several encoded frames into one buffer so a caller
// can hand the socket a single write (a gather-write stand-in). Batching turns
// K syscalls into one and is the second lever, after encode-once, for a hot
// push path.
type BatchBuffer struct {
	buf []byte
	n   int
}

// NewBatchBuffer returns a buffer with room for capBytes of encoded frames.
func NewBatchBuffer(capBytes int) *BatchBuffer {
	if capBytes < 0 {
		capBytes = 0
	}
	return &BatchBuffer{buf: make([]byte, 0, capBytes)}
}

// Add appends one encoded frame.
func (bb *BatchBuffer) Add(cmd Cmd, seq uint32, flags uint8, payload []byte) {
	bb.buf = EncodeFrame(bb.buf, cmd, seq, flags, payload)
	bb.n++
}

// Bytes returns the concatenated frames.
func (bb *BatchBuffer) Bytes() []byte { return bb.buf }

// Len reports how many frames are buffered.
func (bb *BatchBuffer) Len() int { return bb.n }

// Reset clears the buffer but keeps its backing array, so the next batch reuses
// the same allocation.
func (bb *BatchBuffer) Reset() {
	bb.buf = bb.buf[:0]
	bb.n = 0
}

BroadcastEncoded 和 BroadcastNaive 唯一的差别就是 EncodeFrame 那行在不在循环里。保留一个「慢版本」不是为了凑数,而是为了让基准能直接对比——把优化前后的两个实现并排放在同一个基准里,数字才有意义。

批量写:把 K 次 syscall 压成一次

编一次解决的是 CPU 和内存,但每个 socket 至少还要一次 write 系统调用,这一步没法靠复用字节省掉。能省的是「同一个接收者一次要收好几帧」的情况:与其连写 K 次,不如把 K 帧拼进一块缓冲区,一次写出去。

BatchBuffer 就是干这个的。它把多个 EncodeFrame 的结果 append 到同一块 []byte 里,Reset 时只把长度归零、保留底层数组,下一批继续复用同一块内存。这样 K 帧变成一次 write,系统调用次数从 O(K) 降到 O(1),分配也从 O(K) 降到 O(1)(稳态下零分配)。

一个要留意的边界:批量不能无上限。拼得越大,单次写阻塞的时间越长,尾延迟越差;而且一旦 socket 缓冲区写满,大的批量会让一个慢客户端更容易拖住整个发送 goroutine。实践中按「最多 N 帧或最多 M 字节,谁先到算谁」封顶,再配一个写队列(第 10 章的背压会展开)。

扇出的锁粒度

扇出最容易踩的坑不是编码,是锁。最直觉的写法是「持锁遍历 conns,边遍历边写」:

b.mu.Lock()
for _, c := range b.conns { c.WriteFrame(buf) }   // 写发生在锁内
b.mu.Unlock()

这段代码有两个致命问题。第一,一个卡住的 socket 会阻塞所有广播。WriteFrame 底层是 socket 写,对端不读、TCP 发送窗口满了,它会阻塞;持着全局锁阻塞,等于把整个进程的广播能力交给最慢的那个客户端。第二,它把 Add / Remove 也堵死。玩家上下线要改这张表,现在得等一次完整广播写完。

所以 Broadcaster 的写法是快照 + 锁外写:snapshot() 在 RLock 下把接收者列表拷进一个切片,随即放锁;真正的 WriteFrame 全部发生在锁外。锁的临界区只剩「遍历 map 拷指针」,微秒级完成;而写阶段即便某个客户端卡住,也只是让这一个 goroutine 慢,不影响其他广播、不影响上下线。

持锁写(慢客户端拖死全体)        快照 + 锁外写(本节做法)
───────────────────────────       ─────────────────────────────
Lock()                            RLock() → 拷贝接收者切片 → RUnlock()
  for c { c.Write(buf) }  ←阻塞    for c { c.Write(buf) }   ← 锁外
Unlock()                          慢的只慢自己,其余广播与上下线不受影响

两种写法的临界区大小差了三个数量级:前者是「N 次 socket 写」,后者是「N 次指针拷贝」。这就是锁粒度在扇出路径上的全部意义。

代价是快照期间可能有连接进出,导致这一次广播少发/多发一两条。这是可以接受的:广播本身不是强一致操作,晚一个 tick 收到全服公告没有任何玩家能察觉。用一致性换可用性,是扇出路径上默认的取舍。

L1 基准

// snippet: internal/router/fanout_test.go(节选)

// 1000 个连接,128 字节 payload,两个实现跑同一个基准。
func BenchmarkBroadcastEncoded(b *testing.B) {
	bd := NewBroadcaster()
	for i := 0; i < 1000; i++ {
		bd.Add(&countingConn{id: uint64(i)})
	}
	payload := make([]byte, 128)
	b.ReportAllocs()
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		bd.BroadcastEncoded(CmdAttrSync, uint32(i), 0, payload)
	}
}

本机实跑(Go 1.26,macOS arm64),同机相对量级,绝对数值不可移植:

$ go test ./internal/router/ -run '^$' -bench 'BenchmarkBroadcast' -benchmem -count=3
BenchmarkBroadcastEncoded-10   64026   16480 ns/op   16544 B/op   3 allocs/op
BenchmarkBroadcastNaive-10     20070   67489 ns/op  176384 B/op   2001 allocs/op
指标编一次发 N 次编 N 次差异
1000 连接一次广播约 16.5–22.9 µs约 65.9–69.4 µs约 3–4 倍
每次广播分配16544 B / 3 allocs176384 B / 2001 allocs分配次数差约 660 倍
每接收者摊薄约 16–23 ns约 66–69 ns—

三点结论。第一,编码的复用是真实的:allocs 从 2001 掉到 3,说明慢版本确实对每个接收者都分配了一次。第二,写阶段是共同成本:即便编一次,每接收者仍有约 16–23 ns,那是一次方法调用加几次原子操作的成本,两者都要付。第三,这个基准里的「写」是假连接,只做计数不做真 syscall;真机上的绝对数字会大得多,但「编一次比编 N 次省」这个相对关系不变——所以这里只给相对量级,不给「快 N 倍」的绝对话。

本节小结

扇出只有三个旋钮:编几次、写几次、锁多细。编一次发 N 次,是把与接收者无关的工作挪出循环;批量写,是把 O(K) 次 syscall 压成 O(1);快照加锁外写,是让一个慢客户端无法阻塞全体。三者叠加,一万连接的广播才可能停在微秒级,而不是毫秒级。

到这里,一条消息从进来到出去的全链路就闭合了:路由决定去哪(8.1),序号决定收不收(8.2),扇出决定怎么复制到多人(8.3)。下一章回到单条连接的底层机制,讲定时器与时间轮——心跳超时、技能冷却、世界 Tick 都靠它。

阅读导航:上一节:8.2 消息序号、去重与顺序保证 · 下一节:9.1 心跳超时与技能冷却 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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