大家好,我是公号「左诗右码」作者 Alex。
学完了并发基础(Channel、GMP、锁),很多同学在写实际代码时还是会手忙脚乱。
比如:
- “我开了 10 个 goroutine,怎么等它们都跑完我再退出?”
- “for 循环里启动 goroutine 怎么总是打印同样的值?”
- “我要处理 100 万个请求,怎么限制同时只能有 10 个在跑?”
今天我们就来解决这三个最常见的实战问题。
1. 主协程如何等待子协程退出?
初学者常犯的错误:
func main() {
go func() {
fmt.Println("Worker doing job...")
}()
// 主协程直接退出了,子协程还没来得及跑
}
1.1 笨办法:Sleep
time.Sleep(time.Second)。
评价:极差。你不知道子协程要跑多久,睡短了没用,睡长了浪费时间。
1.2 Channel 通信
对于单个或少量子协程:
done := make(chan bool)
go func() {
// ... do work ...
done <- true
}()
<-done // 阻塞等待
评价:尚可。适合一对一等待。
1.3 最佳实践:sync.WaitGroup
对于多个子协程,sync.WaitGroup 是标准答案。
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1) // 1. 计数器 +1 (必须在 go 之前)
go func(id int) {
defer wg.Done() // 3. 退出时计数器 -1
fmt.Printf("Worker %d starting\n", id)
time.Sleep(time.Second)
fmt.Printf("Worker %d done\n", id)
}(i)
}
wg.Wait() // 2. 阻塞直到计数器归零
fmt.Println("All workers done")
避坑:
-
wg.Add(1)一定要在启动 Goroutine 之前调用。如果在 Goroutine 里面调用,可能主协程跑到Wait()时计数器还是 0,直接就退出了。 -
wg对象如果要传递给函数,必须传指针,不能值拷贝,否则会导致死锁。
2. 经典面试坑:循环中的闭包陷阱与 Go 1.22 的重磅修复
在上面 WaitGroup 的例子中,我们把 i 作为参数传给了 goroutine。如果在老版本的 Go 中直接使用外部变量会怎样?
for i := 0; i < 3; i++ {
go func() {
fmt.Println(i) // 在 Go 1.22 之前,这里打印的可能全是 3!
}()
}
老版本的深层原因(Go 1.21 及以前):
for 循环跑得非常快,而 goroutine 的启动和调度需要时间。在老版本中,整个 for 循环共享同一个变量 i 的内存地址。当 goroutine 真正开始执行时,循环往往已经结束了,此时该地址里的值已经变成了 3,导致所有的 goroutine 都打印 3。
老版本中的解决办法:
- 传参(推荐):把变量作为参数传给匿名函数,这会发生值拷贝。
go func(val int) { fmt.Println(val) }(i)
- 局部变量遮蔽:在循环体内部重新定义一个同名变量。
for i := 0; i < 3; i++ {
i := i // 重新在当前块级作用域定义一个 i
go func() { fmt.Println(i) }()
}
从 Go 1.22 开始,for 循环的变量会在每次迭代时按值拷贝,而不是共享地址。因此,上面的代码在 Go 1.22 中会按预期打印 0, 1, 2。也就是说,每个 goroutine 都有自己的 i 副本,互不干扰。
3. 如何控制并发数(限流)?
假设你有 100 万个 URL 要爬取,如果你直接 for 循环开 100 万个 Goroutine:
- 内存爆了(OOM)。
- CPU 调度不过来。
- 目标网站把你 IP 封了。
你需要并发控制(Semaphore 模式)。
3.1 方案一:有缓冲 Channel (最常用)
利用 Channel 的缓冲区大小作为并发限制(令牌桶)。
func main() {
tasks := 100 // 总任务数
concurrency := 10 // 最大并发数
// 创建一个容量为 10 的 channel 作为信号量
sem := make(chan struct{}, concurrency)
var wg sync.WaitGroup
for i := 0; i < tasks; i++ {
wg.Add(1)
// 获取令牌:如果 channel 满了,这里会阻塞,从而控制了并发数
sem <- struct{}{}
go func(id int) {
defer wg.Done()
defer func() { <-sem }() // 释放令牌:读出一个元素
// 模拟任务
time.Sleep(time.Second)
fmt.Printf("Worker %d done\n", id)
}(i)
}
wg.Wait()
}
原理:sem 满了之后,主协程的 for 循环就会阻塞在 sem <- struct{}{} 这一行,不再创建新的 Goroutine。直到有 Worker 完成并从 sem 中取走一个,主协程才能继续。
3.2 方案二:Worker Pool (协程池)
固定启动 N 个 Worker,它们抢着去消费任务队列。
func worker(id int, jobs <-chan int, wg *sync.WaitGroup) {
defer wg.Done()
for j := range jobs {
fmt.Printf("Worker %d processing job %d\n", id, j)
time.Sleep(time.Second)
}
}
func main() {
jobs := make(chan int, 100)
var wg sync.WaitGroup
// 1. 启动 3 个 worker
for w := 1; w <= 3; w++ {
wg.Add(1)
go worker(w, jobs, &wg)
}
// 2. 发送 5 个任务
for j := 1; j <= 5; j++ {
jobs <- j
}
close(jobs) // 发送完毕关闭 channel,让 worker 退出
// 3. 等待 worker 全部结束
wg.Wait()
}
对比:
- 方案一(Channel 限流):实现简单,动态创建 G。适合任务数不确定或流式处理。
- 方案二(Worker Pool):复用 G,减少创建销毁开销。适合任务量巨大且明确的场景。
4. 总结
-
等待退出:多协程用
sync.WaitGroup。记住Add在前,Done在defer中。 -
闭包陷阱:在
for循环里开 Goroutine,一定要把循环变量通过参数传进去,或者在循环内部重新声明。 -
并发控制:
- Token 模式:用带缓冲 Channel 控制同时运行的 G 数量。
- Worker Pool 模式:固定 G 数量消费 Channel。
掌握了这两招,你就能写出既快又稳的并发程序了。
但是,如果你的 Worker Pool 想要做得更通用、更优雅(比如支持动态扩容、优雅关闭),该怎么封装呢?
下一篇,我们来手撸一个生产级的 Goroutine Pool。