Skip to content

Day 37: std.atomic模块:原子操作

欢迎来到第三十七天!今天我们开始涉足并发编程。当多个线程需要同时访问和修改共享数据时,如果没有任何同步机制,就会发生数据竞争(Data Race),导致程序状态混乱和不可预测的行为。一种常见的同步机制是使用互斥锁(Mutex),但锁可能会引入性能瓶颈和死锁等问题。**原子操作(Atomic Operations)**提供了一种更底层的、无锁(lock-free)的同步方式。std.atomic 模块提供了在硬件层面保证不可分割的操作,例如读取、写入和修改,确保在多线程环境下对单个变量的访问是安全的。

最基本的原子操作是加载(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);
// 线程B
const value = @atomicLoad(u32, &shared_variable, .SeqCst);

order 参数是原子操作中最复杂也最重要的部分。它告诉编译器和CPU,当前原子操作与其他内存操作之间需要维持什么样的顺序保证。

  • .Unordered: 最宽松的顺序。不提供任何顺序保证,只保证操作本身的原子性。性能最高。
  • .Monotonic: 保证在单个原子变量上的操作顺序是一致的,但与其他变量无关。
  • .Acquire: “获取”语义。在它之后的任何内存读写操作,都不能被重排到它之前。通常用于“进入”一个临界区或读取共享数据之前。
  • .Release: “释放”语义。在它之前的任何内存读写操作,都不能被重排到它之后。通常用于“离开”一个临界区或写入共享数据之后。
  • .AcqRel: 同时具有 .Acquire 和 .Release 的语义。
  • .SeqCst: 顺序一致性(Sequentially Consistent)。最强的内存顺序。它保证所有线程看到的原子操作顺序都是全局一致的。这最容易理解,但性能开销也最大。如果你不确定,请先使用 .SeqCst。

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)。这是无锁编程的基石。它原子地执行以下逻辑:

    1. 读取 ptr 的当前值。
    2. 如果当前值等于 expected_value,就将 ptr 的值更新为 new_value,并返回 null 表示成功。
    3. 如果当前值不等于 expected_value,则不做任何修改,并返回实际从内存中读取到的值。
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循环”:

  1. 原子地读取当前值 (old_value)。
  2. 计算新值 (new_value = old_value + value)。
  3. 使用CAS尝试将 old_value 交换为 new_value。
  4. 如果CAS失败(意味着在第1步和第3步之间,有其他线程修改了 ptr),则循环重试。

问:原子操作能保证ABI(应用二进制接口)兼容吗?

答:不一定。不同平台、不同CPU架构对原子操作的支持和实现可能不同。Zig的交叉编译能力会处理大部分差异,但如果你在编写需要与特定硬件或C代码进行极底层交互的代码,你需要仔细查阅相关平台的文档。

问:什么时候应该用原子操作,什么时候应该用互斥锁?

答:

  • 原子操作:适用于对单个变量(如标志位、计数器、指针)进行简单、独立的修改。它是实现无锁数据结构的基础。
  • 互斥锁:适用于需要保护一个代码块,这个代码块中可能涉及对多个变量的复杂修改。使用锁可以保证整个代码块的原子性。

总的来说,原子操作更底层,性能潜力更高,但使用起来也更复杂,更容易出错。互斥锁则提供了一个更高级、更容易理解的抽象。

今天,我们初步踏入了并发编程的底层世界,学习了 std.atomic 提供的原子操作。我们了解了原子加载/存储、读-改-写操作(特别是强大的CAS),并初次接触了复杂的内存顺序概念。原子操作是构建高性能、无锁并发数据结构和算法的基石。虽然它使用起来比锁更具挑战性,但它也为我们提供了在多核时代榨干硬件性能的终极武器。

明天,我们将学习一个非常有趣的模块 std.zig,看看Zig是如何使用自己来解析自己的。