Go runtime 不主动回收空闲 M,而是使其 idle 休眠以复用;仅当满足严格条件(无绑定 P、未锁定、长时间空闲且全局线程数超阈值)才 detach 释放资源,这是为避免线程创建销毁开销的设计权衡。
go runtime 不会主动回收空闲的 m(os 线程),除非满足严格条件;默认行为是让它们 idle 休眠,等待复用,而非退出。 这不是 bug,而是设计权衡:避免频繁创建/销毁线程的开销,但代价是线程数可能远超 gomaxprocs —— 尤其在大量系统调用或 cgo 场景下。
为什么空闲 M 很难被回收
runtime 对 M 的“回收”本质是 detach + 休眠,不是 kill。它只在以下情况真正释放线程资源:
- 该 M 处于 idle 状态(无绑定 P、未执行系统调用、未被
runtime.LockOSThread()锁定) - 空闲时间足够长(内部有退避逻辑,非固定秒数)
- 当前全局线程数未低于某个阈值(实际策略未公开,但观察表明 runtime 倾向保留至少 2–4 个 idle M)
常见误解是“M 空闲就会被杀掉”。现实是:strace -f ./yourapp 中能看到大量 clone 后长期 futex 休眠的线程 ID,它们就是 idle M —— 活着,但不干活。
startm 和 wakep 怎么决定要不要拉起新 M
关键不在“有没有空闲 M”,而在“有没有空闲 P 且没人去偷任务”。wakep 的判断逻辑非常保守:
- 先用
atomic.Cas(&sched.nmspinning, 0, 1)尝试抢占一个 spinning 计数 —— 成功才进下一步 - 再调
startm(nil, true):true 表示“允许新建”,但startm内部仍会先查 idle M 队列,有就唤醒,没才新建 - 所以
wakep触发 ≠ 新建线程,大概率是复用已有 idle M
典型场景:P 被系统调用阻塞后解绑,G 回到全局队列,此时若 sched.npidle > 0 且 sched.nmspinning == 0,wakep 就会被调,试图让那个 idle M 绑定空闲 P 去捞活干。
如何观察和限制 M 的数量
运行时线程暴增通常源于系统调用堆积(如 DNS 查询、文件读写阻塞)或 cgo 调用。控制手段有限但有效:
- 用
debug.SetMaxThreads(100)硬性限制最大 M 数(超过会 panic,需提前设) - 通过
pprof.Lookup("threadcreate").WriteTo(os.Stdout, 1)查看 M 创建堆栈,定位高频创建点 -
runtime.MemStats.MNumThread是当前活跃+idle 的总 M 数,比/proc/pid/status的Threads:行更准(后者含 kernel thread) - 注意:
GOMAXPROCS只限制“同时执行 Go 代码”的 M 数,对阻塞中的 M 完全无效
最易被忽略的一点:runtime.LockOSThread() 绑定的 M 永远不会被回收,哪怕它之后什么也不做。这类 M 在 pprof 中表现为“idle but locked”,必须靠业务逻辑显式解绑或进程退出才能清理。











