25.1 多处理器同步原语的实现原理
当计算机中存在不止一个CPU时,基于关中断的同步原语就失效了。这是因为每个CPU的中断是独立的,关闭一个CPU的中断并不会影响其他CPU。从本质上说,中断由rflags
控制,但rflags
在每个CPU中都有一个,因此,只有找到一个共享区域,才能实现多CPU间的同步原语。内存正是这样的共享区域,其可用于实现锁。
如果使用内存实现锁,那就需要一块内存标记锁的状态,并需要判断并尝试修改锁的状态。如果使用常规方法实现这套功能,需要的指令一定不止一条,这就陷入了循环依赖:这段指令本身也需要锁,但这段指令本身就是用于实现锁的。
想要解决这个问题,就需要使用一条指令同时判断并尝试修改锁的状态。xchg
是实现这个功能的最简单的选择,具体来说:
- 将锁初始化为0
- 加锁时,固定使用1与锁进行
xchg
。如果交换来的是0,就说明锁曾经是0,现在是1,加锁成功;如果交换来的是1,就说明锁在交换前已经是1了,加锁失败,此时,任务应通过某种方式等待锁 - 解锁时,
mov [锁], 0
即可
加锁是一个对效率要求很高的操作,因此,CPU提供了cmp
与xchg
的二合一增强版:cmpxchg
指令。顾名思义,cmpxchg
能同时进行比较与交换操作。具体来说,cmpxchg lhs, rhs
的效果是:比较lhs
与al/ax/eax/rax
,如果相等,则mov lhs, rhs
,否则,mov rax, lhs
,此外,比较操作会影响rflags
的ZF位,即je/jne
考察的位。
cmpxchg
的效果看上去比较绕,如果将其用在锁上,就可以得到一个比较具体的描述:将rax
设为0,rdx
设为1,然后执行cmpxchg [锁], rdx
。cmpxchg
首先比较[锁]
与rax
,如果相等,就说明锁是0,是可用的,于是执行mov [锁], rdx
,将锁置1,同时修改rflags
的ZF位,使je
通过,且rax
仍为0,表示加锁成功;否则,如果不等,就说明锁是1,是不可用的,于是执行mov rax, [锁]
,将rax
置1,表示加锁失败,同时修改rflags
的ZF位,使jne
通过。
25.2 总线锁定
当内存被多个CPU同时访问时,其也需要锁。因此,CPU提供了总线锁定前缀lock
,当使用lock
前缀时,内存会被当前指令锁定,其他CPU不能使用内存,直至当前指令结束。
总线锁定对效率有影响,因此是不能滥用的。此外,仅有非常少的指令支持lock
前缀,它们是:add, adc, and, btc, btr, bts, cmpxchg, cmpxch8b, cmpxchg16b, dec, inc, neg, not, or, sbb, sub, xor, xadd, xchg
,其他指令不能使用lock
前缀。
xchg
被强制视为具有lock
前缀。
25.3 自旋锁的实现
在我们的操作系统中,使用自旋锁进行多处理器同步。
请看本章代码25/Lock.h
。
第5行,声明了Lock
类型。当锁不可用时,自旋锁将进行忙等待,因此其不需要等待队列,只需要一个整数即可。
第7~9行,声明了锁的接口函数,这些函数是用汇编语言实现的。
接下来,请看本章代码25/Lock.s
。
lockInit
函数用于初始化锁,简单的将锁置0即可。
lockAcquire
函数用于加锁并返回rflags
的值。
第18行,将rdx
置1,准备进入自旋状态。
第20~24行,不断尝试加锁。注意:xor rax, rax
不能放在循环外面,因为cmpxchg
会在加锁失败时将rax
从0改成1。
第26~28行,返回rflags
的值,然后关中断。
lockRelease
函数用于解锁并恢复rflags
的值。
第36行,将锁重新置0。
第38~39行,恢复rflags
的值。
25.4 自旋锁的使用
在我们的操作系统中,任务队列和显卡驱动会被每个CPU使用,因此是需要加锁的。
请看本章代码25/Queue.h
。
第16行,在Queue
中加入自旋锁。
接下来,请看本章代码25/Queue.hpp
。
第11行,初始化锁。
第17、21、29、37、43、50行,将关中断升级为自旋锁。
接下来,请看本章代码25/Print.hpp
。
第8行,定义显卡驱动锁。
第98、105行,在printStr
函数中加入锁。
第214行,初始化显卡驱动锁。
25.5 编译与测试
本章代码25/Makefile
增加了Lock.s
的编译与链接命令。
本章代码25/Kernel.c
测试了加锁以后的任务切换和printStr
函数。