Day 37: std.atomic模块:原子操作
欢迎来到第三十七天!今天我们开始涉足并发编程。当多个线程需要同时访问和修改共享数据时,如果没有任何同步机制,就会发生数据竞争(Data Race),导致程序状态混乱和不可预测的行为。一种常见的同步机制是使用互斥锁(Mutex),但锁可能会引入性能瓶颈和死锁等问题。**原子操作(Atomic Operations)**提供了一种更底层的、无锁(lock-free)的同步方式。std.atomic 模块提供了在硬件层面保证不可分割的操作,例如读取、写入和修改,确保在多线程环境下对单个变量的访问是安全的。
2. @atomicLoad 和 @atomicStore
Section titled “2. @atomicLoad 和 @atomicStore”最基本的原子操作是加载(load)和存储(store)。Zig提供了内置函数来实现它们。
@atomicLoad(T: type, ptr: *const T, order: std.builtin.AtomicOrder) T: 原子地从ptr读取一个T类型的值。@atomicStore(T: type, ptr: *T, value: T, order: std.builtin.AtomicOrder) void: 原子地将value写入到ptr指向的内存。
const std = @import("std");
var shared_variable: u32 = 0;
// 线程A@atomicStore(u32, &shared_variable, 100, .SeqCst);
// 线程Bconst value = @atomicLoad(u32, &shared_variable, .SeqCst);3. 内存顺序 (Memory Ordering)
Section titled “3. 内存顺序 (Memory Ordering)”order 参数是原子操作中最复杂也最重要的部分。它告诉编译器和CPU,当前原子操作与其他内存操作之间需要维持什么样的顺序保证。
.Unordered: 最宽松的顺序。不提供任何顺序保证,只保证操作本身的原子性。性能最高。.Monotonic: 保证在单个原子变量上的操作顺序是一致的,但与其他变量无关。.Acquire: “获取”语义。在它之后的任何内存读写操作,都不能被重排到它之前。通常用于“进入”一个临界区或读取共享数据之前。.Release: “释放”语义。在它之前的任何内存读写操作,都不能被重排到它之后。通常用于“离开”一个临界区或写入共享数据之后。.AcqRel: 同时具有.Acquire和.Release的语义。.SeqCst: 顺序一致性(Sequentially Consistent)。最强的内存顺序。它保证所有线程看到的原子操作顺序都是全局一致的。这最容易理解,但性能开销也最大。如果你不确定,请先使用.SeqCst。
4. 读-改-写 (Read-Modify-Write) 操作
Section titled “4. 读-改-写 (Read-Modify-Write) 操作”std.atomic 的核心价值在于提供“读-改-写”操作,这些操作将读取、修改和写回三个步骤合并成一个单一的、不可分割的原子指令。
-
@atomicRmw(T: type, ptr: *T, op: std.builtin.AtomicRmwOp, value: T, order: std.builtin.AtomicOrder) T: 原子地执行一个二元操作(如加、减、与、或),并返回修改前的旧值。 -
@atomicCmpxchg(T: type, ptr: *T, expected_value: T, new_value: T, success_order: std.builtin.AtomicOrder, failure_order: std.builtin.AtomicOrder) ?T: 比较并交换(Compare-and-Swap, CAS)。这是无锁编程的基石。它原子地执行以下逻辑:- 读取
ptr的当前值。 - 如果当前值等于
expected_value,就将ptr的值更新为new_value,并返回null表示成功。 - 如果当前值不等于
expected_value,则不做任何修改,并返回实际从内存中读取到的值。
- 读取
5. 示例:实现一个原子计数器
Section titled “5. 示例:实现一个原子计数器”const std = @import("std");
var counter: u64 = 0;
// 这个函数可以在多个线程中被安全地调用fn incrementCounter() void { _ = @atomicRmw(u64, &counter, .Add, 1, .SeqCst);}使用 @atomicRmw 的 .Add 操作,我们可以安全地实现一个多线程计数器,而无需使用锁。
6. 实践练习:使用CAS循环实现原子加法
Section titled “6. 实践练习:使用CAS循环实现原子加法”@atomicRmw 实际上可以用 @atomicCmpxchg 来实现。尝试编写一个函数 atomicAdd(ptr: *u64, value: u64),它使用一个循环和 @atomicCmpxchg 来原子地给 ptr 增加 value。
这个循环通常被称为“CAS循环”:
- 原子地读取当前值 (
old_value)。 - 计算新值 (
new_value = old_value + value)。 - 使用CAS尝试将
old_value交换为new_value。 - 如果CAS失败(意味着在第1步和第3步之间,有其他线程修改了
ptr),则循环重试。
7. 常见问题
Section titled “7. 常见问题”问:原子操作能保证ABI(应用二进制接口)兼容吗?
答:不一定。不同平台、不同CPU架构对原子操作的支持和实现可能不同。Zig的交叉编译能力会处理大部分差异,但如果你在编写需要与特定硬件或C代码进行极底层交互的代码,你需要仔细查阅相关平台的文档。
问:什么时候应该用原子操作,什么时候应该用互斥锁?
答:
- 原子操作:适用于对单个变量(如标志位、计数器、指针)进行简单、独立的修改。它是实现无锁数据结构的基础。
- 互斥锁:适用于需要保护一个代码块,这个代码块中可能涉及对多个变量的复杂修改。使用锁可以保证整个代码块的原子性。
总的来说,原子操作更底层,性能潜力更高,但使用起来也更复杂,更容易出错。互斥锁则提供了一个更高级、更容易理解的抽象。
今天,我们初步踏入了并发编程的底层世界,学习了 std.atomic 提供的原子操作。我们了解了原子加载/存储、读-改-写操作(特别是强大的CAS),并初次接触了复杂的内存顺序概念。原子操作是构建高性能、无锁并发数据结构和算法的基石。虽然它使用起来比锁更具挑战性,但它也为我们提供了在多核时代榨干硬件性能的终极武器。
明天,我们将学习一个非常有趣的模块 std.zig,看看Zig是如何使用自己来解析自己的。