XV6学习(10)锁

1,000 阅读18分钟

在包括XV6的绝大部分操作系统都是多个任务交错执行的。交错的一个原因是多核硬件:多核计算机的多个CPU核心独立执行计算,如XV6的RISC-V处理器。多个CPU核心共享物理内存,XV6利用这种共享来维护所有核心都会读写的数据结构。而这种共享会导致一个CPU在读取某数据结构时,可能有另一个CPU正在对此数据进行更新;或者多个CPU同时更新同一个数据。如果不对这种并行访问进行小心的设计,就可能会导致错误的结果产生或者损坏数据。即使是单核处理器,内核也可能会在多个线程之间进行切换,导致它们交错运行。最后,如果中断在错误的时间发生,设备中断处理程序也可能会对数据造成损坏。并发一词就是指由于多核并行、线程切换或中断,导致多个指令流交错执行。

内核中充满了被并发访问的数据。如两个CPU可以同时调用kalloc,从而同时从空闲链表的中弹出空闲页。内核设计者必须允许大量并发,因为并发可以提高系统的性能和响应速度。然而,系统设计者需要耗费很多精力来保证并发的正确性。有很多种方法可以写出正确的代码,其中有一些比其他更容易推理。针对并发的正确性以及支持它们的抽象的策略被称为并发控制技术。

XV6基于不用的情况使用了多种并发控制技术,并且还有更多技术可以使用。其中一个广泛使用的技术就是锁。锁可以提供互斥性,保证同一时间只有一个CPU能够持有锁。如果程序员将共享数据与锁进行关联,在代码使用这些数据时就必须持有相应的锁,这样就可以保证同一时间只有一个CPU能使用该数据。尽管锁是一种容易理解的并发控制机制,但锁的缺点是其会降低性能,因为锁将并发操作串行化。

竞争条件

假如两个进程在不同的CPU上同时调用wait函数释放子进程内存,导致在每个CPU上,内核都会调用kfree来释放子进程的页面。内核维护了一个空闲页面链表,kalloc会pop一个页面,而kfree会push一个页面。为了最佳的性能,我们希望两个父进程的kfree能够并行执行而不需要等待另一个,但是在XV6的kfree实现中是不正确的。

一种竞争条件是指一个内存位置被并发访问,并且至少一个访问是写入。竞争通常是bug的信号,要么更新发生丢失,要么读取到不完整的数据更新。竞争的结果取决于两个CPU执行的实际时间以及对内存的操作如何被内存系统排序,这些会使得竞争导致的bug难以复现和调试。例如插入print语句来调试可能会改变执行的时间从而使得竞争消失。

struct element *list = 0;
struct lock listlock;

void
push(int data)
{
  struct element *l;
  l = malloc(sizeof *l);
  l->data = data;
  acquire(&listlock);
  l->next = list;
  list = l;
  release(&listlock);
}

当我们说锁保护了数据,实际的意思是锁保护了应用在数据上的一系列不变性。一个操作的正确性取决于操作开始时的不变性是否正确。操作可能会暂时违反不变性,但必须在在操作结束前恢复其不变性。例如对于链表,不变性时指头指针指向第一个元素,且每一个元素的next域指向下一个元素。push操作的l->next = list会暂时破坏其不变性,使得头指针并不是指向第一个元素。竞争条件发生因为在另一个CPU上的操作依赖于不变性,而这被暂时破坏了。锁的使用可以保证在数据结构上一次只有一个CPU在临界区执行,因此不会有CPU在不变性被破坏时执行操作。

可以认为锁将并发的临界区串行化使其一次只能执行一个,从而保护了不变性。也可以认为被锁保护的临界区对于其他临界区来说是原子性的,因此每一个都只能看见一系列先前临界区的完整修改,而永远不会看见部分修改。

尽管锁的正确使用可以使错误代码变正确,但锁也限制了性能。例如当两个进程同时调用kfree,锁会将两个调用串行化,将它们在不同的CPU上运行就不会获得收益。在内核设计中一个大的挑战就是避免锁争用。XV6在这方面做的很少,但是复杂的内核会通过特殊的方法组织数据结构和算法来避免锁争用。例如内核会为每个内核维护一个独立的空闲内存链表,只有当当前CPU的链表为空时才会去请求其他CPU的链表来获取空闲内存。而其他的争用可能需要更加复杂的设计。

