iT邦幫忙

2021 iThome 鐵人賽

DAY 28
2

今天是 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;技術上,則至少要知道開發與測試的流程為何才行。

各位讀者,我們明天再會!


上一篇
予焦啦!基本的命令列
下一篇
予焦啦!附錄:詭異的時間中斷(timer interrupt)擱置位元(pending bit)
系列文
予焦啦!Hoddarla 專案起步:使用 Golang 撰寫 RISC-V 作業系統的初步探索33
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言