为了告诉硬件和编译器不要进行这种重拍,XV6在acquire和release中使用__sync_synchronize()。__sync_synchronize()是一个内存屏障:它告诉编译器和CPU不要越过屏障重排load和store指令。XV6acquire和release的屏障在几乎所有会出现问题的情况下强制保持顺序,后面的章节会讨论一些例外。
睡眠锁有时候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避免了无锁编程带来的额外复杂性。