6.5: Re-entrant locks

注释:该内容基于原版课程的textbook和源代码,在大模型辅助翻译的基础上进行的整理。

看起来有些死锁和锁顺序问题可以通过使用可重入锁(也称为递归锁)来避免。
其思想是,如果一个进程已经持有锁,并且该进程再次尝试获取该锁,那么内核可以直接允许这种情况(因为该进程已经持有锁),而不是像 xv6 内核那样调用 panic。 然而,事实证明,可重入锁使并发性推理变得更困难:可重入锁打破了锁使临界区对其他临界区原子化的直觉。考虑以下函数 f 和 g,以及一个假设的函数 h:

struct spinlock lock;
int data = 0; // protected by lock

f() {
    acquire(&lock);
    if(data == 0){
        call_once();
        h();
        data = 1;
    }
    release(&lock);
}

g() {
    aquire(&lock);
    if(data == 0){
        call_once();
        data = 1;
    }
    release(&lock);
}

h() {
...
}

查看这段代码片段,我们的直觉是 call_once 只会被调用一次:要么被 f 调用,要么被 g 调用,但不会被两者同时调用。

但是,如果允许可重入锁,并且 h 恰好调用了 g,那么 call_once 就会被调用两次。

如果不允许可重入锁,那么 h 调用 g 会导致死锁,这也不是理想的情况。不过,假设调用 call_once 是一个严重错误,那么死锁是相对更好的结果。

内核开发人员会观察到死锁(内核会崩溃),然后可以修复代码以避免这种情况,而调用 call_once 两次可能会导致难以追踪的错误。

出于这个原因,xv6 使用了更容易理解的非可重入锁。然而,只要程序员牢记锁的使用规则,无论哪种方法都可以正常工作。

如果 xv6 使用可重入锁,则需要修改 acquire 以检测该锁是否当前由调用线程持有。同时,还需要在 struct spinlock 中添加嵌套获取计数,这与接下来讨论的 push_off 类似。