文章目录
-
- WaitGroup:等待一组goroutine完成
- Once:确保操作只执行一次
- WaitGroup和Once的联合应用
- 面试专题与避坑指南
-
- WaitGroup面试常见问题
-
- [Q1: WaitGroup的Add方法应该在何处调用?](#Q1: WaitGroup的Add方法应该在何处调用?)
- [Q2: 如何避免WaitGroup的死锁?](#Q2: 如何避免WaitGroup的死锁?)
- [Q3: WaitGroup可以传递值还是指针?](#Q3: WaitGroup可以传递值还是指针?)
- Once面试常见问题
-
- [Q1: [Once.Do](https://Once.Do)中的函数panic了会怎样?](#Q1: Once.Do中的函数panic了会怎样?)
- [Q2: 如何实现可重试的Once?](#Q2: 如何实现可重试的Once?)
- [Q3: Once和init函数有什么区别?](#Q3: Once和init函数有什么区别?)
- 综合面试问题
-
- [Q1: 什么时候用WaitGroup,什么时候用通道?](#Q1: 什么时候用WaitGroup,什么时候用通道?)
- [Q2: 如何用WaitGroup和通道实现工作池?](#Q2: 如何用WaitGroup和通道实现工作池?)
- [Q3: 如何调试WaitGroup/Once相关的死锁?](#Q3: 如何调试WaitGroup/Once相关的死锁?)
- [Q4: WaitGroup的内部实现原理是什么?](#Q4: WaitGroup的内部实现原理是什么?)
- [Q5: Once为什么使用双重检查锁?](#Q5: Once为什么使用双重检查锁?)
- [Q6: 如何实现一个分布式的Once?](#Q6: 如何实现一个分布式的Once?)
- 避坑指南
- 总结与最佳实践
在并发编程中,有两个常见的需求:
- 等待一组goroutine完成工作(例如:等待所有文件下载完成)
- 确保某个初始化操作只执行一次(例如:数据库连接池初始化)
Go语言通过sync.WaitGroup和sync.Once这两个工具,为这些需求提供了优雅的解决方案。它们比直接使用通道或互斥锁更简洁、更安全。
WaitGroup:等待一组goroutine完成
WaitGroup的基本原理
sync.WaitGroup用于等待一组goroutine完成执行,它内部维护一个计数器,通过三个方法来协调goroutine的执行:
核心方法:
Add(delta int):增加计数器的值(delta可以为负数)Done():将计数器减1Wait():阻塞直到计数器变为0
内部实现解析:WaitGroup内部使用32位或64位的原子操作(取决于平台)来维护计数器,配合信号量实现等待机制。当计数器为0时,所有等待的goroutine会被唤醒。
下面是WaitGroup的基本工作流程示意图:
每个goroutine
是
否
是
否
开始
WaitGroup初始化
wg.Add n
设置等待n个任务
启动goroutine
任务执行
wg.Done 任务完成
主goroutine调用wg.Wait
计数器 > 0?
阻塞等待
所有Done调用完成
计数器归零
Wait返回继续执行
计数器减1
计数器 == 0?
唤醒等待的goroutine
继续其他任务
下面是WaitGroup的基本使用示例:
go
package main
import (
"fmt"
"sync"
"time"
)
func basicWaitGroupExample() {
fmt.Println("=== WaitGroup 基本示例 ===")
var wg sync.WaitGroup
// 启动3个goroutine
for i := 1; i <= 3; i++ {
wg.Add(1) // 每个goroutine前增加计数器
go func(id int) {
defer wg.Done() // goroutine结束时减少计数器
fmt.Printf("Goroutine %d 开始工作\n", id)
time.Sleep(time.Duration(id) * time.Second)
fmt.Printf("Goroutine %d 完成工作\n", id)
}(i)
}
fmt.Println("主goroutine: 等待所有goroutine完成...")
wg.Wait() // 阻塞直到计数器归零
fmt.Println("所有goroutine已完成,程序继续执行")
}
func main() {
basicWaitGroupExample()
}
https://gitee.com/rxbook/go-demo-2025/blob/master/demo/concurrent2/demo04_WaitGroup/main1.go
go run demo04_WaitGroup/main1.go === WaitGroup 基本示例 === 主goroutine: 等待所有goroutine完成... Goroutine 3 开始工作 Goroutine 2 开始工作 Goroutine 1 开始工作 Goroutine 1 完成工作 Goroutine 2 完成工作 Goroutine 3 完成工作 所有goroutine已完成,程序继续执行
代码解析:
wg.Add(1)在启动每个goroutine前调用,增加计数器defer wg.Done()确保goroutine结束时计数器减1wg.Wait()阻塞主goroutine直到计数器为0
WaitGroup的进阶用法
批量添加任务
使用场景:当我们知道需要等待的具体任务数量时,可以一次性添加所有任务。
注意事项:
- 必须在启动goroutine之前调用Add
- 建议将Add调用放在主goroutine中
- 避免在子goroutine中调用Add
go
func batchAddExample() {
fmt.Println("=== WaitGroup 批量添加示例 ===")
var wg sync.WaitGroup
tasks := []string{"下载文件", "处理图片", "发送邮件", "备份数据"}
// 一次性添加所有任务(推荐方式)
wg.Add(len(tasks))
// 启动所有goroutine
for i, task := range tasks {
go func(id int, taskName string) {
defer wg.Done()
fmt.Printf("Worker %d: 开始执行 %s\n", id, taskName)
time.Sleep(time.Duration(id+1) * 500 * time.Millisecond)
fmt.Printf("Worker %d: 完成 %s\n", id, taskName)
}(i, task)
}
fmt.Println("主goroutine: 等待所有任务完成...")
wg.Wait()
fmt.Println("所有任务已完成")
}
https://gitee.com/rxbook/go-demo-2025/blob/master/demo/concurrent2/demo04_WaitGroup/main2_1.go
go run demo04_WaitGroup/main2_1.go === WaitGroup 批量添加示例 === 主goroutine: 等待所有任务完成... Worker 3: 开始执行 备份数据 Worker 1: 开始执行 处理图片 Worker 2: 开始执行 发送邮件 Worker 0: 开始执行 下载文件 Worker 0: 完成 下载文件 Worker 1: 完成 处理图片 Worker 2: 完成 发送邮件 Worker 3: 完成 备份数据 所有任务已完成
代码解析:
- 批量添加减少
Add调用次数,性能更好 - 避免goroutine启动和
Add调用的竞态条件
还有一个更接近实际开发的Demo,点此查看:main2_2.go
WaitGroup的复用
使用场景:在某些情况下,我们需要重复使用同一个WaitGroup来等待多批任务。当你的程序中需要多次执行"等待一组goroutine完成"的操作时,可以复用同一个WaitGroup,而不需要为每批任务都创建新的WaitGroup。这在循环处理批任务、分阶段处理等场景下很常见。
完整生命周期:
每个复用周期遵循:Add() → 启动goroutine → Wait() → 归零 → 下一个周期
注意事项:
go
// 1. 必须等待前一批任务全部完成后才能开始新一批
wg.Wait() // 必须调用!这是复用前的"归零时刻"
// 2. 计数器必须归零才能安全复用
// 错误示例:如果计数器不是0,Add会导致panic
// wg.Add(3) // 如果计数器>0,这里会panic
// 正确做法:确保通过Wait()让计数器归零
wg.Wait() // 等待归零
wg.Add(2) // 现在可以安全开始新批次
// 3.每批任务都是完全独立的
代码示例:
go
func waitGroupReuseExample() {
fmt.Println("=== WaitGroup 复用示例 ===")
fmt.Println("开始时间:", time.Now().Format("15:04:05.000"))
var wg sync.WaitGroup
// 第一批任务
fmt.Println("\n=== 第一批任务, 每个任务执行1秒 ===")
wg.Add(2)
for i := 1; i <= 2; i++ {
go func(id int) {
taskStart := time.Now()
defer wg.Done()
fmt.Printf("[%s] 第一批任务 %d: 执行中...\n", taskStart.Format("15:04:05.000"), id)
time.Sleep(1 * time.Second)
}(i)
}
wg.Wait() // 关键:必须等待第一批完成
fmt.Printf("[%s] 第一批任务全部完成\n", time.Now().Format("15:04:05.000"))
// 第二批任务
fmt.Println("\n=== 第二批任务, 每个任务执行2秒 ===")
wg.Add(3) // 可以安全地重新开始新批次
for i := 1; i <= 3; i++ {
go func(id int) {
defer wg.Done()
fmt.Printf("[%s] 第二批任务 %d: 执行中...\n", time.Now().Format("15:04:05.000"), id)
time.Sleep(2 * time.Second)
}(i)
}
wg.Wait()
fmt.Printf("[%s] 第二批任务全部完成\n", time.Now().Format("15:04:05.000"))
}
https://gitee.com/rxbook/go-demo-2025/blob/master/demo/concurrent2/demo04_WaitGroup/main3.go
go run demo04_WaitGroup/main3.go === WaitGroup 复用示例 === 开始时间: 17:18:24.538 === 第一批任务, 每个任务执行1秒 === [17:18:24.538] 第一批任务 2: 执行中... [17:18:24.538] 第一批任务 1: 执行中... [17:18:25.539] 第一批任务全部完成 === 第二批任务, 每个任务执行2秒 === [17:18:25.540] 第二批任务 1: 执行中... [17:18:25.540] 第二批任务 3: 执行中... [17:18:25.540] 第二批任务 2: 执行中... [17:18:27.543] 第二批任务全部完成
代码解析:
- 复用WaitGroup可以减少内存分配
- 必须确保前一阶段
Wait返回后才能开始下一阶段
如果不复用WaitGroup,会出现代码复杂度和可读性问题;多阶段处理的场景下,每阶段都需要等待。
go
// 不复用(冗余代码):
func processPipeline() {
// 阶段1
var wg1 sync.WaitGroup
wg1.Add(2)
go stage1Task1(&wg1)
go stage1Task2(&wg1)
wg1.Wait()
// 阶段2
var wg2 sync.WaitGroup // 重复的声明
wg2.Add(3)
go stage2Task1(&wg2)
go stage2Task2(&wg2)
go stage2Task3(&wg2)
wg2.Wait()
}
// 复用(更简洁):
func processPipelineBetter() {
var wg sync.WaitGroup // 只声明一次
// 阶段1
wg.Add(2)
go stage1Task1(&wg)
go stage1Task2(&wg)
wg.Wait()
// 阶段2
wg.Add(3) // 直接复用
go stage2Task1(&wg)
go stage2Task2(&wg)
go stage2Task3(&wg)
wg.Wait()
}
虽然复用通常是更好的做法,但 以下情况可以不复用:
go
// 1.简单的一次性任务:
func main() {
var wg sync.WaitGroup // 只用一次,不复用也没问题
wg.Add(1)
go doSomething(&wg)
wg.Wait()
}
// 2.独立的、不相关的任务组:
func processA() {
var wg sync.WaitGroup // 专门给processA用
// ...
}
func processB() {
var wg sync.WaitGroup // 专门给processB用
// ...
}
// 两个函数完全独立,各自用自己的WaitGroup
//3. 可读性考虑:如果复用会让代码更难理解,可以适当创建新的
动态添加任务
使用场景:当任务数量无法预先确定,或者需要根据运行时条件动态生成任务时,可以使用WaitGroup动态添加任务。这在任务队列处理、流式数据处理等场景中很常见。
常见应用场景:
- Web爬虫:发现新链接时动态添加抓取任务
- 文件批量处理:根据处理结果决定是否需要后续处理
- 实时数据处理:从数据流中动态创建处理任务
打个比方:动态添加任务就像餐厅加菜 - 主线程是顾客(生产者),worker是厨师(消费者)。顾客可以随时加菜(Add),但必须等所有菜都上齐了(Wait)才能离开。
注意事项:
- 必须在
Wait()被调用之前添加所有任务 - 动态添加要谨慎,容易导致死锁
- 建议将任务添加逻辑集中管理
go
package main
import (
"fmt"
"sync"
"time"
)
func dynamicAddExample() {
fmt.Println("=== WaitGroup 动态添加任务示例 ===")
fmt.Println("说明: 在worker处理任务过程中动态添加新任务")
fmt.Println()
var wg sync.WaitGroup
taskChan := make(chan string, 5) // 任务队列
// 启动2个worker处理任务
for i := 1; i <= 2; i++ {
go func(workerID int) {
for task := range taskChan { // 从通道读取任务
fmt.Printf("Worker %d: 开始处理 '%s'\n", workerID, task)
time.Sleep(500 * time.Millisecond) // 模拟处理时间
fmt.Printf("Worker %d: 完成处理 '%s' OK \n", workerID, task)
wg.Done() // 任务完成后通知WaitGroup
}
}(i)
}
// 第一波:预先添加3个任务
fmt.Println("主goroutine: 添加第一波任务")
tasks1 := []string{"下载文件", "备份数据", "发送邮件"}
for _, task := range tasks1 {
wg.Add(1)
taskChan <- task
}
// 稍等后添加第二波任务(模拟动态添加)
time.Sleep(800 * time.Millisecond)
fmt.Println("\n主goroutine: 动态添加第二波任务")
tasks2 := []string{"清理缓存", "更新配置"}
for _, task := range tasks2 {
wg.Add(1) // 动态添加时调用Add
taskChan <- task
}
// 等待所有任务完成
fmt.Println("\n主goroutine: 等待所有任务完成...")
wg.Wait()
close(taskChan) // 所有任务完成后关闭通道
fmt.Println("\n所有任务处理完成!")
}
func main() {
start := time.Now()
dynamicAddExample()
fmt.Printf("总耗时: %.2f秒\n", time.Since(start).Seconds())
}
https://gitee.com/rxbook/go-demo-2025/blob/master/demo/concurrent2/demo04_WaitGroup/main4.go
go run demo04_WaitGroup/main4.go === WaitGroup 动态添加任务示例 === 说明: 在worker处理任务过程中动态添加新任务 主goroutine: 添加第一波任务 Worker 2: 开始处理 '下载文件' Worker 1: 开始处理 '备份数据' Worker 1: 完成处理 '备份数据' OK Worker 1: 开始处理 '发送邮件' Worker 2: 完成处理 '下载文件' OK 主goroutine: 动态添加第二波任务 主goroutine: 等待所有任务完成... Worker 2: 开始处理 '清理缓存' Worker 1: 完成处理 '发送邮件' OK Worker 1: 开始处理 '更新配置' Worker 2: 完成处理 '清理缓存' OK Worker 1: 完成处理 '更新配置' OK 所有任务处理完成! 总耗时: 1.50秒
执行流程解析:
1. 启动2个worker,等待任务
2. 主goroutine添加3个任务到队列
├─ Worker1: 处理"下载文件"
├─ Worker2: 处理"备份数据"
└─ 队列中: "发送邮件"
3. 800ms后动态添加2个新任务
├─ Worker1完成"下载文件",开始处理"清理缓存"
├─ Worker2完成"备份数据",开始处理"更新配置"
└-> 等待所有任务完成
使用WaitGroup的注意事项
计数器不能为负
WaitGroup的计数器不能小于0,否则会引发panic。下面是一些常见错误示例:
go
func waitGroupNegativeCounter() {
fmt.Println("=== WaitGroup 计数器错误示例 ===")
var wg sync.WaitGroup
// 错误1:Done()调用次数超过Add()调用次数
wg.Add(1)
go func() {
defer func() {
if r := recover(); r != nil {
fmt.Printf("捕获到panic: %v\n", r)
}
}()
wg.Done() // 正确
wg.Done() // 错误:多调用一次,会导致panic
}()
time.Sleep(100 * time.Millisecond)
// 错误2:Add()传入负数导致计数器为负
wg.Add(-2) // 这会导致panic
fmt.Println("程序继续执行")
}
https://gitee.com/rxbook/go-demo-2025/blob/master/demo/concurrent2/demo04_WaitGroup/main5.go
go run demo04_WaitGroup/main5.go === WaitGroup 计数器错误示例 === 捕获到panic: sync: negative WaitGroup counter panic: sync: negative WaitGroup counter
面试常考点:
- 问:WaitGroup计数器为负会发生什么?答:会触发panic,程序崩溃
- 问:如何避免计数器为负?答:确保Add和Done调用次数匹配,使用defer调用Done
WaitGroup的正确使用模式
为了避免出现上述问题,我们应该遵循WaitGroup的正确使用模式:
go
// 正确模式1:在启动goroutine之前调用Add
func correctPattern1() {
var wg sync.WaitGroup
tasks := []string{"任务1", "任务2", "任务3"}
// 正确:在启动goroutine之前添加所有任务
wg.Add(len(tasks))
for i, task := range tasks {
go func(id int, taskName string) {
defer wg.Done() // 使用defer确保Done被调用
fmt.Printf("Worker %d: 处理%s\n", id, taskName)
time.Sleep(time.Duration(id+1) * time.Second)
}(i, task)
}
wg.Wait()
fmt.Println("所有任务完成")
}
// 错误模式:在goroutine内部调用Add
func wrongPattern() {
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
go func(id int) {
wg.Add(1) // 错误:在goroutine内部调用Add
defer wg.Done()
fmt.Printf("Worker %d: 工作\n", id)
time.Sleep(time.Duration(id+1) * time.Second)
}(i)
}
// 这里可能会提前返回,因为Add可能在Wait之后才被调用
wg.Wait()
fmt.Println("所有工作完成(可能不正确)")
}
https://gitee.com/rxbook/go-demo-2025/blob/master/demo/concurrent2/demo04_WaitGroup/main6.go
WaitGroup的死锁问题
WaitGroup使用不当可能导致死锁。下面是常见死锁场景的示意图:
开始
主goroutine调用wg.Wait
阻塞等待
子goroutine启动
需要先执行某些初始化
初始化需要等待其他条件
条件依赖主goroutine
主goroutine被Wait阻塞
死锁发生 主等待子
子等待主
程序永远阻塞
死锁示例:
go
func deadlockExample() {
var wg sync.WaitGroup
var mu sync.Mutex
var sharedResource bool
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("子goroutine: 等待获取锁...")
mu.Lock()
defer mu.Unlock()
// 死锁点:子goroutine等待主goroutine设置资源
for !sharedResource {
fmt.Println("子goroutine: 等待sharedResource变为true...")
time.Sleep(100 * time.Millisecond)
// 但主goroutine在Wait()处被阻塞,无法设置资源
}
}()
// 正确的顺序应该是:
// 1. 主goroutine先设置资源
// 2. 再调用wg.Wait()
// 错误的顺序(当前):
wg.Wait() // 死锁!
// 主goroutine永远无法执行到这里来设置sharedResource
// mu.Lock()
// sharedResource = true
// mu.Unlock()
}
避坑指南:
- Add调用位置:必须在所有goroutine启动前调用Add
- Wait调用时机:确保所有必要资源都已就绪再调用Wait
- 避免循环依赖:goroutine之间不要有循环等待
- 超时机制:长时间任务考虑添加超时
Once:确保操作只执行一次
Once的基本原理
sync.Once用于确保某个操作只执行一次,无论有多少个goroutine调用它。这在初始化单例、加载配置等场景中非常有用。
核心方法 :Do(f func()):执行函数f,确保它只执行一次
内部实现解析:Once内部使用一个uint32类型的done标志和sync.Mutex互斥锁。通过双重检查锁模式(Double-Checked Locking)实现高性能的线程安全。
下面是Once的基本工作原理示意图:
是
否
是
否
开始Do调用
原子读取done标志
done == 1?
立即返回
函数已执行
获取互斥锁
再次检查done标志
done == 1?
释放锁并返回
其他goroutine已执行
执行用户函数f
原子设置done = 1
释放锁
返回
并发Do调用
排队等待锁
下面是Once的基本使用示例:
go
func basicOnceExample() {
fmt.Println("=== Once 基本示例 ===")
var once sync.Once
var wg sync.WaitGroup
// 初始化函数(只应执行一次)
initialize := func(id int) {
fmt.Printf("Goroutine %d: 初始化函数执行中...\n", id)
time.Sleep(3 * time.Second) // 模拟耗时的初始化
fmt.Printf("Goroutine %d: 初始化完成...\n", id)
}
// 启动多个goroutine同时尝试初始化
for i := 0; i < 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fmt.Printf("Goroutine %d: 尝试初始化\n", id)
once.Do(func() { initialize(id) }) // 确保initialize只执行一次
fmt.Printf("Goroutine %d: 初始化检查完成\n", id)
}(i)
}
wg.Wait()
fmt.Println("所有goroutine完成")
}
https://gitee.com/rxbook/go-demo-2025/blob/master/demo/concurrent2/demo05_once/main1.go
go run demo05_once/main1.go === Once 基本示例 === Goroutine 2: 尝试初始化 Goroutine 2: 初始化函数执行中... Goroutine 0: 尝试初始化 Goroutine 1: 尝试初始化 Goroutine 2: 初始化完成... Goroutine 2: 初始化检查完成 Goroutine 0: 初始化检查完成 Goroutine 1: 初始化检查完成 所有goroutine完成
Once的高级用法
懒加载单例模式
使用场景:在需要延迟初始化单例对象时,Once提供了一种线程安全的懒加载实现。
go
// DatabaseConnection 模拟数据库连接
type DatabaseConnection struct {
connectionID string
connectedAt time.Time
}
var (
dbInstance *DatabaseConnection
once sync.Once
)
// GetDatabaseConnection 获取数据库连接(单例)
func GetDatabaseConnection() *DatabaseConnection {
once.Do(func() {
fmt.Println("正在创建数据库连接...")
time.Sleep(2 * time.Second) // 模拟连接建立的耗时
dbInstance = &DatabaseConnection{
connectionID: fmt.Sprintf("conn-%d", time.Now().UnixNano()),
connectedAt: time.Now(),
}
fmt.Printf("数据库连接已建立: %s\n", dbInstance.connectionID)
})
return dbInstance
}
func singletonExample() {
fmt.Println("=== Once 实现懒加载单例 ===")
var wg sync.WaitGroup
// 多个goroutine同时获取数据库连接
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fmt.Printf("Goroutine %d: 请求数据库连接\n", id)
conn := GetDatabaseConnection()
// 验证所有goroutine获取的是同一个实例
fmt.Printf("Goroutine %d: 获取到连接 %s\n", id, conn.connectionID)
}(i)
}
wg.Wait()
}
https://gitee.com/rxbook/go-demo-2025/blob/master/demo/concurrent2/demo05_once/main2.go
go run demo05_once/main2.go === Once 实现懒加载单例 === Goroutine 4: 请求数据库连接 正在创建数据库连接... Goroutine 1: 请求数据库连接 Goroutine 2: 请求数据库连接 Goroutine 3: 请求数据库连接 Goroutine 0: 请求数据库连接 数据库连接已建立: conn-1767861258704978000 Goroutine 4: 获取到连接 conn-1767861258704978000 Goroutine 1: 获取到连接 conn-1767861258704978000 Goroutine 2: 获取到连接 conn-1767861258704978000 Goroutine 3: 获取到连接 conn-1767861258704978000 Goroutine 0: 获取到连接 conn-1767861258704978000
面试常考点:
- 问:如何用Once实现线程安全的单例模式?
- 答:使用Once.Do包裹初始化逻辑,配合包级变量
- 问:Once实现的单例和双重检查锁有什么区别?
- 答:Once更简洁,Go编译器对其有优化;双重检查锁需要自己管理锁和volatile语义
配置初始化
使用场景:在应用程序启动时,需要加载配置文件,但只需加载一次。
go
type Config struct {
DatabaseURL string
CacheSize int
Timeout time.Duration
}
var (
config *Config
configOnce sync.Once
configErr error // 注意:需要单独的错误变量
)
// LoadConfig 加载配置(线程安全,只执行一次)
func LoadConfig() (*Config, error) {
configOnce.Do(func() {
fmt.Println("开始加载配置...")
time.Sleep(1 * time.Second)
// 模拟可能出现的错误
if time.Now().Unix()%10 == 0 {
configErr = fmt.Errorf("配置文件不存在")
return
}
config = &Config{
DatabaseURL: "localhost:5432",
CacheSize: 1000,
Timeout: 30 * time.Second,
}
})
return config, configErr // 返回值和错误
}
func main() {
var wg sync.WaitGroup
// 启动3个goroutine同时加载配置
for i := 1; i <= 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
// 每个goroutine都调用LoadConfig
fmt.Printf("Goroutine %d 调用LoadConfig()...\n", id)
config, err := LoadConfig()
if err != nil {
fmt.Printf("Goroutine %d 错误: %v\n", id, err)
return
}
fmt.Printf("Goroutine %d 成功获取配置: %v\n", id, config)
}(i)
}
// 等待所有goroutine完成
wg.Wait()
//注意:上面的输出中'开始加载配置...'只打印了一次
//证明无论有多少个goroutine同时调用,配置加载只执行了一次
}
https://gitee.com/rxbook/go-demo-2025/blob/master/demo/concurrent2/demo05_once/main3.go
go run demo05_once/main3.go Goroutine 3 调用LoadConfig()... 开始加载配置... Goroutine 1 调用LoadConfig()... Goroutine 2 调用LoadConfig()... Goroutine 1 成功获取配置: &{localhost:5432 1000 30s} Goroutine 3 成功获取配置: &{localhost:5432 1000 30s} Goroutine 2 成功获取配置: &{localhost:5432 1000 30s}
面试避坑指南:
- 错误处理:Once没有错误返回机制,需要额外处理
- Panic处理:如果Do中的函数panic,Once会认为已执行完成
- 初始化状态:需要额外的标志位来判断初始化是否成功
Once的错误处理
使用场景:当初始化操作可能失败时,我们需要正确处理错误,并允许重试。
go
type ResourceManager struct {
once sync.Once
mu sync.Mutex // 额外加锁管理状态
inited bool
err error
resource interface{}
}
func (rm *ResourceManager) Init() (interface{}, error) {
rm.mu.Lock()
if rm.inited {
rm.mu.Unlock()
return rm.resource, rm.err
}
rm.mu.Unlock()
// 使用Once确保初始化逻辑只执行一次
rm.once.Do(func() {
rm.mu.Lock()
defer rm.mu.Unlock()
// 执行初始化(可能失败)
res, err := someExpensiveInit()
rm.resource = res
rm.err = err
rm.inited = true
})
return rm.resource, rm.err
}
https://gitee.com/rxbook/go-demo-2025/blob/master/demo/concurrent2/demo05_once/main4.go
go run demo05_once/main4.go Worker 3 尝试初始化... [Do] 开始执行初始化操作... Worker 2 尝试初始化... Worker 1 尝试初始化... [Do] 初始化失败: 初始化失败 Worker 3 初始化失败 (耗时 501.429ms): 初始化失败 Worker 2 初始化失败 (耗时 501.463625ms): 初始化失败 Worker 1 初始化失败 (耗时 501.45225ms): 初始化失败
Once的细节与注意事项
Once的不可重置性
标准的sync.Once执行后就不能再次执行。如果需要可重置的Once,需要自己封装:
go
type ResettableOnce struct {
once sync.Once
mu sync.Mutex
done bool
}
func (ro *ResettableOnce) Do(f func()) {
ro.mu.Lock()
defer ro.mu.Unlock()
if !ro.done {
ro.once.Do(func() {
f()
ro.done = true
})
}
}
func (ro *ResettableOnce) Reset() {
ro.mu.Lock()
defer ro.mu.Unlock()
ro.once = sync.Once{} // 创建新的Once
ro.done = false
}
Once中的死锁
如果Once.Do中的函数再次调用同一个Once.Do,会导致死锁:
go
func deadlockOnceExample() {
var once sync.Once
// 错误的函数:在Once.Do中再次调用同一个Once.Do
recursiveFunc := func() {
fmt.Println("第一次进入")
once.Do(func() { // 死锁!等待自己完成
fmt.Println("第二次进入(永远不会执行到这里)")
})
}
go func() {
once.Do(recursiveFunc)
}()
time.Sleep(500 * time.Millisecond)
fmt.Println("检测到死锁")
}
面试避坑指南:
- 禁止递归调用:绝对不要在Do函数内调用同一个Once
- 避免间接递归:注意函数调用链中的Once使用
- 死锁检测:添加超时机制防止永久阻塞
Once的性能考虑
go
func performanceComparison() {
var once sync.Once
var data string
getData1 := func() string {
once.Do(func() {
time.Sleep(100 * time.Millisecond)
data = "Once数据"
})
return data
}
// 测试高并发场景
var wg sync.WaitGroup
start := time.Now()
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_ = getData1()
}()
}
wg.Wait()
fmt.Printf("Once性能: %v (1000个并发调用)\n", time.Since(start))
}
面试常考点:
- 问:Once在高并发下的性能如何?
- 答:首次调用有锁开销,后续调用只有原子读取,性能很好
- 问:Once和初始化构造器有什么区别?
- 答:Once是懒加载,减少启动时间;构造器是立即加载,启动时完成
WaitGroup和Once的联合应用
并发任务执行与结果收集
使用场景:需要并行执行多个任务,等待所有任务完成,并且每个任务只需要初始化一次。
go
type Worker struct {
ID int
once sync.Once // 每个Worker自己的Once
result string
err error
}
func (w *Worker) Process() (string, error) {
w.once.Do(func() {
// 每个Worker只执行一次处理
fmt.Printf("Worker %d: 开始处理...\n", w.ID)
// 模拟一定概率的处理失败
if time.Now().Nanosecond()%12 == 0 {
w.err = fmt.Errorf("Worker %d: 处理失败", w.ID)
return
}
time.Sleep(time.Duration(w.ID) * 100 * time.Millisecond)
w.result = fmt.Sprintf("Worker %d: 处理成功", w.ID)
})
return w.result, w.err
}
func combinedExample() {
var wg sync.WaitGroup
workers := make([]*Worker, 0, 10)
// 创建worker
for i := 1; i <= 10; i++ {
workers = append(workers, &Worker{ID: i})
}
// 启动所有worker
for i, worker := range workers {
wg.Add(1)
go func(w *Worker, idx int) {
defer wg.Done()
//fmt.Printf("Goroutine %d 调用 Worker %d.Process()\n", idx, w.ID)
result, err := w.Process() // 获取返回值
if err != nil {
fmt.Printf("Worker %d 返回错误: %v\n", w.ID, err)
} else {
fmt.Printf("Worker %d 返回结果: %s\n", w.ID, result)
}
}(worker, i+1)
}
wg.Wait()
fmt.Println("所有worker完成")
}
https://gitee.com/rxbook/go-demo-2025/blob/master/demo/concurrent2/demo05_once/main5.go
go run demo05_once/main5.go Worker 5: 开始处理... Worker 5 返回错误: Worker 5: 处理失败 Worker 4: 开始处理... Worker 4 返回错误: Worker 4: 处理失败 Worker 1: 开始处理... Worker 3: 开始处理... Worker 8: 开始处理... Worker 6: 开始处理... Worker 1 返回错误: Worker 1: 处理失败 Worker 9: 开始处理... Worker 9 返回错误: Worker 9: 处理失败 Worker 10: 开始处理... Worker 10 返回错误: Worker 10: 处理失败 Worker 2: 开始处理... Worker 7: 开始处理... Worker 7 返回错误: Worker 7: 处理失败 Worker 2 返回结果: Worker 2: 处理成功 Worker 3 返回结果: Worker 3: 处理成功 Worker 6 返回结果: Worker 6: 处理成功 Worker 8 返回结果: Worker 8: 处理成功 所有worker完成
分布式任务调度系统模拟
系统架构示意图:
同步控制层
任务执行层
任务调度器
Worker池
Worker池
Worker池
Worker 1
Worker 2
Worker 3
Worker 4
Worker 5
Worker 6
执行任务
Once确保幂等
相同任务只执行一次
WaitGroup等待所有结果
完成处理
面试专题与避坑指南
WaitGroup面试常见问题
Q1: WaitGroup的Add方法应该在何处调用?
必须在启动goroutine之前,在主goroutine中调用Add。原因:
- 避免竞态条件:如果Add在goroutine中调用,可能Wait先执行
- 保证计数准确:提前知道任务数量
- 代码更清晰:集中管理任务计数
错误示例:
go
// 错误:Add在goroutine内部调用
for i := 0; i < 3; i++ {
go func() {
wg.Add(1) // 可能在Wait之后执行
defer wg.Done()
// 工作
}()
}
wg.Wait() // 可能提前返回
Q2: 如何避免WaitGroup的死锁?
-
匹配Add/Done调用:确保Add和Done调用次数相等
-
使用defer调用Done:即使goroutine panic也会执行
-
避免循环等待:goroutine之间不要互相等待
-
添加超时机制:
go
done := make(chan bool)
go func() {
wg.Wait()
done <- true
}()
select {
case <-done:
fmt.Println("完成")
case <-time.After(5 * time.Second):
fmt.Println("超时")
}
Q3: WaitGroup可以传递值还是指针?
必须传递指针。因为WaitGroup是结构体,值传递会复制,导致计数器不共享。
go
// 正确:传递指针
func process(wg *sync.WaitGroup) {
defer wg.Done()
// 工作
}
// 错误:传递值(编译不通过,因为WaitGroup不能被复制)
func process(wg sync.WaitGroup) { // 编译错误!
// ...
}
Once面试常见问题
Q1: Once.Do中的函数panic了会怎样?
Once会认为函数已执行完成,后续调用不会再执行该函数,但会重新panic。
go
var once sync.Once
var count int
once.Do(func() {
count++
panic("故意panic")
})
// 后续调用不会执行函数,但可能重新panic
defer func() {
if r := recover(); r != nil {
fmt.Println("捕获panic:", r)
}
}()
once.Do(func() {
count++ // 不会执行
})
fmt.Println("count:", count) // 输出: 1
Q2: 如何实现可重试的Once?
封装Once,添加状态管理:
go
type RetryableOnce struct {
once sync.Once
mu sync.Mutex
success bool
}
func (ro *RetryableOnce) Do(f func() error) error {
ro.mu.Lock()
defer ro.mu.Unlock()
if ro.success {
return nil
}
var err error
ro.once.Do(func() {
err = f()
if err == nil {
ro.success = true
}
})
// 如果失败,重置Once以允许重试
if err != nil {
ro.once = sync.Once{}
}
return err
}
Q3: Once和init函数有什么区别?
具体参考下面表格:
| 特性 | sync.Once | init函数 |
|---|---|---|
| 执行时机 | 懒加载,第一次调用时 | 包导入时立即执行 |
| 线程安全 | 是 | 是(运行时保证) |
| 错误处理 | 需要额外机制 | 不支持,panic会导致启动失败 |
| 控制权 | 程序控制 | 运行时控制 |
| 性能 | 首次调用有开销 | 启动时一次性开销 |
综合面试问题
Q1: 什么时候用WaitGroup,什么时候用通道?
- 用WaitGroup:只需要等待一组goroutine完成,不需要收集结果或通信
- 用通道:需要goroutine间通信、传递数据、控制流程
- 结合使用:用通道传递任务,用WaitGroup等待所有worker完成
Q2: 如何用WaitGroup和通道实现工作池?
go
func workerPool(numWorkers int, jobs <-chan int, results chan<- int) {
var wg sync.WaitGroup
// 启动worker
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
for job := range jobs {
fmt.Printf("Worker %d processing job %d\n", workerID, job)
results <- job * 2 // 处理结果
}
}(i)
}
// 等待所有worker完成
wg.Wait()
close(results) // 所有结果已产生
}
Q3: 如何调试WaitGroup/Once相关的死锁?
- 添加日志:记录Add/Done的调用
- 使用pprof:分析goroutine堆栈
- 超时检测:为Wait添加超时
- 竞态检测 :使用
go run -race - 简化复现:创建最小复现案例
go
func debugWaitGroup() {
var wg sync.WaitGroup
done := make(chan bool)
// 超时机制
go func() {
wg.Wait()
done <- true
}()
select {
case <-done:
fmt.Println("正常完成")
case <-time.After(3 * time.Second):
fmt.Println("超时:可能死锁")
// 可以在这里打印调试信息
debug.PrintStack()
}
}
Q4: WaitGroup的内部实现原理是什么?
- 计数器:使用int32/int64的原子操作
- 等待队列:使用信号量(semaphore)实现
- 内存屏障:确保指令顺序
- 无锁优化:大部分操作为原子操作,性能高
Q5: Once为什么使用双重检查锁?
- 性能优化:第一次检查避免锁竞争(fast path)
- 线程安全:第二次检查确保只执行一次
- 内存屏障:原子操作保证可见性
- Go优化:编译器对Once有特殊优化
Q6: 如何实现一个分布式的Once?
需要结合分布式锁和版本控制:
go
type DistributedOnce struct {
key string
locker DistributedLocker // 分布式锁接口
version int64
}
func (do *DistributedOnce) Do(ctx context.Context, f func() error) error {
// 1. 检查是否已执行(读本地缓存)
if localCache.has(do.key) {
return nil
}
// 2. 获取分布式锁
lock, err := do.locker.Lock(ctx, do.key)
if err != nil {
return err
}
defer lock.Unlock()
// 3. 再次检查(防止并发的另一个节点已执行)
if globalStorage.has(do.key) {
localCache.set(do.key)
return nil
}
// 4. 执行函数
if err := f(); err != nil {
return err
}
// 5. 标记为已执行
globalStorage.set(do.key, do.version)
localCache.set(do.key)
return nil
}
避坑指南
避坑1:WaitGroup的Add调用位置
go
// ✅ 正确:先Add,后启动goroutine
wg.Add(1)
go func() {
defer wg.Done()
// 工作
}()
// ❌ 错误:goroutine内部Add
go func() {
wg.Add(1) // 可能竞态
defer wg.Done()
// 工作
}()
避坑2:Once的错误处理
go
// ✅ 正确:结合mutex处理错误
var (
once sync.Once
mu sync.Mutex
resource interface{}
initErr error
)
func GetResource() (interface{}, error) {
once.Do(func() {
mu.Lock()
defer mu.Unlock()
res, err := initialize()
resource = res
initErr = err
})
mu.Lock()
defer mu.Unlock()
return resource, initErr
}
// ❌ 错误:无法处理初始化失败
func GetResource() interface{} {
once.Do(func() {
resource = initialize() // 如果失败怎么办?
})
return resource
}
避坑3:避免嵌套死锁
go
// ❌ 危险:可能死锁
var once1, once2 sync.Once
once1.Do(func() {
once2.Do(func() {
// 工作1
})
})
once2.Do(func() {
once1.Do(func() { // 死锁!
// 工作2
})
})
// ✅ 安全:解耦依赖
var initPhase1, initPhase2 sync.Once
initPhase1.Do(func() {
// 阶段1初始化
})
initPhase2.Do(func() {
initPhase1.Do(func() {}) // 确保阶段1完成
// 阶段2初始化
})
总结与最佳实践
核心要点总结
- WaitGroup :
- 用于等待一组goroutine完成
- Add必须在goroutine启动前调用
- 必须使用指针传递
- 配合defer使用Done
- Once :
- 确保操作只执行一次
- 懒加载单例的完美解决方案
- 注意错误处理和panic
- 避免递归调用
面试和学习清单
基础问题:
- WaitGroup的三个方法及作用
- Once的Do方法特点
- WaitGroup计数器为负的后果
- Once.Do中函数panic的影响
进阶问题:
- WaitGroup和通道的选择
- 实现可重置的Once
- WaitGroup的死锁场景
- Once的双重检查锁原理
实战问题:
- 工作池模式实现
- 懒加载单例实现
- 配置加载的最佳实践
- 分布式任务调度设计
性能优化建议
- WaitGroup优化 :
- 批量Add减少调用次数
- 合理设置goroutine数量
- 避免过度细分任务
- Once优化 :
- 将Once用于真正的单次初始化
- 避免在热点路径中使用
- 考虑使用sync.Pool缓存对象
- 内存优化 :
- 复用WaitGroup对象
- 使用sync.Pool管理资源
- 避免goroutine泄露