数据竞争、原子与内存序:C/C++ 并发为什么不能靠运气
C/C++ 并发的核心边界是数据竞争。 两个线程同时访问同一对象,其中至少一个写入,且没有同步关系,就会产生数据竞争。 数据竞争在 C/C++ 中是未定义行为。 这不是“偶发读到旧值”,而是编译器和 CPU 都不再需要维持你想象中的执行顺序。
并发不是线程 API 的问题
线程只是执行载体。 真正的问题是多个执行流如何观察共享对象。 如果共享对象没有同步保护,源码顺序无法代表真实顺序。
int ready = 0;
int data = 0;
void producer() {
data = 42;
ready = 1;
}
void consumer() {
while (ready == 0) {}
use(data);
}
这段代码看起来清晰。
但 ready 和 data 都是普通对象。
两个线程间没有同步。
编译器可以缓存 ready。
CPU 可以重排可见性。
结果是数据竞争和未定义行为。
happens-before 是并发因果链
C/C++ 内存模型用 happens-before 描述可见性和顺序关系。 如果写入 happens-before 读取,读取才能可靠观察到写入。 锁、原子 release/acquire、线程 join 等机制可以建立这种关系。
线程 A:
写 data
release store ready
线程 B:
acquire load ready
读 data
release/acquire 建立同步
data 写入对线程 B 可见
没有 happens-before,就不能靠时间先后推理共享状态。
mutex 是最直接的同步边界
互斥锁保护临界区。 锁定和解锁之间的写入,对随后获得同一锁的线程可见。
std::mutex mutex;
int counter = 0;
void inc() {
std::lock_guard<std::mutex> lock(mutex);
++counter;
}
锁不仅防止同时写。
它还建立内存同步。
lock_guard 用 RAII 确保异常路径释放锁。
这和资源释放规则直接相关。
原子对象消除数据竞争
std::atomic<T> 对自身访问是原子的。
多个线程读写同一个 atomic 对象不会产生数据竞争。
std::atomic<int> ready{0};
int data = 0;
void producer() {
data = 42;
ready.store(1, std::memory_order_release);
}
void consumer() {
while (ready.load(std::memory_order_acquire) == 0) {}
use(data);
}
release 保证它之前的写入不会越过发布点。
acquire 保证看到发布后,后续读取能观察发布前写入。
这让普通 data 的写入可见。
memory_order_relaxed 只保证原子性
relaxed 原子不会建立跨变量同步。 它适合计数器、统计和不参与发布数据的场景。
std::atomic<uint64_t> requests{0};
void record() {
requests.fetch_add(1, std::memory_order_relaxed);
}
这能安全统计请求数。 但不能用 relaxed flag 发布另一个普通对象。 否则读取方看到 flag,也不保证看到数据。
seq_cst 简单但不是免费
默认原子操作是 sequentially consistent。 它提供最强直觉:所有线程看到一个全局一致的原子顺序。 这便于推理,但可能限制优化和硬件执行。
flag.store(true); // 默认 seq_cst
flag.load(); // 默认 seq_cst
初学并发时使用 seq_cst 是稳妥的。
在性能敏感路径降低内存序前,必须有测试、profile 和审计。
内存序优化不是凭感觉微调。
volatile 不是线程同步工具
C/C++ 的 volatile 主要用于特殊内存访问,例如内存映射 IO 或信号相关场景。
它不建立线程间同步。
它不能替代 atomic 或 mutex。
volatile int ready = 0; // 不适合作为线程同步 flag
volatile 可以影响编译器对单次访问的处理。
它不能保证 CPU 缓存一致性语义。
也不能消除数据竞争。
double-checked locking 需要原子和生命周期配合
懒加载单例常见错误是只检查指针,不建立发布顺序。
std::atomic<Service*> instance{nullptr};
std::mutex mutex;
Service* get() {
Service* p = instance.load(std::memory_order_acquire);
if (p == nullptr) {
std::lock_guard<std::mutex> lock(mutex);
p = instance.load(std::memory_order_relaxed);
if (p == nullptr) {
p = new Service();
instance.store(p, std::memory_order_release);
}
}
return p;
}
发布指针前必须保证对象构造完成。 读取指针后必须通过 acquire 观察构造写入。 但这段代码还需要解决销毁顺序和泄漏策略。 很多时候局部静态对象更简单。
条件变量需要谓词
条件变量可能虚假唤醒。 等待必须放在谓词循环里。
std::mutex mutex;
std::condition_variable cv;
bool ready = false;
void wait_ready() {
std::unique_lock<std::mutex> lock(mutex);
cv.wait(lock, [] { return ready; });
}
谓词是状态。 通知只是提示。 把通知当作状态,会丢信号。
生命周期是并发安全的一半
同步只解决访问顺序。 对象是否还活着是另一个问题。
std::thread t([ptr] {
ptr->run();
});
delete ptr;
t.join();
这段代码可能在线程仍使用对象时释放对象。 正确顺序应该先请求停止,再 join,再释放资源。 线程生命周期和对象生命周期必须绑定。
false sharing 是缓存层面的性能问题
多个线程更新不同变量,如果这些变量落在同一缓存行,会互相干扰。 这不是数据竞争,但会严重拖慢。
cache line
├── counter_a 线程 A 写
└── counter_b 线程 B 写
两个变量独立,却共享缓存行。 CPU 缓存一致性协议会反复转移所有权。 高频计数器可考虑 padding 或 per-thread 聚合。
lock-free 不是自动更快
无锁结构减少阻塞,但增加内存序、ABA、回收和饥饿问题。 很多无锁队列最难的不是 CAS,而是节点什么时候安全释放。
常见风险:
- ABA 问题。
- 内存回收延迟。
- 忙等导致 CPU 占用。
- 弱内存模型下顺序错误。
- 超时和降级策略缺失。
没有成熟需求和验证,不要为了“高级”重写锁。
TSan 是数据竞争的观测工具
ThreadSanitizer 能发现很多数据竞争。
c++ -std=c++23 -g -O1 \
-fsanitize=thread \
concurrent_test.cpp
TSan 会增加运行开销。 它适合测试和 CI。 它不能覆盖未执行路径。 对低层同步原语和自定义原子算法,仍需要代码审计。
并发设计要能停机
生产系统中的线程不能只会启动。 它还必须可取消、可超时、可 join、可降级。
start workers
-> process queue
-> request stop
-> wake blocked workers
-> drain or discard tasks
-> join threads
-> release resources
没有停机协议的并发代码,最终会在发布、回滚或进程退出时暴露资源释放问题。
工程清单
- 共享可变状态必须有 mutex 或 atomic。
volatile不作为线程同步工具。- 原子 flag 发布普通数据时使用 release/acquire。
- 降低内存序前必须有 profile 和审计。
- 条件变量等待必须使用谓词。
- 线程退出前先停止、唤醒、join,再释放对象。
- 对计数热点检查 false sharing。
- 无锁算法必须设计内存回收。
- TSan 进入测试矩阵。
- 并发模块必须有超时、降级和日志。
小结
C/C++ 并发可靠性建立在数据竞争边界上。 普通读写没有同步就不是“偶尔不一致”,而是未定义行为。 锁提供清晰同步。 原子提供细粒度同步。 内存序提供可见性承诺。 把这些机制和生命周期、停机协议、观测工具放在一起,才是真正可运行的并发工程。