Golang

关注公众号 jb51net

关闭
首页 > 脚本专栏 > Golang > Go WaitGroup使用与避坑

Go等待协程之WaitGroup使用与避坑指南

作者:XMYX-0

在Go并发编程中,goroutine非常轻量,但如何优雅地等待多个协程执行完成,才是工程实践中的关键问题,很多人第一反应是用 channel,但其实sync.WaitGroup才是更合适的工具,这篇文章不仅讲用法,还会带你深入理解WaitGroup的本质、实现机制,需要的朋友可以参考下

引言

在 Go 并发编程中,goroutine 非常轻量,但如何优雅地等待多个协程执行完成,才是工程实践中的关键问题。

很多人第一反应是用 channel,但在“只关心完成,不关心结果”的场景中,sync.WaitGroup 才是更合适的工具。

这篇文章不仅讲用法,还会带你深入理解 WaitGroup 的本质、实现机制,以及那些非常容易踩的坑

什么是 WaitGroup

WaitGroup 本质上是一个协程计数器 + 阻塞等待机制

它解决的问题是:

主协程如何等待一组子协程执行完成?

来看一个最简单的模型:

package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup

	wg.Add(3) // 设置需要等待的 goroutine 数量

	go func() {
		defer wg.Done() // 执行完任务后,计数器减1
		fmt.Println("任务1完成")
	}()

	go func() {
		defer wg.Done() // 执行完任务后,计数器减1
		fmt.Println("任务2完成")
	}()

	go func() {
		defer wg.Done() // 执行完任务后,计数器减1
		fmt.Println("任务3完成")
	}()

	wg.Wait() // 阻塞,直到计数器归零
	fmt.Println("所有任务完成")
}

输出:

任务3完成
任务1完成
任务2完成
所有任务完成

小结

核心思想:计数器归零 → 主协程继续执行

使用示例(逐步深入)

基础示例:等待多个任务

package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	for i := 0; i < 3; i++ {
		wg.Add(1) // 增加计数器
		// 启动goroutine处理任务
		go func(i int) {
			// 任务完成后,计数器减1
			defer wg.Done()
			fmt.Println("处理任务:", i)
		}(i) // 注意这里的i是值传递,而不是引用传递
	}
	wg.Wait()
	fmt.Println("全部完成")
}

输出:

处理任务: 2
处理任务: 0
处理任务: 1
全部完成

示例进阶:结合业务处理

package main

import (
	"fmt"
	"sync"
	"time"
)

// 并发执行
func worker(id int, wg *sync.WaitGroup) {
	defer wg.Done() // 告诉主协程,子协程已经执行完毕

	fmt.Println("worker", id, "开始")
	time.Sleep(time.Second)
	fmt.Println("worker", id, "结束")
}
func main() {
	// 并发执行多个 worker
	var wg sync.WaitGroup
	// 等待5个协程执行完毕
	for i := 0; i < 5; i++ {
		// 告诉主协程,子协程还没执行完毕
		wg.Add(1)
		// 并发执行子协程
		go worker(i, &wg)
	}
	// 等待所有协程执行完毕
	wg.Wait()
	fmt.Println("所有 worker 执行完毕")
}

输出:

worker 4 开始
worker 0 开始
worker 1 开始
worker 2 开始
worker 3 开始
worker 3 结束
worker 4 结束
worker 0 结束
worker 1 结束
worker 2 结束
所有 worker 执行完毕

示例进阶:错误处理(常见误区前奏)

WaitGroup 不能直接获取返回值,如果你需要结果,必须结合 channel

package main

import (
	"fmt"
	"sync"
)

// 定义一个 worker,将计算结果发送到 channel 中
func worker(id int, wg *sync.WaitGroup, ch chan<- int) {
	// 告诉 WaitGroup 我们已经完成了
	fmt.Println("worker", id, "starting")
	defer wg.Done()
	ch <- id * 2
	fmt.Println("worker", id, "done")
}
func main() {
	var wg sync.WaitGroup
	// 创建一个 channel,大小为5
	ch := make(chan int, 5)
	for i := 1; i < 5; i++ {
		wg.Add(1)
		go worker(i, &wg, ch)
	}
	// 等待所有 worker 都完成
	wg.Wait()
	// 关闭 channel,防止阻塞
	close(ch)
	// 从 channel 中读取数据
	for v := range ch {
		// v 就是从 channel 中接收到的值
		fmt.Println("结果:", v)
	}
}

