要想同时获得良好的并行性能、并发时的正确性和易于理解的代码是内核设计的一大挑战。直接使用锁是得到正确性的最佳途径,但不总是这样。本章重点介绍了xv6不得不以复杂的方式使用锁的例子,以及使用类似锁但不是锁的例子。

9.1 Locking patterns

缓存项通常是锁的一个挑战。例如,文件系统的块缓存(kernel/bio.c:26)最多可存储NBUF个磁盘块的副本。一个给定的磁盘块在缓存中最多只有一个副本,这一点非常重要;否则,不同的进程可能会对同一磁盘块的不同副本进行修改时会发生冲突。每一个缓存的磁盘块都被存储在一个buf结构中(kernel/buf.h:1)。buf结构有一个锁字段,它有助于确保每次只有一个进程使用一个给定的磁盘块。然而,这个锁是不够的:如果一个块根本不存在于缓存中,而两个进程想同时使用它怎么办?没有 buf (因为该块还没有被缓存),因此没有什么需要锁定的。Xv6对每一个块的唯一标识符关联一个额外的锁(bcache.lock)来处理这种情况。判断块是否被缓存的代码(e.g,bget(kernel/bio.c:59)),或改变缓存块的集合的代码,必须持有bcache.lock。当代码找到它所需要的块和buf结构后,他就可以释放bcache.lock,然后锁定特定的块,这是一种通用模式:一组项一个锁,外加每个项一个锁。

通常情况下,获取锁的同一个函数会释放它。但更准确的看法是,当一个序列需要保证原子性时,会在该序列开始时获取锁,序列结束时释放。如果序列的开始和结束在不同的函数中,或者不同的线程中,或者在不同的CPU上,那么锁的获取和释放也必须是一样的。锁的功能是强制其他的使用等待,而不是将一段数据固定在特定的代理上。一个例子是yield中的acquire(kernel/proc.c:496),它是在调度线程中释放的,而不是在获取锁的进程中释放的。另一个例子是ilock(kernel/fs.c:289)中的acquiresleep;这段代码经常在读取磁盘时睡眠;它可能在不同的CPU上醒来,这意味着锁可能在不同的CPU上获取和释放。

释放一个被锁保护的对象时,若该锁是嵌入在对象里的,释放这个对象是一件很棘手的事情,因为拥有锁并不足以保证释放对象的正确性。当有其他线程在acquire中等待使用对象时,问题就会出现;释放这个对象就意味着释放嵌入的锁,释放这个锁会导致等待线程出错。一种方式是追踪该对象有多少个引用,为了将只有在最后一个引用消失时才会释放对象。pipeclose (kernel/pipe.c:59)就是这样的一个例子。pi->readopenpi->writeopen跟踪管道是否有文件描述符引用它。

9.2 Lock-like patterns

在许多地方,xv6使用引用计数或标志作为一种软锁,以表明一个对象已被分配且不应该被释放或重用。进程的p->state以这种方式起作用,文件、inodebuf结构中的引用计数也是如此。虽然在每种情况下,锁都会保护标志或引用计数,但正是标志或引用计数防止了对象被过早释放。

文件系统使用结构体inode的引用计数作为一种共享锁,可以由多个进程持有,以避免代码使用普通锁时出现的死锁。例如,namex(kernel/fs.c:629)中的循环依次锁定每个路径名命名的目录。然而,namex必须在循环末尾释放每一个锁,因为如果它持有多个锁,那么如果路径名中包含一个点(例如,“a/”,“./b”),它可能会与自己发生死锁。它也可能因为涉及目录和“..”,”.”的并发查找而死锁。正如第8章所解释的那样,解决方案是让循环将目录inode带入下一次迭代,并增加其引用计数,但不锁定。

