2427 字
约 8 分钟
14
S09-A20 · `sync` 包的三个误用:`Once`、`WaitGroup`、`Mutex`
2024-12-31
2026-11-10

一段初始化代码用 sync.Once 包了一个可能失败的初始化:once.Do(initDB)。第一次 initDB 失败了,once 仍然标记为"已执行",于是后续调用都不再重试——服务带着"没初始化"的状态跑了一整天。

sync 包的工具都"看起来简单",而它们各自的语义边界恰好与"错误处理、复制、重入"这三件事相关。它们的另一个共同点是"出错时不一定 panic":Once 静默跳过、WaitGroup 静默提前返回、Mutex 静默死锁——于是问题表现为"功能不对"或"卡住",而不是"报错"。
而这三件事在并发代码里最容易出错。


1. Once 的三个事实

事实 含义
只执行一次 无论成功失败,都算"执行过"
无返回值 失败信息必须存在外部变量里
不可重置 没有 Reset(除非重新赋值 Once)

第一项是最常见的坑:once.Do 不理解"成功",它只保证"调用了一次函数"。第一项的"语义"值得读原文:Do 保证"函数被调用完成一次",它不关心函数内部发生了什么。所以"失败要重试"必须由外部状态表达。

第三项的后果很具体:结构体里放了 sync.Once 值,而结构体被复制(如作为参数传值、放进 slice 扩容),于是"初始化被执行两次"——而它在单次运行里看起来正常。
第三项与"复制"有关:Once 内含 Mutex 与 atomic,复制它会导致"两个独立的 Once",于是"初始化被执行两次"。

// 反例:失败也被记为"已执行"
var once sync.Once
var db *DB
func getDB() *DB {
    once.Do(func() {
        db, err = connect()      // 失败时 db 为 nil,但 once 已生效
    })
    return db                    // 永远返回 nil
}

// 正解:失败时重置(用指针 + 显式状态)
var (
    mu     sync.Mutex
    db     *DB
    inited bool
)
func getDB() (*DB, error) {
    mu.Lock(); defer mu.Unlock()
    if inited { return db, nil }
    var err error
    db, err = connect()
    if err != nil { return nil, err }    // 失败不置 inited → 下次重试
    inited = true
    return db, nil
}

2. WaitGroup 的三种误用

误用 后果
Add 写在 go 之后 计数漏加(可能提前退出)
Done 少了 永远阻塞
wg 被复制(传值) 各自独立,等待失效

第一项是"经典竞态":go f() 之后才 Add(1),而 Wait() 可能先返回。第一项的"经典"在于它在低并发下从不出错:go 与 Add 之间的间隙极小,于是"偶尔提前返回"。而这类"偶发"最难定位。
正确顺序是 Add → go → Done。

第三项与 Once/Mutex 的"复制"问题是同一个根因:这些类型内含状态,复制会分裂状态。所以 go vet 的 copylocks 是"必须开"的检查。
第三项最隐蔽:把 WaitGroup 作为值传给函数,于是函数内部操作的是"副本",而调用方的 Wait 永远等不到。

// 反例一:Add 在 go 之后
for i := 0; i < 3; i++ {
    go work(&wg)          // 里面 Done(),但外面还没 Add
}
wg.Wait()                 // 可能立刻返回

// 反例二:WaitGroup 传值
func run(wg sync.WaitGroup) { wg.Done() }   // 操作副本

// 正解:先 Add,传指针(或用闭包捕获)
for i := 0; i < 3; i++ {
    wg.Add(1)
    go func() { defer wg.Done(); work() }()
}
wg.Wait()

3. Mutex 的四种边界

边界 说明
不可重入 同一 goroutine 重复 Lock 会死锁
复制即失效 值复制后是两个独立的锁
Unlock 未持有则 panic 需配对
读写锁不升级 RLock 后 Lock 会死锁

第一项对"习惯 Java 的人"最容易踩:sync.Mutex 不是可重入锁第一项的"心理来源"是语言迁移:Java 的 synchronized 是可重入的,于是"加锁方法调用另一个加锁方法"在 Java 里正常,在 Go 里死锁。
,所以"加锁方法调用另一个加锁方法"会死锁。

第四项的"变体"是"读锁在写锁持有者里申请":看起来"我只读一下",但读锁要等写锁释放——而死锁就此形成,且 pprof 里只显示"两个 goroutine 都在等"。
第四项(RWMutex 不升级)同样:持读锁时申请写锁会死锁,而它的写法看起来"只是多一层"。

// 反例:不可重入导致自死锁
func (s *S) A() { s.mu.Lock(); defer s.mu.Unlock(); s.B() }
func (s *S) B() { s.mu.Lock(); defer s.mu.Unlock() }   // 死锁

// 正解:拆出"无锁内部方法",公开方法只加一次锁
func (s *S) A() { s.mu.Lock(); defer s.mu.Unlock(); s.bLocked() }
func (s *S) bLocked() { /* 假设已持锁 */ }

