本文为MIT 6.S081课程第六章教材内容翻译加整理。
本课程前置知识主要涉及:
上一节中整理的是教材上对锁的说明,本节补充课堂上的一些说明。
教材上写的话一般比较精炼和官方,所以阅读起来不好理解,课堂上老师讲的更加大白话和易懂一些,所以这也是本节进行补充说明的原因,如果觉得锁章节已经掌握了,那么可以跳过对本节的阅读。
使用多个CPU核可以带来性能的提升,如果一个应用程序运行在多个CPU核上,并且执行了系统调用,那么内核需要能够处理并行的系统调用。
到目前为止,你们也看到了XV6有很多共享的数据结构,例如proc、ticks和我们之后会看到的buffer cache等等。
所以,我们需要锁来控制并确保共享的数据是正确的。
但是实际的情况有些令人失望,因为我们想要通过并行来获得高性能,我们想要并行的在不同的CPU核上执行系统调用,但是如果这些系统调用使用了共享的数据,我们又需要使用锁,而锁又会使得这些系统调用串行执行,所以最后锁反过来又限制了性能。
所以现在我们处于一个矛盾的处境,出于正确性,我们需要使用锁,但是考虑到性能,锁又是极不好的。这就是现实,我们接下来会看看如何改善这个处境。
以上是一个大概的介绍,但是回到最开始,为什么应用程序一定要使用多个CPU核来提升性能呢?这个实际上与过去几十年技术的发展有关,下面这张非常经典的图可以解释为什么。
这张图有点复杂,X轴是时间,Y轴是单位,Y轴具体意义取决于特定的曲线。这张图中的核心点是,大概从2000年开始:
所以现在如果一个应用程序想要提升性能,它不能只依赖单核,必须要依赖于多核。这也意味着,如果应用程序与内核交互的较为紧密,那么操作系统也需要高效的在多个CPU核上运行。这就是我们对内核并行的运行在多个CPU核上感兴趣的直接原因。你们可能之前已经看过上面这张图,但我们这里回顾一下背景知识也是极好的。
那为什么要使用锁呢?
锁就是一个对象,就像其他在内核中的对象一样。有一个结构体叫做lock,它包含了一些字段,这些字段中维护了锁的状态。锁有非常直观的API:
锁的acquire和release之间的代码,通常被称为critical section。
现在的程序通常会有许多锁。实际上,XV6中就有很多的锁。为什么会有这么多锁呢?因为锁序列化了代码的执行。如果两个处理器想要进入到同一个critical section中,只会有一个能成功进入,另一个处理器会在第一个处理器从critical section中退出之后再进入。所以这里完全没有并行执行。
如果内核中只有一把大锁,我们暂时将之称为big kernel lock。基本上所有的系统调用都会被这把大锁保护而被序列化。系统调用会按照这样的流程处理:
这里有几点很重要,首先,并没有强制说一定要使用锁,锁的使用完全是由程序员决定的。如果你想要一段代码具备原子性,那么其实是由程序员决定是否增加锁的acquire和release。其次,代码不会自动加锁,程序员自己要确定好是否将锁与数据结构关联,并在适当的位置增加锁的acquire和release。
很明显,锁限制了并发性,也限制了性能。那这带来了一个问题,什么时候才必须要加锁呢?我这里会给你们一个非常保守同时也是非常简单的规则:
这是个保守的规则,如果一个数据结构可以被多个进程访问,其中一个进程会更新这个数据,那么可能会产生race condition,应该使用锁来确保race condition不会发生。
但是同时,这条规则某种程度上来说又太过严格了。如果有两个进程共享一个数据结构,并且其中一个进程会更新这个数据结构,在某些场合不加锁也可以正常工作。不加锁的程序通常称为lock-free program,不加锁的目的是为了获得更好的性能和并发度,不过lock-free program比带锁的程序更加复杂一些。
矛盾的是,有时候这个规则太过严格,而有时候这个规则又太过宽松了。除了共享的数据,在一些其他场合也需要锁,例如对于printf,如果我们将一个字符串传递给它,XV6会尝试原子性的将整个字符串输出,而不是与其他进程的printf交织输出。尽管这里没有共享的数据结构,但在这里锁仍然很有用处,因为我们想要printf的输出也是序列化的。
所以,这条规则并不完美,但是它已经是一个足够好的指导准则。
因为有了race condition,所以需要锁。我们之前在kfree函数中构造的race condition是很容易被识别到的,实际上如果你使用race detection工具,就可以立即找到它。但是对于一些更复杂的场景,就不是那么容易探测到race condition。
那么我们能通过自动的创建锁来自动避免race condition吗?如果按照刚刚的简单规则,一旦我们有了一个共享的数据结构,任何操作这个共享数据结构都需要获取锁,那么对于XV6来说,每个结构体都需要自带一个锁,当我们对于结构体做任何操作的时候,会自动获取锁。
可是如果我们这样做的话,结果就太过严格了,所以不能自动加锁。接下来看一个具体的例子:
在这个例子中,我们会有错误的结果,那么为什么这是一个有问题的场景呢?为什么这个场景不能正常工作?
所以这里正确的解决方法是,我们在重命名的一开始就对d1和d2加锁,之后删除x再添加y,最后再释放对于d1和d2的锁。
在这个例子中,我们的操作需要涉及到多个锁,但是直接为每个对象自动分配一个锁会带来错误的结果。在这个例子中,锁应该与操作而不是数据关联,所以自动加锁在某些场景下会出问题。
可不可以在访问某个数据结构的时候,就获取所有相关联的数据结构的锁?
通常锁有三种作用,理解它们可以帮助你更好的理解锁。
接下来我们再来看一下锁可能带来的一些缺点。我们已经知道了锁可以被用来解决一些正确性问题,例如避免race condition。但是不恰当的使用锁,可能会带来一些锁特有的问题。最明显的一个例子就是死锁(Deadlock)。
一个死锁的最简单的场景就是:
这是死锁的一个最简单的例子,XV6会探测这样的死锁,如果XV6看到了同一个进程多次acquire同一个锁,就会触发一个panic。
说明xv6不支持锁重入,当然在某些场景下,确实不需要锁重入,这是不符合相关场景的
当有多个锁的时候,场景会更加有趣:
这也是死锁的一个例子,有时候这种场景也被称为deadly embrace。这里的死锁就没那么容易探测了。
这里的解决方案是,如果你有多个锁,你需要对锁进行排序,所有的操作都必须以相同的顺序获取锁。
所以对于一个系统设计者,你需要确定对于所有的锁对象的全局的顺序。
不过在设计一个操作系统的时候,定义一个全局的锁的顺序会有些问题。如果一个模块m1中方法g调用了另一个模块m2中的方法f,那么m1中的方法g需要知道m2的方法f使用了哪些锁。因为如果m2使用了一些锁,那么m1的方法g必须集合f和g中的锁,并形成一个全局的锁的排序。这意味着在m2中的锁必须对m1可见,这样m1才能以恰当的方法调用m2。
但是这样又违背了代码抽象的原则。在完美的情况下,代码抽象要求m1完全不知道m2是如何实现的。但是不幸的是,具体实现中,m2内部的锁需要泄露给m1,这样m1才能完成全局锁排序。所以当你设计一些更大的系统时,锁使得代码的模块化更加的复杂了。
有必要对所有的锁进行排序吗?
我们前面已经看过了两类锁带来的挑战,一个是死锁,另一个是破坏了程序的模块化。这一部分来看看第三个挑战,也就是锁与性能之间的权衡。我们前面已经提过几次锁对性能的影响,但是因为这部分太重要了,我们再来详细的看一下。
基本上来说,如果你想获得更高的性能,你需要拆分数据结构和锁。如果你只有一个big kernel lock,那么操作系统只能被一个CPU运行。如果你想要性能随着CPU的数量增加而增加,你需要将数据结构和锁进行拆分。
那怎么拆分呢?通常不会很简单,有的时候还有些困难。比如说,你是否应该为每个目录关联不同的锁?你是否应该为每个inode关联不同的锁?你是否应该为每个进程关联不同的锁?或者是否有更好的方式来拆分数据结构呢?如果你重新设计了加锁的规则,你需要确保不破坏内核一直尝试维护的数据不变性。
如果你拆分了锁,你可能需要重写代码。如果你为了获得更好的性能,重构了部分内核或者程序,将数据结构进行拆分并引入了更多的锁,这涉及到很多工作,你需要确保你能够继续维持数据的不变性,你需要重写代码。通常来说这里有很多的工作,并且并不容易。
所以这里就有矛盾点了。我们想要获得更好的性能,那么我们需要有更多的锁,但是这又引入了大量的工作。 通常来说,开发的流程是:
在这个流程中,测试的过程比较重要。有可能模块使用了coarse-grained lock,但是它并没有经常被并行的调用,那么其实就没有必要重构程序,因为重构程序设计到大量的工作,并且也会使得代码变得复杂。所以如果不是必要的话,还是不要进行重构。
从代码上看UART只有一个锁:
所以你可以认为对于UART模块来说,现在是一个coarse-grained lock的设计。这个锁保护了UART的的传输缓存,写指针和读指针。
当我们传输数据时,写指针会指向传输缓存的下一个空闲槽位,而读指针指向的是下一个需要被传输的槽位。这是我们对于并行运算的一个标准设计,它叫做消费者-生产者模式。
所以现在有了一个缓存,一个写指针和一个读指针:
我们前面已经了解到了锁有多个角色。第一个是保护数据结构的特性不变,数据结构有一些不变的特性,例如读指针需要追赶写指针;从读指针到写指针之间的数据是需要被发送到显示端;从写指针到读指针之间的是空闲槽位,锁帮助我们维护了这些特性不变。
我们接下来看一下uart.c中的uartputc函数:
函数首先获得了锁,然后查看当前缓存是否还有空槽位,如果有的话将数据放置于空槽位中;写指针加1;调用uartstart;最后释放锁。
如果两个进程在同一个时间调用uartputc,那么这里的锁会确保来自于第一个进程的一个字符进入到缓存的第一个槽位,接下来第二个进程的一个字符进入到缓存的第二个槽位。这就是锁帮助我们避免race condition的一个简单例子。如果没有锁的话,第二个进程可能会覆盖第一个进程的字符。
接下来我们看一下uartstart函数:
如果uart_tx_w不等于uart_tx_r,那么缓存不为空,说明需要处理缓存中的一些字符。锁确保了我们可以在下一个字符写入到缓存之前,处理完缓存中的字符,这样缓存中的数据就不会被覆盖。
最后,锁确保了一个时间只有一个CPU上的进程可以写入UART的寄存器,THR。所以这里锁确保了硬件寄存器只有一个写入者。
当UART硬件完成传输,会产生一个中断。在前面的代码中我们知道了uartstart的调用者会获得锁以确保不会有多个进程同时向THR寄存器写数据。但是UART中断本身也可能与调用printf的进程并行执行。如果一个进程调用了printf,它运行在CPU0上;CPU1处理了UART中断,那么CPU1也会调用uartstart。因为我们想要确保对于THR寄存器只有一个写入者,同时也确保传输缓存的特性不变(注,这里指的是在uartstart中对于uart_tx_r指针的更新),我们需要在中断处理函数中也获取锁。
所以,在XV6中,驱动的bottom部分(注,也就是中断处理程序)和驱动的up部分(注,uartputc函数)可以完全的并行运行,所以中断处理程序也需要获取锁。
UART的缓存中,读指针是不是总是会落后于写指针?
实现锁的主要难点在于锁的acquire接口,在acquire里面有一个死循环,循环中判断锁对象的locked字段是否为0,如果为0那表明当前锁没有持有者,当前对于acquire的调用可以获取锁。之后我们通过设置锁对象的locked字段为1来获取锁。最后返回。
并且我们需要确保抢锁和释放锁过程的原子性,通常会借助硬件层面提供的特殊的原子指令完成CAS操作:
锁定住address
,将address中的数据保存在一个临时变量中(tmp),之后将r1中的数据写入到地址中,之后再将保存在临时变量中的数据写入到r2中,最后再对于地址解锁。通过这里的加锁,可以确保address中的数据存放于r2,而r1中的数据存放于address中,并且这一系列的指令打包具备原子性。大多数的处理器都有这样的硬件指令,因为这是一个实现锁的方便的方式。这里我们通过将一个软件锁转变为硬件锁最终实现了原子性。不同处理器的具体实现可能会非常不一样,处理器的指令集通常像是一个说明文档,它不会有具体实现的细节,具体的实现依赖于内存系统是如何工作的,比如说:
硬件原子操作的实现可以有很多种方法。但是基本上都是对于地址加锁,读出数据,写入新数据,然后再返回旧数据(注,也就是实现了atomic swap)。
接下来我们看一下如何使用这条指令来实现自旋锁。让我们来看一下XV6中的acquire和release的实现。首先我们看一下spinlock.h :
// Mutual exclusion lock.
struct spinlock {
uint locked; // Is the lock held?
// For debugging:
char *name; // Name of lock.
struct cpu *cpu; // The cpu holding the lock.
};
如你所见,里面有spinlock结构体的定义。内容也比较简单,包含了locked字段表明当前是否上锁,其他两个字段主要是用来输出调试信息,一个是锁的名字,另一个是持有锁的CPU。
接下来我们看一下spinlock.c文件,先来看一下acquire函数,
在函数中有一个while循环,这就是我刚刚提到的test-and-set循环。实际上C的标准库已经定义了这些原子操作,所以C标准库中已经有一个函数__sync_lock_test_and_set,它里面的具体行为与我刚刚描述的是一样的。因为大部分处理器都有的test-and-set硬件指令,所以这个函数的实现比较直观。我们可以通过查看kernel.asm来了解RISC-V具体是如何实现的。下图就是atomic swap操作。
这里比较复杂,总的来说,一种情况下我们跳出循环,另一种情况我们继续执行循环。C代码就要简单的多。如果锁没有被持有,那么锁对象的locked字段会是0,如果locked字段等于0,我们调用test-and-set将1写入locked字段,并且返回locked字段之前的数值0。如果返回0,那么意味着没有人持有锁,循环结束。如果locked字段之前是1,那么这里的流程是,先将之前的1读出,然后写入一个新的1,但是这不会改变任何数据,因为locked之前已经是1了。之后__sync_lock_test_and_set会返回1,表明锁之前已经被人持有了,这样的话,判断语句不成立,程序会持续循环(spin),直到锁的locked字段被设置回0。
接下来我们看一下release的实现,首先看一下kernel.asm中的指令:
可以看出release也使用了atomic swap操作,将0写入到了s1。下面是对应的C代码,它基本确保了将lk->locked中写入0是一个原子操作:
有关spin lock的实现,有3个细节我想介绍一下:
首先,为什么release函数中不直接使用一个store指令将锁的locked字段写为0?
amoswap并不是唯一的原子指令,下图是RISC-V的手册,它列出了所有的原子指令。
第二个细节是,在acquire函数的最开始,会先关闭中断。为什么会是这样呢?让我们回到uart.c中。我们先来假设acquire在一开始并没有关闭中断。在uartputc函数中,首先会acquire锁,如果不关闭中断会发生什么呢?
uartputc函数会acquire锁,UART本质上就是传输字符,当UART完成了字符传输它会做什么?
所以spinlock需要处理两类并发,一类是不同CPU之间的并发,一类是相同CPU上中断和普通程序之间的并发。针对后一种情况,我们需要在acquire中关闭中断。中断会在release的结束位置再次打开,因为在这个位置才能再次安全的接收中断。
第三个细节就是memory ordering。假设我们先通过将locked字段设置为1来获取锁,之后对x加1,最后再将locked字段设置0来释放锁。下面将会是在CPU上执行的指令流:
但是编译器或者处理器可能会重排指令以获得更好的性能。对于上面的串行指令流,如果将x<-x+1移到locked<-0之后可以吗?这会改变指令流的正确性吗?
但是对于并发执行,很明显这将会是一个灾难。如果我们将critical section与加锁解锁放在不同的CPU执行,将会得到完全错误的结果。所以指令重新排序在并发场景是错误的。为了禁止,或者说为了告诉编译器和硬件不要这样做,我们需要使用memory fence或者叫做synchronize指令,来确定指令的移动范围。对于synchronize指令,任何在它之前的load/store指令,都不能移动到它之后。锁的acquire和release函数都包含了synchronize指令。
这样前面的例子中,x<-x+1就不会被移到特定的memory synchronization点之外。我们也就不会有memory ordering带来的问题。这就是为什么在acquire和release中都有__sync_synchronize函数的调用。
有没有可能在锁acquire之前的一条指令被移到锁release之后?或者说这里会有一个界限不允许这么做?
首先,锁确保了正确性,但是同时又会降低性能,这是个令人失望的现实,我们是因为并发运行代码才需要使用锁,而锁另一方面又限制了代码的并发运行。
其次锁会增加编写程序的复杂性,在我们的一些实验中会看到锁,我们需要思考锁为什么在这,它需要保护什么。如果你在程序中使用了并发,那么一般都需要使用锁。如果你想避免锁带来的复杂性,可以遵循以下原则:不到万不得已不要共享数据。如果你不在多个进程之间共享数据,那么race condition就不可能发生,那么你也就不需要使用锁,程序也不会因此变得复杂。但是通常来说如果你有一些共享的数据结构,那么你就需要锁,你可以从coarse-grained lock开始,然后基于测试结果,向fine-grained lock演进。
最后,使用race detector来找到race condition,如果你将锁的acquire和release放置于错误的位置,那么就算使用了锁还是会有race。
以上就是对锁的介绍,我们之后还会介绍很多锁的内容,在这门课程的最后我们还会介绍lock free program,并看一下如何在内核中实现它。
在一个处理器上运行多个线程与在多个处理器上运行多个进程是否一样?