《游戏服务端编程实践》13.2 无锁结构与内存复用

划定无锁结构的适用边界,说明原子计数、快照发布与单生产者单消费者队列的取舍,讨论 sync.Pool、对象池、切片复用和逃逸分析对分配的影响,剖析环形缓冲区的读写指针、2 的幂容量与伪共享,并附一个 SPSC 环形缓冲区与 Frame 复用池实现及测试。

一、无锁不是“没有同步”

“无锁”经常被当成性能银弹,但它首先是一个正确性约束:多个执行体通过原子操作和内存序协作,不依赖互斥锁来保护共享状态。它并不自动更快;当竞争激烈、临界区很小或缓存行频繁失效时,无锁结构可能比一把普通互斥锁更慢。

场景是否适合无锁原因
全服在线人数、击杀计数适合单个原子加一,没有跨字段不变式
配置或排行榜快照发布适合写少读多,整块替换指针即可
单生产者单消费者日志/帧队列适合读写各占一端,指针可以各自推进
多生产者多消费者订单队列通常不适合需要 CAS 循环或额外协调,复杂度和 ABA 风险高
需要跨多个字段保持一致的状态不适合原子操作只保证单变量,跨字段仍要锁或版本号
临界区里要调数据库或第三方 SDK不适合阻塞操作与无锁目标矛盾

严格来说,只有 Drop 策略下的正常路径接近无锁;Block 策略为了睡眠等待使用了互斥锁和条件变量,不能宣称是 lock-free。工程上更准确的说法是“热路径不加锁”:正常读写走原子变量,只有需要排队等待时才进入慢路径。

二、原子计数与快照发布

原子计数适合“只增不减”的统计量,例如在线人数、消息计数、错误计数。使用时要注意两点:第一,Load 再 Add 不是原子读改写,业务上需要“读后判断”的场景仍然要 CAS 或锁;第二,多个计数之间没有共同快照,同时读取两个计数器可能看到不一致的组合,报表应接受这种近似,或者用版本号成组发布。

快照发布是另一个高频模式:读侧频繁读取一份只读数据,写侧周期性构建新副本,然后用一个原子指针整体替换。读侧拿到旧指针后继续使用完全安全,因为 Go 的 GC 会保证只要还有引用,旧对象就不会被回收。这个模式适合配置表、排行榜、路由表,不适合写侧频繁且每次只改一个字段的场景,因为整块复制的成本会很高。

模式读侧成本写侧成本适用读写比
互斥锁保护加锁、解锁加锁、解锁读写均衡
原子计数一次原子读一次原子加写多读也多,但只做计数
快照指针替换一次原子读构建完整副本读远多于写
SPSC 环形队列原子读 + 取元素原子读 + 写元素一对一流水线

三、内存复用:sync.Pool、对象池与切片

高频消息路径的主要成本往往不是“计算”,而是“分配”。每个位置同步包都新建一个结构体、每个日志事件都拼接一个字符串,会让 GC 频繁扫描大量短命对象。复用对象可以减少分配次数和 GC 压力,但前提是复用出来的对象必须被正确重置。

sync.Pool 有几个必须知道的行为。第一,它不是连接池,池中对象可能在 GC 时被清空,不能依赖它保存有状态资源。第二,Get 返回的对象可能是全新的,也可能是别人刚归还的,所以取用时不能假设字段为零值,归还时更应该主动重置。第三,Pool 按 P 做本地缓存,跨 P 取用会增加开销,适合“短生命周期、同 goroutine 附近复用”的场景。

复用对象归还前必须做什么常见错误
消息结构体清空序号、类型、引用字段残留上一条消息的序号,客户端去重误判
字节切片截断长度,必要时清空指针元素只截断长度,底层数组仍引用大对象
编码缓冲区buf = buf[:0]直接复用未截断的切片,追加后数据错位
定时器停止并 drain,或使用池化 timer复用未停止的 timer,触发旧回调

切片复用有一个隐蔽陷阱:s = s[:0] 只改长度,底层数组里的指针仍然持有对象。如果切片元素是指针或包含指针的结构体,应该先把旧元素清零,否则复用的切片会延长这些对象的生命周期,看起来像内存泄漏。

3.1 逃逸分析与分配位置

变量是否分配在堆上由编译器决定。返回局部变量指针、把变量放进 interface、闭包捕获超过一定条件,都可能让变量逃逸。可以用 go build -gcflags='-m' 查看编译器的逃逸判断,但不要把输出当成绝对真理:内联和版本差异都会改变结果,最终仍以 benchmark 的 allocs/op 为准。

减少逃逸的常见手段包括:让函数接收切片或指针而不是返回新对象、避免不必要地把具体类型装进 any、为已知大小的切片预分配容量、把热路径上的小结构体按值传递。目标不是“零逃逸”,而是“热路径上没有意料之外的高频分配”。

