go协程在syscall中不被抢占,而是解绑复用p;os.readfile等标准库i/o接入netpoller可安全retake,而syscall.read等裸调用绕过运行时导致p长期卡死。

syscall 中的 Go 协程不会被“抢占”,而是被解绑复用——这是最关键的判断。Go 调度器从不尝试中断内核态执行,比如 read、epoll_wait 或 accept 正在等待时,sysmon 无法发信号、也无法强制切走 G。真正发生的是:G 主动释放绑定的 P,让 P 进入 _Psyscall 状态,随后被 sysmon 拿去复用。
为什么 syscall.Read 会卡住 P,而 os.ReadFile 不会
根本差异在于是否接入 netpoller:
-
os.ReadFile、net.Conn.Read、http.Server等标准库 I/O 函数,底层调用都经过运行时封装,自动注册进netpoller,系统调用返回前会通知调度器,P 可在_Prunning↔_Psyscall↔_Pidle间安全切换 -
syscall.Read、unix.Read是裸系统调用,绕过所有 Go 运行时逻辑,M 线程真阻塞,P 长期卡在_Psyscall状态,sysmon无法回收(除非超时且满足 retake 条件) -
CGO中的fread默认也不进netpoller;若未配runtime.LockOSThread(),M 可能被回收,但唤醒后需重新抢 P,延迟不可控
验证方式:启动时加 GODEBUG=schedtrace=1000,观察输出中某 P 是否持续显示 Psyscall —— 若连续多行都是同一 P,说明没被成功 retake。
retake 触发条件与常见失败原因
sysmon 每约 20ms 扫描一次所有 P,对状态为 _Psyscall 的 P,只要其 syscalltick 自上次更新已超 20μs(即 1 个 sysmon tick),就尝试 retake。但以下情况会导致跳过或失败:
- 调用了
runtime.LockOSThread()→ M 和 P 强绑定,retake逻辑直接跳过该 P,避免破坏线程亲和性 - P 当前无其他待运行 G(
runqempty(p)为真),且没有空闲或自旋中的 M(sched.nmspinning + sched.npidle == 0)→retake被抑制,防止过度唤醒 - 裸
syscall时间极短(如微秒级),sysmon来不及扫描到状态变化 - 系统调用本身未返回,而
retake只作用于 P 状态,不是 G 本身;G 仍卡在内核,只是 P 已脱手
注意:retake 成功 ≠ G 被调度走,它只解决 P 复用问题;G 返回用户态后是否让出 CPU,取决于 exitsyscall 是否触发重调度。
exitsyscall 才是真正的调度点
当系统调用返回用户态,控制流不会直接回到原 G 的下一条指令,而是先进入 runtime.exitsyscall。这个函数才是决定是否让出 P 的关键环节:
- 如果此时本地队列或全局队列有其他 G 等待,且当前 P 未被标记为“必须还给原 M”,
exitsyscall会调用gopreempt_m,把刚返回的 G 放回队列,切换执行别的 G - 这个切换是同步、确定的,不依赖信号,也不受内联/汇编/Cgo 影响
- 哪怕你刚从一个 5s 的
select返回,也大概率立刻让出 P
容易被忽略的是:retake 和 exitsyscall 是两套机制:前者管 P 的复用,后者管 G 的重调度。写网络服务时,别指望靠 sysmon 把卡在 accept 里的 G “拉出来”——它本来就不该在那里久留,而应靠 net.Conn.SetDeadline 或 context.WithTimeout 主动控制阻塞边界。










