第 22 篇:实战并发(一):主协程等待、闭包陷阱与并发数控制

大家好,我是公号「左诗右码」作者 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。

老版本中的解决办法:

  1. 传参(推荐):把变量作为参数传给匿名函数,这会发生值拷贝。
go func(val int) { fmt.Println(val) }(i)
  1. 局部变量遮蔽:在循环体内部重新定义一个同名变量。
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:

  1. 内存爆了(OOM)。
  2. CPU 调度不过来。
  3. 目标网站把你 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. 总结

  1. 等待退出:多协程用 sync.WaitGroup。记住 Add 在前,Done 在 defer 中。
  2. 闭包陷阱:在 for 循环里开 Goroutine,一定要把循环变量通过参数传进去,或者在循环内部重新声明。
  3. 并发控制:
    • Token 模式:用带缓冲 Channel 控制同时运行的 G 数量。
    • Worker Pool 模式:固定 G 数量消费 Channel。

掌握了这两招,你就能写出既快又稳的并发程序了。

但是,如果你的 Worker Pool 想要做得更通用、更优雅(比如支持动态扩容、优雅关闭),该怎么封装呢?
下一篇,我们来手撸一个生产级的 Goroutine Pool。

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容