能,但只是并发而非并行;GOMAXPROCS=1时仅有一个P,所有G共享该P的本地队列或全局队列,I/O操作可触发M解绑再接管实现调度切换,而纯CPU密集型任务则彻底串行。

Go 的 GMP 模型不是“协程调度教程”能讲清楚的抽象概念,而是一套运行时强制执行的调度契约——go 关键字触发的每个 G,都必须经由 P 入队、被 M 执行,且三者状态变更全部由 runtime 控制,用户代码无法绕过或模拟。
为什么不能直接用 runtime.Gosched() 控制 G 调度顺序
runtime.Gosched() 只是让当前 G 主动让出 M,不保证下一个被调度的是你期望的那个 G。调度器按本地队列(runq)FIFO + runnext 优先级 + 工作窃取逻辑选取目标,你无法指定“接下来执行 G2”。真实场景中,它只在防止长循环饿死其他 G 时有用,比如:
- 纯计算密集型循环里每千次迭代调一次,避免阻塞整个
P - 非阻塞式轮询中主动让出,给网络就绪
G让路 - 但它不会改变
G入队位置,也不会影响go func()创建时默认进本地队列的行为
GOMAXPROCS 设置为 1 时,G 还能并发执行吗
能,但只是并发(concurrent),不是并行(parallel)。GOMAXPROCS=1 表示最多只有 1 个 P,也就只能有 1 个 M 绑定它持续工作。此时:
- 所有
G都挤在同一个P的本地队列(容量 256)或全局队列里 - 没有工作窃取(
stealWork),因为只有一个P - 系统调用(如
read/write)会触发M解绑P,但P会立刻被另一个空闲M接管(如果存在),而GOMAXPROCS=1时 runtime 通常只维持 1 个活跃M,所以多数 I/O 阻塞仍会暂停调度 - 结论:I/O 密集型任务仍可“看起来并发”,CPU 密集型任务则彻底串行
goroutine 阻塞在 time.Sleep 时,M 真的被释放了吗
没有。time.Sleep 是 Go 运行时内部实现的“假阻塞”:它不进入系统调用,而是把 G 状态设为 _Gwaiting,扔进定时器堆(timer),然后直接调用 schedule() 去找下一个 G 执行。此时 M 仍在运行,P 也没被释放。这正是 Go 能轻松跑百万 G 的关键——绝大多数“阻塞”根本不动操作系统线程。
立即学习“go语言免费学习笔记(深入)”;
真正会释放 M 和 P 的,只有这几类操作:
- 阻塞式系统调用(
open,accept, 同步read等)→ 触发entersyscall - CGO 调用且设置了
runtime.LockOSThread - 调用
runtime.Goexit()或 panic 后未恢复
如何观察实际 GMP 调度行为,而不是靠猜
别依赖 pprof 或日志打点,直接用 Go 自带的调度追踪:
- 启动程序时加环境变量:
GODEBUG=schedtrace=1000,scheddetail=1,每秒输出调度器快照,能看到M数量、P状态、G分布 - 配合
go tool trace生成交互式轨迹:go run -gcflags="-l" -trace=trace.out main.go && go tool trace trace.out,里面能精确看到每个G的创建、入队、执行、阻塞、唤醒时间点和所属P - 注意:
schedtrace输出里的idleP数不等于 CPU 空闲率,它只表示该P当前无G可执行,但可能正卡在系统调用里
最易被忽略的一点:P 的数量固定后,其本地队列满溢时迁移 G 到全局队列的逻辑(globrunqput)是加锁的,高频创建 goroutine 的场景下,这里会成为隐形瓶颈——不是 CPU 不够,而是锁争用拖慢了 go 语句本身。


















