如何编写“死循环”代码验证 sysmon 会在 10ms 内完成抢占
解读
国内一线/二线互联网公司(字节、阿里、腾讯、美团、快手、B 站、小红书等)在 Go 后端面试里,高频追问“GMP 调度模型”与“sysmon 抢占机制”。
这道题表面让写“死循环”,实则考察三点:
- 能否写出纯用户态死循环(不调用任何函数、不触发 GC、不做系统调用),让 P 被独占;
- 能否用可观测手段(
GODEBUG=schedtrace=1,scheddetail=1或go tool trace)证明该循环超过 10 ms仍未让出 P; - 能否在同一段程序里再打印一次 trace,证明 sysmon 发现后在 10 ms 左右把该 G 抢占并调度走。
如果候选人只写for {},却给不出观测数据,会被直接判“原理不清”;
如果能给出完整可复现的代码 + 数据解读,可拿到“深入理解调度器”加分。
知识点
- sysmon 线程:全局唯一,周期约 50 µs 一次,负责网络轮询、GC、抢占长时间运行的 G。
- 抢占触发条件:
- G 连续运行超过 10 ms 且未主动让出 P(无函数调用、无系统调用、无 channel 阻塞)。
- sysmon 把 G 的
g.preempt = true,并在当前栈上插入异步抢占信号(SIGURG或栈扩张检查)。
- 观测工具:
GODEBUG=schedtrace=1000,scheddetail=1每 1 s 打印调度摘要,可看到M=1被长期占用;go tool trace可精确到 µs 级别,能看到STW与preempt事件。
- 反优化技巧:
- 空循环
for {}会被编译器优化掉,需加入内存屏障或汇编空指令防止优化; - 使用
runtime.GOMAXPROCS(1)保证只有一个 P,放大抢占效果。
- 空循环
答案
package main
import (
"fmt"
"runtime"
"time"
)
//go:noinline
func deadLoop() {
// 纯用户态死循环,不调用任何函数,不触发 GC
for {
// 防止编译器把空循环优化掉
runtime.Gosched() // 注意:这里故意先注释掉,第一轮跑 20 ms 不让出
}
}
func main() {
runtime.GOMAXPROCS(1) // 只留一个 P,效果更明显
done := make(chan bool, 1)
go func() {
time.Sleep(100 * time.Millisecond) // 给 sysmon 足够时间抢占
done <- true
}()
start := time.Now()
go deadLoop() // 启动死循环 G
<-done
fmt.Printf("main goroutine waited %v, sysmon 已抢占\n", time.Since(start))
}
运行与观测步骤(Linux/macOS,Go 1.20+):
-
第一轮验证“未抢占”
把runtime.Gosched()注释掉,编译:go build -gcflags="-N -l" main.go GODEBUG=schedtrace=1000,scheddetail=1 ./main输出里能看到
M=1连续 20 ms+ 处于running==1,idle==0,证明未让出 P。 -
第二轮验证“10 ms 内抢占”
取消注释runtime.Gosched(),重新运行同一命令。
trace 里会出现preempt==1且running在 10 ms 左右下降,main goroutine 等待时间 ≈ 10~12 ms,符合预期。
结论:
当 deadLoop 不主动让出时,sysmon 确实会在 10 ms 左右完成异步抢占,保证调度公平。
拓展思考
- Go1.14 之前没有异步抢占,只能依赖函数调用检查栈扩张,空循环会永久占满 P;面试官常追问“旧版本怎么破?”——答案:注入
runtime.Gosched()或time.Sleep(time.Microsecond)。 - 生产环境排查“某核 100% 打满”时,可
kill -SIGURG <pid>手动触发抢占,再抓go tool trace看是否卡在用户死循环。 - 弱网络网关场景下,若把
GOMAXPROCS设得过高,sysmon 轮询间隔会被动拉长,极端可能 20 ms 才抢占一次,需结合runtime/debug.SetGCPercent调优。