一段初始化代码用 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. 自测
Once的"三个事实"里,哪一个是"失败后不再重试"的根源?怎么改才能重试?WaitGroup的三种误用里,哪一种是"值复制"导致的?go vet能发现哪些?- 为什么
sync.Mutex不可重入会造成"自死锁"?正确改法是什么? RWMutex的"读锁升写锁"为什么会死锁?- "只是加一"为什么该用
atomic?"channel 传值"与"锁保护"的分工是什么? - 判断"是否需要同步原语"的那个问题是什么?