go runtime 不提供 api 控制 m 的创建或销毁,所有 m 由调度器全自动管理;新 m 在现有 m 全阻塞且本地 p 有就绪 g、启动预创建、lockosthread 需求、系统调用异常时被动拉起;m“销毁”实为 detach+休眠,需解绑 p、未锁定、无系统调用且空闲超约 2 秒,不可配置或干预。

Go runtime 不提供任何 API 控制 M 的创建或销毁,所有 M 的生命周期都由调度器内部逻辑全自动管理;你看到的线程增减,本质是 runtime 在复用、休眠、detach OS 线程,而非“新建/释放对象”。
runtime 什么时候会创建新 M
新 M 的创建不是按需即时触发,而是当调度压力无法被现有 M 消化时被动拉起:
- 所有当前 M 全部阻塞在系统调用(
read、accept、epoll_wait)或 cgo 调用中,而本地 P 队列仍有就绪G—— 此时必须启用新 M 接管该 P - 程序启动初期,runtime 初始化阶段会预创建少量 M(通常 1~2 个),配合
GOMAXPROCS设置的 P 数量形成初始调度环 - 调用
runtime.LockOSThread()时,若当前 M 已绑定其他线程,runtime 可能新建一个 M 来满足独占需求 - 某些系统调用返回后,原 M 状态异常(如被信号中断、栈损坏),runtime 可能用新 M 接管其 P 和待运行
G
注意:M 创建开销显著(涉及 clone()、栈分配、TLS 初始化),runtime 会优先尝试唤醒 idle M,而非新建。
为什么你的 goroutine 没跑满 CPU 却看到大量线程
常见误判:看到 ps -T -p $PID | wc -l 返回值远超 GOMAXPROCS,就认为是泄漏。实际多数是 M 处于 idle 状态等待复用,或卡在系统调用中未返回:
-
GOMAXPROCS只限制「同时执行 Go 用户代码」的 M 数量,对阻塞在read()、netpoll、数据库连接等待中的 M 完全无约束 - HTTP 长连接、空闲数据库连接池、
time.Sleep中的定时器唤醒逻辑,都会让 M 停留在“可运行但无G”状态,不满足回收条件 - cgo 调用派生的 M(C 函数里调 Go)完全绕过 runtime 调度逻辑,C 侧不主动
pthread_exit或复用线程,就会永久驻留
验证方式:go tool pprof http://localhost:6060/debug/pprof/threadcreate?debug=2 查看线程创建频次;对比 /proc/self/status 中 Threads: 字段与 runtime.MemStats.MNumThread,若前者远大于后者,说明存在未被 runtime 管理的线程(比如 cgo 泄漏)。
M 的“销毁”其实是 detach + 休眠,且阈值不可配
runtime 从不主动 kill 或 pthread_cancel 一个 M,所谓“回收”是指将其置为 idle 并最终调用 pthread_detach() 释放资源。触发条件极其严格:
- M 已解绑 P(
handoffp完成),且 P 进入自旋或移交状态 - 未被
runtime.LockOSThread()锁定 - 未处于任何系统调用中
- 连续空闲时间超过 runtime 内部硬编码阈值(实测约 2 秒,非 10ms;10ms 是更早版本或特定路径下的临时 idle 判断)
关键细节:这个过程完全不可干预。runtime.GC()、debug.FreeOSMemory()、SetMaxThreads() 对 M 生命周期毫无影响;M 与 OS 线程 ID 也不是永久绑定,strace 中看到相同 tid 重复出现是正常复用现象,不代表回收失效。
真正容易被忽略的是:M 的数量失控往往不是因为“没回收”,而是因为系统调用没设超时、cgo 调用没做清理、或误用 LockOSThread 导致线程被钉死。优化重点不在等回收,而在源头控制阻塞和线程派生。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











