卷 V · 并发CH 17深度 17/24

goroutine:一行 go,一万个「线程」

本章卸下Thread / Executor 的重量感suspend 的函数染色

前面 16 章 Go 一直在删东西。这一章开始,它把删到最后攒下的地方,用来塞回一样大礼:便宜到离谱的并发go f()——就一个关键字加一个函数调用——起一个 goroutine,一个能和别人同时跑的执行单元。它轻到你能一口气开一万个、十万个。这一章讲清它怎么用、怎么等它们干完,以及它和你熟的 Kotlin 协程,像在哪、本质差在哪。

goroutinego 关键字WaitGroup对照协程调度器

起一个 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 对照

⇄ goroutine vs 协程:像的表面,不同的骨

表面很像: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。下一章,我们用调度器看它怎么工作。