6.1810 / 6.1810 · 第 12 课 · nz

锁与并发

应用希望利用多核处理器获得并行加速,因此内核必须处理并发的系统调用,以及并发的内核数据访问(缓冲区缓存、进程表等)。锁帮助正确共享数据,但也可能限制并行加速。

授课教师:nz

日期:2025 年春季

难度:中等

代码:xv6 kernel/kalloc.c, kernel/spinlock.c

为什么需要锁?

案例研究:xv6 kfree() 中的竞态条件

如果删除 kfree() 中的 acquire/release 会发生什么?

// kfree() 简化
r->next = freelist;   // ①
freelist = r;         // ②

同时 kfree() → 丢失页面

Core 1Core 2
r1->next = freelistr2->next = freelist
freelist = r1freelist = r2

结果:r1 仍在 freelist 中(r2->next = r1),但 freelist 现在指向 r2。r1 之后会被分配出去——但某个进程可能仍在使用它!

教训

我们需要锁来保证正确性,但锁会让 kfree() 串行化,损失性能。


锁的抽象

lock l;
acquire(l);
x = x + 1;  // 临界区
release(l);

何时需要锁的保守规则

如果两个线程可能使用同一内存位置,且至少一个是写操作,就需要锁。

为什么是"保守"的?

锁能实现什么?


锁的问题

死锁(Deadlock)

rename(f1, f2) 必须持有两个目录的锁:

Core ACore B
rename(d1/x, d2/y)rename(d2/a, d1/b)
lock d1 ✓lock d2 ✓
lock d2 ...(等待)lock d1 ...(等待)

解决方案:程序员确定所有锁的全序,所有代码必须按该顺序获取锁。即预测锁、排序、获取——复杂!

锁与模块化

锁使得难以在模块内部隐藏细节。为避免死锁,我需要知道被调用函数获取了哪些锁,甚至可能需要在调用前就获取它们。锁通常不是个体的私有事务。

锁与并行

锁阻止并行执行。为获得并行,通常需要拆分数据和锁,使每个核心通常使用不同数据和锁——"细粒度锁"。

锁粒度建议

  1. 从大锁开始(如一个锁保护整个模块)
  2. 测量是否有问题——大锁通常足够
  3. 仅在有并行性能需要时再重新设计为细粒度锁

xv6 中的锁实例:UART 输出

并发源tx_lock 保护什么
进程/进程不交错输出;原子 write(THR) + tx_busy=1
进程/中断不变量 tx_busy=1 ⇒ 中断会来;原子 read(LSR) + tx_busy=0

如何实现锁?

错误实现:非原子 test-and-set

struct lock { int locked; }
acquire(l) {
  while (1) {
    if (l->locked == 0) {  // A
      l->locked = 1;       // B
      return;
    }
  }
}

问题:两个 acquire() 都看到 locked==0,都设置 locked=1。一个释放时设置 locked=0;第二个释放时看到已释放,触发 panic。

RISC-V 原子交换指令

// acquire() in kernel/kernel.asm
a5 = 1
s1 = &lk->locked
amoswap.w.aq a5, a5, (s1)
// 硬件完成:
//   lock *s1 in memory system
//   temp = *s1
//   *s1 = a5
//   a5 = temp
//   unlock *s1

xv6 spinlock 实现

acquire(l) {
  while (__sync_lock_test_and_set(&lk->locked, 1) != 0)
    ;
}

push_off() 的作用

为什么在 acquire 中禁用中断?如果设备中断,中断处理程序需要同一锁(如 UART 代码),会死锁。

内存读写排序

// 这段代码可能被编译器或 CPU 重排!
locked = 1   (aquire)
x = x + 1
locked = 0   (release)
// 可能生成:
locked = 1
locked = 0      // 编译器把增量移出临界区!
x = x + 1

__sync_synchronize() 防止重排:编译器不会跨越它移动内存引用,并可能发出"内存屏障"指令告诉 CPU。使用锁时,你无需理解内存排序规则。

为什么用自旋锁?

自旋锁在等待时浪费 CPU。为什么不放弃 CPU、切换到另一个进程?

自旋锁指南:

系统为较长临界区提供"阻塞锁"(等待线程放弃 CPU),但开销通常更高。


锁设计建议


测验

Q1:为什么非原子的 test-and-set 锁实现会失败?

Q2:死锁的解决方案是什么?

Q3:为什么自旋锁的 acquire 中要禁用中断?

Q4:__sync_synchronize() 的作用是什么?

Q5:锁粒度设计的最佳实践是什么?

要点总结

锁保证正确性

并发访问共享数据(至少一个写)必须通过锁保护,否则会出现竞态条件。

原子指令是基础

RISC-V 的 amoswap 等硬件原子指令是实现锁的基石——把问题推给硬件。

权衡与折中

锁粒度太粗限制并行,太细增加复杂度和死锁风险。从粗开始,按需细化。

内存排序

编译器和 CPU 都可能重排内存引用,__sync_synchronize 和内存屏障确保正确顺序。

延伸阅读