异步抢占:带宽忽高忽低的真凶
go_send_bw 同一条命令反复跑,吞吐在 27000~49000 MB/s 之间乱跳,
而 C 的 ib_send_bw 稳如磐石。本节复盘整个排查:从被证伪的 NUMA 假设,到
GORDMA_PROBE 锁定 poll,最后一个环境变量让单核也打满线速——真凶是 Go 运行时的
信号式异步抢占(async preemption)。这也顺手解开了
上一节遗留的悬案:RawConn 的 poll 为什么被死死压在 ~1.40µs。
症状:每次都不一样
同机、同命令、同参数,连跑九次(64KB,-n 1000000,单位 MB/s):
35474 29868 28092 27082 49007 40621 49001 33103 48996
最差到最好差了 1.8 倍,且毫无规律。而 ib_send_bw 每次都贴着 ~49000。
Go 工具的代码是确定的,网卡和链路也没变——抖动只能来自进程跑起来之后的运行时行为。
第一个弯路:NUMA 假设被证伪
第一反应:忙轮询的线程被调度器在核间迁移,偶尔跨 NUMA 访问 CQ 内存。于是锁核试验—— 结果反过来了:
# 锁到任意单核(0 / 24 / 200 都一样)+ GOMAXPROCS=1
taskset -c 24 GOMAXPROCS=1 go_send_bw ... → 27150(死稳定)
taskset -c 0 GOMAXPROCS=1 go_send_bw ... → 27141(死稳定)
taskset -c 200 GOMAXPROCS=1 go_send_bw ... → 27029(死稳定)
# GOGC=off 也试了,没区别 → GC 排除
关键反信号:锁到哪颗核都一样(NUMA 落点排除),而且单核稳定值正是地板 ~0.414 Mpps。 换句话说——高吞吐不是「落对了核」得来的,而是「用了多核」得来的;把活压到单核就塌到地板。 之前忽高忽低,是默认多核时运行时把杂务分散开,偶尔让忙轮询循环「干净独占」了它那颗核。
GORDMA_PROBE 锁定:全在 poll
用 GORDMA_PROBE=1 把发送循环拆成提交 WR(post)和忙等完成(poll)两段
(探针机制见 go_rdmanet_bw 详解),三种条件下各跑一次:
| 条件 | post 每 WR | poll 每 WR | 消息率 |
|---|---|---|---|
| 单核(默认) | 256 ns | 1.458 µs | 0.414 Mpps |
| 多核(默认) | 266 ns | 0.731 µs | 0.747 Mpps |
单核 + asyncpreemptoff=1 | 257 ns | 0.744 µs | 0.747 Mpps |
三行对比下来,结论无可辩驳:
- post(cgo 提交)三种条件几乎完全一致(~260 ns/WR)——和核数、抢占都无关,不是变量。
- 全部差异都在 poll:单核默认 1.458µs,多核降到 0.731µs,整整翻倍。
- 最后一行是判决书:单核只要关掉异步抢占,poll 立刻砍半、单核也打满线速。
真凶:SIGURG 切碎忙轮询自旋
为什么偏偏打这个循环
Go 的 sysmon 监控线程盯着每个 goroutine,发现某个 goroutine
在同一个 P 上连续跑超过 ~10ms 不让出就尝试抢占它。对普通代码,抢占能在函数调用的栈检查点上
协作式完成;但忙轮询循环(runBWPipeline / RawConn 的 Pipeline)是一段
紧凑自旋——长时间停在 ibv_poll_cq 这种 cgo 调用和小循环里,到不了一个能安全让出的协作点。
这时 sysmon 唯一的手段就是给那个线程发 SIGURG 信号强行打断。
这个循环「跑得久」又「难在安全点让出」,正好是信号式抢占的高频靶子。
为什么这一刀格外贵
普通 CPU 密集循环被打断一下几乎无损。但 RDMA 发送流水线是个收割–补发的闭环:
poll CQ → 收到完成 → 立刻补一个新的 SEND → 再 poll … # 保持 txDepth=128 个 WR 在飞
信号打进来的那一瞬间,没人收割 CQ、也没人补发新 WR。在途的 128 个 send 陆续完成却无人补位 → 网卡发送队列被抽干 → 恢复后还要从空把流水线重新填满。一个本该 µs 级补位的环节被拉成一个明显的 气泡。摊到每个 WR,poll 等待正好被拉长一倍。
cgo 放大效应
还有一层叠加:因为循环大部分时间停在 cgo 的 ibv_poll_cq 里(C 代码不可被异步抢占),SIGURG 经常
打不中可抢占点,于是 sysmon 会反复重发——不是 10ms 才一刀,而更像信号反复掐。
每一刀都打断「收割→补发」的节奏。
perf trace -e 'signal:*' 或 strace -f -e trace=tgkill,rt_sigreturn 数 SIGURG 频次。
对得上实测
回看上面那张 GORDMA_PROBE 表,三个数字逐一吻合这套机制:
- post 纹丝不动(~260 ns/WR):提交是短促的 cgo 调用,不是那个「跑很久」的段,
sysmon不盯它,抢占影响不到。 - poll 精确砍半:循环绝大部分时间花在 poll 上,所有被信号掐出的气泡都归到这一段—— 它正是抢占的全部落点。
- 「变好」还顺带「变稳」:抢占的触发时机是随机的,某轮少挨几下就接近线速、多挨几下就掉到地板—— 这正是最早 27k–49k 乱跳的根源。关掉这个信号机制,随机源消失,所以结果不仅变快、还不再忽高忽低。 (协作式让出仍保留,goroutine 调度行为一切正常。)
GOMAXPROCS 设成 1,等于把 sysmon、信号投递、netpoll 这些运行时杂务
全压回唯一那颗自旋核——所以它最慢也最稳(稳定在地板)。多核时这些杂务能跑到别的 P 上,
自旋核才有机会保持干净。
修复:preempt.Disable() 重启自己一次
方向因此反转:不是把循环关进一颗核,而是关掉异步抢占、再给它多核环境,让运行时杂务跑到别处。
关抢占靠 GODEBUG=asyncpreemptoff=1。但有个坑——
// 这样写编译不过:asyncpreemptoff 不在 go:debug 白名单里
//go:debug asyncpreemptoff=1
package main
// → invalid //go:debug: unknown //go:debug setting "asyncpreemptoff"
GODEBUG 是运行时在 main 之前就解析好的,进程没法给自己设。gordma 的做法是让进程
带着这个变量把自己重新 exec 一次:
// internal/preempt.Disable(),在每个 bw 工具 main 的第一行调用
func Disable() {
// 已经带上了(重启后的子进程,或用户显式设了)→ 直接返回,不会死循环
if 已含 asyncpreemptoff { return }
exe, _ := os.Executable()
env := 合并出单个 GODEBUG=...,asyncpreemptoff=1
syscall.Exec(exe, os.Args, env) // 同 PID、同 fd、同参数、同退出码
}
- 无死循环:重启后的子进程已带该设置,
Disable()立即返回;用户显式设了asyncpreemptoff(包括=0想保留抢占)也尊重、不覆盖。 - best-effort:定位或 exec 失败就照常带抢占跑,绝不让工具因此启动失败。
- 两端都要:
go_send_bw/go_write_bw/go_read_bw/go_rdmanet_bw的客户端和服务端都接入了——服务端的 recv 循环同样是忙等,被抢占会反过来 stall 客户端的完成。
GORDMA_PROBE 看 post/poll 拆分定位,确认抖动在 poll;
修复就是 GODEBUG=asyncpreemptoff=1——但要靠启动时重新 exec 注入,源码里的 //go:debug
指令对它无效。