Loading [MathJax]/jax/output/CommonHTML/config.js
前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
MCP广场
社区首页 >专栏 >深入理解Rust的Atomic及Ordering

深入理解Rust的Atomic及Ordering

作者头像
newbmiao
发布于 2023-12-26 03:16:04
发布于 2023-12-26 03:16:04
62800
代码可运行
举报
文章被收录于专栏:学点Rust学点Rust
运行总次数:0
代码可运行

之前提到的MutexCondvarRust中比较偏高层的共享数据型并发控制,更底层的并发控制也有,比如Atomic(原子操作)。

今天结合代码来深入聊聊Atomic及其相关的Ordering

文章目录

  • Mutex vs Atomic
  • Atomic 初探
  • 指令重排
  • Ordering
  • 验证 Ordering 的可见性
  • fence
  • 延迟加载

首先为什么要有 Atomic,用 Mutex 不就可以了吗,我们来对比下

Mutex vs Atomic

1.从数据操作上对比:

Mutex是并发下对数据的互斥访问控制,多个线程尝试写入,同时必须只能有一个线程争得锁,进而写入成功,其他线程只能等待其释放锁后再争夺锁。

Atomic本身就是并发下对数据的原子操作,其操作是构建在操作系统的原子指令上,比如读取(Load),写入(Store),比较写入(CAS,compare and swap),自增(fetch_add)等,操作要么成功要么失败,不可能被其他线程打断,出现中间状态,避免操作中数据竞争状态的发生。

也就是说Atomic是依赖底层系统的指令不可拆分达到无需锁(lock free)就能直接对数据地址共享操作。

2.从临界区构建上对比:

Mutex是在加锁和释放锁之间构建了并发访问的临界区,进而进行数据操作。

Atomic本身对于数据地址操作就是原子的,如果临界区想操作就是数据本身,那就不需要额外的保证

但如果还有别的数据需要在临界区操作,则需要通过load/store/cas等组合wait loop才能实现,也就是常说的自旋(spin),这也是更底层的方式。一般在对性能要求更细致场景会需要。

下边先用伪代码举例常见临界区的样子(后边会结合 Ordering 用代码详细展开)

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
thread 1:
    // 条件满足设置flag
    store/CAS flag: false->true
thread 2:
    // wait flag满足条件,模拟类似锁的阻塞, spin
    while load flag == false {};
    // 执行临界区操作
    ...

Atomic 初探

了解了Atomic的作用,下边先从一个例子了解下如何使用

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
use std::{
    sync::atomic::{AtomicBool, Ordering},
    thread,
};
// 初始化原子bool类型
static FLAG: AtomicBool = AtomicBool::new(false);

fn main() {
    let a = thread::spawn(|| {
        // 原子操作修改
        FLAG.store(true, Ordering::Relaxed);
    });
    let b = thread::spawn(|| {
        // 原子操作读取
        if FLAG.load(Ordering::Relaxed) {
            println!("Relaxed: Flag is set!");
        }
    });
    a.join().unwrap();
    b.join().unwrap();
}

代码很简单,两个线程分别对 Flag 修改和读取,读取时会尝试判断是否满足打印条件。

不过运行代码,打印不一定会发生。你可能觉得多线程下,两线程执行顺序不能保证,执行顺序可能是先 load 后 store,这样的结果也很正常。

这是一种可能,然而远没有那么简单。

这里要提下指令重排和AtomicOrdering排序

指令重排

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
fn f(a: &mut i32, b: &mut i32) {
    *a += 1;
    *b += 1;
    *a += 1;
}

当你写下这段代码,交给操作系统编译执行,但很可能你得到的是这样的

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
fn f(a: &mut i32, b: &mut i32) {
    *a += 2;
    *b += 1;
}

为什么?

操作系统处理器和编译器悄悄的帮你优化了代码来让他运行更快,这里规则是:

只要不影响程序语义,指令可以重排执行以优化,即不按代码顺序执行

