Loading

MIT6.S081 ---- Preparation: Read chapter 6

Chapter 6 Locking

许多内核,包括 xv6 ,交替执行多个任务。交替执行的原因之一是多处理器硬件:计算机有多个独立执行的 CPUs,如 xv6 的 RISC-V。多个 CPUs 共享物理 RAM,xv6 利用这种共享维护所有 CPU 都能读写的数据结构。
这种共享产生了一种可能:一个 CPU 正在读一个数据结构,而另一个 CPU 正在更新这个数据结构,甚至多个 CPU 同时更新同一个数据;不经心设计这种并发访问可能产生错误的结果或被破坏的数据结构。
即使在单处理器上,内核也可能在多个线程间切换 CPU,造成多个线程交替执行。
最后,如果一个中断发生在错误的时间,设备中断处理程序修改了被中断程序使用的数据,则会破坏这个数据。并发(\(concurrency\))指的是由于多处理器并行、线程切换、中断导致多个指令流交替执行的情况。

内核有很多并发访问的数据。如,两个 CPUs 可能同时调用 kalloc,因此同时取出空闲页列表的头部。内核设计者喜欢并发,因为能提高响应能力,能通过并行提高性能。然而,因为并发性,内核设计者需花费大量精力验证正确性。有许多方法可以获得正确的代码,一些方法更容易推理验证。并发正确性的策略以及支持这些策略的抽象,被称为并发控制技术(\(concurrency\ control\ techniques\))。

xv6 根据情况使用了很多并发控制技术,还有很多其他技术可。本章关注一种广泛使用的技术:锁(\(lock\))。锁提供了互斥(mutual exclusion),确保同一时间只有一个 CPU 能持有这个锁。如果程序员将锁与每个共享数据项关联,当使用一个数据项时必须先持有锁,则同一时间该项只能由一个 CPU 使用。这种情况下,我们称锁保护了这个数据项。尽管,锁是一种容易理解的并发控制机制,但缺点是锁会降低性能,因为他们会序列化并发操作。

本章将介绍 xv6:为什么需要锁?如何实现锁?如何使用锁?

Race conditions

为什么需要锁的一个例子:考虑两个进程在不同的 CPUs 上调用 waitwait 释放子进程的内存。因此,在每个 CPU 上,内核都会调用 kfree 释放子进程的物理页。内核物理页分配器维护了一个链表:kalloc()(kernel/kalloc.c)从空闲页链表中取出一页物理内存,kfree()(kernel/kalloc)将物理页加入空闲页链表。为了最好的性能,我们希望两个父进程并行执行 kfree(),而不是互相等待,但 xv6 的 kfree() 实现可能会得到错误的结果。

6.1

图6.1描述了:内存中的链表被两个 CPUs 共享,CPUs 使用 L/S 指令操作链表(实际处理器有 caches,但是概念上多处理器系统使用单一的、共享的内存)。如果没有并发请求,可以用如下代码实现 push

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(),可能都在执行 \(16\) 行前执行了 \(15\) 行,这会导致不正确的结果(图6.2所示)。两个链表项的 next 指向了 list 的头,当两个进程执行 \(16\) 行时,第二个执行的将覆盖第一个执行的,第一个的链表项将丢失。

\(16\) 行丢失的更新可以看作竞争条件(\(race\ condition\))的例子。
竞争条件是并发访问内存的一个地址,至少有一个访问是写操作。一个竞争经常代表一个 bug,或者一个丢失的更新(如果访问有写操作),或者一次读取未完全更新的数据结构。
竞争的结果取决于相关的 CPUs 运行的准确时间,以及内存系统安排的内存操作的顺序,这使得竞争导致的错误难以复现和调试。比如,在调试时增加 print 语句可能改变执行的时间,从而使竞争消失。

避免竞争的常用方法是使用锁。锁确保互斥\(mutual\ exclusion\)),所以同一时间只有一个 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 中间的代码称为临界区\(critical\ section\))。

