1. 课程导论

1.1 并发与竞态:操作系统必须面对的核心问题

操作系统内核是一个典型的并发程序。多个进程、中断处理程序、CPU 核心同时访问共享数据(如进程表、文件描述符表、内存分配链表),如果没有正确的同步机制,就会产生竞态条件(Race Condition),导致数据损坏、系统崩溃甚至安全漏洞。

关键认知: 竞态条件不是“偶尔发生”的异常,而是并发系统在时间窗口内必然出现的逻辑错误。xv6 作为教学内核,其同步机制的设计直接反映了真实操作系统(Linux、FreeBSD)的工程实践。

下表总结了操作系统内核中常见的并发场景及其风险:

并发场景 共享资源 竞态后果
进程调度 vs 中断处理 进程控制块(proc[]) 进程状态错乱、调度器选择已终止进程
多核 CPU 同时执行系统调用 文件描述符表 文件描述符泄漏、引用计数错误
生产者-消费者(管道/缓冲区) 环形缓冲区 数据覆盖、读取到不完整数据
内存分配器 空闲链表(kalloc/free) 内存块重复分配、内存泄漏

1.2 从自旋锁到条件等待:同步原语的演进

最简单的同步原语是自旋锁(Spinlock):如果锁被占用,CPU 原地循环等待。自旋锁在短临界区中效率很高,但存在两个致命缺陷:

  • 忙等待(Busy Waiting):持有锁的进程被调度出去时,其他等待进程会浪费整个时间片。
  • 无法表达“等待某个条件”:例如,进程需要等待磁盘 I/O 完成、等待管道有数据、等待子进程退出。自旋锁只能保护临界区,不能阻塞等待条件成立。

因此,操作系统需要一种更高级的同步原语——条件同步(Conditional Synchronization)。xv6 提供的 sleep 和 wakeup 正是这种原语的核心实现。

工程直觉: 自旋锁解决的是“谁可以进入临界区”,而 sleep/wakeup 解决的是“什么时候可以继续执行”。两者结合,才能构建出完整的并发控制体系。

1.3 sleep/wakeup 在同步原语中的位置

下图展示了操作系统同步原语的层次结构,以及 sleep/wakeup 所处的关键位置:

操作系统同步原语层次结构 底层:硬件原子操作(xchg / test-and-set / 内存屏障) 自旋锁(Spinlock) 睡眠锁(Sleeplock) 条件同步:sleep(channel) / wakeup(channel) 基于等待队列的阻塞唤醒机制 应用层:信号量 / 条件变量 / 管道 / 等待队列 sleep/wakeup 是连接“锁机制”与“高级同步抽象”的桥梁 xv6 的管道、wait/exit、console 输入全部依赖此原语

1.4 为什么 sleep/wakeup 是 xv6 的核心设计?

xv6 选择 sleep(channel) 和 wakeup(channel) 作为条件同步原语,而非更常见的 POSIX 条件变量(pthread_cond_wait),有其深刻的工程考量:

  1. 统一等待点:所有阻塞等待(管道读、等待子进程、等待磁盘)都通过同一个 sleep 函数实现,内核代码结构更简洁。
  2. 避免丢失唤醒(Lost Wakeup):xv6 要求调用 sleep 时必须持有保护条件的锁,并在 sleep 内部原子性地释放锁并进入睡眠,从根本上杜绝了唤醒丢失。
  3. 通道(Channel)抽象:使用任意整数地址作为等待通道,无需额外分配条件变量对象,内存开销极低。
  4. 与调度器深度集成:sleep 直接操作进程状态(RUNNABLE → SLEEPING),wakeup 遍历进程表查找匹配通道,调度器在 sched() 中统一处理上下文切换。
常见误解: sleep/wakeup 不是“万能同步方案”。它不保证公平性(可能发生饥饿),也不支持超时唤醒。xv6 的设计哲学是“够用且正确”,而非“功能完备”。

1.5 一个直观的案例:管道生产者-消费者

管道是 xv6 中使用 sleep/wakeup 最典型的场景。以下代码片段展示了核心逻辑:

// 管道读端(消费者)
int piperead(struct pipe *pi, char *addr, int n) {
    acquire(&pi->lock);
    while (pi->nread == pi->nwrite) {  // 管道空
        if (pi->writeopen == 0) {
            release(&pi->lock);
            return 0;  // 写端已关闭
        }
        sleep(&pi->nread, &pi->lock);  // 等待数据
    }
    // 从管道读取数据...
    wakeup(&pi->nwrite);  // 唤醒可能阻塞的写者
    release(&pi->lock);
    return i;
}

// 管道写端(生产者)
int pipewrite(struct pipe *pi, char *addr, int n) {
    acquire(&pi->lock);
    while (pi->nwrite - pi->nread == PIPESIZE) {  // 管道满
        wakeup(&pi->nread);  // 先唤醒读者
        sleep(&pi->nwrite, &pi->lock);  // 等待空间
    }
    // 写入数据到管道...
    wakeup(&pi->nread);  // 唤醒可能阻塞的读者
    release(&pi->lock);
    return n;
}

这个案例揭示了 sleep/wakeup 的三个关键工程模式:

  • 循环检查条件:使用 while 而非 if,防止虚假唤醒。
  • 锁的传递:sleep 内部释放锁,唤醒返回时重新获取锁,保证条件检查的原子性。
  • 对称唤醒:读者唤醒写者,写者唤醒读者,形成生产-消费的协作循环。

1.6 本章小结

核心概念 要点
竞态条件 并发访问共享数据导致的不确定性错误
自旋锁的局限 忙等待、无法表达条件等待
sleep/wakeup 定位 条件同步原语,连接锁机制与高级抽象
通道(channel) 任意整数地址作为等待标识,轻量高效
丢失唤醒 通过锁的原子性释放/获取来避免