单线程下这样问题可能还不大,但如果多线程下,同一线程下多条原子指令,也是会有指令重排的可能,数据竞争很有可能发生,就是说加了原子操作也无法确定数据操作顺序。

如以下代码:

b 线程的 1 和 2 不互相依赖,可以指令重排成 2 和 1

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
use std::{
    sync::atomic::{AtomicI32, Ordering},
    thread,
};

static X: AtomicI32 = AtomicI32::new(0);
static Y: AtomicI32 = AtomicI32::new(0);

fn main() {
    let a = thread::spawn(|| {
        let x = X.load(Ordering::Relaxed);
        Y.store(x, Ordering::Relaxed);
    });
    let b = thread::spawn(|| {
        // 1
        let y = Y.load(Ordering::Relaxed);
        // 2
        X.store(42, Ordering::Relaxed);
    });
    a.join().unwrap();
    b.join().unwrap();
    assert_eq!(X.load(Ordering::Relaxed), 42);
    // 有可能, b线程的1和2不互相依赖,可以指令重排成2和1
    assert_eq!(Y.load(Ordering::Relaxed), 42);
}

底层的原子操作当然不能坐视不理,提出了Ordering来约束当前原子操作修改在其他多线程下的可见性,希望能约束当前线程发生原子操作如何同步到其他线程,能在并发中并发数据操作能有更好的确定性

Ordering

Rust用于的内存访问顺序(memory order)的Ordering基本和`C++ 20`的内存排序[1]的保持一致, 下边先挨个过一遍

  • Relaxed

最基础的内存排序要求,只要求当前原子操作是要么完全执行,要么还未执行,其操作结果的可见性同步在其他线程没有任何顺序的保证(如指令重排代码所示)

  • Acquire

适用于读取数据操作,要求:

当前线程不能有其他的读或写被 reorder 在 load 之前其他线程的同一数据已发生的 Release 写入操作都是对其可见的。

  • Release

适用于写数据操作,要求:

当前线程不能有其他的读或写被 reorder 在 store 之后当前写入后的结果对其他线程的同一数据 Acquire 读取操作是可见的。

也就是说,这里线程间可见性要求,acquire load总是可以同步到其他线程已发生的release store

结合代码来看就是(指令重排部分见注释):

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
use std::{
    sync::atomic::{AtomicBool, Ordering},
    thread,
};
fn main() {
    // 更严谨的测试可以用loom
    for _ in 0..100000 {
        acquire_release()
    }
}

fn acquire_release() {
    static FLAG: AtomicBool = AtomicBool::new(false);
    static mut DATA: u64 = 0;
    let a = thread::spawn(|| {
        unsafe { DATA = 42 };
        FLAG.store(true, Ordering::Release);
        // 不允许有读写重排到store之后
    });
    let b = thread::spawn(|| {
        // 不允许有读写重排到load之前
        while !FLAG.load(Ordering::Acquire) {}
        assert_eq!(unsafe { DATA }, 42);
    });

    a.join().unwrap();
    b.join().unwrap();
}
  • AcqRel

适用于同时读写操作(Read and write),读操作用 Acquire,写操作用 Release

  • SeqCst

在保证读写一定是 Acquire 和 Release 的约束外,还保证其他线程看到的原子操作顺序一致,即全局只有一种内存结果可见顺序(a single total order)

也就是说多线程下,即使执行顺序不能保证,但执行完后全局只能有一种原子操作的结果顺序,可以每次是不一样的(因为执行的先后不同),但一旦执行顺序确定后,就不可能有第二种原子操作结果的可能性存在。如同将不同线程的原子操作执行给串行化了一样。

所以内存顺序的严格程度就是从Relax->Acquire+Relase->SeqCst。越严格当然也会带来越多的性能开销。

