在嵌入式编程中,有对某地址重复读取两次的操作,如地址映射IO。但如果编译器直接处理p[0] = *a; p[1] = *a这种操作时,往往会忽略后一个,而直接使用前一个已计算的结果。这是有问题的,因为地址a由于映射了端口,每一次读取都不同,都必须从地址上读取,不能让编译器进行优化。volatile因此而生。加了volatile的变量,编译器生成二进制代码时,每次都从源码意图取的地方去取,不优化。
但,后面有人妄想使用volatile每次从真实地址读取的特性而在多线程中使用,初始衷是在其他线程变化的共享变量,也能在当前线程中立即显现。这是误用。多线程共享变量的正确做法是加锁,不应创造发明线程间同步方法。
有这想法的人有个基本假设,认为CPU会老实按顺序执行编译出的二进制代码。
以下引入内存访问模型:
Memory consistency models,内存访问模型
内存访问模型描述的是硬件架构保证的内存的访问顺序。
对于程序员最习惯内存模型是顺序一致(sequential consistency),即上文所说的volatile使用于多线程者的默认假设:
- 所有的内存操作看起来和一次操作一样;
- 对单个CPU,内存操作的顺序与CPU执行代码的顺序一致。
就像:
Thread 1 | Thread 2 |
---|---|
A = 3 |
reg0 = B |
设默认内存地址上值为0,那么结果的可能性是这样的:
Registers | States |
---|---|
reg0=5, reg1=3 | possible (thread 1 ran first) |
reg0=0, reg1=0 | possible (thread 2 ran first) |
reg0=0, reg1=3 | possible (concurrent execution) |
reg0=5, reg1=0 | never |
可见,在顺序一致的内存模型中,不可能出现第4种情况,因为B的写入在A的后面。往往写代码时默认的是这种内存模型,大多单CPU架构上,包含ARM和X86确实是,但是,绝大多数的SMP架构上,不是顺序一致模型。
X86 SMP内存模型是处理器一致(processor consistency),它比顺序一致稍弱。对于单独的读、写,是顺序一致的,但它不保证先写后读的顺序。
ARM SMP更甚,它也不保证单独读写的顺序。
考虑以下例子:
Thread 1 | Thread 2 |
---|---|
A = true |
B = true |
Thread 1 | Thread 2 |
---|---|
reg1 = B |
|
探究原因就必须了解些CPU缓存。
CPU cache用于在CPU与主内存之间缓存数据,按离CPU从近到远分为L1,L2,L3级。L1级可以达到10-100倍的内存访问速度。
CPU cache分write-through与write-back两种,前者写到缓存后,直接触发从缓存往内存的写;后者会等到如缓存满才写主内存。写完缓存后,CPU会执行下条指令,有可能接下来若干条指令执行时,之前所写的内容都仍没有到主内存中。
上例中,写内存A的操作可能写到了缓存但未到主内存,而线程1继续执行了读B。而对线程2来说,A在主内存并没有变化,因此从线程2的角度,线程1的实际执行顺序“反序”了。
多核情况下,各核有自己的缓存会导致内存访问不具备时效性,CPU架构上的“缓存一致性模型”定义各核之间数据的共享机制。
考虑下面这段例子:
Thread 1 | Thread 2 |
---|---|
A = 41 |
loop_until (B == 1) |
按上面讨论的,X86 SMP架构上,没有问题,对线程2来说,线程1的两个写操作不会反序。但对ARM SMP,就不一样了。对线程1,写写,线程2,读读,都不保证顺序的话,就不会跑出期望的结果。
ARM的这种写写都不能保证顺序的情况是由于缓存读写是按一小块一小块来进行引起的。读写某块缓存时,是按块(ARM 32字节)进行,可能会将目标地址附近的数据一起刷新掉,可能比这些数据更老的内存数据仍然没有更新到缓存,这就是不能保证顺序的原因。
正确使CPU确保有序的方式是内存屏障,而一般锁都会进行内存屏障操作。
内存屏障告诉CPU内存访问需要确保有序。对于单CPU的X86,其实不需要,它天生支持顺序一致。
Thread 1 | Thread 2 |
---|---|
A = 41 |
loop_until (B == 1) |
写写屏障的作用是将所有CPU cache中的内容刷入主内存,使后续的写在其他核看来是有序的;读读屏障的作用是将CPU cache中内容清空,保证下次读时是从内存获取数据。
以下是读写屏障。
Thread 1 | Thread 2 |
---|---|
reg = A |
loop_until (B == 1) |
对于线程2,由于有个循环,看起来只要CPU不主动将指令执行顺序打乱,是不会在读B前取到A的。但是对于可能存在的第3个线程,可能看到的是A=41,B=0。因为线程2看到的B线程3不一定能看到。所以保险起见,还是加上内存屏障。
一开始提过,在X86 SMP中,只需要写读屏障。
各不同CPU有不同的屏障指令,如mfence是X86上的全屏障指令。要注意的是,内存屏障保证的只是访问顺序,不能把它当作CPU cache的flush机制来使用。
do {
success = atomic_cas(&lock, 0, 1) // acquire
} while (!success)
full_memory_barrier()
critical-section
full_memory_barrier()
atomic_store(&lock, 0) // release
上述是一个spinlock,用来执行一段关键的代码段。spinlock只在多CPU情况下使用,理想的实现是spin一段时间后转为非spin形式的lock。
这里有个内存屏障,它的作用有两个:
1 是调用CPU的内存屏障指令, 2是告诉编译器,这里的代码不能乱序。如果没有这个内存屏障调用,编译器可能把代码顺序优化的面目全非。
在释放锁前又调用了另一个内存屏障,保证关键代码处的改动对其他CPU可见。
实践方面:
C/C++ volatile
在单CPU单线程情况下,十分有用。它防止编译器省略或者将代码乱序,再加上单CPU的顺序一致性,可以保证代码按源码中的顺序执行。
而在单CPU多线程情况下,volatile的内存访问顺序可能会被非volatile打乱,可能需要显式加上编译乱序的barrier;
在SMP情况下,volatile就完全无用。应该被换掉。
在C/C++中,volatile往往意味着并发问题。
可以直接用pthread的mutex解决,它内部提供了内存屏障。或者直接用原子操作实现无锁化,这一般很难。
C++将会引入内存屏障相关的操作。
总结:
1. volatile在C系代码中,只应出现在开头的嵌入式编程的场景下,其他情况下应杜绝使用;
2. 并发编程加锁是正确操作,内部实现了内存屏障;
3. CPU乱序的原因是多CPU间的缓存同步机制问题;
4. C++后续会在语言层面引入内存屏障。