0.briefly speaking
并发(Concurrency)指的是在多处理器系统(multiprocessor)中的并行,线程切换和中断导致的多个指令流交互错杂的情况,再和现代处理器体系结构中的多发射、乱序执行、Cache一致性等话题结合起来,这几乎变成了一个相当相当复杂的话题。在Xv6运行的平台SiFive_Unleashed上,也有5个核心(1个S51 Moniter Core和4个U54 Application Core),这说明在Xv6系统运行的过程中考虑并发控制(Concurrency Control)是非常必要的。锁机制(lock mechanism)是一种最常见的管理并发程序正确性的方案,它通过将对临界区的访问串行化(serialize)来保证并发操作的正确性。Xv6中在如下地方使用了锁机制,可以作为研究对象自己去一一体悟锁的重要性(比如画画这些情况下的锁链):
这篇博客中将要研究一下Xv6内核中自旋锁的实现,而至于它的使用,我们很难一一在内核中去分析之。它涉及的地方非常多且零碎,只能自己慢慢体悟锁的使用才可以慢慢熟悉它的用法和时机。不过,Xv6 book大纲中为我们提供了一些需要注意的话题,如下所示,我简单做了个概括:
- 死锁与锁定序:如果一个代码路径可能会在同一时间持有多个锁,那么所有代码路径都应该按照相同的次序获取这些锁,否则就会有死锁的风险。不过这个很难,尤其是锁链非常长的时候,保持所有进程持有锁的顺序是非常艰难的。
- 锁与中断处理程序:有些时候中断处理程序(interrupt handler)需要锁来保证并发安全,那么在中断处理程序中它会尝试获取这把锁,如果进程恰好在进入中断处理程序之前持有这把锁,那么这个进程就会出现自己等待自己的死锁。所以如果中断处理程序中需要持有锁,在CPU持有锁的期间必须保证不能被中断。而Xv6的策略更加保守:Xv6在申请锁之前,必须先关中断。
- 指令与内存定序:即使正确安排了锁次序,也有可能会出现错误,这是因为编译器和CPU可能会对指令的执行顺序做重新排序以提升指令执行速度,这种重新排序的规则叫做访存模型(memory rules),但是错误的调整动作会导致并发控制错误,所以必须加入一些内存屏障指令(memory barrier),比如fence指令,来避免这些重排序对并发造成的影响。
下面进入正题,看看Xv6系统中自旋锁的实现,代码量不大,我们要阅读的代码文件如下:
1.kernel/spinlock.h
2.kernel/spinlock.c
1.自旋锁的结构与初始化
首先来看自旋锁结构体中有什么,自旋锁结构体定义在spinlock.h中,示意如下:
// Mutual exclusion lock.
// 译:互斥锁
struct spinlock {
// 锁是否已被持有的标志位
uint locked; // Is the lock held?
// For debugging:
// 译:用于debug的一些附加信息
char *name; // Name of lock.
struct cpu *cpu; // The cpu holding the lock.
};
我们可以看到,一个自旋锁的实现非常简单,其实只需要一个标志来记录锁当前是否被占用即可,另外附加的一些信息是用来debug时提示的。
同样的,对自旋锁的初始化过程也非常简单,代码位于kernel/spinlock.c:11,它只给锁起了个名字,并将锁是否被持有以及当前持有锁的CPU标志置为空,代码如下:
// 对自旋锁的初始化
// 给锁起了个名字,并且将锁中的标志位置为空
void
initlock(struct spinlock *lk, char *name)
{
lk->name = name;
lk->locked = 0;
lk->cpu = 0;
}
2.关中断——push/pop off
我们在briefly speaking部分说过,当并发和中断交织在一起的时候可能会爆发意想不到的死锁。Xv6为了避免这种情况采取了一种保守的策略:在一个进程尝试获取锁(还不一定成功呢…)之前就把中断关掉。
实现这个功能的一对函数是push off和pop off,它们是配对使用的。你可能会有疑问,为什么要提供这么一套函数来控制中断的开关,我们明明使用两个宏intr_on和intr_off(kernel/riscv.h:273, 280)就可以实现这些功能了呀?
这是因为在处理并发的过程中,我们可能会在一条代码路径上多次获取和释放不同的自旋锁(这些锁按照获取的先后次序排成了一个锁链),又因为Xv6内核采取了上述的保守策略,在尝试获取锁之前就把中断关闭,这样一来开关中断的动作也就随着加锁、解锁动作形成多次嵌套,所以开关中断的动作一定要随着加锁解锁操作配对起来,这就需要我们额外记录一些信息。
为了提供这种支持,在保存处理器信息的结构体cpu(kernel/proc.h:22)中添加了两个变量noff和intena来分别记录这种嵌套操作的深度,和进入锁链之前CPU的中断开关情况,一旦身处锁链之中,中断一定保证是关闭的,但是最终进程一定要逐层从锁链中退出,并且恢复到进入锁链之前的中断状态。
// Per-CPU state.
struct cpu {
struct proc *proc; // The process running on this cpu, or null.
struct context context; // swtch() here to enter scheduler().
int noff; // <push_off操作的嵌套深度>,其实也就是当前的锁链长度
int intena; // <在首次push_off操作之前,中断的开关状态>
};
好了,至此我们算是比较详细地分析了为什么需要有push_off和pop_off函数,现在就来看看分别它们的实现,首先是push_off函数,它主要做的事情就是:关中断、递增中断动作嵌套深度,如果当前是在尝试获取锁链中的第一把锁,则将进入锁链之前的中断状态保存到intena变量中。
void
push_off(void)
{
// 获取当前CPU的中断开关状态
int old = intr_get();
// 无论如何,都关闭中断
// 事实上在初次获取到锁之后,中断就已经是关闭状态了
intr_off();
// 如果是刚刚进入锁链
// 就将进入锁链之前的中断状态,也就是上面的old
// 保存在intena中
if(mycpu()->noff == 0)
mycpu()->intena = old;
// 嵌套深度+1
mycpu()->noff += 1;
}
然后就是pop_off函数,它几乎是push_off函数的逆动作,唯一加入的就是一些异常状态的检测,详见下面的代码和注释:
void
pop_off(void)
{
// 获取当前的CPU
struct cpu *c = mycpu();
// 错误情况检测与判断:
// 1.如果中断没关,这是不符合常理的,处于锁链之中的CPU中断一定是关掉的
// 2.中断嵌套深度<1(也就是0),说明已经退出锁链,没有理由再调用pop_off
// 两种情况之一发生,内核陷入panic
if(intr_get())
panic("pop_off - interruptible");
if(c->noff < 1)
panic("pop_off");
// 嵌套深度-1
c->noff -= 1;
// 如果当前已经完全退出锁链,且未进入锁链时中断打开
// 则恢复CPU在进入锁链之前的开中断状态
if(c->noff == 0 && c->intena)
intr_on();
}
3.加锁与解锁操作
3.1 加锁动作——acquire
当一个进程想要对共享内存做操作且要保证正确性时,它要尝试去争夺保护这块内存区域的锁。这个操作我们简单称为加锁,在Xv6中它对应的函数叫做acquire(kernel/spinlock.c:21),代码如下:
// Acquire the lock.
// Loops (spins) until the lock is acquired.
// 译:获取锁
// 在原地循环直到锁被获取
void
acquire(struct spinlock *lk)
{
// 调用push_off关闭中断,增加迭代深度
push_off(); // disable interrupts to avoid deadlock.
// 如果已经持有了要获取的锁,则内核陷入panic
// Xv6不允许可重入锁(re-entrant lock)
if(holding(lk))
panic("acquire");
// On RISC-V, sync_lock_test_and_set turns into an atomic swap:
// a5 = 1
// s1 = &lk->locked
// amoswap.w.aq a5, a5, (s1)
// amoswap.w.aq(原子交换字大小的变量值:atomic swap.word.acquire)
// acquire标志保证在此操作之后的访存操作不可以被搬运到此操作之前
// 相当于隐含了内存定序(memory ordering)的语义
// 使用sync_lock_test_and_set 函数,可以原子式的完成test&set操作
// __sync_lock_test_and_set函数的第一个参数是一个指针,指向一个内存位置
// __sync_lock_test_and_set函数的第二个参数是要往上述内存区域写入的值
// 返回值是第一个参数指向的内存位置被改写之前的值
// 如果返回值是1,表示这把锁正在被其他进程持有,我们继续向其中写入1,不会影响到其他进程
// 如果返回值是0,那么说明这把锁是空闲状态,当前进程成功获取了锁,于是可以退出循环
// Test&Set指令必须是原子的,因为多个进程尝试获取锁的过程
// 本质上也是对临界区(lcoked标志)的访问,如果不是原子化的也会出错
while(__sync_lock_test_and_set(&lk->locked, 1) != 0)
;
// Tell the C compiler and the processor to not move loads or stores
// past this point, to ensure that the critical section's memory
// references happen strictly after the lock is acquired.
// On RISC-V, this emits a fence instruction.
// 译:告知C编译器和处理器,不要让load/store命令随意翻越此处
// 这是为了保证对临界区内存的访问严格发生在锁被获取之后
// 在RISC-V架构上,这会生成一个fence指令
// fence指令将会保证位于这条指令前后的访存指令,都互相不越界
__sync_synchronize();
// Record info about lock acquisition for holding() and debugging.
// 译:将当前获取锁的PCU信息记录在锁结构体中,方便调试
lk->cpu = mycpu();
}
加锁代码中涉及到一些编译器的内置函数,比如__sync_lock_test_and_set和__sync_synchronize,这些都是编译器对开发者管理访存模型时提供的一些工具函数。__sync_lock_test_and_set函数的含义我们在上面已经简单介绍过,操作系统原理教材中一般也都会对所谓的Test&Set操作进行介绍,这里不再过多赘述。
值得注意的是__sync_lock_test_and_set在翻译为汇编指令时会有一条amoswap.w.aq指令, 这条指令除了原子语义之外(atomic semantic),其实还兼具内存屏障的语义(barrier semantic)。aq标志位的使用保证了在这条汇编之后的访存指令不会被移到这条指令的前面,其实这和下面的fence指令语义有所重复,详见RISC-V原子指令这篇博客。而fence指令既不允许位于本条指令之前的访存指令移到后面,也不允许位于本条指令之后的指令移到前面,约束是双向的,就像是一个屏障(fence),牢牢将界限划分开来。这是为了防止指令的乱序执行导致锁机制失效,我们在briefly speaking部分已经提到过了。
3.2 解锁动作——release
解锁操作是和加锁操作对应的,用于在锁链中释放一个锁,它的代码和注释如下:
// Release the lock.
// 译:释放锁
void
release(struct spinlock *lk)
{
// 如果没有持有锁,那么没必要释放
// 这是一个错误,内核会陷入panic
if(!holding(lk))
panic("release");
// 首先将锁中的CPU信息清除
lk->cpu = 0;
// Tell the C compiler and the CPU to not move loads or stores
// past this point, to ensure that all the stores in the critical
// section are visible to other CPUs before the lock is released,
// and that loads in the critical section occur strictly before
// the lock is released.
// On RISC-V, this emits a fence instruction.
// 此处不译,和上面的说明差不多
__sync_synchronize();
// Release the lock, equivalent to lk->locked = 0.
// This code doesn't use a C assignment, since the C standard
// implies that an assignment might be implemented with
// multiple store instructions.
// On RISC-V, sync_lock_release turns into an atomic swap:
// s1 = &lk->locked
// amoswap.w zero, zero, (s1)
// 译:释放锁,等同于lk->locked = 0
// 这里的代码没有使用C语言里的赋值运算符,因为C标准会用
// 多个store指令来实现一个赋值操作
// 在RISC-V中,sync_lock_release则会变成一个原子交换操作amoswap.w
__sync_lock_release(&lk->locked);
// 调用pop_off回到锁链的上一层
pop_off();
}
这段代码中的__sync_synchronize函数我们在之前已经介绍过了,这里不再赘述。这段代码还会调用__sync_lock_release函数来原子地对锁进行释放,其实就是将锁的被占用标志原子地置为0。所以逻辑还是比较简单的,在阅读上述代码时,请多注意这些原子访存指令和内存屏障指令,它们是构成锁机制的核心。
以上就是有关自旋锁的实现,可以看到自旋锁在尝试获取锁时会在一个死循环中持续调用Test&Set原子指令,这样的好处在于进程在锁释放的第一时间可以获得锁,但坏处在于空转盲目等待会浪费大量的CPU运算。为此Xv6中实现了睡眠锁来规避锁的获取过程中出现的空等状态,这个话题和后续的进程调度强相关,所以我们放到下一个专题深入研究,敬请期待!