本节摘要:数据竞争的定义是三要件:两个执行流并发访问同一内存位置、至少一方是写、之间无同步。竞争的后果是未定义行为,最常见表现是"丢失更新"——计数莫名变少。本节用一个可复现的丢账实验建立直觉,并给出竞争的识别特征与最小修复思路(修复的制度化工具在下一节)。
前四章的账本都由单支笔记录,本章开始同一本账上出现两支笔。本节从主线的那条"少记了千分之几"的曲线出发:先学会开线程、等线程的基本功,然后亲手让账目丢一笔,看清楚竞争的发生机制。它是 5.2 与 5.3 的共同起点——不知道病根,锁与原子操作就只是咒语。
两个线程各给同一个计数器加十万次,直觉说结果是二十万,实际运行:
#include <cstdio> #include <thread> long counter = 0; // 共享账本:无任何保护 void deposit_many(int times) { for (int i = 0; i < times; ++i) { ++counter; // 读、改、写三步,可被中途插队 } } int main() { std::thread teller1(deposit_many, 100000); std::thread teller2(deposit_many, 100000); teller1.join(); // 等两位柜员都下班 teller2.join(); std::printf("期望 200000,实际 %ld\n", counter); return 0; }
一次典型输出(数字不唯一,几乎必然少于二十万):
期望 200000,实际 137542
丢账的机制要拆开看。++counter 在机器层面是三步:把 counter 读进寄存器、寄存器加一、写回内存。两位柜员可以这样交错:甲读到 100,乙读到 100,甲写回 101,乙写回 101——两次加账,账本只进了 1。每一次交错丢一笔,十万次里交错的机会俯拾皆是。竞争丢的不是概率问题,是定义问题:三步操作没有原子性保证,任何交错都是合法执行,账目自然无从谈起。

不是所有共享都是竞争。竞争的法定定义有三要件,缺一不成立:两个执行流(线程、信号处理),并发访问同一内存位置,至少一方写且无同步。两个线程都只读同一变量——无竞争;两个线程写不同变量——无竞争;写写之间隔着一层互斥量——同步存在,无竞争。审计一段代码就按这三要件逐对核查:把所有共享变量列出来,对每个"至少一方写"的访问对,找它们的同步凭据,找不到就是坏账候选。
竞争的识别特征对排错极其重要。特征一:症状随时序漂移。 同一份代码,压测机丢三千笔、开发机丢一笔、加条打印就一笔不丢——时序变了,交错点变了。特征二:崩溃栈风马牛不相及。 竞争破坏的是任意共享数据,崩溃点常在下游无辜代码;本章主线的"崩溃栈指向不相干代码"正是此症。特征三:单线程测试全绿。 逻辑正确性与并发正确性是两本账,后者只有并发压测才能探测。三条特征凑齐,基本可断定竞争。
线程基本功还有两处与账本直接相关的纪律:
#include <cstdio> #include <thread> #include <vector> int main() { std::vector<std::thread> pool; for (int i = 0; i < 4; ++i) { pool.emplace_back([i] { std::printf("柜员 %d 上岗\n", i); // i 按值捕获:各持一份 }); } for (auto& t : pool) { t.join(); // 必须汇合:析构前未 join 会终止程序 } std::printf("全员下班,账房打烊\n"); return 0; }
输出(行序可能变化,四行上岗必到齐):
柜员 0 上岗 柜员 2 上岗 柜员 1 上岗 柜员 3 上岗 全员下班,账房打烊
纪律一:thread 对象析构前必须 join 或 detach,否则 std::terminate 直接终结程序——这本身就是一条"必须销账"的语言制度,与第 2 章 RAII 同源,工程上常用一个小的 joining thread 封装把 join 写进析构。纪律二:跨线程传参默认按值(lambda 按值捕获),引用捕获的局部变量在线程还活着时就可能随外层函数出栈销户——那是 3.1 悬垂教训的并发版,比单线程更难查。
背景:回到主线——上线当晚请求数对不上网关侧,差千分之几;同时偶发崩溃。
操作:按三要件核查共享变量清单,找出无凭据的写写对:统计计数器(就是本节的丢账)、一个全局配置指针(刷新线程写、工作线程读,无同步)。先修计数器:换成原子变量(5.3 的标准答案,先当咒语用),丢账立刻消失;配置指针加互斥量换成原子交换,崩溃不再复现。
结果:曲线与网关侧对平,观察一周无崩溃。
解读:这单案子浓缩了竞争审计的全部要点——先列共享清单,再找同步凭据,最后按访问模式选工具(计数选原子,指针发布选原子交换或锁保护)。丢账与崩溃是同一根因的两种果:计数器竞争只是丢数据(良性的坏账),指针竞争却是未定义行为(能崩的坏账),后者优先级永远更高。
变式:检测工具能显著加速定位——ThreadSanitizer 专抓数据竞争,报告直接给出两个冲突访问的代码位置与线程关系,比人工排查快一个量级(用法见 6.4)。它不能替代三要件分析(工具只覆盖已执行的路径),但配着用效率最高。
不需要——竞争三要件里"至少一方是写"不成立,纯读并发是安全的。审计时的麻烦在于"看起来只读"的访问暗地里在写:函数内部改了传入对象的缓存成员、首次访问时延迟初始化的全局对象、连只读统计计数都在写。判别方法是看实际执行的写而不是接口的 const 标注。延迟初始化有标准答案——局部 static 变量的初始化在 C++11 起由语言保证线程安全,比手工加锁的检查更可靠;真要跨线程共享可变状态,回到本章的制度:锁或原子操作,没有第三条路。
⚠️ 常见坑:用"加个打印就正常了"当修复。打印改变时序让竞争暂不可见,坏账本身分文未动,上线流量一变就复发。凡"改了无关代码就好了"的现象,都该按竞争重新立案。
病根看清了,下一节上正规制度:互斥量与三种 RAII 锁守卫——第 2 章的审计思想在账房门上的落地,外加死锁这条并发世界的特殊坏账的防与治。