锁保护数据,实际上是保护应用于数据的不变量集合。
不变量(Invariants)是跨操作维护的数据结构的属性(我理解这里的 invariants 是一些变量的含义在操作中可以修改,但是在操作前和操作后应该是一致的)。通常,一个操作是正确的行为取决于当操作开始时不变量是否为真。操作可能临时违反不变量,但是在结束之前必须重建不变量。
例如,在链表例子中,不变量是: list 指向了链表的第一个元素;每一个元素的 next 域指向下一个元素。push的实现暂时违反了这个不变量:l->next = list;l 指向了下一个链表元素,但是 list 没有指向第一个链表元素(在 list = l; 中重建)。我们研究的竞争条件发生的原因:第二个 CPU 执行的代码依赖于 list 不变量,然而这些代码(暂时)违反了不变量。
锁的正确使用确保了一个时间只能有一个 CPU 能操作临界区的数据结构,所以没有获得数据结构的不变量,CPU 就不会执行数据结构的操作。

可以将锁看作序列化(\(serializing\))并发临界区,每次只能运行一个,因此确保了不变量(假设独立的临界区是正确的)。也可以认为被同一个锁保护的临界区的访问是原子的(\(atomic\)),这样每个进程只能看到临界区完整的更新,而不会看到部分更新。

尽管使用锁能使不正确的代码变得正确,但锁限制了性能。
例如,如果两个进程并发调用 kfree(),锁会序列化两个调用,不能发挥多 CPUs 的优势。如果多进程同时需要同一个锁,或者发生锁竞争(\(lock\ contention\)),则多个进程冲突(\(conflict\))。内核设计的一个大的挑战是避免 \(lock\ contention\)。xv6 没有做这方面工作,但是成熟的内核特别组织数据结构和算法避免 lock contention。
在链表的例子中,内核可能为每个 CPU 维护一个空闲链表,只有当 CPU 的 list 为空时且必须从另一个 CPU 那里获取内存数据时,才会影响另一个 CPU 的空闲 list。
其他使用情况可能需要更复杂的设计。

锁的放置位置对性能很重要。例如,将 acquire 移动到 push() 的更前面的位置,如 malloc 的前面。这会降低性能,因为 malloc 调用被序列化。“Using locks” 小节会提供在哪里插入 acquirerelease 的指导。

Code: Locks

xv6 有两种类型的锁:spinlocks 和 sleep-locks。先介绍 spinlocks。xv6 用 struct spinlock(kernel/spinlock.h) 表示 spinlock。结构体中的重要的域(field)是 locked,当锁可用时为 \(0\),锁被占有时为非 \(0\)。逻辑上来说,xv6 需要执行类似下面这样的代码获取锁:

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

不幸的是,这个实现不能保证多进程的互斥。可能会发生两个 CPU 同时执行 if(lk->locked == 0),且都认为 lk->locked\(0\),然后执行 lk->locked = 1; 都获得了锁,这时,两个不同的 CPU 获得了锁,违反了互斥属性。我们需要将 \(5\)\(6\) 行作为原子的(\(atomic\))一步。

因为锁的广泛应用,多核处理器通常提供了实现 \(5\)\(6\) 行的原子实现。RISC-V 中这条指令是 amoswap r, aamoswap读取内存地址 a 的值,将寄存器 $r 的值写到该地址,将读取的值放入寄存器 $r。它交换寄存器和内存中的值,原子性的执行这些操作,使用特殊的硬件防止其他 CPU 读/写这个内存地址。

xv6 的 acquire(kernel/spinlock.c)使用方便的 C 库调用 __sync_lock_test_and_set,归结起来就是 amoswap 指令,返回值是旧的(被交换的) lk->lockedacquire 函数将交换封装在循环中,重试(自旋)直到获得一个锁。每次迭代将一个值和 lk->locked 交换,检查之前的值,如果是 \(0\),表明已经获得了锁,并且 swap 会设置 lk->locked\(1\)。如果之前的值是 \(1\),表明其他 CPU 占用了锁,这时的交换不会改变 lk->locked 的值。