四、环形缓冲区的实现要点

环形缓冲区的本质是一个固定大小的数组加两个游标。生产者把元素写到 head 位置,消费者从 tail 位置读取;两个游标都对容量取模,从而循环使用数组。实现时有四个关键点。

第一,容量取 2 的幂,这样 index & (size-1) 可以替代取模,既便宜又避免负数取模的边界问题。第二,用单调递增的 64 位游标而不是回绕的下标,通过 head - tail 判断元素个数,可以自然区分“空”和“满”,不需要浪费一个槽位。第三,生产者只写 head,消费者只写 tail,这是 SPSC 成立的前提;多生产者必须用 CAS 抢写位置。第四,先写数据再发布游标,否则消费者可能读到尚未写完的槽位。

4.1 伪共享与缓存行

head 和 tail 如果落在同一条缓存行上,生产者和消费者会不断让对方的核心缓存失效,这种现象叫伪共享。高吞吐场景下,可以把两个游标分别填充到不同的缓存行,或者把它们放进不同的结构体。Go 不保证结构体字段的缓存行布局,填充是否有效要用 benchmark 验证,不能只凭代码看起来“隔开了”就下结论。

结构生产者数量消费者数量需要的同步
SPSC 环形队列11原子游标即可
MPSC 队列多1生产者侧 CAS 或锁
SPMC 队列1多消费者侧 CAS 或分发
MPMC 队列多多通常直接使用 channel 或成熟库

4.2 满与空的策略

环形缓冲区满时,业务必须选择阻塞、拒绝还是覆盖。位置同步通常选择覆盖最旧帧或丢弃新帧,因为玩家只关心最新状态;控制消息则必须阻塞或拒绝,不能静默丢。空时也一样:消费者可以阻塞等待,也可以立即返回空。策略应该作为构造参数显式表达,而不是藏在实现细节里。

五、失败场景:无锁外壳里藏着锁

某项目把消息队列改成了“无锁环形缓冲区”,但每次 Push 和 Pop 都在内部加了一把全局互斥锁,理由是要“保护游标”。压测结果显示吞吐与改造前几乎一样,CPU 却更高,因为原子操作和锁双重开销叠加。这不是无锁结构的问题,而是把锁藏进了无锁的外壳里,调用方以为没有竞争,实际竞争点一个没少。

另一个更隐蔽的故障来自对象池。某个 Frame 归还时没有清空 Seq,下一次取用时恰好复用了旧序号。客户端按序号去重,把新帧当成重复包丢弃,表现为“偶发不刷新”;服务端日志里消息明明已经发出。排查时只盯着网络和客户端,最后才发现是池化对象没有重置。

现象可能原因排查方式
改造后吞吐不变、CPU 升高无锁路径里仍有锁或频繁 CAS 失败CPU profile、锁竞争 profile
偶发消息被客户端丢弃复用对象的序号/类型未重置在 Get 后断言字段为零值
内存不降反升切片截断但底层仍引用大对象heap profile 看 retained size
消费者读到半条消息先发布游标、后写数据检查写序列与内存序
高并发下序号错乱多生产者共用了 SPSC 结构确认生产者数量,改用 MPSC 或加锁

六、可运行实现:SPSC 环形缓冲区与 Frame 池

下面的实现把游标、策略和对象池放在一起:Ring 是严格的单生产者单消费者结构,容量向上取整到 2 的幂;Drop 策略在满/空时立即返回错误,Block 策略用条件变量等待;Frame 通过 sync.Pool 复用,归还时保留 Data 容量但清空长度和字段。

// file: ch13/ring/ring.go
// tier: L1
// verify: go test ./ch13/ring/

package ring

import (
	"errors"
	"sync"
	"sync/atomic"
)

var (
	// ErrFull 表示 Drop 策略下缓冲区已满。
	ErrFull = errors.New("ring: full")
	// ErrEmpty 表示 Drop 策略下缓冲区为空。
	ErrEmpty = errors.New("ring: empty")
	// ErrClosed 表示缓冲区已关闭。
	ErrClosed = errors.New("ring: closed")
	// ErrNil 表示向缓冲区投递了 nil Frame。
	ErrNil = errors.New("ring: nil frame")
)

// Policy 决定缓冲区满或空时的行为。
type Policy uint8

const (
	// Drop 在满时立即返回 ErrFull,在空时立即返回 ErrEmpty。
	// 适合可以丢弃旧帧或中间帧的场景,例如位置同步。
	Drop Policy = iota
	// Block 在满/空时等待,直到有空间/数据或缓冲区关闭。
	// 适合不能丢消息的控制流,但要求调用方接受阻塞。
	Block
)

