予焦啦!实作基本排程

本节是以 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 错误。显然还需要继续努力。各位读者,我们明日再会!


<<:  [DAY 17]Discord server串接webhook

>>:  actions 就是 Vuex 里「共用的 method」

【Day27】this - 简易呼叫(Simple Call)

简易呼叫(Simple Call) 当我们直接执行函式时,就是所谓的简易呼叫(Simple Call...

Day3-DotCloud? Docker?

问世间Docker为何物,直教DotCloud以死相许,所以Docker到底是虾毁?不仅让当时云端巨...

[Day 13]从零开始学习 JS 的连续-30 Days---Event 事件

Event 事件是什麽? Event 介面表示一个在 DOM 物件上所发生的事件。 一个事件可以是由...

Golang 切片slice与Map

Golang 切片(Slice) 我看很多中文的教学都是翻切片,我也不知道是不是正确的说法,总之也附...

【day3】和牛涮-和牛三吃

和牛涮最近很常出现在朋友的ig画面中 前阵子找时间到忠孝店品嚐 在价位方面 考量炙烧和牛寿司有数量限...