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 allocs | 176384 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 心跳超时与技能冷却 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。