bigywater.tech
Go · 64

sysmon:后台监控线程在检查什么

Go 程序里有一个不占 P 的后台线程 sysmon。它在干什么、空闲时会不会白白耗电、一个什么都不做的程序到底会不会被强制 GC?这篇用 schedtrace、getrusage 和 130 秒的 gctrace 实测,再对着源码逐项读。

@120.014s
先 GC 过一次的空闲进程被强制 GC 的时刻,forcegcperiod 是 2 分钟
0.000 秒
完全空闲 3.5 秒,进程用掉的 CPU 时间
5 件事
读源码看到 sysmon 周期性做的事:轮询网络、更新 GOMAXPROCS、叫醒 scavenger、retake、强制 GC

空闲和忙的样子

实测 · 本机执行输出
  SCHED 0ms: gomaxprocs=4 idleprocs=2 threads=3 spinningthreads=1 needspinning=0 idlethreads=0 runqueue=0 [ 0 0 0 0 ] schedticks=[ 0 0 0 0 ]
  SCHED 1011ms: gomaxprocs=4 idleprocs=4 threads=5 spinningthreads=0 needspinning=0 idlethreads=3 runqueue=0 [ 0 0 0 0 ] schedticks=[ 2 0 2 3 ]
  SCHED 2017ms: gomaxprocs=4 idleprocs=4 threads=5 spinningthreads=0 needspinning=0 idlethreads=3 runqueue=0 [ 0 0 0 0 ] schedticks=[ 2 0 2 3 ]
  SCHED 3019ms: gomaxprocs=4 idleprocs=4 threads=5 spinningthreads=0 needspinning=0 idlethreads=3 runqueue=0 [ 0 0 0 0 ] schedticks=[ 2 0 2 3 ]
  SCHED 0ms: gomaxprocs=4 idleprocs=1 threads=4 spinningthreads=1 needspinning=0 idlethreads=0 runqueue=0 [ 1 0 0 0 ] schedticks=[ 0 0 0 0 ]
  SCHED 1010ms: gomaxprocs=4 idleprocs=0 threads=5 spinningthreads=0 needspinning=1 idlethreads=0 runqueue=0 [ 0 0 0 0 ] schedticks=[ 37 35 36 37 ]
  SCHED 2018ms: gomaxprocs=4 idleprocs=0 threads=5 spinningthreads=0 needspinning=1 idlethreads=0 runqueue=0 [ 0 0 0 0 ] schedticks=[ 71 69 70 71 ]
  SCHED 3028ms: gomaxprocs=4 idleprocs=0 threads=5 spinningthreads=0 needspinning=1 idlethreads=0 runqueue=0 [ 0 0 0 0 ] schedticks=[ 106 104 105 105 ]

schedtrace 每秒打印一次调度器快照。空闲时 4 个 P 全空闲、schedticks 不再变化;每个 P 上都有空转 goroutine 时没有空闲 P,schedticks 每秒增加约 35 次。注意源码注释说开启 schedtrace 会让 sysmon 不进入深睡,所以这个观察本身改变了 sysmon 的行为。

空闲的程序几乎不被打扰

实测 · 本机执行输出
  完全空闲 3.5 秒:进程用掉的 CPU 时间 0.000 秒
  完全空闲 3.5 秒:进程用掉的 CPU 时间 0.000 秒
  完全空闲 3.5 秒:进程用掉的 CPU 时间 0.000 秒

完全空闲 3.5 秒,进程自己读到的 CPU 时间是 0.000 秒(三次)。源码里所有 P 都空闲时 sysmon 会睡到下一个定时器到期,或者最多 forcegcperiod/2。我没有直接观察 sysmon 线程,这个解释来自源码。

强制 GC 有前提

实测 · 本机执行输出
[A 没有调用过 runtime.GC()]
  启动后 没有调用过 runtime.GC():NumGC=0,然后什么都不做,空闲 130 秒(不分配)
    130 秒后 NumGC=0
