iT邦幫忙

2021 iThome 鐵人賽

DAY 23
3
Software Development

予焦啦!Hoddarla 專案起步:使用 Golang 撰寫 RISC-V 作業系統的初步探索系列 第 23

予焦啦!實作基本排程

本節是以 Golang 上游 6a79f358069195e1cddb821e81fab956d9a0c7d1 為基準做的實驗

予焦啦!昨日我們觀察了 Golang 執行緒(以下以 M 代稱)在被系統呼叫創建真實的執行緒之前後,於 Golang 執行期分別做了哪些事情。今日我們就來試著將執行緒與基本的排程直接實作進來。

本節重點概念

  • Hoddarla/ethanol
    • 初期的基本排程

延伸昨天的觀察

昨天的兩個範例裡面,首先 freebsd/arm64 系統組合的 thr_new 我們不好參考。它太方便了,能夠讓新的執行緒在回到使用者空間的時候直接從指定的函數開始。我們(opensbi/riscv64)無論如何不可能做到這件事情。

那麼,linux/riscv64 系統組合的 clone 又如何?在組合語言實作的 clone 包裝函數之中,父執行緒在系統呼叫之前,會幫忙將重要的資訊先行塞到即將被創造的子執行緒的堆疊之中。雖然 clone 的服務沒有那麼周到,但至少堆疊是設定好的,所以回到使用者空間之後,也立刻就能夠使用堆疊當中的內容。

筆者這裡的判斷是,我們就讓 newosproc 沒有什麼真正的作用吧。反正沒有任何底層的系統能夠提供我們這種服務。可是,Golang 本身在 newm 以降的過程當中,早已將新的 M 設立完畢,並將之置放到全執行緒列表的 allm 裡面了。

但是,執行緒間的並行(concurrency)仍然應該要設法解決,否則整個 Golang 執行期就只會是 m0 的千里獨行。是的,所以筆者打算正式來實作上下文交換了。

實作概念

目前的 ethanol_trap 只處理一種中斷,也就是前一章安置的時間中斷。而且若是讀者諸君仔細觀察不難發現,現在的寫法非常粗暴:

// func ethanol_trap()
TEXT runtime·ethanol_trap(SB),NOSPLIT|TOPFRAME,$0
...
        CSRRW   CSR_SCAUSE, ZERO, T6
        MOV     T6, 0xF8(SP)
        CSRRW   CSR_SEPC, ZERO, T6
        MOV     T6, 0x100(SP)
        CSRRW   CSR_STVAL, ZERO, T6
        MOV     T6, 0x108(SP)

        // TODO: setup g for the handling
        CALL    runtime∕ethanol·SetTimer(SB)
        // TODO: reset sscratch for user space
        MOV     0x100(SP), T6
        CSRRW   CSR_SEPC, T6, ZERO
...

在進入後儲存上下文的階段,與離開前回復上下文的階段兩者之間,只有一個呼叫,用來設定下一次的計時器中斷。以兩個層面來說,這麼做是不夠的:

  • 進入到 stvec 的陷阱處理並非只有計時器中斷。還有其他種類的中斷或是例外狀況,理論上也都會進入這裡。ethanol 核心總是會漸漸增加功能,所以這裡也至少該引入針對 scause 判斷的邏輯才是。
  • 另外一個則是,這裡沒有讓我們實行執行緒之間的上下文交換(context switch)的邏輯。所以就算計時器中斷來來去去,陷阱處理之中也沒有邏輯能夠觸發上下文交換,並將當前 CPU 的使用權交付給另外一個執行緒。

以下,就先設法將這兩件事情都實作為能夠滿足的形式吧。

清空 newosproc

