12.2 满员判断与匹配锁定
上一节我们把「谁能改成员集合」收敛到了 entryAllowedLocked 一处,看上去入口控制已经很干净。但干净的判据并不等于安全的实现——如果判断和写入之间隔着一次解锁,那么两个线程就都能看到同一个空位。这是并发编程里最经典的 check-then-act 竞态,也是组队服务最高频的线上事故来源。
更隐蔽的一层在匹配上。匹配服务开房间之前必须先确认「队伍满员」,而满员这件事和「队伍还在组建」是两回事:玩家可以在匹配请求刚发出、还没锁定的那个窗口里再拉一个人进来。结果就是一个五人小队被送进四人副本,或者一次匹配发出了两张不同成员名单的票据。
本节把这两处竞态一次修掉:满员判断与成员写入合并为一个临界区,满员校验与匹配锁定合并为一次原子操作。全部用 go test -race 真跑。
竞态从哪来:先判断、后写入
先看一个「看起来对」的写法。很多初版代码就是这样的:
// snippet: 反面示例,不要照抄
func (p *Party) Invite(actor, target PlayerID) error {
if p.Count() >= MaxMembers { // ← 这里解锁了
return ErrPartyFull
}
return p.AddMember(actor, target) // ← 这里才加锁写入
}
Count() 内部加锁、AddMember() 内部也加锁,单看每个方法都没问题,但两次加锁之间是一个真空期。两个并发的邀请请求会这样交错:
时刻 goroutine A(邀请 甲) goroutine B(邀请 乙)
t1 Count() 返回 3,未满
t2 Count() 返回 3,未满
t3 members[甲] = ...(此时 4 人)
t4 members[乙] = ... ← 5 人,超编
要触发它,只需要队长手快连点两次邀请,或者两个客户端几乎同时接受邀请。它不会每次都发生,所以测试常常漏掉,但压测一上量就必然出现——这类「低频但必然」的 bug 是最难排查的一种。
为什么单测抓不到它?因为单测通常是串行调用:先邀请甲、断言成功,再邀请乙、断言被拒。在串行世界里 t1 → t3 → t2 → t4 这个顺序永远不会发生,代码看起来完美。要暴露它,必须让两个邀请真的同时跑到 Count() 和 AddMember() 之间——这就是下面必须用 -race 和真 goroutine 的原因。
判断和写入必须处在同一个临界区里。判断本身不是问题,问题是判断与写入之间被允许插队。
把「查满员 + 写成员」压进一个临界区
下面这段是 L1,go test 可以直接跑。TryJoin 是唯一的入队写路径,它把状态检查、队长检查、重复检查、满员检查和写入全部放进一次 Lock/Unlock 之间:
// file: internal/party/lock.go
// tier: L1
// verify: go test ./internal/party/ -run TestTryJoin -race
package party
// MatchID 是匹配服务给这次匹配分配的单号。锁定后队伍记住它,用于对账
// 「这支队伍到底是在哪一次匹配里被锁的」。
type MatchID uint64
// MatchTicket 是锁定匹配那一刻冻结下来的成员名单。匹配服务只认这张票据,
// 不再回头读队伍对象——队伍在匹配期间怎么抖动都影响不了已经发出的名单。
type MatchTicket struct {
MatchID MatchID
Members []PlayerID
}
// Full 判断队伍是否已满员。注意它和 Count 一样只是读快照:真正安全的满员
// 判断发生在 TryJoin 的临界区里,绝不能是「先 Full() 再 AddMember()」。
func (p *Party) Full() bool {
p.mu.Lock()
defer p.mu.Unlock()
return len(p.members) >= MaxMembers
}
// TryJoin 是唯一的入队写路径,把「查满员」和「写成员」压进同一个临界区。
// 两个并发邀请里,只有一个能观察到空位并占到它,另一个必然看到 ErrPartyFull。
// 这是 12.2 的核心修复点:把读-改-写做成原子。
func (p *Party) TryJoin(actor, target PlayerID) (bool, error) {
p.mu.Lock()
defer p.mu.Unlock()
if p.state != StateForming {
return false, ErrPartyLocked
}
if actor != p.leader {
return false, ErrNotLeader
}
if _, ok := p.members[target]; ok {
return false, ErrAlreadyMember
}
if len(p.members) >= MaxMembers {
return false, ErrPartyFull
}
p.members[target] = &Member{PlayerID: target, Role: RoleMember, Online: true}
p.order = append(p.order, target)
return true, nil
}
// LockForMatch 把「满员校验」和「状态置为锁定」也做成原子操作。这里有一个
// 真实事故:若先 Full() 判断、再另行 Lock(),两次调用之间可能刚好有人入队,
// 结果一个超编队伍被送进匹配。LockForMatch 把这两步合并,并返回冻结名单。
func (p *Party) LockForMatch(actor PlayerID, mid MatchID) (*MatchTicket, error) {
p.mu.Lock()
defer p.mu.Unlock()
if p.state != StateForming {
return nil, ErrPartyLocked
}
if actor != p.leader {
return nil, ErrNotLeader
}
if len(p.members) < MaxMembers {
return nil, ErrPartyFull // 未满员不允许锁定匹配
}
members := make([]PlayerID, 0, len(p.order))
for _, id := range p.order {
members = append(members, id)
}
if err := p.setStateLocked(StateLocked); err != nil {
return nil, err
}
return &MatchTicket{MatchID: mid, Members: members}, nil
}
// Unlock 把队伍从锁定态放回组建态。匹配失败、玩家取消、副本结算都走它。
// 队长离线由 12.3 的迁移先补一个新队长,所以这里仍要求 actor 是队长。
func (p *Party) Unlock(actor PlayerID) error {
p.mu.Lock()
defer p.mu.Unlock()
if p.state != StateLocked {
return ErrPartyLocked
}
if actor != p.leader {
return ErrNotLeader
}
return p.setStateLocked(StateForming)
}
// EnterDungeon 是锁定 -> 副本的单向推进,只在匹配成功后被调用。
func (p *Party) EnterDungeon(actor PlayerID) error {
p.mu.Lock()
defer p.mu.Unlock()
if actor != p.leader {
return ErrNotLeader
}
return p.setStateLocked(StateInDungeon)
}
注意 TryJoin 返回 (bool, error) 而不是只返回 error。这个布尔值在调用方那里很关键:「没加进去」和「加进去了」必须是两个可分辨的结果,因为上层要据此决定要不要给新成员发 CmdPartyInfoSync 0x0702。如果只回一个 error,调用方就得靠猜——猜错就会给没进队的人同步一份队伍信息,客户端于是显示他在队里,实际却不在。
匹配锁定:满员校验与置锁必须原子
LockForMatch 修的是第二类竞态,比第一类更隐蔽。假设你先判断满员、再单独调用一个 Lock():
时刻 队长线程(提交匹配) 队友线程(拉人)
t1 Full() == true,满员
t2 TryJoin(丙) → 成功,队伍变 5 人
t3 Lock() 成功,状态 = Locked
t4 用 5 人名单开房 → 四人副本塞进五人
LockForMatch 把「校验满员」和「置为锁定」放进同一个临界区,中间没有真空期。并且它顺手做了一件同样重要的事:返回一份冻结的成员名单(MatchTicket)。匹配服务拿到票据后只认票据里的 Members,不再回头读队伍对象——这样即使队伍在等待匹配期间发生了掉线、迁移(12.3 的内容),已经提交的匹配名单也不会中途变形。
这里还有一个语义选择值得说明:未满员时拒绝锁定匹配。有些玩法允许不满员进副本,那应该是一个显式的参数(比如允许 2–4 人),而不是让 LockForMatch 悄悄放过。把规则写成代码里的一次判断,比写进注释里让人记着要可靠得多。
锁定不是终点,它有一条完整的生命周期,每个出口都对应一个明确的调用方:
| 出口 | 触发者 | 调用 | 队伍回到 |
|---|---|---|---|
| 匹配成功、进入副本 | 匹配服务回调 | EnterDungeon | StateInDungeon |
| 匹配失败 / 超时 | 匹配服务回调 | Unlock | StateForming |
| 玩家取消匹配 | 队长 | Unlock | StateForming |
| 副本结算完成 | 副本实例 | Unlock | StateForming |
MatchID 在这里的价值是对账:当匹配服务的回调迟到——比如房间已经因为超时被回收,票据才姗姗来迟——队伍可以比对回调里的 MatchID 与当前锁定记录的 MatchID,不一致就直接丢弃这次回调。这跟卷一 11.1 幂等窗口
讲的思路是同一类:用一次性的业务单号把「重复到达的旧消息」识别出来,而不是指望消息不重复。
并发验证:go test -race
这类 bug 的特点是「不并发就不出现」,所以验证方式必须是真的并发。下面这段测试节选起 64 个 goroutine 同时抢最后一到三个空位,然后断言成员数永远不会越过 MaxMembers:
// snippet: internal/party/party_test.go(节选)
// TestTryJoinRace 是 12.2 的核心证据:把满员判断和写入拆开就会超编,
// 合进一个临界区后,无论并发多凶,成员数都不会越过 MaxMembers。
func TestTryJoinRace(t *testing.T) {
leader := PlayerID(1)
p := NewParty(PartyID(9), leader)
const attempts = 64
var wg sync.WaitGroup
var mu sync.Mutex
joined := 0
wg.Add(attempts)
for i := 0; i < attempts; i++ {
go func(i int) {
defer wg.Done()
ok, err := p.TryJoin(leader, PlayerID(1000+i))
if err != nil && !errors.Is(err, ErrPartyFull) {
t.Errorf("unexpected err: %v", err)
}
if ok {
mu.Lock()
joined++
mu.Unlock()
}
}(i)
}
wg.Wait()
if joined != MaxMembers-1 {
t.Fatalf("joined: want %d, got %d", MaxMembers-1, joined)
}
if got := p.Count(); got != MaxMembers {
t.Fatalf("count: want %d, got %d", MaxMembers, got)
}
}
验证命令是 go test ./internal/party/ -race。-race 不是可选项:没有它,这个测试在没修 bug 的版本上也可能「碰巧」通过,只有 race detector 会把那次并发的读-改-写标红。测试同时断言 joined == MaxMembers-1(恰好 3 人成功入队),这比只断言总数更强——它排除了「多加了又删掉」这种蒙混过关。
本节小结
| 竞态 | 错误写法 | 修复 |
|---|---|---|
| 两个邀请都看到空位 | Count() 后 AddMember() | TryJoin 单临界区 |
| 满员校验与置锁之间被插队 | Full() 后 Lock() | LockForMatch 原子化并冻结名单 |
| 匹配期间名单变形 | 匹配服务回读队伍对象 | MatchTicket 快照 |
判据只有一条:任何「先判断、再写入」的代码,只要判断和写入不在同一个临界区,它就是错的。把它记成肌肉记忆,能省掉后面一大半并发事故。
阅读导航:上一节:12.1 队伍状态与入口控制 · 下一节:12.3 掉线处理与队长迁移 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。