Skip to content

数据竞争

Data race · 数据竞态

不同线程对同一内存位置的冲突访问既未按模型同步排序、也未通过原子操作协调的情形。

形式陈述

数据竞争不是“并发结果有时不同”的泛称,而是相对于特定内存模型定义的冲突关系。现代语言模型虽有细节差异,却共享一个基本骨架:在同一次程序执行中,两个动作 a,b 构成候选冲突,当且仅当

  1. a,b 属于不同线程并访问同一内存位置;
  2. 至少一个动作是写;
  3. 两个访问不是由模型认可的原子协议协调;
  4. ab 之间既没有 ahbb,也没有 bhba 的同步排序。

“同一位置”“冲突”“原子”与 happens-before 的精确定义必须由目标语言给出。例如重叠存储区域、位域、对象生命周期开始与结束是否冲突,各语言可能采用不同规则。这里的 hb 指程序顺序与同步边在语言内存模型中生成的偏序;它与分布式系统中用于消息因果的Happens-before 关系同源,但事件种类和生成规则不能未经说明地互换。

对 C 与 C++ 的普通非原子对象,若一个程序执行含有数据竞争,标准将该执行归入未定义行为。编译器因此可以在“良构程序无数据竞争”的前提下进行优化,实际结果不只限于丢失更新或撕裂读取。这个结论是 C/C++ 语言契约,不是所有共享内存模型的普遍定理:Java 为某些数据竞争执行规定了不同约束,硬件模型也只描述指令级允许行为。

数据竞争还必须与更广的 race condition 区分。后者指程序正确性依赖事件先后,可能完全由合法原子操作构成;消除 data race 只排除一类未同步冲突,并不自动保证复合逻辑原子、确定性或业务不变量。

直觉

可以把内存位置看成一份共享草稿。两个线程同时阅读没有冲突,因为谁先读不改变内容;两个线程访问不同位置也不会因“同时发生”自动冲突。危险来自至少一方正在改写同一位置,而双方没有共同认可的顺序或原子协议,于是语言无法为这两个动作建立稳定的观察关系。

锁、原子变量和其他同步操作的作用不是让执行变成单线程,而是向历史中加入足够的排序边或不可分割操作。只要冲突的普通访问被这些边定向,线程仍可在无关位置上并行。因而判断 data race 要检查具体访问对和模型关系,不能凭 CPU 利用率、线程数量或结果是否偶尔异常来命名。

例子与边界

从普通整数 x = 0 开始,两个线程都执行 x = x + 1。每次加一通常分为读、计算和写;两个线程可能都读到 0,再分别写入 1。两个写以及读写对访问同一位置、包含写且没有同步排序,因而构成数据竞争。最终值为 1 是易见的交错结果,但在 C/C++ 中不能把所有可能行为限定为 1 或 2,因为未定义行为允许编译器作更广泛变换。

若线程 A 只写 array[0],线程 B 只写 array[1],两个元素在语言对象模型中是不同内存位置,则并发写本身不构成 data race。仍可能出现 false sharing,引发缓存行抖动和性能下降;性能共享单元与语言定义的冲突位置是两个层次,不能因为硬件缓存行相同就把访问改判为数据竞争。

再看全原子的检查—行动逻辑:两个线程各自原子读取 balance >= 100,若为真再原子扣除 100。每个单次访问都合法原子,因此没有普通对象 data race;两者却可能同时通过检查,使余额违反业务约束。问题是复合操作缺少整体线性化,而不是内存访问未定义。修复应使用 CAS 循环、锁或事务把检查与更新合成一个协议,而不是只把字段类型换成 atomic。

推论与应用

数据竞争检测器通常依据一次实际运行中观察到的内存访问与同步关系报告冲突;未报告只能说明已探索执行中没有命中,不能证明所有可能历史都 race-free。静态类型、所有权系统与分离逻辑可以在更强前提下给出全程序保证,但它们证明的是各自形式系统中的性质,还需与目标语言内存模型对应。

无数据竞争程序在某些语言中获得接近顺序一致性的语义,是额外的 SC-for-DRF 定理,其前提、原子操作范围和保证随模型变化。数据竞争定义页只负责刻画冲突;它不把模型特定的定理偷渡成无条件结论,也不把“race-free”夸大为程序结果唯一。

参考资料
  • ISO/IEC 14882, C++ draft,[intro.races] 与原子操作相关条款。
  • Hans-J. Boehm and Sarita V. Adve, “Foundations of the C++ Concurrency Memory Model,” PLDI 2008, pp. 68–78。
  • Java Language Specification, Ch. 17, “Threads and Locks”。