17、Go协程通关秘籍:主协程等待+多协程顺序执行实战解析
摘要: 本文针对Go协程开发中的两大痛点——主协程提前退出导致子协程终止、多协程执行顺序不可控,提供实战解决方案。对于主协程等待问题,对比了time.Sleep(不推荐)、chan struct{}信号通知及sync.WaitGroup三种方案;针对顺序执行问题,通过原子操作+自旋或通道链实现精准控制,并强调参数传递的避坑要点。文章深入剖析并发安全与调度逻辑,帮助开发者掌握协程同步的核心技术。(1
点击投票为我的2025博客之星评选助力!
Go协程通关秘籍:主协程等待+多协程顺序执行实战解析
关键词:Go、goroutine、并发编程、协程同步、原子操作、chan
作为Go语言开发者,goroutine(协程)是实现并发编程的核心利器,但新手在使用时总会踩两个高频坑:主goroutine提前退出导致子协程终止、多goroutine执行顺序完全不可控。
本文基于实战场景拆解这两个核心问题的解决方案,从“粗暴兜底”到“优雅实现”,带你吃透goroutine的执行规则!
一、核心痛点1:主goroutine如何等待子协程执行完毕?
Go程序的生命周期由主goroutine主导——一旦主goroutine执行完毕,整个程序会直接退出,无论子goroutine是否运行完成。如何让主goroutine“等一等”子goroutine?我们来看三种方案:
1.1 粗暴兜底:time.Sleep(不推荐)
最简单的方式是让主goroutine“睡眠”一段时间,给子goroutine留出执行时间:
package main
import (
"fmt"
"time"
)
func main() {
for i := 0; i < 10; i++ {
go func() {
fmt.Println(i)
}()
}
// 主goroutine睡眠500毫秒
time.Sleep(time.Millisecond * 500)
}
缺点:睡眠时长完全靠“猜”——太短可能子协程没执行完,太长纯浪费资源,无法适配复杂场景。
1.2 优雅方案:基于chan的信号通知
利用通道(chan)传递“执行完成”的信号,是更可控的方式。核心思路:
- 创建与子协程数量一致的通道(推荐
chan struct{},空结构体占0字节,仅作信号传递); - 每个子协程执行完毕时向通道发送信号;
- 主goroutine接收所有信号后再退出。
示例代码:
package main
import (
"fmt"
)
func main() {
// 空结构体通道,仅传递信号
sign := make(chan struct{}, 10)
for i := 0; i < 10; i++ {
go func(i int) {
defer func() {
// 子协程结束发送信号
sign <- struct{}{}
}()
fmt.Println(i)
}(i)
}
// 接收所有子协程的信号
for i := 0; i < 10; i++ {
<-sign
}
}
优势:精准控制等待时机,无资源浪费;struct{}作为通道类型,兼顾性能与语义。
1.3 更优方案:sync.WaitGroup(后续重点)
标准库sync包的WaitGroup是专门解决“等待一组协程完成”的工具,比通道更简洁(后续讲解sync包时详细展开)。
二、核心痛点2:如何让多goroutine按既定顺序执行?
默认情况下,goroutine的执行顺序与go语句的执行顺序无关,完全由Go运行时调度。如何让子协程按0→1→2→…→9的顺序打印?
2.1 先避坑:参数传递的关键细节
先看一个错误示例(打印结果无序且可能重复):
// 错误示例:go函数未传参,共享循环变量i
for i := 0; i < 10; i++ {
go func() {
fmt.Println(i) // 所有goroutine共享i,执行时i已被修改
}()
}
修复:给go函数显式传参,利用Go“参数求值在go语句执行时完成”的特性,让每个goroutine拿到唯一的i:
for i := 0; i < 10; i++ {
go func(i int) {
fmt.Println(i) // 每个goroutine拿到当次迭代的i
}(i)
}
2.2 核心实现:原子操作+自旋(spinning)
通过原子操作保证count变量的并发安全,以count作为“下一个可执行协程的序号”,实现顺序执行:
完整示例:
package main
import (
"fmt"
"sync/atomic"
"time"
)
func main() {
var count uint32 // 原子操作的计数器,标记下一个可执行的协程序号
// 触发函数:自旋等待count匹配,执行函数后更新count
trigger := func(i uint32, fn func()) {
for {
// 原子读取count值,避免竞态条件
if n := atomic.LoadUint32(&count); n == i {
fn() // 执行打印逻辑
atomic.AddUint32(&count, 1) // count+1,放行下一个协程
break
}
// 短暂睡眠,减少CPU空转
time.Sleep(time.Nanosecond)
}
}
// 启动10个goroutine
for i := uint32(0); i < 10; i++ {
go func(i uint32) {
fn := func() {
fmt.Println(i)
}
trigger(i, fn)
}(i)
}
// 等待所有子协程执行完毕(count=10时触发)
trigger(10, func() {})
}
核心逻辑:
count是全局信号量,值为“下一个可执行的协程序号”;- 每个goroutine通过
trigger函数自旋检查count,匹配时才执行打印; - 原子操作(
atomic.LoadUint32/atomic.AddUint32)保证count的并发安全; - 短暂
Sleep避免CPU 100%空转(实测去掉Sleep会因CPU抢占导致程序卡死)。
2.3 拓展思路:基于chan的链式通知
也可通过“通道链”实现顺序执行——每个goroutine执行完后,通过通道通知下一个goroutine启动:
package main
import (
"fmt"
)
func main() {
num := 10
// 创建通道数组,实现链式通知
chs := [11]chan struct{}{}
for i := 0; i < 11; i++ {
chs[i] = make(chan struct{})
}
// 启动goroutine,等待前一个通道信号
for i := 0; i < num; i++ {
go func(i int) {
<-chs[i] // 等待前一个协程的信号
fmt.Println(i)
chs[i+1] <- struct{}{} // 通知下一个协程执行
}(i)
}
chs[0] <- struct{}{} // 启动第一个协程
<-chs[num] // 等待最后一个协程完成
}
三、总结
本文围绕goroutine的两个核心问题展开:
- 主goroutine等待子协程:
time.Sleep(兜底)→chan struct{}(优雅)→sync.WaitGroup(最优); - 多goroutine顺序执行:核心是通过“信号量(count/chan)”控制执行时机,结合原子操作/通道保证并发安全。
理解这些方案,不仅能解决实际开发中的并发问题,更能深入掌握Go协程的调度规则。
思考题
runtime包中提供了哪些与GPM模型(G:goroutine、P:处理器、M:系统线程)相关的函数?(比如runtime.GOMAXPROCS()可控制P的数量,评论区聊聊你的发现~)
更多推荐

所有评论(0)