一个简单的例子
关于代码块中的静态变量(相对于全局范围中的静态变量)的一个原则是:当代码执行流第一次运行到静态变量的声明处时才会进行初始化,且仅此一次。
下面的代码,会存在潜在的竞争条件:
上面代码的本意是:只在第一次函数被调用的时候,才进行耗时久的计算,下一次则直接返回一个缓存值。
问题在于,这段代码不是线程安全的。代码块中的静态变量会被编译器内部转换为如下的代码:
通过上面的代码,我们就能很容易地看出来这个潜在的竞争条件了。
假设有两个线程,都是第一次调用这个函数。第一个线程执行到了”cachedResult_computed = true”,然后系统将它的时间片抢占并执行第二个线程。第二个线程看到,此时cachedResult_computed为true,则它会跳过下面if语句并返回一个未初始化的变量。
上面这个行为,并不是一个编译器Bug,这个行为是定义在C++标准里的。
基于上面的代码,我们还可以编写更加糟糕的竞争条件代码,如下图所示:
编译器将上面的代码翻译如下:
请注意了,上面的代码中,存在多个竞争条件。和之前类似,有可能一个线程比另一个线程早运行,并使用了为初始化的”s”。
更加糟糕的是,还有一种可能是:就在第一个线程刚刚完成s_constructed的判断且没有将它设置为true这个时间点时,系统就将执行流切换到另一个线程。这种情况下,对象s会自行构造函数和析构函数两次,那可不是什么好事儿。
但是,等等,这好像还不是全部的呢。
另一个例子
考察下面的代码,我们定义了两个局部的静态变量:
下面是编译器翻译的代码:
编译器为了节省空间,它将两个”x_constructed”放入到了一个位域(BitField)中。这就可能导致在变量”constructed”上发生多次非锁定情况下的读取-写入-存储操作。
让我们假设,如果一个线程尝试执行”constructed |= 1″的同时,另一个线程尝试执行”constructed |= 2″,会发生什么呢?
在x86架构上,以上两个语句可以表示为下面两条汇编指令:
or constructed, 1
…
or constructed, 2
因为使用锁定机制,在一台多处理器的机器上,有可能会导致存储的时候读取到了旧值并使得数据结果自相矛盾。
在一台x64的机器上,这种情况会变得更加明显,因为x64平台没有一条单独的”读取-写入-存储”的指令,必须使用如下指令集合来实现:
ldl t1,0(a0) ; load
addl t1,1,t1 ; modify
stl t1,1,0(a0) ; store
如果线程在执行load和store之间被抢占,则被存储的值可能就是所预期的值了。
让我们考虑下列线程执行序列:
> 线程A判断变量constructed,然后发现它的值为0,然后就准备将它设置为1,但是此时它被抢占了。
> 线程B进入相同的函数,它发现变量constructed是0,于是准备创建s和t,变量constructed变为了3。
> 线程A恢复执行并完成它的”读取-写入-存储”操作序列,将变量constructed设置为1,然后第二次创建了s。
> 线程A接下来也会第二次创建t,并将constructed最后设置为3。
你可能会想到,使用临界区来对确保只有一个线程能执行关键代码片段,如下图所示:
通过使用临界区,我们将静态变量的一次性初始化工作放到了临界区执行,现在的版本就是线程安全的了。
但是,如果第二个调用来自同一线程,该怎么办? (我们已经跟踪了该调用;它来自线程内部)
如果ComputeSomethingSlowly()本身调用ComputeSomething()(可能是间接调用),则可能会发生这种情况。
由于该线程已经进入临界区,因此代码能正常执行,而我们会再次返回一个未初始化的变量。
总结
当你看到代码中有局部静态变量的初始化时,请特别小心。
另外,从C++11开始,标准经过了修改,在C++11之后,局部静态变量将是线程安全的了。
最后
Raymond Chen的《The Old New Thing》是我非常喜欢的博客之一,里面有很多关于Windows的小知识,对于广大Windows平台开发者来说,确实十分有帮助。
本文来自:《C++ scoped static initialization is not thread-safe, on purpose!》