予焦啦!我们昨日实作完简易排程,确保 Golang 执行绪(M
)都会被排到 CPU 资源。但是却有不定时炸弹会出现,那就是试图解锁非上锁的锁的错误;大部分时候都运行良好,但偶尔这个错误会自己发生,又或者像是昨日最後的附加实验那样,调整计时器中断的频率或是增加其它系统活动,都可能会看到这个问题:
fatal error: unlock of unlocked lock
在深入研究之前,我们得先检视 Golang 执行期的锁是怎麽回事。尤其是,我们现在使用的锁是怎麽来的?
爬梳一下发现,我们当初在参赛第 3 天,产出可执行档的过程中,lock
与 unlock
这两个一度是非定义符号,但我们强行引用 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
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
的执行绪。
观察 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,读者可以自行浏览,会发现实作上非常的相似。
笔者比较熟悉的 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
系统呼叫帮忙停住这麽多时间。
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
)的机制。
明天开始,我们就进入本篇的最後一章。各位读者,我们明天再会!
Protocol又叫做协定,我们可以透过协定,去让我们的class或struct去遵从这个协定里所规...
前一篇我们运用 Dynamic Module 与 dotenv 设计了一个简单的环境变数管理模组,但...
上篇回顾 Story Telling - 简易有效的讨论 讲到会议很烦很冗长没重点还要开好几次, 是...
上一篇的Bookinfo这个服务中,我们有使用samples/bookinfo/networking...
第十一天罗~ 昨天我们说到 state , 那我们如何去改变 state 呢? 当我们直接改动 st...