Rust 所有权模型与 缓存一致性 的思考
前阵子优化一个多线程计数的小工具,逻辑特别简单:几个线程各写各的统计变量,最后汇总一下。结果性能死活上不去,用 perf 一看,热点居然落在根本没几行代码的地方。查了半天资料才反应过来是 false sharing——几个变量挤在同一条 cache line 上,硬件层面天天打架。顺藤摸瓜去补 MESI 协议的时候,看着看着突然愣住了:这套东西怎么越看越像 Rust 的借用检查?越想越觉得有意思,索性把想法整理成这篇。
先说说缓存一致性到底在管什么
现代 CPU 每个核都有自己的私有缓存,同一份数据可能在好几个核的缓存里各存一份。核 A 改了自己那份,核 B 手上的拷贝立马就成了脏数据。为了保证大家看到的东西一致,硬件里有一套 MESI 协议在默默收拾局面。
MESI 是四个状态的首字母:
- Modified:这条 line 只有我有,而且我改过了,和内存不一致
- Exclusive:只有我有,还没改,和内存一致
- Shared:大家都有,都只能读
- Invalid:我这份已经作废了,不能用
关键的规则就一条:任何核想写某条 cache line,必须先把其他核手里的拷贝全部作废(置为 I),让自己独占之后才能动手。 x86 上写一个别的核正在读的变量,硬件会先发一个 RFO(Read-For-Ownership)请求把 line 拽过来,对方手里的副本就地作废。
Rust 的借用规则:编译期的 MESI 对照表
Rust 那几条规则大家背得滚瓜烂熟:每个值同一时刻只有一个所有者;要么有多个不可变借用 &T,要么只有一个可变借用 &mut T;move 过后旧变量不能再用。
把两张表摆在一起:
| Rust | MESI |
|---|---|
多个 &T 共享只读 |
Shared 状态 |
独占的 &mut T |
Modified / Exclusive 状态 |
&mut 让旧引用失效 |
写之前 Invalidate 其他拷贝 |
| move 转移所有权 | cache line 所有权易主 |
这个对应关系不是巧合,因为它们解决的是同一个本质问题:可变的东西必须独占,不可变的东西可以随便共享。 区别只在执行时机。
借用检查器就是软件版的总线嗅探
MESI 里,核想写,先在总线上喊一嗓子 Invalidate,其他核把副本作废,这才轮到它动手。Rust 里,你想要 &mut T,编译器检查一下还有没有别的引用活着,有就直接报错,逼你先把旧引用的生命周期结束掉。一个在运行期作废缓存副本,一个在编译期作废引用,动作几乎一模一样,只是一个默默扣你性能,一个明着骂你。
这也顺便解释了为什么 Rust 敢把 fearless concurrency 挂在嘴边:数据竞争的前提是“两个执行流同时访问同一份数据,且至少一方在写”,而借用规则直接从语言层面堵死了“同时存在两个可写方”的可能。硬件没有拒绝执行这个选项,程序必须跑下去,所以只能拿性能换正确;编译器有拒绝编译这个选项,所以能把这笔账提前到编译期收走。
False sharing:借用检查器够不着的地方
有对应就有漏网之鱼。硬件上独占的单位是 cache line,一般 64 字节;Rust 里独占的单位是“值”。两个 AtomicU64 在语言层面是两个独立的值,各自独占,完全合法,但要是恰好挨在一条 line 上,两个线程各写各的,硬件层面照样天天互相作废,性能直接腰斩。
实测能用的修法也简单,把每个计数器隔开就行:
use std::sync::atomic::AtomicU64;
#[repr(align(64))]
struct Padded(AtomicU64);
static A: Padded = Padded(AtomicU64::new(0));
static B: Padded = Padded(AtomicU64::new(0));
(crossbeam 里有现成的 CachePadded,不用自己手搓。)
这个 #[repr(align(64))] 干的事,说白了就是在硬件层面手动制造“独占”——每个值独占一条 line,相当于人肉把借用检查的粒度补齐到和硬件一致。挺讽刺的:语言层面合法的代码,到硬件层面反而是“非法”的,得程序员自己把这套规则再执行一遍。
共享可变的代价,两边算法一样
那绕过编译期独占、非要运行期共享可变怎么办?Rust 给的答案是 Arc<Mutex<T>>。仔细想想,Mutex 的语义就是“写之前先抢独占权,抢不到就等着”,这和缓存一致性里“写之前先抢 line 的 M/E 状态”在概念上是同构的。而且 Mutex 内部靠原子 CAS 实现,x86 上带 lock 前缀的指令,硬件行为就是先把目标 line 拉成独占状态再说。所以锁争用激烈的时候性能曲线崩得那么难看,本质上就是一堆核在总线上互相喊 Invalidate,line 在核之间弹来弹去——和借用检查器拦下的那种冲突是同一个冲突,只是从编译期挪到了运行期。
写在最后
早年学 Rust 的时候被借用检查折磨得不轻,心想“我就是想同时改一个数,至于吗”。补完 MESI 才发现,CPU 内部几十年来每天都在执行同一套规则,只是它不报错,它默默扣你性能。很多语言的“反直觉”设计,背后其实是对硬件现实的诚实:硬件没有免费的共享,所有一致性都是有账单的,Rust 只是选择把账单寄到编译期,让你在写代码的时候就看见它。
后面打算把那个计数工具优化前后的实测数据整理一下再发一篇,这次就先写到这。
评论
还没有评论。
发表评论
提交后评论将经过自动审核,审核通过后公开展示。