bigywater.tech
Go · 60

异步抢占:死循环的 goroutine 怎么被信号打断

一个没有任何函数调用的死循环,调度器怎么把它打断?老版本的 Go 打不断,整个程序会被一个循环卡死。这篇关掉信号抢占做对照,量出默认设置下被打断需要多久、GC 会不会被拖住,并读源码里的安全点说明。

25–30 ms
死循环占着唯一的 P 时,main 的 Sleep(10ms) 实际醒来的时间
0.2 ms
死循环在跑时 runtime.GC() 的耗时(GOMAXPROCS=2)
0 个 CALL
死循环函数汇编里的调用指令个数,没有可以主动让出的入口

没有函数调用就没有主动让出的机会

实测 · 本机执行输出
  main 每次 time.Sleep(10ms) 实际耗时:最小 25.2 ms / 中位 30.0 ms / 最大 30.1 ms(30 次,GOMAXPROCS=1,GODEBUG="")
  (超时:15 s 内没有结束,被我杀掉了;GOMAXPROCS=1,GODEBUG='asyncpreemptoff=1')

同步抢占靠函数序言里的栈检查:运行时把 goroutine 的栈边界改成一个必定失败的值,下次进函数就会发现“该让出了”。但上面这个循环的汇编里没有任何调用、也没有栈检查,它从来不会走进那段代码。

关掉信号抢占:程序不再往前走

实测 · 本机执行输出
  死循环 goroutine 在跑时调用 runtime.GC():耗时 0.2 ms(GOMAXPROCS=2,GODEBUG="")
  (超时:4 s 内没有结束,被我杀掉了;GOMAXPROCS=2,GODEBUG='asyncpreemptoff=1')

加上 GODEBUG=asyncpreemptoff=1 后,main 的 Sleep 到期也拿不到唯一的 P,程序 15 秒内没有结束;GC 的情形里 runtime.GC() 4 秒内没有返回。我杀掉了它们,所以只能说“超过这个时间都没结束”,不是证明永远不结束。

默认设置下靠信号打断

实测 · 本机执行输出
  死循环的 goroutine 占着唯一的 P 跑了约 1 秒:收到 SIGURG 19 次(GOMAXPROCS=1,GODEBUG="")
  (超时:4 s 内没有结束,被我杀掉了;GOMAXPROCS=1,GODEBUG='asyncpreemptoff=1')

默认情况下运行时会给占着线程的死循环发信号(SIGURG),在一条安全的指令上把它停住。实验里一个死循环占着 P 约 1 秒,订阅 SIGURG 的 os/signal 收到 19 次;main 的 10 ms 睡眠实际 25–30 ms 才醒——比 10 ms 慢,但不会被卡死。慢多少、为什么是这个数,我没有深查,源码里能确认的只有每个 G 有 10 ms 的时间片。

GC 也依赖它

实测 · 本机执行输出
  main.spinNoCall STEXT size=48 align=0x0 args=0x0 locals=0x0 funcid=0x0 leaf
    (<gtmp>/ap2/main.go:16)	TEXT	main.spinNoCall(SB), LEAF|NOFRAME|ABIInternal, $0-0
    (<gtmp>/ap2/main.go:16)	FUNCDATA	$0, gclocals·g5+hNtRBP6YXNjfog7aZjQ==(SB)
    (<gtmp>/ap2/main.go:16)	FUNCDATA	$1, gclocals·g5+hNtRBP6YXNjfog7aZjQ==(SB)
    (<gtmp>/ap2/main.go:19)	MOVD	ZR, R0
    (<gtmp>/ap2/main.go:20)	ADD	$1, R0, R0
    (<gtmp>/ap2/main.go:21)	TST	$268435455, R0
    (<gtmp>/ap2/main.go:21)	BNE	4
    (<unknown line number>)	NOP
    (<GOROOT>/src/sync/atomic/type.go:19)	MOVD	$main.stop(SB), R1
    (<GOROOT>/src/sync/atomic/type.go:19)	LDARW	(R1), R1
    (<gtmp>/ap2/main.go:21)	CBZW	R1, 4
    (<gtmp>/ap2/main.go:21)	RET	(R30)
    0x0000 e0 03 1f aa 00 04 00 91 1f 6c 40 f2 c1 ff ff 54  .........l@....T
    0x0010 01 00 00 90 21 00 00 91 21 fc df 88 41 ff ff 34  ....!...!...A..4
  这个函数的汇编里 CALL 指令的个数:0(上面是整个函数:LEAF|NOFRAME,没有栈检查的序言,也没有任何调用)

GC 的停顿阶段要让所有 goroutine 停下来。默认设置下死循环在跑时 runtime.GC() 只花了 0.2 ms;关掉信号抢占就等不到。源码里 asyncpreemptoff 的说明也提到这会让某些循环长时间不可抢占、可能延迟 GC。

读源码的结论

实测 · 本机执行输出
  preempt.go:7-22
    // A goroutine can be preempted at any safe-point. Currently, there
    // are a few categories of safe-points:
    //
    // 1. A blocked safe-point occurs for the duration that a goroutine is
    //    descheduled, blocked on synchronization, or in a system call.
    //
    // 2. Synchronous safe-points occur when a running goroutine checks
    //    for a preemption request.
    //
    // 3. Asynchronous safe-points occur at any instruction in user code
    //    where the goroutine can be safely paused and a conservative
    //    stack and register scan can find stack roots. The runtime can
    //    stop a goroutine at an async safe-point using a signal.
    //
    // At both blocked and synchronous safe-points, a goroutine's CPU
    // state is minimal and the garbage collector has complete information
  proc.go:6677-6679
    // forcePreemptNS is the time slice given to a G before it is
    // preempted.
    const forcePreemptNS = 10 * 1000 * 1000 // 10ms
  extern.go:227-229
    	asyncpreemptoff: asyncpreemptoff=1 disables signal-based
    	asynchronous goroutine preemption. This makes some loops
    	non-preemptible for long periods, which may delay GC and
  signal_unix.go:44-45
    // sigPreempt is the signal used for non-cooperative preemption.
    //

preempt.go 开头把安全点分成三类:阻塞、同步(函数序言)、异步(用户代码任意指令,用信号停住并检查这条指令是否安全)。异步抢占补上了前两类够不到的地方。具体“哪些指令算安全”的判断我没有读。

分步演示

异步抢占
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew);用 go run / go build / go test 直接编译运行,需要第三方库时经代理从 proxy.golang.org 下载;计时用 time.Now / testing.B,数字每次运行会有小幅抖动。死循环函数 spinNoCall 只做自增,每 2^28 次读一次原子变量;“被杀”的程序是由我的脚本在 15 秒或 4 秒后 kill 的;SIGURG 次数用 os/signal.Notify 统计;汇编来自 go build -gcflags=-S;读过的源码:runtime/preempt.go 开头注释、proc.go 的 forcePreemptNS、extern.go 的 asyncpreemptoff 说明、signal_unix.go 的 sigPreempt 注释
    • 没有验证:Linux 上的信号行为、25–30 ms 的原因、19 次信号的构成、哪些指令是异步安全点、Windows 上的实现、asyncpreemptoff 下“永远不结束”(只测到超时)、GOMAXPROCS 更大时的情形
    京ICP备2026067502号-1京公网安备11010502063487号