Go 同步原语
同步原语用来约束并发执行的先后关系。Go 的 sync.WaitGroup 等待一组已登记的工作完成,sync.Once 协调一次初始化,sync.Cond 让 goroutine 等待共享状态满足条件。
这些工具解决的问题不同。判断代码是否正确,需要找出等待什么、谁改变状态,以及等待返回后可以相信什么。本文按 Go 1.22.12 讲解三个工具;共享状态的互斥访问仍由 Go 互斥锁解释。
1. 面试核心
先记住这几条,再读正文WaitGroup维护未完成工作的计数。先登记,再启动工作,完成后减少计数;Wait 等到计数为零。它不自动收集错误,也不保护工作之间的共享写入。卡 · 登记工作与等待结果- 当计数为零时,正数
Add必须发生在对应Wait之前。把第一次 Add 放进新 goroutine,可能让 Wait 提前返回;已经登记的父任务在完成前登记子任务,则需要按当时的计数和结束顺序判断。卡 · Add 为什么通常放在启动之前 WaitGroup可以等待下一批独立工作,但新一批 Add 要等上一批所有 Wait 返回。WaitGroup、Once、Cond首次使用后都不能复制。卡 · 复用、计数错误与实现Once.Do不只是决定谁执行初始化。其他调用者也要等初始化结束,Do 返回才能作为读取初始化结果的依据;先设置原子标记再执行初始化,不能提供这个保证。卡 · 初始化完成之后再返回Once记录的是这次调用已经执行,初始化 panic 后不会自动重试,初始化函数递归调用同一个 Once 会死锁。需要失败重试时,要另行设计状态和错误处理。卡 · 失败、panic 与递归调用Cond.Wait在持锁时调用,等待期间释放锁,返回前重新取得锁。通知只表示可以重新检查条件,不能代替条件本身;条件要放在循环里检查。通知不为未来的等待者累计。卡 · 等待的是受锁保护的条件- 这三个工具的等待 API 都没有
context参数。调用方停止等待,不代表内部等待或工作已经结束;取消需要传到真正的阻塞点,并完成退出与清理。卡 · 停止等待与停止工作
2. 等待对象与同步关系
使用同步工具前,先说清需要建立哪种关系:
| 工具 | 等待或约束什么 | 返回后仍不能推断什么 |
|---|---|---|
WaitGroup | 已登记工作的计数归零 | 工作都成功、共享写入没有竞争、Done 后的代码已执行 |
Once | 同一个对象的一次初始化调用结束 | 初始化业务成功、后续修改自动受到保护 |
Cond | 收到通知并重新取得关联锁 | 共享条件一定为真、当前 goroutine 一定优先执行 |
发布是把已经写好的状态,通过同步关系交给其他 goroutine 安全读取。完成计数、初始化完成和通知都有相应的同步保证,但保证依赖正确的调用顺序。同步工具存在于结构体里,不会自动使结构体的所有访问安全。
channel 负责值的交付或通知,可以与这些工具配合;具体的状态保护与任务等待分工见 channel。
3. WaitGroup
3.1 登记工作与等待结果
WaitGroup 是一个等待计数器。Add(n) 增加未完成数量,Done() 等价于 Add(-1),Wait() 等到计数为零;零值可以直接使用。
下面让三个 goroutine 分别计算一个结果:
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
results := make([]int, 3)
for i := range results {
wg.Add(1)
go func(index int) {
defer wg.Done()
results[index] = (index + 1) * (index + 1)
}(i)
}
wg.Wait()
fmt.Println(results)
}
输出 [1 4 9]。每项工作在启动前登记,分别写不同的切片元素,主 goroutine 在 Wait 返回后读取。切片长度和底层数组在这期间没有变化,也没有两个工作写同一个元素。
Done 与它所解除阻塞的 Wait 返回建立同步关系,因此工作在完成之前写好的结果可以在等待后读取。但若多个工作都执行 total++,它们之间仍有共享读改写竞争;最后调用 Wait 不能补上之前缺失的互斥。
计数表示应用声明的完成位置,不是运行时自动追踪的 goroutine 数量。如果先调用 Done,再释放资源或写结果,Wait 就可能在这些后续操作之前返回。使用 defer wg.Done() 时,也要检查其他 defer 的逆序执行关系,把需要等待的清理纳入完成之前。
3.2 Add 为什么通常放在启动之前
当计数为零时,增加正数的 Add 必须先于对应的 Wait。下面用 start 控制执行顺序,复现把第一次登记放进工作 goroutine 的问题:
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
start := make(chan struct{})
finished := make(chan struct{})
go func() {
<-start
wg.Add(1) // 错误的登记位置:第一次 Wait 已经可能结束。
defer close(finished)
defer wg.Done()
fmt.Println("work")
}()
wg.Wait()
fmt.Println("wait returned")
close(start)
<-finished
}
固定输出先是 wait returned,后是 work。Wait 检查时计数仍为零,根本不知道未来还有一次 Add。finished 只用于把这个反例中的 goroutine 收尾,不是用来修复第一次等待。
错误也不一定表现为 panic。这段程序的执行顺序完全确定,仍然违背“等待这项工作完成”的目标;不能通过一次没有崩溃来证明登记正确。
“Add 不能出现在任何 goroutine 内”又过于绝对。如果一个父任务已经登记、计数仍大于零,父任务在自己的 Done 之前为子任务 Add(1),再启动子任务,就可以保持这批工作从未过早归零。关键是已经存在的完成责任和登记顺序,而不是代码写在哪个函数里。
3.3 复用、计数错误与实现
一个 WaitGroup 可以等待多批工作,但下一批的 Add 必须在上一批所有 Wait 都返回之后发生。计数刚变成零时,旧等待者可能还没有恢复执行;此时开始新一批,可能让旧等待者观察到下一批的状态。
| 情况 | 结果或要求 |
|---|---|
| 多个 goroutine 等待同一批工作 | 可以,但复用要等这些等待全部返回 |
Done 调用多于登记数量 | 计数为负,触发 panic |
漏掉某次 Done | 计数无法归零,等待可能一直持续 |
给下一批工作重新 Add | 先确认上一批所有等待已经返回 |
用赋值或值参数传递已使用的 WaitGroup | 复制了同步对象,违反使用契约 |
Go 1.22.12 的实现把工作计数和等待者数量放在一个原子状态中,并使用运行时信号量挂起和唤醒等待者。Wait 看到非零计数时登记自己;最后一次减计数到零的操作释放已登记的等待者。
这种实现避免让每个等待者反复轮询计数,但不能替应用补登记,也不能推断什么才算业务完成。该版本会检测负计数和部分并发误用;不是所有错误顺序都能被运行时识别,内部字段布局也不是需要业务代码依赖的接口。
4. Once
4.1 初始化完成之后再返回
Once 让同一个对象上的多次 Do(f) 调用只执行一次初始化函数。并发调用 Do 的其他 goroutine,需要等这次函数调用结束,才能从 Do 返回。
package main
import (
"fmt"
"sync"
)
var once sync.Once
var config string
var builds int
func load() string {
once.Do(func() {
builds++
config = "ready"
})
return config
}
func main() {
var wg sync.WaitGroup
results := make([]string, 3)
for i := range results {
wg.Add(1)
go func(index int) {
defer wg.Done()
results[index] = load()
}(i)
}
wg.Wait()
fmt.Println(builds, results)
}
输出 1 [ready ready ready]。初始化对 config 的写入,通过 Do 的同步关系发布给三个调用者;builds 只在初始化中修改,主 goroutine 又等到全部工作完成才读取。
Go 1.22.12 用原子完成标记处理已经初始化的快速路径,用互斥锁协调初始化中的慢路径,并在初始化调用结束时设置完成标记。若只用 CAS 抢到资格后立刻把标记设为完成,其他调用者可能直接返回,读到尚未初始化的内容。
Once 的身份也很重要:同一个对象即使每次传入不同函数,也只有其中一次调用会执行函数。初始化后若继续修改 config,这些修改不再由 Once 保护,需要其他同步规则。它更适合初始化后保持稳定的状态,不是通用读写锁。
4.2 失败、panic 与递归调用
Once.Do 不判断业务是否成功。初始化函数没有返回值;若把错误写入外部变量,正常返回后也不会因此自动重试。调用者应读取并处理保存的错误,不能把“执行过”当作“初始化成功”。
发生 panic 时也一样:Once 将这次调用视为已经结束,后续 Do 不再调用函数。下面在调用边界恢复 panic,观察第二次调用:
package main
import (
"fmt"
"sync"
)
func main() {
var once sync.Once
attempts := 0
func() {
defer func() {
fmt.Println("recovered:", recover())
}()
once.Do(func() {
attempts++
panic("init failed")
})
}()
once.Do(func() { attempts++ })
fmt.Println("attempts:", attempts)
}
输出 recovered: init failed 和 attempts: 1。恢复 panic 不会把 once 恢复成未使用状态。也不能据此认为结果已经完整可用:初始化可能只写了一部分字段就失败。
如果初始化函数直接或间接调用同一个 Once 的 Do,内层要等外层初始化结束,外层又在等待内层返回,就会死锁。需要可重试初始化、刷新或按键合并请求时,应使用另行设计的状态与失败协议,不把一个 Once 当成所有这些能力。
5. Cond
5.1 等待的是受锁保护的条件
Cond 是一个条件变量,让 goroutine 在共享状态暂时不满足要求时等待,并在状态变化后重新检查。这个共享状态上的布尔判断称为谓词,例如“队列非空”或“已经停止”。
sync.NewCond(&mu) 把条件变量与锁关联。调用 Wait 时必须持有这把锁;Wait 登记等待并释放锁,让其他 goroutine 有机会改变状态,被通知后重新取得锁,再返回给调用方。
下面的消费者在队列为空且尚未结束时等待,生产者更新队列后通知它:
package main
import (
"fmt"
"sync"
)
func main() {
var mu sync.Mutex
cond := sync.NewCond(&mu)
var queue []int
closed := false
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
defer mu.Unlock()
for {
for len(queue) == 0 && !closed {
cond.Wait()
}
if len(queue) == 0 {
return
}
value := queue[0]
queue = queue[1:]
fmt.Println(value)
}
}()
mu.Lock()
queue = append(queue, 7)
cond.Signal()
mu.Unlock()
mu.Lock()
closed = true
cond.Broadcast()
mu.Unlock()
wg.Wait()
fmt.Println("done")
}
输出 7 和 done。queue 和 closed 的检查、修改都使用同一把 mu。结束状态不会丢弃已经排队的 7:消费者先处理队列,等队列为空且 closed 为真时退出。
消费者即使在通知之后才开始,也能从共享状态中读到队列或结束条件,不需要补领过去的通知。Cond 没有保存业务状态,保存状态的是 queue 和 closed。
示例只用一次整数输出表示消费。真实任务较慢或会调用其他代码时,应在锁内取出工作,再在锁外处理,避免让其他状态访问承担不受控的等待。
5.2 为什么必须循环检查
Go 的 Cond.Wait 不会无缘无故返回,正常返回需要 Signal 或 Broadcast 唤醒。仍要使用 for,原因是收到通知到重新取得锁之间,业务条件可能再次变化。
例如两个消费者都在等队列非空,生产者放入一个元素后 Broadcast。两个消费者都会被唤醒,但只有一个先取得锁并取走元素;另一个重新取得锁时,队列已经空了,需要继续等待。若使用 if,第二个消费者可能直接访问空队列。
通知也可能针对多个谓词中某一个的变化。等待者只能在重新持锁后判断自己所需的条件是否成立,不能把“醒过一次”当作获得资源。
5.3 通知、锁与等待登记
Signal 唤醒一个已经等待的 goroutine,Broadcast 唤醒当时所有等待者。没有等待者时,这次通知不会累计成一张将来可以使用的凭证;它不同于一个保存了值的缓冲 channel,也不同于持续保持关闭状态的 channel。
API 允许调用 Signal 或 Broadcast 时不持有 L,但观察和修改谓词,以及调用 Wait,必须遵守同一套锁协议。把状态修改与通知放在持锁区间内,通常更容易看清顺序。通知并不转交锁;若通知者仍持锁,被唤醒者还要等锁释放,也不保证比其他竞争者更早获得锁。
Go 1.22.12 在 Wait 释放锁之前,先向运行时通知列表登记等待序号。通知若发生在释放锁之后、真正挂起之前,运行时能够发现该序号已被通知,避免在这个间隙永远睡下。这个登记过程不表示“任意时刻提前发一次 Signal,未来的 Wait 都能收到”。
Signal 和 Broadcast 与它们唤醒的 Wait 返回建立同步关系;业务仍需用锁保护实际共享状态。不持锁检查条件再调用 Wait,或在锁外直接修改条件,都不能因为用了条件变量就自动正确。
6. 复制与取消边界
6.1 同步对象需要保持身份
WaitGroup、Once、Cond 首次使用后都不能复制。赋值、按值传参、值接收者和复制包含它们的结构体,都可能产生另一份同步状态。
复制 WaitGroup 不会把未来对原对象的 Done 转发给副本;复制 Once 也不是重新初始化的 API。Cond 还涉及关联锁和内部等待记录,更不能靠结构体复制迁移等待者。通常让这些对象通过指针共享,并检查包含它们的对象怎样传递。
go vet 的复制锁检查能发现部分问题,Go 1.22.12 的 Cond 还会检测已经使用过的自身副本。但静态提示和运行时检测都只是辅助,不能把“没有报错”当作允许复制的依据。
6.2 停止等待与停止工作
WaitGroup.Wait、Once.Do、Cond.Wait 都不接受取消参数。把它们放进另一个 goroutine,再在外层用 select 等取消,只会让外层调用返回,内部等待仍然存在。
对 WaitGroup,应让实际工作观察取消、结束阻塞和清理资源,再调用 Done。若仍用一个 goroutine 将 Wait 的完成转换成 channel 通知,也必须保证工作最终结束,否则这个等待者会一起遗留。
对 Once,取消某个调用者不会中断正在进行的初始化。若用某次请求的短期限执行全局初始化,又把失败结果永久留在同一个 Once 中,后续请求也不会自动重试。
对 Cond,仅在取消时 Broadcast 还不够:谓词未改变,等待者醒来后会再次等待。可以像前面示例那样,在同一把锁下记录结束状态、通知等待者,并在循环条件中检查它。需要对每个请求分别取消时,还要协调取消状态检查、等待登记和通知时机;只在进入 Wait 前检查一次取消,可能错过之后发生的取消。
最终都要确认派生任务结束,不能只确认调用方按时返回。channel 的可取消阻塞与退出责任见 select。
7 · 面试题卡片
本页题目与参考回答
7.1 WaitGroup 怎样等待一组工作,内部大致如何实现?
考察重点:登记、完成计数、等待者唤醒与结果同步。
相关内容:登记工作与等待结果、复用、计数错误与实现。
参考回答
WaitGroup 用计数表示尚未完成的工作,Add 登记、Done 减一,Wait 等待计数归零。 Go 1.22.12 用原子状态记录计数和等待者数量,再用运行时信号量挂起和唤醒等待者。
Done 与它解除阻塞的 Wait 返回建立同步关系,可以在等待后读取完成之前写好的结果。它不自动收集错误或保护工作之间的共享修改;Done 之后还有工作时,也不能由 Wait 推断那些工作已结束。
7.2 可以把 WaitGroup.Add 放在新启动的 goroutine 内吗?
考察重点:零计数、登记顺序与父子任务的完成责任。
相关内容:Add 为什么通常放在启动之前。
参考回答
如果这是计数为零时的首次登记,通常应在启动 goroutine 之前 Add。 放在新 goroutine 内可能让外部 Wait 先看到零并返回,程序甚至不需要 panic 就已经等错了。
若父任务已经登记、计数仍大于零,父任务在自身 Done 之前登记并启动子任务,可以符合这一批工作的协议。判断依据是 Add 与 Wait、计数归零的关系,不是简单禁止在 goroutine 中调用 Add。
7.3 WaitGroup 能否复用,能否复制后分别等待?
考察重点:独立批次、旧等待者与同步对象身份。
相关内容:复用、计数错误与实现、同步对象需要保持身份。
参考回答
可以复用,但下一批 Add 必须等上一批所有 Wait 返回;首次使用后不能复制。 计数刚归零不代表旧等待者已经全部返回,过早复用可能混入下一批状态。
复制得到另一份对象,原对象后续的 Done 不会自动更新副本。应保持同一个同步对象的身份,也不要依赖运行时一定检测出误用。
7.4 Once 为什么不能只用 CAS 抢到初始化资格后直接返回?
考察重点:只执行一次与初始化完成、结果发布的区别。
相关内容:初始化完成之后再返回。
参考回答
Once 还要保证调用者从 Do 返回时,初始化调用已经结束。 如果抢到资格后就把标记设为完成,其他调用者可能跳过等待,读到未初始化或部分初始化的结果。
Go 1.22.12 通过原子完成标记和互斥锁协调,并在初始化调用结束时设置标记。初始化后的再次修改仍需其他同步,不能由 Once 永久保护。
7.5 Once 初始化发生 panic 后会重试吗,能在初始化中再次调用同一个 Once 吗?
考察重点:调用结束、业务成功、递归等待和重试协议。
相关内容:失败、panic 与递归调用。
参考回答
不会自动重试,初始化中递归调用同一个 Once 会死锁。 panic 展开时这次调用仍被标记为结束,后续 Do 不再执行函数;即使调用方恢复 panic,也不表示结果完整可用。
递归调用要等待当前初始化结束,而当前初始化又在等递归调用返回。需要失败重试或刷新时,应另行管理状态、错误和重试条件。
7.6 Cond.Wait 为什么要持锁并循环检查条件,提前 Signal 能否保留通知?
考察重点:谓词、释放与重新加锁、通知登记和资源竞争。
相关内容:等待的是受锁保护的条件、为什么必须循环检查、通知、锁与等待登记。
参考回答
Wait 在持锁时登记等待并释放锁,收到通知后重新加锁;返回时条件可能已经被其他 goroutine 改变,所以必须重新检查。 Go 的 Wait 正常返回需要通知,但通知不等于资源已经分配给当前等待者。
Signal 没有等待者时不会为未来等待者累计通知。应把业务条件保存在共享状态中,并让检查、修改和等待遵守同一把锁;Signal/Broadcast 本身允许不持锁,不代表谓词可以任意无锁访问。
7.7 外层等待超时后返回,是否已经取消 WaitGroup、Once 或 Cond 的内部等待?
考察重点:调用者、等待者、实际工作及资源清理的不同生命周期。
相关内容:停止等待与停止工作。
参考回答
没有,外层返回只表示放弃等待,这些 API 本身没有取消参数。 WaitGroup 对应的工作要真正结束并 Done;Once 的初始化不会被其他调用者的取消中断。
Cond 需要把结束或取消状态纳入受锁保护的条件,并安排通知,单独 Broadcast 而不改变条件可能让等待者再次睡下。无论用哪种工具,都要确认派生任务退出,不能留下无人负责的等待 goroutine。
8. 参考资料
- Go 1.22.12:sync
- Go 1.22.12:WaitGroup
- Go 1.22.12:Once
- Go 1.22.12:Cond
- Go 1.22.12:运行时等待与通知
- Go 1.22.12:内存模型
来源:Coding 101 原文 · 免费阅读,引用请保留出处。内容使用说明