云原生探索系列(十八):Go 语言sync.WaitGroup以及sync.Once

112 阅读7分钟

前言

先来看这样一段计数的程序:

var counter int
var mu sync.Mutex

func increment() {
	mu.Lock()
	counter++
	defer mu.Unlock()
}

func main() {

	for i := 0; i < 100; i++ {
		go func() {
			increment()
		}()
	}
	fmt.Println("Counter:", counter)
}

如果你执行了该程序,发现结果值小于100,这是为啥呢?

这段代码中, main 函数启动了 100 个 goroutine ,并行执行 increment 函数, 每个 goroutine 都会对 counter 进行加锁和自增操作。然而, main 函数在所有 goroutine 执行完之前就调用了 fmt.Println("Counter:", counter), 这时打印出来的 counter 值可能是一个较小的值,因为并发执行的 goroutine 可能还没有完成。如何解决呢?

Go 语言提供了许多并发工具来帮助开发者管理多个 goroutine 的生命周期和同步。 sync.WaitGroup 和 sync.Once 是其中两个非常常用且重要的工具,它们分别用于等待一组 goroutine 完成和确保某个操作只执行一次。

sync.WaitGroup

sync.WaitGroup 用于等待多个 goroutine 完成。 通过 Add、 Done 和 Wait 方法, sync.WaitGroup 可以确保我们能够在主线程或者其他 goroutine 中等待多个并发任务完成。

常用方法

  • Add(delta int) :增加或减少等待计数器。 delta 参数可以是正数或负数,通常使用 Add(1) 来表示有一个 goroutine 需要等待, Done() 方法内部会调用 Add(-1) 来表示 goroutine 完成。
  • Done() :表示当前 goroutine 完成。当一个 goroutine 执行完毕时调用 Done() ,减少 WaitGroup 的计数。
  • Wait() :阻塞当前 goroutine,直到所有 goroutine 完成。它会阻塞直到 Add 增加的数量通过 Done 调用相应次数。

2. 使用示例

还是前言中的案例,我们进行如下修改:

var counter int
var mu sync.Mutex

func increment() {
	mu.Lock()
	counter++
	defer mu.Unlock()
}

func main() {
	var wg sync.WaitGroup

	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			increment()
		}()
	}
	wg.Wait()
	fmt.Println("Counter:", counter)
}

这段代码,我们使用 wg.Add(1) 来告诉 WaitGroup 我们需要等待一个 goroutine, wg.Done() 用于每个 goroutine 完成时调用,减少计数器。最后, wg.Wait() 会阻塞,直到所有 goroutine 完成。
此时,再执行这段代码,输出结果就是100.

sync.Once

sync.Once 是一种机制,它确保某个操作只会执行一次,无论有多少 goroutine 尝试执行该操作。 sync.Once 对象的核心方法是 Do ,它会确保提供的函数只被执行一次,无论有多少个 goroutine 调用它。

常用方法

  • Do(f func()) :如果 f 还没有执行过,它将执行一次。无论调用多少次, f 函数只会被执行一次。

使用示例

var once sync.Once
var wg sync.WaitGroup

func initOnce() {
	fmt.Println("Initializing...")
}

func worker(id int) {
	defer wg.Done()
	// 确保初始化函数只执行一次
	once.Do(initOnce)
	fmt.Printf("Worker %d is working\n", id)
}

func main() {

	for i := 1; i <= 3; i++ {
		wg.Add(1)
		go worker(i)
	}

	wg.Wait()
}

这段代码中,在 worker 函数中,使用了 sync.Once 来确保 initOnce 函数只被执行一次。 即使有多个 goroutine 调用 worker, initOnce 也仅会执行一次。

执行代码,输出如下:

Initializing...
Worker 1 is working
Worker 2 is working
Worker 3 is working

不管启动多少个 worker, initOnce 函数始终只会被调用一次,这就是 sync.Once 的强大之处。

sync.WaitGroup 和 sync.Once 对比

特性sync.WaitGroupsync.Once
主要用途等待一组 goroutine 完成确保某个操作只执行一次
适用场景当你需要在多个并发操作完成后继续执行下一步工作时当你需要初始化某些资源或做一次性工作时
常用方法Add、 Done、 WaitDo
使用限制必须调用 Done 与 Add 配合使用只执行一次,且没有回调次数限制

使用WaitGroup值实现一对多的 goroutine 协作流程时,怎样才能让分发子任务的 goroutine 获得各个子任务的具体执行结果

通常的做法是使用 通道(Channel) 来传递每个子任务的执行结果。通过这种方式,主 goroutine 可以从子任务 goroutine 中获取结果。

具体步骤如下:

  1. 创建一个用于传递结果的通道 :每个子任务的执行结果将通过这个通道传递给主 goroutine。
  2. 等待所有 goroutine 完成 :通过 WaitGroup 等待所有子任务完成。
  3. 收集结果 :主 goroutine 通过通道收集来自子任务 goroutine 的结果。

这里假设我们有 5 个子任务,每个子任务都返回一个整数结果,主 goroutine 将等待所有子任务完成并收集它们的结果。

func processTask(id int, ch chan<- int, wg *sync.WaitGroup) {
	defer wg.Done()
	result := id * id
	ch <- result // 将结果发送到通道
}

func main() {
	var wg sync.WaitGroup
	resultCh := make(chan int, 5) // 创建一个缓冲区大小为 5 的通道,存储每个任务的结果

	for i := 1; i <= 5; i++ {
		wg.Add(1)
		go processTask(i, resultCh, &wg)
	}

	wg.Wait()
	close(resultCh) // 关闭通道,表示没有更多的数据发送

	// 收集并打印结果
	for result := range resultCh {
		fmt.Println("Task result:", result)
	}
}

这段代码中,我们使用一个带缓冲的通道 resultCh 来传递每个子任务的执行结果。通道的缓冲区大小为 5,因为我们计划启动 5 个子任务。 当所有 goroutine 完成后,我们关闭通道 resultCh ,表示没有更多的数据会被发送到该通道。关闭通道是为了避免在主 goroutine 中发生死锁。 主 goroutine 在 close(resultCh) 后,通过 range 从通道中读取结果。

执行代码,输出如下:

Task result: 4
Task result: 25
Task result: 1
Task result: 9
Task result: 16

最后

再总结一下, sync.WaitGroup 使我们能够等待多个 goroutine 完成工作,而 sync.Once 确保某个操作只会执行一次。理解并正确使用这些工具能够让你的并发程序更加高效、简洁和可靠。