6.1810 / 6.1810 · 第 14 课 · rtm

线程同步与协调

线程经常需要等待特定事件:磁盘读完成、管道有数据、子进程退出。xv6 使用 sleep/wakeup 原语实现协调,但必须小心避免"丢失唤醒"(lost wakeup)问题。

授课教师:rtm

日期:2025 年春季

难度:中等

代码:xv6 kernel/proc.c(sleep/wakeup), kernel/uart.c

回顾

回顾 #1:为什么在 swtch 期间持有 p->lock?

yield():              scheduler():
  acquire(&p->lock);     swtch();
  p->state = RUNNABLE;   release(&p->lock);
  swtch();

两个原因

  1. 防止另一个核心的调度器看到 p->state == RUNNABLE,直到原始核心完成保存寄存器并停止使用线程的栈
  2. 防止定时器中断在 swtch 期间调用 yield()(acquire 关闭了中断),避免二次 swtch 覆盖已保存的寄存器

回顾 #2:为什么 xv6 从不在持有自旋锁时 yield CPU?(除 p->lock)

单核上的死锁场景

P1           P2
acq(x)       sched()
             acq(x)  // 自旋等待
// P2 自旋时中断已禁用
// 无时钟中断 → P2 不 yield → P1 不运行
// P1 不释放 x → 死锁!

解决方案:禁止在持有锁时放弃 CPU!(除了 p->lock)


顺序协调(Sequence Coordination)

线程经常需要等待特定事件/条件:

为什么不用自旋等待?

// pipe read 的错误方案
while (buffer is empty) {
  // 自旋等待
}
// 浪费 CPU!

更好的方案:让出 CPU 的协调原语

操作系统使用多种方案:barrier、semaphore、event queue、condition variable 等。xv6 使用 sleep & wakeup。


示例:uartwrite() 和 uartintr()

UART 是控制台设备硬件,每次发送一个字节需要很长时间(~1ms)。

基本交互

uartwrite(buf):
  for each char c:
    while tx_busy:
      sleep(&tx_chan, &tx_lock)
    send c
    tx_busy = 1

uartintr():
  tx_busy = 0
  wakeup(&tx_chan)

sleep/wakeup 术语

概念说明
sleep(wait_chan, lock)让出 CPU,等待某事件
wakeup(wait_chan)唤醒所有在 sleep() 中等待同一 wait_chan 的进程
wait_chan"等待通道"——只是一个数字,链接 sleep 和 wakeup
condition程序员知道的条件(如 tx_busy == 0),sleep/wakeup 不知道

Lost Wakeup 问题

错误的 sleep/wakeup 实现

// 有 bug 的 sleep/wakeup
broken_sleep(chan)
  acquire(&p->lock)
  p->state = SLEEPING;
  p->chan = chan;
  swtch(...);  // 不再可运行

wakeup(chan)
  for each p:
    acquire(&p->lock);
    if p->state == SLEEPING && p->chan == chan:
      p->state = RUNNABLE

UART 代码如何使用这个(有 bug 的)sleep/wakeup?

uartwrite(buf):
  for each char c:
    while tx_busy:
      release(&tx_lock);       // 释放锁
      broken_sleep(&tx_chan);  // 然后睡眠
      acquire(&tx_lock);       // 重新获取

会发生什么?

  1. uartwrite 看到 tx_busy = 1(UART 未完成前一字符)
  2. 释放 tx_lock——这使能了中断
  3. 中断!uartintr 设置 tx_busy = 0,调用 wakeup()——但没有进程需要唤醒!
  4. uartwrite 调用 sleep()
  5. uartwrite 进入睡眠,尽管 UART TX 已完成、tx_busy 为零
  6. 没有其他东西会唤醒 uartwrite → 永远睡眠(直到 UART 输入中断)

这就是"Lost Wakeup"问题

我们需要消除 uartwrite 检查条件与 sleep 标记进程为 SLEEPING 之间的窗口。使用锁防止 wakeup 在整个窗口期间运行。


正确的 sleep/wakeup 接口

新的 sleep 接口:

sleep(chan, lock)
  调用者必须持有 lock
  sleep 释放 lock,返回前重新获取

wakeup(chan)
  调用者必须持有 lock

lock 必须防止等待的条件发生变化!lock 必须传给 sleep(),且必须在持有 lock 的情况下调用 wakeup()。

实现关键

// sleep() 关键:在释放条件锁之前获取 p->lock
sleep(chan, lk):
  acquire(&p->lock)     // 先获取 p->lock
  release(lk)           // 再释放条件锁
  p->state = SLEEPING
  p->chan = chan
  swtch()               // 此时持有 p->lock
  // 返回时:
  acquire(lk)           // 重新获取条件锁
  release(&p->lock)

为什么这样能避免 lost wakeup?

时间线

uartwrite()         uartintr()
-----------         ----------
acq tx_lock         acq tx_lock
check tx_busy       tx_busy = 0
sleep()             wakeup()
  acq p->lock         acq p->lock(等待)
  rel tx_lock         ...
  SLEEPING            check SLEEPING / chan
  swtch()             rel p->lock
    rel p->lock       rel tx_lock

为什么 uartwrite 在循环中包裹 sleep?


另一个示例:piperead()

条件:有数据可读(nread != nwrite)。条件锁:管道的 pi->lock。

注意:lost wakeup 不只是中断问题!

如果 piperead 使用 broken_sleep(先释放锁再 sleep),多个读进程之间也会发生 lost wakeup。


线程终止

exit() 的困境

线程不能释放自己的所有资源:

exit() 策略

  1. kexit() 释放部分资源(非全部)
  2. 唤醒等待的父进程
  3. p->state = ZOMBIE(不能是 UNUSED,因为仍在使用内核栈)
  4. swtch() 到调度器

kwait()(父进程调用)

  1. 扫描 proc[] 找 p->state == ZOMBIE 的子进程
  2. 调用 freeproc() 释放:trapframe、页表、...、p->state = UNUSED

wait() 不仅是为了应用方便,也是操作系统需要的。

如何避免 exit() → wait() 的 lost wakeup?

kill() 的困境

线程 X 不能直接销毁线程 Y:

kill() 解决方案

  1. kill() 只设置 p->killed 标志
  2. 目标进程自己检查 p->killed,调用 kexit()
  3. 在 usertrap() 中检查:不在任何中间操作,不持有锁,安全退出

如果 kill() 的目标正在 SLEEPING?

kill() 将 SLEEPING 改为 RUNNABLE(像 wakeup)。sleep 循环可能检查 p->killed(如 piperead、consoleread),有些不检查(如 virtio_disk.c——因为可能在文件系统更新中间,不能直接退出)。

xv6 kill 规范


测验

Q1:"Lost wakeup"是什么问题?

Q2:sleep() 为什么需要 lock 参数?

Q3:sleep() 在释放条件锁之前先获取 p->lock 的目的是什么?

Q4:为什么 kill() 不能直接销毁目标进程?

Q5:为什么 exit() 不直接将 p->state 设为 UNUSED?

要点总结

Lost Wakeup

检查条件与进入 SLEEPING 之间的窗口导致 wakeup 丢失。通过重叠的锁消除窗口。

sleep 接口设计

sleep(chan, lock) 要求调用者持有条件锁,在释放条件锁前先获取 p->lock。

线程终止

exit 设 ZOMBIE 后由父进程清理;kill 设 killed 标志由目标自行退出。

协调原语

sleep/wakeup 灵活但底层;更高层抽象如 semaphore、condition variable 更简洁。

延伸阅读