系统监控 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)被选为抢占信号,原因很现实:
- 默认情况下不会被用户程序处理或屏蔽。
- 在 Unix 系统上通常没有业务语义,不会干扰正常信号处理。
- 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.After、time.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 自动内存管理的核心机制之一。
八、逐条解读
runtime.GOMAXPROCS(1)把 P 数量限制为 1,制造“独木桥”场景。- 死循环 goroutine 不会主动调用
Gosched,在协作式调度下会饿死主 goroutine。 - sysmon 检测到这个 G 运行超过时间片,发送 SIGURG。
- 信号处理函数在 G 的安全点注入抢占,让它让出 CPU。
- 主 goroutine 因此能获得时间片继续打印。
time.Sleep和time.After依赖 sysmon 检查 timer 到期。- sysmon 的独立线程让它不依赖 GMP 调度循环的忙碌程度。
九、小结
| 概念 | 一句话总结 |
|---|---|
| sysmon | 独立 OS 线程,负责监控和辅助 GMP 调度 |
| 异步抢占 | Go 1.14+ 用 SIGURG 信号打断长时间运行的 G |
| 协作式调度 | G 主动让出 CPU,无法处理纯死循环 |
| netpoll 唤醒 | sysmon 发现有网络事件时,唤醒 M 处理 |
| GC 触发 | 根据堆增长自动发起垃圾回收 |
| timer 检查 | 到期 timer 对应的 G 被重新放入可运行队列 |