【C】多线程原子操作内存顺序
自C11标准以来,C语言标准库增加了新的头文件——stdatomic.h,能实现原子操作。而stdatomic.h里面对原子操作的定义其实包含了两个概念,一个是原子性,一个是顺序可靠性。
[*]原子性保证原子操作能被别的线程立即观测到。如何理解这句话呢?首先你需要知道这么几点:
[*]普通的变量读写的过程可能会被编译器优化省略掉。
因为普通的变量仅用于描述运算逻辑,但编译器本身具备优化这种逻辑的职能(不然还要编译器有个卵用)。
[*]对于没有被优化掉的变量读写过程,CPU可能只进行了缓存或者寄存器的读写。
通过使用volatile关键字来声明变量,可以保证编译器不会把你的变量读写过程省略掉。但是它不保证这个变量一定是一个内存变量,它可以是一个寄存器,除非你指定了地址(把一个地址当作 volatile xxx* 来读写)。
但不管你是否使用了这个关键字,就算编译器生成了读写该变量的指令,CPU也并不一定会立即将数据写入内存——它会先把数据写进缓存,再在攒够差不多数据的时候把缓存的数据写进内存。
这个过程中,你对“内存变量”进行了操作,但CPU并没有真的立即读写内存。那么别的CPU核心或者线程将无法观测到你的写入操作。
原子性保证你的操作不会被编译器省略到造成无法进行多线程信号传递的过程。虽说你大概觉得编译器似乎不能对多线程的程序进行优化吧(这也是别什么事都多线程的原因之一),但你只需要做好你需要做的事情就行了——正确描述逻辑。
算法优化高于编译器优化或者指令优化,先优化算法,再考虑编译器的多线程优化效果(不存在的)。
[*]顺序可靠性定义你的原子操作实际发生的顺序可控。
说到指令的执行顺序,熟悉x86、x86-64的强顺序架构平台的伙伴们似乎没有什么直观的概念——指令都是一条一条执行的,它难道会有前后顺序颠倒的情况吗?其实是有的。因为有MIPS、RISC(含ARM)、Itanium、PowerPC等弱顺序架构平台。
弱顺序架构的平台一条指令通常做多件事,以及它们通常有多级流水线用于实现让指令叠加执行的效果——典型例子是ARM的三级流水线可以保证每一个时钟周期里,它同时能进行三条指令的取指、译码、执行的过程——对指令1取指的同时,对之前取好的指令2进行译码,与此同时执行译码完成的指令3。
在这样的平台上,你的原子操作可能会被排到附近的读写指令的前后执行,和你代码描述的执行顺序并不完全相同。在这种架构上,如果你要严格保证执行顺序,你就需要使用内存屏障。它可以让你在执行完一系列指令后,再执行后面的指令。然而使用内存屏障会带来性能惩罚,因为它需要让后续指令等待之前的指令执行完毕后才执行。
在使用原子操作进行多线程间交互的时候,指令执行的顺序至关重要。如果你还没有完成数据的写入就把一个原子量设置了值,别的线程可能会误以为你完成了数据的写入,就开始读取数据了,从而读到错误的数据。
但你并不是任何时候都需要进行严格保障执行顺序的原子操作,因为这样会导致你大量使用不必要的内存屏障,造成严重的性能惩罚。因此stdatomic对内存顺序也进行了多种不同的定义。
顺带一提,MSVC编译器对volatile变量的读取过程保证它具备取用方式的内存顺序并具备原子性,而对volatile变量的写入过程保证其具备释放方式的内存顺序并具备原子性。这一点并不是规范要求的,而且也容易对程序员造成误导——因为在其它平台或其它编译器上,volatile变量并不一定具备原子性和顺序定义性。被MSVC惯坏的程序员如果去你们那儿当了实习生,别让他接触跨平台的项目。
内存顺序有如下六种方式:enum memory_order {
memory_order_relaxed, // 宽松
memory_order_consume, // 消费
memory_order_acquire, // 取用
memory_order_release, // 释放
memory_order_acq_rel, // 取用与释放
memory_order_seq_cst// 顺序严格
};虽然任何时候使用memory_order_seq_cst方式能保证最大的线程安全性,但频繁使用内存屏障带来的性能惩罚应当被避免。
对于这六种内存顺序的具体描述是这样的:
[*]memory_order_relaxed
宽松方式内存顺序。也就是顺序无关紧要,只要附近的一批指令都得到执行就可以了。
不会插入内存屏障,速度是最快的。
但也不具备锁的特性,因为当别的线程观察到原子信号变化的时候,你的读写操作很可能并没有彻底完成。
[*]memory_order_consume
消费方式内存顺序。保证先发生原子操作读,再进行后续的读取过程。用于多线程中扮演消费数据的一方。
消费方式内存顺序不保证附近的写入操作具备顺序严格性,只影响读取的过程。此外,它通常不会插入内存屏障,只通过影响编译器优化来实现效果。
消费方式通常和释放方式配合使用。释放者写好数据以后,更新原子信号,然后消费者观测到原子信号变动,随即开始读取数据。
2015人们发现没有任何编译器或者组织使用这个内存顺序。所有人都使用memory_order_acquire并且编译器也自动把memory_order_consume的行为提升到memory_order_acquire。
[*]memory_order_acquire
取用方式内存顺序。和消费方式一样,保证先发生原子操作读,再进行后续的读取过程,用于多线程中扮演消费数据的一方。
对于取用方式内存顺序,编译器可能会插入内存屏障来保证读取操作不会提早于原子操作的读取操作进行,从而防止过早获取到数据,使锁失去作用。
与消费方式一样配合释放方式进行线程间数据交换。
[*]memory_order_release
释放方式内存顺序。与取用方式相反,释放方式通过插入内存屏障保证先前的写入操作都完成后,再更新原子信号使取用者可以观测到原子信号变化。
释放方式不保证读取过程的顺序严格性,只保证写入过程的顺序严格性。只影响写入操作的顺序。
[*]memory_order_acq_rel
取用与释放方式内存顺序。相当于memory_order_acquire和memory_order_release的组合版,先保证先前的写入过程完成后,更新或读取原子信号,然后再进行后面的读取过程。
注意它可能会允许原子操作前的读取操作延后到原子操作发生后,以及有可能让原子操作后的写入操作提前到原子操作发生前。
它保证的是原子操作发生前的写入操作不会被延后到原子操作后,以及原子操作后的读取操作不会被提前到原子操作发生前。
[*]memory_order_seq_cst
严格保证内存顺序。它确保前面的读写等操作都完成后,再进行原子操作,然后再进行后续的读写。这是最安全的内存顺序。
微软的各种Interlocked开头的API都保证这个顺序。
一个典型的无锁数据交互过程是Release->Acquire(Consume)逻辑,如下图所示:
其中,线程1完成数据写入后,以memory_order_release方式更新原子信号量;线程2以memory_order_consume方式或者memory_order_acquire方式观测到原子信号量变动后,读取线程1写入的数据。
线程1对于数据的读取并不严格保证顺序,而线程2则对数据的写入不严格保证顺序。这样一来,编译器就有了更多的优化的余地,相较于完全保证读写顺序的memory_order_seq_cst而言。
此外这种针对性情况可以得到针对性优化。Anydream通过逆向Windows Vista开始的srwlock来编写一个跨平台多线程读写锁库,这个库针对这种释放->获取方式的内存顺序有额外的优化,请参考:
https://github.com/anydream/SharedReadWriteLock
参考资料:
https://en.cppreference.com/w/c/language/history
https://en.cppreference.com/w/c/atomic/memory_order
https://docs.microsoft.com/en-us/previous-versions/visualstudio/visual-studio-2010/12a04hfd(v%3dvs.100)
:funk::funk:我们搞WinForm开发的不懂这个 谢谢楼主分享
页:
[1]