雖說是清空,但考量到 linux/riscv64 的 clone 包裝函數(wrapper function)裡面有一個敘述,使用 linux 核心的 gettid 系統呼叫,取得作業系統核心內的執行緒 ID,以賦值與 m.procid。這裡我們也沒有相對應的系統服務可用,但若要簡單賦予 Golang 執行緒之間可區分的 ID,筆者這裡先直接將之令為 mp 本身:

 func newosproc(mp *m) {
-       for {
-               var i, j int
-               i = 16
...
+       // Do nothing.
+       // mp is already registered in allm, so we can switch to it
+       // later anyway.
+       mp.procid = uint64(uintptr(unsafe.Pointer(mp)))
-       panic("newosproc: not implemented")

 }

試著執行

若是執行這個簡單的修改版,則會遭遇新的錯誤:

fatal error: nanotime returning zero

goroutine 1 [running, locked to thread]:
runtime.throw({0xffffffc0000612e7, 0x17})
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/panic.go:965 +0x60 fp=0xffffffcf040267a8 sp=0xffffffcf04026780 pc=0xffffffc00002ec70
runtime.main()
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/proc.go:196 +0x11c fp=0xffffffcf040267d8 sp=0xffffffcf040267a8 pc=0xffffffc000030d44
runtime.goexit()
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/asm_riscv64.s:494 +0x4 fp=0xffffffcf040267d8 sp=0xffffffcf040267d8 pc=0xffffffc00005100c

這肇因於先前在打通工具鏈與相依性的時候,將許多函數都直接以空函數的形式引入進來。nanotime1 就是其中之一。要正規的解決這個 API 的話,需要再解析 timer 裝置,並解析其頻率,再使用虛擬狀態暫存器 TIME 來計算得到真正的奈秒數才對。

為何說 TIME 是虛擬的狀態暫存器?因爲真正的時間資訊來自 mtime,但作業系統模式已經無法存取機器模式的控制或狀態暫存器了;上週實作 SetTimer 函數時,我們曾提到這是 mtime 的別名。

但這裡筆者打算先暴力地將之繞過。首先,移除 src/runtime/os_opensbi.go 內的 nanotime1 函數(由於 walltime 函數也很類似,就一併移除)

+func nanotime1() int64
-func nanotime1() int64 {
-       return 0
-}
-
+func walltime() (sec int64, nsec int32)
-func walltime() (sec int64, nsec int32) {
-       return
-}

然後在 src/runtime/sys_opensbi_riscv64.s 之中引入:

+
+TEXT runtime·nanotime1(SB),NOSPLIT,$0
+        CSRRS   CSR_TIME, X0, T6
+        MOV     T6, ret+0(FP)
+        RET
+
+TEXT runtime·walltime(SB),NOSPLIT,$0
+        CSRRS   CSR_TIME, X0, T6
+        MOV     T6, ret+0(FP)
+        RET

linux/riscv64 系統組合裡面,兩者確實是共用 clock_gettime 這個系統呼叫。nanotime1 使用的是 CLOCK_MONOTONIC 單調計時,而 walltime 使用的是 CLOCK_REALTIME 真實時間計時。

試著執行之二

還是遇到了新的問題,

fatal error: stoplockedm: not runnable
runtime stack:
runtime.throw({0xffffffc00009858a, 0x19})
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/panic.go:965 +0x60
runtime.stoplockedm()
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/proc.go:2600 +0x2c0
runtime.schedule()
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/proc.go:3292 +0x40
runtime.park_m(0xffffffcf040001a0)
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/proc.go:3509 +0x198
runtime.mcall()
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/asm_riscv64.s:279 +0x48
        ...

但筆者決定先忽略這個問題。我們可以先將觸發這個問題的地方卡住,就像先前我們以無窮迴圈卡住 newosproc 一樣。然後,由於目前為止已經至少有兩個 M 的存在,我們還是可以實作出排程,來檢驗我們是否能夠觀測到 m0 與新的 M 的上下文成功交換。

雖然上面省略了完整的錯誤回溯,但問題點發生在稍早、我們已經用暴力方式打通的 nanotime1 的使用之後,發生在 src/runtime/proc.go 呼叫 gcenable 函數之內。所以我們可以先以無窮迴圈卡住流程如下:

diff --git a/src/runtime/proc.go b/src/runtime/proc.go
index 197441dfa7..6605b0516b 100644
--- a/src/runtime/proc.go
+++ b/src/runtime/proc.go
@@ -211,6 +211,8 @@ func main() {
                }
        }()
 
+       for GOOS == "opensbi" {
+       }
        gcenable()
 
        main_init_done = make(chan bool)

這個卡住的迴圈千萬要記得加上這個作業系統為 opensbi 的條件,否則編工具鏈時,也會讓工具鏈的那些執行檔卡在這裡。

