予焦啦!附录:旅途拾贝

今天是 Hoddarla 系列文中的附录第 0 篇。笔者在这一年半的准备期当中送了两个 patch 给 Golang 上游。一次是分心想要更了解 Golang 的其它执行期机制,另一次则是已经开始准备 Hoddarla,但因为使用方法过於另类,因此踩到了没有人踩过的问题。今天的附录内容,就是分享这两次的上游经验。

第一个 patch:支援 RISC-V 的 AsyncPreempt 机制

Golang AsyncPreempt 机制简介

抢占相关的程序码注解描述,抢占必须发生在 Goroutine 安全点,并列出了三种不同的安全点:

  • (同步)Goroutine 离开排程、等待於同步的阶段、或是系统呼叫时
  • (同步)Goroutine 检查是否有其他 Goroutine 提出抢占请求时
  • (非同步)可能发生在任何指令,但必须能够在被抢占还能恢复状态者;也就是说,不能因为非同步的抢占造成不可回复的状态。

本文主要要探讨的对象即是第三项的非同步抢占。之所以需要这个功能,是因为人们希望整个 Golang 程序在运行的时候,不会被特定的 Goroutine 占据过多 CPU 资源,但既有的同步抢占方法都无法保证这一点。也就是说,若是有一个 Goroutine 执行庞大的回圈,而回圈内又没有使用到前两项的抢占安全点的话,在非同步抢占功能之前的 Golang 执行期环境是拿它没办法的。

这个功能在 Go 1.14 正式上线,许多系统的支援都已经在 2019 年底就已经完成。RISC-V 则是没有赶上那班车。

第一次的 Golang 贡献

如同先前章节描述过多次的那样,执行像 Hoddarla 这样的胡闹专案必须要持续地面对自我质疑:这到底有什麽用?真的能够学到什麽东西吗?之类问题总会时不时浮现,而需要以精神力克服,才有办法继续前进。

其实,笔者会贡献这个项目,就是在无法克服上述自我质疑的时候,自 Hoddarla 专案分心出来的结果。有点像是围棋国手吴清源大师告诫林海峰名誉天元的「追二兔不如一兔在手」的感觉。以结果论,从这个过程里确实是学到了正式贡献 Golang 专案的方式,以实用性角度来看也是较有意义的时间投资。但这个项目的成功也多少提昇了点信心,让笔者得以重新面对 Hoddarla 专案,这是题外话了。

当时也是怀抱着「不如就看看 RISC-V 有什麽 bug 好了」,在 github 里面搜寻,结果找到了当时的这个 issue。题干也很简单,就是 riscv64 尚无非同步抢占功能的支援。由於笔者之前曾经追踪过 RISC-V Linux 有关 context 处理的部份,於是就打算来一试身手。

如何贡献?

不像一般的 github 专案,Golang 对於贡献的流程有其他的要求。虽然 issue/bug 的追踪也是使用 github issue,但程序码的审查是在以 Gerrit 工具为基础的这个网站。审查过程本身通常有 Golang 团队的成员负责进行,但体验上与 Github PR 的感觉没有太大的差异。

最大的差异应该是实名制的贡献规则吧。上列的官方贡献说明中,也有提到必须签署 CLA,也就是贡献者授权同意书。因为笔者是以业余时间进行非同步抢占的功能开发,所以以个人身份签署。

patch 解说

笔者的 patch commit 为 b89f4c67200e6128e1dc936a9362b07900c2af3e,从首次送出(当时没有按部就班遵守前一段的贡献流程文件去操作,最後还是使用 github 的 PR 再仰赖 gobot 自动联动)到被接受花了两周。本小节就用以解说其中的部份程序码之意义。

执行期流程:插入模拟的呼叫

src/runtime/signal_unix.go 之中的 doSigPreempt 函式是所有 Unix-like 系统的抢占讯号入口(可见参考资料中描述抢占讯号如何产生,简单来说就是类似定时触发的 timer 中断,只不过是 signal)。其中的 ctxt.pushCall 是系统相依的。对应到 src/runtime/signal_riscv64.go,可以看到以下片段

