锁机制与内存顺序

本页目录

一个计数更新正确,不代表相关对象仍然存活;快照可以重试,也不代表错误访问能撤回。同步需要同时说明互斥、顺序与寿命。

查阅目标阅读位置
锁与上下文第 1~4 节
一致快照与原子操作第 5~6 节
发布、等待与退出第 7~8 节

同步的目标不只是防止两个 CPU 同时写同一地址,还包括维持多字段不变量、限制对象寿命、保证数据发布顺序,以及避免同一 CPU 上的中断重入。选锁前应先列出所有读写者及其上下文。

例如队列由 head、tail 和 count 构成,把 count 改成 atomic 并不能保护节点链接;读者还可能在 count 更新前看到新节点,或在节点释放后继续访问。首先写清“什么时候队列对读者可见、谁能删除、谁负责释放”。

先列访问上下文,再决定互斥与本地屏蔽 任务上下文 是否可睡眠? softirq / hardirq 能否打断当前持锁者? 其他 CPU 是否同时读写同一状态? 共同的同步协议 锁顺序、对象寿命、可见性和退出清理 关闭本地中断不锁住其他 CPU;单个原子字段也不自动保护整个对象。

1. 按约束选择,而不是按所谓重量排序

需求常见选择不能忽略的条件
可睡眠任务之间保护复合状态mutex同一拥有者释放,不支持递归
真正原子/中断上下文中的短临界区合适 spinlock/raw spinlock核实 RT 语义和中断重入
多读者需要保持稳定共享状态rw_semaphore持有期间允许阻塞也可能拖延写者
少量标量快照可重读seqcount/seqlock写者串行、读者可重试、无指针寿命漏洞
读路径低开销地访问可替换对象RCU另外处理写者互斥与延迟回收
独立原子状态或计数atomic/refcount 等原子性、顺序和对象寿命分别设计
等待一次操作结束或条件改变completion / wait queue它们不取代保护共享数据的锁

读多写少只是统计特征,不能自动决定使用 RCU 或 seqlock。读者是否必须看到最新值、是否可睡眠、是否允许重试、是否需要跨临界区持有对象,都会改变选择。

2. Spinlock 与本地屏蔽分别做什么

在非 PREEMPT_RT 内核中,spin_lock() 自身会禁用抢占来保持相应临界区语义,不要求调用者事先关闭抢占。但它不自动屏蔽本地 hardirq。

假设任务持有锁 L,随后本 CPU 的中断 handler 也获取 L:handler 自旋等待任务释放,任务又无法在 handler 返回前运行,形成自锁死循环。需要在合适访问侧使用 irqsave 等协议,避免这种重入。

  • spin_lock_irqsave() 保存本地中断状态、屏蔽中断并取锁,对应 irqrestore 恢复原状态。
  • _irq() / unlock_irq 不保存原状态,调用者必须满足其入口状态约定。
  • _bh() 处理本地 bottom-half/softirq 并发,不能据此认为 hardirq 或 NMI 也被屏蔽。

关闭本地中断不能防止其他 CPU 访问共享数据;真正的跨 CPU 互斥来自锁。NMI 不受普通中断屏蔽控制,在 NMI 也会使用的数据需单独设计。

PREEMPT_RT 改变了 spinlock_t 的实现与语义

RT 内核的普通 spinlock_t 基于可阻塞的实时锁机制,不应假设获取后禁用抢占;其 hardirq 后缀也不等同于非 RT 上的本地硬中断屏蔽。raw_spinlock_t 保留严格自旋和相应原子语义,用于真正需要它的底层场景。

这不是鼓励把所有锁替换成 raw spinlock:过长不可抢占区会损害实时性,而且持有 raw 锁时不能再调用可能阻塞的普通 RT spinlock 获取路径。类型与嵌套规则见锁类型文档。

3. qspinlock 与 mutex 的竞争路径

qspinlock 将紧凑锁状态与排队等待结合,包含无竞争、pending 和队列路径;竞争者可通过 MCS 风格节点减少所有 CPU 持续争用同一个锁字的开销。它不是“所有竞争者永远只碰自己的 cache line”,解锁、队头和尾部交接仍需共享状态。

实现的位布局还受配置影响,应读取 include/asm-generic/qspinlock_types.h,不能把 locked/pending/tail 随意按字节画成固定 ABI。架构和虚拟化也可能采用不同实现。

mutex 的无竞争路径快速取得所有权,竞争时可在合适条件下乐观自旋或睡眠等待。唤醒等待者并不保证它立即在 CPU 上运行;公平性、handoff 与 owner 状态处理也不能缩写成一次 owner=0。

持 mutex 可以在允许上下文中睡眠,但要考虑睡眠路径是否反过来需要同一锁;mutex_lock_interruptible() 等接口可返回未取得锁的结果,调用方必须检查。普通 mutex 不等于提供优先级继承的 rt_mutex。参见mutex 设计。

4. rwsem 与 percpu rwsem 是不同接口

rw_semaphore 允许多个读者或一个写者;percpu_rw_semaphore 是专门接口,利用 per-CPU 读侧状态和相应同步机制优化某些读多写少场景。不是普通 rwsem 到某个版本后自动全部变成 per-CPU。

读锁仍可能等待写锁;一个读者长时间睡眠会阻挡写者。RT 下多个读者与优先级继承的关系尤其需要注意,不能承诺任意配置中都有严格的等待时延上限。

