予焦啦!Golang 执行绪与作业系统执行绪

本节是以 Golang 上游 6a79f358069195e1cddb821e81fab956d9a0c7d1 为基准做的实验

予焦啦!昨日以前的第三章解决了计时器中断,今天我们将迈入以排程为主要目标的新章。过关斩将至此,已经不得不面对了。

有些读者可能会误以为昨日最後展示的结果当中,不断刷出的 i = n 讯息代表被计时器中断的次数,事实上这是笔者描述不清。那只是为了让 ethanol 的执行可以先不要撞到错误,而用无穷回圈卡住;不断刷出只是在展示,就算一直经历计时器中断,也能够因为正确的上下文储存、回复机制,持续回到无穷回圈里面,并继承正确的数字 n 执行下去。

本节重点概念

  • Golang
    • M 物件相关的重要方法(method)
  • 其他
    • 作业系统提供的创建执行绪页面

整理现状

最後一次之前,我们不得不卡在 newosproc 函数里面、踩到 panic 之前的无限回圈之中:

func newosproc(mp *m) {
        for {
                ...
        }
        panic("newosproc: not implemented")
}

若非我们使用无穷回圈将之暂停,这里的 panic 就将直接导致程序流程的终止了。至於此时的状态分析,强烈建议参考计时器中断之前的断章之一大致上来说,之所以会走到这里来,是因为 Golang 执行期的设计,需要一个监控者执行绪(monitor thread)。这里观测到的现象,只是最後的结果。

趁现在的机会,就介绍一些 Golang 的执行绪物件(m 结构)相关的方法(method),以下拆分成数小节。

