Go 程序里有一个不占 P 的后台线程 sysmon。它在干什么、空闲时会不会白白耗电、一个什么都不做的程序到底会不会被强制 GC?这篇用 schedtrace、getrusage 和 130 秒的 gctrace 实测,再对着源码逐项读。
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 线程,这个解释来自源码。
[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。
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,没有得出结论,所以没有写进来)。