[B 先调用一次 runtime.GC()]
  gc 1 @0.001s 6%: 0.079+0.31+0.009 ms clock, 0.79+0/0.73/0.16+0.093 ms cpu, 0->0->0 MB, 4 MB goal, 0 MB stacks, 0 MB globals, 10 P (forced)
    启动后 先调用了一次 runtime.GC():NumGC=1,然后什么都不做,空闲 130 秒(不分配)
  GC forced
  gc 2 @120.014s 0%: 0.11+0.28+0.014 ms clock, 1.1+0/0.62/0+0.14 ms cpu, 0->0->0 MB, 4 MB goal, 0 MB stacks, 0 MB globals, 10 P
    130 秒后 NumGC=2

很多文章说“Go 每 2 分钟强制 GC 一次”。实测:程序从没发生过 GC 时,空闲 130 秒也不会;先发生过一次 GC 之后,在 @120.014s 触发了强制 GC。源码里的条件正是 lastgc != 0 && now-lastgc > forcegcperiod。

sysmon 的清单

实测 · 本机执行输出
  proc.go:6618-6621
    		// poll network if not polled for more than 10ms
    		lastpoll := sched.lastpoll.Load()
    		if netpollinited() && lastpoll != 0 && lastpoll+10*1000*1000 < now {
    			sched.lastpoll.CompareAndSwap(lastpoll, now)
  proc.go:6637-6640
    		// Check if we need to update GOMAXPROCS at most once per second.
    		if debug.updatemaxprocs != 0 && lastgomaxprocs+1e9 <= now {
    			sysmonUpdateGOMAXPROCS()
    			lastgomaxprocs = now
  proc.go:6642-6644
    		if scavenger.sysmonWake.Load() != 0 {
    			// Kick the scavenger awake if someone requested it.
    			scavenger.wake()
  proc.go:6646-6647
    		// retake P's blocked in syscalls
    		// and preempt long running G's
  proc.go:6653-6654
    		// check if we need to force a GC
    		if t := (gcTrigger{kind: gcTriggerTime, now: now}); t.test() && forcegc.idle.Load() {
  proc.go:6522-6527
    // forcegcperiod is the maximum time in nanoseconds between garbage
    // collections. If we go this long without a garbage collection, one
    // is forced to run.
    //
    // This is a variable for testing purposes. It normally doesn't change.
    var forcegcperiod int64 = 2 * 60 * 1e9
  mgc.go:692-694
    	// gcTriggerTime indicates that a cycle should be started when
    	// it's been more than forcegcperiod nanoseconds since the
    	// previous GC cycle.
  mgc.go:718-719
    		lastgc := int64(atomic.Load64(&memstats.last_gc_nanotime))
    		return lastgc != 0 && t.now-lastgc > forcegcperiod

函数里依次是:睡眠(20 µs 起、最长 10 ms,见上一篇系统调用的文章)、必要时深睡、10 ms 没人轮询就自己轮询网络、每秒一次检查 GOMAXPROCS、叫醒 scavenger、retake 系统调用中的 P 并抢占运行太久的 G、满足条件时唤醒强制 GC 的 goroutine。其中 scavenger 被叫醒后具体怎么归还内存,我没有实验(试过一次分配 400 MB 再释放,10 秒内只多归还了约 2 MB,没有得出结论,所以没有写进来)。

分步演示

sysmon
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew);用 go run / go build / go test 直接编译运行,需要第三方库时经代理从 proxy.golang.org 下载;计时用 time.Now / testing.B,数字每次运行会有小幅抖动。schedtrace 与 gctrace 由 GODEBUG 打开;CPU 时间用 syscall.Getrusage 读自己;强制 GC 的两个进程同时跑 130 秒,GOMAXPROCS 为默认的 10;读过的源码:runtime/proc.go 的 sysmon 与 forcegcperiod,runtime/mgc.go 的 gcTrigger.test
    • 没有验证:sysmon 线程自身的 CPU 占用与唤醒次数(没有直接观测)、scavenger 被 sysmon 叫醒后的行为、网络轮询 10 ms 的触发场景、Linux 上的表现、schedticks 每秒约 35 次的原因、空闲 GOMAXPROCS 更大时的 idlethreads 数量
    京ICP备2026067502号-1京公网安备11010502063487号