實作

這個小節按時間順序帶出 ethanol 核心在上下文交換的設計,也就是依序回答下列問題:從 M 遭到計時器中斷起算,如何判斷執行緒是否該進行上下文交換了?若是,則該如何進行?欲交接的下一個執行緒若是新生成的執行緒,該如何另外處理?

計時器中斷的改寫

如前述,我們先將陷阱處理的部分做得正式一點:

...
        MOV     T6, 0x108(SP)
 
-       // TODO: setup g for the handling
-       CALL    runtime∕ethanol·SetTimer(SB)
+       MOV     (g_m)(g), g
+       MOV     (m_gsignal)(g), g
+       MOV     SP, -0x08(SP)
+       ADD     $-0x10, SP, SP
+       CALL    runtime·ethanol_trap1(SB)
+       ADD     $0x10, SP, SP
        // TODO: reset sscratch for user space
...

移除掉直接設置計時器的部分,然後把先前欠的共常式(g)設置做成原先打算的(g.m.gsignal)。這麼一來,後續的呼叫都會以 gsignal 共常式的身份繼續執行。

又,接下來我們挪動堆疊,將原先的堆疊指標當作一個參數傳入。這裡是遵守一般的 Golang 函數 ABI,所以第一個函數在傳入時的堆疊的下一個位置(8(SP)),並在返回時將堆疊指標回歸。

這裡我們呼叫了 ethanol_trap1,實作在 src/runtime/os_opensbi.go 當中:

func ethanol_trap1(ctxt *rv64ctxt) {
        _g_ := getg()

        print("ethanol_trap1 ", _g_.m, "\n")
        if (ctxt.scause&uint64(0x8000000000000000))>>63 != 0 {
                // interrupt
                ctxt.scause &= 0xF
                switch ctxt.scause {
                case TIMER_INTERRUPT:
                        ethanol.SetTimer()
                        _g_.m.life -= 1
                default:
                        throw("cannot handle other traps now")
                }
        } else {
                // exception
                switch ctxt.scause {
                default:
                        throw("unexpected exception")
                }
        }

        if _g_.m.life == 0 {
                _g_.m.life = 3
                mswitch()
        }
 ...

這個函數的傳入參數其實只是前一段堆疊的內容,但是為了在 Golang 裡面解析,我們另外宣告了一個結構體(rv64ctxt)來存取。其中包含所有通用暫存器與我們所關注的三個狀態暫存器,對應到稍早 ethanol_trap 當中的順序。此處略過該結構的宣告。

根據先前所儲存的 scause 狀態暫存器,先做最有效位元(most significant bit)的判斷。當這個位元是 1 時,表示進入陷阱處理的原因是中斷;反之,則為例外事件

所以這裡我們僅在計時器中斷時才重新設置。除此之外都先以 throw 的錯誤卡住。

這裡有一個新增的 life 屬性,它代表的意思是,每個執行緒的生命週期中能夠遭到計時器中斷的次數。這個屬性新增在作業系統定義的 mOS 當中,且初值目前設定為 3。當時間到了之後,原執行緒先恢復生命值,然後進行上下文交換,呼叫 mswitch

上下文交換

mswitch 以 Golang 實作,

func mswitch1(prev, next *m)
func mswitch() {
        _g_ := getg()
        var next *m
        for mp := allm; mp != nil; mp = mp.alllink {
                if mp == _g_.m {
                        next = _g_.m.alllink
                        break
                }
        }
        if next == nil {
                next = allm
        }

        mswitch1(_g_.m, next)
}

這裡採用的主要邏輯與 newm 時看到的類似,我們也是遍歷所有執行緒,找到當前的執行緒在列表中的位置之後,再設置下一個執行緒 next。之後牽涉到上下文處理,因此我們又得回到組合語言部分。

讀者是否會疑問,為什麼這裡手續繁多,卻不怕被計時器中斷又打進來?這是因為,在中斷而進入到陷阱處理之時,系統已經改變了狀態。具體來說,sstatus 控制暫存器中的先前中斷狀態(SPIE,Supervisor Previous Interrupt Enable)位元會儲存這次中斷之前的中斷啟用位元(SIE,Supervisor Interrupt Enable)的狀態,並將當前SIE 設置為 0。

另一個可能的疑問是,為什麼在陷阱處理向量前後已經儲存與回復過上下文,而這裡還要另外處理?因為前者是正常的流程被中斷或是例外強迫進到陷阱處理向量,所以需要保存狀態;後者則是,在陷阱處理向量之後又已經走了一段路,因為排程的因素而與其他執行緒交換上下文,日後交換回來之後、離開陷阱向量之前,或許也還有其他事情要由作業系統執行。所以這兩者無法互相取代。

mswitch1 定義在 src/runtime/sys_opensbi_riscv64.s 之中:

+// func mswitch1(prev, next *m)
+TEXT runtime·mswitch1(SB),NOSPLIT|NOFRAME,$0-16
+       MOV     prev+0(FP), A0
+       MOV     next+8(FP), A1
+ 儲存部分,省略
+       MOVB    (m_mOS+mOS_mstarted)(A1), A2
+       BEQ     A2, ZERO, new
+       JMP     restore
+new:
+       MOV     $1, A2
+       MOVB    A2, (m_mOS+mOS_mstarted)(A1)
+       MOV     $3, A2
+       MOVB    A2, (m_mOS+mOS_life)(A1)
+       MOV     (m_g0)(A1), g
+       MOV     (g_stack+stack_hi)(g), SP
+       MOV     $runtime·mstart(SB), A0
+       CSRRW   CSR_SEPC, A0, ZERO
+       SRET
+restore:
+ 回復部分,省略
+       RET

主要的邏輯是,判斷 next 執行緒是否已經開始。這裡我們也是透過一個 mOS 結構中的 mstarted 布林變數來記錄這個狀態。若已開始,則可以直接著手回復 next 執行緒的上下文。

若否,表示這是個全新的執行緒。設置完 mstartedlife 這些由 opensbi/riscv 系統組合賦予的屬性(分別是設置為真,以及三格的生命值)之後,設置 next 執行緒的共常式 gmstart 函數的後續要求 g0 共常式,所以這裡如此設置。)與堆疊所代表的暫存器。這是在模擬類似 clone 的效果。

