ELI5 · 说人话 · GO 面试
一个 go 字,就多一条自己往下跑的活儿。
它不是操作系统给的线程,是 Go 自己造、自己派活的。
先分清
自己一块地,跟隔壁工厂互不相干。
共用这块地。开几条、谁先开工,全听操作系统排班。
产线上排队的活儿。Go 自己排班,哪条产线空了就往哪条上放。
所以 goroutine 不是线程的替代品,是盖在线程上面的一层。真正占着 CPU 干活的还是线程,goroutine 只是被 Go 挪来挪去地往线程上放。
最直观的差别
线程的栈是操作系统一次划好的一整块,你只用 3KB,剩下的也还给不了别人。goroutine 的栈是 Go 自己在堆上开的,2KB 不够就整块搬到更大的地方去,用完还能缩回来。
一条一条对
「切一次约 100 纳秒」是这堆数字里最要命的那个:线程切换要陷进内核、保存整套上下文;goroutine 切换就是在同一条线程里改几个寄存器,连门都没出。
Go 怎么办到的
几十万个 goroutine,最后都是被塞进几条线程里轮流跑的。管这件事的,就是 GMP。
一份待办的活儿。写一个 go f() 就多一个 G。
一条真的操作系统线程。只有它能真正占着 CPU 干活。
一张「准你干活」的号牌,自带一个本地待办队列。号牌几张,就最多几条 M 同时在跑 Go 代码。
号牌数量就是 GOMAXPROCS,默认等于 CPU 核数。自己队列干完的 P 不会闲着——先去全局队列拿,还没有就去别的 P 那儿偷一半。
拆开看
栈是长出来的
2KB 起。不够了就整块搬到更大的地方,用完还能缩回去。不像线程一上来就划走 8MB。
切换不出门
换一个 goroutine,就是在同一条线程里改几个寄存器,不用陷进内核——虚线那一层根本没跨过去。
多对少地挤
N 个 goroutine 挤在少数几条线程上跑。goroutine 涨到十万,线程数基本不动。
还会被叫停
Go 1.14 起有异步抢占:一个 goroutine 连着跑满 10 毫秒,运行时发个信号把它踢下来,没人能霸着不走。
走一遍
你的某个 goroutine 去读了一个文件,磁盘慢,卡住了 30 毫秒。
卡住这个 G 发起系统调用,它所在的那条线程 M 也跟着被内核扣住了。
摘牌运行时把号牌 P 从这条 M 上摘走——线程可以卡,号牌不能跟着陪葬。
换人P 挂到另一条 M 上:有睡着的就叫醒,没有就新建一条。
继续本地队列里排着的其它 G 照跑不误,一个都没被这 30 毫秒拖住。
回来文件读完,那个 G 重新排队等号牌,接着从断的地方往下跑。
如果卡住的原因是 channel 或者锁,那连换线程都省了:G 直接被挂起,M 立刻转头去跑队列里的下一个,一次内核调用都没发生。
网络 I/O 也一样——Go 偷偷把它换成了非阻塞 + 轮询器,所以你写着「阻塞式」的代码,底下其实没有线程真的在阻塞。
别忘了
往一个没人接的 channel 里写,这个 goroutine 就永远挂在那儿——它的栈和它引用的对象一个都回收不掉。所以:开之前先想好它怎么退出,context、close、超时,三选一。查泄漏靠 pprof 看 goroutine 数,-race 是查不出来的。
main 函数返回的那一刻,所有 goroutine 当场被砍:不等、不通知、defer 也不跑。所以:用 WaitGroup 或 errgroup 收口。
一百万个 × 2KB 起步就是 2GB,栈还会往上长;GC 每一轮都得把所有栈扫一遍。所以:该限流就限流——worker pool,或者拿带缓冲的 channel 当信号量。
调度归 Go 管,数据竞争还是你的事。两个 goroutine 同时写一个 map,照样直接崩。所以:该加锁加锁,提交前跑一遍 go test -race。
面试就这么答
goroutine 是 Go 运行时自己调度的用户态轻量线程。运行时用 GMP 模型,把 N 个 goroutine 复用到少量 OS 线程上,号牌 P 的数量就是 GOMAXPROCS。
跟线程比,差别集中在三点:栈从 2KB 起、还能伸缩,线程是固定的 1~8MB;创建和切换全在用户态,不用陷内核,切一次约 100 纳秒,线程要 1 微秒;遇到系统调用阻塞,运行时会把 P 从卡住的 M 上摘走换条线程接着跑,channel 阻塞更是连线程都不用换。
所以开几十万个 goroutine 很正常,开几十万条线程不可能。代价是泄漏更隐蔽——每一个 goroutine 都得有明确的退出路径。
线程贵,因为归内核管。
goroutine 便宜,因为归 Go 自己管。