xv6 book Chapter6 中文翻译

〇、前言

本文是 xv6 book 第六章的翻译,以下将开始翻译。

一、(翻译)第六章 锁

大多数内核,包括 xv6,在执行多个活动时会交错执行。一个交错的来源是多处理器硬件:拥有多个独立执行的CPU的计算机,例如 xv6 的 RISC-V。这些多个CPU共享物理RAM,而 xv6 利用这种共享来维护所有CPU都可以读写的数据结构。这种共享引发了一个可能性,即一个CPU在读取数据结构时,另一个CPU可能正在更新它,甚至多个CPU可能同时更新同一数据;如果没有仔细设计这样的并行访问,很可能会产生不正确的结果或破坏数据结构。即使在单处理器上,内核也可能在多个线程之间切换CPU,导致它们的执行被交错。最后,一个设备中断处理程序修改与一些可中断代码相同的数据可能在中断发生时损坏数据,如果中断发生的时间恰好不对的话。并发一词指的是多个指令流由于多处理器的并行性、线程切换或中断而交错执行的情况。

内核中充满了被并发访问的数据。例如,两个CPU可以同时调用 kalloc,从空闲列表的头部并发弹出。内核设计者喜欢允许大量并发,因为它可以通过并行性提高性能,并增加响应性。然而,结果是内核设计者花费了大量的精力来确保即使在这种并发情况下也能正确运行。有许多方法可以得到正确的代码,其中一些比其他方法更容易理解。旨在在并发情况下保持正确性的策略和支持它们的抽象称为并发控制技术。

Xv6根据情况使用了许多并发控制技术,还有许多其他可能的方法。本章重点介绍了一种广泛使用的技术:锁。锁提供互斥性,确保一次只有一个CPU可以持有锁。如果程序员将每个共享数据项与一个锁相关联,并且代码在使用数据项时始终持有相关联的锁,那么该数据项将一次只被一个CPU使用。在这种情况下,我们说该锁保护了数据项。虽然锁是一种易于理解的并发控制机制,但锁的缺点是它们可能会降低性能,因为它们使并发操作串行化。本章的其余部分解释了为什么 xv6 需要锁,以及 xv6 如何实现它们以及如何使用它们。
在这里插入图片描述

图 6.1:简化的对称多处理器架构

6.1 竞争条件

举例说明为什么我们需要锁,考虑两个进程在两个不同的 CPU 上调用 waitwait 释放子进程的内存。因此,在每个 CPU 上,内核将调用 kfree 释放子进程的页面。内核分配器维护一个链表:kalloc() (kernel/kalloc.c:69) 从空闲页面列表中取出一个页面内存,而 kfree() (kernel/kalloc.c:47) 则将一个页面推送回空闲列表。为了获得最佳性能,我们可能希望这两个父进程的 kfree 操作并行执行,而不必等待对方,但是在 xv6 的 kfree 实现中这是不正确的。

图 6.1 更详细地说明了这种情况:链表存在于两个 CPU 共享的内存中,这两个 CPU 使用 loadstore 指令来操作链表。(实际上,处理器有缓存,但在概念上,多处理器系统的行为就好像有一个单一的共享内存。)如果没有并发请求,您可以按以下方式实现列表的推送操作:

struct element {
    int data;
    struct element *next;
};

struct element *list = 0;

void push(int data) {
    struct element *l;

    l = malloc(sizeof *l);
    l->data = data;
    l->next = list;
    list = l;
}

在这里插入图片描述

图 6.2:竟态示例

这个实现如果单独执行是正确的。然而,如果有多个副本同时执行,那么代码就不正确了。如果两个 CPU 同时执行 push,都可能在图6.1所示的方式执行第15行,而在执行第16行之前,这将导致一个不正确的结果,正如图6.2所示。这时会有两个列表元素的 next 都设置为 list 之前的值。当在第16行发生两次赋值时,第二次赋值会覆盖第一次赋值;参与第一次赋值的元素将会丢失。

16行的丢失更新是竞争条件的一个例子。竞争条件是指同时访问内存位置的情况,其中至少有一个访问是写操作。竞争通常是一个错误的迹象,要么是丢失更新(如果访问是写操作),要么是对尚未完全更新的数据结构进行读取。竞争的结果取决于涉及的两个 CPU 的确切时序以及它们的内存操作如何被内存系统排序,这可能使由竞争引起的错误难以重现和调试。例如,在调试 push 时添加打印语句可能会改变执行的时间,足以使竞争消失。

