Skip to content

6.1810 2024 第10讲:锁

为什么要讨论锁?

  • 应用程序希望使用多核处理器以获得并行加速
  • 因此内核必须处理并行的系统调用
  • 从而并行访问内核数据(缓冲区缓存、进程等)
  • 锁有助于正确共享数据
  • 锁可能会限制并行加速

如果没有锁会出什么问题?

  • 案例研究:删除 kalloc.c 中的 acquire/release
  • 启动
    • 内核工作正常!
  • 运行 usertests
    • 多次通过!
    • 有些会崩溃,我们会丢失一些页面
  • 为什么我们会丢失页面?
    • 共享内存多处理器的图示
    • 两个核心调用 kfree() 时发生竞争

糟糕的是:

  • 我们需要锁来保证正确性
  • 但会损失性能(kfree 被序列化了)

锁的抽象:

lock l
acquire(l)
  x = x + 1 -- "临界区"
release(l)
  • 锁本身就是一个对象
  • 如果多个线程调用 acquire(l)
    • 只有一个会立即返回
    • 其他的会等待 release() —— “阻塞”
  • 一个程序通常有很多数据,很多锁
    • 如果不同的线程使用不同的数据,
    • 那么它们可能持有不同的锁,
    • 所以它们可以并行执行 —— 完成更多的工作。
  • 注意,锁 l 并没有特别绑定到数据 x
    • 程序员对对应关系有一个计划

一个保守的规则来决定何时需要锁:

  • 任何时候两个线程使用一个内存位置,并且至少有一个是写入操作
  • 除非你持有正确的锁,否则不要接触共享数据!
  • (太严格了:程序逻辑有时可能会排除共享;无锁)
  • (太宽松了:printf();并不总是简单的锁/数据对应关系)

锁可以是自动的吗?

  • 也许语言可以为每个数据对象关联一个锁
    • 编译器在每次使用周围添加 acquire/release
    • 程序员忘记的可能性更小!
  • 这个想法通常太僵化了:
    if present(table1, key1):
      add(table2, key1)
  • 竞争:
    • 另一个线程可能在当前线程有机会添加到 table2 之前,从 table1 中删除 key1
  • 我们需要:
    lock table1
    lock table2
      present(..)
      add()
    unlock table1; unlock table2
  • 也就是说,程序员通常需要显式控制
    • 持有锁的代码区域
    • 以便隐藏尴尬的中间状态

思考锁实现了什么的方式

  • 锁有助于避免丢失更新
  • 锁帮助你创建原子的多步操作 —— 隐藏中间状态
  • 锁帮助操作维护数据结构的不变性
    • 假设在操作开始时不变性为真
    • 操作使用锁来隐藏对不变性的临时违反
    • 操作在释放锁之前恢复不变性

问题:死锁

  • 注意 rename() 持有两个锁
  • 如果:
    核心 A              核心 B
    rename(d1/x, d2/y)  rename(d2/a, d1/b)
      lock d1             lock d2
      lock d2 ...         lock d1 ...
  • 解决方案:
    • 程序员为所有锁制定一个顺序
    • 所有代码必须按该顺序获取锁
    • 即预测锁,排序,获取 —— 复杂!

锁与模块化

  • 锁使得在模块内部隐藏细节变得困难
  • 为了避免死锁,我需要知道我调用的函数获取了哪些锁
  • 并且我可能需要在调用之前获取它们,即使我不使用它们
  • 即,锁通常不是单个模块的私事

锁与并行性

  • 阻止并行执行
  • 为了获得并行性,你通常需要拆分数据和锁
    • 以一种让每个核心使用不同数据和不同锁的方式
    • “细粒度锁”
  • 选择最佳的数据/锁拆分是一个设计挑战
    • 整个文件系统;目录/文件;磁盘块
    • 整个内核;每个子系统;每个对象
  • 你可能需要重新设计代码以使其在并行下良好工作
    • 示例:将单个空闲内存列表分解为每个核心的空闲列表
      • 如果线程在单个空闲列表的锁上等待很多,这会有所帮助
  • 这样的重写可能需要大量工作!

锁粒度建议

  • 从大锁开始,例如一个锁保护整个模块
    • 死锁更少,因为持有两个锁的机会更少
    • 需要更少关于不变性/原子性的推理
  • 测量以查看是否存在问题
    • 大锁通常就足够了 —— 也许在该模块中花费的时间很少
  • 仅在需要时才为并行性能重新设计为细粒度锁