有些数据项在不同的时候会受到不同机制的保护,有时可能会被xv6代码的结构隐式保护,而不是通过显式锁来防止并发访问。例如,当一个物理页是空闲的时候,它被kmem.lock(kernel/kalloc.c:24)保护。如果页面被分配作为管道(kernel/pipe.c:23),它将被一个不同的锁(嵌入的pi->lock)保护。如果该页被重新分配给一个新进程的用户内存,它就不会受到锁的保护。相反,分配器不会将该页交给任何其他进程(直到它被释放)的事实保护了它不被并发访问。一个新进程的内存的所有权是很复杂的:首先父进程在fork中分配和操作它,然后子进程使用它,(在子进程退出后)父进程再次拥有内存,并将其传递给kfree。这里有两个需要注意的地方:第一,一个数据对象在其生命周期中的不同点可以用不同的方式来保护它不被并发访问;第二,保护的形式可能是隐式结构而不是显式锁。

最后一个类似于锁的例子是在调用mycpu()(kernel/proc.c:80)时需要禁用中断。禁用中断会导致调用代码对定时器中断是原子性的,而定时器中断可能会强制上下文切换,从而将进程移到不同的CPU上。

9.3 No locks at all

xv6有几个地方是在完全没有锁的情况下共享可变数据的。一个是在spinlocks的实现中,尽管你可以把RISC-V原子指令看作是依靠硬件实现的锁。另一个是main.c (kernel/main.c:7)中的started变量,用来防止其他CPU运行,直到CPU 0完成xv6的初始化;volatile确保编译器实际生成加载和存储指令。第三个例子是 p->killed,它在持有 p->lock (kernel/proc.c:579) 时设置,但在没有持有锁的情况下检查 (kernel/trap.c:56)。

Xv6包含这样的情况:一个CPU或线程写入一些数据,另一个CPU或线程读取数据,但没有专门的锁来保护这些数据。例如,在fork中,父线程写入子线程的用户内存页,子线程(不同的线程,可能在不同的CPU上)读取这些页;没有锁显式地保护这些页。严格来说,这不是锁的问题,因为子线程在父线程写完后才开始执行。这是一个潜在的内存排序问题(见第6章),因为如果没有内存屏障,就没有理由期望一个CPU看到另一个CPU的写入。然而,由于父线程CPU释放锁,而子线程CPU在启动时获取锁,所以在acquirerelease中的内存屏障保证了子线程CPU能看到父线程CPU的写入。

9.4 Parallelism

锁主要是为了正确性而抑制并行性。因为性能也很重要,所以内核设计者经常要考虑如何使用锁,来保证正确性和良好的并行性。虽然xv6并不是为高性能而设计的,但仍然值得考虑哪些xv6操作可以并行执行,哪些操作可能在锁上发生冲突。

xv6 中的管道是相当好的并行性的一个例子。每个管道都有自己的锁,因此不同的进程可以在不同的CPU上并行读写不同的管道。然而,对于一个给定的管道,writer和reader必须等待对方释放锁,他们不能同时读/写同一个管道。还有一种情况是,从一个空管道读(或向一个满管道写)必须阻塞,但这不是因为锁的方案的问题。

上下文切换是一个比较复杂的例子。两个内核线程,每个线程在自己的CPU上执行,可以同时调用yieldschedswtch,这些调用将并行执行。这两个线程各自持有一个锁,但它们是不同的锁,所以它们不必等待对方。但是一旦进入调度器,两个CPU在遍历进程表的时候,可能会在一个RUNABLE的进程上发生锁冲突。也就是说,xv6在上下文切换的过程中,很可能会从多个CPU中获得性能上的好处,但可能不会达到它所能达到的程度。

另一个例子是在不同的CPU上从不同的进程并发调用fork。这些调用可能需要互相等待pid_lockkmem.lock,以及在进程表中搜索一个UNUSED进程所需的进程锁。另一方面,两个正在fork的进程可以完全并行地复制用户内存页和格式化页表页。

上述每个例子中的锁方案在某些情况下都牺牲了并行性能。在每一种情况下,都有可能使用更精细的设计获得更多的并行性。这是否值得取决于实现细节:相关操作被调用的频率、代码在争用锁的情况下所花费的时间、有多少CPU可能同时运行冲突的操作、代码的其他部分是否是更具限制性的瓶颈。很难猜测一个给定的锁方案是否会导致性能问题,或者一个新的设计是否有明显的改进,所以往往需要在实际的工作负载上进行测量。