点击投票为我的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的两个核心问题展开:

  1. 主goroutine等待子协程:time.Sleep(兜底)→ chan struct{}(优雅)→ sync.WaitGroup(最优);
  2. 多goroutine顺序执行:核心是通过“信号量(count/chan)”控制执行时机,结合原子操作/通道保证并发安全。

理解这些方案,不仅能解决实际开发中的并发问题,更能深入掌握Go协程的调度规则。

思考题

runtime包中提供了哪些与GPM模型(G:goroutine、P:处理器、M:系统线程)相关的函数?(比如runtime.GOMAXPROCS()可控制P的数量,评论区聊聊你的发现~)

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