goroutine:一行 go,一万个「线程」
前面 16 章 Go 一直在删东西。这一章开始,它把删到最后攒下的地方,用来塞回一样大礼:便宜到离谱的并发。go f()——就一个关键字加一个函数调用——起一个 goroutine,一个能和别人同时跑的执行单元。它轻到你能一口气开一万个、十万个。这一章讲清它怎么用、怎么等它们干完,以及它和你熟的 Kotlin 协程,像在哪、本质差在哪。
起一个 goroutine:就加一个 go
把任意一个函数调用前面加上 go,它就不再是「调用完等它返回」,而是「另起一个 goroutine 去跑它,我自己立刻往下走」:
func main() {
go sayHello() // 起一个 goroutine 跑 sayHello,不等它
go crunch(data) // 再起一个
fmt.Println("main 继续往下") // main 不停,立刻执行这行
}
就这么简单。go 后面可以跟具名函数,也可以跟一个匿名函数当场起:
go func() {
fmt.Println("我在另一个 goroutine 里")
}() // 注意末尾的 () —— 定义并立刻调用
你的 main 函数本身也跑在一个 goroutine 里(主 goroutine)。程序一启动就有它一个,你每 go 一次就多一个。它们并发执行 —— 在多核机器上还可能真的并行(这两个词的区别,《同时》那本书掰得很细,这里你只需知道:goroutine 之间是同时推进的)。
goroutine 到底有多便宜
这是它和「线程」最本质的区别,也是「一行 go 一万个」这句话的底气。操作系统线程很贵 —— 每个默认要 1MB 左右的栈、创建和切换要陷入内核,你开几千个机器就吃不消了(这就是为什么 Java 要用线程池、Executor 精打细算地复用线程)。
goroutine 不是操作系统线程。它是 Go 运行时自己管理的、极轻量的执行单元:初始栈只有 2KB(还能按需自动增长收缩),创建和切换全在用户态由 Go 的调度器完成,不碰内核。于是:
- 开一万个、十万个 goroutine 是完全正常的操作,内存和调度都扛得住。
- Go 运行时把这成千上万个 goroutine,多路复用到少数几个操作系统线程上(通常等于 CPU 核数)跑。
- 你不需要线程池、不需要 Executor、不需要「复用线程」的心智 —— 需要并发就直接
go,用完就扔。
当一个东西便宜到几乎免费,你使用它的方式会彻底变。因为线程贵,你在 JVM 上写并发是「精打细算」的:搞线程池、算核心线程数、复用、怕开太多。因为 goroutine 便宜,你在 Go 里写并发是「挥霍」的:来一个请求就 go 一个 goroutine 处理它,一个下载任务就 go 一个,根本不用池化。
这就是为什么 Go 被称为「为并发而生」—— 不是它有什么独门的并发理论,而是它把并发的单位成本压到了地板,让你敢于用海量的、一次性的并发单元去自然地表达问题。这份「敢挥霍」,就是 Go 删掉那么多东西后,塞回你手里的核心礼物。
一个必知的陷阱:main 一走,全员散场
先跑下面这台真调度器。它不是动画 —— goroutine 谁在跑、谁被阻塞、谁唤醒谁,都是它按 Go 的真实语义算的。切到第二个场景「忘了同步:main 提前退出」:
第二个场景演示了新手第一个坑:
func main() {
go func() {
fmt.Println("worker 开始干活")
fmt.Println("worker 干完了")
}()
fmt.Println("main 结束")
// main 到这里返回了 —— 整个程序退出,那个 worker 可能一个字都没打印
}
当 main goroutine 返回,整个程序立刻退出,不管其它 goroutine 有没有跑完。它们被直接丢弃,没有任何「等一等」。所以上面那个 worker 很可能什么都没打印,程序就结束了。这跟你的直觉冲突 —— 你以为起了就会跑完,但 Go 不等。所以你几乎总需要一个机制,让 main「等」它起的 goroutine 干完。
等它们干完:WaitGroup
最直接的「等一组 goroutine 全干完」的工具是 sync.WaitGroup —— 一个计数器:
func main() {
var wg sync.WaitGroup // 零值可用(第 4 章!不用 New)
for i := 0; i < 3; i++ {
wg.Add(1) // 计数 +1:我要等一个
go func() {
defer wg.Done() // 干完 -1(用 defer 保证一定执行,第 16 章)
work(i)
}()
}
wg.Wait() // 阻塞,直到计数归零 —— 也就是三个都 Done 了
fmt.Println("三个都干完了,现在才往下")
}
三步:Add(1) 登记「要等一个」、每个 goroutine 干完 Done()(配 defer 稳妥)、Wait() 卡住直到全部完成。这是「扇出一批活、等它们全回来」的标准骨架。回到 Demo 第一个场景 —— 那是用 channel 做同步的另一种方式(生产者发、消费者收,天然地互相等待),下一章会专门讲 channel。
并发最怕死锁 —— 所有 goroutine 都在互相等、谁都动不了。Go 有个很贴心的设计:当运行时发现「所有 goroutine 都睡死了、没有一个能推进」,它会直接崩溃并打印一句非常醒目的报错。切 Demo 第三个场景(main 往没人接收的 channel 发送),看它打印:
fatal error: all goroutines are asleep - deadlock!
这句报错你在真 Go 程序里也会一字不差地见到(Demo 里那句就是照 Go 运行时来的)。它不能抓出所有死锁(只能抓「全员睡死」这种),但这个「宁可崩掉也要喊出来」的设计,帮你在开发时就撞见很多同步 bug,而不是让程序默默卡住。
底层它是怎么调度的?一句话,然后交给《同时》
你可能好奇:几万个 goroutine 是怎么塞进几个线程里跑的?一句话版本:Go 运行时有个 GMP 调度器 —— G 是 goroutine、M 是操作系统线程、P 是处理器(调度的许可证)。调度器把海量的 G 分配到 P 上、由 M 实际执行,还会在 goroutine 阻塞(比如等 IO)时把它挪开、让线程去跑别的 G,从而用少数线程喂饱大量 goroutine。它还做工作窃取来均衡负载。
GMP 的完整内部机制 —— work-stealing、P 的本地队列、系统调用时的 handoff、抢占式调度、channel 的底层结构 —— 是一个很深的话题,展开够写好几章。本书的定位是让你会「写对」并发的 Go 代码,不是剖析运行时源码。那些底层细节,站内另一本书《同时》(并发·异步·同步)里有专门的真调度模拟器讲透,那本书正是围绕 Kotlin / Go / Python 三家的并发底层写的。这里你记住「goroutine 由 Go 调度器多路复用到少数线程上」这个心智模型就够写好代码了,想深挖去看《同时》。
Kotlin 对照
表面很像:go f() 和 Kotlin 的 launch { f() } 都是「起一个轻量并发单元、不阻塞当前流程」。都比线程便宜得多,都由运行时/库调度到少数线程上。你从协程过来,goroutine 的基本手感不陌生。
但骨子里有两个大不同。一,函数染色。Kotlin 有 suspend 关键字 —— 一个函数要能挂起,它和它的调用链都得标 suspend,普通函数和挂起函数是两种颜色,不能随便互调。Go 没有这个:任何普通函数前面加 go 就能并发跑,没有 suspend、没有 async/await、没有函数染色。这是 Go 一个很大的简化 —— 并发不「污染」函数签名。
二,结构化并发。Kotlin 的协程内建了结构化并发:coroutineScope / Job 树,父协程会等所有子协程、取消会自动向下传播、子协程的异常会汇报给父。这是 Kotlin 协程一个很强的安全网。Go 的 goroutine 没有内建这个 —— go f() 起出去就是一个「自由」的 goroutine,没有父子关系、main 不会自动等它(所以要 WaitGroup)、取消要靠你手动传 context(第 20 章)。Go 给你更原始、更自由的积木,但也把「组织好它们」的责任交回给你。这份自由和责任,是你从协程迁过来最需要重新校准的地方。
这一章的一句话
goroutine 是 Go 塞回你手里的大礼:一行 go f() 起一个轻到 2KB、能开一万个的并发单元,由 Go 调度器多路复用到少数线程上;它便宜到让你敢挥霍式地用并发。但 main 一返回全员散场(要用 WaitGroup 等)、它没有 suspend 染色也没有内建的结构化并发——比协程更原始、更自由,也把组织它们的责任交回给你。
goroutine 解决了「怎么同时跑」,但它们之间怎么安全地传数据?答案是 Go 并发的另一半灵魂 —— channel。下一章,我们用调度器看它怎么工作。