XV6 Homework - Lock

当多个处理器上的指令对共享数据进行操作时,可能用到锁。多个处理器运行的可能是同一份代码,也可能是不同的代码。如果共享数据是常量,或者指令都是读数据,没有修改数据,应该不需要用到锁。

还有一种情况是,当中断发生时,中断 handler 也访问了共享数据,这时也需要对共享数据加锁。但是这种情况可能发生死锁,例如:

  • 线程 T 为了访问共享数据,获取锁
  • T 获取锁后,发生某一中断
  • 中断回调函数要访问共享数据,需要获取同一个锁
  • 因为线程 T 还没有释放锁,所以中断 handler 只能等待。
  • T 等待中断处理结束后,才能释放锁,但是中断处理函数又一直等待这个锁。这种情况下,系统就会死锁。

xv6 持有锁时会关闭中断,防止上述情况发生。

锁的实现

一个简单的实现如下

void acquire (struct spinlock *lk) {
  while(lk->locked == 1)
    ;

  lk->locked = 1;
}

这种实现存在的问题是,判断语句和赋值语句是多个指令,不是原子指令。线程 T1 执行到while 循环,发现锁没有被占用,此时发生调度切换(T1 还没来得及设置 lk->locked = 1)线程 T2 也执行到 while 循环,也可以占用锁。此时,就有两个线程获取了同一个锁。

所以操作系统通过硬件提供一些底层原子操作来实现锁,xv6 就是通过 xchg 指令实现的。

xchg(old_mem, new_value) 功能如下,只不过下面这些操作是原子的

old_value = *old_mem;
*old_mem = new_value;
return old_value;

锁 API

xv6 提供两个 API,用来获取和释放锁,分别是 acquire/release,下面看一下具体实现。

pushcli/popcli 函数

根据前面的介绍,在获取和释放锁时,需要关闭和开启中断,这里有两个对应的函数 pushcli/popcli 处理。

这里主要考虑,如果当前CPU需要获取多次锁时,如何管理中断。

pushcli

CPU 在第一次调用 pushcli 时,获取当前的 flag 寄存器的 IF 位,保存在 cpu->intena中。每次调用 pushcli 时,关闭中断,记录关闭中断的次数。

popcli

什么时候才需要恢复中断呢?

  • 如果 CPU 第一次调用 pushcli 时,中断就是关闭的,那么不需要开启中断。通过 pushcli 保存的 cpu->intena 可以知道第一次 pushcli 中断是否是关闭的
  • 如果调用了多次 pushcli, 那么只有最后一次调用 popcli 的时候,才需要开启中断

重复获取锁

占用锁后,CPU 再次运行 acquire 函数,会导致获取锁的指令死循环,当前系统会 hang 住

//重复调用,死循环
while(xchg(&lk->locked, 1) != 0)  
  ;

所以如果出现重复调用,系统应该 panic。

内存乱序

有时候编译器/处理器为了提高性能,可能会把指令顺序打乱。如果现在有个加锁操作,被编译器/处理器打乱了顺序,就会导致锁的功能失效

//正常逻辑               //乱序
acquire()               acquire()
i++                     release()
release()               i++

所以需要 __sync_synchronize(),禁止编译器/处理器乱序内存操作。

具体怎么个乱序法,还要再研究一下。

保存调用栈信息

acquire还会保存调用栈信息,用来做调试。 getcallerpcs() 通过参数地址,获取 ebp 地址,然后根据 ebp 信息,就可以找出整个函数的调用栈,这里只保存了最近10个函数的调用信息。

Homework

acquire两次

这个前面已经分析过了,内核会 panic

Interrupts in ide.c

这个作业是获取 ide 锁后,打开中断,运行后可能出现如下结果

cpu0: starting
sb: size 1000 nblocks 941 ninodes 200 nlog 30 logstart 2 inodestart 32 bmap start 58
init: starting sh
cpu with apicid 1: panic: acquire
 80105061 80102824 80106b9d 80106835 80102986 801001e7 80101ef3 80108492 80100cd0 8010648a
80105061: acquire() -> panic()  //spinlock.c
80102824: ideintr() -> acquire() //ide.c
80106b9d: trap() -> ideintr() // trap.c
80106835: trap()  //vectors.S
80102986: iderw() -> idestart() // ide.c
801001e7: bread -> iderw() // bio.c
80101ef3: readi -> bread() // fs.c
80108492: loaduvm -> readi() // vm.c
80100cd0: exec -> loaduvm() // exec.c
8010648a: sys_exec -> exec() //sysfile.c 

通过调用栈可以发现,fork一个进程并调用 exec syscall 后,需要读磁盘,在 idestart 后发生中断(IDE中断?),进入 IDE 中断处理函数,而 ideintr() 也需要获取 ide 锁,于是导致重复获取锁,kernel panic。

Interrupts in file.c

为什么获取 file_table_lock 锁后,打开中断,kernel 没有 panic 呢?

原因就是发生中断后,所有中断处理函数,都不需要再次获取 file_table_lock。意思是中断处理函数不会访问 file_table_lock 锁保护的共享数据,所以这种情况,不需要处理中断。

xv6 lock implementation

如果释放锁后,再去清理 lk->pcs[0], lk->cpu,可能出现

  • 锁释放,但是还未清理 lk->pcs[0], lk->cpu
  • 另一个 CPU 尝试获取锁并成功,设置 lk->pcs[0], lk->cpu
  • 当前 CPU 继续执行,清理了 lk->pcs[0], lk->cpu

这就会导致锁相关的信息不正确,影响锁的功能,比如判断当前 CPU 是否占用该锁,需要用到 lk->cpu