生成 Golang 执行绪(m

欲了解 Golang 的执行绪生成过程,在需要系统呼叫之前已经完成哪些事情,请务必参考拙作。本小节只做部分补充。

回顾一下呼叫堆叠:

runtime.newosproc(...)                                                                          
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/os_opensbi.go:154
runtime.newm1(0xffffffcf0402a000)
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/proc.go:2244 +0x104
runtime.newm(0xffffffc000063cd8, 0x0, 0xffffffffffffffff)
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/proc.go:2223 +0x108
runtime.main.func1()
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/proc.go:175 +0x3c

allocm

主要的入口为 newm 函数。它最一开始的函数呼叫 allocm,於注解中说明自己的目的为:配置一个新的 m 物件,而这个物件尚未与作业系统执行绪扯上关系。

在配置 m 物件所需的记忆体空间(使用 new 这个内建的 Golang 函数),并设置一些 m 结构体内的成员之前,还偷偷安插了一段逻辑,用以处理已经标记为闲置的执行绪。这类执行绪平常被记录在 sched.freem 这一段列表之中(sched 是一个纪录排程器(scheduler)相关状态的大型结构)。注解中描述:这总得在每个时间点的某个地方执行,不如就在现在(要配置新的 m 物件时)做,或许还可以腾出空间给新的 m 使用。这个功能虽然并非主要目的、也就是配置相关的功能,但也是值得一提。

此外还有配置几个 m 结构内的成员,

  • mstartfn,也就是这个 mmstart 系列呼叫之後,应该执行的函数。
  • g0,主要的共常式,由 malg 函数配置。除了 new(g) 以配置结构体之外,也一并进行所需的堆叠的配置。
  • id,用来标记这个执行绪的 ID。
  • gsignal:通常在作业系统各自定义的档案中实作在 mpreinit 里面。

还有一个步骤,

// Add to allm so garbage collector doesn't free g->m
// when it is just in a register or thread-local storage.
mp.alllink = allm
...
atomicstorep(unsafe.Pointer(&allm), unsafe.Pointer(mp))

allm 是 Golang 的所有执行绪列表,将之赋值给予 mp.alllink,就是让新的这个执行绪(mp 是它的指标)以自己的 alllink 串起当前的列表开头;再将列表开头指向这个执行绪。之所以後者不能单纯赋值就好,与一些 Cgo(C 与 Golang 之间的介面)有关,目前不在 Hoddarla 探讨的范围。

newm1

runtime 组件中我们看到很多这种命名规则了:newm 前後有一些处理,核心是 newm1 函数。

在这函数之中,主角就是 newosproc 函数的呼叫,其前後有 execLock 的两个锁(lock)在保护着。execLock 是一个全域变数,型别为 rwmutex,是从 sync 组件里面复制出来修改为 runtime 组件所用的。这个同步用的资料结构可以允许多个执行绪竞争,能够容忍多个读取执行绪(rlock 上锁,runlock 解锁,也就是这里给予 newosproc 的保护),或是单一一个写入执行绪(lock 上锁,unlock 解锁)。

execLock 用来避免执行(exec)与复制(clone)的同时进行造成的怪问题。除了 runtime 这里的这些使用,还有 syscall 组件里面,相当於 Unix 作业系统的 execve 呼叫的 Exec 函数里面也有使用到。

至此,是执行绪抽象物件由作业系统真正生成之前,Golang 的处置

执行绪启动後的 mstart

本小节补述断章 中的一些内容。

如前所述,在 newm 启动生成执行绪流程之後,交付这项任务给作业系统之前,Golang 执行期预先指定的执行绪入口函数,即是 mstart

理想上,作业系统执行绪生成之後,应该会想办法来到 mstart 之中,并附带有 Golang 的诸般条件,如共常式的设置等等。

但这不是我们第一次接触到 mstart。更早之前,在 rt0_go 的组合语言码之中,这个 mstart 就已经用来生成 m0 了。先从概念上理解这件事情:m0 是 Golang 执行期最初期就已经存在的特殊执行绪,它所需的记忆体已经规划在初值为 0 的 BSS 区域之中,所以,这样的 m0 物件,当然没有必要由 newm 这样的入口函数来动态配置初始化。

src/runtime/asm_riscv64.s 当中,关於 mstart 函数的部分是 rt0_go 的尾声与它的本体:

...
        // start this M
        CALL    runtime·mstart(SB)

        WORD $0 // crash if reached
        RET

TEXT runtime·mstart(SB),NOSPLIT|TOPFRAME,$0
        CALL    runtime·mstart0(SB)

在其它的组合语言档案也能看到,这个 mstart 只是一个转运站,之後会导向位於 src/runtime/proc.gomstart0,这就是通用的 Golang 实作了。

mstart0mstart1

主要是些堆叠的初始设置。若是有指定 mstartfn,则会呼叫进去,但如果没有的话(如 m0 的情况),就会执行到我们先前也有简单一瞥的 schedule 函数,将会试图取得其他的共常式来执行。没有其他意外的话,进入 schedule 之後就不会再回归了。

这个部分有 g.sched 的设定,与之前我们在断章当中追踪的部分类似,也是在调整堆叠的呈现。

newmmstart 之间

在这两者之间的,当然就是我们已经烦恼一阵子了的 newosproc,以及它如何呼叫作业系统服务,以生成新的执行绪。

简单浏览之後可以发现,就算在类 Unix 家族里面,也有三种以上不同的做法:

我们接下来稍微深入两组系统组合,看看其中的奥秘如何。

linux/riscv64 系统组合

这个系统组合走得稍微迂回一点,在 src/runtime/sys_linux_riscv64.s 之中,提供的组合语言函数和实际上的 clone 呼叫不太匹配。clone 系统呼叫所要求的参数之外,传入的 mpgp 比较像是只对 Golang 有意义的内容。这些特殊参数在系统呼叫之前,被塞到 A1 的堆叠之中;事实上,这被预期是新执行绪的堆叠

// func clone(flags int32, stk, mp, gp, fn unsafe.Pointer) int32
TEXT runtime·clone(SB),NOSPLIT|NOFRAME,$0
        MOVW    flags+0(FP), A0
        MOV     stk+8(FP), A1

        // Copy mp, gp, fn off parent stack for use by child.
        MOV     mp+16(FP), T0
        MOV     gp+24(FP), T1
        MOV     fn+32(FP), T2

        MOV     T0, -8(A1)
        MOV     T1, -16(A1)
        MOV     T2, -24(A1)
        MOV     $1234, T0
        MOV     T0, -32(A1)

        MOV     $SYS_clone, A7
        ECALL

而且还多塞了一个 1234 数值到堆叠中,之所以需要这麽做,是要做一个心智检查(sanity check,看到这个译名,基本上我可以断言国家教育研究院已经没有认真在做翻译了,向各位读者说声抱歉),确认整个复制流程没有出错。

在系统呼叫成功之後,这个流程回传之後分岔为两个执行绪,所以需要依其回传值检查:

        // In parent, return.
        BEQ     ZERO, A0, child
        MOVW    ZERO, ret+40(FP)
        RET

child:
        // In child, on new stack.
        MOV     -32(X2), T0
        MOV     $1234, A0
        BEQ     A0, T0, good
        WORD    $0      // crash

针对子执行绪的部分,只要能够从堆叠上取回 1234 数值没问题,就当作是复制成功了。执行到 good 标签去:

good:
        // Initialize m->procid to Linux tid
        MOV     $SYS_gettid, A7
        ECALL

        MOV     -24(X2), T2     // fn
        MOV     -16(X2), T1     // g
        MOV     -8(X2), T0      // m

        BEQ     ZERO, T0, nog
        BEQ     ZERO, T1, nog

        MOV     A0, m_procid(T0)

        // In child, set up new stack
        MOV     T0, g_m(T1)
        MOV     T1, g

nog:
        // Call fn
        JALR    RA, T2
        // It shouldn't return.  If it does, exit this thread.
        MOV     $111, A0
        MOV     $SYS_exit, A7
        ECALL
        JMP     -3(PC)  // keep exiting

依序从堆叠取回 Golang 需要的入口函数(fn)、共常式(g)与执行绪物件(m)之後,因为需要设置 gm 的属性,所以针对两者传入空值(nil)的状况,就直接跳到呼叫入口函数的 nog 标签去。

freebsd/arm64 系统组合

作为参考,我们也观察一下这个组合。

值得一提的是,thr_new 这个系统呼叫的手册上面说,正常的应用程序应该还是要使用 pthread_create 这种标准的执行绪介面。显然 Golang 执行期不是什麽正常的应用程序。

thr_new 的两个参数分别是一个 struct param 结构以及其大小。在 src/runtime/os_freebsd.go 里面使用起来像是这个样子:

...
        param := thrparam{
                start_func: abi.FuncPCABI0(thr_start),
                arg:        unsafe.Pointer(mp),
                stack_base: mp.g0.stack.lo,
                stack_size: uintptr(stk) - mp.g0.stack.lo,
                child_tid:  nil, // minit will record tid
                parent_tid: nil,
                tls_base:   unsafe.Pointer(&mp.tls[0]),
                tls_size:   unsafe.Sizeof(mp.tls),
        }

        var oset sigset
        sigprocmask(_SIG_SETMASK, &sigset_all, &oset)
        ret := thr_new(&param, int32(unsafe.Sizeof(param)))
        sigprocmask(_SIG_SETMASK, &oset, nil)
...

与其它的作业系统大同小异,关键的系统呼叫前後都有 sigprocmask 在保护这个过程不受非同步的讯号影响。又,这个 param 结构体里面,预先填入了三项我们最关心的内容:执行绪物件的指标(mp)置放於 arg 成员变数内;堆叠的起始位址与大小分别放在 stack* 成员变数中,其内容提取自 mp.g0.stack;新执行绪的入口函数给定为 thr_start

参考 src/runtime/sys_freebsd_arm64.s 里面的 thr_new 包装函式(wrapper function),这是呼叫流程的下一站:

// func thr_new(param *thrparam, size int32) int32
TEXT runtime·thr_new(SB),NOSPLIT,$0
        MOVD    param+0(FP), R0
        MOVW    size+8(FP), R1
        MOVD    $SYS_thr_new, R8
        SVC
        BCC     ok
        NEG     R0, R0
ok:
        MOVW    R0, ret+16(FP)
        RET

结果这是毫不罗唆的呼叫了系统呼叫(ARM64 里面使用的是 SVC 指令),再下一站,则是 FreeBSD 系统保证的,新的执行绪可以直接从入口函数开始执行。(这与 Linux 更类似 fork 的行为不同,在上一节里面我们展示了,入口函数的跳转是由 Golang 执行期自己进行的。)

所以下一站是,thr_start,位在同一个档案之中,

        // set up g
        MOVD    m_g0(R0), g
        MOVD    R0, g_m(g)
        BL      emptyfunc<>(SB)  // fault if stack check is wrong
        BL      runtime·mstart(SB)

        MOVD    $2, R8  // crash (not reached)
        MOVD    R8, (R8)
        RET

果然,还是得让共常式暂存器 g 设置起来才行,之後还是跳转到 mstart 开始。

其他相关方法

mexit

如果是 m0 进到这个函数的话,Golang 实作上让它卡住,以避免它成为作业系统难以处理的殭屍。之後,也有各作业系统自行定义的 muninit 函数,可以释放一些在 minit 配置的资源。

但所谓的释放,不是呼叫某个复杂的 free 函数去通知记忆体管理系统,而是将相关的指标清空,让之後触发的垃圾回收机制去清理。

        // Remove m from allm.
        lock(&sched.lock)
        for pprev := &allm; *pprev != nil; pprev = &(*pprev).alllink {
                if *pprev == m {
                        *pprev = m.alllink
                        goto found
                }
        }

之後,透过这一个遍历 allm 的回圈,取得当前的执行绪,之後会再呼叫到作业系统自行定义的 mdestroy,做更彻底的资源释放。

Linux 的话,muninit 释放了一些讯号相关的东西,而 mdestroy 则是留空的。

小结

予焦啦!今天浏览了 Golang 的 m 结构体,以及它为什麽能够大致上与作业系统所提供的执行绪牵扯上关系。我们走访了与 m 相关的函数,从作业系统插手之前的 newm 系列,到作业系统生成之後、让新生执行绪进入到 Golang 的境界的 mstart 系列。最後我们也带出几个释放 m 结构的函数。

针对两者中间的部分,由於 Hoddarla/ethanol 也是以成为作业系统核心为目标,所以当然不能忽略。我们挑选了两种系统组合,浏览过它们使用的执行绪系统呼叫之後,我们可以深切地感受到,作业系统是如何提供一个功能强大的抽象层,来让使用者空间透过简单的准备(堆叠设置、记忆体读写)与模式的转换(RISC-V 的 ECALL 或 ARM 的 SVC),就可以安心让作业系统帮忙安排资源的调配。

无论如何,各位读者,我们明天再会!


<<:  [DAY16] 实战 Azure Machine Learning RBAC

>>:  Day 17 跟着官方文件学习Laravel-再战测试

语法糖小测验: Scope functions & Extension function

最近补课的模式有了改变,大部分时间都是诗忆读着讲义,遇到问题或是想要学得更深入的时候再和唯心讨论。 ...

04

蒙特兰在过去曾经讲过,在别人藐视的事中获得成功,是一件了不起的事,因为它证明不但战胜了自己,也战胜了...

建立你想要的文化(2)- 定义价值观

先从参考开始 我们可以先从参考一些优秀企业的价值观开始,这里有几个例子,它们都给了我很多启发,给你...

[Day 17] 轻量化的梯度提升机 - LightGBM

LightGBM 今日学习目标 LightGBM 与 XGBoost 比较 了解 LightGBM ...

转行的探索跟可以闪开的思考误区

早起运动30分钟Day1 今天一边运动,一边听《转行》这本书。里面提到几个我喜欢的观点。 “我们都以...