予焦啦!Golang 执行期的锁

予焦啦!我们昨日实作完简易排程,确保 Golang 执行绪(M)都会被排到 CPU 资源。但是却有不定时炸弹会出现,那就是试图解锁非上锁的锁的错误;大部分时候都运行良好,但偶尔这个错误会自己发生,又或者像是昨日最後的附加实验那样,调整计时器中断的频率或是增加其它系统活动,都可能会看到这个问题:

fatal error: unlock of unlocked lock

在深入研究之前,我们得先检视 Golang 执行期的锁是怎麽回事。尤其是,我们现在使用的锁是怎麽来的?

本日重点概念

  • Golang
    • 锁(lock)的实作
  • 常识
    • 旗号(semaphore)
    • 快速使用者空间互斥锁(futex, fast userspace mutex)

Golang 执行期的锁

爬梳一下发现,我们当初在参赛第 3 天,产出可执行档的过程中,lockunlock 这两个一度是非定义符号,但我们强行引用 src/runtime/lock_js.go 将之解消了。

然而,若是让我们浏览一下 js/wasm 系统组合的这个档案内容,不难发现一些注解,似乎给予我们迟来的警告:

// js/wasm has no support for threads yet. There is no preemption.
...
func lock2(l *mutex) {
        if l.key == mutex_locked {
                // js/wasm is single-threaded so we should never
                // observe this.
...

显然这个系统组合只支援单一执行绪,所以很可能它的锁的实作,会有一些对於多执行绪系统行为来说过强的假设。说得更白话就是,当初 opensbi/riscv64 选择用 lock_js.go 来逃避相关实作,现在可能要认赔杀出啦。

笔者承认,笔者阅读完 js/wasm 这里的实作之後,还是想不通出现 unlock unlocked lock 的错误是怎麽回事。也许是这里仗着单一执行绪的特性,所以在 lock/unlock 函数当中都只是加加减减,而没有牵涉到任何原子性操作的缘故?这些都只是臆测,笔者不想再花力气在这一组锁的实作上了。

那麽,下一步该何去何从?老样子,先参考其他 Golang 所支援的系统组合,都是如何实作它们所使用的锁。

$ grep 'func lock' -R src/runtime/ | grep linux
$ grep 'func lock' -R src/runtime/ | grep freebsd
$

空空如也!执行期之大,难道 Linux 和 FreeBSD 却没有一个锁的机制吗?原来是多虑了。事实上,关於锁的实作,除了比较特立独行的 js/wasm 系统组合之外,只有两个阵营:

$ grep 'func lock(' -R src/runtime/
src/runtime/lock_futex.go:func lock(l *mutex) {
src/runtime/lock_sema.go:func lock(l *mutex) {
src/runtime/lock_js.go:func lock(l *mutex) {
src/runtime/lock_opensbi.go:func lock(l *mutex) {

一个是快速使用者空间互斥锁(futex),另一个则是旗号(semaphore)。前者支援的作业系统是两款 BSD 与 Linux:

//go:build dragonfly || freebsd || linux

後者则是几款其他的类 Unix 作业系统与 Plan9 与 Windows:

//go:build aix || darwin || netbsd || openbsd || plan9 || solaris || windows

快速使用者空间互斥锁

Golang 的介面:src/runtime/lock_futex.go

最一开始,就有一段注解在描述,作业系统应该实作的部分。实际上还蛮少的,只有两个(中文是笔者的翻译):

// This implementation depends on OS-specific implementations of
//
//      futexsleep(addr *uint32, val uint32, ns int64)
//              原子性地进行以下操作:
//                      if *addr == val { sleep }
//              不会睡超过 ns 的时间长度。若 ns 小於 0,代表永远睡眠。
//              这个睡眠的状态可以虚假地(spuriously)被叫醒。
//
//      futexwakeup(addr *uint32, cnt uint32)
//              叫醒至多 cnt 个执行绪,若它们睡在 addr 上的话。

这个档案中的其余部分,都是这两个呼叫的使用者。最主要的两个组合为

  • lock/unlock:作为互斥锁之用的 API。
  • noteclear/notesleep/notewakeup:围绕 note 结构体发展出来出来的三个基本方法。这些 API 的抽象功能在 src/runtime/runtime2.go 里面有定义。简单来说, note 是一种一次性的事件,使用前须有 noteclear 对之初始化。只能够恰有一个执行绪呼叫 notesleep,表示这个执行绪将会进入睡眠状态。唤醒它的方法,是必须恰有一个执行绪呼叫 notewakeup 且针对该事件。

以下我们再分两小节,稍微深入地观察其中的机制吧。

lock_futex.go 的互斥锁同步机制实作

这里显示的是 lock2 而非 lock 函数没有错,但後缀为 2 的互斥锁函数才是这一组同步机制的核心。

首先,上锁的部份可以约略区分为五个区块。其中後三者是一起处在一个无穷回圈里面,除非取到这个锁回传,否则无法离开回圈与这个上锁的函数。

func lock2(l *mutex) {
        gp := getg()

        if gp.m.locks < 0 {
                throw("runtime·lock: lock count")
        }
        gp.m.locks++

第一阶段是很简单的维护 gp.m,也就是当前共常式所属的执行绪的一个资料成员,locks。从程序语意上不难看出,这个值用来代表一个执行绪当前取得的锁的数量。这个成员变数的一个用例是,当因为某些原因需要中止这个执行绪,而在 Golang 执行期内呼叫了 stopm 函数,其中就有一个条件判断:若是 g.m.locks 的值非为 0,这个执行绪就不该被停止;理由是,如果它握有其它执行绪执行下去必须取得的锁,那整个流程都会出问题。

        // Speculative grab for lock.
        v := atomic.Xchg(key32(&l.key), mutex_locked)
        if v == mutex_unlocked {
                return
        }

这里动用了原子性的交换(Xchg)功能,试着去换换看互斥锁结构(这里是变数 l.key)的状态。mutex_ 开头的常数是可以顾名思义的状态常数,除了这一段看到的上锁状态与非上锁状态之外,还有一个是睡眠状态

换过之後的回传值(这里储存在 v)是原先的状态。所以如果原本是非上锁状态,那就表示已经成功取得了这个锁,那就可以回传了。反之,则表示互斥锁处在上锁状态或是睡眠状态,所以当前的执行绪本身需要再等一下。

        wait := v
...
        for {
                // Try for lock, spinning.
                for i := 0; i < spin; i++ {
                        for l.key == mutex_unlocked {
                                if atomic.Cas(key32(&l.key), mutex_unlocked, wait) {
                                        return
                                }
                        }
                        procyield(active_spin_cnt)
                }

第三阶段起始,都包在这个无条件的无穷回圈之内。简单来说,就是不断的尝试取得这个锁。现在撷取的这一段,核心概念是透过比对後交换(Cas,Compare And Swap)来达成互斥锁本身的状态转移与伺机取得。我们可以忽略 i 变数回圈,那是细节。在其中会先读取 l.key,看它是否是非上锁状态。注意这个是单纯的读取,而不是原子性读取,所以无论当前的执行流程观察的结果如何,其它执行绪都很有可能并行地造成这个锁状态的改变。因此这里只是先预先偷偷地读取一下,看看有没有必要试着改变互斥锁的状态。

如果这个读取本身就失败了,表示互斥锁并不是非上锁状态,那就可以直接呼叫到 procyield 函数去并准备离开这一段了。这个函数在 RISC-V 没有实作,但是在 ARM64 的话,他们有实作 YIELD 指令,可以接收到这里传入的 active_spin_cnt 变数并有意义地运用;大致上,是让当前核心可以停止那麽多个 cycle 的意思。procyied 函数是与架构相依的实作,所以 RISC-V 的部份出现在 src/runtime/asm_riscv64.s 里面。

如果这个读取成功了,则无论其它并行的执行绪正在作什麽动作、是否修改到了这个互斥锁的状态,至少蛮值得一试。所以这里的行为是,我原子性地尝试改变互斥锁状态,如果它现在是非上锁状态,那麽我将之改变为 wait 变数的状态(上锁或睡眠)。至於 Cas 函数的回传值,是在成功替换时回传 1,无法替换时回传 0。所以如果成功将非上锁的状态改变为不可用的状态之任一,当前执行绪就取得了锁,可以离开上锁函数了。反之,则回到先前的读取去。

                // Try for lock, rescheduling.
                for i := 0; i < passive_spin; i++ {
                        for l.key == mutex_unlocked {
                                if atomic.Cas(key32(&l.key), mutex_unlocked, wait) {
                                        return
                                }
                        }
                        osyield()
                }

第四阶段与第三阶段几乎一样,但是 i 回圈的数量其实比前一阶段少。从注解也可以看出端倪,前一阶段是一边试着拿到锁一边空转(spin),这个阶段则是一边试着拿到锁,一边重新排程,显然时间尺度较大一些。最主要的差异在於这里用了 osyield,这是作业系统相依的实作部份,所以以 Linux 为例的话,实作在 src/runtime/sys_linux_riscv64.s 里面,内容是呼叫 Linux 的 sched_yield 系统呼叫,代表说,这个执行绪自愿交出控制权,让作业系统可以优先安排其它执行绪来使用。

                // Sleep.
                v = atomic.Xchg(key32(&l.key), mutex_sleeping)
                if v == mutex_unlocked {
                        return
                }
                wait = mutex_sleeping
                futexsleep(key32(&l.key), mutex_sleeping, -1)
        }

最後一个阶段如注解,前两种试过之後都没有消息,就有点放弃想直接进入睡眠模式了吧。首先是直接试着改变互斥锁状态为睡眠状态,若是刚好很巧发现原先状态是非上锁,表示虽然前面几个阶段在试的时候都有其它执行绪占用,但这里刚好运气好排到了,那也是表示成功取得了锁,可以回传。反之,表示它可能在非上锁或是睡眠状态。整个大无穷回圈的最後这一行,正式使用到 futexsleep 功能,也就是原子性地判断,若是互斥锁状态为睡眠状态,那就不设时限的睡下去吧。

至於解锁部份,比较单纯一点,只分为两个阶段就可以解释完基本功能:

func unlock2(l *mutex) {
        v := atomic.Xchg(key32(&l.key), mutex_unlocked)
        if v == mutex_unlocked {
                throw("unlock of unlocked lock")
        }
        if v == mutex_sleeping {
                futexwakeup(key32(&l.key), 1)
        }

第一组条件判断就是我们先前遭遇过的,不该解锁已经是非上锁状态的锁,无须赘言。第二组判断的意思是,如果这不只是上锁状态,而是某个睡眠状态的锁,表示有其它执行绪正陷入睡眠,因此这里叫醒一个。

        gp := getg()
        gp.m.locks--
        if gp.m.locks < 0 {
                throw("runtime·unlock: lock count")
        }
...

第二段将执行绪本身取得的锁 locks 成员变数维护好,就可以算是解锁离开关键区域(critical section)了。

lock_futex.go 的一次性事件同步机制实作

首先介绍 noteclear,也就是初始化一个 note 结构:

func noteclear(n *note) {
        n.key = 0
}

没有其它的繁文缛节,设为 0 之後就算可以使用的事件了。然後是 notesleep

func notesleep(n *note) {
        gp := getg()
        if gp != gp.m.g0 {
                throw("notesleep not on g0")
        }
        ns := int64(-1)
        if *cgo_yield != nil {
                // Sleep for an arbitrary-but-moderate interval to poll libc interceptors.
                ns = 10e6
        }
        for atomic.Load(key32(&n.key)) == 0 {
                gp.m.blocked = true
                futexsleep(key32(&n.key), 0, ns)
                if *cgo_yield != nil {
                        asmcgocall(*cgo_yield, nil)
                }
                gp.m.blocked = false
        }
}

笔者承认,这里实在不太清楚 cgo_yield 的功能是什麽,但透过 GDB 观察,指到 _cgo_yield 的部份都是 0,也就是说相关的判断与呼叫,我们这里应该是可以忽略的。虽然不难猜测,应该是连接到某些 C 语言界面时,可以实行更细致的让出资源(也就是 yield 的本意)动作。

note 结构是乾净状,则直接无时限地睡下去,因为 ns 变数为 -1。这里也维护了 blocked 成员变数,目前看起来只有 windows 有在利用这个属性,代表一个执行绪是否正在被某个一次性事件卡着。这个前提是,n.key 的值一直都还是 0,没有改变过。但这个判断的逻辑实际上也代表,如果 n.key 被修改过而变成不是 0 了,那麽根本就不会走进这些流程,也就不需要睡了。

参照 notewakeup,就可以理解这整组的效果,

func notewakeup(n *note) {
        old := atomic.Xchg(key32(&n.key), 1)
        if old != 0 {
                print("notewakeup - double wakeup (", old, ")\n")
                throw("notewakeup - double wakeup")
        }
        futexwakeup(key32(&n.key), 1)
}

这个唤醒呼叫迎面就来个 n.key 的交换,它预期要嘛这个 note 的状态是刚初始化完但却没有对应的执行绪睡眠之,或是已经有执行绪对之睡眠。总之只有一个排除错误状况的判断,之後就是使用 futexwakeup 去唤醒至多一个睡眠於这个 note 的执行绪。

作业系统呼叫部分:以 Linux 为例

观察 os_linux.go 里面的 futex 系列,可以看到这两者都使用 futex 系统呼叫实作,差异在於 futexsleep 使用了 _FUTEX_WAIT_PRIVATE 参数,而 futexwakeup 使用的是 _FUTEX_WAKE_PRIVATE 参数。至於 sys_linux_riscv64.s 中的 futex 系统呼叫,也只是很简单地将暂存器转手之後再呼叫核心而已,因此就先都省略了。

旗号

如果是旗号阵营的作业系统的话,Golang 要求实作的介面(列在 src/runtime/lock_sema.go 之中)有三个(中文为笔者的翻译),

// This implementation depends on OS-specific implementations of
//
//      func semacreate(mp *m)
//              如果不是已经创造过了的话,创造一个 mp 的旗号 
//
//      func semasleep(ns int64) int32
//              如果 ns 小於 0,取得 m 的旗号并且回传 0;
//              如果 ns 大於等於 0,试着取得 m 的旗号,持续至多 ns 奈秒的睡眠状态;若是取得了旗号则回传 0,
//              若是被中断或是时间到了,则回传 -1。
//
//      func semawakeup(mp *m)
//              叫醒 mp。它的状态是正在睡眠,或可能马上准备要睡眠。
//

这个抽象层也能够提供上述的互斥锁对以及一次性事件的两组 API,读者可以自行浏览,会发现实作上非常的相似。

研究旗号 API

笔者比较熟悉的 Linux 和勉强玩过一阵子的 FreeBSD 都是 futex 阵营的作业系统,理论上是该往那边靠拢才对,但是 futex 系统呼叫提供的功能实在太强大了,若要实作整个系统呼叫,我们必然需要从位址对应到等待该位址的执行绪的一种查询机制,而且这种机制本身也必须被保护在关键区域之内才行,这实在是有点麻烦。

所以不得已,我们只好投奔旗号阵营了。以 NetBSD 的实作先参考一下:

所需资料结构与 semacreate

NetBSD 多使用一个成员变数,waitsemacount,在 mOS 之中。创造旗号的这个 API 则是留空,毕竟都已经直接对应在执行绪的 mOS 里面了。

semawakeup

相较於进入睡眠的逻辑,唤醒的逻辑相当简单,如下

func semawakeup(mp *m) {
        atomic.Xadd(&mp.waitsemacount, 1)
        // From NetBSD's _lwp_unpark(2) manual:
        // "If the target LWP is not currently waiting, it will return
        // immediately upon the next call to _lwp_park()."
        ret := lwp_unpark(int32(mp.procid), unsafe.Pointer(&mp.waitsemacount))
        if ret != 0 && ret != _ESRCH {
                // semawakeup can be called on signal stack.
                systemstack(func() {
                        print("thrwakeup addr=", &mp.waitsemacount, " sem=", mp.waitsemacount, " ret=", ret, "\n")
                })
        }
}

先将 waitsemacount 的值增加 1,然後引用 lwp_unpark 系统呼叫,让作业系统去帮忙排时间给指定的执行绪。

semasleep

func semasleep(ns int64) int32 {
        _g_ := getg()
        var deadline int64
        if ns >= 0 {
                deadline = nanotime() + ns
        }

        for {
                v := atomic.Load(&_g_.m.waitsemacount)
                if v > 0 {
                        if atomic.Cas(&_g_.m.waitsemacount, v, v-1) {
                                return 0 // semaphore acquired
                        }
                        continue
                }

上半段,先在给定时限的情况下计算死线(deadline)为何,然後进入到无条件的无穷回圈之中。新增的 waitsemacount 於是派上用场,其值被读取之後,只有大於 0 时可以算是取得了这个旗号。怎麽说呢?虽然我们前一个段落跳过了旗号部份的逻辑,但与快速使用者互斥锁的逻辑并不相差太多。会需要呼叫到睡眠 API 时,都是因为这个互斥锁当前是被其它执行绪上锁的,历经短期的空转与中期的重新排程未果,只好选择睡眠一途。所以,这个执行绪若要取得互斥锁,得有其它执行绪先释放这个锁才行。若能够成功从大於 0 的状态减一,则可回报取得这个旗标,重新回到抢夺 lock 当中互斥锁的状态,或是从针对单一事件的 notesleep 当中醒转。

                // Sleep until unparked by semawakeup or timeout.
                var tsp *timespec
                var ts timespec
                if ns >= 0 {
                        wait := deadline - nanotime()
                        if wait <= 0 {
                                return -1
                        }
                        ts.setNsec(wait)
                        tsp = &ts
                }
                ret := lwp_park(_CLOCK_MONOTONIC, _TIMER_RELTIME, tsp, 0, unsafe.Pointer(&_g_.m.waitsemacount), nil)
                if ret == _ETIMEDOUT {
                        return -1
                }
        }
}

下半段的部份,即是必须要认真考虑睡觉这回事了。在 ns 有确实传入的状况,就重新计算一次看看是否死线已经超过了。若否,则依赖 lwp_park 系统呼叫帮忙停住这麽多时间。

实作旗号 API

OpenSBI 当然没有通知其它执行绪或是帮助我们暂停特定时间的功能。前者是完全不相干的抽象。後者和我们打通的计时器中断当然是能够牵扯在一起,但我们就必须先把计时器中断机制抽象掉,另外维护一层排序过的事件,在系统执行期间都仅有最近要发生的事件会被设定计时器中断,这听起来又太麻烦了。

不过巧的是,我们这里其实可以耍诈。反正以计时器中断为基础的固定式执行绪切换已经在运作了,所以陷在 semasleep 里面的执行绪虽然没有一个系统呼叫来帮助它睡指定的时间,但是它总还是会被计时器中断通知,并且转移控制权。虽然有点浪费 CPU 资源,但是理论上是可行的。

所以笔者就将 NetBSD 的系统呼叫拔掉之後,就完成今天的主要部份。

试跑

有一个先前先透过无穷回圈卡住的 gcenable 函数,我们必须移除这个路障,让它能够继续进行下去。

结果还是跑一跑之後就会出现疯狂的错误!以下的讯息不断洗频:

...
goroutine 0 [idle]:
runtime.throw({0xffffffc000098ae6, 0x14})
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/panic.go:965 +0x60
runtime.ethanol_trap1(0xffffffcf04009ee0)
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/os_opensbi.go:320 +0x21c
runtime.ethanol_trap()
        /home/noner/FOSS/hoddarla/ithome/go/src/runtime/sys_opensbi_riscv64.s:78 +0xe4
for thread 0xffffffcf04000000(0xffffffc0001195e0): [0xffffffc000119440, 0xffffffcf04000000, 0xffffffcf040001a0]
fatal error: unexpected exception

配合 GDB 设置断点在 throw,观察这个错误,发现是在 runtime.main 的最後面的阶段:

ffffffc000036f2c:       00027f97                auipc   t6,0x27
ffffffc000036f30:       0d4f80e7                jalr    212(t6) # ffffffc00005e000 <runtime.exit>
ffffffc000036f34:       00000193                li      gp,0
ffffffc000036f38:       0001a023                sw      zero,0(gp)
ffffffc000036f3c:       ff9ff06f                j       ffffffc000036f34 <runtime.main+0x2fc>

由於呼叫 exit 没有效果,有一个强制写入 0 到位址 0 的区段:

 277         exit(0)
 278         for {
 279                 var x *int32
 280                 *x = 0
 281         }

所以这里我们为 exit 与概念相近的 exitThread 补上实作,如下:

TEXT runtime·exit(SB),NOSPLIT,$0
        MOV     $0, A0
        MOV     $0, A1
        MOV     $0, A6
        MOV     $0x53525354, A7
        ECALL
        RET

TEXT runtime·exitThread(SB),NOSPLIT,$0
        CALL    runtime·exit(SB)

exit 里面的序列有点魔术,其实只是呼叫 OpenSBI,让它将平台关机的指令。

再试跑

今天的内容可以在 github 取得。

...
[0xffffffcf04000d00|0xffffffcf0402c580] wakes [0xffffffc000119440|0xffffffc0001195e0] 
[0xffffffcf04000d00|0xffffffcf0402c580] try sema in -1 nanosecs 
for thread 0xffffffcf04000b60(0xffffffcf0402c580): [0xffffffcf04000d00, 0xffffffcf04000b60, 0x0]
for thread 0xffffffcf04000b60(0xffffffcf0402c580): [0xffffffcf04000d00, 0xffffffcf04000b60, 0x0]
for thread 0xffffffcf04000b60(0xffffffcf0402c580): [0xffffffcf04000d00, 0xffffffcf04000b60, 0x0]
for thread 0xffffffcf04000340(0xffffffcf0402c000): [0xffffffcf040004e0, 0xffffffcf04000340, 0x0]
for thread 0xffffffcf04000340(0xffffffcf0402c000): [0xffffffcf040004e0, 0xffffffcf04000340, 0x0]
for thread 0xffffffcf04000340(0xffffffcf0402c000): [0xffffffcf040004e0, 0xffffffcf04000340, 0x0]
...

绕过 gcenable 的效果是,为了垃圾回收,Golang 执行期会再多创一个执行绪(m)与两个共常式(g)。所以,加上笔者补上的一些印出资讯,执行之後可以看到执行绪之间的换手历程,以及我们补进来的旗号系列函数被呼叫的过程,然後停止执行,QEMU 正常离开。

小结

今天是我们第一次成功让 QEMU 从系统启动到它自然结束,照理来说固然是值得庆幸的,但是这时候我们不禁会觉得纳闷,Golang 执行期就这样度过了,但 ethanol/ethanol.go 里面的那个 Hello World 讯息又怎麽了呢?从 QEMU 的输出讯息,我们什麽也没有看到,显然这里还有我们没有处理的部份,否则怎麽没有印出讯息呢?

无论如何,这一章也可以算是结束了。这一章的基本发想是,Golang 有如此复杂的执行期功能,应该有些抽象足以挪用为作业系统的执行绪;我们因此将脑筋动到 m 结构上面,然後比对了 newosproc 所需要的抽象层。之後,我们加上了一个很简单的 Round-Robin 式的固定排程;然後再基於旗号机制,补上了相关的函数。我们因此得以摆脱先前的 js/wasm 系统组合单一执行绪假设造成的错误,以及未完备的同步机制造成 gcenable 与 Golang 频道(channel)不能使用的现象。最後也有一个小型修补,是执行绪离开(exit)的机制

明天开始,我们就进入本篇的最後一章。各位读者,我们明天再会!


<<:  day18 kotlin - flow基本操作

>>:  Day25- Go with MySQL

Day 11 Swift语法-进阶篇(4/5)-Protocol

Protocol又叫做协定,我们可以透过协定,去让我们的class或struct去遵从这个协定里所规...

[NestJS 带你飞!] DAY16 - Configuration

前一篇我们运用 Dynamic Module 与 dotenv 设计了一个简单的环境变数管理模组,但...

Domain Storytelling - 简单的方法说出一个Domain story

上篇回顾 Story Telling - 简易有效的讨论 讲到会议很烦很冗长没重点还要开好几次, 是...

[Day23]Virtual Service

上一篇的Bookinfo这个服务中,我们有使用samples/bookinfo/networking...

Day 11 state & props -2

第十一天罗~ 昨天我们说到 state , 那我们如何去改变 state 呢? 当我们直接改动 st...