并发与内存序

自动关联目录:并发与内存序

并发问题的根源是:编译器、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 / 硬件的关系

本页是语言层的内存模型;对应的下层在:

三层是同一件事的三个视角:硬件提供重排能力 → OS 提供同步原语 → 语言定义内存模型。

死锁

条件破坏方式
互斥难避免
持有并等待一次性申请全部资源(scoped_lock)
不可抢占超时机制
循环等待给锁排序,按固定顺序获取

最实用的一条:给所有锁编号,永远按编号从小到大获取。

UE 侧对照

C++UE
std::mutexFCriticalSection / FScopeLock
std::atomicstd::atomic 或 TAtomic
std::threadFRunnable / AsyncTask / UE::Tasks
游戏线程 vs 其它线程UE 对象大多只能在游戏线程访问,这是更大的约束
ThreadSanitizer与 UE 的自定义分配器配合有限,需实测

UE 里并发的第一约束不是内存序,而是"哪些东西只能在游戏线程碰":UObject、Actor 的创建与销毁、渲染资源。跨线程访问这些,加锁也没用。

常见坑

坑说明
用 volatile 做线程同步不对,它只防编译器优化,不防 CPU 重排,也不是原子
双重检查锁定(DCLP)在弱内存序下失效(见 Singleton)
默认改内存序先 profile,再改,且要能说清
锁里调回调/虚函数死锁高发
以为原子变量能替代锁多个变量的组合操作仍需锁
忽略伪共享加线程反而变慢
在 UE 里跨线程碰 UObject加锁也救不回来
锁的粒度太粗并行度归零