避免竞争的通常方法是使用锁。锁确保互斥,因此只有一个 CPU 可以同时执行 push 的敏感行;这使得上述场景成为不可能。上述代码的正确加锁版本仅添加了几行代码(用黄色突出显示):

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);	// 黄色
}

acquirerelease之间的指令序列通常被称为临界区。通常说锁是在保护list

当我们说锁保护数据时,我们实际上是指锁保护了一些适用于数据的不变性集合。不变性是数据结构的属性,在操作中保持不变。通常情况下,操作的正确行为取决于不变性在操作开始时是正确的。操作可能会暂时违反不变性,但必须在完成之前重新确立它们。例如,在链表的情况下,不变性是list指向链表中的第一个元素,并且每个元素的next字段指向下一个元素。push的实现暂时违反了这个不变性:在第17行,l指向下一个列表元素,但是list还没有指向l(在第18行重新建立)。我们上面检查的竞争条件发生是因为第二个CPU执行了依赖于列表不变性的代码,而这些不变性(暂时)被违反。正确使用锁可以确保一次只有一个CPU可以在临界区上操作数据结构,因此当数据结构的不变性不成立时,不会有CPU执行数据结构操作。

你可以将锁视为串行化并发的临界区,以便一次只运行一个临界区,并因此保持不变性(假设临界区在单独的情况下是正确的)。你也可以将由同一个锁保护的临界区视为相互原子化,因此每个临界区仅看到先前临界区的完整更改集,并且永远不会看到部分完成的更新。

虽然正确使用锁可以使不正确的代码变得正确,但锁限制了性能。例如,如果两个进程同时调用kfree,锁将序列化这两个调用,我们将无法从在不同CPU上运行它们中获得任何好处。我们说多个进程发生冲突,如果它们在同一时间想要相同的锁,或者说锁遇到争用。内核设计的一个主要挑战是避免锁争用。xv6做得很少,但是复杂的内核专门组织数据结构和算法,以避免锁争用。在列表示例中,内核可以为每个CPU维护一个空闲列表,并且仅在CPU的列表为空且必须从另一个CPU窃取内存时才会访问另一个CPU的空闲列表。其他使用情况可能需要更复杂的设计。

锁的放置对性能也很重要。例如,将acquire移到push中更早的位置是正确的:将acquire的调用移动到第13行之前是可以的。这可能会降低性能,因为然后对malloc的调用也被序列化了。下面的“使用锁”部分提供了一些关于在何处插入acquirerelease调用的指导方针。

6.2 代码:锁

xv6有两种类型的锁:自旋锁(spinlocks)和睡眠锁(sleep-locks)。我们首先来看自旋锁。xv6将自旋锁表示为一个struct spinlockkernel/spinlock.h:2)。结构中的一个重要字段是locked,这是一个字,当锁可用时为零,被持有时为非零。从逻辑上讲,xv6应该通过执行如下代码来获取锁:

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

很遗憾,这个实现在多处理器上无法保证互斥性。可能会出现两个 CPU 同时到达第 25 行,发现 lk->locked 是零,然后都执行第 26 行抢占锁。在这一刻,两个不同的 CPU 持有了这个锁,这违反了互斥性质。我们需要的是一种方法,使得第 2526 行作为一个原子(即不可分割)步骤执行。

由于锁被广泛使用,多核处理器通常提供了实现第 2526 行的原子版本的指令。在 RISC-V 上,这个指令是 amoswap r, aamoswap 读取内存地址 a 处的值,将寄存器 r 的内容写入该地址,并将它读取的值放入 r 中。也就是说,它交换了寄存器和内存地址的内容。它使用特殊的硬件以原子方式执行这个序列,防止其他 CPU 在读取和写入之间使用这个内存地址。

xv6 的 acquirekernel/spinlock.c:22)使用了可移植的 C 库调用 __sync_lock_test_and_set,它归结为 amoswap 指令;返回值是 lk->locked 的旧(交换过的)内容。acquire 函数将交换包装在一个循环中,重复(自旋)直到获得锁。每次迭代都将一个值交换到 lk->locked 中并检查先前的值;如果先前的值是零,那么我们已经获得了锁,交换将把 lk->locked 设置为一。如果先前的值是一,那么其他一些 CPU 拥有了这个锁,而我们原子性地将一交换到 lk->locked 中并没有改变它的值。

