Rust 中的 Atomic 类型

Atomic 类型用于包装一个整数,保证对这个整数的操作是原子的,并且可以指定内存顺序。用于多线程场景。

如果多个线程同时持有一个普通整数,并且至少有一个线程执行写操作,那么就会有 data race 即 UB 的风险。举几个例子:

  • 两个线程都进行 x += 1,这是一个典型的读改写,由于整个操作非原子,两次修改可能基于同一个原始值,最终 x 的值并没有增加 2.
  • 一个线程等待 x != 0 发生再退出循环,另一个线程修改 x。由于编译器不知道 x 是一个多线程访问的变量,可能将 x != 0 优化为 tmp = x, tmp != 0。此时另一个线程修改 x 不会影响 tmp 的值,导致死循环。
  • 两个线程同时执行 x = 1 和 print(x),由于没有同步要求,编译器可能从硬件指令上对这些操作做各种优化,导致最终 print(x) 打印的值并非原始值或 1,出现了 UB。

也就是说,即使我们不要求读改写原子,只要求简单的赋值操作完整、跨线程可见,我们也需要使用 Atomic。否则编译器可能在"无需同步"的假设下,做出各种优化,导致 UB。

Atomic 类型提供一系列 API,如 fetch_add 等。在编译这些 API 时,编译器不会随意优化,可能还会使用特殊的硬件指令保证写入结果同步到各个 CPU 上。(这里可能涉及缓存一致性协议)从而避免了上面提到的可能出现的 UB。

如果在实际编程时不好想象使用简单的整数类型会引发什么问题,可以选择 坚定的相信 在"多个线程同时持有一个普通整数,并且至少有一个线程执行写操作" 的场景下,一定要使用 Atomic。

Ordering

fetch_add 等 API 中有一个参数叫 Ordering,他是用于限制访存操作的内存顺序的。因为默认情况下,CPU 和 编译器 可能进行指令重排序的优化,即代码中编写的变量赋值顺序(单个线程中)可能与实际发生的顺序不同。

举一个例子

rust 复制代码
// 线程 1
data = 42;
ready.store(true, Release);

// 线程 2
if ready.load(Acquire) {
    println!("{}", data);
}

通过使用 Release / Acquire 保证:store(true) 发生时,前面的 data 一定被赋值为了 42;load 发生后,线程 2 才会去获取 data 的值。如果这两个有一个没有得到保证,就可能发生 ready 是 true 了,但 data 不是 42 的情况。

所以 Release 和 Acquire 的语义是这样的

  • Release:适用于写操作,要求此次写操作之前的内存访问一定都完成了,再执行写操作。
  • Acquire:适用于读操作,要求此次读操作完成后,再进行后续的内存访问操作。

Release / Acquire 必须成对、对同一个变量使用,才能达成在线程间同步的效果。

其他的 Ordering 还有 Relaxed、AcqRel、SeqCst 等,就不一一赘述了。

相关推荐
Rust研习社18 小时前
组合真的优于继承吗?为什么 Rust 和 Go 都拥抱组合舍弃继承?
后端·rust·编程语言
红尘散仙2 天前
想写一个像样的终端 App?试试把 React 的开发体验搬进 Rust TUI
前端·rust
vivo互联网技术2 天前
从 Web 到桌面:基于 Tauri 2.0 + Vue 3 打造 vivo 线下门店「大头贴」拍照体验系统
前端·rust
Rust研习社2 天前
这 8 个 Rust 学习资源值得每个新手收藏起来
后端·rust·编程语言
星栈3 天前
10 分钟跑起第一个 Dioxus 应用:`dx` CLI、`rsx!` 和热更新好不好用
前端·rust·前端框架
望眼欲穿的程序猿3 天前
读取芯片内部温度传感器
嵌入式硬件·rust
望眼欲穿的程序猿3 天前
ADC 模拟电压采集
嵌入式硬件·rust
codexu_4612291873 天前
NoteGen 里一条记录如何变成 Markdown
前端·笔记·rust·tauri
Rust研习社3 天前
Rust 错误处理的黄金搭档:一个定义错误,一个传播错误
后端·rust·编程语言
techdashen3 天前
绕过系统 ICMP:用 rawsock、Npcap 和 WMI 找到默认网卡
开发语言·arm开发·rust