输出:

worker 4 starting
worker 4 done
worker 1 starting
worker 1 done
worker 2 starting
worker 2 done
worker 3 starting
worker 3 done
结果: 8
结果: 2
结果: 4
结果: 6

小结:
- WaitGroup 解决“同步问题”(等完成)
- channel 解决“通信问题”(传结果)
- 两者通常组合使用,而不是互相替代

思考点

为什么 WaitGroup 不设计成可以直接返回结果?

因为它的职责非常单一:只做“等待”这件事,避免职责膨胀。

常见坑(重点)

这里是实际开发中最容易翻车的地方。

坑一:Add 写在 goroutine 里(致命问题)

错误写法:

for i := 0; i < 3; i++ {
	go func() {
		wg.Add(1) // ❌ 错误
		defer wg.Done()
		fmt.Println("任务")
	}()
}
wg.Wait()

问题:

正确写法:

wg.Add(1)
go func() {
	defer wg.Done()
}()

小结

Add 必须在启动 goroutine 之前执行

坑二:多调用 Done 导致负数 panic

wg.Add(1)

go func() {
	defer wg.Done()
	wg.Done() // ❌ 多调用
}()

运行直接炸:

panic: sync: negative WaitGroup counter

小结

坑三:WaitGroup 被复制(隐蔽但致命)

func worker(wg sync.WaitGroup) { // ❌ 传值
	defer wg.Done()
}

问题:

结果:永远等不到结束

正确写法:

func worker(wg *sync.WaitGroup)

小结

WaitGroup 必须用指针传递

坑四:WaitGroup 重用不当

wg.Add(1)
go func() {
	defer wg.Done()
}()

wg.Wait()

wg.Add(1) // ❌ 有风险

如果之前的 goroutine 还没完全结束,可能出现竞态问题

建议

一个 WaitGroup 对应一批任务,不要复用

底层原理解析(重点)

WaitGroup 看起来简单,但内部实现非常精妙。

核心结构(简化理解):

type WaitGroup struct {
	state1 [3]uint32
}

实际包含:

Add 的本质

wg.Add(n)

本质是:

原子操作增加计数器

atomic.AddInt32(&counter, n)

Done 的本质

wg.Done()

等价于:

wg.Add(-1)

Wait 的本质

wg.Wait()

核心逻辑:

唤醒机制

当最后一个 Done() 执行:

使用的是 runtime 层的信号量机制(runtime_Semrelease

思考点

为什么 WaitGroup 不用 channel 实现?

因为:

WaitGroup vs channel vs context

这是很多人容易混淆的点。

WaitGroup

channel

context

小结

工具作用
WaitGroup等待任务结束
channel通信 + 同步
context取消 / 控制生命周期

最佳实践(非常重要)

Add 和 goroutine 启动要“绑定”

wg.Add(1)
go func() {
	defer wg.Done()
}()

永远使用 defer Done

避免遗漏:

defer wg.Done()

不要跨函数滥用 WaitGroup

建议:

与 channel 组合使用

WaitGroup 等待结束,channel 传递结果:

这是生产环境最常见组合

不要用 WaitGroup 做这些事

总结

WaitGroup 看起来只是三个方法,但背后是 Go 并发设计的一个重要思想:

用最简单的机制解决最单一的问题

它的定位非常明确:

也正因为如此,它才能做到:

最后的思考

如果让你自己设计一个 WaitGroup,你会怎么做?

想明白这个问题,你对 Go 并发的理解会再上一个层次。

如果你真的从零设计一套,你最终会得到一个结论:

WaitGroup 本质 = 计数器 + 阻塞机制 + 唤醒机制

WaitGroup 的本质是什么?

你可以直接答:

它是一个基于原子计数器的并发同步原语,
通过 runtime 信号量实现 goroutine 的阻塞与唤醒,
用于解决多个并发任务的收敛(join)问题。

以上就是Go等待协程之WaitGroup使用与避坑指南的详细内容,更多关于Go WaitGroup使用与避坑的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文