Atomic、内存序与任务系统
原子操作解决的是单个共享状态的无数据竞争访问,任务系统解决的是工作如何被切分、调度、等待和取消。两者都建立在明确的数据依赖和生命周期之上。
一、atomic 保证什么
std::atomic<int> counter = 0;
counter.fetch_add(1);原子操作不会被其他线程观察到“只完成了一半”。多个线程对同一原子对象执行支持的操作,不会形成普通数据竞争。
但原子性只针对该原子对象,不会自动维护多个字段之间的不变量:
std::atomic<int> x = 0;
std::atomic<int> y = 0;即使 x 和 y 分别原子,其他线程仍可能观察到两者处于业务上不一致的组合。需要一起更新的复杂状态通常更适合 mutex,或需要专门设计的状态协议。
二、常见原子操作
std::atomic<int> value = 0;
value.load();
value.store(10);
value.exchange(20);
value.fetch_add(1);
value.fetch_sub(1);exchange 返回旧值并原子地写入新值。fetch_add 返回加法前的旧值。
三、Compare-and-swap
CAS 在当前值仍等于预期值时才写入新值:
int expected = 10;
bool changed = value.compare_exchange_strong(expected, 20);概念语义:
如果 value == expected:
value = desired
返回 true
否则:
expected = value 当前值
返回 falseCAS 常用于状态转换:
enum class State {
Idle,
Loading,
Ready,
Failed
};
std::atomic<State> state = State::Idle;
State expected = State::Idle;
if (state.compare_exchange_strong(expected, State::Loading)) {
startLoading();
}这样只有一个线程能成功把 Idle 改为 Loading。
weak 与 strong
compare_exchange_weak 允许在值相等时发生虚假失败,因此通常放在重试循环中;某些架构上它可以更高效。
int current = value.load();
while (!value.compare_exchange_weak(current, current + 1)) {
// 失败时 current 已更新为最新值
}strong 不允许这种虚假失败,适合不希望无原因重试的单次状态转换。
四、内存序解决什么
原子性回答“这次操作是否被撕裂”;内存序回答“这个原子操作与其他普通内存访问之间建立什么可见顺序”。
常见内存序:
| 内存序 | 作用概览 |
|---|---|
relaxed | 只保证该原子对象操作原子,不建立跨变量发布关系 |
acquire | 成功读取同步状态后,看到发布线程之前的写入 |
release | 发布当前线程在此之前的写入 |
acq_rel | 读改写操作同时承担 acquire 与 release |
seq_cst | 提供最直观的全局顺序模型,也是默认值 |
五、relaxed 计数器
如果只要求计数本身不丢失,不用它发布其他数据:
std::atomic<std::uint64_t> completedJobs = 0;
completedJobs.fetch_add(1, std::memory_order_relaxed);适合统计、诊断和独立计数。它不能表达:看到计数变化后,其他普通数据一定已经可见。
六、Release/Acquire 发布数据
int data = 0;
std::atomic<bool> ready = false;生产者:
data = 42;
ready.store(true, std::memory_order_release);消费者:
if (ready.load(std::memory_order_acquire)) {
use(data); // 可以看到发布前写入的 42
}成功观察到 release store 的 acquire load,与之前的普通写入建立 happens-before 关系:
生产者写 data
↓
release store ready=true
↓ synchronize-with
acquire load 看到 true
↓
消费者读取 data如果把双方都改成 relaxed,只能保证 ready 自身原子,不能用它可靠发布 data。
七、为什么默认先用 seq_cst
默认原子操作采用 memory_order_seq_cst,更接近所有线程共同观察到一个一致的原子操作顺序。
较弱内存序可以降低部分架构上的约束,但协议更难证明。除非:
- 已经确认同步是瓶颈;
- 能画出完整的发布与获取关系;
- 有并发测试和平台覆盖;
- 能说明每个原子操作为何使用该内存序;
否则优先 mutex 或默认原子顺序。错误的内存序代码可能在 x86 测试正常,却在更弱内存模型的平台暴露。
八、Lock-free 不等于 Wait-free
- Lock-free:系统整体持续取得进展,但单个线程可能长期重试;
- Wait-free:每个线程都能在有限步骤内完成操作;
- Obstruction-free:线程独占执行足够久时能完成。
“没有 mutex”也不等于更快。CAS 在高竞争下会反复失败:
读取旧值
↓
其他线程先修改
↓
CAS 失败
↓
重新读取并重试这会产生缓存行争用、重试和高尾延迟。低竞争下的简单 mutex 有时更高效、更容易维护。
九、ABA 问题
线程 A 读取状态 A,暂停;线程 B 把状态从 A 改成 B,再改回 A。线程 A 恢复后 CAS 只看到值仍是 A,误以为期间没有变化:
线程 A:读取 A ───────────────┐
线程 B:A → B → A │
线程 A:CAS 发现仍为 A,成功 ──┘在无锁栈或 freelist 中,节点可能已被移除、释放并复用。常见缓解方法包括:
- 指针附带版本计数;
- Tagged Pointer;
- Hazard Pointer;
- Epoch-based Reclamation;
- 延迟回收;
- 使用经过验证的并发容器。
无锁数据结构最困难的部分往往不是 CAS,而是安全回收节点内存。
十、False Sharing
两个线程修改不同变量,如果变量位于同一缓存行,缓存一致性协议仍会让整条缓存行在核心之间反复转移:
同一 Cache Line
┌──────────────────────────────────┐
│ Thread A counter │ Thread B counter │
└──────────────────────────────────┘这就是 False Sharing。数据在逻辑上不共享,硬件缓存行却被共享写入。
缓解方式:
- 把高频写状态按线程分离;
- 每线程本地累计,再低频合并;
- 让热点字段落在不同缓存行;
- 避免多个线程持续写紧邻的全局计数器。
struct alignas(std::hardware_destructive_interference_size) Counter {
std::atomic<std::uint64_t> value = 0;
};对齐值和实际收益应结合目标平台验证,不能盲目为所有对象填充缓存行。
十一、线程池为什么优于频繁创建线程
为每个小任务创建操作系统线程会产生创建、栈空间、调度和销毁成本。线程池预先创建固定工作线程:
Task Queue
↓
Worker 0
Worker 1
Worker 2
Worker 3提交任务只需要把工作描述放入队列,由已有线程执行。
线程数量通常围绕可用核心、主线程/渲染线程占用、任务阻塞特征和平台调度行为设计,而不是越多越好。
十二、任务粒度
任务太小:
- 入队与出队成本占比高;
- 原子和队列争用增加;
- 依赖管理成本超过计算;
- 指令与数据局部性变差。
任务太大:
- 核心负载不均;
- 最慢任务决定阶段结束时间;
- 依赖者等待时间长;
- 难以利用空闲核心。
可以通过批量大小、任务耗时分布和工作线程利用率调整粒度。
十三、共享队列与 Work Stealing
单一全局队列简单,但所有线程争用同一个同步点。
Work Stealing 通常让每个工作线程拥有本地双端队列:
Worker A:优先从自己的队列取任务
Worker B:本地队列为空时,从 A 的另一端偷任务本地操作减少竞争,偷取机制改善负载不均。但实现需要处理队列并发、任务所有权、内存回收和睡眠唤醒策略。
十四、Task Graph
任务不只是一组独立函数,还可以构成依赖图:
Animation Sampling ─┐
├→ Build Bone Matrices → Skinning
Physics Step ───────┘
Culling → Build Draw Commands → Render Submit只有依赖完成后任务才进入可运行状态。相比整阶段 barrier,任务图可以让无关工作继续执行,减少所有线程等待最慢任务。
任务图需要明确:
- 前置依赖计数;
- 完成后唤醒哪些任务;
- 错误和取消如何传播;
- 谁拥有任务捕获的数据;
- 等待时工作线程是否帮助执行其他任务。
十五、等待时不要浪费工作线程
如果工作线程提交子任务后直接阻塞等待:
所有 Worker 都在等待自己的子任务
↓
没有 Worker 可以执行子任务
↓
线程池死锁或饥饿任务系统常采用“等待时帮助执行其他可运行任务”的策略,或使用 continuation,把后续工作表示为依赖完成后的新任务。
十六、游戏引擎线程模型
一种常见但非唯一的结构:
Main/Game Thread
├── 输入、世界状态、玩法调度
├── 提交动画/物理/可见性任务
└── 生成渲染数据
Worker Threads
├── 动画采样
├── 物理子任务
├── 可见性和资源处理
└── 其他并行 Job
Render Thread
├── 消费渲染快照
├── 构建图形命令
└── 提交给 GPU
GPU
└── 执行更早提交的命令实际引擎可能合并或拆分这些线程。核心是明确每份数据在哪个阶段可写、何时转为只读,以及哪一个 Fence 表示可以复用。
十七、CPU 完成、提交完成与 GPU 完成
三个时刻不能混为一谈:
CPU 构建命令完成
↓
命令提交到图形 API
↓
GPU 稍后真正执行完成CPU 提交结束不代表 GPU 已经不再读取 Buffer。资源销毁和帧分配器复用必须等待正确的 GPU Fence 或使用多缓冲。
Fence 是 GPU 队列的完成进度标记
可以把 Fence 理解为 GPU 命令队列中的里程碑编号:
Draw Character
Draw Scene
Copy Texture
Signal Fence = 58
Post Process
Draw UI
Signal Fence = 59当 Completed Fence == 58 时,可以确定第一个标记之前的命令已经完成,但不能据此认为后处理和 UI 也已完成。Fence 表达的是某个队列推进到哪里,而不是整个 GPU 已经空闲。
提交端的概念代码如下:
queue.execute(commandList);
const std::uint64_t value = ++nextFenceValue;
queue.signal(fence, value);signal() 通常是把“执行到这里后推进 Fence”的操作追加到 GPU 队列。CPU 从 signal() 返回时,GPU 不一定已经到达该标记。
Fence 有三类基本操作:
// 在队列中提交进度标记
queue.signal(fence, 58);
// 非阻塞查询
const auto completed = fence.completedValue();
// 阻塞当前 CPU 线程,直到 GPU 完成到指定位置
fence.wait(58);常规资源回收通常采用查询,而不是在卸载时立即等待。立即 wait() 虽然可以保证正确性,却会破坏 CPU 与 GPU 的异步流水。程序退出、交换链重建、显存紧张或必须立即复用资源时,才可能需要显式等待。
Fence 如何参与延迟销毁
如果 Texture A 的最后一次使用属于 Fence 58 之前的提交,逻辑层可以立即让 Handle 失效,但底层 GPU 资源要进入延迟队列:
struct RetiredTexture {
GpuTexture texture;
std::uint64_t retireFence;
};每帧根据完成进度回收:
void collectRetiredTextures()
{
const auto completed = graphicsQueue.completedFence();
std::erase_if(retiredTextures,
[completed](RetiredTexture& item) {
if (item.retireFence > completed) {
return false;
}
item.texture.destroy();
return true;
});
}引擎负责建立资源与提交批次的对应关系。Fence 本身不知道 Texture A 是什么,也不会自动跟踪资源的最后一次使用。如果资源后来又被 Fence 61 的提交引用,只记录 58 仍会提前回收;正确的 retireFence 必须覆盖最后一次 GPU 使用。
这里要区分逻辑销毁和物理销毁:
逻辑销毁:Handle 立即失效,禁止产生新的使用
物理销毁:等待最后一次 GPU 使用完成,再释放底层资源Fence 不是 mutex
mutex:现在谁可以进入 CPU 临界区
Fence:此前提交的异步工作是否已经完成Fence 不会自动保护 CPU 容器,不会阻止其他线程修改资源管理状态,也不会替代引擎对 Handle、资源状态和队列依赖的管理。如果资源线程向渲染线程提交卸载请求,通常同时存在两层同步:CPU 线程之间使用线程安全队列或锁,渲染线程与 GPU 之间使用 Fence。
当多个 GPU Queue 都可能访问同一资源时,只检查某一个队列的 Fence 也可能不够。资源必须等待所有相关访问完成,或者通过明确的跨队列依赖把完成关系归并到可追踪的同步点。
CPU 对象存活不代表 GPU 已经使用完毕
shared_ptr<Texture> 可以保证 C++ 包装对象在 CPU 代码使用期间不被析构,但图形命令提交通常是异步的:
CPU 持有 shared_ptr 并提交 Draw
↓
CPU 释放最后一个 shared_ptr
↓
GPU 可能尚未执行 Draw如果析构函数此时立即释放底层 GPU Texture,GPU 仍可能访问已经回收的资源。引擎通常把真正的图形资源销毁放入延迟队列,并记录提交时对应的 Fence;只有 Fence 表明相关 GPU 工作完成后,资源才进入可回收状态。
因此需要区分两个生命周期:
- CPU 生命周期:C++ 对象、任务和指针是否仍然有效;
- GPU 生命周期:之前提交的图形命令是否还可能访问底层资源。
引用计数可以参与第一层管理,却不能代替第二层的 GPU 完成信号。
十八、任务捕获与生命周期
危险代码:
taskSystem.enqueue([this] {
updateResource();
});对象可能在任务执行前销毁。任务需要明确选择:
- 任务完成前所有者不能销毁;
- 捕获稳定 Handle,并在执行时验证;
- 使用
weak_ptr取得临时所有权; - 捕获任务需要的独立数据副本;
- 销毁时取消并等待任务;
- 把资源回收延迟到安全 Epoch 或 Fence。
十九、停止与退出协议
线程池关闭不能只设置一个布尔值。需要回答:
- 停止后是否接收新任务;
- 队列中已有任务是执行、取消还是丢弃;
- 正在运行的任务如何取消;
- 谁唤醒睡眠线程;
- 谁等待所有线程退出;
- 回调和完成事件是否仍会触发;
- 被任务引用的资源何时释放。
可靠顺序通常类似:
停止接收新任务
↓
请求取消或排空队列
↓
唤醒所有工作线程
↓
等待线程退出
↓
释放队列和共享资源二十、并发性能分析
关注:
- 工作线程利用率;
- Ready 但未运行的任务数量;
- 锁等待和 CAS 重试;
- 队列长度与任务耗时分布;
- False Sharing 与缓存抖动;
- 最慢任务形成的阶段尾部;
- 线程唤醒延迟;
- 主线程、渲染线程和 GPU 的互相等待。
不要只看总 CPU 使用率。高使用率可能来自自旋和争用,低使用率也可能是任务依赖或错误同步造成的空闲。
本章结论
- atomic 保证单个对象操作的原子性,不自动维护多字段不变量。
- Release/Acquire 用于发布普通数据,relaxed 只保证原子对象本身。
- 内存序是可证明的同步协议,不应为了“更快”随意减弱。
- Lock-free 不代表单线程必然有进展,也不代表比 mutex 快。
- 无锁结构的核心难题通常是节点生命周期和安全回收。
- False Sharing 是缓存行层面的共享写入竞争。
- 任务系统需要平衡调度成本、负载均衡和数据局部性。
- Task Graph 比全阶段 barrier 更能表达局部依赖。
- CPU 工作完成、图形命令提交和 GPU 执行完成是不同时间点。
- 停止、取消、等待和资源释放必须形成完整退出协议。
- Fence 是 GPU 队列的完成进度标记,不是 mutex,也不会自动追踪资源生命周期。