锁的位置同样对性能影响很大。例如在push中可以将acquire放在更加前面,但这就会降低性能,因为malloc的调用也被串行化。

代码:锁

XV6中有两种锁:自旋锁和睡眠锁。自旋锁定义为struct spinlock,最重要的域就是locked,1代表被持有而0代表未被持有。理论上xv6可以通过下列代码来上锁:

void
acquire(struct spinlock *lk) // does not work!
{
  for(;;) {
    if(lk->locked == 0) {
      lk->locked = 1;
      break;
    }
  }
}

然而不幸地是在多处理器上这种实现不会达到互斥。当两个CPU同时对locked进行读取并且结果为0时,它们都会获得这个锁,而这就会违反互斥的性质。因此我们需要第5第6行的执行原子化。

由于锁的广泛使用,多核处理器通常会提供该原子指令。在RISC-V中为amoswap r, a,该指令会交换ra的值。该指令是原子性的,其会通过特殊硬件来防止其他CPU在读写时使用该内存地址。

XV6的acquire使用可移植的C库函数__sync_lock_test_and_set,而其在底层是使用amoswap实现的。函数返回值是locked的旧值。acquire函数在循环中不停(自旋)调用swap直到其获得锁。每次循环将1swaplocked中,并判断旧值是否为0,为0就说明获取到了锁,同时swap也将locked设置为了1。如果旧值为1,说明其他CPU持有锁,而swap也并没有改变locked的值。

当获取到了锁,acquire就会为了调试而记录获取锁的CPU。lk->cpu域是被锁保护的并且必须在获取锁后才能被改变。

release函数则与acquire相反;该函数清空cpu域并释放锁。理论上释放只需要将locked域置0。而C语言标准运行编译器使用多存储指令来实现赋值,因此一条赋值语句可能不是原子的。因此,release使用C库函数__sync_lock_release来进行原子性赋值。该函数底层也是通过amoswap指令实现。

代码:使用锁

XV6在许多地方都使用锁来避免竞争条件。kallockfree是一个很好的例子。使用锁的一个难点是决定要使用多少锁以及每个锁要保护哪些数据和不变性。这里有几个基本原则:首先当一个变量在被一个CPU写入时,有其他CPU可以对其读写时,应该使用锁来避免两个操作重叠;第二,记住锁所保护的不变性,如果一个不变性涉及多个内存位置,则所有的位置都需要被一个单独的锁来保护,从而保证不变性。

上述只说了锁什么时候是必要的而没有锁什么时候是不必须的,而减少锁的数量对效率来说是很重要的,因为锁减少了并行。如果并行不是必须的,那么可以只使用一个线程从而不必考虑锁的问题。简单内核可以在多处理器上只使用一个锁,当进入内核态时获取锁,离开时释放锁(尽管系统调用如管道的读和wait将会产生问题)。很多单处理器系统使用这种方法来在多处理器上运行,有的时候被成为“大内核锁”。但是,这种方法破坏了并行性:一次只有一个CPU可以在内核中执行。如果内核要进行任何重计算任务,使用一系列的锁会更加高效,内核可以同时在多个CPU上运行。

作为粗粒度锁的例子,XV6的kalloc.c分配器只有一个被一个锁保护的空闲链表。如果多个进程在不同CPU上同时尝试申请页面,那么每一个都需要在acquire中自旋等待。自旋是在做无用功从而降低了性能。如果锁的争用浪费了大量时间,那么可能就要通过改变分配器的设计来提高性能,使用多个空闲链表,每个链表单独持有锁,从而允许真正的并行分配。

作为细粒度锁的例子,XV6对于每个文件都有一个单独的锁,因此操作不同文件的进程可以无需等待其他的文件的锁。文件锁模式的粒度可以变得更加的细,如果希望进程同时写相同文件的不同区域。总而言之,锁的粒度需要由性能度量以及锁的复杂性的考虑来决定。

XV6中使用的锁如下表所示:

lockDescription
bcache.lockProtects allocation of block buffer cache entries
cons.lockSerializes access to console hardware, avoids intermixed output
ftable.lockSerializes allocation of a struct file in file table
icache.lockProtects allocation of inode cache entries
vdisk_lockSerializes access to disk hardware and queue of DMA descriptors
kmem.lockSerializes allocation of memory
log.lockSerializes operations on the transaction log
pipe’s pi->lockSerializes operations on each pipe
pid_lockSerializes increments of next_pid
proc’s p->lockSerializes changes to process’s state
tickslockSerializes operations on the ticks counter
inode’s ip->lockSerializes operations on each inode and its content
buf’s b->lockSerializes operations on each block buffer

