豆浆大叔

cgo 优化后续,打脸来得真快

cgo 优化被合并主干之后,很高兴的写了篇流水账,倾诉了一番心酸史,也感谢各位大佬的转发和打赏,收获了写公众号以来最多的围观

然而打脸来得也真快,就在昨儿,愚人节的早上,被 revert 了 ...

好在 Cherry 大佬还有意继续推进,搞了个 CL 481061[1] ,Michael 大佬也给了 +2 approve,希望能合回去,赶上 1.21 这个版本。

1挖坑小能手

我们看看 Cherry 大佬最新这个 CL 的 commit log 中的描述:

CL 392854, by doujiang24 <[email protected]>, speed up C to Go
calls by binding the M to the C thread. See below for its
description.
CL 479255 is a followup fix for a small bug in ARM assembly code.
CL 479915 is another followup fix to address C to Go calls after
the C code uses some stack, but that CL is also buggy.
CL 481057, by Michael Knyszek, is a followup fix for a memory leak
bug of CL 479915.

最初我搞的 CL 392854[2] 被合并之后,大佬们已经帮忙搞了三个 bugfix 了,真是惭愧...

最开始的 CL 479255[3],是一个低级 bug,arm 的汇编少复制了一行,没啥好说的

2morestack on g0

CL 479915[4] 则是费了一番功夫才搞明白...

Cherry 大佬的描述在这个 issue 59294[5]

g0 是干啥的

要讲清楚这个 bug,得先介绍一下 g0

每个 M 都有一个 g0,来处理一些特殊的事情,比如扩栈的时候,newstack 函数就运行在 g0 上。

如下 morestack 的代码:

// Call newstack on m->g0's stack.
MOVQ m_g0(BX), BX
MOVQ BX, g(CX)
MOVQ (g_sched+gobuf_sp)(BX), SP
MOVQ (g_sched+gobuf_bp)(BX), BP
CALL runtime·newstack(SB)
CALL runtime·abort(SB) // crash if newstack returns

这里是先切换到 g0 栈,再运行的 newstack,结合 g 的数据结构,核心就是这行伪代码

SP = g0.sched.sp

问题

但是,在 cgo 启用的时候,g0 并不会像普通的 g 一样,拥有自己的 stack 空间,而是会复用 C 线程的 stack 空间。

我们可以看 needm 对 g0 栈的处理,g0 栈就是当前 C 栈顶往下的 32 kb 地址空间

gp.stack.hi = getcallersp() + 1024 // 这个 1024 没搞懂是为啥
gp.stack.lo = getcallersp() - 32*1024
gp.stackguard0 = gp.stack.lo + _StackGuard

优化之前,每次 c 调用 go 都会执行 needm,也就是 g0 的栈会根据当前 c 栈来动态计算;

但是,优化之后,并不是每次 c 调用 go 都会执行 needm 了,也就是 g0 栈固定在第一次进入 go 时计算的栈空间了。

也就是 stackguard0 是固定的了,如果后续 c 调用 go 的时候,c 栈比第一次高很多,这可能就会导致,runtime 在栈检查的时候认为 g0 栈 overflow 了;而 g0 的栈是不能扩的,也就会抛 morestack on g0 的异常。

栈检查

这里稍微解释下 Go 栈检查的逻辑,比如 amd64 上,我们经常会看到这样的指令:

000000000008b100 <runtime.main.func1>:
   8b100:       49 3b 66 10             cmp    rsp,QWORD PTR [r14+0x10]
   8b104:       76 2d                   jbe    8b133 <runtime.main.func1+0x33>
   ...
   8b133:       e8 e8 8a 02 00          call   b3c20 <runtime.morestack_noctxt.abi0>
   8b138:       eb c6                   jmp    8b100 <runtime.main.func1>

核心是这样的伪代码:

if rsp <= g.stack.stackguard0; { runtime.morestack_noctx() }

也就是拿栈顶 rsp 与 stackguard0 对比,小于 stackguard0,则需要扩栈。

对应上面问题的场景,也就是 stackguard0 是固定的,但是 rsp 则是每次不一样的。

解决办法

Cherry 大佬在 issue 里说的解法有两种:

  1. 每次 c 调用的时候,重新为 g0 计算栈空间,这样就跟优化之前保持一致的行为。
  2. 将 g0 的栈顶地址,设置为 c 的栈顶;本质上就是把 g0 的栈空间搞到最大,这样就不容易达到扩栈的条件

大佬在 CL 479915[6] 选择了第二种解法,patch 都搞到 13 个版本才合并,我一路看下来,最大的感觉是,系统兼容性真难搞。

不过,这里还有一个小隐患,也就是栈顶是直接拉满够用了,但是栈底实际上还是不对的,比如 morestack 切栈的时候,会不会切到 c 栈使用了的空间,把 c 栈写坏了呢?

目前看下来,即使在复用 M 的时候,也会有这段逻辑,也就是会将真实的 rsp 存入 g0.sched.sp,所以看起来貌似还好。不过这个还是比较的 tricky,会不会有其他的坑,其实也说不太好。

MOVQ m_g0(BX), SI
MOVQ (g_sched+gobuf_sp)(SI), AX
MOVQ AX, 0(SP)
MOVQ SP, (g_sched+gobuf_sp)(SI)

3又来 revert

而然在 CL 479915[7] 合并之后第二天,Cherry 大佬就来了个 revert CL[8] ,直接把 cgo 优化全 revert 了。原因是新的修复,发现了更多了 breakage。

不过是啥错误,也没有细说,不过,Michael 大佬提了另外一个 CL 481057[9] ,看起来是内存泄漏导致了 sanitizers 异常。

好在呢,Cherry 大佬又提了个 CL 481061[10] ,打算重新把 cgo 优化合进来,虽然 Michael 大佬已经给了 +2 approve,但是还没合。

比较让人担心的是,不清楚 google 内部的测试,是否还有其他异常,希望顺利吧 ...

4最后

其实,之前对于 g0 的理解是不到位的,以为是分配了单独的栈空间,实际上开启 cgo 时 g0 是复用的 c 线程栈。

果然,没理解到位的,终将是要付出代价的。辛苦了大佬们帮忙填坑,哈哈

这是第二次 CL 被 revert 了,真心不好玩,不过,确实是自己没搞好,也挺感谢有这些测试,也很感叹工程化能力真牛,respect!

参考资料

[1][10]

CL 481061: https://go-review.googlesource.com/c/go/+/481061

[2]

CL 392854: https://go-review.googlesource.com/c/go/+/392854

[3]

CL 479255: https://go-review.googlesource.com/c/go/+/479255

[4][6][7]

CL 479915: https://go-review.googlesource.com/c/go/+/479915

[5]

issue 59294: https://github.com/golang/go/issues/59294

[8]

revert CL: https://go-review.googlesource.com/c/go/+/481060

[9]

CL 481057: https://go-review.googlesource.com/c/go/+/481057