一旦获取了锁,为了调试,acquire 会记录哪个 CPU 获取了锁。lk->cpu 域被锁保护,只有获取锁时才会改变。

函数 release(kernel/spinlock.c)与 acquire 相对:它清空 lk->cpu 域,然后释放锁。从概念上看,release 仅仅要求将 lk->locked 赋值为 \(0\)。C 标准允许编译器用多条 store 指令实现赋值语句,所以对于并发代码,一条赋值语句可能并非原子性的。release 使用 C library 函数 __sync_lock_release 实现一个原子赋值。这个函数也可以归结为 RISC-V 的 amoswap 指令。

Code: Using locks

xv6 在许多地方使用锁避免 race conditions。上述描述中,kalloc(kernel/kalloc.c)和 kfree(kernel/kalloc.c)都是很好的例子。如果撤掉这两个函数中的锁相关代码,也可能很难触发错误的行为,很难可靠的测试代码是否有锁 errors 和 races。xv6 很可能有一些 races。

使用锁的关键是:使用多少锁;锁需要保护哪些数据和不变量。有一些基本的原则:首先,一个 CPU 在另一个 CPU 读/写 变量的同时写变量,应该使用锁防止两个操作重叠;第二,锁保护不变量,如果一个变量涉及多个内存地址,通常这些地址都需要被一个锁保护,确保维护不变量。

上述规则说明了什么时候需要锁,但没有说明什么时候不需要锁,锁会影响效率,因为锁会降低并行性。如果并行性不重要,则可以只安排一个线程,那么不需要锁。一个简单的内核可以在多处理器上实现这点:通过一个锁,在进入内核时 acquire 锁,离开内核 release 锁(尽管 pipe reads 或 wait 等系统调用会出问题)。许多单处理器操作系统使用这种方法在多处理器上运行,称为 “big kernel lock”,但这种方法牺牲了并行性:同一时间只有一个 CPU 执行内核。如果内核计算任务多,使用一组更细粒度的锁会更有效,那样内核可以在多个 CPU 上同时执行。

粗粒度锁的一个例子,xv6 的 kalloc.c 的分配器有一个由单个锁保护的空闲链表,如果不同 CPU 上的多个进程尝试同时分配页,则每个进程必须在 acquire 中自旋等待。自旋会降低性能,因为它是无用的。如果争用(lock contention)浪费了一大部分的 CPU 时间,或许可以通过改变分配设计------使用多个空闲链表,每个链表有自己的锁,允许真正的并行分配,从而提升性能。

细粒度锁的一个例子,xv6 每个文件有一个独立的锁,因此操作不同文件的多个进程可以并行执行。文件锁方案可以做的更细粒度:允许多个进程同时修改一个文件的不同区域。最终,锁粒度决策需要由综合考虑性能和复杂性。

后面继续介绍使用锁处理并发。下面是 xv6 的所有锁。

Lock Description
bcache.lock Protects allocation of block buffer cache entries
cons.lock Serializes access to console hardware, avoids intermixed output
ftable.lock Serializes allocation of a struct file in file table
itable.lock Protects allocation of in-memory inode entries
vdisk.lock Serializes access to disk hardware and queue of DMA descriptors
kmem.lock Serializes allocation of memory
log.lock Serializes operations on the transaction log
pipe's p->lock Serializes operations on each pipe
pid_lock Serializes increments of next_pid
proc's p->lock Serializes changes to process’s state
wait_lock Helps wait avoid lost wakeups
tickslock Serializes operations on the ticks counter
inode’s ip->lock Serializes operations on each inode and its content
buf’s b->lock Serializes operations on each block buffer

Deadlock and lock ordering