死锁和锁顺序

如果代码在内核中需要同时持有多个锁,那么有一点很重要,就是获取锁的顺序要相同。如果顺序不同,那么就会有死锁的风险。假设XV6中两个代码路径要获取锁A和B,但是路径1先获取A再获取B,而另一条路径先获取B再获取A。假设线程T1执行代码路径1并获取了锁A,而线程T2执行路径2并获取了锁B。那么接下来T1会尝试获取获取锁B而T2会尝试获取锁A。两个获取就肯定都会被阻塞,因为另一个线程持有需要的锁,并且不会释放直到它的acquire返回。为了避免这种死锁,所有代码路径必须以同样的顺序获取锁。对全局锁获取顺序的需要意味着锁实际上是每个函数规范的一部分:调用者必须以某种方式调用函数,使锁按约定的顺序被获取。

XV6有很多长度为2的涉及每个进程的锁的锁顺序链,因为在路径上sleep函数会工作。例如consoleintr是处理输入字符的中断程序。当一个新行到达,任何等待控制台输入的进程就会被唤醒。当调用wakeupconsoleintr持有cons.lock,而wakeup又会获取等待进程的锁来唤醒它。因此,避免全局死锁的锁顺序包含cons.lock锁必须在每个进程锁之前被获取的规则。文件系统代码包含XV6的最长的锁链。例如创建文件需要同时获取目录上的锁,新文件的inode的锁,磁盘块缓冲区的锁,磁盘驱动的vdisk_lock以及调用进程的锁。为了避免死锁,文件系统代码通常要求以一定顺序来获取锁。

遵守避免全局死锁的顺序可能会十分困难。有时候锁顺序会与程序结构的逻辑冲突,如模块M1调用模块M2,但是锁顺序要求M2的锁在M1之前获取。有时候也无法预知需要的锁,可能获取一个锁之后才能直到下一个锁是什么。这种情况出现于在文件系统中根据路径名连续查找模块以及waitexit函数在进程表中查找子进程中。最后,死锁的危险通常会限制锁策略能够使用多细粒度的锁,越多的锁就意味着越多死锁的可能性。在内核实现中,避免死锁通常是很重要的一部分。

锁和中断处理程序

XV6中有一些自旋锁保护了会同时被线程和中断处理程序使用的数据。例如clockintr定时器中断处理程序可能会增加ticks当一个内核线程同时在sys_sleep函数中读取tickstickslock锁会将两个访问串行化。

自旋锁和中断的交互会带来潜在的风险。假设sys_sleep持有锁,并且CPU产生了一个定时器中断。clockintr将会尝试申请锁,发现锁被持有,于是等待其被释放。在这种情况下,锁将永远不会被释放:只有sys_sleep会释放锁,但是sys_sleep不会释放锁直到clockintr返回。因此CPU会进入死锁状态,并且其他需要该锁的代码都会被冻结。

为了避免这种情况,如果一个自旋锁在中断处理程序中被使用,CPU必须在中断允许时不会持有该锁。XV6更加保守:当CPU申请任何锁时,XV6总是会在该CPU上关闭中断。中断可能仍在其他CPU上发生,因此中断的acquire可以等待一个线程释放自旋锁,只要不在同一个CPU上。

XV6会重新允许中断当一个CPU不再持有自旋锁,必须通过一些小的记录来处理嵌套临界区。acquire调用push_offrelease调用pop_off来追踪当前CPU的嵌套的层次。当计数器为0时,pop_off恢复最外层临界区开始前的中断允许状态。intr_offintr_on函数分别执行RISC-V的关和开中断指令。

acquire设置lk->locked之前调用push_off是非常重要的。如果两者顺序交换,就会有一个小窗口,此时锁被获取而中断是允许的,如果不幸此时发生了中断,就可能会使系统死锁。相同的,release释放锁之后再调用pop_off也是很重要的。

指令和内存顺序

自然地会认为程序以源代码语句出现的顺序来执行程序。但在很多编译器和CPU中,代码是乱序执行的从而来获得更高的性能。如果一条指令需要很多个周期来完成,CPU可能会更早地发射指令,使其与其他指令重叠,从而避免CPU停顿。例如CPU可能注意到一串指令序列A和B不依赖彼此,CPU就可能会先执行指令B,因为其输入比A的输入更早就绪或者为了将A和B的执行重叠起来。编译器也可能会进行类似的重排,通过先于源代码中之前的语句的指令发射一条语句的指令。

