【问题标题】:Why does a boolean need to be atomic?为什么布尔值需要是原子的?
【发布时间】:2021-05-21 13:43:52
【问题描述】:

在 rust 中,有 AtomicBool 这样的东西。定义为:

可以在线程之间安全共享的布尔类型。

我了解,如果您使用布尔值来实现线程锁,以便从多个线程中使用来控制对资源的访问,请执行以下操作:

// Acquire the lock
if thread_lock == false:
    thread_lock = true

...

// Release the lock
thread_lock = false

绝对不是线程安全的。两个线程可以同时读取thread_lock变量,看到它是解锁的(false),设置为true,都认为自己拥有线程的独占访问权限。

使用适当的线程锁,您需要一个布尔值,当您尝试设置它时,会发生以下两种情况之一:

  • 如果另一个线程已经拥有锁,则尝试获取锁可能会失败
  • 尝试获取锁将阻塞,直到没有其他线程拥有锁

我不知道 Rust 是否有这样的概念,但我知道 Python 的 threading.Lock 正是这样做的。

据我所知,这不是AtomicBool 所针对的场景。 AtomicBool 具有 load() 方法和 store() 方法。既不返回 Result<bool> 类型(暗示操作不会失败),据我所知,也没有任何类型的阻塞。

AtomicBool 究竟能保护我们免受什么影响?为什么我们不能使用来自不同线程的常规布尔值(除了编译器不允许我们这样做)?

我唯一能想到的是,当一个线程将位写入内存时,另一个线程可能会尝试同时读取这些位。布尔值是 8 位。如果在另一个线程尝试读取数据时写入了 8 位中的 4 位,则读取的数据将是旧值的 4 位和新值的 4 位。这是正在解决的问题吗?这会发生吗?即使在这种情况下,bool 似乎也不需要是原子的,因为在 8 位中,只有一位很重要,即 0 或 1。

【问题讨论】:

  • 线程锁有MutexRwLock
  • 您也可以使用atomic_bool.compare_and_swap(false, true, SeqCst),它将布尔值设置为true,并且只有在原始值为false时才返回false。这可以用来实现锁,但是当你可以只使用Mutex<()>时就没有必要了。
  • 这不是关于访问布尔位的竞赛,而是关于其他内存访问。内存并发比较复杂,不过可以看cfsamsonbooks.gitbook.io/explaining-atomics-in-rustWTF?/AHA! < 1的介绍。
  • 你是否能想到一些可能出错的方法是无关紧要的。适用的规则要么保证它是安全的,要么不保证它是安全的。事情可能会以没人能想到的方式失败,并且存在规则和标准,因此我们不必尝试考虑可能失败的所有可能方式。
  • AtomicBool 究竟能保护我们免受什么影响? - 简而言之,防止编译器和 CPU 对优化单线程程序但破坏多线程程序的指令重新排序。有关编译器重新排序的示例,请参见 this article,对于基于 CPU 的示例,请参见 thisthis。 (由于外部链接太多,未发布作为答案。)

标签: multithreading rust


【解决方案1】:

Rust 中的线程锁是Mutex。它通常用于提供对值的多线程可变访问(这通常是您想要在线程之间锁定的原因),但您也可以使用它来锁定空元组 Mutex<()> 以锁定任何内容。不过,我想不出你需要锁定线程而不需要锁定特定值的充分理由;例如,如果您想从多个线程写入日志文件,您可能希望有一个像这样的Mutex<fs::File>

let file = Arc::new(Mutex::new(fs::File::create("write.log")?));
for _ in 0..10 {
    let file = Arc::clone(&file);
    thread::spawn(move |file| {
        // do other stuff
        let mut guard = file.lock();
        guard.write_all(b"stuff").unwrap();
        drop(guard);
        // do other stuff
        Ok(())
    })
}

对于原子值,通常最重要的原语不是loadstore,而是compare_and_exchange 等。原子可以被认为是只包含原语数据的“轻量级”互斥锁,但您可以执行所有您想要的操作在一次调用中,而不是在两个单独的操作中获取和释放它。此外,如果操作系统不支持,实际上可以基于AtomicBool 实现互斥锁,如以下代码:

struct MyMutex(AtomicBool);
impl MyMutex {
    fn try_lock(&self) -> Result<(), ()> {
        let result = self.0.compare_exchange(false, true, Ordering::SeqCst);
        if result {
            Ok(()) // we have acquired the lock
        } else {
            Err(()) // someone else is holding the lock
        }
    }

    fn release(&self) {
        self.0.store(false, Ordering::Release);
    }
}

您可以从多个线程共享 anySync,前提是您可以正确处理生命周期。例如,下面的编译没有任何不安全的代码:

fn process(b: &'static bool) {
    if b { do_something () }
    else { do_something_else() }
}

