并发与内存序
自动关联目录:并发与内存序
并发问题的根源是:编译器、CPU、缓存都会重排你的代码,只要"单线程看起来一样"。多线程崩溃,几乎都是因为你假定了某种顺序,而没人向你保证过这个顺序。
一句话定位:用 mutex 就不用管内存序;只有为了性能放弃锁时,才需要理解六种 memory_order。
数据竞争
// 数据竞争 = 两个线程访问同一内存,至少一个写,且没有同步
int Counter = 0;
void Inc() { ++Counter; } // 多线程调用 → 未定义行为数据竞争是未定义行为,不是"可能算错"。编译器可以假设没有竞争,从而做出让你崩溃的优化(比如把循环里的读提到循环外)。
| 后果 | 例子 |
|---|---|
| 结果不对 | 丢失更新 |
| 程序卡死 | 编译器把读优化到循环外 |
| 偶发崩溃 | 半写状态被另一个线程读到 |
排查工具:ThreadSanitizer(-fsanitize=thread)、UE 侧的 FThreadSafeCounter 与断言。
互斥
std::mutex M;
int Counter = 0;
void Inc()
{
std::lock_guard<std::mutex> Lock(M); // RAII,见 [移动语义与 RAII](/fddca970.html)
++Counter;
}| 工具 | 适用 |
|---|---|
std::mutex | 通用 |
std::shared_mutex | 读多写少 |
std::recursive_mutex | 通常说明设计有问题 |
std::scoped_lock | 同时锁多个,避免死锁 |
std::atomic<T> | 单个变量的无锁访问 |
| 无锁结构 | 只有真有性能测试支撑时才写 |
// 同时锁两个,用 scoped_lock 避免死锁
std::scoped_lock Lock(MA, MB);锁的代价不是"慢",是"串行化":临界区越长,并行度越低。优化方向是缩小临界区,不是换更快的锁。
| 手段 | 说明 |
|---|---|
| 缩小临界区 | 只锁真正共享的部分 |
| 读写分离 | shared_mutex |
| 每线程副本 + 合并 | 计数、统计 |
| 无锁队列 | 只用于高竞争且已验证的场景 |
| 避免锁内调用虚函数/回调 | 极易死锁 |
原子与内存序
std::atomic<bool> Ready{false};
std::atomic<int> Data{0};
// 线程 1
Data.store(42, std::memory_order_relaxed);
Ready.store(true, std::memory_order_release);
// 线程 2
if (Ready.load(std::memory_order_acquire))
{
int V = Data.load(std::memory_order_relaxed); // 保证看到 42
}| 内存序 | 保证 | 代价 |
|---|---|---|
relaxed | 只有原子性,无顺序保证 | 最便宜 |
consume | 数据依赖顺序(实际几乎不用) | 低 |
acquire | 本线程后续读写不能重排到它之前 | 中 |
release | 本线程之前的读写不能重排到它之后 | 中 |
acq_rel | 两者都(读改写操作) | 中 |
seq_cst | 全局单一顺序,默认 | 最贵 |
默认用 seq_cst。只有在确认是性能瓶颈(profile 证明)时才降级,而且必须能说清为什么安全。
理解 acquire/release 的口诀:
- release:"我之前做的所有事,对拿到这个值的线程可见"
- acquire:"拿到这个值之后,我能看到对方之前做的所有事"
两者配对使用(release 存、acquire 读)就构成了一次同步。
常见模式
| 模式 | 该用什么 |
|---|---|
| 计数器 | relaxed(只要原子性,不要顺序) |
| 发布数据(先写数据,再置标志) | release 存 + acquire 读 |
| 引用计数 | release 减 + acq_rel 增 |
| 自旋锁 | acquire 锁 + release 放 |
| 什么都不确定 | seq_cst |
// 典型的"发布"模式
void Producer()
{
Payload = ComputeSomething(); // 普通写
Ready.store(true, std::memory_order_release);
}
void Consumer()
{
if (Ready.load(std::memory_order_acquire))
{
Use(Payload); // 安全:能看到 Producer 之前的所有写
}
}伪共享(False Sharing)
// 两个原子变量在同一缓存行 → 两核互相使对方缓存失效
struct alignas(64) Padded // 缓存行对齐
{
std::atomic<int> A;
};| 现象 | 原因 |
|---|---|
| 多线程各自写一个变量却很慢 | 变量在同一缓存行,缓存行在核间来回弹 |
解决:按 64 字节(缓存行大小)对齐或填充。这是"加了线程反而更慢"的经典原因。
与 OS / 硬件的关系
本页是语言层的内存模型;对应的下层在:
- 硬件为什么能重排:Computer Organization(流水线、缓存一致性)
- 操作系统提供的原语:Operating system / 并发(锁的实现、条件变量、死锁)
三层是同一件事的三个视角:硬件提供重排能力 → OS 提供同步原语 → 语言定义内存模型。
死锁
| 条件 | 破坏方式 |
|---|---|
| 互斥 | 难避免 |
| 持有并等待 | 一次性申请全部资源(scoped_lock) |
| 不可抢占 | 超时机制 |
| 循环等待 | 给锁排序,按固定顺序获取 |
最实用的一条:给所有锁编号,永远按编号从小到大获取。
UE 侧对照
| C++ | UE |
|---|---|
std::mutex | FCriticalSection / FScopeLock |
std::atomic | std::atomic 或 TAtomic |
std::thread | FRunnable / AsyncTask / UE::Tasks |
| 游戏线程 vs 其它线程 | UE 对象大多只能在游戏线程访问,这是更大的约束 |
| ThreadSanitizer | 与 UE 的自定义分配器配合有限,需实测 |
UE 里并发的第一约束不是内存序,而是"哪些东西只能在游戏线程碰":UObject、Actor 的创建与销毁、渲染资源。跨线程访问这些,加锁也没用。
常见坑
| 坑 | 说明 |
|---|---|
用 volatile 做线程同步 | 不对,它只防编译器优化,不防 CPU 重排,也不是原子 |
| 双重检查锁定(DCLP) | 在弱内存序下失效(见 Singleton) |
| 默认改内存序 | 先 profile,再改,且要能说清 |
| 锁里调回调/虚函数 | 死锁高发 |
| 以为原子变量能替代锁 | 多个变量的组合操作仍需锁 |
| 忽略伪共享 | 加线程反而变慢 |
| 在 UE 里跨线程碰 UObject | 加锁也救不回来 |
| 锁的粒度太粗 | 并行度归零 |
许可协议:CC BY
作者:Davids
本文链接:https://hustjjd.github.io/854364f5.html
更新于:2026年10月10日