编译器和CPU允许通过不会改变一串代码执行结果的规则来重排语句。然而,这些允许重排的规则会改变并发代码的结果,并且很容易在多处理器上导致错误的行为。CPU的排序规则被成为内存模型。

例如push的代码,如果CPU将第4行对应的store指令移动到release之后就会引起灾难:

l = malloc(sizeof *l);
l->data = data;
acquire(&listlock);
l->next = list;
list = l;
release(&listlock);

如果这种重排发生了,这里就会有一个窗口使得其他CPU可以获取锁并且更新list,但是看见的并不是初始的list->next

为了告诉硬件和编译器不要进行这种重排,XV6在acquirerelease中使用__sync_synchronize()__sync_synchronize()是一个内存屏障:它告诉编译器和CPU不要越过屏障重排load和store指令。XV6acquirerelease的屏障在几乎所有会出现问题的情况下强制保持顺序,后面的章节会讨论一些例外。

睡眠锁

有时候XV6需要长时间持有一个锁。例如文件系统在磁盘上读写文件内容时持有一个文件锁,而这些磁盘操作会耗费数十毫秒。当其他进程要获取锁时,长时间持有自旋锁会引起很大的浪费,因为申请的进程会长时间浪费CPU在自旋上。自旋锁的另一个缺点就是当其保持自旋锁时进程不会让出CPU,我们希望当持有锁的进程在等待磁盘时其他进程能使用CPU。当持有自旋锁时让出CPU是非法的,因为如果第二个线程尝试获取自旋锁时,这可能会导致死锁;因为acquire不会让出CPU,第二个进程的自旋可能会阻止第一个线程运行和释放锁。当持有锁时让出CPU同样违反了当自旋锁被持有时中断必须关闭的需求。因此我们需要一种当acquire等待时能让出CPU以及允许持有锁时让出(和中断)的锁。

XV6提供了睡眠锁这种锁、acquiresleep使用下一章讲到的技术使其在等待时会让出CPU。在高层来看,睡眠锁有一个被自旋锁保护的locked域,而acquiresleep调用sleep会原子地让出CPU并释放自旋锁。这使得其他线程可以在acquiresleep等待时执行。

因为睡眠锁使中断允许,因此它们不能被用在中断处理程序中。因为acquiresleep会让出CPU,睡眠锁不能在自旋锁保护的临界区内使用(尽管自旋锁可以在睡眠锁保护的临界区内使用)。

自旋锁最好在短临界区使用,因为等待它们会浪费CPU时间;睡眠锁在长时间操作上表现更好。

真实操作系统

尽管并发原语和并行被研究了很多年,锁编程仍然是十分有挑战性的。最好是将锁隐藏在更高级的结构如同步队列中,尽管XV6没有这样做。如果你使用锁来编程,最好使用工具来标识临界区,因为很容易忽略需要获得锁的不变性。

大部分操作系统支持POSIX threads(pthreads),这允许用户在不同CPU上并发运行多个线程。Pthreads支持用户级别锁和屏障等。Pthread的支持需要得到操作系统的支持。例如当一个pthread在系统调用中阻塞时,同一个进程的其他pthread应该能够在这个CPU上运行。另一个例子是当一个pthread改变了进程的地址空间(如内存映射),内核应该安排其他运行相同进程的线程的CPU更新页表硬件来映射地址空间上的修改。

是有可能不使用原子指令来实现锁的,但是其代价非常高昂,并且大部分操作系统都是使用原子指令。

如果很多CPU尝试同时获取一个锁时,锁的代价是非常高昂的。如果一个CPU在本地cache中缓存了一个锁,其他CPU必须获取这个锁,之后原子指令会更新cache行,持有锁必须将cache行从一个CPU的cache复制到其他CPU的cache,并且可能需要使cache行的其他所有内容失效。从另一个CPU的cache中获取cache行的代价可能比从本地cache中获取行要高几个数量级。

为了避免锁相关的高昂代价,许多操作系统使用无锁数据结构和算法。如上文中提到的多个空闲内存链表。然而无锁编程比锁编程要更加复杂;例如必须考虑指令和内存重排。锁编程已经很困难了,因此XV6避免了无锁编程带来的额外复杂性。