fn main() {
    let boxed = Box::new(true);
    let refed: &'static bool = my_bool.leak();
    for _ in 0..10 {
        thread::spawn(move || process(refed));
    }
}

您也可以使用足够的工具对非'static 引用执行此操作,例如将它们包装在Arcs 中等。

布尔值是 8 位。如果在其他线程尝试读取数据时写入了 8 位中的 4 位,则读取的数据将是旧值的 4 位,新值的 4 位。

这在 Rust 中不会发生。 Rust 非常严格地执行所有权和借用。你甚至不能在同一个线程上有两个对同一个值的可变引用,更不用说在不同的线程上。

对同一值的多个可变引用在 Rust 中始终是未定义行为;这条严格的规则没有例外。通过声明一个引用是可变的,编译器可以对你的代码进行各种优化,假设我们是可以读/写值的唯一地方;不是其他线程,不是其他函数,甚至不是其他变量(如果a: &amp;mut boollet b = &amp;mut *a,则在删除b 之前不能使用a)。如果你有多个可变指针,你会遇到比同时写入不同位更糟糕的问题。

(顺便说一句,将位“写入”到相同的值并不是正确的思考方式;即使没有 Rust 的借位检查规则,它也比现代 CPU 中的“写入位”复杂得多)

TL;DR:如果您的代码中无论如何都没有 unsafe 关键字,则无需担心竞争条件。 Rust 是一种非常内存安全的语言,其中内存错误大多在编译时进行检查。

【讨论】:

  • 我不是在谈论 Rust。我说的是概念。它们与语言无关。编译器做什么或不允许你做什么并不重要。我想知道为什么它甚至有一个AtomicBool,而不是让我们在多个线程中使用布尔值。通过您的示例,我现在了解到 AtomicBool 确实有一些有用的方法,例如 compare_exchange,使用来自多个线程的常规 bool 无法安全地完成这些方法。另一方面,仅使用 loadstore 听起来并不比使用简单的 bool 更安全。
  • 请参阅@DavidSchwartz 对此的回答。通常,对同一内存的并发写入是未定义的行为,无论 似乎 有多少位有效。实际上,在 OS/arch 级别没有AtomicBool;只有原子字节,严格来说,并发写入同一内​​存,即使具有相同的值,在法律上也可能导致任何意外行为。
  • @SOFe 说“并发写入同一内​​存是未定义的行为”并没有帮助,因为它没有回答 为什么 这是未定义的行为。不是很明显,它必须是——例如,在 JVM 内存模型中it isn't
  • 而 Rust 没有defined memory model
【解决方案2】:

AtomicBool 究竟能保护我们免受什么影响?为什么我们不能使用来自不同线程的常规布尔值(除了编译器不允许我们这样做)?

任何可能出错的事情,不管你能不能想到。我讨厌用我能想到的东西来跟进这件事,因为这无关紧要。规则说它不能保证有效,应该结束它。认为你必须想出一种可能失败或不能失败的方法是错误的。

但这里有一种方法:

 // Release the lock
 thread_lock = false

假设这个特定的 CPU 没有特别好的方法可以在不使用寄存器的情况下将布尔值设置为 false,但确实有一个很好的单一操作,可以在不使用寄存器的情况下否定布尔值并测试它是否为零。在这个 CPU 上,在寄存器压力的情况下,这可能会被优化为:

  1. 取反 thread_lock 并测试它是否为零。
  2. 如果 thread_lock 的副本为 false,则再次否定 thread_lock。

如果在第 1 步和第 2 步之间,另一个线程观察到 thread_locktrue,即使它是 false 进入此操作并且完成后将是 false,会发生什么?

【讨论】:

  • 起初,当您说The rules say it's not guaranteed to work 时,不清楚 WHAT 不能保证有效。这就是我想知道的。但是,您的示例表明,在使用 bool 时,在单线程应用程序中可以正常读写。你可以确定你写的就是你会读回来的。另一方面,在多线程应用程序中,除非您使用原子变量,例如 AtomicBool,否则您不能始终确定您所写的内容就是您将读回的内容。谢谢你的好例子。
  • @John 可能比这更糟。内存可以暂时用于其他目的,您可能会破坏任意其他数据位。规则根本没有说会发生什么,所以你不知道会发生什么。
  • 所以使用来自多个线程的非原子变量本质上是不安全的。即使是简单的读/写操作也是不安全的,并且会产生意想不到的结果。它们只能从单个线程安全地使用。这与您的代码或您如何使用它们无关,更多的是与内核决定与内存交互的方式有关。底线是,切勿以任何可以让您直接访问它们的语言使用来自多个线程的常规变量。高级语言可以通过将它们透明地包装在某种互斥体中来使这些操作安全。听起来对吗?
猜你喜欢
  • 2014-03-16
  • 2011-01-29
  • 1970-01-01
  • 2022-11-08
  • 1970-01-01
  • 2015-03-06
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多