Summary
上一篇我们讲到,饭店当节日里同时来了一万桌客人,每桌配一个正式厨师,你请不起:每个厨师要一个工位、一个储物柜,换岗还得经过经理登记。于是你换了个办法:只留少数几个正式厨师的灶台,另外招一大批学徒,学徒怎么干活、什么时候换人,由厨房领班当场安排,不去麻烦经理。
这个办法听起来很好,但真正落地时,领班马上要面对一连串具体问题:学徒干到一半要等炖锅,灶台该怎么办?学徒要亲自去远处仓库取货,灶台上的厨师被拴住了怎么办?有的灶台忙死、有的灶台闲着怎么办?有个学徒霸着灶台一直不让怎么办?
这一篇就跟着领班把这些问题一个个解决掉。解决完,你就理解了协程和用户态调度,也理解了 Go 的 goroutine 调度器为什么长成现在这个样子。
学徒干活的方式:随时能停,回来接着干
先看学徒和正式厨师在干活方式上的区别。
正式厨师接到一道菜,规矩是从头做到尾。哪怕中途要等十分钟炖锅,他也站在灶台前等着,不会把这道菜放下去做别的。
学徒不一样。领班给学徒定的规矩是:做到一半需要等的时候,就在小票上写清楚"做到第几步、锅里放了什么",把位置让出来;等的事情好了,再回来照着小票接着做。
就像看书时夹一张书签。你读到第 87 页,有人叫你,你夹上书签去忙;回来翻到书签那一页,接着往下读,前面的内容一点都没丢。
计算机里,能这样"做到一半停下、之后从原地接着做"的执行单位,就叫协程。普通的函数调用一旦开始,就必须一路执行到返回;协程可以在中途挂起,把 CPU 让给别人,之后再从挂起的那一行恢复,局部变量都还在。那张写着进度的小票,就是协程保存下来的执行状态:做到哪一条指令、几个关键寄存器的值,以及它自己的栈。
换人不用找经理:用户态调度为什么快
经理排班和领班排班
进程篇说过,正式厨师换岗要经过经理(内核)。经理管着整个饭馆,每次换岗都要走一套正式流程:被换下的厨师交接、经理登记、查看排班表决定下一个是谁、新厨师上岗。这一套走下来,时间不短。
领班给学徒换人就简单多了:领班就站在厨房里,看一眼学徒手上的小票,说一句"你先下来,你上",就换好了。经理那边根本不知道发生过这次换人,在经理的记录里,这个灶台一直是那个正式厨师在用。
经理看到的 厨房里实际发生的
┌─────────────────────────┐ ┌─────────────────────────────┐
│ │ │ 学徒 A 炒了 10 秒 │
│ 3 号灶台:厨师老王 │ │ → 换学徒 B 切了 5 秒 │
│ 一直在干活 │ ←→ │ → 换学徒 C 调了 20 秒酱汁 │
│ │ │ → 再换回学徒 A…… │
│ │ │ 经理全程不知道 │
└─────────────────────────┘ └─────────────────────────────┘
计算机里,经理就是操作系统内核,正式厨师是线程,学徒是协程,领班是程序自带的调度器。 线程之间的切换由内核负责,要从程序的普通运行模式(用户态)进入内核的特权模式(内核态),需要保存一整套寄存器,更新内核的调度记录,再切回来;协程之间的切换由程序自己的调度器在用户态完成,只需要保存几个寄存器、换一下栈指针、跳到另一个位置,内核完全不知情。这就是用户态调度。
两者的开销差了一个数量级左右:
线程切换(经理排班): 约 1~2 微秒,要进内核
协程切换(领班排班): 约几十到两百纳秒,不进内核
每秒切换几十万次的服务里,这个差距就是能不能扛住的差别。
学徒只要一个小凳子
领班排班还有第二个好处:学徒的家当由领班自己分配。
经理给正式厨师分工位是一刀切的,每人一个标准工位、一个大储物柜,不管你实际用多少。领班给学徒的是一个小凳子,东西放不下了再给换个大点的,用完还能收回来。
线程的栈由内核分配,Linux 默认预留 8MB;协程的栈由程序自己管理,可以很小。Go 的 goroutine 栈初始只有 2KB 左右,不够用时自动扩容(把整个栈复制到一块更大的内存里),用不了那么多时还会缩回去。
一万个线程: 10000 × 8MB = 80GB 的预留空间
一万个 goroutine: 10000 × 2KB = 20MB 左右
这就是一个程序能开几十万个 goroutine 的直接原因。
学徒和厨师怎么搭配:三种排班模型
学徒再多,真正能炒菜的还是灶台和正式厨师。学徒怎么跟厨师搭配,饭馆业历史上试过三种办法。
办法一:所有学徒只跟着一个厨师(N:1)。 整个厨房只有一个正式厨师和一个灶台,几百个学徒轮流用。排班最简单,换人最快。但有两个大问题:其他灶台空着也用不上,有再多灶台都白搭;更要命的是,只要有一个学徒说"我得亲自去仓库取货",这个唯一的厨师就得陪着去,整个厨房停摆,所有学徒干等着。因为在经理眼里只有这一个厨师,经理看到他去仓库了,就认为这个灶台在等,不会安排别人。
办法二:每个学徒配一个正式厨师(1:1)。 每人一套,谁去仓库都不影响别人,所有灶台也都能用上。但这其实就是回到了"全请正式厨师",上一篇说的工位贵、换岗慢的问题又回来了。
办法三:几个灶台、几个厨师,带一大批学徒(M:N)。 领班把学徒动态分配给几个灶台,哪个灶台空了就派学徒过去;某个厨师被拖住了,领班把他灶台上的学徒挪到别的灶台。能用上所有灶台,学徒依然轻便,一个人卡住也不至于全体停工。代价是领班的活很复杂:学徒怎么分、灶台空了怎么办、有人卡住了怎么救、怎么保证公平,全都得领班操心。
计算机里这三种模型的名字就是括号里的写法,前一个数字是协程数,后一个是线程数:
| 模型 | 计算机里的例子 | 能用多核 | 一个阻塞会不会拖累全部 | 能开多少 |
|---|---|---|---|---|
| N:1 | 早期的 Python gevent、Ruby 绿色线程、JavaScript 事件循环 | 不能 | 会 | 很多 |
| 1:1 | Java 传统线程、C++ std::thread | 能 | 不会 | 几千 |
| M:N | Go 的 goroutine、Java 21 的虚拟线程 | 能 | 不会 | 几十万 |
Go 选的是第三种。下面几节就是 Go 的领班怎么解决这个模型里最难的几个问题。
Go 的厨房:G、M、P
Go 的厨房里有三种角色:
学徒(G) G = goroutine,要干的活
│ 被分配到
▼
灶台(P),每个灶台旁边有一个小票架 P = processor,干活的位置和资格
│ 需要一个正式厨师站在这里才能开火
▼
正式厨师(M) M = machine,也就是操作系统线程
这里最容易混的是 P 和 M。M 是真正动手的人,也就是操作系统线程,只有它能让 CPU 执行指令;P 是一个灶台,代表"可以在这里干活的资格",厨师必须站到一个灶台前才能开火。灶台的数量是固定的,就是 GOMAXPROCS,默认等于能用的 CPU 核数;厨师的人数却可以比灶台多,下面会看到为什么。
每个灶台旁边有一个小票架,挂着排队等这个灶台的学徒(本地队列,最多 256 张);厨房里还有一块公共订单板,挂着暂时没分到灶台的学徒(全局队列)。厨师站在灶台前,就从小票架上取下一个学徒,让他上灶干活。
用这三个角色,就能看懂领班的四个绝招。
学徒要等炖锅:起身让位,呼叫器一响再回来
一个学徒在做红烧肉,下锅要炖 40 分钟。他不会站在灶台前等,而是把进度写在小票上,把小票交给一个专门盯炖锅的呼叫器,然后让开。厨师马上从小票架上换下一个学徒上灶。
40 分钟后呼叫器响了,学徒的小票被重新挂回某个灶台的小票架上,轮到他时接着做。整个过程里,厨师一直在干活,没有一秒钟在陪着炖锅。
这正是 Go 处理网络读写的方式。你写的代码看起来是一个普通的"等数据":
读取这个网络连接上的数据 ← 看起来会一直卡在这里,直到数据到达
但 Go 运行时在底下做的是:
① 把这个连接设成"非阻塞"模式:没数据时立刻告诉我"没有",而不是让我傻等
② 去读,没数据,内核马上返回"暂时没有"
③ 把这个连接登记到 epoll 上(epoll 就是那个呼叫器,能同时盯住成千上万个连接)
④ 把当前 goroutine 挂起,让出灶台
⑤ 厨师(线程)转头去执行别的 goroutine,线程本身一刻也没闲着
⑥ 数据到了,epoll 通知运行时(Go 里这部分叫 netpoller)
⑦ 运行时把那个 goroutine 挂回队列,轮到它时从"读取数据"那一行接着往下走
结果就是:你按"一个连接一段顺序代码"的直觉写程序,底下跑的却是"少数线程盯着大量连接"的事件驱动模型。 上一篇说 C10K 问题有两条出路,事件驱动性能好但代码难写,协程好写;Go 把两条路合成了一条。
学徒要亲自去仓库:把灶台交给别的厨师
有些事情没法交给呼叫器。比如学徒要去远处仓库亲手翻找一样东西,而这个仓库的规矩是取货的人必须是正式厨师本人。于是厨师只能陪着去,这段时间他被拴在仓库里。
如果领班什么都不做,这个灶台就空着,小票架上排队的学徒全都干等。所以领班的做法是:厨师一出门,就把这个灶台连同小票架交给另一个厨师,空闲的厨师来接手,没有空闲的就临时再请一个。仓库里那位回来以后,看看有没有空灶台:有就接着干,没有就把手上这个学徒的小票挂到公共订单板上,自己去休息室待命。
这叫 handoff(移交)。计算机里,“仓库"是那些没法变成非阻塞的系统调用,比如读本地文件、部分 cgo 调用,线程会被内核真正挂起。Go 运行时有一个后台巡查员叫 sysmon,发现某个线程卡在系统调用里超过一小会儿(微秒级),就把它的 P 拿走交给别的线程。
这也解释了上一篇提到的现象:一个 Go 程序的线程数可以远大于 GOMAXPROCS。 灶台数是固定的,但被困在仓库里的厨师越多,就得请越多的厨师来顶班。线程数默认上限是 10000,大量 goroutine 同时卡在阻塞的系统调用上,就会撞到这个上限,程序报错退出。
有的灶台忙死、有的闲着:去别人的小票架上拿一半
1 号灶台的小票架上挂了 100 张小票,2 号灶台的已经空了。2 号灶台的厨师不会干站着,他的找活顺序大致是:先看自己的小票架,再看公共订单板,再问呼叫器那边有没有刚好好了的,最后随机找一个别的灶台,从它的小票架上拿走一半小票。
1 号灶台 [■ ■ ■ ■ ■ ■] 2 号灶台 [ ] ← 空了
│ 去拿一半
1 号灶台 [■ ■ ■] 2 号灶台 [■ ■ ■]
拿一半而不是拿一张,是因为去别人那里拿小票要协调一下,有成本;一次只拿一张,很快又得再去,来回折腾。一次拿一半,两边都能忙一阵子。这叫工作窃取(work-stealing),是一种很常见的"批量化减少协调次数"的思路。
领班还有一条规矩:每处理 61 张本地小票,就去公共订单板上看一眼,免得挂在公共板上的学徒一直没人理。
有个学徒霸着灶台不让:到点拍肩膀
前面说的"学徒做到一半主动让位”,依赖的是学徒自觉:他遇到要等的事,才会让开。如果某个学徒接了一个活,要连续不停地切三个小时的土豆丝,中间不需要等任何东西,他就会一直占着灶台,同一个小票架上的其他学徒全部被晾着。
这就是协作式调度的软肋。所谓协作式,就是切换要靠被调度者主动让出;与之相对的是抢占式调度,由管理者在任何时刻强行把人换下来。操作系统对线程用的是抢占式:时间片一到,经理就把厨师换下,厨师自己不知道什么时候会被换。
早期的 Go 主要是协作式的,一个纯计算的死循环真能把其他 goroutine 饿住,当时只能靠程序员在循环里手动加一句"主动让出"。Go 1.14 引入了基于信号的异步抢占:sysmon 发现某个 goroutine 连续占着灶台超过 10 毫秒,就给它所在的线程发一个信号,相当于拍拍学徒的肩膀,让他在下一个安全的地方停下,把小票挂回去,让别人上。
所以 Go 是混合式的:大部分切换发生在 goroutine 主动等待的时候,开销很小;万一有人赖着不走,还有抢占兜底。
把饭馆翻译回计算机
协程挂起时保存了什么。 一个 goroutine 被挂起时,运行时把它的程序计数器(下一条指令的地址)、栈指针和少数几个寄存器存进它自己的结构体 g 里。恢复时把这些值装回 CPU,跳到保存的地址继续执行。因为每个 goroutine 有自己独立的栈,所以它可以在任意深的函数调用里被挂起,回来时调用链完整无缺。这种协程叫有栈协程。
另一种协程:无栈协程。 Python、JavaScript、Rust 的 async/await 走的是另一条路:编译器把一个 async 函数改写成一个状态机,只在写了 await 的地方才能挂起,不需要为每个协程单独准备一块栈,更省内存。代价是函数着色问题:async 函数只能在 async 函数里被 await,于是一个底层函数改成了 async,调用它的所有函数都得跟着改,整个代码库被劈成"同步"和"异步"两半,很多库也要出两个版本。Go 的 goroutine 没有这个问题:任何普通函数前面加个 go 就能并发跑,不需要改动调用链。
几种语言的选择:
| 语言 | 方案 | 说明 |
|---|---|---|
| Go | goroutine,M:N,有栈 | 运行时内置,写法和普通函数一样 |
| Java 21+ | 虚拟线程,M:N,有栈 | 思路和 goroutine 非常接近 |
| JavaScript | async/await + 单线程事件循环 | N:1,用不了多核 |
| Python | async/await(asyncio) | 无栈,有函数着色问题 |
| Rust | async/await + tokio 等运行时 | 无栈,零开销抽象,同样有着色问题 |
观察 Go 调度器。 设置环境变量 GODEBUG=schedtrace=1000 运行任何 Go 程序,运行时每秒打印一行调度器状态:
SCHED 1000ms: gomaxprocs=8 idleprocs=6 threads=12 spinningthreads=1 runqueue=0 [0 0 2 0 0 0 0 0]
灶台数 空闲灶台 厨师数 正在找活的厨师 公共订单板 各灶台的小票架
idleprocs 一直很高,说明灶台大多闲着,活不够或者都在等;threads 远大于 gomaxprocs,说明很多厨师被困在仓库(阻塞系统调用)里;runqueue 和小票架一直很长,说明活比灶台多,CPU 是瓶颈。
Goroutine 使用小建议
判断该开多少 goroutine,先看它会卡在哪。 卡在网络上(调接口、查 RPC 节点、读写连接)的,走的是上面说的的呼叫器,不占线程,开几千个都没问题;卡在本地文件读写或 cgo 调用上的,走的是仓库,每个都可能占住一个线程,要用固定大小的 worker 池限流;纯计算的(签名验证、哈希、序列化),开到 GOMAXPROCS 个就够了,再多只是在灶台前排队。
cgo 调用要格外小心。 用到 C 写的密码学库、图片处理库时,每次 cgo 调用都会占住一个线程直到返回。大量并发调用会让线程数暴涨,甚至撞上 10000 的上限。这类调用要用有界的 worker 池包一层。
“同步写法、异步性能"是用 Go 写网络服务最实际的理由。 并发请求几十个外部接口,代码就是普通的顺序调用加一个 go;换成 Node.js 要写 Promise 链,换成 Python 要让整条调用链都变成 async。
runtime.Gosched() 和 runtime.LockOSThread()。 前者是主动让出,对应"学徒自觉让位”,有了异步抢占之后几乎用不上了。后者把一个 goroutine 钉死在当前线程上,只有 GUI、OpenGL、依赖线程局部存储的 C 库等少数场景需要,钉多了会让线程数暴涨,失去 M:N 的好处。
在 Go 1.26 下,GOMAXPROCS(灶台数)默认按容器的 CPU 限额设置,前提是 go.mod 的 go 版本也够新。
总结
一万桌客人,正式厨师请不起
│ 招学徒,领班自己排班 = 协程 + 用户态调度
│ 学徒做到一半能停下,回来接着做 = 协程可挂起、可恢复
│ 换人不经过经理 = 切换不进内核,快一个数量级
│ 学徒只坐小凳子 = 栈只有几 KB,按需增长
▼
学徒和厨师怎么搭配
│ N:1 一个厨师带所有人:用不上多余灶台,一人去仓库全体停工
│ 1:1 一人配一个厨师:又回到请不起
│ M:N 几个灶台带一大批学徒:Go 的选择,但领班的活很难
▼
Go 的领班的四个绝招(G 学徒、M 厨师、P 灶台)
├─ 等炖锅:让位,呼叫器响了再回来 = netpoller + epoll
├─ 去仓库:厨师被拴住,灶台交给别人 = handoff,线程数因此可多于 GOMAXPROCS
├─ 灶台闲着:去别人那里拿走一半小票 = work-stealing
└─ 有人霸着灶台:超过 10ms 拍肩膀 = 异步抢占(Go 1.14+)
最该记住的几句话:
- 协程是能在中途停下、之后从原地继续的执行单位,停下时把进度存在自己的栈和几个寄存器里。
- 用户态调度的快,在于切换不进内核;协程的轻,在于栈由程序自己按需分配。
- M:N 模型兼顾了多核、轻量和阻塞隔离,代价是调度器复杂。
- Go 里 P 的数量就是
GOMAXPROCS,M 是线程,G 是 goroutine。 线程数可以多于 P,因为阻塞在系统调用里的线程要有人顶班。 - Go 是混合式调度:平时靠 goroutine 在等待时主动让出,赖着不走的由运行时抢占。
动手练习
- 看 Go 程序的调度器状态。 找任意一个 Go 写的命令行程序或服务(自己写的、
go build出来的都行),用GODEBUG=schedtrace=1000 ./程序名运行,读懂每一列。让它处理一些请求或任务,观察idleprocs、threads、runqueue怎么变化。 - 对比线程数和灶台数。 运行一个 Go 服务,Linux 上用
grep Threads /proc/<进程号>/status,macOS 上用ps -M <进程号> | wc -l看线程数,再和schedtrace里的gomaxprocs对比。线程数多出来的部分,就是 4.2 节说的顶班厨师和运行时自己的后台线程。 - 看浏览器里的单线程事件循环。 在浏览器开发者工具的 Console 里执行
while(true){},整个页面会卡死,点什么都没反应。这就是 N:1 模型里"一个人不让位,全体干等"的样子。关掉标签页即可恢复。