最後,就是設置新生執行緒的起始函數,返回到 mstart 去。注意這裏是透過 SRET 指令回到被中斷前的狀態,而非再返回陷阱向量去回復上下文。若是後者的話,會變成回復 prev 執行緒的狀態,產生邏輯錯誤。

執行

以下的範例已更新到 Hoddarla repo

試著運行看看吧!則會看到

...
ethanol_trap1 0xffffffcf0002a000
ethanol_trap1 0xffffffcf0002a000
ethanol_trap1 0xffffffcf0002a000
ethanol_trap1 0xffffffc0000ac200
ethanol_trap1 0xffffffc0000ac200
ethanol_trap1 0xffffffc0000ac200
ethanol_trap1 0xffffffcf0002a000
ethanol_trap1 0xffffffcf0002a000
ethanol_trap1 0xffffffcf0002a000
ethanol_trap1 0xffffffc0000ac200
ethanol_trap1 0xffffffc0000ac200
ethanol_trap1 0xffffffc0000ac200
ethanol_trap1 0xffffffcf0002a000
ethanol_trap1 0xffffffcf0002a000
ethanol_trap1 0xffffffcf0002a000
ethanol_trap1 0xffffffc0000ac200
ethanol_trap1 0xffffffc0000ac200
ethanol_trap1 0xffffffc0000ac200
...

的輸出結果,確實可以觀察到三次計時器中斷交換一次的狀態。結果相當符合預期!

附加的實驗

本節實驗不包含在上傳的新增之中。有興趣的讀者需搭配簡單的程式碼修改:一行組語裡面的一個字元,以及 Makefile 裡面修改一行指令,總共兩行不到 20 個字元。

也許超過一秒一次的計時器中斷還是太不真實了。我們可以回到 src/runtime/ethanol/trap.s 之中修改 SetTimer 函數,

 TEXT runtime∕ethanol·SetTimer(SB), NOSPLIT|NOFRAME, $0-0
        CSRRS   CSR_TIME, ZERO, A0
