P 的五种状态是 _Pidle、_Prunning、_Psyscall、_Pgcstop、_Pdead;其中 _Pidle→_Prunning 由 acquirep() 触发,_Prunning→_Psyscall 在阻塞系统调用时解绑 P,_Psyscall→_Pidle 在系统调用返回后归还 P,_Pgcstop 在 STW 期间由 stopTheWorldWithSema() 设置并在 startTheWorldWithSema() 中清除,_Pdead 为终止态。
什么是 P 的五种状态,以及它们在调度循环中如何流转
go 调度器里的 p(processor)不是 cpu 核心,而是一个逻辑执行上下文,它持有可运行的 g 队列、本地内存缓存、syscall 状态等资源。它的状态机只有 5 种:_pidle、_prunning、_psyscall、_pgcstop、_pdead。其中前三种是活跃态,后两种是过渡或终止态。
关键在于:状态转换不是由用户控制,而是由 schedule()、exitsyscallfast()、stopTheWorldWithSema() 等内部函数在特定时机触发的。比如:
-
_Pidle → _Prunning:当一个空闲P被handoffp()或acquirep()分配给刚唤醒的 M 时发生 -
_Prunning → _Psyscall:当G发起阻塞系统调用(如read()),且未启用netpoll优化时,当前P会解绑并进入该状态 -
_Psyscall → _Pidle:系统调用返回后,若原M无法立即重获P(例如被抢占或休眠),则P被放回全局空闲队列
为什么 _Psyscall 不等于 “正在执行系统调用”,而是一种解绑标记
这是最容易误解的一点:_Psyscall 状态本身不表示 P 还在干活,恰恰相反——它表示这个 P 已经和当前 M 解绑,且该 M 正在阻塞于系统调用中。此时 P 的所有权已释放,可能被其他 M 复用。
典型场景是 net.Conn.Read() 在 Linux 上触发 epoll_wait 时,如果使用了 runtime_pollWait() + netpoll,就根本不会进入 _Psyscall;但如果是 os.Open() 后直接 Read() 普通文件,则大概率走传统阻塞路径,触发 P 解绑。
验证方式:在 src/runtime/proc.go 中搜索 casgstatus(gp, _Grunning, _Gsyscall) 和 handoffp() 的调用链,就能看到状态切换的实际位置。
GC 停顿期间的 _Pgcstop 是怎么被设置和清除的
_Pgcstop 是 STW(Stop-The-World)阶段的临时状态,只在 GC 的 mark termination 阶段出现。它不是由某个 G 主动触发,而是由 stopTheWorldWithSema() 统一广播给所有正在运行的 P。
每个 P 在下一次进入调度循环(即执行 scheduler() 前)时,会检查 gcstoptheworld == 2,然后将自身状态设为 _Pgcstop 并暂停执行新 G。注意:_Pgcstop 下的 P 仍持有其本地队列,但不会从中取 G,也不会响应 work stealing。
- 清除时机:GC 完成 mark termination 后,
startTheWorldWithSema()会逐个唤醒P,将其状态从_Pgcstop切回_Pidle或_Prunning - 风险点:如果你在 GC 期间手动调用
runtime.GC()并紧跟着做大量分配,可能观察到短时间大量P卡在_Pgcstop—— 这不是 bug,是预期行为
调试 P 状态时,pprof 和 debug.ReadGCStats 都看不到状态机信息
Go 标准工具链没有暴露 P 状态的实时视图。go tool trace 可以看到 P 的绑定/解绑事件(如 “ProcStatusChange”),但不会显示具体状态值;runtime.ReadMemStats() 和 debug.ReadGCStats() 完全不涉及状态字段。
真正能查到当前所有 P 状态的方式只有两种:
- 在调试器中读内存:
dlv attach <pid>→print runtime.allp→ 遍历每个P的status字段 - 加日志编译:修改
src/runtime/proc.go中pprofLabel或scheduler()开头,用printf输出p.status(需重新 buildgo工具链)
别指望靠 GODEBUG=schedtrace=1000 看到状态码——它只打印计数器和队列长度,P 的状态字段始终被隐藏。


