如果通过内核的代码链必须同时持有多个锁,则所有代码链要按照同样的顺序获取锁。如果不这样,会有死锁(\(deadlock\))的风险。如果 xv6 中的两条代码链需要锁 \(A\)\(B\),但代码链 \(1\) 先获取锁 \(A\) 后获取锁 \(B\),其他代码链先获取锁 \(B\) 再获取锁 \(A\)。假设线程 \(T1\) 执行代码链 \(1\),获取锁 \(A\),线程 \(T2\) 执行代码链 \(2\),获取锁 \(B\),然后 \(T1\) 尝试获取 \(B\)\(T2\) 尝试获取 \(A\)。两个线程会无期限阻塞,因为它们都是其他线程持有需要的锁,在 acquire 返回之前不会释放它们。为了避免死锁,所有的代码链必须以相同的顺序获取锁。对于锁获取的顺序的需要意味着锁实际上是函数规范的一部分:调用者调用函数必须以约定的顺序获取锁。

因为 sleep 工作方式(Ch7 介绍),xv6 有许多长度为 \(2\) 的 lock-order chains,涉及到 proc.lock(在 struct proc 中)。例如,consoleintr(kernel/console.c)是处理键入字符的中断处理程序。当新的一行键入,等待 console 输入的进程需要被唤醒。当调用 wake 时,consoleintr() 占有 cons.lock,为了唤醒等待进程获取等待进程的锁。因此,全局避免死锁的 lock order 包括规则:cons.lock 必须在 proc.lock acquire 之前被 acquire。文件系统包含 xv6 最长的 lock chains。例如,创建一个文件要求同时占有目录锁、文件 inode 锁、硬盘块缓冲区锁、硬盘驱动的 vdisk_lock、调用进程的 p->lock。为了避免死锁,文件系统需要按照上述顺序 acquire 锁。

遵守一个全局死锁避免顺序(deadlock-avoiding order)非常困难。有时 lock order 和 程序逻辑结构相互矛盾,如,代码模块 \(M1\) 调用模块 \(M2\),但是 lock order 要求获取 \(M1\) 中的锁之前必须先获取 \(M2\) 中的锁。有时事先不知锁的身份,可能因为必须先持有一个锁,才能发现下一个 acquire 锁的身份。这种情况:出现在文件系统中,因为它在一个路径名中查找连续的组成部分;出现在 waitexit 代码中,因为需要搜索进程表查找子进程。
死锁的危险通常是限制锁方案的适合的粒度,因为更多的锁意味着更大的死锁风险。
避免死锁的需要通常是内核实现的主要因素。

\(M1\) 调用 \(M2\),则需要保证 \(M1\) 清楚 \(M2\) 内部的实现,才可以确保 lock order,但这是对程序抽象的破坏。
好的程序抽象应该做到 \(M1\) 不去关心 \(M2\) 的实现。
lock order 会使大系统难以进行模块化

Re-entrant locks

一些死锁和 lock-ordering 挑战可能通过使用可重入锁(\(re-entrant\ locks\),也称作 \(recursive\ locks\))避免。
思想是如果一个锁被一个进程占有,如果这个进程尝试再次 acquire 锁,内核允许这样(因为进程已经获取了锁)而不会向 xv6 一样调用 panic。

然而,事实证明,可重入锁使并发更难分析:可重入锁打破了 锁导致临界区相对其他临界区是原子性的常识。考虑如下两个函数 fg

struct spinlock lock;
int data = 0; // protected by lock

f() {
    acquire(&lock);
    if(data == 0){
        call_once();
        h();
        data = 1;
    }
    release(&lock);
}

g() {
    aquire(&lock);
    if(data == 0){
        call_once();
        data = 1;
    }
    release(&lock);
}

看上述代码,常识是 call_once 只被调用一次:要么是 f 要么是 g,而不是两个都调用。

但是如果是可重入锁,h 调用 g,则 call_once 将被调用两次

但是如果不是可重入锁,则 h 调用 g 导致死锁,这也不太好。但是,假设调用 call_once 是严重错误,则死锁更好。内核开发者能排查死锁(内核 panic)并且修复代码避免死锁,然而调用两次 call_once 可能导致难以追踪的错误。