-       ADD     $0x1000000, A0, A0
+       ADD     $0x100000, A0, A0

當然,這個頻率,比照一般的作業系統使用習慣,也還是太低,不過現在當然還沒什麼關係。再來,筆者也想再展示一個功能。先前我們在斷章當中設定了環境變數,但一直沒有去使用它們。現在,由於 Golang 的系統監控執行緒(sysmon)已經啟動,所以我們其實可以傳入一些它認得的環境變數,使它改變一點點行為。具體來說,我們修改 Makefilerun 項目的傳入參數,

-               -append "ethanol arg1 arg2 env1=1 env2=abc" \
+               -append "ethanol arg1 arg2 GODEBUG=schedtrace=1" \

如此一來就可以看到其他的除錯訊息輸出,如下:

ethanol_trap1 0xffffffc0000ac200
ethanol_trap1 0xffffffc0000ac200
ethanol_trap1 0xffffffc0000ac200
SCHED 0ms: gomaxprocs=1 idleprocs=0 threads=2 spinningthreads=0 idlethreads=0 runqueue=0 [0]
SCHED 0ms: gomaxprocs=1 idleprocs=0 threads=2 spinningthreads=0 idlethreads=0 runqueue=0 [0]
ethanol_trap1 0xffffffcf0002c000
SCHED 1ms: gomaxprocs=1 idleprocs=0 threads=2 spinningthreads=0 idlethreads=0 runqueue=0 [0]
SCHED 2ms: gomaxprocs=1 idleprocs=0 threads=2 spinningthreads=0 idlethreads=0 runqueue=0 [0]
ethanol_trap1 0xffffffcf0002c000
SCHED 2ms: gomaxprocs=1 idleprocs=0 threads=2 spinningthreads=0 idlethreads=0 runqueue=0 [0]
SCHED 3ms: gomaxprocs=1 idleprocs=0 threads=2 spinningthreads=0 idlethreads=0 runqueue=0 [0]
ethanol_trap1 0xffffffcf0002c000
ethanol_trap1 0xffffffc0000ac200
ethanol_trap1 0xffffffc0000ac200
ethanol_trap1 0xffffffc0000ac200
SCHED 6ms: gomaxprocs=1 idleprocs=0 threads=2 spinningthreads=0 idlethreads=0 runqueue=0 [0]
SCHED 7ms: gomaxprocs=1 idleprocs=0 threads=2 spinningthreads=0 idlethreads=0 runqueue=0 [0]
ethanol_trap1 0xffffffcf0002c000
...

然而,一瞬間之後,卻會發生瘋狂的錯誤輸出如下:

...
runtime.dopanic_m(0xffffffcf040004e0, 0xffffffc000030738, 0xffffffcf0403be08)
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/panic.go:1168 +0x2e4
runtime.fatalthrow.func1()
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/panic.go:1020 +0x5c
runtime.fatalthrow()
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/panic.go:1017 +0x4c
runtime.throw({0xffffffc000066608, 0x24})
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/panic.go:965 +0x60
runtime.newstack()
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/stack.go:955 +0xd48
runtime.morestack()
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/asm_riscv64.s:201 +0x70
fatal error: unlock of unlocked lock

runtime stack:
runtime.throw({0xffffffc0000650e0, 0x17})
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/panic.go:965 +0x60
runtime.unlock2(0xffffffc0000daa90)
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/lock_opensbi.go:53 +0x90
        ...

小結

予焦啦!今天也算是走了頗長的一段路!我們以 Golang 的 M 物件充當執行緒的單位,試圖啟用一套基本的排程機制。對於新生成的 M 物件,我們主要關注它使用的共常式與堆疊,針對性地初始化;其餘的 M 物件,我們則是簡單的儲存與回復

乍看之下一切都運行得很好,但稍微讓執行狀態複雜一點點之後,立刻出現大量的 unlock of unlocked lock 錯誤。顯然還需要繼續努力。各位讀者,我們明日再會!


上一篇
予焦啦!Golang 執行緒與作業系統執行緒
下一篇
予焦啦!Golang 執行期的鎖
系列文
予焦啦!Hoddarla 專案起步:使用 Golang 撰寫 RISC-V 作業系統的初步探索33

尚未有邦友留言

立即登入留言