Golang 系统监控 sysmon:GMP 背后的后台管家

6 阅读4分钟

系统监控 sysmon:GMP 背后的后台管家

GMP 的调度循环和网络轮询器已经让 Go 并发非常高效,但仍有一些场景是它们单独解决不了的:

  • 一个 goroutine 陷入纯计算死循环,不主动让出 CPU。
  • 网络事件已经就绪,但所有 M 都在忙,没人去调 netpoll()
  • GC 需要触发,但调度循环一直忙于执行任务。
  • 定时器(timer)到期了需要被处理。

于是 Go runtime 启动了一个独立的 OS 线程,运行 runtime.sysmon,它不参与 GMP 调度循环,专门做这些“后台监察”工作。

一、sysmon 的职责

sysmon 主要做四件事:

职责说明
网络轮询唤醒当 netpoll 有就绪事件时,唤醒一个 M 去处理
抢占式调度发现 G 运行时间过长(如死循环),发送 SIGURG 抢占
GC 触发检查堆增长,决定是否需要启动 GC
定时器检查检查 timer 是否到期,把到期的 timer G 放入队列

二、抢占式调度

Go 1.2 之前是协作式调度,goroutine 必须主动调用 runtime.Gosched() 或阻塞才会让出 CPU。如果某个 G 写一个纯死循环:

for {}

就会独占一个 P,其他 G 饿死。

Go 1.14 引入了基于信号的异步抢占:sysmon 检测到某个 G 运行超过 10ms(或一个时间片),就向该 G 所在的 M 发送 SIGURG 信号。信号处理函数会在用户栈上注入一个安全点检查,让 G 停下来,把 CPU 让给调度循环。

三、SIGURG 为什么选择它

SIGURG(urgent socket condition)被选为抢占信号,原因很现实:

  1. 默认情况下不会被用户程序处理或屏蔽。
  2. 在 Unix 系统上通常没有业务语义,不会干扰正常信号处理。
  3. Go runtime 可以把它完全接管。

四、sysmon 的工作循环

简化逻辑:

for {
    usleep(20μs ~ 10ms)  // 根据系统负载动态调整
    if netpoll 有就绪事件 {
        找一个空闲或自旋的 M,让它去 netpoll
    }
    if 某个 G 运行时间超过阈值 {
        向该 M 发送 SIGURG 抢占
    }
    if 堆大小达到 GC 阈值 {
        触发 GC
    }
    if timer 到期 {
        把 timer 对应的 G 加入可运行队列
    }
}

五、代码实践:验证异步抢占

下面这个程序把 GOMAXPROCS 设为 1,一个 goroutine 写纯死循环,另一个 goroutine 定时打印。如果 Go 1.14+ 的异步抢占生效,打印 goroutine 仍然能运行。

package main

import (
	"fmt"
	"runtime"
	"time"
)

func main() {
	// 只有一个 P,如果协作式调度失败,死循环会饿死其他 goroutine
	runtime.GOMAXPROCS(1)

	go func() {
		fmt.Println("spinner started")
		for {
			// 纯死循环,没有任何阻塞或 Gosched
		}
	}()

	// 主 goroutine 每隔一段时间打印一次
	for i := 0; i < 5; i++ {
		time.Sleep(100 * time.Millisecond)
		fmt.Printf("main still alive %d\n", i)
	}

	fmt.Println("done")
}

运行后你会看到:

spinner started
main still alive 0
main still alive 1
main still alive 2
main still alive 3
main still alive 4
done

这说明即使有一个 goroutine 在死循环,sysmon 也会通过 SIGURG 把它抢占,让主 goroutine 继续执行。

六、验证 timer 由 sysmon 处理

Go 的 time.Aftertime.Sleep 等底层都依赖 timer。sysmon 会检查到期的 timer,并把对应的 G 唤醒。

package main

import (
	"fmt"
	"time"
)

func main() {
	start := time.Now()

	go func() {
		fmt.Println("worker sleeping 300ms")
		time.Sleep(300 * time.Millisecond)
		fmt.Printf("worker woke up after %v\n", time.Since(start))
	}()

	select {
	case <-time.After(500 * time.Millisecond):
		fmt.Printf("main timer fired after %v\n", time.Since(start))
	}
}

输出:

worker sleeping 300ms
worker woke up after 300.xxxms
main timer fired after 500.xxxms

即使 worker 和 main 都在等待 timer,也只需要 sysmon 定期检查即可,不需要占用 M 空转。

七、GC 触发与 sysmon

sysmon 还会根据 heap 增长情况判断是否触发 GC。当分配速度超过回收速度,heap 达到 GOGC 设定的阈值时,sysmon 会发起 GC。这是 Go 自动内存管理的核心机制之一。

八、逐条解读

  1. runtime.GOMAXPROCS(1) 把 P 数量限制为 1,制造“独木桥”场景。
  2. 死循环 goroutine 不会主动调用 Gosched,在协作式调度下会饿死主 goroutine。
  3. sysmon 检测到这个 G 运行超过时间片,发送 SIGURG。
  4. 信号处理函数在 G 的安全点注入抢占,让它让出 CPU。
  5. 主 goroutine 因此能获得时间片继续打印。
  6. time.Sleeptime.After 依赖 sysmon 检查 timer 到期。
  7. sysmon 的独立线程让它不依赖 GMP 调度循环的忙碌程度。

九、小结

概念一句话总结
sysmon独立 OS 线程,负责监控和辅助 GMP 调度
异步抢占Go 1.14+ 用 SIGURG 信号打断长时间运行的 G
协作式调度G 主动让出 CPU,无法处理纯死循环
netpoll 唤醒sysmon 发现有网络事件时,唤醒 M 处理
GC 触发根据堆增长自动发起垃圾回收
timer 检查到期 timer 对应的 G 被重新放入可运行队列