17、Go协程通关秘籍:主协程等待+多协程顺序执行实战解析


点击投票为我的2025博客之星评选助力!


Go协程通关秘籍:主协程等待+多协程顺序执行实战解析

关键词:Go、goroutine、并发编程、协程同步、原子操作、chan

作为Go语言开发者,goroutine(协程)是实现并发编程的核心利器,但新手在使用时总会踩两个高频坑:主goroutine提前退出导致子协程终止多goroutine执行顺序完全不可控

本文基于实战场景拆解这两个核心问题的解决方案,从"粗暴兜底"到"优雅实现",带你吃透goroutine的执行规则!

一、核心痛点1:主goroutine如何等待子协程执行完毕?

Go程序的生命周期由主goroutine主导------一旦主goroutine执行完毕,整个程序会直接退出,无论子goroutine是否运行完成。如何让主goroutine"等一等"子goroutine?我们来看三种方案:

1.1 粗暴兜底:time.Sleep(不推荐)

最简单的方式是让主goroutine"睡眠"一段时间,给子goroutine留出执行时间:

go 复制代码
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接收所有信号后再退出。

示例代码:

go 复制代码
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 复制代码
// 错误示例:go函数未传参,共享循环变量i
for i := 0; i < 10; i++ {
	go func() {
		fmt.Println(i) // 所有goroutine共享i,执行时i已被修改
	}()
}

修复 :给go函数显式传参,利用Go"参数求值在go语句执行时完成"的特性,让每个goroutine拿到唯一的i:

go 复制代码
for i := 0; i < 10; i++ {
	go func(i int) {
		fmt.Println(i) // 每个goroutine拿到当次迭代的i
	}(i)
}

2.2 核心实现:原子操作+自旋(spinning)

通过原子操作保证count变量的并发安全,以count作为"下一个可执行协程的序号",实现顺序执行:

完整示例:

go 复制代码
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启动:

go 复制代码
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的数量,评论区聊聊你的发现~)

相关推荐
曾阿伦6 分钟前
Python3 文件 (夹) 操作备忘录
开发语言·python
清心歌8 分钟前
记一次系统环境变量更改后在IDEA中无法读取新值的排查过程
java·后端·intellij-idea·idea
G探险者11 分钟前
聊聊流程编排框架LiteFlow
后端
大尚来也11 分钟前
驾驭并发:.NET多线程编程的挑战与破局之道
java·前端·算法
dong__csdn14 分钟前
jdk添加信任证书
java·开发语言
向阳而生,一路生花16 分钟前
深入浅出 JDK7 HashMap 源码分析
算法·哈希算法
快乐小土豆~~19 分钟前
echarts柱状图的X轴label过长被重叠覆盖
前端·javascript·vue.js·echarts
hhcccchh28 分钟前
1.1 HTML 语义化标签(header、nav、main、section、footer 等)
java·前端·html
随风,奔跑31 分钟前
Spring Security
java·后端·spring
君义_noip40 分钟前
信息学奥赛一本通 4150:【GESP2509七级】⾦币收集 | 洛谷 P14078 [GESP202509 七级] 金币收集
c++·算法·gesp·信息学奥赛·csp-s