Go 语言中的 //go:nosplit 指令解析

本文翻译自 https://mcyoung.xyz/2025/07/07/nosplit/ ,原语言文章版权所有。如有侵权请联系删除。

//go:nosplit 是什么?

大多数人不知道 Go 有特殊的指令语法。不幸的是,它不是真正的语法,它只是一个注释。例如,//go:noinline 会导致下一个函数声明永远不会被内联,这对于改变调用它的函数的内联成本很有用。指令有三种类型:

  • gc 的文档注释中记录的指令。这包括 //go:noinline//line
  • 在其他地方记录的指令,例如 //go:build//go:generate
  • runtime/HACKING.md 中记录的指令,只有在将 -+ 标志传递给 gc 时才能使用。这包括 //go:nowritebarrier
  • 完全没有文档的指令,可以通过搜索编译器的测试来发现它们的存在。这包括 //go:nocheckptr//go:nointerface//go:debug

我们最感兴趣的是第一种类型的指令://go:nosplit。根据文档:

//go:nosplit 指令必须紧跟函数声明。它指定函数必须省略其常规的栈溢出检查。这最常用于低级运行时代码,在调用协程被抢占不安全时调用。

这到底是什么意思?普通程序代码可以使用此注解,但其行为定义不明确。让我们深入探讨。

Go 栈增长

Go 为新的 goroutine 分配非常小的栈,这些栈会动态增长。这使得程序可以生成大量短生命周期的 goroutine,而无需在它们的栈上花费大量内存。这意味着栈溢出非常容易发生。每个函数都知道其栈的大小,runtime.g(goroutine 结构体)包含栈的结束位置;如果栈指针小于它(栈向上增长),控制权就会传递给 runtime.morestack,它会有效地抢占 goroutine,同时调整其栈的大小。实际上,每个 Go 函数周围都有以下代码:

TEXT .f(SB), ABIInternal, $24-16
CMPQ SP, 16(R14)
JLS grow
PUSHQ BP
MOVQ SP, BP
SUBQ $16, SP
// Function body...
ADDQ $16, SP
POPQ BP
RET

grow:
MOVQ AX, 8(SP)
MOVQ BX, 16(SP)
CALL runtime.morestack_noctxt(SB)
MOVQ 8(SP), AX
MOVQ 16(SP), BX
JMP .f(SB)

请注意,r14 持有指向当前 runtime.g 的指针,栈限制是该结构体中的第三个字大小的字段(runtime.g.stackguard0),因此偏移量为 16。如果栈即将耗尽,它会跳转到函数末尾的一个特殊块,该块会溢出所有参数寄存器,陷入运行时,一旦完成,就会取消溢出参数并重新启动函数。请注意,参数在调整 rsp 之前溢出,这意味着参数被写入调用者的栈帧。这是 Go ABI 的一部分;调用者必须在其栈帧顶部为它调用的任何函数分配空间,以便在必要时溢出所有寄存器以进行抢占。抢占是不可重入的,也就是说,在已经被抢占的 G 上下文中,或者根本没有 G 的情况下运行的函数,都不能被这个检查再次抢占。

Nosplit 函数

//go:nosplit 指令将函数标记为“nosplit”,或“不可分割函数”。“分割”与此指令的作用无关。

分段栈

在过去,Go 的栈被分成多个段,每个段都以指向下一个段的指针结尾,有效地用这样的数组链表替换了栈的单个数组。分段栈非常糟糕。这些序言不是触发大小调整,而是负责通过遵循此指针来更新 rsp 到下一个(或上一个)块,每当当前段触底时。这意味着如果函数调用恰好在段边界上,它会比其他函数调用慢得多,因为正确更新 rsp 需要大量工作。这意味着栈帧大小的不幸选择意味着性能会突然下降。有趣!

Go 从那时起就发现分段栈是一个糟糕的主意。在实现正确的 GC 栈扫描算法(它在许多稳定版本中都没有)的过程中,它还获得了将栈内容从一个位置复制到另一个位置的能力,以这种方式更新指针,使用户代码不会注意到。这个栈分割代码就是“nosplit”这个名字的由来。

nosplit 函数不会加载并分支到 runtime.g.stackguard0,而是简单地假定它有足够的栈。这意味着 nosplit 函数不会自行抢占,因此,在热循环中调用时速度明显更快。不相信我?

//go:noinline
func noinline(x int) {}

//go:nosplit
func nosplit(x int) {
	noinline(x)
}

func yessplit(x int) {
	noinline(x)
}