基于这个原因,xv6 使用了更易理解的不可重入锁。然而,只要程序员牢记 locking rules,任何方法都是有效的。如果 xv6 使用可重入锁,必须修改 acquire 标识锁由当前调用线程持有。也必须添加一个 struct spinlock 的 acquire 嵌套调用计数,和 push_off 类似,后续讨论。

Locks and interrupt handlers

一些 xv6 spinlocks 保护被线程和中断处理程序使用的数据。例如,clockintr 时钟中断处理程序可能增加 ticks (kernel/trap.c) ,同时内核线程在 sys_sleep(kernel/sysproc.c)中读取 tickstickslock 锁序列化两次访问。

自旋锁和中断的结合带来了潜在的危险。假设 sys_sleep 占有了 tickslock,它的 CPU 被一个时钟中断中断了。clockintr 尝试 acquire 锁tickslock,发现锁被占有,等待锁被释放。这种情况下,tickslock 将不会被释放:只有 sys_sleep 能释放 tickslock,但是 clockintr 不返回, sys_sleep 不会运行。这时 CPU 将会死锁,任何需要这个锁的代码都将停止运行。

为了避免这种情况:如果一个中断处理程序使用了 spinlock ,CPU 不能在开中断的情况下占有该锁。xv6 更保守:当一个 CPU 占有了任何锁,xv6 都会在该 CPU 上关中断。
中断可能发生在其他 CPUs 上,所以一个中断程序 acquire 锁可能要等待另一个线程释放一个 spinlock;中断程序和这个线程可能不在同一个 CPU 上。

当一个 CPU 没有占有 spinlock,xv6 重新开启中断;它必须做一些记录处理嵌套的临界区。acquire 调用 push_off (kernel/spinlock.c)然后 release 调用 pop_off (kernel/spinlock.c)跟踪当前 CPU 上锁的嵌套级别。当计数为 \(0\) 时,pop_off 在最外层临界区的开始处恢复中断开启状态。intr_onintr_off 函数执行 RISC-V 指令分别用于开/关中断。

重要的是:在设置 lk->locked(kernel/spinlock.c) 之前 acquire 直接调用 push_off 。如果两个操作相反,则在锁被占有和开中断前会有一个短暂的窗口,不幸的是,定时器中断会使系统死锁。类似的,只有在释放锁之后,release 才能调用 pop_off(kernel/spinlock.c)。(我理解所有对锁的操作应该处于 push_off 和 pop_off之间)

Instruction and memory ordering

很自然的想到程序按照源代码语句出现的顺序执行的。然而,许多编译器和 CPUs 为了更高的性能乱序执行代码。如果一条指令需要多个周期完成,CPU 可能提前发射指令,以便与其他指令重叠执行,避免 CPU 暂停。例如,CPU 可能注意到指令 A 和 指令 B 的顺序不相互依赖。CPU 可能先执行指令 B,因为它的输入在 A 的输入准备好之前已经准备好了,或者为了重叠执行。编译器可能执行类似重新排序,通过提前发射一条语句的指令(早于该语句的指令的顺序)。

编译器和 CPUs 重新排序时遵循的规则:确保不会改变正确编写的串行代码的结果。然而,规则允许重新排序改变并发代码的结果,很容易导致多处理器上的不正确的行为。CPU 的排序规则被称为 \(memory\ model\)

例如,对于 push 这个代码,如果编译器或者 CPU 将第 \(4\) 行的 store 指令移动到第 \(6\) 行之后,将是一个灾难:

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

如果发生这样一个重新排序,将产生一个窗口:另一个 CPU 可能acquire 锁并观察更新的 list,但看到一个没有初始化的 list->next

为了告知硬件和编译器不要重排序,xv6 在 acquirerelease 中使用 __sync_synchronize()__sync_synchronize() 是一个 \(memory\ barrier\) :通知编译器和 CPU 不要重排序 load 和 store 指令跨越 barrier。因为 xv6 使用锁访问共享数据,在几乎所有重要情况下,xv6 的 acquire 和 release 中的 barriers 会强制顺序执行(Ch9 讨论)。