来个代码帮助理解下用Ordering组合构建临界区:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
fn main() {
    let lock = Arc::new(AtomicBool::new(false));
    let lock_clone_read = lock.clone();
    let lock_clone_store = lock.clone();
    thread::spawn(move || {
        // 持有锁
        lock.store(true, Ordering::SeqCst);
        // 执行临界区操作
    });

    // 等待锁被获取
    while !lock_clone_read.load(Ordering::Acquire) {}

    // 进入临界区,可以放心的执行临界区操作了
    println!("Critical section!");

    // 释放锁
    lock_clone_store.store(false, Ordering::Release);
}

验证 Ordering 的可见性

原子操作结果可见性同步严格程度不同影响大么?

眼见为实,下边在通过一段Relaxed的代码先来验证下

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
use std::{
    sync::{
        atomic::{AtomicBool, AtomicU64, Ordering},
        Arc,
    },
    thread,
};

static S: AtomicU64 = AtomicU64::new(0);
fn relaxed() {
    let a = Arc::new(AtomicBool::new(false));
    let b = Arc::new(AtomicBool::new(false));
    let a_clone = a.clone();
    let b_clone = b.clone();

    let t1 = thread::spawn(move || {
        a.store(true, Ordering::Relaxed);
        if !b.load(Ordering::Relaxed) {
            S.fetch_add(1, Ordering::Relaxed);
        }
    });

    let t2 = thread::spawn(move || {
        b_clone.store(true, Ordering::Relaxed);
        if !a_clone.load(Ordering::Relaxed) {
            S.fetch_add(1, Ordering::Relaxed);
        }
    });

    t1.join().unwrap();
    t2.join().unwrap();
}

fn main() {
    let cnt = 100000;
    for _ in 0..cnt {
        relaxed();
    }
    // 结果可能大于10000
    let s = S.load(Ordering::SeqCst);
    println!("s: {}", s);
}

结果是 S 基本大于 10000,为什么?我们展开分析下

两个AtomicBool A 和 B 初始是 false,两个线程用Relaxed先修改一个为 true 和再去读取另一个的值,如果判断读取到的值还是 false 就增加结果长度一次。

两个线程在判断是否增加结果长度时,会有以下几种可能:

  1. 其他线程指令重排
  • 修改排到 load 之后还没执行修改,S+1 (Relaxed 时)
  1. 其他线程指令不重排
  • 还没开始修改,S+1
  • 其他线程修改了,但对当前 load 不可见,S+1(Relaxed 时)
  • 其他线程修改了,对当前 load 可见,S 不变

所以指令重排和读取对修改不可见都会让 S+1, Relaxed会有更多可能让 S+1

所以遍历一次,两线程并发的情况下,如果 S 增加了 2,则说明对于修改的可见性同步要求较弱,即使另一个线程修改了值,也没能及时同步到当前值的 load

对于Relaxed约束,那么执行 100000 次,S 很容易大于 100000。

如果换成SeqCst,不允许上边代码中指令重排,又全局串行化了不同的原子操作。

如果其他线程修改发生在当前线程 load 之前,其一定是对当前线程 load 可见的,不会同时都不可见的可能性。所以 S 最多只能是 100000。

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
let t1 = thread::spawn(move || {
    a.store(true, Ordering::SeqCst);
    if !b.load(Ordering::SeqCst) {
        S.fetch_add(1, Ordering::Relaxed);
    }
});

let t2 = thread::spawn(move || {
    b_clone.store(true, Ordering::SeqCst);
    if !a_clone.load(Ordering::SeqCst) {
        S.fetch_add(1, Ordering::Relaxed);
    }
});

fence

Ordering除了可以对绑定到单个原子数据类型的操作上,也可以用在fence约束多条原子操作上,防止编译器和处理器对内存操作的重排,添加内存屏障(memory barrier),这也是构建临界区的一种方式

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
a.store(1, Release);
// 可以替换为
fence(Release);
a.store(1, Relaxed);
代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
a.load(Acquire);
// 可以被替换为
a.load(Relaxed);
fence(Acquire);

这样拆分后,可以被扩展用作多个数据操作组合在线程间可见性的保证。也可以可选的选择什么时候用acquire/release/seqcst

