6.s081 锁

时间:2021-08-09
本文章向大家介绍6.s081 锁,主要包括6.s081 锁使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

使用多核CPU可以提升性能, 但由于多核CPU的存在, os会并行运行多个事件(多个CPU同时独立运行). 这些CPU共享物理内存.

并发带来的问题:

  • 有一个CPU正在读取某处内存数据, 但同时有另一个CPU正在写数据到该内存.
  • 多个CPU同时更新相同数据时.
  • 即使是在一个CPU, 内核可能在不同进程之间切换CPU.

既然并发会带来问题, 为什么要使用多核CPU?

由图, 自2000开始, 单CPU时钟频率没有增加(绿线), 所以CPU单线程性能达到极限(蓝线). 但CPU的晶体管却持续增加(深红线), 所以从2000开始, 处理器上的核数量增加(黑线).

并发会带来性能的提升, 但同时又容易出错. 所以需要锁来确保正确性. 当出现race condition时锁能确保一次只有一个CPU能持有, 所以在确保正确性的同时, 锁又会降低性能.

race condition

list被两个CPU共享.

struct elememt {
  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;
}

当两个CPU在同一内存上同时运行push时, 会出错. 当同时运行到15行时, CPU1和CPU2的l->next同时指向list. 下一步CPU2更新list(第16行), 但之后又被CPU1更新list所覆盖, 所以CPU2的l没有加入到list中.

race conditon就是16行时丢失的情况, 当内存被并发地访问就要小心race condition. 当对一个数据的更新被丢失或者读取一个没有完全更新的数据结构时就会产生bug, 这种bug很难调试, 对其加入print时, 可能会改变代码运行的先后顺序, 从而造成race conditon消失.

使用锁来避免race condition. 锁能确保同时只能有一个CPU运行10到13行里的内容.

struct element *list = 0;
struct lock listlock;

void 
push(int data)
{
  struct element *l;
  l = malloc(sizeof *l);
  l->data = data;
  acquire(&lisklock);
  l->next = list;
  list = l;
  release(&listlock);
}

锁保护了数据的不变式. 不变式正确时, 该操作的表现也正确. 可能操作会有一小段时间违背了不变式, 但需要在结束前修正. 例如list总是指向链的第一个元素, 并且每个元素的next总是指向下一个元素, 这是一个不变式, push违背了不变式, 在11行, l指向list的元素, 但l不在list中, bug就是因为push这一小段违背了不变式而产生的. 所以在违背处使用锁(acquirerelease之间的代码被称为critical section). critical section是原子性的, 锁序列化代码的执行, 如果有两个CPU同时要进入同一个critical section, 只有一个能成功进入, 另一个会在前一个退出后再进入, 这样能维持不变式从而解决race condition问题.

由于锁会影响效率, 需要在正确的位置增加acquirerelease.

锁的代码

xv6有两种类型的锁: spinlocksleeplock.

spinlock

spinlockstruct spinlock中(kernel/spinlock.h). 当locked非0说明锁正在被使用, 0说明锁可以被使用. 其他两个主要用于输出调试信息, 一个是锁的名字, 一个是持有锁的CPU.

CPU通过调用acquire来获得锁. 但这实现并不能在多个CPU上实现互斥. 当两个CPU同时到达第5行, 此时lk->locked == 0成立, 然后进入第6行, 这样两个CPU同时获取同一把锁.

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

所以需要将5和6行合并为一个原子, 在RISC-V中, 有一条指令为amoswap(atomic memory swap). 该指令接收三个参数: address, 寄存器r1, 寄存器r2. 这条指令会先锁定住address, 将address的数据保存在临时变量tmp, 之后将r1的数据写入address, 然后将tmp中保存的数据写入到r2, 最后对该地址解锁.

