sync.Once 与 sync.Cond 源码与并发控制陷阱

sync.Once 与 sync.Cond 源码与并发控制陷阱

一、核心概念与架构设计

这两个原语解决的是并发控制里方向相反的两个问题:

  • sync.Once 要保证一件事在并发下只发生一次 。它有性能上限要求:Do 的首次调用之后的每一次调用都要近乎免费,否则单例初始化的 fast-path 会成为热点路径的开销。
  • sync.Cond 要保证条件不满足时等待者精确挂起、条件变化时精确唤醒。它补的是 Mutex 的盲区:锁只提供互斥,不提供"等到某个条件成立再继续"的能力。

两者的坑都很集中。Once 的坑在 panic 语义:f 恐慌之后,后续调用到底会不会重试?Cond 的坑在信号时效:Signal 发出时如果没人 Wait,通知就永远消失。两个问题的答案都写在源码里。

二、深度原理与底层剖析

2.1 sync.Once:fast-path 一次原子读

go 复制代码
// 位于 sync/once.go
type Once struct {
    done atomic.Uint32 // 0 = 未执行,1 = 已执行
    m    Mutex
}

func (o *Once) Do(f func()) {
    // fast-path:done 已经是 1,直接返回,成本一次原子读(x86 上普通 LOAD)
    if o.done.Load() == 0 {
        o.doSlow(f)
    }
}

func (o *Once) doSlow(f func()) {
    o.m.Lock()
    defer o.m.Unlock()
    if o.done.Load() == 0 { // 双重检查:抢到锁时可能别人已完成
        defer o.done.Store(1) // 注意:f panic 时这行 defer 依然执行
        f()
    }
}

逐层拆解:

fast-path 的成本构成 。atomic.Uint32.Load 在 x86/ARM64 无竞争下编译为一条普通加载指令(Go 的原子 Load 默认 seq-cst 语义,但在 x86 上 LOAD 本身就满足)。也就是说,一千万个 goroutine 反复过 Do,总成本就是一千万次 L1 缓存命中级别的读,几纳秒一次。这是把 done 单独拆成原子变量而不是塞进 Mutex 状态位的原因。

双重检查锁定(Double-Check Locking) 。慢路径持锁后必须再读一次 done:两个并发 Do,A 先抢到锁执行 f,B 在锁上排队;A 完成释放,B 拿锁、复查发现 done 已是 1,直接返回。没有第二次检查就会执行两次 f。

panic 语义:不重试 。defer o.done.Store(1) 在函数返回时执行,而 panic 的堆栈展开(unwinding)过程中 defer 照常运行。所以 f 恐慌后,done 依然被置为 1,后续所有 Do 调用直接返回,f 不会重试。这与文档一致:"if f panics, Do considers it to have returned"。第三节的示例会实测验证这一点。这个设计是合理的:f 恐慌通常意味着初始化逻辑有 bug,重试只会放大问题;需要重试语义的场景应该自己包装 recover 与状态复位。

Go 1.21 新增的 OnceFunc / OnceValue / OnceValues 把"f 的返回值缓存下来"这个高频模式标准化了,同时修掉了用户自己实现时常见的两类错误:忘记在 f 之前先 Do 完成初始化就并发读取缓存变量,以及 f panic 后缓存变量处于半初始化状态。Go 1.23 又有一个实现级修复:OnceFunc 系列把 f 放进独立 goroutine 执行再传播 panic,保证恐慌堆栈完整(此前 panic 会被 recover 再重抛,丢失原始栈帧)。业务代码里手写 double-checked 单例的场景,建议直接换成 OnceFunc。

2.2 sync.Cond:notifyList 与 runtime 的挂起机制

go 复制代码
type Cond struct {
    noCopy noCopy       // 编译期禁止拷贝
    L Locker           // 关联的锁(通常是 *sync.Mutex / *sync.RWMutex)
    notify notifyList  // runtime 侧的等待队列
    checker copyChecker // 检测运行期 Cond 被拷贝
}

func (c *Cond) Wait() {
    t := runtime_notifyListAdd(&c.notify) // 1. 先登记 ticket(原子自增)
    c.L.Unlock()                          // 2. 释放锁,避免死锁
    runtime_notifyListWait(&c.notify, t)  // 3. 挂起,等 ticket 被通知
    c.L.Lock()                            // 4. 醒来后重新持锁
}

Wait 的四步顺序是 Cond 正确性的全部秘密:

  1. 先拿 ticket 再解锁 。ticket 是全局递增的序号,notifyListWait 按序号挂起。这个顺序保证了"从 Wait 挂起到被唤醒"之间,任何 Signal/Broadcast 都能覆盖到本等待者。
  2. 解锁必须发生在登记之后。反过来就死锁:持锁挂起,通知者拿不到锁,永远无法改条件。
  3. 醒来后重新持锁。这让"检查条件 - Wait - 处理数据"整体处于锁的保护下。