一旦锁被获取,acquire 为调试记录了获取锁的 CPU。lk->cpu 字段由锁保护,必须在持有锁的情况下才能更改。release 函数(kernel/spinlock.c:47)是 acquire 的反向操作:它清除 lk->cpu 字段,然后释放锁。从概念上讲,释放只需要将零赋给 lk->locked。C 标准允许编译器使用多个存储指令来实现赋值,因此 C 赋值在并发代码方面可能是非原子的。相反,release 使用 C 库函数 __sync_lock_release 执行原子赋值。

6.3 Code: Using locks

xv6 在许多地方使用锁来避免竞争条件。正如上文所述,kallockernel/kalloc.c:69)和 kfreekernel/kalloc.c:47)是一个很好的例子。尝试练习1和2,看看如果这些函数省略了锁会发生什么。你可能会发现很难触发错误行为,这表明要可靠地测试代码是否没有锁定错误和竞态条件是很困难的。不排除 xv6 存在一些竞争条件的可能性。

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

上述规则说明了何时需要锁,但没有说明何时不需要锁,而且不锁定太多对于效率来说很重要,因为锁会减少并行性。如果并行性不重要,那么可以安排只有一个线程,并且不必担心锁。一个简单的内核可以在多处理器上通过拥有一个必须在进入内核时获取并在退出内核时释放的单个锁来实现(尽管管道读取或等待等系统调用可能会带来问题)。许多单处理器操作系统已经使用这种方法转换为在多处理器上运行,有时被称为“大内核锁”,但这种方法会牺牲并行性:一次只能有一个 CPU 在内核中执行。如果内核进行任何重型计算,使用更大的、更细粒度的锁集可能会更有效,这样内核就可以在多个 CPU 上同时执行。

作为粗粒度锁的例子,xv6 的 kalloc.c 分配器具有一个由单个锁保护的自由列表。如果不同 CPU 上的多个进程同时尝试分配页面,每个进程都将通过在 acquire 中旋转来等待轮到自己。旋转会降低性能,因为这不是有用的工作。如果为了锁的争用浪费了大量 CPU 时间,也许通过更改分配器设计,使用多个带有自己锁的自由列表,来允许真正并行的分配,可以提高性能。

作为细粒度锁的例子,xv6 每个文件都有一个单独的锁,因此操纵不同文件的进程通常可以在不等待对方锁定的情况下继续执行。如果希望允许进程同时写入同一文件的不同区域,文件锁定方案可以被做得更细粒度。最终,锁粒度的决策需要根据性能测量以及复杂性考虑来做出。

随后的章节将解释 xv6 的各个部分,并提到 xv6 处理并发性的示例。作为预览,图 6.3 列出了 xv6 中的所有锁。

6.4 死锁和锁定顺序

如果内核中的某个代码路径必须同时持有多个锁,重要的是所有代码路径以相同的顺序获取这些锁。如果不这样做,就有发生死锁的风险。假设 xv6 中有两个代码路径需要锁 AB,但是代码路径 1 按照 A 然后 B 的顺序获取锁,而另一条路径按照 B 然后 A 的顺序获取。假设线程 T1 执行代码路径 1 并获取了锁 A,线程 T2 执行代码路径 2 并获取了锁 B。接下来 T1 将尝试获取锁 B,而 T2 将尝试获取锁 A。两次获取都会无限期地阻塞,因为在两种情况下,另一个线程持有所需的锁,且在其获取返回之前不会释放。为了避免这种死锁,所有代码路径必须以相同的顺序获取锁。全局锁获取顺序的需要意味着锁实际上是每个函数规范的一部分:调用者必须以使锁按约定的顺序获取的方式调用函数。

描述
bcache.lock保护块缓冲区缓存条目的分配
cons.lock序列化对控制台硬件的访问,避免混合输出
ftable.lock序列化文件表中 struct file 的分配
icache.lock保护 inode 缓存条目的分配
vdisk_lock序列化对磁盘硬件和 DMA 描述符队列的访问
kmem.lock序列化内存分配
log.lock序列化对事务日志的操作
pipe’s pi->lock序列化对每个管道的操作
pid_lock序列化对 next_pid 的增量操作
proc’s p->lock序列化对进程状态的更改
tickslock序列化对计时器 ticks 的操作
inode’s ip->lock序列化对每个 inode 及其内容的操作
buf’s b->lock序列化对每个块缓冲区的操作
图 6.3:xv6 中的锁

