Go语言中的同步等待组和单例模式:sync.WaitGroup和sync.Once

文章目录

书接上回:《Go语言原子操作:atomic包全解析》

在并发编程中,有两个常见的需求:

  1. 等待一组goroutine完成工作(例如:等待所有文件下载完成)
  2. 确保某个初始化操作只执行一次(例如:数据库连接池初始化)

Go语言通过sync.WaitGroupsync.Once这两个工具,为这些需求提供了优雅的解决方案。它们比直接使用通道或互斥锁更简洁、更安全。

WaitGroup:等待一组goroutine完成

WaitGroup的基本原理

sync.WaitGroup用于等待一组goroutine完成执行,它内部维护一个计数器,通过三个方法来协调goroutine的执行:

核心方法

  • Add(delta int):增加计数器的值(delta可以为负数)
  • Done():将计数器减1
  • Wait():阻塞直到计数器变为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已完成,程序继续执行

代码解析

  1. wg.Add(1) 在启动每个goroutine前调用,增加计数器
  2. defer wg.Done() 确保goroutine结束时计数器减1
  3. wg.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()
}

避坑指南

  1. Add调用位置:必须在所有goroutine启动前调用Add
  2. Wait调用时机:确保所有必要资源都已就绪再调用Wait
  3. 避免循环依赖:goroutine之间不要有循环等待
  4. 超时机制:长时间任务考虑添加超时

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}

面试避坑指南

  1. 错误处理:Once没有错误返回机制,需要额外处理
  2. Panic处理:如果Do中的函数panic,Once会认为已执行完成
  3. 初始化状态:需要额外的标志位来判断初始化是否成功
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("检测到死锁")
}

面试避坑指南

  1. 禁止递归调用:绝对不要在Do函数内调用同一个Once
  2. 避免间接递归:注意函数调用链中的Once使用
  3. 死锁检测:添加超时机制防止永久阻塞
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。原因:

  1. 避免竞态条件:如果Add在goroutine中调用,可能Wait先执行
  2. 保证计数准确:提前知道任务数量
  3. 代码更清晰:集中管理任务计数

错误示例

go 复制代码
// 错误:Add在goroutine内部调用
for i := 0; i < 3; i++ {
    go func() {
        wg.Add(1)  // 可能在Wait之后执行
        defer wg.Done()
        // 工作
    }()
}
wg.Wait()  // 可能提前返回
Q2: 如何避免WaitGroup的死锁?
  1. 匹配Add/Done调用:确保Add和Done调用次数相等

  2. 使用defer调用Done:即使goroutine panic也会执行

  3. 避免循环等待:goroutine之间不要互相等待

  4. 添加超时机制

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相关的死锁?
  1. 添加日志:记录Add/Done的调用
  2. 使用pprof:分析goroutine堆栈
  3. 超时检测:为Wait添加超时
  4. 竞态检测 :使用go run -race
  5. 简化复现:创建最小复现案例
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的内部实现原理是什么?
  1. 计数器:使用int32/int64的原子操作
  2. 等待队列:使用信号量(semaphore)实现
  3. 内存屏障:确保指令顺序
  4. 无锁优化:大部分操作为原子操作,性能高
Q5: Once为什么使用双重检查锁?
  1. 性能优化:第一次检查避免锁竞争(fast path)
  2. 线程安全:第二次检查确保只执行一次
  3. 内存屏障:原子操作保证可见性
  4. 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初始化
})

总结与最佳实践

核心要点总结

  1. WaitGroup
    • 用于等待一组goroutine完成
    • Add必须在goroutine启动前调用
    • 必须使用指针传递
    • 配合defer使用Done
  2. Once
    • 确保操作只执行一次
    • 懒加载单例的完美解决方案
    • 注意错误处理和panic
    • 避免递归调用

面试和学习清单

基础问题

  • WaitGroup的三个方法及作用
  • Once的Do方法特点
  • WaitGroup计数器为负的后果
  • Once.Do中函数panic的影响

进阶问题

  • WaitGroup和通道的选择
  • 实现可重置的Once
  • WaitGroup的死锁场景
  • Once的双重检查锁原理

实战问题

  • 工作池模式实现
  • 懒加载单例实现
  • 配置加载的最佳实践
  • 分布式任务调度设计

性能优化建议

  1. WaitGroup优化
    • 批量Add减少调用次数
    • 合理设置goroutine数量
    • 避免过度细分任务
  2. Once优化
    • 将Once用于真正的单次初始化
    • 避免在热点路径中使用
    • 考虑使用sync.Pool缓存对象
  3. 内存优化
    • 复用WaitGroup对象
    • 使用sync.Pool管理资源
    • 避免goroutine泄露
相关推荐
lsx20240610 小时前
C++ 变量作用域
开发语言
小鸡脚来咯10 小时前
设计模式面试介绍指南
java·开发语言·单例模式
小北方城市网10 小时前
GEO 全场景智能生态:自适应架构重构与极限算力协同落地
开发语言·人工智能·python·重构·架构·量子计算
十五年专注C++开发10 小时前
CMake进阶:核心命令get_filename_component 完全详解
开发语言·c++·cmake·跨平台编译
Blossom.11810 小时前
工业级扩散模型优化实战:从Stable Diffusion到LCM的毫秒级生成
开发语言·人工智能·python·深度学习·机器学习·stable diffusion·transformer
嘿嘿潶黑黑10 小时前
关于QButtonGroup 在Qt5和Qt6之间的差异
开发语言·qt
代码游侠10 小时前
应用——Linux FrameBuffer图形显示与多线程消息系统项目
linux·运维·服务器·开发语言·前端·算法
hqwest10 小时前
码上通QT实战09--监控页面01-区域划分
开发语言·qt·layout·qss·qt 布局
有梦想的攻城狮10 小时前
Django使用介绍
后端·python·django