比如下边用原子操作和 fence 模拟锁的实现

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
use std::sync::atomic::AtomicBool;
use std::sync::atomic::fence;
use std::sync::atomic::Ordering;

pub struct Mutex {
    flag: AtomicBool,
}

impl Mutex {
    pub fn new() -> Mutex {
        Mutex {
            flag: AtomicBool::new(false),
        }
    }

    pub fn lock(&self) {
        // relaxed+acquire fence 来及时同步已发生的unlock (release)
        // cas时没有直接使用严格的acquire
        while self
            .flag
            .compare_exchange_weak(false, true, Ordering::Relaxed, Ordering::Relaxed)
            .is_err()
        {}
        fence(Ordering::Acquire);
    }

    pub fn unlock(&self) {
        self.flag.store(false, Ordering::Release);
    }
}

延迟加载

Atomic也常用来做资源的延迟初始化,让多线程共享一份资源。

比如:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
use std::{
    sync::atomic::{AtomicPtr, Ordering},
    thread,
};

struct Data {}
fn generate_data() -> Data {
    Data {}
}
fn get_data() -> &'static Data {
    static PTR: AtomicPtr<Data> = AtomicPtr::new(std::ptr::null_mut());

    let mut p = PTR.load(Ordering::Acquire);

    if p.is_null() {
        p = Box::into_raw(Box::new(generate_data()));
        if let Err(e) = PTR.compare_exchange(
            std::ptr::null_mut(),
            p,
            Ordering::Release,
            Ordering::Acquire,
        ) {
            // Safety: p comes from Box::into_raw right above,
            // and wasn't shared with any other thread.
            drop(unsafe { Box::from_raw(p) });
            p = e;
        }
    }

    // Safety: p is not null and points to a properly initialized value.
    unsafe { &*p }
}
fn main() {
    let t1 = thread::spawn(|| get_data());
    let t2 = thread::spawn(|| get_data());
    let (ret1, ret2) = (t1.join().unwrap(), t2.join().unwrap());
    assert_eq!(ret1 as *const _, ret2 as *const _);
}

不过一般情况下我们都没有需要自己去实现,很多 crate 都能实现类似操作,比如OneCell[2]

综上,Atomic是更底层的原子操作,为了同步原子操作的结果在其他线程的可见性以及约束编译器和操作系统的指令重排,也支持Ordering来提供不同程度的可见性保证。

深入了解Atomic并不意味着我们一定会用他来做一些lock free的开发,毕竟轮子已经有好多了,但至少能更好理解一些并发控制代码中原子操作的实现,也不会对各种Ordering傻傻分不清了。

最后推荐两个不错的Atomic资料,非常有助于理解,感兴趣的可以去看看

  • Rust Atomics and Locks: memory ordering[3]
  • Crust of Rust: Atomics and Memory Ordering[4]

参考资料

[1]

C++ 20的内存排序: https://en.cppreference.com/w/cpp/atomic/memory_order

[2]

OneCell: https://docs.rs/once_cell/latest/once_cell/#lazy-initialized-global-data

[3]

Rust Atomics and Locks: memory ordering: https://marabos.nl/atomics/memory-ordering.html

[4]

Crust of Rust: Atomics and Memory Ordering: https://www.youtube.com/watch?v=rMGWeSjctlY


推荐阅读

如果有用,点个 在看,让更多人看到