-const pushCallSupported = false
+const pushCallSupported = true
 
 func (c *sigctxt) pushCall(targetPC uintptr) {
-       throw("unimplemented")
+       // Push the LR to stack, as we'll clobber it in order to
+       // push the call. The function being pushed is responsible
+       // for restoring the LR and setting the SP back.
+       // This extra slot is known to gentraceback.
+       sp := c.sp() - sys.PtrSize
+       c.set_sp(sp)
+       *(*uint64)(unsafe.Pointer(uintptr(sp))) = c.ra()  // 下节补述 ... A
+       // Set up PC and LR to pretend the function being signaled
+       // calls targetPC at the faulting PC.
+       c.set_ra(c.pc())
+       c.set_pc(uint64(targetPC))

这个函式的目的是要让非同步抢占的 Goroutine 的呼叫堆叠看起来像是上从被抢占的 PC 位置 c.pc()呼叫到非同步抢占相关的函式 targetPC 去一样。为此,最主要需要操弄的 context 就包含堆叠指标(SP),返回位址(RISC-V 的 ra 暂存器,Golang 习惯称为 LR),以及程序指标(PC)。

Golang 的呼叫惯例在堆叠而非在暂存器上,所以第一段先将原本的 ra 存到挪出来的堆叠空间上去,并纪录新的堆叠位置。

这种移植工作如果全部都要从零开始的话,需要非常透彻的理解。但笔者在实作这个功能时,早有 ARM、MIPS、POWERPC 等同为 RISC 的架构可供参考,实际上也非常相似。

现在的 upstream 的 pushCall() 函式已经略有修改,新增了第二个传入参数 resumePC。这是因为在 Golang 1.15 时导入的可重启片段(Restartable Sequence)功能,所以应该要调整想要假造的返回位址,而并非直接是被抢占的位址。

後续执行

由於被抢占的 Goroutine 的 context 已经如上节描述的那般被调整,所以等到 Goroutine 回复之时,将会准备执行 runtime.asyncPreempt 函式;因为上述的 targetPC 都指到这个函式去。

runtime.asyncPreempt 本身是个系统相依的函式,并以组语实作,即是笔者的 patch 里面的 src/runtime/preempt_riscv64.s,大致上内容是

TEXT ·asyncPreempt(SB),NOSPLIT|NOFRAME,$0-0
+       MOV X1, -480(X2)    // ... B
+       ADD $-480, X2
+       MOV X3, 8(X2)
+       MOV X5, 16(X2)
...
+       MOVD F29, 456(X2)
+       MOVD F30, 464(X2)
+       MOVD F31, 472(X2)
+       CALL ·asyncPreempt2(SB)
+       MOVD 472(X2), F31
+       MOVD 464(X2), F30
+       MOVD 456(X2), F29
+       MOVD 448(X2), F28
+       MOVD 440(X2), F27
+       MOVD 432(X2), F26
...
+       MOV 480(X2), X1     // 恢复 ra 暂存器内容,参照 pushCall 注解 ... A
+       MOV (X2), X31       // 提取被抢占的程序指标 ... B
+       ADD $488, X2        // 恢复 sp 到 pushCall 之前的状态
+       JMP (X31)           // 回到当初被抢占前的位置

自动生成的组合语言片段

事实上,asyncPreempt 函式虽然系统相依,但逻辑大同小异,所以其实是自动生成的,相关的改动位在 src/runtime/mkpreempt.go 之中

 func genRISCV64() {
-       p("// No async preemption on riscv64 - see issue 36711")
-       p("UNDEF")
+       // X0 (zero), X1 (LR), X2 (SP), X4 (g), X31 (TMP) are special.
+       var l = layout{sp: "X2", stack: 8}
+
+       // Add integer registers (X3, X5-X30).
+       for i := 3; i < 31; i++ {
+               if i == 4 {
+                       continue
+               }
+               reg := fmt.Sprintf("X%d", i)
+               l.add("MOV", reg, 8)
+       }
+
+       // Add floating point registers (F0-F31).
+       for i := 0; i <= 31; i++ {
+               reg := fmt.Sprintf("F%d", i)
+               l.add("MOVD", reg, 8)
+       }
+
+       p("MOV X1, -%d(X2)", l.stack)
+       p("ADD $-%d, X2", l.stack)
+       l.save()
+       p("CALL ·asyncPreempt2(SB)")
+       l.restore()
+       p("MOV %d(X2), X1", l.stack)
+       p("MOV (X2), X31")
+       p("ADD $%d, X2", l.stack+8)
+       p("JMP (X31)")
 }

展开之後自然就会是上一段的样子了。

工具链部份:堆叠指标处理

当初笔者原本也以为只要把执行期的这些步骤串起来就可以了,但果然还是没那麽单纯。上述部份解决之後,执行相对应的测试,虽然测试项目可以被成功抢占,但整个程序却会在接近结束时接收到垃圾回收机制的抱怨,然後非正常中止。相关的发现纪录在笔者正式送出 patch 之前的 issue 回报

在该留言中,笔者也一并讨论该如何选择非同步抢占所必须破坏的暂存器。许多其他架构都采用 REGTMP,而笔者虽然原本在考虑挪用 LR,但後来还是从善如流。

在正式送出时,笔者其实将这些不同的功能分在不同的 commit。这个部份由於涉及到预处理器和组译器的行为,所以是放在另外一个 patch当中,但相关的讯息没有被捡到 Golang 的历史里面:

cmd/internal/obj/riscv: refine spadj field

The preprocess function calculates Spadj for each Prog purely based on
the stacksize of the function, which ignores frameless calls.  However,
if some frameless assembly function moves sp, the offset will not be
recorded correctly in pcln table, and thus it fails when executing
anything involving backtrace.

This is essential for async preempt support, because the injected call
asyncPreempt performs context save/restore on the stack.  It cannot
survive a runtime.GC() without correct sp information.

简单来说,Golang 的内部机制仰赖正确地维护堆叠指标来作回溯处理(backtrace),而在非同步抢占这个 patch 里面实作的组语部份又恰好会以非传统的方式挪动堆叠指标。堆叠指标所需要的关键变数在预处理器里面的名称即是 Spadj,意为堆叠指标所需要的调整值

相关的程序码内容为:

diff --git a/src/cmd/internal/obj/riscv/obj.go b/src/cmd/internal/obj/riscv/obj.go
index 73fe8c284f..6fcde2d67e 100644
--- a/src/cmd/internal/obj/riscv/obj.go
+++ b/src/cmd/internal/obj/riscv/obj.go
@@ -745,6 +745,12 @@ func preprocess(ctxt *obj.Link, cursym *obj.LSym, newprog obj.ProgAlloc) {
                        // count adjustments from earlier epilogues, since they
                        // won't affect later PCs.
                        p.Spadj = int32(stacksize)
+
+               case AADDI:
+                       // Refine Spadjs account for adjustment via ADDI instruction.
+                       if p.To.Type == obj.TYPE_REG && p.To.Reg == REG_SP && p.From.Type == obj.TYPE_CONST {
+                               p.Spadj = int32(-p.From.Offset)
+                       }
                }
        }

