外观
并发:用锁维护不变量,用等待协议交接执行权
从两个 CPU 拿走同一页开始
空闲链表头指向 A,A 后面是 B。CPU0 和 CPU1 同时读取 head=A,随后都将 head 改为 B,并各自返回 A。程序没有访问越界,却把同一物理页分给了两名使用者。错误不在某个单独赋值,而在“取头并更新头”这个复合动作没有作为整体发生。
因此锁保护的是不变量:每个空闲页恰好出现在一个空闲链表中;每个已分配页不在任何空闲链表中。锁的边界应该覆盖所有暂时破坏这一关系的步骤。只锁住写 head 的一行,而不锁住读取和取走操作,仍然会出错。
自旋锁、关中断和内存顺序
自旋锁通过原子操作竞争所有权,未取得者忙等。它适合持有时间短、持锁期间不会睡眠的路径。原子修改锁变量还不够:锁的 acquire/release 语义要保证临界区内数据读写不被重排到错误的一侧;volatile 无法替代这类同步。
xv6 的 acquire 会关闭当前 CPU 中断,避免本 CPU 在持锁期间被中断处理程序打断,而后者又试图取得同一锁,形成自我死锁。关中断只影响当前 CPU,不能阻止其他 CPU,因此仍然需要真正的锁。push_off/pop_off 管理嵌套计数,不能把嵌套的关中断简单改成无条件开关。
读取 cpuid 并操作 per-CPU 数据期间也需要避免迁移。如果读到 CPU0 的编号后发生调度,恢复时在 CPU1 却继续使用旧编号,就可能破坏“当前 CPU 的私有状态”假设。
sleep/wakeup 为什么要带锁
考虑消费者发现队列为空,准备睡眠;生产者刚好入队并调用 wakeup;消费者随后才标为睡眠。唯一一次唤醒已经过去,消费者可能永远等下去。这叫丢失唤醒,不能靠多加一次打印解决。
sleep(chan, lock) 的意义,是把“放开保护条件的锁”和“让自己进入可被唤醒的状态”以受控的锁交接方式连接起来。xv6 通过进程锁与调用者的条件锁完成这一协议。唤醒后必须重新检查条件,因为其他消费者可能先取走了资源。
text
acquire(queue_lock)
while queue_empty():
sleep(queue_channel, queue_lock)
take_one_item()
release(queue_lock)这里 while 不是多余防御:wakeup 只表示“条件可能改变”,不保证资源已经为当前线程预留。wakeup 应和修改队列条件使用兼容的同步规则,否则检查与通知仍会出现空窗。
2025 lock:分散竞争与读写并行
第一部分让空闲页按 CPU 分组,日常分配只取得本地锁,本地不足时再从别处偷取。它把所有 CPU 竞争的单点拆开,但引入平衡问题。稳妥起点是每次只持有一把链表锁,先从远端摘下一批,释放远端锁,再挂入本地;若设计同时持多锁,则必须有全局锁顺序。
第二部分实现 writer-priority 读写自旋锁。任意多个读者可以共存;写者必须独占;一旦有写者等待,后来的读者不能不断插队。状态至少要表达活跃读者数、活跃写者、等待写者数。仅保证没有两个写者同时进入,不足以保证写者不会饥饿。2025 官方 lock
一个交错例子:R1 已进入;W1 登记等待;R2 到来。R2 必须等待,R1 退出后 W1 才能进入;W1 退出且没有等待写者时,R2 才能进入。若 R2 只检查“当前没有活跃写者”,它会绕过 W1。
如何评价优化
先正确性,再竞争,再吞吐。kalloctest 的失败 test-and-set 次数能粗略观察锁竞争,但不是程序耗时,也不直接代表实际工作效率。频繁偷取可能把竞争转移到另一处;过大的批次可能让其他 CPU 缺页;单 CPU 测试再漂亮也不能证明多核安全。
记录同一机器、相同 CPU 数、相同负载下的结果,至少给出取得锁次数、失败竞争次数和运行时间。KCSAN 可以发现部分数据竞争,但“没报告”不是数学证明,且它会显著改变时序与性能。
自测:把 acquire 换成关中断,能修复双 CPU 分配同一页吗?
不能。两个 CPU 可以同时关闭各自中断,然后同时操作同一链表。关中断解决本 CPU 的重入与迁移问题,锁解决跨 CPU 的互斥与内存顺序。
自测:为什么持自旋锁期间调用可能睡眠的函数危险?
持锁者不再运行,其他 CPU 只能忙等;被等待事件还可能依赖取得该锁的代码,形成死锁。必须审查调用链,不能只看当前函数里有没有 sleep 字样。