func BenchmarkCall(b *testing.B) {
	b.Run("nosplit", func(b *testing.B) {
		for b.Loop() {
			nosplit(42)
		}
	})
	b.Run("yessplit", func(b *testing.B) {
		for b.Loop() {
			yessplit(42)
		}
	})
}

如果我们分析这个并查看每个函数的时间,我们会得到:

390ms  390ms func nosplit(x int) { noinline(x) }
 60ms   60ms 51fd80: PUSHQ BP
 10ms   10ms 51fd81: MOVQ SP, BP
  .      .
 51fd84: SUBQ $0x8, SP
 60ms   60ms 51fd88: CALL .noinline(SB)
190ms  190ms 51fd8d: ADDQ $0x8, SP
  .      .
 51fd91: POPQ BP
 70ms   70ms 51fd92: RET

440ms  490ms func yessplit(x int) { noinline(x) }
 50ms   50ms 51fda0: CMPQ SP, 0x10(R14)
 20ms   20ms 51fda4: JBE 0x51fdb9
  .      .
 51fda6: PUSHQ BP
 20ms   20ms 51fda7: MOVQ SP, BP
  .      .
 51fdaa: SUBQ $0x8, SP
 10ms   60ms 51fdae: CALL .noinline(SB)
200ms  200ms 51fdb3: ADDQ $0x8, SP
  .      .
 51fdb7: POPQ BP
140ms  140ms 51fdb8: RET
  .      .
 51fdb9: MOVQ AX, 0x8(SP)
  .      .
 51fdbe: NOPW
  .      .
 51fdc0: CALL runtime.morestack_noctxt.abi0(SB)
  .      .
 51fdc5: MOVQ 0x8(SP), AX
  .      .
 51fdca: JMP .yessplit(SB)

在这些函数共享的所有指令中,每个指令花费的时间(对于整个基准测试,我确保每个测试用例花费的时间相等,使用 -benchtime Nx)是可比的,但栈检查会额外产生约 2% 的成本。这是一个非常人为的设置,因为在 yessplit 基准测试中,g 结构体始终在 L1 中,因为循环中没有其他内存操作。然而,对于需要饱和缓存的非常热的代码,由于缓存未命中,这可能会产生巨大的影响。我们可以通过添加一个执行 clflush [r14] 的汇编函数来增强此基准测试,这会导致 g 结构体从所有缓存中弹出。

TEXT .clflush(SB)
CLFLUSH (R14) // Eject the pointee of r14 from all caches.
RET

如果我们将对该函数的调用添加到两个基准测试循环中,我们会看到从 RAM 冷读取的惊人成本出现在每个函数调用中:BenchmarkCall/nosplit 为 120.1 纳秒,而 BenchmarkCall/yessplit 为 332.1 纳秒。200 纳秒的差异是从主内存中获取。L1 未命中的成本大约低 15 倍,因此如果 g 结构体设法从 L1 中被踢出,您将支付大约 15 纳秒左右,或者大约两次映射查找的成本!

尽管该语言抵制添加内联启发式,程序员会在不知道其作用的情况下将其放置在任何地方,但它们确实提供了一些更糟糕的东西,使代码明显更快:nosplit

但它无害吗?

考虑以下程序:

//go:nosplit
func x(y int) {
	x(y+1)
}

自然,这会立即导致栈溢出。相反,我们得到了一个非常可怕的链接器错误:

x.x: nosplit stack over 792 byte limit
x.x<1> grows 24 bytes, calls x.x<1> infinite cycle

Go 链接器包含一个检查,用于验证任何调用 nosplit 函数的 nosplit 函数链不会溢出小的额外栈窗口,这是 nosplit 函数的栈帧在超出 stackguard0 时所在的位置。每个栈帧都会贡献一些栈使用(至少是返回地址),因此在出现此错误之前可以调用的函数数量是有限的。而且由于每个函数都需要为其所有被调用者分配空间以在必要时溢出其参数,如果这些函数中的每一个都使用所有可用的参数寄存器,您可能会很快达到此限制(问我怎么知道的)。此外,打开模糊测试会通过在分支周围的模糊测试运行时中插入 nosplit 调用来检测代码,这意味着打开模糊测试可能会导致以前正常的代码不再链接。栈使用量也因架构而异,这意味着在一个架构中构建的代码在其他架构中无法链接(在从 32 位到 64 位时最明显)。没有简单的方法可以使用构建标签控制指令(两个设计不佳的功能冲突),因此您也不能仅仅为了调试而“关闭”对性能敏感的 nosplit。因此,在使用 nosplit 进行性能优化时,您必须非常非常小心。