工具链部份之二:标记非安全点

同前所述,REGTMP 在这个过程中无可避免的必须被牺牲掉,这也表示如果 REGTMP 的内容正在被使用的话,Golang 的非同步抢占框架就不应该在该行指令生效。相关内容为:

@@ -1998,6 +2004,12 @@ func assemble(ctxt *obj.Link, cursym *obj.LSym, newprog obj.ProgAlloc) {
        for p, i := cursym.P, 0; i < len(symcode); p, i = p[4:], i+1 {
                ctxt.Arch.ByteOrder.PutUint32(p, symcode[i])
        }
+
+       obj.MarkUnsafePoints(ctxt, cursym.Func.Text, newprog, isUnsafePoint)
+}
+
+func isUnsafePoint(p *obj.Prog) bool {
+       return p.From.Reg == REG_TMP || p.To.Reg == REG_TMP || p.Reg == REG_TMP
 }

结论

从结果回头看所需要付出的心力其实远比做的时候少。笔者最一开始处理完执行期实作之後,一头撞上预处理器 Spadj 的问题,但当然当时是不知道的,只能够从垃圾回收机制吐出的讯息往回查证。

迂回道路总须先走过一次,才能够决定应该怎麽实作。之所以垃圾回收和回溯机制会回报错误讯息,是因为当经历过多个函式呼叫,以至於呼叫堆叠较深的时候,每一个函式在堆叠里的区间都需要明确的边界。在堆叠指标所在之处 0(sp) 的内容正是回传位址。

Golang 的执行档有一个特殊的 ELF section 叫做 gopclntab,里面记载以程序指标为索引,堆叠指标的变动值为内容的对照资料。追踪到後来发现是这个内容不合预期,才能够再进一步推知,是有些 PC 位置其实有堆叠指标变动,却没有被正确记录下来。行进至此,解决方案才算是有了曙光。

笔者现在只剩下一些当初除错时的零碎手稿,相关片段也已经不太确定具体的档案是哪几个了。

笔者认为之所以这种 bug 会存在,正是因为 RISC-V 世界里使用 Golang 的人还不多的缘故。我们在下一个旅途拾贝附录中会看到另外一个 bug,比此更加荒谬。

