6.3: Code: Using locks

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

xv6在许多地方使用锁来避免竞争条件。如上所述,kalloc(kernel/kalloc.c:69)和kfree(kernel/kalloc.c:47)是一个很好的例子。

尝试练习 1 和 2 来观察这些函数如果省略锁会发生什么情况。

你可能会发现,很难触发不正确的行为,这表明很难可靠地测试代码是否没有锁定错误和竞争条件。xv6 可能还有尚未发现的竞争问题。

使用锁的一大难点是决定要使用多少个锁以及每个锁应该保护哪些数据和不变量。有一些基本原则。
首先,任何时候一个变量可以被一个 CPU 写入,同时另一个 CPU 可以读取或写入它,都应该使用锁来防止两个操作重叠。
其次,请记住,锁保护不变量:如果一个不变量涉及多个内存位置,通常需要一个单独的锁来保护所有这些位置,以确保不变量得以维持。

上述规则说明了何时需要锁,但未涉及何时不需要锁。为了提高效率,不应该过多使用锁,因为锁会减少并行性。
如果并行性不重要,那么可以安排只有单线程,从而无需担心锁。在多处理器上,一个简单的内核可以通过在进入内核时获取一个锁,并在退出内核时释放这个锁来实现这种效果(尽管阻塞系统调用如管道读取或等待会带来问题)。
许多单处理器操作系统已经通过这种方法转换为在多处理器上运行,这种方法有时称为“大内核锁”,但这种方法牺牲了并行性:只有一个 CPU 能够在内核中同时执行。
如果内核进行任何繁重的计算,使用一组更细粒度的锁以便内核能够在多个 CPU 上同时执行会更高效。

作为粗粒度锁的一个例子,xv6 的 kalloc.c 分配器有一个单独的空闲列表,由一个锁保护。如果不同 CPU 上的多个进程尝试同时分配页面,每个进程都需要通过在 acquire 中自旋来等待它的机会(spinning in acquire)。
自旋浪费了 CPU 时间,因为这不是有用的工作。如果锁争用浪费的 CPU 时间比较明显,或许可以通过将分配器设计更改为有多个空闲列表,每个列表都有自己的锁,从而实现真正的并行分配以提高性能。

作为细粒度锁的一个例子,xv6 为每个文件分配了一个单独的锁,以便处理不同文件的进程通常可以在不等待彼此锁的情况下继续。如果希望允许进程同时写入同一文件的不同区域,文件锁定方案可以设计更细的粒度。
最终,锁粒度的决策需要由性能测量以及复杂性考虑来驱动。

locks in xv6

在后续章节中,每个部分解释 xv6 时,会提到 xv6 用锁处理并发的例子。作为预览,上图列出了 xv6 中的所有锁。

itable

.\xv6-labs-2024\kernel\fs.c

struct {
  struct spinlock lock;
  struct inode inode[NINODE];
} itable;

void
iinit()
{
  int i = 0;
  
  initlock(&itable.lock, "itable");
  for(i = 0; i < NINODE; i++) {
    initsleeplock(&itable.inode[i].lock, "inode");
  }
}