rust使用Atomic创建全局变量和使用

Mutex用起来简单,但是无法并发读,RwLock可以并发读,但是使用场景较为受限且性能不够,那么有没有一种全能性选手呢? 欢迎我们的Atomic闪亮登场。

从 Rust1.34 版本后,就正式支持原子类型。原子指的是一系列不可被 CPU 上下文交换的机器指令,这些指令组合在一起就形成了原子操作。在多核 CPU 下,当某个 CPU 核心开始运行原子操作时,会先暂停其它 CPU 内核对内存的操作,以保证原子操作不会被其它 CPU 内核所干扰。

由于原子操作是通过指令提供的支持,因此它的性能相比锁和消息传递会好很多。相比较于锁而言,原子类型不需要开发者处理加锁和释放锁的问题,同时支持修改,读取等操作,还具备较高的并发性能,几乎所有的语言都支持原子类型。

可以看出原子类型是无锁类型,但是无锁不代表无需等待,因为原子类型内部使用了CAS循环,当大量的冲突发生时,该等待还是得等待!但是总归比锁要好。

CAS 全称是 Compare and swap, 它通过一条指令读取指定的内存地址,然后判断其中的值是否等于给定的前置值,如果相等,则将其修改为新的值

原子类型的一个常用场景,就是作为全局变量来使用:

rust 复制代码
use std::sync::atomic::{AtomicI32, Ordering};
use std::thread::{self, JoinHandle};


static R: AtomicI32 = AtomicI32::new(0);


fn thread_add() {
    // 多个线程修改全局变量
    for i in 0..1000 {
        R.fetch_add(1, Ordering::Relaxed);
    }
}


fn main() {
    // This will POST a body of `foo=bar&baz=quux`
    let mut init_data = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
    let mut hand_list = Vec::with_capacity(init_data.len());
    for i in init_data {
        hand_list.push(thread::spawn(thread_add));
    }
    for h in hand_list {
        h.join().unwrap();
    }
    let r_value = R.load(Ordering::Relaxed);
    println!("全局变量最后的值是: {r_value:?}");
}

并且能够保证数据读写不出错:

以上代码启动了数个线程,每个线程都在疯狂对全局变量进行加 1 操作, 最后将它与线程数 * 加1次数进行比较,如果发生了因为多个线程同时修改导致了脏数据,那么这两个必将不相等。好在,它没有让我们失望,不仅快速的完成了任务,而且保证了 100%的并发安全性。

当然以上代码的功能其实也可以通过Mutex来实现,但是后者的强大功能是建立在额外的性能损耗基础上的,因此性能会逊色不少:

Atomic实现:673ms Mutex实现: 1136ms

可以看到Atomic实现会比Mutex41%,实际上在复杂场景下还能更快(甚至达到 4 倍的性能差距)!

还有一点值得注意: Mutex一样,Atomic的值具有内部可变性 ,你无需将其声明为mut

rust 复制代码
use std::sync::Mutex;
use std::sync::atomic::{Ordering, AtomicU64};

struct Counter {
    count: u64
}

fn main() {
    let n = Mutex::new(Counter {
        count: 0
    });

    n.lock().unwrap().count += 1;

    let n = AtomicU64::new(0);

    n.fetch_add(0, Ordering::Relaxed);
}

这里有一个奇怪的枚举成员Ordering::Relaxed, 看上去很像是排序作用,但是我们并没有做排序操作啊?实际上它用于控制原子操作使用的内存顺序

内存顺序

内存顺序是指 CPU 在访问内存时的顺序,该顺序可能受以下因素的影响:

代码中的先后顺序

编译器优化导致在编译阶段发生改变(内存重排序 reordering)

运行阶段因 CPU 的缓存机制导致顺序被打乱

限定内存顺序的 5 个规则:

在理解了内存顺序可能存在的改变后,你就可以明白为什么 Rust 提供了Ordering::Relaxed用于限定内存顺序了,事实上,该枚举有 5 个成员:

Relaxed, 这是最宽松的规则,它对编译器和 CPU 不做任何限制,可以乱序。

Release 释放,设定内存屏障(Memory barrier),保证它之前的操作永远在它之前,但是它后面的操作可能被重排到它前面。

Acquire 获取, 设定内存屏障,保证在它之后的访问永远在它之后,但是它之前的操作却有可能被重排到它后面,往往和Release在不同线程中联合使用。

AcqRel, 是 Acquire 和 Release 的结合,同时拥有它们俩提供的保证。比如你要对一个 atomic 自增 1,同时希望该操作之前和之后的读取或写入操作不会被重新排序。

SeqCst 顺序一致性, SeqCst就像是AcqRel的加强版,它不管原子操作是属于读取还是写入的操作,只要某个线程有用到SeqCst的原子操作,线程中该SeqCst操作前的数据操作绝对不会被重新排在该SeqCst操作之后,且该SeqCst操作后的数据操作也绝对不会被重新排在SeqCst操作前。

这些规则由于是系统提供的,因此其它语言提供的相应规则也大同小异,大家如果不明白可以看看其它语言的相关解释。

Atomic 能替代锁吗

那么原子类型既然这么全能,它可以替代锁吗?答案是不行:

对于复杂的场景下,锁的使用简单粗暴,不容易有坑

std::sync::atomic包中仅提供了数值类型的原子操作:AtomicBool, AtomicIsize, AtomicUsize, AtomicI8, AtomicU16等,而锁可以应用于各种类型

在有些情况下,必须使用锁来配合,例如上一章节中使用Mutex配合Condvar

Atomic 的应用场景

事实上,Atomic虽然对于用户不太常用,但是对于高性能库的开发者、标准库开发者都非常常用,它是并发原语的基石,除此之外,还有一些场景适用:

无锁(lock free)数据结构

全局变量,例如全局自增 ID, 在后续章节会介绍

跨线程计数器,例如可以用于统计指标

以上列出的只是Atomic适用的部分场景,具体场景需要大家未来根据自己的需求进行权衡选择。

相关推荐
阿里小阿希23 分钟前
CentOS7 PostgreSQL 9.2 升级到 15 完整教程
数据库·postgresql
荒川之神28 分钟前
Oracle 数据仓库雪花模型设计(完整实战方案)
数据库·数据仓库·oracle
做个文艺程序员38 分钟前
MySQL安全加固十大硬核操作
数据库·mysql·安全
不吃香菜学java1 小时前
Redis简单应用
数据库·spring boot·tomcat·maven
一个天蝎座 白勺 程序猿1 小时前
Apache IoTDB(15):IoTDB查询写回(INTO子句)深度解析——从语法到实战的ETL全链路指南
数据库·apache·etl·iotdb
不知名的老吴1 小时前
Redis的延迟瓶颈:TCP栈开销无法避免
数据库·redis·缓存
YOU OU1 小时前
三大范式和E-R图
数据库
一江寒逸1 小时前
零基础从入门到精通MySQL(上篇):筑基篇——吃透核心概念与基础操作,打通SQL入门第一关
数据库·sql·mysql
@土豆1 小时前
Ubuntu 22.04 运行 Filebeat 7.11.2 崩溃问题分析及解决文档
linux·数据库·ubuntu
专注API从业者1 小时前
淘宝商品详情 API 与爬虫技术的边界:合法接入与反爬策略的技术博弈
大数据·数据结构·数据库·爬虫