notifyList 的实现位于 runtime/sema.go,与信号量的 sudog 队列同源:内部维护一对 ticket 计数器(wait 与 notify),Signal 把 notify+1 并唤醒一个对应 ticket 的等待者,Broadcast 把 notify 直接跳到 wait 当前值并唤醒区间内全部等待者。它比信号量队列轻,因为不需要处理"锁交接"语义,只需要精确的序号配对。

2.3 丢信号:Cond 没有"记忆"

Cond 的通知是一次性的、无记忆的 。Signal 在没有等待者时调用,什么都不会发生,这个通知不会存起来等下一个 Wait。由此推出 Cond 的两条铁律:

铁律一:必须用 for 循环重检条件。

go 复制代码
for !condition() {   // 用 for,不能用 if
    c.Wait()
}

原因有三重:Broadcast 唤醒的多个等待者里,只有第一个能消费到资源,其余醒来发现条件已不成立;OS 层面存在虚假唤醒(spurious wakeup)的可能;条件可能在 Wait 返回前又被第三方改掉。写成 if 的代码在低并发测试下可能全部通过,上线后在高争抢下随机出错,这类 bug 的复现成本极高。

铁律二:先改条件再 Signal,且条件修改必须在持锁状态下。 Signal/Broadcast 允许不持锁调用(语法上合法),但会引入"检查条件与 Wait 之间"的窗口竞态,正确姿势是持锁改条件、持锁(或改完立即)发通知。

三、完整可运行示例

go 复制代码
package main

import (
	"fmt"
	"sync"
	"time"
)

// 演示一:Once.Do 中的函数 panic 后,后续调用不会再执行 f。
// 源码实现里 f() 外包裹了 defer o.done.Store(1),
// 即使 panic 在展开堆栈时也会先把 done 置为 1。
func oncePanic() {
	var once sync.Once
	calls := 0
	f := func() {
		calls++
		panic("boom")
	}

	func() {
		defer func() { recover() }() // 吞掉第一次 panic
		once.Do(f)
	}()

	once.Do(f) // 第二次调用:done==1,直接返回,f 不再执行
	fmt.Printf("  f 实际执行次数: %d(panic 后 done 仍被置位,后续调用不再重试)\n", calls)
}

// 演示二:sync.Cond 正确用法。消费者先持锁检查条件并 Wait,
// 生产者修改条件后 Broadcast 唤醒全部等待者。
func condCorrect() {
	var (
		mu    sync.Mutex
		cond  = sync.NewCond(&mu)
		queue []int
		done  bool
		wg    sync.WaitGroup
	)

	for i := 0; i < 3; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			for {
				mu.Lock()
				// 必须用 for 循环重新检查条件:
				// Wait 被唤醒后条件可能已被其他消费者消费掉。
				for len(queue) == 0 && !done {
					cond.Wait() // 原子地释放锁并挂起
				}
				if len(queue) == 0 && done {
					mu.Unlock()
					return
				}
				v := queue[0]
				queue = queue[1:]
				mu.Unlock()
				_ = v
			}
		}(i)
	}

	mu.Lock()
	for i := 0; i < 6; i++ {
		queue = append(queue, i)
	}
	done = true
	cond.Broadcast() // 唤醒所有等待者,让其重新检查 done
	mu.Unlock()
	wg.Wait()
	fmt.Println("  3 个消费者全部退出(Broadcast + for 循环重检)")
}

// 演示三:经典的"丢信号"错误:先 Signal 后 Wait。
// Cond 本身不记忆历史唤醒,若等待者尚未进入 Wait,
// Signal 发出的通知会直接消失。
func condMissedSignal() {
	var (
		mu   sync.Mutex
		cond = sync.NewCond(&mu)
		wg   sync.WaitGroup
	)

	wg.Add(1)
	go func() {
		defer wg.Done()
		time.Sleep(50 * time.Millisecond) // 模拟:等待者晚到了一步
		mu.Lock()
		fmt.Println("  消费者开始 Wait(生产者的 Signal 早已丢失)")
		cond.Wait() // 将永远等不到通知,只能靠超时兜底
		mu.Unlock()
		fmt.Println("  消费者被唤醒")
	}()

	mu.Lock()
	cond.Signal() // 此时消费者还没 Wait,通知凭空消失
	mu.Unlock()

	time.Sleep(200 * time.Millisecond)
	// 生产补救:再广播一次模拟超时兜底
	mu.Lock()
	cond.Broadcast()
	mu.Unlock()
	wg.Wait()
	fmt.Println("  (Signal 先于 Wait 发生 => 通知丢失,需 for 循环 + 超时兜底)")
}

