Skip to content

29.4 Atomic

Rust 标准库还为我们提供了一系列的“原子操作”数据类型,它们在std::sync::atomic模块里面。它们都是符合 Sync 的,可以在多线程之间共享。比如,我们有 AtomicIsize 类型,顾名思义,它对应的是 isize 类型的“线程安全”版本。我们知道,普通的整数读取再写入,这种操作是非原子的。而原子整数的特点是,可以把“读取”“计算”“再写入”这样的操作编译为特殊的 CPU 指令,保证这个过程是原子操作。

我们来看一个示例:


rust
use std::sync::Arc;
use std::sync::atomic::{AtomicIsize, Ordering};
use std::thread;

const COUNT: u32 = 1000000;
fn main() {
    // Atomic 系列类型同样提供了线程安全版本的内部可变性
    let global = Arc::new(AtomicIsize::new(0));

    let clone1 = global.clone();
    let thread1 = thread::spawn(move|| {
        for _ in 0..COUNT {
            clone1.fetch_add(1, Ordering::SeqCst);
        }
    });

    let clone2 = global.clone();
    let thread2 = thread::spawn(move|| {
        for _ in 0..COUNT {
            clone2.fetch_sub(1, Ordering::SeqCst);
        }
    });

    thread1.join().ok();
    thread2.join().ok();
    println!("final value: {:?}", global);
}

这个示例我们很熟悉:两个线程修改同一个整数,一个线程对它进行多次加 1,另外一个线程对它多次减 1。这次我们发现,使用了 Atomic 类型后,我们可以保证最后的执行结果一定会回到 0。

我们还可以把这段代码改动一下:


rust
use std::sync::Arc;
use std::sync::atomic::{AtomicIsize, Ordering};
use std::thread;

const COUNT: u32 = 1000000;

fn main() {
    let global = Arc::new(AtomicIsize::new(0));

    let clone1 = global.clone();
    let thread1 = thread::spawn(move|| {
        for _ in 0..COUNT {
            let mut value = clone1.load(Ordering::SeqCst);
            value += 1;
            clone1.store(value, Ordering::SeqCst);
        }
    });


    let clone2 = global.clone();
    let thread2 = thread::spawn(move|| {
        for _ in 0..COUNT {
            let mut value = clone2.load(Ordering::SeqCst);
            value -= 1;
            clone2.store(value, Ordering::SeqCst);
        }
    });

    thread1.join().ok();
    thread2.join().ok();
    println!("final value: {:?}", global);
}

与上一个版本相比,这段代码的区别在于:我们没有使用原子类型自己提供的fetch_add fetch_sub方法,而是使用了 load 把里面的值读取出来,然后执行加/减,操作完成后,再用 store 存储回去。编译程序我们看到,是可以编译通过的。再执行,出现了问题:这次的执行结果就不是保证为0了。

大家应该很容易看明白问题在哪里。原来的那种写法,“读取/计算/写入”是一个完整的“原子操作”,中间不可被打断,它是一个“事务”(transaction)。而后面的写法把“读取”作为了一个“原子操作”,“写入”又作为了一个“原子操作”,把一个 transaction 分成了两段来执行。上面的那个程序,其逻辑类似于“lock=>读数据=>加/减运算=>写数据=>unlock”。下面的程序,其逻辑类似于“lock=>读数据=>unlock=>加/减运算=>lock=>写数据=>unlock”。虽然每次读写共享变量都保证了唯一性,但逻辑还是错的。

所以,编译器只能防止基本的数据竞争问题。如果程序里面有逻辑错误,工具是没有办法帮我们发现的。上面这个示例中并不存在数据竞争问题,是完全的“业务逻辑 bug”。

Released under the MIT License