# 10.7 自旋锁（Spin lock）的实现（一）

接下来我们看一下如何实现自旋锁。锁的特性就是只有一个进程可以获取锁，在任何时间点都不能有超过一个锁的持有者。我们接下来看一下锁是如何确保这里的特性。

我们先来看一个有问题的锁的实现，这样我们才能更好的理解这里的挑战是什么。实现锁的主要难点在于锁的acquire接口，在acquire里面有一个死循环，循环中判断锁对象的locked字段是否为0，如果为0那表明当前锁没有持有者，当前对于acquire的调用可以获取锁。之后我们通过设置锁对象的locked字段为1来获取锁。最后返回。

![](https://1977542228-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-MHZoT2b_bcLghjAOPsJ%2F-MPMxL2sI23o_n-tc7PV%2F-MPS3RtWtlbCtxKANF71%2Fimage.png?alt=media\&token=20a8e5c6-68e8-47bc-a91d-392a8f00c148)

如果锁的locked字段不为0，那么当前对于acquire的调用就不能获取锁，程序会一直spin。也就是说，程序在循环中不停的重复执行，直到锁的持有者调用了release并将锁对象的locked设置为0。

在这个实现里面会有什么样的问题？

> 学生回答：两个进程可能同时读到锁的locked字段为0。

是的，所以这里会有race condition，在下面的位置会有race。

![](https://1977542228-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-MHZoT2b_bcLghjAOPsJ%2F-MPS4AMuEHdeXFZVa2tv%2F-MPSarxJA7vjJP_xuy2c%2Fimage.png?alt=media\&token=82b26df9-63a7-445a-bbee-ef6a0afbc07d)

如果CPU0和CPU1同时到达A语句，它们会同时看到锁的locked字段为0，之后它们会同时走到B语句，这样它们都acquire了锁。这样我们就违背了锁的特性。

为了解决这里的问题并得到一个正确的锁的实现方式，其实有多种方法，但是最常见的方法是依赖于一个特殊的硬件指令。这个特殊的硬件指令会保证一次test-and-set操作的原子性。在RISC-V上，这个特殊的指令就是amoswap（atomic memory swap）。这个指令接收3个参数，分别是address，寄存器r1，寄存器r2。这条指令会先锁定住address，将address中的数据保存在一个临时变量中（tmp），之后将r1中的数据写入到地址中，之后再将保存在临时变量中的数据写入到r2中，最后再对于地址解锁。

![](https://1977542228-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-MHZoT2b_bcLghjAOPsJ%2F-MPS4AMuEHdeXFZVa2tv%2F-MPScSwCXXqo0hs_ewEY%2Fimage.png?alt=media\&token=ca227f3a-2bc1-477b-8e23-2d36aab025fc)

通过这里的加锁，可以确保address中的数据存放于r2，而r1中的数据存放于address中，并且这一系列的指令打包具备原子性。大多数的处理器都有这样的硬件指令，因为这是一个实现锁的方便的方式。这里我们通过将一个软件锁转变为硬件锁最终实现了原子性。不同处理器的具体实现可能会非常不一样，处理器的指令集通常像是一个说明文档，它不会有具体实现的细节，具体的实现依赖于内存系统是如何工作的，比如说：

* 多个处理器共用一个内存控制器，内存控制器可以支持这里的操作，比如给一个特定的地址加锁，然后让一个处理器执行2-3个指令，然后再解锁。因为所有的处理器都需要通过这里的内存控制器完成读写，所以内存控制器可以对操作进行排序和加锁。
* 如果内存位于一个共享的总线上，那么需要总线控制器（bus arbiter）来支持。总线控制器需要以原子的方式执行多个内存操作。
* 如果处理器有缓存，那么缓存一致性协议会确保对于持有了我们想要更新的数据的cache line只有一个写入者，相应的处理器会对cache line加锁，完成两个操作。

硬件原子操作的实现可以有很多种方法。但是基本上都是对于地址加锁，读出数据，写入新数据，然后再返回旧数据（注，也就是实现了atomic swap）。

接下来我们看一下如何使用这条指令来实现自旋锁。让我们来看一下XV6中的acquire和release的实现。首先我们看一下spinlock.h

![](https://1977542228-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-MHZoT2b_bcLghjAOPsJ%2F-MPS4AMuEHdeXFZVa2tv%2F-MPTKBMvidl5JDe89V15%2Fimage.png?alt=media\&token=b9427226-3928-4e4d-a1cc-04c13641fe75)

如你所见，里面有spinlock结构体的定义。内容也比较简单，包含了locked字段表明当前是否上锁，其他两个字段主要是用来输出调试信息，一个是锁的名字，另一个是持有锁的CPU。

接下来我们看一下spinlock.c文件，先来看一下acquire函数，

![](https://1977542228-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-MHZoT2b_bcLghjAOPsJ%2F-MPS4AMuEHdeXFZVa2tv%2F-MPTLYWRF8Z7xnWXnrs2%2Fimage.png?alt=media\&token=03319722-1193-4ab4-bb36-23dbc3c11fae)

在函数中有一个while循环，这就是我刚刚提到的test-and-set循环。实际上C的标准库已经定义了这些原子操作，所以C标准库中已经有一个函数\_\_sync\_lock\_test\_and\_set，它里面的具体行为与我刚刚描述的是一样的。因为大部分处理器都有的test-and-set硬件指令，所以这个函数的实现比较直观。我们可以通过查看kernel.asm来了解RISC-V具体是如何实现的。下图就是atomic swap操作。

![](https://1977542228-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-MHZoT2b_bcLghjAOPsJ%2F-MPS4AMuEHdeXFZVa2tv%2F-MPTNUkQSugrjhscfd94%2Fimage.png?alt=media\&token=e67cecca-85fa-4c2e-9040-441dd85fdfd0)

这里比较复杂，总的来说，一种情况下我们跳出循环，另一种情况我们继续执行循环。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中的指令

![](https://1977542228-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-MHZoT2b_bcLghjAOPsJ%2F-MPS4AMuEHdeXFZVa2tv%2F-MPTQ6_2PmfELO2S40wq%2Fimage.png?alt=media\&token=d7b4b0e0-50cf-4fd8-80b9-00fbb94522a4)

可以看出release也使用了atomic swap操作，将0写入到了s1。下面是对应的C代码，它基本确保了将lk->locked中写入0是一个原子操作。

![](https://1977542228-files.gitbook.io/~/files/v0/b/gitbook-legacy-files/o/assets%2F-MHZoT2b_bcLghjAOPsJ%2F-MPTWI4LYcl0jB6hDkgX%2F-MPU8Xm8AmqsNVAEAaKb%2Fimage.png?alt=media\&token=ed3441cc-6193-45b1-9570-91d4d060aa0e)
