原子类型和原子操作

原子(atom)指的是一系列不可被 CPU 上下文交流的机器指令,这些指令组合在一起就形成了原子操作。在多核 CPU 下,当某个 CPU 中心开始运转原子操作时,会先暂停其它 CPU 内核对内存的操作,以确保原子操作不会被其它 CPU 内核所搅扰。

原子操作( atomic operation )是指不可分割且不可中止的一个或一系列操作,在并发编程中需求由 CPU 层面做出一些确保,让一系列操作成为原子操作。 一个原子操作从开始到结束能够是一个操作步骤,也能够包括多个操作步骤,这些步骤的顺序不能够被打乱,履行进程也不会被其他机制打断。

注:由于原子操作是经过指令供给的支撑,因而它的功用比较消息传递会好许多。比较较于锁而言,原子类型不需求开发者处理加锁开释锁等问题,一起支撑修正读取等操作,并具有较高的并发功用,简直一切的言语都支撑原子类型。

原子类型是用来协助开发者更轻松的完成原子操作的数据类型,原子类型是无锁类型,可是无锁不代表无需等候,由于原子类型内部运用了CAS循环,当大量的冲突发生时,该等候仍是得等候!可是总之比锁要好。

注:CAS 全称是 Compare and swap, 它经过一条指令读取指定的内存地址,然后判别其中的值是否等于给定的前置值,假如持平,则将其修正为新的值

Atomic 原子操作作为一个并发原语,是完成一切并发原语的柱石,简直一切的言语都支撑原子类型和原子操作,比方 Java 的java.util.concurrent.atomic供给了许多原子类型,Go 言语的sync/atomic包供给了对原子操作的支撑,Rust 也不破例。

注:原子操作是 CPU 的概念,而编程言语中也有相似的概念,叫做并发原语。并发原语是内核供给给外核调用的函数,这种函数在履行进程中不允许中止。

Rust 中的 Atomic 并发原语

Rust 中的原子类型位于std::sync::atomic module中。

这个 module 的文档中对原子类型有如下描绘: Rust 中的原子类型在线程之间供给原始的同享内存通讯,而且是其他并发类型的构建基础。

std::sync::atomic module 现在共供给了以下12种原子类型:

AtomicBool
AtomicI8
AtomicI16
AtomicI32
AtomicI64
AtomicIsize
AtomicPtr
AtomicU8
AtomicU16
AtomicU32
AtomicU64
AtomicUsize

原子类型与一般的类型基本上没有太多的区别,例如AtomicBoolbool,仅仅一个能够在多线程中运用,另一个则更适用于单线程下运用。

AtomicI32为例,它的定义是一个结构体,有以下原子操作相关的办法:

pub fn fetch_add(&self, val: i32, order: Ordering) -> i32 - 对原子类型进行加(或减)运算
pub fn compare_and_swap(&self, current: i32, new: i32, order: Ordering) -> i32 - CAS(rust 1.50废弃, 由compare_exchange代替)
pub fn compare_exchange(&self, current: i32, new: i32, success: Ordering, failure: Ordering) -> Result<i32, i32> - CAS
pub fn load(&self, order: Ordering) -> i32 - 从原子类型内部读取值
pub fn store(&self, val: i32, order: Ordering) - 向原子类型内部写入值
pub fn swap(&self, val: i32, order: Ordering) -> i32 - 交流

能够看到每个办法都有一个 Ordering 类型的参数,Ordering 是一个枚举,表明该操作的内存屏障的强度,用于控制原子操作运用的内存顺序

注:内存顺序是指 CPU 在拜访内存时的顺序,该顺序或许受以下因素的影响:

  • 代码中的先后顺序
  • 编译器优化导致在编译阶段发生改动(内存重排序 reordering)
  • 运转阶段因 CPU 的缓存机制导致顺序被打乱
pub enum Ordering {
    Relaxed,
    Release,
    Acquire,
    AcqRel,
    SeqCst,
}

