fence 到底在解决什么问题:内存序、可见性与缓存的边界
一句话:
fence不刷缓存、不搬数据、不管落盘,它只做一件事——规定"我之前的访存,不能被观察到在我之后的访存后面"。这篇文章把 fence 的原理、乱序的真正来源、
fence/fence.i/cbo.*/ 设备 flush 的区别,以及常见误区一次讲清楚。
一、先分清三个完全不同的问题
很多"fence 到底是啥"的困惑,来源是把三件事混成一件:
| 问题 | 谁负责 | 典型手段 |
|---|---|---|
| 内容一致性:我写的值,你最终能不能拿到正确值 | 缓存一致性协议(MESI / 目录) | 硬件自动 |
| 顺序可见性:看到 B 时,A 一定已经可见吗 | 内存模型 + 内存屏障 | fence / acquire / release |
| 持久性:掉电后还在吗 | 写到持久介质 + 设备缓存刷新 | fsync / 设备 FLUSH |
fence 只活在第二行。它既不能制造"有效数据",也不能保证"落盘"。
二、为什么没有 fence 会乱:乱序的六个真实来源
| 来源 | 说明 | 与缓存的关系 |
|---|---|---|
| 编译器重排 | 别名分析不确定时调整 store 顺序 | 无关 |
| Store buffer / 写缓冲 | 写先进缓冲再慢慢传播,“退休"≠"可见” | 无关 |
| 乱序执行 + 非阻塞访存 | load 提前发射,多个 miss 并行(MSHR),返回顺序不定 | 无关 |
| Cache 命中 / miss 的传播时延差 | 命中行立刻可见;miss 行要 RFO,等独占权 | 相关 |
| 写合并 / 多通道互连 / 多级桥 | 写被合并、不同通道独立推进、posted write | 无关 |
| 多副本(多核) | 每个核看到的时间点不同,协议只保证单地址收敛 | 相关 |
一个最小例子
data = 42; // ① 该行不在本核 cache → miss,RFO,慢
flag = 1; // ② 该行已在本核 cache(E/M 态)→ 命中,几乎立刻可见
读者:
if (flag) // 看到 1
use(data); // 但 data 可能还是旧值
根因不是"cache 里存了旧数据"(那是一次性可见性,由一致性协议解决),而是:
① 两笔写的传播时延天然不同;② 内存模型允许不同地址互不排序。
一致性协议只保证单地址最终一致、写有唯一顺序;它不保证跨地址的先后。这就是缺口。
三、fence 的语义:一条偏序,不是一个动作
sw data, 0(a0)
fence w,w # 承诺:data 这笔写先于 flag 那笔写被观察到
sw 1, 0(a1)
形式化地说:
若 store A ; fence ; store B
则 任何外部观察者看到 B,必然已经能看到 A
它不承诺:
- 数据什么时候可见(只保证"不会因果倒置");
- 一定有人看到;
- 数据到了哪一层(cache / DDR / 介质)。
语法:pred / succ 掩码
fence pred, succ —— pred 里的访存必须先于succ 里的访存被观察到。
| 字母 | 含义 |
|---|---|
i | 设备 / MMIO 访问(I/O) |
o | 普通内存访问 |
r | load |
w | store |
| 写法 | Linux 对应 | 典型用途 |
|---|---|---|
fence rw,rw | smp_mb() / RISCV_FENCE(rw,rw) | 全屏障 |
fence w,w | smp_wmb() | 写数据 → 写标志/描述符 |
fence r,r | smp_rmb() | 读标志 → 读数据 |
fence r,rw | acquire | 进入临界区 |
fence rw,w | release | 退出临界区 |
fence iorw,iorw | mb()(含 MMIO) | 设备寄存器序列 |
fence.tso | — | 只保证 W→R 顺序,弱于全屏障 |
💡 acquire = fence r,rw、release = fence rw,w 的直觉:acquire 只要求"之后的访存不许前移";release 只要求"之前的访存不许后移",它们各自只有一个方向,所以比全屏障便宜。
四、fence 在硬件里怎么实现
CPU: store data ──► store buffer ──► L1 ──► L2/LLC ──► 互连 ──► 观察者
▲
ordering point (POI)
fence 的实现本质是两件事:
- 栅栏标记:fence 之后的 succ 访存不得越过它发射 / 提交(在乱序核里相当于 LSQ/ROB 上的排序点);
- 等待 pred 到达有序点:通常表现为排空 store buffer + 等待 outstanding 事务完成;跨桥、跨时钟域时还需要把 fence 传播到对应的可见点。
所以:
- fence 不刷 cache(一致性协议负责把最新值给其他核);
- fence 很贵:代价 ≈ 访存流水排空 + 等待响应;
- 优化方向:缩小掩码(能用
fence w,w就别用fence rw,rw)、用 acquire/release、把 fence 提到循环外。
五、别混:fence vs fence.i vs cbo.* vs 设备 flush
| 手段 | 回答什么问题 | 数据动了吗 | 掉电还在吗 |
|---|---|---|---|
fence | 谁先被观察到 | ❌ | — |
fence.i | 我改的代码,之后取指能看到吗(仅本 hart) | ❌ | — |
cbo.clean / cbo.flush | 脏行写回下一级(flush 还作废本地副本) | ✅ | ❌ |
cbo.inval | 丢弃本地副本,强制重取 | ✅(丢弃) | — |
设备 FLUSH / fsync | 设备写缓存 → 持久介质 | ✅ | ✅ |
sfence.vma | 页表修改后的 TLB 失效 | ❌ | — |
最容易记错的两句
- 改代码:
写代码 → clean D-cache(非一致平台)→ fence w,w → fence.i → 跳转。
fence.i只对本 hart 生效,其他 hart 必须各自执行(通常靠 IPI 广播)。 - 非一致 DMA:
fence只保证顺序,不保证设备看得到;必须 先 clean。
顺序是写数据 → clean → fence w,w → 写 doorbell。
六、四个必须用 fence 的真实场景
1. 多核消息传递
/* 生产者 */
data = compute();
smp_wmb(); /* 或 smp_store_release(&ready, 1) */
ready = 1;
/* 消费者 */
if (smp_load_acquire(&ready))
use(data);
2. DMA / 加速器的 doorbell
desc->len = n; /* 填描述符 */
dma_wmb(); /* 或 fence w,w */
writel(head_idx, ®s->doorbell);
/* 完成侧 */
inval_cache(buf);
dma_rmb();
if (readl(®s->status) & DONE)
consume(buf);
这里的 fence 不控制 DMA。它保证的是:DMA 看到 doorbell 时,描述符已经对它是可见的。请求发出去之后确实是异步的——那部分正确性靠中断/轮询/超时。
3. MMIO 配置序列
writel(src, &dev->src);
writel(dst, &dev->dst);
writel(len, &dev->len);
wmb(); /* 保证上面三笔先于 start */
writel(START, &dev->ctrl);
4. 自修改代码 / 固件打补丁
memcpy(dst, new_code, len);
flush_dcache_range(dst, dst + len);
fence(); /* 数据可见 */
fence_i(); /* 取指同步 */
((void (*)(void))dst)();
七、单核里 fence 有意义吗?
有,取决于"是否存在第二个观察者"。
| 观察者 | 单核也有吗 |
|---|---|
| 其他 hart | ❌ 多核才有 |
| DMA / 加速器 | ✅ 单核就有 |
| 外设寄存器(MMIO) | ✅ |
| 调试器 / JTAG | ✅ |
| 自己的取指单元(改代码) | ✅ |
反过来,若同时满足:只有一个 hart、没有 DMA/外部主机、不改代码、不依赖 MMIO 顺序 —— 那么 fence 对正确性没有作用,只剩代价。
注意:单核多线程共享变量时,同核同地址顺序硬件保证;但代码通常要跑在 SMP 内核上,所以内核里一律按多核写(
smp_mb()/READ_ONCE),这是可移植性要求。
八、六个常见误区
| 误区 | 正确说法 |
|---|---|
| “fence 是刷缓存” | fence 是顺序约束,不搬数据;刷内容用 cbo.* |
| “fence 能保证落盘” | 落盘要设备 flush / fsync;DDR 也不是持久介质 |
| “有 cache 才需要 fence” | 根因是内存模型允许跨地址乱序;store buffer、桥、写合并都会乱 |
| “单核不用写 fence” | 有 DMA / MMIO / 改代码就必须写 |
| “fence 会加速或让快的等慢的” | 它只是要求 succ 不早于 pred 被观察,代价是排空流水 |
| “别的核看不到是因为没写回内存” | 一致性协议会给最新副本;看不到是顺序问题,不是内容问题 |
九、一张图记住全链路
- fence:在 D 这个点上排顺序;
- cbo.clean/flush:把内容从 C 送到 D(甚至到内存);
- 设备 FLUSH:把内容从设备的内部缓存送到介质。
十、速查表
① 只想让别的核按顺序看到 → fence rw,rw / acquire-release
② DMA 读我写的缓冲 → cbo.clean + fence w,w + doorbell
③ DMA 写我的缓冲,我要读 → cbo.inval + fence r,r
④ 改代码后要执行 → fence w,w + fence.i
⑤ 改页表后要继续访问 → sfence.vma
⑥ 要掉电不丢 → fsync / 设备 FLUSH(与 fence 无关)
⑦ 只防编译器重排 → barrier() / READ_ONCE / WRITE_ONCE
结语
fence 之所以难,是因为它站在**规范(内存模型)和微架构(乱序、缓冲、时延)**的交界处,而它做的事又极其克制——只加一条顺序,不搬一个字节。
记住三句话就够了:
- 一致性管"值对不对",fence 管"先后对不对",flush/fsync 管"还在不在"。
- fence 不是"因为缓存",而是"因为我们选了允许乱序的模型"。
- 有没有 fence,取决于有没有第二个观察者——多核只是最常见的那一个。
757



被折叠的 条评论
为什么被折叠?