func main() {
	fmt.Println("== 演示一:sync.Once 的 panic 语义 ==")
	oncePanic()
	fmt.Println("== 演示二:sync.Cond 正确的生产者-消费者 ==")
	condCorrect()
	fmt.Println("== 演示三:sync.Cond 丢信号陷阱 ==")
	condMissedSignal()
}

输出与解读:

复制代码
== 演示一:sync.Once 的 panic 语义 ==
  f 实际执行次数: 1(panic 后 done 仍被置位,后续调用不再重试)
== 演示二:sync.Cond 正确的生产者-消费者 ==
  3 个消费者全部退出(Broadcast + for 循环重检)
== 演示三:sync.Cond 丢信号陷阱 ==
  消费者开始 Wait(生产者的 Signal 早已丢失)
  消费者被唤醒
  (Signal 先于 Wait 发生 => 通知丢失,需 for 循环 + 超时兜底)

演示三值得动手改一改:把消费者的 time.Sleep(50ms) 去掉,让它先于生产者进入 Wait,Signal 就能立即唤醒它,程序瞬间跑完。同一个程序,几十毫秒的时序差把"正确"变成了"挂死",这就是 Cond 类 bug 的本质:正确性依赖时序,而时序在测试环境不可控。

四、生产踩坑与调优建议

1. 单例初始化优先用包级 sync.Once 或 OnceFunc,而不是 init()。 init 在进程启动时串行执行,初始化慢会拖长启动时间且无法失败恢复;Once 把初始化推迟到首次使用,配合懒加载可以跳过用不到的重资源模块(如某些存储 driver)。注意 Once 包住的内容里不要调用同一个 Once(自死锁)。

2. 初始化失败的语义要自己设计。 Once 的 panic 不重试语义意味着:连接数据库失败就 panic 的话,这个服务永远不会有第二次初始化机会。正确的分层是 Once 里只做"决定成败的策略",把可重试的失败用返回值暴露出去,由调用方决定重试或降级。

3. Cond 适合资源池与优雅关停,消息投递不要用它。 Cond 的通知不排队、无容量,拿它做"事件投递"必然丢事件;带缓冲的 channel 或 ring buffer 才是事件队列的正解。Cond 的舒适区是:多等待者共享一个互斥保护的资源池,需要"池空挂起、池非空唤醒"语义。另外 Go 1.24 的 testing/synctest 包可以在"时间气泡"里精确测试这类时序敏感代码,值得在并发测试中引入。

4. Cond.Wait 持有的锁必须是同一个。 sync.NewCond(&mu) 之后,所有 Wait/Signal 的参与方必须用同一把 mu,且 Wait 期间不得提前 Unlock。混用两把锁会出现"通知者与等待者在不同临界区操作同一条件"的窗口,症状是偶发的重复消费或条件覆盖。

5. 广播风暴的代价评估。 Broadcast 一次性唤醒全部等待者,100 个等待者醒来后抢同一把锁,会出现惊群式的锁排队。高并发下如果等待者很多而资源每次只满足一个,把 Broadcast 换成循环 Signal(每次资源就绪 Signal 一个),或者在条件检查前加一层资源配额预判,能显著降低无效唤醒。

五、总结与下篇预告

Once 用一个原子 done 加双重检查锁定,把"并发下只执行一次"压缩到 fast-path 一次原子读的成本,panic 后 done 依然置位,语义是"视为已完成"而非"重试"。Cond 基于 runtime 的 notifyList 序号配对机制实现精确挂起与唤醒,正确性依赖两条铁律:for 循环重检条件、先改条件后通知;它没有信号记忆,时序错了通知就会凭空消失。

相关推荐
阿钱真强道1 小时前
21 嵌入式操作系统 | JSON 与 json-c:生成与解析
c语言·开发语言·json·json-c
j7~1 小时前
【Python】(篇六)《基础语法(函数、列表和元组、字典、文件)》---详解
开发语言·python·文件·函数·字典·编程学习·元组与列表
montEvergreen1 小时前
位运算不是魔法——AV1 配置解析实战
后端
重生之小比特1 小时前
【C++进阶】红黑树的实现
java·开发语言·c++
纪念 2291 小时前
C++算法(一)
开发语言·c++·算法
程序猿乐锅2 小时前
【黑马点评 | 第九篇】秒杀优化-异步实现
java·spring boot·redis·后端·spring·中间件·mybatis
Wx-bishekaifayuan2 小时前
springboot户外登山社交小程序19787-计算机课程设计、毕业设计
spring boot·后端·python·spring·elasticsearch·django·课程设计
+VX:Fegn08952 小时前
计算机毕业设计|基于springboot + vue外卖点餐系统(源码+数据库+文档)
数据库·vue.js·spring boot·后端·课程设计
爱签AI电子合同2 小时前
电子合同上手成本怎么测?易用性维度专项测评
服务器·人工智能·智能合约·企业微信