// Frame 是通过环形缓冲区传递的消息。
//
// Data 使用池化切片,PutFrame 时只截断长度、保留容量,
// 下一次 GetFrame 拿到的对象因此既干净又可复用。
type Frame struct {
	Seq  uint64
	Kind uint16
	Data []byte
}

// Reset 把 Frame 恢复到可复用的零值状态,同时保留 Data 的容量。
func (f *Frame) Reset() {
	f.Seq = 0
	f.Kind = 0
	f.Data = f.Data[:0]
}

var framePool = sync.Pool{
	New: func() any { return &Frame{} },
}

// GetFrame 从池中取一个已重置的 Frame。
func GetFrame() *Frame {
	return framePool.Get().(*Frame)
}

// PutFrame 重置并归还 Frame。同一个 Frame 不能重复归还。
func PutFrame(f *Frame) {
	if f == nil {
		return
	}
	f.Reset()
	framePool.Put(f)
}

// Ring 是单生产者单消费者(SPSC)的定长环形缓冲区。
//
// 容量会被向上取整到 2 的幂,索引用位掩码代替取模。
// 生产者只推进 head,消费者只推进 tail,进度通过原子变量
// 发布,因此正常路径没有共享锁。Block 策略下用条件变量
// 睡眠等待,唤醒后必须在锁内重新检查条件,避免丢失唤醒。
type Ring struct {
	buf    []*Frame
	mask   uint64
	size   uint64
	policy Policy

	head atomic.Uint64 // 生产者写入位置
	tail atomic.Uint64 // 消费者读取位置

	closed atomic.Bool

	mu       sync.Mutex
	notFull  *sync.Cond
	notEmpty *sync.Cond
}

// New 创建容量为 capacity 的环形缓冲区。
//
// capacity 小于 2 时按 2 处理,其余向上取整到 2 的幂。
func New(capacity int, policy Policy) *Ring {
	if capacity < 2 {
		capacity = 2
	}
	size := uint64(1)
	for size < uint64(capacity) {
		size <<= 1
	}
	r := &Ring{
		buf:    make([]*Frame, size),
		mask:   size - 1,
		size:   size,
		policy: policy,
	}
	r.notFull = sync.NewCond(&r.mu)
	r.notEmpty = sync.NewCond(&r.mu)
	return r
}

// Push 由唯一的生产者调用。
//
// Drop 策略在满时返回 ErrFull;Block 策略等待消费者腾出位置。
// 关闭后 Push 返回 ErrClosed。Close 必须发生在生产者停止之后,
// 否则最后一条正在写入的帧可能落在关闭之后。
func (r *Ring) Push(f *Frame) error {
	if f == nil {
		return ErrNil
	}
	for {
		if r.closed.Load() {
			return ErrClosed
		}
		head := r.head.Load()
		if head-r.tail.Load() < r.size {
			r.buf[head&r.mask] = f
			r.head.Store(head + 1)
			if r.policy == Block {
				r.mu.Lock()
				r.notEmpty.Broadcast()
				r.mu.Unlock()
			}
			return nil
		}
		if r.policy == Drop {
			return ErrFull
		}

		r.mu.Lock()
		if r.closed.Load() {
			r.mu.Unlock()
			return ErrClosed
		}
		// 拿到锁后重新检查,防止消费者在上面的检查与 Wait 之间
		// 完成出队并广播,造成丢失唤醒。
		if r.head.Load()-r.tail.Load() >= r.size {
			r.notFull.Wait()
		}
		r.mu.Unlock()
	}
}

// Pop 由唯一的消费者调用。
//
// Drop 策略在空时返回 ErrEmpty。Block 策略等待生产者写入;
// 缓冲区关闭且已排空时返回 ErrClosed。关闭后仍可继续 Pop
// 出已经入队的数据,直到排空为止。
func (r *Ring) Pop() (*Frame, error) {
	for {
		tail := r.tail.Load()
		if r.head.Load() != tail {
			f := r.buf[tail&r.mask]
			r.buf[tail&r.mask] = nil
			r.tail.Store(tail + 1)
			if r.policy == Block {
				r.mu.Lock()
				r.notFull.Broadcast()
				r.mu.Unlock()
			}
			return f, nil
		}
		if r.closed.Load() {
			return nil, ErrClosed
		}
		if r.policy == Drop {
			return nil, ErrEmpty
		}

		r.mu.Lock()
		if r.closed.Load() && r.head.Load() == r.tail.Load() {
			r.mu.Unlock()
			return nil, ErrClosed
		}
		if r.head.Load() == r.tail.Load() {
			r.notEmpty.Wait()
		}
		r.mu.Unlock()
	}
}