如果从读锁直接尝试升级成写锁,可能与其他升级者相互等待。应按接口支持情况释放后重查条件、改为一开始取写锁,或设计专门转换协议。

5. Seqlock:得到一致快照,代价是可能重试

写者进入时把序列推进到写入状态,结束时再次推进;读者复制数据后检查序列是否变化。write_seqlock() / unlock 已管理序列,调用者不能在两者之间再次手工 seq++。

下面是可独立编译的内核 C 翻译单元示例,不包含模块注册。它展示两个标量的不变量 high = low + 1;读取结果只在一次成功检查后使用。

#include <linux/compiler.h>
#include <linux/seqlock.h>
#include <linux/types.h>

static DEFINE_SEQLOCK(range_lock);
static u32 range_low;
static u32 range_high = 1;

void kb_set_range(u32 low);
void kb_get_range(u32 *low, u32 *high);

void kb_set_range(u32 low)
{
    write_seqlock(&range_lock);
    WRITE_ONCE(range_low, low);
    WRITE_ONCE(range_high, low + 1); /* u32 回绕是本例允许的行为 */
    write_sequnlock(&range_lock);
}

void kb_get_range(u32 *low, u32 *high)
{
    unsigned int seq;
    u32 a, b;
    do {
        seq = read_seqbegin(&range_lock);
        a = READ_ONCE(range_low);
        b = READ_ONCE(range_high);
    } while (read_seqretry(&range_lock, seq));
    *low = a;
    *high = b;
}
读者的动作暂存结果是否可交给调用者
先读取旧 lowa = 0不可,快照尚未验证
写者更新为 (10, 11),读者再读 high(a, b) = (0, 11)不可,组合破坏 high = low + 1
序列检查发现更新丢弃临时组合并重读只有检查成功才可使用

重试区间内不能基于临时值执行不可撤回的外部副作用。

本例假设访问上下文与普通 seqlock 匹配;若中断读者能打断写者,写者还需相应本地屏蔽,否则读者可能等待被自己打断的写者恢复。纯 seqcount 本身也不串行多个写者,必须另有写侧锁,并满足不可抢占等规则。

在写者更新途中分别读取两个字段,可以主动拼出一份混合版本。序列验证决定这份临时结果能否使用,不能把已经读到两个值当成一致快照。

正在呈现知识画面
拼出来的两个值,能当作同一份快照吗?

临时读数可以不一致;只有序列未变且为偶数时才使用结果。重试保护一致快照,不撤销非法指针访问,也不允许不可重复副作用。

快照一致与指针寿命分开保证

重试不能修复已经发生的非法访问。 如果读者通过快照中的指针访问已被释放对象,再发现序列变了,仍然是 UAF。指针寿命必须由 RCU、引用或其他机制保护。读侧不能在可能重试的区域做不可重复的外部副作用。见 seqcount/seqlock。

6. 原子操作、refcount 与顺序

atomic_read/set、无返回值 RMW、带返回值 RMW 及 acquire/release/relaxed 变体的顺序保证不同,不能把所有 atomic 操作称为全屏障。失败的条件操作也需单独检查返回与顺序语义。

对象引用应优先使用合适的 refcount_t 等接口,利用溢出/下溢防护与特定寿命语义;一般计数器不自动防止“从零复活”或安全获取已经可能释放的对象。

CAS 能原子比较替换一个状态,却不自动消除 ABA:某个值经历 A→B→A 后,比较仍可成功。无锁结构往往还需要版本、回收协议和严格的顺序设计。见 atomic 类型。

7. 发布数据需要匹配的观察关系

一个只发布一次、发布后不再修改 payload 的例子:写者先填写 payload,再 smp_store_release(&ready, 1);读者通过 smp_load_acquire(&ready) 观察到该发布值后,才读取 payload。

release/acquire 的作用来自匹配的发布与观察,不是任意两个 CPU 各执行一次屏障就自动同步。若 ready 重用、payload 循环覆盖,还需要防止旧读者与新写者竞争的额外协议。

工具所约束的层次
READ_ONCE / WRITE_ONCE单次访问相关的编译器行为
barrier()编译顺序
smp_* 屏障相应跨 CPU 内存排序
设备访问器与 dma_* 屏障MMIO/DMA 的相应设备访问协议

这些操作不是统一的“刷新缓存”,单次访问约束也不独立建立完整发布关系。参见内存屏障文档。

下面固定读者已经读到 ready=1,只改变发布与获取的配对。观察哪些结果仍允许出现,从结果集合理解顺序约束,而不是把源代码上下位置直接当成跨 CPU 先后。

正在呈现知识画面
看到 ready=1,为什么还需要匹配的发布关系?

只有读者通过 acquire 观察到对应 release 发布值,才能沿匹配关系推导此前 payload 写入对后续读取的约束。READ_ONCE/WRITE_ONCE 本身不建立这条完整关系。

8. 等待必须与条件和寿命配合

wait queue 使用 wait_event 等接口在正确协议下登记等待并检查条件,唤醒者先更新条件再通知。单独写一个 if (!ready) schedule() 会在检查与睡眠之间丢掉通知。

completion 适合一次或受控重复的完成事件;超时返回并不取消异步操作,等待对象及回调上下文仍必须保持有效。销毁工作时先阻止新提交,再同步取消/等待,最后释放资源,且等待期间不能持有完成路径需要的锁。

如何验证这些约束,见同步调试。