外链不能跳转,戳 阅读原文 查看参考资料

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2023-12-18,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 菜鸟Miao 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
暂无评论
推荐阅读
编辑精选文章
换一批
透过 Rust 探索系统的本原:并发原语
几周前我写了篇关于并发的文章(透过 rust 探索系统的本原:并发篇),从使用者的角度介绍了常用的处理并发的工具:Mutex / RwLock / Channel,以及 async/await。今天我们讲讲这些并发手段背后的原语。这些原语,大家在操作系统课程时大多学过,但如果不是做一些底层的开发,估计大家都不记得了。今天,我们就来简单聊聊这些基础的并发原语,了解它们的差异,明白它们使用的场景,对撰写高性能的并发应用有很大的帮助。
tyrchen
2021/04/07
1.1K0
透过 Rust 探索系统的本原:并发原语
rust多线程
在rust中,多线程编程不算困难,但是也需要留心和别的编程语言中不同的地方。rust的标准库中提供的thread库来帮助我们进行多线程编程。在使用的时候需要使用use std::thread来引入thread库即可。
zy010101
2023/05/28
1K0
Rust入坑指南:齐头并进(下)
前文中我们聊了Rust如何管理线程以及如何利用Rust中的锁进行编程。今天我们继续学习并发编程。
Jackeyzhe
2020/03/25
8730
Rust常用并发示例代码
如果method1()被多次调用,就会创建多个线程,如果希望不管调用多少次,只能有1个线程,在不使用线程池的前提下,有1个简单的办法:
菩提树下的杨过
2022/09/28
1K0
Rust常用并发示例代码
c++11单实例(singleton)初始化的几种方法(memory fence,atomic,call_once)
单实例模式(singleton)下要求一个类只能有一个实例,如何保证只创建一个实例?类的静态成员延迟初始化要求静态成员只能被初始化一次,也有类似的问题。 在单线程环境下,这事儿很好办。
10km
2022/05/07
1.1K0
C++ 11 Atomic
SSE2 extensions introduce two new fence instructions (LFENCE and MFENCE) as companions to the SFENCE instruction introduced with SSE extensions. The LFENCE instruction establishes a memory fence for loads. It guarantees ordering between two loads and prevents speculative loads from passing the load fence (that is, no speculative loads are allowed until all loads specified before the load fence have been carried out). The MFENCE instruction establishes a memory fence for both loads and stores. The processor ensures that no load or store after MFENCE will become globally visible until all loads and stores before MFENCE are globally visible.1 Note that the sequences LFENCE;SFENCE and SFENCE;LFENCE are not equivalent to MFENCE because neither ensures that older stores are globally observed prior to younger loads.
changan
2021/03/24
1.2K1
深入理解C11/C++11内存模型
现代计算机体系结构上,CPU执行指令的速度远远大于CPU访问内存的速度,于是引入Cache机制来加速内存访问速度。除了Cache以外,分支预测和指令预取也在很大程度上提升了CPU的执行速度。随着SMP的出现,多线程编程模型被广泛应用,在多线程模型下对共享变量的访问变成了一个复杂的问题。于是我们有必要了解一下内存模型,这是多处理器架构下并发编程里必须掌握的一个基础概念。
Linux阅码场
2020/06/04
2.6K0
深入理解C11/C++11内存模型(白嫖新知识~)
现代计算机体系结构上,CPU执行指令的速度远远大于CPU访问内存的速度,于是引入Cache机制来加速内存访问速度。除了Cache以外,分支预测和指令预取也在很大程度上提升了CPU的执行速度。随着SMP的出现,多线程编程模型被广泛应用,在多线程模型下对共享变量的访问变成了一个复杂的问题。于是我们有必要了解一下内存模型,这是多处理器架构下并发编程里必须掌握的一个基础概念。
嵌入式Linux内核
2022/09/23
4100
深入理解C11/C++11内存模型(白嫖新知识~)
What is the Memory Model in C++11
C++11其实主要就四方面内容,第一个是可变参数模板,第二个是右值引用,第三个是智能指针,第四个是内存模型(Memory Model)。
C语言与CPP编程
2020/12/28
4250
What is the Memory Model in C++11
C++ 内存模型
在《C++ 并发编程》一文中,我们已经介绍了C++11到C++17在并发编程方面的新增API。
C语言与CPP编程
2021/10/09
2.4K0
【crossbeam系列】1有锁并发、无锁并发和crossbeam极简介
随着计算机硬件和软件的发展,个人计算机里动辄几千几万线程已经成为家常便饭。而在程序中大量使用并发也成为了一个主流,因为这样的程序有更小的延迟,并且对多核CPU也有更充分的利用。
MikeLoveRust
2020/07/16
1.5K0
《C++并发编程实战》读书笔记(3):内存模型和原子操作
C++标准中对象定义为某一存储范围。每个变量都是对象,每个对象都占用至少一块内存区域,若变量属于内建基本类型则仅占用一块,相邻的位域属于同一块。
C语言与CPP编程
2023/08/10
3980
《C++并发编程实战》读书笔记(3):内存模型和原子操作
原子变量——内存模型
在多线程编程中,对共享数据的并发访问需要特别注意顺序性和可见性。现代处理器和编译器为了提升性能,往往会对代码指令进行重排,这种重排可能会影响不同线程对共享数据的观察顺序,导致未定义行为。C++11引入了内存模型,其定义了一组以标准化多线程环境下的内存访问规则,控制多线程程序中数据的访问顺序和可见性,保证多线程程序中对共享数据的访问顺序与程序逻辑一致。避免数据竞争和未定义行为,进而保障数据的一致性和程序的正确性。
程序员的园
2024/11/08
1480
原子变量——内存模型
Rust 总结
所有权是用来管理堆上内存的一种方式,在编译阶段就可以追踪堆内存的分配和释放,不会对程序的运行期造成任何性能上的损失。
谛听
2022/06/04
1.8K0
Linux内核中的各种锁:信号量/互斥锁/读写锁/原子锁/自旋锁/内存屏障等
既然是锁CPU,那就都是针对多核处理器或多CPU处理器。单核的话,只有发生中断会使任务被抢占,那么可以进入临界区之前先关中断,但是对多核CPU光关中断就不够了,因为对当前CPU关了中断只能使得当前CPU不会运行其它要进入临界区的程序,但其它CPU还是可能执行进入临界区的程序。
Linux阅码场
2024/02/21
2K0
Linux内核中的各种锁:信号量/互斥锁/读写锁/原子锁/自旋锁/内存屏障等
ARMv8 内存系统学习笔记
Normal memory 可以设置为 cacheable 或 non-cacheable,可以按 inner 和 outer 分别设置。
刘盼
2023/09/11
4990
ARMv8 内存系统学习笔记
Rust入坑指南:齐头并进(上)
我们知道,如今CPU的计算能力已经非常强大,其速度比内存要高出许多个数量级。为了充分利用CPU资源,多数编程语言都提供了并发编程的能力,Rust也不例外。<!-- more -->
Jackeyzhe
2020/03/22
1.2K0
About Cache Coherence, Atomic Operation, Memory Ordering, Memory Barrier, Volatile
该文章介绍了CPU缓存以及多线程程序中CPU缓存一致性的问题,并给出了具体的例子和解决方案。文章指出,多线程程序中的CPU缓存不一致问题可能会导致性能下降,因此需要谨慎处理。通过使用原子操作、锁操作等技术,可以避免CPU缓存不一致问题,从而提高程序的性能。
s1mba
2017/12/28
1.7K0
 About Cache Coherence,  Atomic Operation,  Memory Ordering,  Memory Barrier, Volatile
内存序不再晦涩!用“锁语义”给你讲透
随着多核处理器的发展,多线程编程成为了一种常见的编程方式。但是,多线程编程必然面临数据同步问题,锁作为常见且易用的同步机制,在多线程编程中扮演着重要的角色。但是,锁的开销大,性能较差。原子变量作为一种低开销的同步机制,在多线程编程中也扮演着重要的角色。
程序员的园
2025/05/15
390
内存序不再晦涩!用“锁语义”给你讲透
nydusd 源码理解(二)
接上文 nydusd 源码理解(一),回到process_fs_service函数,创建daemon实例完成后,替换DAEMON_CONTROLLER中daemon的值为新的daemon。
abin
2023/03/21
1.1K0
nydusd 源码理解(二)
相关推荐
透过 Rust 探索系统的本原:并发原语
更多 >
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档
本文部分代码块支持一键运行,欢迎体验
本文部分代码块支持一键运行,欢迎体验