当时序成为隐藏前提

并发程序允许多个执行流程交错推进。在某些交错下程序表现正常,在另一些交错下却违反预期,这种正确性对未受控制时序的依赖就是竞态问题的核心。它不要求两个处理器在物理上同时执行指令;单处理器上的线程切换也能暴露问题。

并非所有结果顺序变化都是错误。两个独立任务以不同先后顺序完成,可能完全符合设计。判断竞态是否构成缺陷,要看程序需要保持的条件,例如计数不能丢失、对象必须先初始化再使用,而不是只看每次输出是否相同。

参考:[1] [2]

一次丢失更新

设共享计数器起初为十,两个线程分别执行一次加一。用分开的读取、加法和写回来理解:甲先读到十,乙也读到十;甲写入十一,乙随后也写入十一。两个逻辑增量只产生了一次净增长,而按要求串行完成两次更新应得到十二。

这个例子说明完整的读改写操作需要保护,不能仅凭源代码看起来只有一行就认为不可分割。不过,对具有数据竞争的实际 C++ 普通变量,语言规则给出的是未定义行为;上述步骤只是说明丢失更新的执行模型,不能声称列举了该程序所有可能结果。

参考:[1] [3]

与数据竞争的边界

C++ 内存模型中的数据竞争有更精确的含义:潜在并发的两个冲突动作,至少一个不是原子动作,且彼此没有 happens-before 关系,通常即构成数据竞争;该标准定义也处理对象生命周期等冲突。对普通跨线程访问,不能用“实际运行时碰巧先后执行”代替同步关系。

广义竞态并不限于这种情况。例如把计数器的读取与写入分别改成原子操作,仍可能让两个线程都读取十,再分别写入十一。单次访问可以没有数据竞争,但两个访问组合成的加一并没有因此成为原子读改写。数据竞争自由是重要条件,却不是整个算法正确的证明。

参考:[3] [1]

修复应对应实际约束

对于丢失更新,可以让所有相关访问遵守同一把锁,覆盖完整的读改写过程,或使用满足要求的原子读改写操作。若问题在于“检查后状态被改变”,保护范围需要同时覆盖检查和依赖该检查的使用。只锁住其中一半可能仍保留错误窗口。

另一类问题是操作顺序违反要求,例如工作线程在初始化完成之前就读取对象。这需要明确的同步协议来表达“已完成”,而不是猜测哪个线程通常更快。互斥、条件等待以及原子操作各有作用,关键是把原先隐含的时序假设转成有保证的程序关系。

参考:[2] [4] [3]