原子变量和内存顺序

翻译来源

https://vorner.github.io/2018/03/25/Atomics.html

内容

使用多线程很困难,不仅是因为很多事情同时发生,还因为你代码中所写的并不一定是CPU中所发生的。为了获得更好的性能表现,编译器在认为没有监视限制的情况下就会cheat(优化代码),他会对指令重新排序,或者忽略一些它认为无用的指令。硬件方面同样会这样做,同一时刻同一内存地址能够存在于不同的缓存中,但某一缓存的修改并不会被立刻更新到其他缓存中。如果不小心处理,会导致未定义行为。除了传统的同步原语(mutexes, barriers, …),一些语言提供了原子类型(C++Rust),这很酷,它允许实现传统的原语和一些快速的多线程数据结构(例如crossbeam)。不幸的是,他们比起在mutex后面放一些代码要难用,部分是因为他们神奇的内存ordering parameter,直接影响了他们的内存同步。

大多数的文档从保证性、因果性和预见性进行说明,它们擅长形式说明,但使用中项像教一个会计学集合论,并不实用。我能找到的最接近实用的文档是在nomicon,但是我决定自己重新陈述一下,希望没有错误。

原子变量究竟是什么?

正如其他类型一样,原子类型一样它存在普通RAM中,它并不比其他非原子变量多使用内存,他们在二进制形式上是相同的,只是编译器生成不同的指令来处理它(避免优化)。理论上一个变量有时可以当作是原子变量使用,有时可以不当作原子变量,但实际中意义不大。
然而,编译器和处理器都希望cheat(你也希望他们cheat,加速执行速度)。 ordering parameter则是说明了什么样的cheat是被允许的。这也是原子变量比mutex要快的原因–它从更细的层面上控制了cheat(另一个原因是mutex 进入了内核态挂起其他线程,直到解锁,这很耗时)。

一些手册推荐当你不确定时,使用顺序一致(Sequentially Consistent)的顺序,但我人我这不是好建议——并不仅仅是从性能方面。如果你不知道你需要的内存顺序,你就无法确定该使用原子变量还是mutex ,还是其他的更耗时的东西。原子变量学习的重点并不是原子变量的工作原理,而是在于他的平行研究,他会影响其他内存位置的同步,其次才是速度。

Relaxed order ‒ sanity just for me

只有当从不同线程中同时访问变量而不引起未定义操作,此时原子变量才称得上稳健如果你从0开始,每次增1,执行10次,你会获得从0到10 的数字,最终也会得到10.线程会将对于一个原子变量的操作看成是顺序操作(原子变量达到10 ,我的线程会看见它,除非有减操作否则不会回到5)。

let mut threads = Vec::new();
for _ in 0..5 {
    threads.push(thread::spawn(|| {
        let first = a.fetch_add(1, Ordering::Relaxed);
        let second = a.fetch_add(1, Ordering::Relaxed);
        assert!(first < second);
    }));
}
for t in threads {
    t.join().unwrap();
}
assert_eq!(10, a.load(Ordering::Relaxed));

除此之外, relaxed order 不保证其他任何东西。无法保证原子变量之间的顺序(当递增原子变量A和B,某一个线程看到的是先递增A后递增B,另一个线程可能看到的是相反的顺序),是的不同的线程看到的是不同的顺序(不要过分在意,感觉有点精神分裂)。如果有多个变量会产生相当怪异的结果。
适用情况:

  • 生成唯一ID
  • 控制其他线程(停止其他线程)
  • 统计或在程序末尾获得某个数
  • 出于性能考虑,

并不是所有的架构都有这个顺序,没有的话就会采用限制更强的东西。

Release & Acquire ‒ sending data from one thread to another

除了relaxed order 所提供的,它提供了两个线程间的契约,若一个线程release 某个原子变量,另一个线程同时acquire原子变量。前一个线程在release 之前对原子变量的写都会被另一个线程所acquire。这对操作是移交内存的交会点。
适用情况:
- 写互斥量,自旋锁,管道,或其他有趣的数据结构。这种成对的操作保证了原始线能程更新RAM和其他线程获取所有相关内存,(这是这是对实际发生的事情的一种简化,存在cache coherence,不必将数据送入RAM)
- 创建双向同步(mutex 以原子变量的形式获得锁,获得当前的内存,修改后释放它)。创建单向同步(写者通过释放操作立刻放弃管道,读者获得写者的内容,从而节约带宽)。

let spinlock = AtomicBool::new(false); // not locked
...
while spinlock.compare_and_swap(false, true, Ordering::Acquire) {}
// It's locked here
spinlock.store(false, Ordering::Release); 

注意:

  • 并不保证与其他线程的同步
  • 并不保证在发布操作之后,第一个线程不会改变内存。
  • 不同原子变量的操作没有联系
  • 正确的同步方式是:在release之后第一个线程停止写。
  • 只有当 release 操作出现在写(store)中,acquire 操作出现在读中才(load)才起作用。相反则无效。

同样存在AcqRel顺序,它同时可以acquire and release,load-store,(像fetch_add操作)。

Sequentially consistent

它与AcqRel类似(load 时Acquire,store时Release ),但是它同步了其它原子变量,准确来说是SeqCst 操作贯穿整个程序,每个线程都有一个单一的操作时间线。较弱的操作仍有可能得到希望的结果。

Other synchronization points

正如AcqRel操作不同线程中同一原子变量的更新,也存在其他情况,可能隐藏着一些原子操作。

  • thread/memory模型中需要强制传播更新: Locking/unlocking 一个mutex,产生临界区。
  • 用管道发送数据。
  • Spawning 或 joining 其他的线程

总结

Relaxed:放轻松,不要担诸如事物的顺序或其他的内存等细节。
Release:告知你在内存中改变了什么。
Acquire:获得所有更新。
SeqCst:确保所有事物的一致性。

猜你喜欢

转载自blog.csdn.net/guiqulaxi920/article/details/79737841