虚拟 Nosplit 函数

令人兴奋的是,地址被获取的 nosplit 函数没有特殊的代码生成,这允许我们通过使用虚拟函数调用来规避链接器栈检查。考虑以下程序:

package main

var f func(int)

//go:nosplit
func x(y int) {
	f(y+1)
}

func main() {
	f = x
	f(0)
}

这会迅速耗尽主 G 的微小栈,并以最暴力的方式导致段错误,阻止运行时打印调试跟踪。此程序的所有输出都是 signal: segmentation fault。这可能是一个错误。

其他副作用

事实证明,nosplit 还有各种其他有趣的副作用,这些副作用都没有文档记录。它主要的作用是它有助于判断函数是否被运行时认为是“不安全的”。考虑以下程序:

package main

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

func main() {
	for range runtime.GOMAXPROCS(0) {
		go func() {
			for {}
		}()
	}
	time.Sleep(time.Second) // Wait for all the other Gs to start.
	fmt.Println("Hello, world!")
	os.Exit(0)
}

此程序将确保每个 P 都绑定到一个永远循环的 G,这意味着它们永远不会陷入运行时。因此,此程序将永远挂起,永远不会打印其结果并退出。但这不是会发生的事情。由于异步抢占,调度程序会检测运行时间过长的 G,并通过向其发送信号来抢占其 M(碰巧,这是 SIGURG)。然而,异步抢占只有在 M 在安全点停止时才可能发生,由 runtime.isAsyncSafePoint 确定。它包含以下代码块:

up, startpc := pcdatavalue2(f, abi.PCDATA_UnsafePoint, pc)
if up == abi.UnsafePointUnsafe {
	// Unsafe-point marked by compiler. This includes
	// atomic sequences (e.g., write barrier) and nosplit
	// functions (except at calls).
	return false, 0
}

如果我们追溯这个值的设置位置,我们会发现它被明确地设置为写屏障序列、任何“属于运行时”的函数(通过使用 -+ 标志构建来定义)以及任何 nosplit 函数。通过将 go 主体提升到 nosplit 函数中进行少量修改,以下程序将永远运行:它永远不会从 time.Sleep 中唤醒。

package main

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

//go:nosplit
func forever() {
	for {}
}

func main() {
	for range runtime.GOMAXPROCS(0) {
		go forever()
	}
	time.Sleep(time.Second) // Wait for all the other Gs to start.
	fmt.Println("Hello, world!")
	os.Exit(0)
}

尽管有工作要做,但每个 P 都绑定到一个永远不会到达安全点的 G,因此永远不会有可用的 P 来运行主 goroutine。这代表了使用 nosplit 函数的另一个潜在危险:那些不调用可抢占函数的函数必须迅速终止,否则有活锁整个运行时的风险。

结论

我经常使用 nosplit,因为我编写高性能、低延迟的 Go 代码。这是一件非常疯狂的事情,这导致我每当遇到奇怪的边缘情况时都会慢慢生成错误报告。例如,在许多情况下,为从不使用它们的函数分配溢出区域,例如,只调用 nosplit 函数的函数会为它们分配空间以溢出其参数,而它们不会这样做。这是一个有文档记录的 Go 语言特性,它:

  • 文档不是很完善(异步抢占行为肯定不是)!
  • 具有非常可怕的、依赖优化的构建失败。
  • 可能导致活锁和神秘的段错误。
  • 可以在不导入 unsafe(unsafe 包)的用户程序中使用!
  • 它使代码更快!

我很惊讶竟然存在如此巨大的“脚枪”,但它对我来说是一个可衡量的基准改进,所以无法判断它是好是坏。

细心的读者会发现,由于抢占是不可重入的,因此在 G 中一次只有一个溢出区域在使用。这是 ABI 中的一个已知错误,本质上是为了方便采用通过寄存器传递参数而打的补丁,而无需运行时中所有期望参数溢出到栈的部分,就像在 Go ABI 在每个平台上都是“i386-unknown-linux 但更糟”的缓慢旧时代一样,即参数进入栈并使 CPU 的存储队列感到悲伤。我最近提交了一个关于此的错误报告,归结为“向 runtime.g 添加一个字段以使用溢出空间”,这在我看来比 ABIInternal 规范中描述的替代方案更简单。

我写的几乎每个错误报告都以这四个词开头,这意味着你即将看到有史以来最糟糕的程序。

溢出区域也用于跨调用溢出参数,但在这种情况下,调用者不需要为 nosplit 函数分配它。

comments powered by Disqus