Lec10 Multiprocessors and locking
-
为什么需要锁?为什么需要尽可能避免锁?
- 出于正确性,我们需要使用锁,但是考虑到性能,锁又是极不好的(串行执行)。
-
锁就是一个对象,就像其他在内核中的对象一样。有一个结构体叫做lock,它包含了一些字段,这些字段中维护了锁的状态。锁有非常直观的API:
- acquire,接收指向lock的指针作为参数。acquire确保了在任何时间,只会有一个进程能够成功的获取锁。
- release,也接收指向lock的指针作为参数。在同一时间尝试获取锁的其他进程需要等待,直到持有锁的进程对锁调用release。
- 锁的acquire和release之间的代码,通常被称为critical section(临界区)。
-
什么时候使用锁
- 共享的数据 (可能过严)
- 在一些其他场合也需要锁,例如对于printf,如果我们将一个字符串传递给它,XV6会尝试原子性的将整个字符串输出,而不是与其他进程的printf交织输出。尽管这里没有共享的数据结构,但在这里锁仍然很有用处,因为我们想要printf的输出也是序列化的。
-
锁的特性
- 锁可以避免丢失操作更新
- 锁可以打包多个操作,使它们具有原子性
- 锁可以维护共享数据结构的不变性
-
锁排序
- 对于一个系统设计者,你需要确定对于所有的锁对象的全局的顺序。例如在这里的例子中我们让d1一直在d2之前,这样我们在rename的时候,总是先获取排序靠前的目录的锁,再获取排序靠后的目录的锁。如果对于所有的锁有了一个全局的排序,这里的死锁就不会出现了。
- 这样又违背了代码抽象的原则。在完美的情况下,代码抽象要求m1完全不知道m2是如何实现的。但是不幸的是,具体实现中,m2内部的锁需要泄露给m1,这样m1才能完成全局锁排序。所以当你设计一些更大的系统时,锁使得代码的模块化更加的复杂了。
-
自旋锁(Spin lock)的实现
- **实现锁的主要难点在于锁的acquire接口,在acquire里面有一个死循环,循环中判断锁对象的locked字段是否为0,如果为0那表明当前锁没有持有者,当前对于acquire的调用可以获取锁。**之后我们通过设置锁对象的locked字段为1来获取锁。最后返回。
- 问题:如何解决两个进程可能同时读到锁的locked字段为0 ?
- 最常见的方法是依赖于一个特殊的硬件指令。这个特殊的硬件指令会保证一次test-and-set操作的原子性。在RISC-V上,这个特殊的指令就是amoswap(atomic memory swap)。这个指令接收3个参数,分别是address,寄存器r1,寄存器r2。这条指令会先锁定住address,将address中的数据保存在一个临时变量中(tmp),之后将r1中的数据写入到地址中,之后再将保存在临时变量中的数据写入到r2中,最后再对于地址解锁。
- 通过这里的加锁,可以确保address中的数据存放于r2,而r1中的数据存放于address中,并且这一系列的指令打包具备原子性。
- 原理
- 基本上都是对于地址加锁,读出数据,写入新数据,然后再返回旧数据(注,也就是实现了atomic swap)。
- 具体的实现依赖于内存系统是如何工作的
- 多个处理器共用一个内存控制器,内存控制器可以支持这里的操作,比如给一个特定的地址加锁,然后让一个处理器执行2-3个指令,然后再解锁。因为所有的处理器都需要通过这里的内存控制器完成读写,所以内存控制器可以对操作进行排序和加锁。
- **如果内存位于一个共享的总线上,那么需要总线控制器(bus arbiter)来支持。**总线控制器需要以原子的方式执行多个内存操作。
- 如果处理器有缓存,那么缓存一致性协议会确保对于持有了我们想要更新的数据的cache line只有一个写入者,相应的处理器会对cache line加锁,完成两个操作。
- 三个细节
- release为什么也使用了atomic swap操作,不直接使用一个store指令将锁的locked字段写为0?
- store指令可能不是一个原子操作
- 每一个cache line的大小可能大于一个整数,那么store指令实际的过程将会是:首先会加载cache line,之后再更新cache line。所以对于store指令来说,里面包含了两个微指令。这样的话就有可能得到错误的结果。所以为了避免理解硬件实现的所有细节,例如整数操作不是原子的,或者向一个64bit的内存值写数据是不是原子的,我们直接使用一个RISC-V提供的确保原子性的指令来将locked字段写为0。
- store指令可能不是一个原子操作
- 第二个细节是,在acquire函数的最开始,会先关闭中断。
- 中断处理程序(如计时器中断)需要获取进程的锁,会造成死锁。所以中断在release的结束位置才能再次打开,因为在这个位置才能再次安全的接收中断。
- memory ordering (禁止编译器和硬件对指令重新排序)
- 如果我们将critical section与加锁解锁放在不同的CPU执行,将会得到完全错误的结果。所以指令重新排序在并发场景是错误的。为了禁止,或者说为了告诉编译器和硬件不要这样做,我们需要使用memory fence或者叫做synchronize指令,来确定指令的移动范围。对于synchronize指令,任何在它之前的load/store指令,都不能移动到它之后。锁的acquire和release函数都包含了synchronize指令。
- 发生在锁acquire之前的指令不会被移到acquire的sync_synchronize函数调用之后,这是一个界限。在锁的release函数中有另一个界限。所以在第一个界限之前的指令会一直在这个界限之前,在两个界限之间的指令会保持在两个界限之间,在第二个界限之后的指令会保持在第二个界限之后。
- release为什么也使用了atomic swap操作,不直接使用一个store指令将锁的locked字段写为0?