goroutine 轻量高效源于初始栈仅2kb、按需伸缩、纯用户态切换(20–50ns)及协作式调度;其百万级并发可行,而线程因固定2mb栈和内核切换开销无法支撑。

面试问 goroutine 原理,不是让你背调度器源码,而是看你会不会用运行时视角解释行为差异;说清楚“为什么 go func() 就能跑百万个”,比复述 G-P-M 三个字母重要得多。
goroutine 创建开销到底多小?
关键在栈:每个 goroutine 初始栈仅 2KB,且按需动态伸缩(最大可达 1GB);而一个 OS 线程(如 pthread)默认栈是 2MB(Linux),固定不可调。这意味着:
- 启动
100_000个goroutine,内存占用约200MB;同等数量的线程在大多数系统上直接触发ENOMEM或被内核拒绝 -
go func() { ... }的创建成本≈一次内存分配 + 几个寄存器赋值,不陷入内核;pthread_create()必须系统调用,涉及上下文切换和内核资源登记 - 栈增长发生在函数调用深度过大时,由 runtime 自动完成
stack split,对开发者完全透明
goroutine 切换为什么比线程快?
线程切换要进内核、保存 16+ 寄存器、更新页表、TLB flush,耗时 1–5μs;goroutine 切换纯用户态,只换 PC/SP/DX 三个寄存器,典型耗时 20–50ns —— 差两个数量级。
但注意:这个“快”只在调度器能接管的场景下成立。以下情况会触发真正线程级阻塞,失去优势:
- 调用 cgo 且该 C 函数长期阻塞(未用
runtime.LockOSThread()保护时,可能卡住整个 M) - 执行未被 runtime hook 的系统调用(如某些自定义 syscall 封装)
- 死循环中既不调用
runtime.Gosched(),也不触发任何调度点(如无 channel 操作、无 sleep、无 GC 辅助)
channel 阻塞时 goroutine 怎么被挂起?
不是靠轮询,也不是靠信号,而是 chan.send / chan.recv 这些操作内部会主动调用 gopark,把当前 G 状态设为 waiting 并放入对应 hchan 的 sendq 或 recvq 链表。此时调度器立刻选择下一个就绪 G 在当前 M 上运行。
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
重点在于:这个挂起是协作式的,但无需你写 yield —— runtime 把所有常见阻塞点(网络 I/O、定时器、channel、sysmon 检测到长时间运行)都埋好了钩子。只要你不写 for {} 死循环,基本不会饿死其他 goroutine。
验证方式很简单:
go func() {
for i := 0; i
<p>输出一定是交叉的;但如果把 <code>time.Sleep</code> 换成空循环,<code>B</code> 可能根本没机会跑——这就是调度点缺失的真实代价。</p>
<h3>为什么不能简单说“goroutine 是协程”就结束?</h3>
<p>因为面试官想听的是 runtime 层的实质约束。比如:</p>
-
goroutine不是传统意义的协作式协程(不用yield),也不是完全抢占式(没有时间片强制中断),而是“协作式抢占”:大部分让出靠调度点,但 runtime 会通过sysmon线程每 20ms 扫描一次,对运行超 10ms 的G强制插入抢占点 - 你无法控制哪个
G在哪个M上跑,也不能指定优先级——Go 故意屏蔽了这些,避免开发者掉进“手动调度”的陷阱 -
GOMAXPROCS控制的是 P 的数量,不是线程数;M 数量可远超 P(比如大量阻塞 I/O 时自动扩容),但 P 是逻辑处理器,决定并行度上限
真正容易被忽略的点是:goroutine 的轻量,本质来自 runtime 对所有阻塞原语的统一接管。一旦你绕过它(比如用 syscall.Read 直接读文件描述符而不走 net.Conn),那个 goroutine 就会拖住整个 M,变成事实上的线程级阻塞。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!