xv6 中涉及进程锁(每个 struct proc 中的锁)的长度为两的锁定顺序链非常多,这是因为 sleep 的工作方式(参见第 7 章)。例如,consoleintrkernel/console.c:138)是处理输入字符的中断例程。当新行到达时,任何等待控制台输入的进程都应该被唤醒。为了做到这一点,consoleintr 在调用 wakeup 时持有 cons.lock,后者获取等待进程的锁以唤醒它。因此,全局避免死锁的锁定顺序包括一个规则,即在获取任何进程锁之前必须获取 cons.lock。文件系统代码包含了 xv6 最长的锁链。例如,创建文件需要同时持有目录锁、新文件的 inode 锁、磁盘块缓冲区的锁、磁盘驱动程序的 vdisk_lock 和调用进程的 p->lock。为避免死锁,文件系统代码始终按照前文提到的顺序获取锁。

遵循全局避免死锁的顺序可能会出人意料地困难。有时锁定顺序与逻辑程序结构冲突,例如,可能代码模块 M1 调用模块 M2,但是锁定顺序要求在 M1 中的某个锁在 M2 之前获取。有时锁的身份事先不知道,也许是因为必须持有一个锁才能发现下一个要获取的锁的身份。这种情况在文件系统中查找路径名中的连续组件时以及 waitexit 的代码中可能出现,因为它们在进程表中搜索子进程。

最后,避免死锁的危险通常限制了锁定方案的粒度,因为更多的锁通常意味着更多的死锁可能性。避免死锁的需求通常是内核实现的一个主要因素。

6.5 锁和中断处理程序

一些 xv6 自旋锁保护着同时被线程和中断处理程序使用的数据。例如,clockintr 定时器中断处理程序可能会在大约相同的时间内增加 tickskernel/trap.c:163),而内核线程在 sys_sleepkernel/sysproc.c:64)中读取 ticks。锁 tickslock 串行化了这两个访问。

自旋锁和中断的交互引发了潜在的危险。假设 sys_sleep 持有 tickslock,并且它的 CPU 被定时器中断打断。clockintr 将尝试获取 tickslock,发现它被持有,并等待释放。在这种情况下,tickslock 将永远不会被释放:只有 sys_sleep 可以释放它,但是 sys_sleep 不会继续运行直到 clockintr 返回。所以 CPU 会发生死锁,并且任何需要这两个锁的代码也会被冻结。

为了避免这种情况,如果自旋锁被中断处理程序使用,CPU 不能在启用中断的情况下持有该锁。Xv6 更加保守:当一个 CPU 获取任何锁时,xv6 总是在该 CPU 上禁用中断。中断仍然可能发生在其他 CPU 上,因此中断的获取可以等待线程释放自旋锁,但不能在同一个 CPU 上。

当 CPU 不持有自旋锁时,xv6 会重新启用中断;它必须做一些书面工作来应对嵌套的关键部分。acquire 调用 push_offkernel/spinlock.c:89)和 release 调用 pop_offkernel/spinlock.c:100)来跟踪当前 CPU 上锁的嵌套级别。当计数达到零时,pop_off 将恢复最外层关键部分开始时存在的中断使能状态。intr_offintr_on 函数执行 RISC-V 指令来分别禁用和启用中断。

acquire 在设置 lk->lockedkernel/spinlock.c:28)之前严格调用 push_off 是很重要的。如果两者颠倒了,会存在一个短暂的窗口期,在这个时段锁被持有但中断被启用,一个不幸时机的中断会使系统发生死锁。同样地,release 在释放锁之后(kernel/spinlock.c:66)才调用 pop_off 也是很重要的。

6.6 指令和内存排序

我们自然地认为程序按照源代码语句的顺序执行。然而,许多编译器和 CPU 为了提高性能会乱序执行代码。如果一条指令需要很多周期才能完成,CPU 可能会提前发出该指令,这样可以与其他指令重叠执行,避免 CPU 停顿。例如,CPU 可能注意到在一个指令序列中,指令 AB 之间没有相互依赖。CPU 可能先开始执行指令 B,要么是因为它的输入在指令 A 的输入之前就准备好了,要么是为了重叠执行 AB。编译器也可以通过发出某个语句的指令来重排序,这些指令会比源代码中紧随其后的语句的指令更早地执行。

当编译器和 CPU 重新排序时,它们会遵循规则,以确保不改变正确编写的串行代码的结果。然而,这些规则允许对并发代码进行更改,很容易在多处理器上导致不正确的行为 [2, 3]。CPU 的排序规则被称为内存模型。

例如,在这段 push 代码中,如果编译器或 CPU 将与第 4 行相对应的存储移动到第 6 行的释放操作之后,那将是一个灾难:

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

