本節是以 Golang 上游 6a79f358069195e1cddb821e81fab956d9a0c7d1 為基準做的實驗
予焦啦!昨日我們觀察了 Golang 執行緒(以下以 M
代稱)在被系統呼叫創建真實的執行緒之前後,於 Golang 執行期分別做了哪些事情。今日我們就來試著將執行緒與基本的排程直接實作進來。
昨天的兩個範例裡面,首先 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
判斷的邏輯才是。以下,就先設法將這兩件事情都實作為能夠滿足的形式吧。
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
執行緒的上下文。
若否,表示這是個全新的執行緒。設置完 mstarted
與 life
這些由 opensbi/riscv 系統組合賦予的屬性(分別是設置為真,以及三格的生命值)之後,設置 next
執行緒的共常式 g
(mstart
函數的後續要求 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
)已經啟動,所以我們其實可以傳入一些它認得的環境變數,使它改變一點點行為。具體來說,我們修改 Makefile
中 run
項目的傳入參數,
- -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
錯誤。顯然還需要繼續努力。各位讀者,我們明日再會!