go testing.b 默认不绑定 cpu,导致 numa 节点间内存访问延迟升高、基准测试结果波动达 15%–40%;应使用 numactl 或 taskset 限定 cpu 与内存域,谨慎使用 runtime.lockosthread() 并配对 defer unlockosthread(),避免计时区污染和 goroutine 绑定泄漏。

Go testing.B 默认不绑定 CPU,NUMA 节点间内存访问延迟会拉高基准结果
Go 的基准测试本身不控制线程亲和性,runtime.GOMAXPROCS 只管 goroutine 调度宽度,不管 OS 线程落在哪个 NUMA 节点。实测中,同一台 2-NUMA socket 服务器上,go test -bench=. 多次运行的 BenchmarkFoo 耗时波动可达 15%–40%,主因就是 OS 随机把 testing.B 启动的 M(OS 线程)调度到了跨节点位置,触发远端内存访问。
实操建议:
立即学习“go语言免费学习笔记(深入)”;
- 用
taskset或numactl在运行前限定 CPU 和内存域,例如:numactl --cpunodebind=0 --membind=0 go test -bench=. - 若需在代码内控制,用
syscall.SchedSetaffinity绑定当前线程到指定 CPU mask(注意:仅对当前 M 有效,且 fork 出的新 M 不继承) - 避免在
BenchmarkXxx函数里调用runtime.LockOSThread()后不 unlock —— 这会导致 goroutine 永久绑定、后续测试无法并行,甚至 panic
使用 runtime.LockOSThread() 前必须确认 goroutine 生命周期
runtime.LockOSThread() 把当前 goroutine 和底层 OS 线程绑定,但 Go 基准框架会在每次 B.N 迭代中复用 goroutine,且 testing.B 实例本身不保证跨迭代的线程一致性。一旦你在 BenchmarkXxx 开头 lock,又没在结尾 unlock,后续迭代可能卡在错误节点,或触发 fatal error: lockOSThread called in a thread not locked to an OS thread。
实操建议:
立即学习“go语言免费学习笔记(深入)”;
- 只在明确需要独占某组 CPU 核心做低延迟测量时才用,比如测试 lock-free 结构或 syscall 性能
- 必须配对使用:
defer runtime.UnlockOSThread()放在函数最开头,确保无论 panic 还是 return 都释放 - 不要在
B.ResetTimer()之后再 lock/unlock —— 此时已进入计时区,额外系统调用会污染结果
go test -benchmem 显示的分配数在 NUMA 下可能失真
Go 的内存分配器(mheap)默认启用 per-P 的 mcache,而 mcache 的初始来源是各 NUMA 节点上的 mcentral。当 benchmark 运行期间发生跨节点调度,goroutine 可能从非本地 mcentral 分配内存,导致 B.AllocsPerOp 数值稳定,但实际 Allocs/op 对应的内存物理位置分散,引发 TLB miss 和带宽争抢 —— 此时 -benchmem 不报错,但 perf stat -e mem-loads,mem-stores,offcore_requests 会显示远端内存请求激增。
实操建议:
立即学习“go语言免费学习笔记(深入)”;
- 用
go tool trace查看GC/STW和HeapAlloc时间点是否与 NUMA 切换重合 - 加
GODEBUG=madvdontneed=1强制使用MADV_DONTNEED(而非MADV_FREE),减少跨节点 page 回收抖动 - 如需纯本地内存测试,启动前用
numactl --membind=0并配合runtime.GOMAXPROCS(1)限单 P,可大幅压缩变量
CI 环境中 NUMA 意外开启导致基准回归误判
很多云 CI(如 GitHub Actions 自托管 runner、GitLab Runner on bare metal)默认启用 NUMA,但未暴露拓扑信息。你本地开发机是单 socket,go test -bench=. 结果稳定;CI 上跑出 2x 波动,第一反应是代码退化,其实只是 go test 进程被调度到了 node1,而测试数据初始化在 node0 的内存页上。
实操建议:
立即学习“go语言免费学习笔记(深入)”;
- CI 脚本开头加检测:
numactl --show 2>/dev/null | grep -q "available:" || echo "NUMA disabled" - 统一用
numactl --interleave=all go test -bench=.作为 baseline —— 虽牺牲局部性,但换来可比性 - 记录
lscpu | grep -E "(Socket|Node|Core)"和cat /sys/devices/system/node/node*/meminfo到日志,方便事后归因
NUMA 亲和性不是“开了就快”,而是“不控就不可靠”。真正难的不是绑核,是在 bind 之后验证内存路径没绕远、MCache 没跨节点、goroutine 没被 runtime 拉走 —— 这些细节藏在 runtime 和 syscall 交界处,一不留神就变成时序幽灵。










