本节摘要:goroutine 是 Go 运行时调度的轻量执行流,初始栈仅数 KB、可随需增长,启动成本远低于操作系统线程。本节讲 go 关键字的用法与生命周期、主 goroutine 退出即全程序退出的规则、GMP 调度模型的直观图景、goroutine 与同步的初阶配合,以及泄漏的成因与检测思路。
阅读完本节,你应当能够:
先掰清两个词:并发是"同时管理多件事"的结构能力,并行是"同时真的在算"的硬件事实。一人炒三个菜是并发(轮流照看),三口锅三个厨师是并行。单核机器照样跑并发程序,靠的是快速切换制造"同时在办"的错觉。Go 的并发原语让你先写对结构,并行度交给运行时按核数自动铺开。
func say(s string) { for i := 0; i < 3; i++ { fmt.Println(s, i) } } func main() { go say("world") // 立即返回,say在新的goroutine里跑 say("hello") // 主goroutine继续自己的活 }
go 后面跟函数调用,被调函数在新的执行流里运行,go 语句本身立刻返回。两条硬规则:
参数在 go 语句处求值并拷贝。go say(msg) 里 msg 的当前值被带走,之后改 msg 不影响已启动的执行流。
主 goroutine 退出,程序就退出——不等其他 goroutine。上面例子如果 say hello 很快跑完,world 可能一行都没打印。这不是 bug 是设计:需要等待就必须显式同步(channel、WaitGroup,本章后续)。
⚠️ 常见坑(来自经典教学例子的演化):循环里
go func() { fmt.Println(i) }()直接捕获循环变量 i,旧版本里所有 goroutine 共享同一个 i,输出可能全是最后一个值。Go 1.22 起每轮迭代是新变量,但显式传参go func(n int) {...}(i)永远是跨版本安全的写法。
Go 运行时的调度器把三样东西分开管理:
几个直观结论:G 多 M 少,成千上万个 G 在少量线程上复用;某 G 陷入阻塞系统调用时,绑定的 M 一起堵,运行时会解绑 P 交给别的(或新的)M 继续跑队列里的 G,线程不会陪着闲死;某个 P 的队列空了会去别的 P 偷活干(工作窃取),负载自动均衡。GOMAXPROCS 环境变量控制 P 的数量,默认等于 CPU 核数。
| 维度 | 操作系统线程 | goroutine |
|---|---|---|
| 初始栈 | 固定较大(MB级) | 数 KB,按需增长收缩 |
| 创建切换 | 陷入内核,微秒级 | 用户态切换,纳秒级 |
| 数量级 | 数千已吃力 | 十万级常见 |
| 调度方 | 操作系统抢占 | 运行时协作加抢占结合 |
栈小是因为初始只给一点点,函数调用需要更多时运行时自动扩(拷贝式增长,第 3 章的逃逸分析与这里呼应)。切换快是因为不进内核。这两条合起来,让"每个连接一个 goroutine"的服务器模型在 Go 里是常态——写起来像同步阻塞,跑起来是高并发事件驱动。
goroutine 结束只有三条路:函数跑完 return;panic 且未被 recover(整个程序崩);被运行时干掉(极少)。没有任何"从外面杀掉某个 goroutine"的 API。这是有意设计:粗暴外部终止会让资源清理与不变量维护无从谈起。想停,就得给协作式的退出信号——channel 关闭或 Context 取消(本章 5.3 与 5.5)。
泄漏的标准剧本:goroutine 在等一个永远没人发的 channel 消息,或等一个永远不会完成的 IO,调用方却早已返回。它不报错、不退出,只是白占几 KB 栈与一个调度位,累积起来内存与调度压力缓慢上涨。检测手段:运行时与指标库暴露的 goroutine 数量监控(突增曲线)、压测后抓 goroutine 堆栈转储(一个测试信号即可触发全量打印)。
💡 关键直觉:把 goroutine 当"合同工"管理——开工前就想好他的完工条件(谁通知他收工),没有退出条款的合同工就是泄漏。
下一节讲 goroutine 之间怎么说话:channel。