Sleep locks

有时,xv6 长时间占有一个锁。例如,文件系统(Ch8)在读/写一个硬盘上的文件时维护一个文件锁,这些硬盘操作可能需要几十毫秒。如果另一个进程想要 acquire 锁,那么长时间占有 spinlock 可能会导致浪费,因为 acquire 锁的进程可能会因为长时间自旋浪费 CPU。spinlock 的另一个缺点是进程不可能在获取 spinlock 后让出 CPU,我们希望当占有锁的进程等待硬盘时,其他进程能使用 CPU。
当占有 spinlock 时让出 CPU 是非法的,因为另一个进程尝试acquire spinlock 的时候可能导致死锁;因为 acquire 不会让出 CPU,另一个线程的自旋可能阻止了第一个线程运行和释放锁。占有锁时让出 CPU 也违反了 spinlock 被占有时必须关中断。因此,我们想要一种锁:等待 acquire 时让出 CPU;占有锁时允许让出 CPU 或产生中断。

xv6 以 \(sleep-locks\) 的形式提供了这样一种锁。acquiresleep (kernel/sleeplock.c)阻塞时让出 CPU(Ch7 介绍这种技术)。在高级别中,sleep-lock 有一个被 spinlock 保护的 locked 域,acquiresleep 原子性的调用 sleep 让出 CPU,释放 spinlock。结果是 acquiresleep 等待时,其他线程可以执行。

因为 sleep-locks 开中断,所以不能在中断处理程序中使用。因为 acquiresleep 可能让出 CPU,在 spinlock 临界区内部不可能使用 sleep-locks(尽管在 sleep-lock 临界区内部可能使用 spinlock)。

spin-locks 适合短的临界区,因为等待 spin-locks 会浪费 CPU 时间;sleep-locks 适合用于长时间操作。

Real world

使用锁编程很有挑战,尽管有多年的并发原语(concurrency primitives)和并行性(parallelism)研究。通常最好在更高级别的构造中(如 synchronized queues)隐藏锁,但 xv6 不这样做。如果你编程使用锁,最好使用一个工具,能识别竞争条件(race conditions),因为很容易错过一个需要锁的不变量。

大多数操作系统支持 POSIX threads(Pthreads),它允许一个用户进程(这个进程有多个线程)在不同 CPUs 上并发运行多个线程。Pthreads 支持用户级锁,内存屏障等。Pthread 允许程序员选择性地指定锁为可重入的。

支持用户级的 Pthreads 需要操作系统的支持。
例如,如果一个 pthread 因系统调用阻塞,那么同一个进程的另一个 pthread 应该能在那个 CPU 上运行。
又如,如果一个 pthread 改变它的进程地址空间(映射或者取消映射内存),内核必须安排运行其他线程(同一进程)的其他 CPUs 更新它们的硬件页表,表明地址空间的改变。

没有原子指令去实现锁是可能的,但代价很大,大多数操作系统使用原子指令。

如果许多 CPUs 尝试同时 acquire 同一个锁,则锁很昂贵。如果一个 CPU 在本地 cache 中缓存了一个锁,另一个 CPU 必须要 acquire 这个锁,则更新占有这个锁的 cache line 的原子指令必须将 cache line 从一个 CPU 的 cache 中移动到另一个 CPU 的 cache 中,或许使其他 cache line 的拷贝无效。从另一个 CPU 的 cache 中获取 cache line 可能比从本地 cache 中获取的成本高几个数量级。

为了避免与锁相关的开销,许多操作系统使用无锁(lock-free)的数据结构和算法。
例如,可以实现一个像本章开头描述的链表,在链表搜索期间不需要锁,还可以实现一个原子指令----在链表中插入一项。
然而,无锁编程比锁编程复杂;例如,需要关心指令和内存的重新排序。使用锁编程已经很难了,所以 xv6 避免了无锁编程增加额外复杂性。

posted @ 2022-02-07 17:39  seaupnice  阅读(93)  评论(0编辑  收藏  举报