察看下面的竞争条件:
int ComputeSomething() { static int cachedResult = ComputeSomethingSlowly(); return cachedResult; }
这段代码的意图是在该函数第一次被调用的时候去计算一些费用, 并且把结果缓冲起来待函数将来再被调用的时候则直接返回这个值即可.
这个基本技巧的变种,在网络上也被叫做 避免 "static initialization order fiasco". ( fiasco这个词 在这个网页上有非常棒的描述,因此我建议大家去读一读然后去理解它.)
这段代码的问题是非线程安全的. 在局部作用域中的静态变量是编译时会在编译器内部转换成下面的样子:
int ComputeSomething()
{
static bool cachedResult_computed = false;
static int cachedResult;
if (!cachedResult_computed) {
cachedResult_computed = true;
cachedResult = ComputeSomethingSlowly();
}
return cachedResult;
}
现在竞争条件就比较容易看到了.
假设两个线程在同一时刻都调用这个函数. 第一个线程在执行 cachedResult_computed = true 后, 被抢占. 第二个线程现在看到的 cachedResult_computed 是一个真值( true ),然后就略过了if分支的处理,最后该函数返回的是一个未初始化的变量.
现在你看到的东西并不是一个编译器的bug, 这个行为 C++ 标准所要求的.
你也能写一个变体来产生一个更糟糕的问题:
class Something { ... }; int ComputeSomething() { static Something s; return s.ComputeIt(); }
同样的在编译器内部它会被重写 (这次, 我们使用C++伪代码):
class Something { ... }; int ComputeSomething() { static bool s_constructed = false; static uninitialized Something s; if (!s_constructed) { s_constructed = true; new(&s) Something; // construct it atexit(DestructS); } return s.ComputeIt(); } // Destruct s at process termination void DestructS() { ComputeSomething::s.~Something(); }
注意这里有多重的竞争条件. 就像前面所说的, 一个线程很可能在另一个线程之前运行并且在"s"还没有被构造前就使用它.
甚至更糟糕的情况, 第一个线程很可能在s_contructed 条件判定 之后,在他被设置成"true"之前被抢占. 在这种场合下, 对象s就会被双重构造和双重析构.
这样就不是很好.
但是等等, 这并不是全部, 现在(原文是Not,我认为是Now的笔误)看看如果有两个运行期初始化局部静态变量的话会发生什么:
class Something { ... }; int ComputeSomething() { static Something s(0); static Something t(1); return s.ComputeIt() + t.ComputeIt(); }
上面的代码会被编译器转化为下面的伪C++代码:
class Something { ... };
int ComputeSomething()
{
static char constructed = 0;
static uninitialized Something s;
if (!(constructed & 1)) {
constructed |= 1;
new(&s) Something; // construct it
atexit(DestructS);
}
static uninitialized Something t;
if (!(constructed & 2)) {
constructed |= 2;
new(&t) Something; // construct it
atexit(DestructT);
}
return s.ComputeIt() + t.ComputeIt();
}
为了节省空间, 编译器会把两个"x_constructed" 变量放到一个 bitfield 中. 现在这里在变量"construted"上就有多个无内部锁定的读-改-存操作.
现在考虑一下如果一个线程尝试去执行 "constructed |= 1", 而在同一时间另一个线程尝试执行 "constructed |= 2".
在x86平台上, 这条语句会被汇编成
or constructed, 1 ... or constructed, 2并没有 "lock" 前缀. 在多处理机器上, 很有可能发生两个存储都去读同一个旧值并且互相使用冲突的值进行碰撞(clobber).
在 ia64 和 alpha平台上, 这个碰撞将更加明显,因为它们么没有这样的读-改-存的单条指令; 而是被编码成三条指令:
ldl t1,0(a0) ; load addl t1,1,t1 ; modify stl t1,1,0(a0) ; store
如果这个线程在 load 和 store之间被抢占, 这个存储的值可能将不再是它曾经要写入的那个值.
因此,现在考虑下面这个有问题的执行顺序:
- 线程A 在测试 "constructed" 条件后发现他是零, 并且正要准备把这个值设定成1, 但是它被抢占了.
- 线程B 进入同样的函数, 看到 "constructed" 是零并继续去构造 "s" 和 "t", 离开时 "constructed" 等于3.
- 线程A 继续执行并且完成它的 读-改-存 的指令序列, 设定 "constructed" 成 1, 然后构造 "s" (第二次).
- 线程A 然后继续去构造 "t" (第二次) 并设定 "constructed" (最终) 成 3.
现在, 你可能会认为你能用临界区 (critical section) 来封装这个运行期初始化动作:
int ComputeSomething() { EnterCriticalSection(...); static int cachedResult = ComputeSomethingSlowly(); LeaveCriticalSection(...); return cachedResult; }
因为你现在把这个一次初始化放到了临界区里面,而使它线程安全.
但是如果从同一个线程再一次调用这个函数会怎样? ("我们跟踪了这个调用; 它确实是来自这个线程!") 如果 ComputeSomethingSlowly() 它自己间接地调用 ComputeSomething()就会发生这个状况.
结论: 当你看见一个局部静态变量在运行期初始化时, 你一定要小心.