於是笔者就这样很幸运的捡到一个算得上是有功能开发、有意义,而且也不会复杂到无法驾驭的 issue,并将之实作出来送回上游,最後获得接受。虽然与 Hoddarla 专案本身没有直接的关系,但这个中途分心处理的过程也回头增进了笔者的信心。

第二个 patch:为 RISC-V 组合语言的编码提供修补

问题

最一开始是我发现,Golang 组合语言的「小於则跳转」控制指令

BLT A2, A0, xxx

透过 objdump 工具看是很合理的

blt a2,a0,xxx

但是,「小於等於则跳转」指令

BLE A1, A2, xxx

透过 objdump 工具看会变成

bge a1,a2,xxx

转换错了整个语意!我当然非常惊讶,但说实话,正常的 Golang 程序用到的组合语言程序码也就执行期初始化的寥寥数行,没有用到也是正常。

发起第一版贡献

cmd/asm, cmd/internal/obj/riscv: fix branch pseudo-instructions

BGT, BGTU, BLT, and BLTU implemented In CL 226397 were translated
into effectively opposite instructions due to the inversion of
registers.  This CL fixes the translation and the tests.

说起来,这个第一版真的很粗心。出问题的是 BLE 不是 BLT,所以这里描述错了。然後我也不确定是不是语意真的太模糊,管理者看到这一段描述,第一件事情竟然是反弹,说他觉得组合语言的语意解析顺序,不管是怎麽翻都有道理。

所以我就只好解释,重点不是我想要改动 Golang 组语在 RISC-V 的暂存器顺序,而是实际上不一致的行为已经发生了。他这才听我解释,并且询问说,编译器里面是否也有类似的错误需要修正?而我找了一下发现,这四道错误的指令,正常来说是不会被产生的,因此这完全是个组译器的问题。

这时,另外一个管理者直接插进来说,他已经准备了另外一个 CL,完善了这个部份的测试。

第二版尝试

拖了一个礼拜之後,我再询问更新状况。由於前一个完善指令测试的 CL 先进了,所以我必须重定基底。

第三版

这次轮到 riscv64 的自动测试机有点不灵光。到後来他们也没有回头厘清为什麽测试农场上的机器一直卡住,而是由管理者手动测试,确认没有造成其余的错误。

结果

这个 gerrit 页面,这个改动被收进 Golang 历史当中,虽然是个全世界除了我之外应该没有人在使用的一个功能。有了前次的经验之後,这次就简单很多了,除错的时间也比较少,反而比较多心力是要为了维护者先进的改动而重定基底,不过反正逻辑也并不复杂。

参考资料

  • Go: Asyncronous Preemption:这篇文章是在我做出贡献之後由 Vincent Blanchon 写成的,通篇描述的是非同步抢占的行为,而没有深入底层的细节。

结论

这两次的上游贡献经验都很有趣。第一次可以算是跨出舒适圈,第二次就自然有了心理上的余裕。总的来说,贡献 Golang 上游没有什麽让人不舒服的地方,但笔者确实是捡了便宜,毕竟 RISC-V 的使用者本来就不多。

给新手的提醒是,这套流程和 github 不太一样,但是有兴趣的话都可以直接加入 github 上的 issue tracking,看是要认领问题、参与讨论或是提出解法。但在那之前,行政上必须要签署 Google 的 CLA;技术上,则至少要知道开发与测试的流程为何才行。

各位读者,我们明天再会!


<<:  D23 - 「不断线的侏罗纪」:有一只小恐龙在跑步

>>:  Day22 Android - RxJava(Observer+Observable)

Day 19:「通通拿去做鸡精啦!」- Vue SFC

嗨大家~ 昨天有没有试着用 Creator 建立专案呢! 没有的话现在赶快去复习哦, 因为我们今天...

Day02 - 修改 Rails console edit 编辑模式

前言 在 rails console 中,若一次贴行数较多的 code 时,有时会失败,变成要逐段复...

cmd 指令

记录一些常用指令 dir 查看目前目录 md 建立资料夹/档案 rd 删除资料夹/档案 cd 移动位...

RISC-V: Memory Store 指令

昨天已经把 Memory Write 的功能做完了, 今天稍微轻松一点,就来完成 Memory St...

【前端效能优化】Lighthouse 网站效能检测工具

Lighthouse 一款在 Google Chrome 上安装的扩充套件,用来检测网站的效能,Li...