4. "复制"为什么是通病

类型 含不可复制字段
sync.Mutex 是
sync.WaitGroup 是
sync.Once 是
sync.Map 是(含 Mutex)

这些类型都应当以指针传递、作为结构体字段而非值。而 go vet 的 copylocks 检查能自动发现"复制锁"——它应当在 CI 里开启。

// CI 里开 copylocks(默认包含在 go vet 中)
go vet ./... | grep -i copylocks
// 若命中:说明某处把 Mutex/Once/WaitGroup 复制了

"复制"这件事还有一个"隐形"版本:把 sync.Mutex 放进结构体,而结构体被放进 map 或 slice——本身不复制,但"取出来赋值给变量"就是一次复制。

// 反例:从 map 取值时复制了含锁的结构体
type Counters struct {
    mu sync.Mutex
    n  int64
}
m := map[string]Counters{"a": {}}
c := m["a"]          // 复制!c.mu 与 m["a"].mu 是两个锁
c.mu.Lock()          // 锁的是副本,m["a"] 完全没保护
// 正解:存指针(map[string]*Counters),或把锁与数据分离

5. 什么时候该用哪个

需求 工具
一次性初始化 Once(注意失败语义)
等待一组任务 WaitGroup
保护共享数据 Mutex / RWMutex
跨 goroutine 传值 channel(不是同步原语)
计数 atomic(不是 Mutex)

第五项常被忽略:"只是加一"用 atomic 更轻第五项的量化差别很明显:atomic.AddInt64 是单条 CPU 指令级操作,而 Mutex 加解锁要两次原子操作 + 可能的调度,在高频路径上差一个数量级。
,而用 Mutex 会引入"锁竞争 + 可能的死锁"。

第四项是 Go 的"语言建议":channel 用于传递,锁用于保护。混用(用 channel 模拟锁)通常更慢更乱。

"死锁"的定位靠"看所有 goroutine 的栈",而不是猜:

// 方法一:SIGQUIT(进程会打印所有 goroutine 栈)
kill -QUIT <pid>
// 方法二:pprof(若已开启)
curl -s localhost:6060/debug/pprof/goroutine?debug=2
// 关键:找"都在 semacquire / sync.(*Mutex).Lock / sync.(*RWMutex).RLock"的栈

6. 排错:五种"sync 相关问题"

现象 成因 处理方向
初始化失败后不再重试 Once 语义 改显式状态
Wait 立刻返回 Add 顺序错 Add 在 go 之前
永远阻塞 Done 少了/传值复制 go vet + 检查
偶发死锁 不可重入/锁顺序 拆分加锁方法
CPU 高但吞吐低 锁竞争 用 atomic/分片

第四项的"防法"是"全局锁顺序":给所有锁定义一个固定的获取顺序(如"先 A 后 B"),于是"交叉等待"不可能发生。而它需要"跨模块的约定"。
第四项在"多个锁"时表现为"偶发":两个 goroutine 以不同顺序获取 A、B 两把锁,于是死锁概率低但存在。

最后一个"演进"建议:先保证正确,再优化竞争。因为"用 Mutex 且正确"比"用 atomic 但写错"好得多——而"竞争"只在高频路径上才是问题(可用 pprof 的 mutex profile 量化)。

// 量化锁竞争(有数据再优化)
go tool pprof http://localhost:6060/debug/pprof/mutex
// 关注:contentions(等待次数)与 delay(累计等待时长)
// 若 delay 只占总时长的 1%,优化锁没有意义

7. 边界:什么情况不必用同步原语

场景 为什么 做法
单 goroutine 访问 无并发 不加锁
只读共享(初始化后不变) 无写 不加锁
通过 channel 传递所有权 无共享 不加锁
用并发安全容器(sync.Map) 已内部同步 不额外加锁

判断口径一句话:"这块数据会不会被两个 goroutine 同时写"。最后一条与"简洁"有关的边界:用锁保护"一小段计算"往往比用 channel 更清楚。因为 channel 的语义是"传值 + 同步",而"我只想保护一个计数器"用 channel 会把代码写复杂。
会,才需要锁;只在读上并发(数据已初始化、不再变),不需要。


8. 自测

  1. Once 的"三个事实"里,哪一个是"失败后不再重试"的根源?怎么改才能重试?
  2. WaitGroup 的三种误用里,哪一种是"值复制"导致的?go vet 能发现哪些?
  3. 为什么 sync.Mutex 不可重入会造成"自死锁"?正确改法是什么?
  4. RWMutex 的"读锁升写锁"为什么会死锁?
  5. "只是加一"为什么该用 atomic?"channel 传值"与"锁保护"的分工是什么?
  6. 判断"是否需要同步原语"的那个问题是什么?
S09-A20 · `sync` 包的三个误用:`Once`、`WaitGroup`、`Mutex`
https://www.clxhxhhr.top/posts/6570/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。