以下文字摘自《Windows 并发编程指南》,版权归原作者所有,仅供学习和研究参考。
int *a = &b;(b假设为一个局部int变量)
(*a)++;
在编译器把以上语句翻译为机器代码时,将包含多条机器指令,用汇编表示如下:
MOV EAX, [a]
INC EAX
MOV [a],EAX
从中我们看出: 在第一条机器指令中将对a执行解引用操作以获得一个虚拟内存地址,并且把从这个地址开始的四个字节复制到寄存器EAX中。接下来,机器指令将递增EAX中的值,最后将递增好a的值从EAX复制回a指向的地址。
但是,请注意,我们从C++源文件中根本无法看到在++运算中包含的这些机器执行步骤。在现代处理器中通常能够保证: 如果在递增运算中对内存的读取和写入等操作是以机器字节大小为单位(即CPU的内存寻址单位),那么将以原子方式来执行这些操作。例如在32位机器上执行32位值的递增操作,以及在64位机器上执行64位值的递增操作等。相反,如果在读取或者写入数据时使用的字节数大于CPU的内存寻址单位,那么这些操作就是非原子的。例如,如果在32位机器上写入一个64位值,那么这个操作就需要两条mov指令将这个值从处理器的私有内存移动到公有内存,其中每条指令复制4个字节。同样,如果在未对齐的地址(即地址的范围至少跨越了一个内存寻址单位)上执行读取操作或者写入操作,那么也需要执行多次内存操作,此外还可能需要一些位掩码操作和移动操作,即使这个值所占的内存空间小于或者等于机器的内存寻址单位也是如此。
那么,以上非原子运算在并发执行会带来哪些问题呢?请看解释:
递增运算的含义是,将在某个内存位置上的值单调地增加1.如果对初始值为0的计数器执行三次递增运算,那么得到的结果应该为3。下一次读取到的值应该比前一次读取到的值要大;因此,如果线程执行了两次(*a)++操作,并且两次操作依次执行,那么执行第二次操作之后的值总是大于执行第一次操作之后的值。这没有什么问题,符合我们的编程逻辑。但是,请注意,在多线程执行环境中,以上逻辑不能保证前后两次操作的结果相差为1;因为另一个线程非常有可能会在两次操作之间插入进来并执行另一次递增操作。
我们可以假设有三个线程t1,t2和t3同时执行编译之后的机器指令。
t1 t2 t3
t1(0):MOV EAX,[a] t2(0):MOV EAX,[a] t3(0):MOV EAX,[a]
t1(1):INC EAX t2(1):INC,EAX t3(1):INC,EAX
t1(2):MOV [a],EAX t2(2):MOV [a],EAX t3(2):MOV [a],EAX
以上每个线程都在单独的处理器上运行。这意味着每个处理器都拥有自己私有的EAX寄存器,但所有线程看到的a值都是相同的并且指向同一块共享内存。具体这三个线程在时间上是如何更替运行,以及各指令间执行顺序如何,我们可以通过时间刻度来分析这个并发执行的行为。注意:这三条指令不会真正地“同时”执行。虽然处理器可以同时执行多条指令,但有一点非常重要,即带有缓存一致性机制的共享内存系统将确保一致的内存全局视图。因此,我们可以以一种简单的、串行的时间刻度来描述程序的执行流程。
每一行序号之前的数字表示时间,横坐标以#n的形式表示相应指令之后a的值。
Time t1 t2 t3
0 t1(0):MOV EAX, [a] #0
1 t1(1):INC,EAX #1
2 t1(2):MOV [a], EAX #1
3 t2(0):MOV EAX, [a] #1
4 t2(1):INC,EAX #2
5 t3(2):MOV [a],EAX #2
6 t3(0):MOV EAX,[a] #2
7 t3(1):INC,EAX #3
8 t3(2):MOV [a],EAX #3
在多线程环境中运行,以上执行结果和执行流程符合我们的编程逻辑。但是一旦操作系统在中间进行了线程切换,以上这段代码很有可能出现错误!为了分析方便,我们暂时先忽略线程t3,看看代码流。
Time t1 t2
0 t1(0): MOV EAX, [a] #0
1 t2(0):MOV EAX, [a] #0
2 t2(1):INC, EAX #1
3 t2(2):MOV [a], EAX #1
4 t1(1):INC, EAX #1
5 t1(2):MOV [a], EAX #1
逻辑上讲,我们希望代码执行完第5步时,a=2才对。因为 每个线程对同一块内存进行了一次递增操作。但为什么会出现上面这种情况呢? 因为 线程 t1 和 t2分别将共享内存中的值复制到各自私有的寄存器中,因此这两个线程看到的是同一个值 0, 然后他们将对各自私有的副本执行递增操作。接下来,这两个线程将递增之后的新值复制到共享内存中,此时并没有执行某种验证或者同步操作来防止覆盖对方的值。这两个线程在各自寄存器中的值都是1,而并不知道彼此的存在。这样,在上面的情况中,t1 就用1覆盖了t2之前在共享位置上写入的值1.
有了上面的分析,我们可以将t3也加入进来,看看可能会出现的代码流及其后果。
Time t1 t2 t3
0 t3(0):MOV EAX,[a] #0
1 t1(0):MOV EAX, [a] #0
2 t1(1):INC,EAX #1
3 t1(2):MOV [a], EAX #1
4 t2(0):MOV EAX, [a] #1
5 t2(1):INC, EAX #2
6 t2(2):MOV [a], EAX #2
7 t3(1):INC, EAX #1
8 t3(2):MOV [a],EAX #1
程序逻辑完全乱了!这就是所谓的数据竞争问题,那有什么解决方案没?当然有,信号量(Semaphore),互斥锁(Mutex),临界区(Critical Section)等核心数据结构的引入,就是用来解决共享内存数据竞争带来的问题。
从以上分析,我们得出以下几个关键知识点:
1. 每个线程对共享位置上的值都各自拥有一个私有副本。
2. 线程将运算结果写回到共享内存中,并在这个过程中覆盖了其他线程写入的值。
3. 为个建立或者维持多个独立共享位置之间的不变性,我们可能需要复杂的更新操作。
4. 多个线程并发运行,从而导致了运行时间的重叠以及彼此执行操作的相互干扰。