__sync_lock_test_and_set就是该指令的C版本, 返回值是lk->locked的旧值. 通过将amoswap包装进while, 会一直调用, 每次将1写入lk->locked, 然后访问返回的旧值, 一旦返回值为0就获得锁并跳出循环. 如果旧值本来就是1, 那么写入1对其没有影响. 一旦CPU获得锁, 就会更新lk->cpu.

release是释放锁, 首先清除lk->cpu然后调用__sync_lock_release来释放锁(将locked赋值为0).

几个细节:

  • release也要实现原子性的原因是store指令有可能会被分为几步执行, 这样就不是原子性的. 比如对于CPU内的缓存, 每个cache line的大小可能大于一个整数, 所以store会被分为两步: 先加载cache line, 在更新cache line.

  • acquire组开始会先关闭中断. 可以看到, 在uartputc中, 先acquire了锁, 如果没关闭中断, 当UART传输完字符, 会产生一个中断, 之后运行uartintr函数, 在uartintr函数中会获得同一把锁, 但这把锁又被uartputc持有会造成问题.

  • memory ordering也很重要. 假设先通过将locked字段设置为1来获取锁, 之后对x加1, 之后将locked设置为0来释放锁, 这是预期的执行顺序, 但是编译器或者处理器可能会重新排列执行的先后顺序. 对于并发来说, 指令重排是错误的.

    所以需要__sync_synchronize指令, 任何在它之前的load/store指令都不能移动到他之后. 所以acquirerelease都需要该指令.

sleeplock

有时xv6会持有锁很长一段时间(例如访问文件), 这时持有一个spinlock的话就会导致CPU浪费在等待上. 所以当进程持有锁并处理长时间的工作时(例如访问文件), 需要一个方式来使其他进程可以使用CPU. 当持有spinlock的时候出让CPU是不合理的, 因为当有其他进程访问该锁时会造成死锁.

sleeplock可以允许进程在持有锁的情况下出让CPU.

锁的特性

通常锁有三种作用:

  • 避免丢失更新.
  • 打包多个操作, 是他们具有原子性(在acquirerelease之间的critical section区域具有原子性).
  • 维护共享结构的不变性. 当共享结构不被写的时候, 就会保持不变. 某个进程acquire了锁, 在release之前做了一些操作使得不变性暂时破坏, 但最后release使得其中的critical section为一个整体, 这样不变性又恢复了.

锁的挑战:

  • 死锁(deadlock).

    一种死锁情况: 先acquire一个锁, 进入critical section, 在critical section中, 再acquire同一个锁, 第二个锁要等待第一个锁release, 但不执行完第二个锁, 第一个锁又不能release, 这就是一个死锁(当同一个进程acquire同一个锁多次就会触发panic).

    另一种: 当有多个锁的时候, 假设有两个CPU: CPU1和CPU2. CPU1执行rename将文件d1/x移动到d2/y, CPU2执行rename将文件d2/a移动到d1/b. CPU1先获取d1的锁, 这时CPU2获取d2的锁, 之后CPU1想要获取d2的锁就卡住了, 之后CPU2获取d1的锁也卡住.

  • 破坏程序的模块化: 所以死锁的解决方案是当有多个锁的时候, 需要多锁排序, 所有操作必须以相同的顺序获得锁. 例如上面的例子, 要求rename的时候总是先获得d1的锁, 再获得d2的锁. 但由于进程的抽象和隔离, 如果进程1中的g函数调用进程2中的f函数, 那么g需要知道f有哪些锁, 这样进程1可以集合fg中的所有锁形成一个排序, 这理论上是不可能的, 因为进程之间内存不可见, 但具体实现中, 进程2中的锁需要泄漏给进程1看.

为了更好的性能, 需要将big kernel lock拆分. 流程:

  • 先以coarse-grained lock(大锁)开始.
  • 对程序测试, 看一下程序是否能使用多核.
  • 如果可以, 工作结束, 如果不行, 说明锁存在竞争, 多个进程会尝试获取同一个锁, 所以需要重构.

原文地址:https://www.cnblogs.com/rainbowg0/p/15119117.html