3.2 Goroutine:给夜班添人手


3.2 Goroutine:给夜班添人手

本节摘要:goroutine 是 Go 运行时调度的轻量执行单元,初始栈仅 KB 量级,一条 go 语句即可启动数万个。本节讲它的启动方式、生命周期规则、GMP 调度架构与让出机制,并给出可靠的等待写法。学完本节,你能放心地把任务拆成任意粒度的并发单元,而不是被"开太多会崩"的线程直觉绑架。

上一节立了观念,本节请出员工本体。先用一组数字刷新直觉:操作系统线程初始栈通常以 MB 计,创建销毁要陷入内核,一台机器敢开的线程以千计;goroutine 初始栈只有 KB 量级、增长收缩自动进行,创建成本微秒级,一台普通机器同时存活数十万个毫无压力。"开多少个"从架构决策变成了普通变量,这正是 Go 并发写法轻盈的物质基础。

派人只需要一个关键字

package main import ( "fmt" "sync" ) func main() { var wg sync.WaitGroup const crowd = 100000 // 十万员工同时在册 wg.Add(crowd) for i := 0; i < crowd; i++ { go func(id int) { // 闭包捕获 id:每个员工拿到自己的编号 defer wg.Done() _ = id // 实际业务里这里是任务处理 }(i) } wg.Wait() // 签到板清零才放行,第 3.5 节展开 fmt.Println("十万员工全部收工,程序正常退出") } // 运行输出: // 十万员工全部收工,程序正常退出

同样的循环开十万个线程,多数操作系统会先一步拒绝你。这段代码还藏着三条军规:go 后面跟的是函数调用;传参靠实参复制(闭包变量在循环里要显式传参,避免所有员工共用一个变量);必须建立等待关系,不能靠 sleep 凑合。

生命周期:只有退出,没有解雇

goroutine 的规则很冷峻:

  • 没有父子关系:主 goroutine 派出员工后互不相干,不存在"等父任务结束子任务自动取消"。
  • 主 goroutine 退出,全员清场:main 一返回,其余 goroutine 无论干到哪儿都被直接收走,不会有机会收尾。
  • 没有从外部杀掉一个 goroutine 的 API:想让它停,只能靠它自己检查退出信号(channel 或 context,3.6 节)。

"从外面杀"的缺失是刻意设计——强杀会把锁、文件句柄、channel 全部悬在半空,不 crash 也算残废。Go 把退出责任交还给员工自己:听到下班铃(收到信号)就自己收拾工位。下面这段是听到铃声最朴素的写法:

package main import "fmt" func worker(stop <-chan struct{}) { // 单向窗口:只能听,不能发 for { select { case <-stop: // 铃响了 fmt.Println("员工:收到,收拾工位走人") return default: // 没铃就干活,这里写业务逻辑 } } } func main() { stop := make(chan struct{}) go worker(stop) // 模拟干了片刻活 fmt.Println("调度台:吹哨收工") close(stop) // 用关窗口广播下班信号,所有监听者同时收到 } // 运行输出: // 调度台:吹哨收工 // 员工:收到,收拾工位走人

关一个 channel 是广播式信号:所有在接收端监听的 goroutine 都会立刻收到零值,一处 close,全员下班。select 与 default 的完整语法在 3.4 节正式讲,这里先记下这个惯用法。

图 3-2:GMP 调度架构

图 3-2:GMP 调度架构

GMP 三层各司其职:G 是员工,P 是"有工位才能干活"的配额(默认等于 CPU 核数),M 是真正干活的机器。本地队列让取活大多无锁,全局队列与工作窃取兜住忙闲不均——这套排班系统解释了为什么 go 语句敢那么便宜。

让出:礼貌的员工不霸占机器

两个 goroutine 逻辑上都该跑,凭什么排队?Go 的调度是协作式加抢占的混合体:函数调用与 channel 操作等节点会主动检查是否该让出;1.14 起又加入了基于信号的异步抢占,长循环不吃系统调用也压不死别人。对写代码的你,结论只有一句:CPU 密集任务放心写长循环,调度器不会让某个员工把整条夜班线锁死;真正要防的是 3.3 节之后的阻塞型事故(死锁与泄露),那不是调度器的锅,是设计者的锅。

完整案例:并发体检一批服务地址

背景:值班巡检要探测一批服务是否可达,逐个探测超时累加,一轮巡检要分钟级。

操作:为每个地址派一个 goroutine 并行探测,WaitGroup 等待全员返回,结果汇总成表。

package main import ( "fmt" "sync" "time" ) func probe(addr string, result *string, wg *sync.WaitGroup) { defer wg.Done() start := time.Now() // 真实场景这里发起网络请求;本例用睡眠模拟不同耗时 switch addr { case "cache": time.Sleep(20 * time.Millisecond) case "db": time.Sleep(50 * time.Millisecond) default: time.Sleep(10 * time.Millisecond) } *result = fmt.Sprintf("%s 可达,耗时 %dms", addr, time.Since(start).Milliseconds()) } func main() { addrs := []string{"gateway", "cache", "db", "queue"} results := make([]string, len(addrs)) // 预分配,按序回填,无共享写冲突 var wg sync.WaitGroup for i, addr := range addrs { wg.Add(1) go probe(addr, &results[i], &wg) // 每人只写自己的格子 } wg.Wait() for _, r := range results { fmt.Println(r) } } // 运行输出: // gateway 可达,耗时 10ms // cache 可达,耗时 20ms // db 可达,耗时 50ms // queue 可达,耗时 10ms // (整轮耗时接近最慢的 50ms,而非四项之和)

结果:四项探测并行完成,总耗时接近最慢项;输出顺序与输入一致,因为每人只写自己的下标格。

解读:这是"无锁并行"的最小样板——预先划分好每人负责的内存区域(results 的一个格子),就没有共享写,也就不需要锁。注意 probe 拿到的是 *string 指针(第 2 章 2.3 的知识直接兑现),且 Add 必须在 go 之前调用,否则可能出现"Wait 时还没 Add 完"的竞态。

变式:如果探测项涨到上千个,全量并行同样不可取——把 goroutine 数压到固定 N 个员工循环领活,就是 4.1 的工作池;再给每次探测加超时上限,就要用 3.4 的 select。三个知识点在真实需求里自然汇合,这正是本章的排布逻辑。

收班要点

  • goroutine 初始栈 KB 级、创建微秒级,"开多少"不再是架构红线,但等待关系必须显式建立。
  • 没有父子关系、不能强杀:退出靠员工自查信号,主 goroutine 退出即全场清空。
  • close 一个 channel 等于广播下班铃,所有接收端同时收到。
  • GMP 用少量线程消化海量 goroutine:P 定并行度,本地队列加工作窃取保吞吐,系统调用阻塞时让出 P。
  • 并行无锁样板:预分配结果区、每人只写自己的格子、WaitGroup 收口。

员工有了,下一个问题是他们之间怎么说话——传话窗口 channel,是本章真正的重头戏,下一节整节只讲它。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U