让我们看看 xv6 中的锁。

一个典型的锁使用:uart.c

  • 许多操作系统设备驱动程序安排的典型
  • 图示:
    • 用户进程、内核、UART、uartputc、从 uart_tx_buf 中移除、uartintr()
  • 并发源:进程、中断
  • uart.c 中只有一个锁:uart_tx_lock —— 相当粗粒度
  • uartputc() —— uart_tx_lock 保护什么?
    1. uart_tx_buf 操作中没有竞争
    2. 如果队列不为空,UART 硬件正在执行队列的头部
    3. 没有对 UART 写入寄存器的并发访问
  • uartintr() —— 中断处理程序
    • 获取锁 —— 可能必须在中断级别等待!
    • uart_tx_buf 中移除字符
    • 将下一个排队的字符交给 UART 硬件 (2)
    • 接触 UART 硬件寄存器 (3)

如何实现锁?

  • 为什么不这样:
    c
    struct lock { int locked; }
    acquire(l) {
      while(1){
        if(l->locked == 0){ // A
          l->locked = 1;    // B
          return;
        }
      }
    }
  • 糟糕:A 行和 B 行之间存在竞争
  • 我们如何原子地执行 A 和 B?

原子交换指令:

assembly
a5 = 1
s1 = &lk->locked
amoswap.w.aq a5, a5, (s1)
  • 这在硬件中执行:
    • 全局锁定地址(其他核心不能使用它)
    • temp = *s1
    • *addr = a5
    • a5 = temp
    • 解锁地址
  • RISC-V 硬件提供了一种锁定内存位置的概念
    • 不同的 CPU 有不同的实现
    • 图示:核心、总线、RAM、锁的东西
    • 所以我们实际上是将问题推给了硬件
    • 硬件在缓存行或整个总线的粒度上实现
  • 内存锁强制并发交换一个接一个地运行,而不是交错进行

看看 xv6 的自旋锁实现

c
acquire(l){
  while(__sync_lock_test_and_set(&lk->locked, 1) != 0)
    ;
}
  • 如果 l->locked 已经是 1,sync_lock_test_and_set 将其(再次)设置为 1,返回 1,

    • 并且循环继续“自旋”
  • 如果 l->locked 是 0,最多只有一个 lock_test_and_set 会看到 0;它会将其设置

    • 为 1 并返回 0;其他的 test_and_set 将返回 1
  • 这是一个“自旋锁”,因为等待的核心在 acquire 循环中“自旋”

  • push_off() 是关于什么的?

    • 为什么要禁用中断?
  • release():

    • 设置 lk->locked = 0
    • 并重新启用中断

细节:内存读/写顺序

  • 假设两个核心使用一个锁来保护一个计数器 x
  • 我们有一个天真的锁实现
  • 核心 A: 核心 B:
    locked = 1
    x = x + 1      while(locked == 1)
    locked = 0       ...
                   locked = 1
                   x = x + 1
                   locked = 0
  • 编译器和 CPU 会重新排序内存访问
    • 即它们不遵守源程序的内存引用顺序
    • 例如,编译器可能会为核心 A 生成此代码:
      locked = 1
      locked = 0
      x = x + 1
      • 即将增量移出临界区!
  • 合法的行为称为“内存模型”
  • release()__sync_synchronize() 的调用可防止重排序
    • 编译器不会将内存引用移动到 __sync_synchronize() 之后
    • 并且(可能)发出“内存屏障”指令来告诉 CPU
  • acquire()__sync_synchronize() 的调用具有类似的效果:
  • 如果你使用锁,你不需要理解内存排序规则
    • 如果你想编写奇特的“无锁”代码,你需要它们

为什么是自旋锁?

  • 它们在等待时不浪费 CPU 吗?
  • 为什么不放弃 CPU 并切换到另一个进程,让它运行?
  • 如果持有线程需要运行;等待线程不应该让出 CPU 吗?
  • 自旋锁指南:
    • 持有自旋锁的时间非常短
    • 持有自旋锁时不要让出 CPU
  • 系统为更长的临界区提供“阻塞”锁
    • 等待的线程让出 CPU
    • 但开销通常更高
    • 你稍后会看到一些 xv6 的阻塞方案

建议:

  • 如果不必共享,就不要共享
  • 从几个粗粒度的锁开始
  • 检测你的代码 —— 哪些锁在阻止并行性?
  • 仅在需要并行性能时才使用细粒度锁
  • 使用自动竞争检测器