Rust 中 Ordering 这个枚举的枚举值别离代表什么:

  • Relaxed, 这是最宽松的规则,它对编译器和 CPU 不做任何限制,能够乱序
  • Release 开释,设定内存屏障(Memory barrier),确保它之前的操作永远在它之前,可是它后面的操作或许被重排到它前面(用于写入)
  • Acquire 获取,设定内存屏障,确保在它之后的拜访永远在它之后,可是它之前的操作却有或许被重排到它后面,往往和Release在不同线程中联合运用(用于读取)
  • AcqRel, 是AcquireRelease的结合,一起拥有它们俩供给的确保。关于load,它运用的是 Acquire 指令,关于store,它运用的是 Release 指令,期望该操作之前和之后的读取或写入操作不会被从头排序。AcqRel一般用在fetch_add
  • SeqCst 顺序一致性SeqCst就像是AcqRel的加强版,它不论原子操作是属于读取仍是写入的操作,只需某个线程有用到SeqCst的原子操作,线程中该SeqCst操作前的数据操作绝对不会被从头排在该SeqCst操作之后,且该SeqCst操作后的数据操作也绝对不会被从头排在SeqCst操作前;它还确保一切线程看到的一切的SeqCst操作的顺序是一致的(尽管功用低,可是最保险)

经过这个Ordering枚举类型的参数,开发者能够自己定制底层的 Memory Ordering。

注:什么是 Memory Ordering, 摘录维基百科中的定义:

Memory Ordering (内存排序) 是指 CPU 拜访主存时的顺序。能够是编译器在编译时发生,也能够是 CPU 在运转时发生。反映了内存操作重排序,乱序履行,然后充分利用不同内存的总线带宽。现代处理器大都是乱序履行。因而需求内存屏障以确保多线程的同步。

关于对Memory Ordering 的了解,有两个线程都要操作 AtomicI32 类型,假定 AtomicI32 类型数据初始值是0,一个线程履行读操作,另一个线程履行写操作要将数据写为10。假定写操作履行完成后,读线程再履行读操作就一定能读到数据10吗? 答案是不确定的,由于不同编译器的完成和CPU的优化策略,或许会出现尽管写线程履行完写操作了,但最新的数据还存在CPU的寄存器中,还没有同步到内存中。为了确保寄存器到内存中的数据同步,就需求Memory Ordering了。 Release 能够了解为将寄存器的值同步到内存,Acquire 是疏忽当前寄存器中存的值,而直接去内存中读取最新的值。 例如当咱们调用原子类型的 store 办法时供给的 Ordering 是 release,在调用原子类型的load 办法时供给的 Ordering 是 Acquire 就能够确保履行读操作的线程一定会读到寄存器里最新的值。

多线程中运用 Atomic

由于原子类型都完成了Sync trait,所以原子类型的变量在线程之间同享是安全的,但由于它们自身没有供给同享机制,因而比较常见的用法是将其放在原子引证计数智能指针Arc。 下面是官方文档中一个简略的自旋锁的例子:

use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::thread;
fn main() {
    // 运用原子类型创建一个锁,经过引证计数取得同享一切权
    let spinlock = Arc::new(AtomicUsize::new(1));
    // 引证计数 +1
    let spinlock_clone = spinlock.clone();
    let thread = thread::spawn(move || {
        // SeqCst排序:写操作(存储)运用release 语义:写屏障之前的读写操作不能重排在写屏障之后
        spinlock_clone.store(0, Ordering::SeqCst);
    });
    // 运用 while循环,来等候某个临界区可用的一种锁
    // SeqCst排序:读操作(读取)运用 acquire 语义 读屏障之后的读写操作不能重排到读写屏障之前
    // 上面的线程中的写(存储)指令,下面的指令要求之后的读写操作不能在此之前
    while spinlock.load(Ordering::SeqCst) != 0 {}
    if let Err(panic) = thread.join() {
        println!("Thread had an error: {:?}", panic);
    }
}

注:自旋锁是指当一个线程测验去获取某一把锁的时候,假如这个锁此刻现已被其他线程获取,那么此线程就无法获取到这把锁,该线程将会等候,间隔一段时刻后会再次测验获取。 自旋锁实际上是经过 CPU 空转 (spin) 忙等候 (budy wait),例如上面代码中的 while 循环,来等候某个临界区可用的一种锁。

运用自旋锁能够的削减线程的阻塞,适用于对锁的竞赛不剧烈,且占用锁时刻十分短的场景。 可是假如锁的竞赛剧烈,或许持有锁的线程需求长时刻占用锁,受维护的临界区过大,线程自旋的就耗费大于线程阻塞挂起操作的耗费,自旋操作会一向占用CPU做无用功,就会形成CPU浪费,其他需求CPU的线程反而不能取得CPU,体系功用会急剧下降。

上面例子是自旋锁功用的完成,而且运用的内存排序是Ordering::SeqCst,下面咱们测验完成一个自选锁:

use std::sync::{
    atomic::{AtomicBool, Ordering},
    Arc,
};
use std::thread;
use std::time::Duration;
struct SpinLock {
    lock: AtomicBool,
}
impl SpinLock {
    pub fn new() -> Self {
        Self {
            lock: AtomicBool::new(false),
        }
    }
    pub fn lock(&self) {
        while self
            .lock
            .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
            .is_err()
        // 测验加锁, 假如加锁失利则一向自旋
        {
            // CAS的耗费比较大, 当加锁失利时, 经过简略load读取锁的状况, 只需读取到锁被开释时才会再去测验CAS加锁
            while self.lock.load(Ordering::Relaxed) {}
        }
    }
    pub fn unlock(&self) {
        // 解锁
        self.lock.store(false, Ordering::Release);
    }
}
fn main() {
    let spinlock = Arc::new(SpinLock::new());
    let spinlock1 = spinlock.clone();
    let thread = thread::spawn(move || {
        // 子线程加锁1,内部调用了compare_exchange 办法,修正状况
        spinlock1.lock();
        thread::sleep(Duration::from_millis(100));
        println!("do something1!");
        // 子线程解锁1
        spinlock1.unlock();
    });
    thread.join().unwrap();
    // 主线程加锁
    spinlock.lock();
    println!("do something2!");
    // 主线程解锁
    spinlock.unlock();
}

上面咱们完成的自旋锁,本质便是一个原子类型AtomicBool,它的初始值为false

当履行lock办法进行加锁操作时,利用了原子操作CAS的特性,假如compare_exchange失利,则测验加锁的线程会卡在这个while循环中自旋。 这里有一个功用上的小优化,由于履行CAS耗费代价比较大,所以在CAS失利时,再不断经过简略load读取锁的状况, 只有读取到锁被开释时才会再去测验CAS加锁。这样效率更好一些。

当履行unlock办法时,直接将AtomicBool设置storefalse,选用的 Memory Ordering 是Release,会将寄存器中的值与内存中的值同步,内存中就为false。此刻,假如有线程卡在lock办法while循环处自旋,CAS操作compare_exchange选用的 Memory Ordering 是Acquire,将会疏忽其自己当前寄存器中的值,从内存中读取到新的值为falseCAS将履行成功,也便是加锁成功。

Atomic 能代替锁吗

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

  • 关于复杂的场景下,锁的运用简略粗暴,不容易有坑;
  • std::sync::atomic包中仅供给了数值类型的原子操作:AtomicBool,AtomicIsize,AtomicUsize等,而锁能够使用于各种类型;
  • 在有些情况下,必须运用锁来合作,比方MutexRwLockCondvar等;

Atomic 的使用场景

事实上,Atomic尽管关于用户不太常用,可是关于高功用库的开发者、标准库开发者都十分常用,它是并发原语的柱石,除此之外,还有一些场景适用:

  • 无锁(lock free)数据结构
  • 全局变量,例如全局自增 ID, 在后续章节会介绍
  • 跨线程计数器,例如能够用于统计指标

以上列出的仅仅Atomic适用的部分场景,具体场景需求我们未来根据自己的需求进行权衡选择。

总结

原子(atom)便是类比生物学中不可再分的原子,原子操作(atomic operation)便是“不可再被中止的一个或一系列操作”。原子类型是用来协助开发者更轻松的完成原子操作的数据类型。并发原语是内核供给给外核调用的函数,这种函数在履行进程中不允许中止。

Atomic原子类型是无锁类型,内部运用了CAS循环,不需求开发者处理加锁开释锁的问题,一起支撑修正读取等原子操作,这些操作是经过指令供给的支撑,因而它的功用比较消息传递会好许多。原子操作需求合作运用Ordering内存排序,经过这个Ordering枚举类型的参数,开发者能够自己定制底层的 Memory Ordering。由于Atomic原子类型功用比锁高不少,所以在 Rust 中有广泛的运用场景,比方作为作为全局变量,作为跨线程变量等,可是无法完全替代锁,由于锁足够简略。

原子操作能够概括为以下5类操作:

  • fetch_add – 对原子类型进行加(或减)运算
  • compare_and_swapcompare_exchange – 比较,假如持平则进行交流
  • load – 从原子类型内部读取值
  • store – 向原子类型内部写入值
  • swap – 交流

参考

  • course.rs/advance/con…
  • blog.frognew.com/2020/07/rus…
  • rustmagazine.github.io/rust_magazi…
  • rustcc.cn/article?id=…
  • doc.rust-lang.org/std/sync/at…
  • doc.rust-lang.org/nomicon/ato…
  • zhuanlan.zhihu.com/p/365905573