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

相关推荐
洛克大航海2 小时前
Python面向对象
开发语言·python·面向对象
东华果汁哥2 小时前
【机器视觉 行人检测算法】FastAPI 部署 YOLO 11行人检测 API 服务教程
算法·yolo·fastapi
每天学一点儿2 小时前
[SimpleITK] 教程 63:配准初始化 (Registration Initialization) —— 从几何对齐到手动干预。
算法
【赫兹威客】浩哥2 小时前
【赫兹威客】框架模板-前端命令行部署教程
前端·vue.js
哪里不会点哪里.2 小时前
Spring 中常用注解详解
java·后端·spring
君义_noip2 小时前
信息学奥赛一本通 1463:门票
c++·算法·哈希算法·信息学奥赛·csp-s
草莓熊Lotso2 小时前
Qt 控件美化与交互进阶:透明度、光标、字体与 QSS 实战
android·java·开发语言·c++·人工智能·git·qt
永远都不秃头的程序员(互关)2 小时前
【决策树深度探索(二)】决策树入门:像人类一样决策,理解算法核心原理!
算法·决策树·机器学习
小妖6662 小时前
javascript 舍去小数(截断小数)取整方法
开发语言·前端·javascript