如果发生这样的重排序,就会存在一个时间窗口,另一个 CPU 可以获取锁并观察到已更新的列表,但是看到了未初始化的 list->next

为了告诉硬件和编译器不要执行这种重新排序,xv6 在 acquirekernel/spinlock.c:22)和 releasekernel/spinlock.c:47)中使用了 __sync_synchronize()__sync_synchronize() 是一个内存屏障:它告诉编译器和 CPU 在屏障之间不要重新排序加载或存储操作。xv6 中 acquirerelease 中的屏障在几乎所有重要情况下都强制排序,因为 xv6 在访问共享数据时使用了锁。第9章讨论了一些例外情况。

6.7 休眠锁

有时,xv6 需要长时间持有锁。例如,文件系统(第 8 章)在读写磁盘上的内容时会保持文件锁定,而这些磁盘操作可能需要数十毫秒的时间。如果长时间持有自旋锁,另一个进程想要获取它时会导致资源浪费,因为获取进程在自旋等待期间会长时间占用 CPU。自旋锁的另一个缺点是,进程在保持自旋锁的同时无法让出 CPU;我们希望这样做,这样其他进程可以在拥有锁的进程等待磁盘时使用 CPU。在持有自旋锁的同时放弃 CPU 控制是不允许的,因为如果第二个线程尝试获取自旋锁,这可能导致死锁;由于 acquire 操作不会让出 CPU,第二个线程的自旋可能会阻止第一个线程运行并释放锁。在持有锁的同时放弃 CPU 控制也会违反自旋锁持有期间必须关闭中断的要求。因此,我们希望有一种在等待获取锁时放弃 CPU 控制,并且在持有锁期间允许放弃 CPU 控制(和中断)的锁类型。

xv6 提供了这种锁,称为休眠锁。acquiresleepkernel/sleeplock.c:22)在等待期间放弃 CPU 控制,使用的技术将在第 7 章中解释。在高层次上,休眠锁有一个由自旋锁保护的 locked 字段,并且 acquiresleep 的调用在原子操作中放弃了 CPU 控制并释放了自旋锁。结果是,acquiresleep 在等待期间其他线程可以执行。

因为休眠锁允许中断,所以它们不能在中断处理程序中使用。因为 acquiresleep 可能放弃 CPU 控制,所以不能在自旋锁关键区域内使用休眠锁(尽管自旋锁可以在休眠锁关键区域内使用)。

自旋锁最适用于短期关键区域,因为等待它们会浪费 CPU 时间;而休眠锁适用于长时间操作。

6.8 现实世界

尽管对并发原语和并行性进行了多年的研究,但使用锁进行编程仍然具有挑战性。通常最好将锁隐藏在诸如同步队列之类的更高级别结构内,尽管 xv6 没有这样做。如果您使用锁进行编程,最好使用一种工具来尝试识别竞态条件,因为很容易忽略需要锁定的不变量。大多数操作系统支持 POSIX 线程(Pthreads),它允许用户进程在不同 CPU 上并发运行多个线程。

Pthreads 支持用户级别的锁、屏障等。支持 Pthreads 需要操作系统的支持。例如,如果一个 pthread 在系统调用中阻塞,同一进程的另一个 pthread 应该能够在该 CPU 上运行。另一个例子是,如果一个 pthread 改变了其进程的地址空间(例如,映射或取消映射内存),内核必须安排其他运行同一进程线程的 CPU 更新其硬件页表,以反映地址空间的更改。

有可能实现没有原子指令的锁,但这是昂贵的,大多数操作系统使用原子指令。

如果许多 CPU 尝试在同一时间获取相同的锁,则锁可能很昂贵。如果一个 CPU 在其本地缓存中缓存了一个锁,而另一个 CPU 必须获取该锁,则用于更新保存锁的缓存行的原子指令必须将该行从一个 CPU 的缓存移动到另一个 CPU 的缓存,并可能使任何其他缓存行的副本无效。从另一个 CPU 的缓存中获取缓存行的成本可能比从本地缓存中获取的成本高出几个数量级。

为了避免与锁相关的费用,许多操作系统使用无锁数据结构和算法[5, 10]。例如,可以实现类似本章开头的链表,而在列表搜索期间不需要锁,并且在列表中插入项目只需要一个原子指令。然而,无锁编程比使用锁更加复杂;例如,必须担心指令和内存重新排序。由于锁本身就已经很难,因此 xv6 避免了无锁编程的额外复杂性。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值