// Close 关闭缓冲区并唤醒所有等待者。
//
// 已经入队的数据不会被丢弃:Block 策略下消费者可以继续 Pop
// 到排空为止,之后返回 ErrClosed。
func (r *Ring) Close() {
	if r.closed.Swap(true) {
		return
	}
	r.mu.Lock()
	r.notFull.Broadcast()
	r.notEmpty.Broadcast()
	r.mu.Unlock()
}

// Len 返回当前已入队但未出队的帧数。
func (r *Ring) Len() int {
	return int(r.head.Load() - r.tail.Load())
}

// Cap 返回向上取整后的实际容量,一定是 2 的幂。
func (r *Ring) Cap() int {
	return int(r.size)
}

// Policy 返回创建时选择的满/空处理策略。
func (r *Ring) Policy() Policy {
	return r.policy
}

// Closed 报告缓冲区是否已经关闭。
func (r *Ring) Closed() bool {
	return r.closed.Load()
}

实现中有三个值得说明的取舍。第一,head 和 tail 使用 atomic.Uint64,生产者只写 head、消费者只写 tail,正常路径没有共享锁;Block 策略的等待才进入互斥锁慢路径。第二,Close 不会丢弃已经入队的数据,消费者可以继续 Pop 到排空,之后才返回 ErrClosed;这要求调用方先停止生产者,再关闭队列。第三,PutFrame 只做截断和清零,不释放 Data 的底层数组,从而让下一次 GetFrame 直接复用容量。

测试覆盖容量取整、FIFO 顺序、Drop 策略的满/空错误、Block 策略的生产者背压与消费者等待、关闭唤醒、对象池重置以及 Reset 保留容量。运行方式:

cd .local/checks/gs-practice-lab
gofmt -l ch13/ring
go vet ./ch13/ring/
go test ./ch13/ring/ -count=1
go test -race ./ch13/ring/ -count=1

Go 的 channel 已经高度优化,绝大多数场景不应该自己实现环形缓冲区。需要自己实现的理由通常只有三个:需要明确的丢弃策略、需要把对象池和队列结合以减少分配、需要在同一套结构上做批量出队或零拷贝。若只是普通的 goroutine 通信,channel 更安全,也更不容易在关闭和阻塞语义上出错。

七、验证:race、bench 与 profile

无锁代码最危险的问题是“测试能过,线上偶发错乱”。并发读写测试必须配合 -race 运行;性能判断则要用 benchmark 和 profile,而不是只看代码里有没有原子操作。

数据竞争用 go test -race ./ch13/ring/ -count=1 验证,分配用 go test -bench . -benchmem 观察 allocs/op 与 B/op,CPU 与内存热点分别用 -cpuprofile、-memprofile 定位。示例值只能作为方向:如果 allocs/op 在池化后没有下降,通常说明对象仍然逃逸,或者取用时又发生了新的分配。此时应先看逃逸分析和调用链,而不是继续加大池容量。

八、选型边界

无锁和池化都是“用复杂度换性能”的工具。单机房间数不多、消息量不大时,一把互斥锁加普通 channel 往往更便宜,也更容易维护。只有当 profile 明确显示锁竞争、分配或 GC 是瓶颈时,才值得引入 SPSC 环形队列和对象池。如果状态需要跨多个字段强一致,优先选择 Actor 或互斥锁;如果只是计数或快照发布,原子操作足够。上一篇 /game-server-in-action-13-1/ 讨论的 Actor 解决状态归属问题,本篇的环形缓冲区解决消息传递成本问题,两者可以组合使用。

小结

无锁结构的价值在于把竞争从锁转移到原子游标和队列策略上,但它只适用于边界清晰的场景:原子计数、快照发布、SPSC 流水线。内存复用的前提是归还时彻底重置,切片截断不等于释放引用;逃逸分析决定对象落在栈上还是堆上,最终仍要用 allocs/op 验证。环形缓冲区的正确性依赖 2 的幂容量、单调游标、先写数据后发布游标,以及明确的满/空策略。

常见错误与边界

  • 把带互斥锁的实现叫作无锁,竞争点没有减少,反而多了原子操作开销。
  • 多生产者共用 SPSC 环形队列,导致游标覆盖或消息错乱。
  • 先推进 head 再写槽位数据,消费者可能读到半条消息。
  • 池化对象归还时不重置字段,复用后携带上一条消息的状态。
  • 切片只做 s[:0],底层数组仍引用大对象或指针元素。
  • 把 sync.Pool 当连接池使用,依赖池中对象长期存在。
  • 在没有 profile 证据的情况下引入无锁结构,复杂度上升但吞吐不变。
  • 关闭队列时先关消费者再停生产者,最后一批消息被静默丢弃。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist