09 · 并发与多线程
定位:游戏引擎是典型的“多线程重灾区”——渲染线程、主线程、物理线程、job system。并发题是 C++ 面试第二梯队的大头(第一梯队是内存/多态),死锁、条件变量、内存序是高频三连。 学完标准:能讲清进程/线程区别;能手写死锁并说明避免方法;能解释 lock_guard/unique_lock 区别与虚假唤醒;能手写 C++11 线程安全单例;能说出线程池与 job system 是什么。
1. 进程 vs 线程(必背)
Section titled “1. 进程 vs 线程(必背)”| 进程 Process | 线程 Thread | |
|---|---|---|
| 资源 | 独立内存空间、独立文件描述符 | 共享所属进程的内存空间 |
| 切换开销 | 大(页表/缓存全换) | 小(只换寄存器/栈) |
| 通信 | 需要 IPC(管道/共享内存/消息队列) | 直接共享变量(但要同步) |
| 崩溃影响 | 进程间隔离,一个崩不影响另一个 | 一个线程崩(如段错误)整个进程崩 |
| 关系 | 一个进程至少一个主线程 | 线程是进程内最小执行单元 |
为什么游戏引擎用多线程而不是多进程? 共享内存(大世界数据、资源)用线程直接读写零拷贝;进程间通信开销太大、太慢。代价是必须处理数据竞争。
什么是并发 vs 并行? 并发 = 宏观上“同时”处理多件事(可单核时间片轮转);并行 = 微观上同时执行(多核同时跑)。游戏里二者都要:job system 把任务拆成可并行单元。
2. std::thread 基础
Section titled “2. std::thread 基础”#include <thread>#include <iostream>
void worker(int id) { std::cout << "线程 " << id << "\n"; }
int main() { std::thread t(worker, 1); // 创建线程并传参 t.join(); // 等待 t 结束(阻塞) // t.detach(); // 分离:不等待,t 自己跑完。分离后不能再 join return 0;}三条铁律:
- 线程对象析构前必须
join()或detach(),否则std::terminate崩溃。 - 传引用必须显式 std::ref:
std::thread t(f, std::ref(x));(直接传 x 是拷贝);同时要保证 x 的生命周期比线程长,否则引用悬垂。 - 把 lambda 丢进线程:
std::thread t([] { ... });
数据竞争(data race):两个线程同时读写同一变量(读读没事)→ 未定义行为,结果不可预测。解决办法就是下面的同步机制。
3. 互斥锁 mutex 与 RAII 锁
Section titled “3. 互斥锁 mutex 与 RAII 锁”3.1 std::mutex 裸用(不推荐,容易忘解锁)
Section titled “3.1 std::mutex 裸用(不推荐,容易忘解锁)”#include <mutex>std::mutex m;int counter = 0;
void add() { m.lock(); ++counter; m.unlock(); // 中途 return/抛异常 → 忘解锁 → 其他线程永久阻塞!}3.2 lock_guard / unique_lock / scoped_lock(重点)
Section titled “3.2 lock_guard / unique_lock / scoped_lock(重点)”// lock_guard:构造加锁,析构自动解锁(RAII),最安全简单void add1() { std::lock_guard<std::mutex> lg(m); // 加锁 ++counter; // 无论怎么退出,析构自动解锁}
// unique_lock:功能更全,可手动 lock/unlock、可移动、可配条件变量void add2() { std::unique_lock<std::mutex> ul(m); ++counter; ul.unlock(); // 提前解锁 // 做一些不需要锁的事 ul.lock(); // 再锁上}
// scoped_lock(C++17):一次性锁多个互斥量,避免死锁void transfer(std::mutex& a, std::mutex& b) { std::scoped_lock lock(a, b); // 同时拿 a 和 b,内部保证不互相死锁 // 操作两个账户}三者对比(必背):
| lock_guard | unique_lock | scoped_lock | |
|---|---|---|---|
| 加锁方式 | 构造加锁 | 构造加锁 | 一次锁多个 |
| 手动解锁 | ❌ | ✅ | ❌ |
| 可移动 | ❌ | ✅ | ❌ |
| 配条件变量 | ❌(必须 unique_lock) | ✅ | ❌ |
| 场景 | 绝大多数加锁 | 需要手动控制/条件变量 | 同时锁多个互斥量 |
4. 死锁(高频必考)
Section titled “4. 死锁(高频必考)”4.1 四个必要条件(背)
Section titled “4.1 四个必要条件(背)”- 互斥(Mutual Exclusion):资源同一时刻只能被一个线程占用
- 占有且等待(Hold and Wait):拿着一个资源等另一个
- 不可剥夺(No Preemption):资源不能强行抢走
- 循环等待(Circular Wait):A 等 B 持有的,B 等 A 持有的
4.2 手写死锁(面试常让现场写)
Section titled “4.2 手写死锁(面试常让现场写)”std::mutex a, b;
void f1() { std::lock_guard<std::mutex> l1(a); std::this_thread::sleep_for(std::chrono::milliseconds(10)); // 制造冲突窗口 std::lock_guard<std::mutex> l2(b); // f1 拿 a 等 b // ...}
void f2() { std::lock_guard<std::mutex> l1(b); std::this_thread::sleep_for(std::chrono::milliseconds(10)); std::lock_guard<std::mutex> l2(a); // f2 拿 b 等 a → 循环等待 → 死锁}4.3 避免方法
Section titled “4.3 避免方法”- 按固定顺序加锁:所有线程都先 a 后 b,循环等待消失
- 一次性拿所有锁:
std::scoped_lock(a, b)内部用死锁避免算法(等价std::lock)一次获取 - try_lock + 超时:拿不到就退让重试(
std::try_lock) - 减少锁粒度/避免嵌套锁
- 破坏任意一个必要条件即可(最常见:破坏循环等待)
5. 条件变量 condition_variable
Section titled “5. 条件变量 condition_variable”5.1 典型场景:生产者-消费者
Section titled “5.1 典型场景:生产者-消费者”std::mutex m;std::condition_variable cv;std::queue<int> q;
// 生产者void producer() { for (int i = 0; i < 10; ++i) { { std::lock_guard<std::mutex> lg(m); q.push(i); } cv.notify_one(); // 通知一个等待者 }}
// 消费者void consumer() { while (true) { std::unique_lock<std::mutex> ul(m); cv.wait(ul, [] { return !q.empty(); }); // 等待直到谓词为真 int v = q.front(); q.pop(); // 处理 v }}5.2 为什么必须配 unique_lock?(必考)
Section titled “5.2 为什么必须配 unique_lock?(必考)”wait() 需要做到三件事的原子性:释放锁 → 挂起等待 → 被唤醒后重新获得锁。
std::condition_variable的 API 要求std::unique_lock(它能手动 unlock/lock,lock_guard不行);若用std::condition_variable_any,任何满足 BasicLockable 的锁都行。- wait(lock, pred) 内部:等价于
while (!pred()) { wait(lock); },单参 wait 内部原子地释放锁、休眠、醒来后重新上锁。
5.3 虚假唤醒(spurious wakeup)(必考)
Section titled “5.3 虚假唤醒(spurious wakeup)(必考)”被唤醒 ≠ 条件一定满足(操作系统/其他原因可能“空唤醒”)。
// ❌ 错误:只 wait 不带谓词 + 醒来后用 if 检查while (true) { cv.wait(ul); // 不带谓词 if (!q.empty()) { ... } // ← 错:虚假唤醒时 q 可能是空的}
// ✅ 正确:wait 带谓词的第二参数 = 内部自动 while 循环检查cv.wait(ul, [] { return !q.empty(); }); // 唤醒后检查不满足 → 继续睡口诀:wait 一定要带谓词(第二参数),内部是 while 循环检查,天然免疫虚假唤醒。
6. atomic 与内存序(进阶重点)
Section titled “6. atomic 与内存序(进阶重点)”6.1 atomic vs mutex
Section titled “6.1 atomic vs mutex”| std::atomic | std::mutex | |
|---|---|---|
| 粒度 | 单个变量 | 任意代码块(临界区) |
| 实现 | 通常无锁(CPU 原子指令/lock 前缀/CAS);标准不保证,大类型可能用内部锁 | 锁,可能系统调用/自旋 |
| 性能 | 快 | 慢 |
| 适用 | 计数器、标志位、共享小数据 | 保护多行代码/数据结构 |
std::atomic<int> counter{0};counter.fetch_add(1); // 原子 +1,等价 counter++int v = counter.load(); // 原子读counter.store(v + 1); // 原子写6.2 内存序(memory order)(高端题,能说出三档即可)
Section titled “6.2 内存序(memory order)(高端题,能说出三档即可)”问题:CPU 和编译器会重排指令(为了优化),多线程下重排会导致“先看到后发生的事”。
| 内存序 | 含义 | 用途 |
|---|---|---|
memory_order_relaxed |
只保证原子性,不保证顺序 | 单纯计数器(无所谓顺序) |
memory_order_acquire |
此操作之后的读写不能被重排到它前面 | 读锁/消费数据 |
memory_order_release |
此操作之前的读写不能被重排到它后面 | 写锁/发布数据 |
memory_order_seq_cst |
全程序全局一致顺序(默认) | 默认选择,最安全但最慢 |
典型 pairing:生产者 release 写,消费者 acquire 读——release 之前写的所有数据,acquire 之后都能看到(同步发生在同一原子变量上)。
std::atomic<bool> ready{false};std::string data;
// 线程 A(生产者)data = "hello"; // 普通写ready.store(true, std::memory_order_release); // 发布:保证 data 写在 ready 之前可见
// 线程 B(消费者)while (!ready.load(std::memory_order_acquire)) {} // 获取:保证能看到 data// 现在读 data 一定是 "hello"(如果没有 acquire/release,这里可能是空/乱序)面试话术:内存序控制“编译器/CPU 对指令的可见性顺序”;最常见的是默认的 seq_cst(最强一致),性能敏感处用 acquire/release 配对,计数器用 relaxed。
7. 线程安全单例(高频手写)
Section titled “7. 线程安全单例(高频手写)”7.1 懒汉模式的坑(DCLP 双重检查锁)
Section titled “7.1 懒汉模式的坑(DCLP 双重检查锁)”// ❌ 线程不安全:两个线程同时进入 if,各 new 一个Singleton* getInstance() { if (instance == nullptr) instance = new Singleton(); return instance;}
// ⚠️ 双重检查锁 DCLP(C++11 前有坑)Singleton* getInstance() { if (instance == nullptr) { // 第一重检查 std::lock_guard<std::mutex> lg(mutex_); if (instance == nullptr) { // 第二重检查 instance = new Singleton(); // 坑:new 分三步(分配/构造/赋值), } // 赋值可能先于构造 → 另一个线程拿到未构造完的对象 } return instance;}DCLP 的坑:instance = new Singleton() 在编译器看来是「分配内存 → 赋值指针 → 构造」可能乱序,另一个线程可能在构造完成前就看到了非空指针。C++11 前需要各种技巧;C++11 后直接:
7.2 C++11 正确写法:Magic Static(重点)
Section titled “7.2 C++11 正确写法:Magic Static(重点)”class Singleton {public: static Singleton& instance() { static Singleton inst; // 局部静态变量 return inst; // C++11 保证:首次初始化时线程安全(编译器插入同步) } Singleton(const Singleton&) = delete; Singleton& operator=(const Singleton&) = delete;private: Singleton() = default;};原理:C++11 标准规定,局部 static 变量的初始化是线程安全的(常见实现用 guard 变量同步;初始化完成后通常只剩廉价检查)。这是现代 C++ 的默认推荐写法。
面试金句:C++11 后写单例优先用 Magic Static;它由标准保证线程安全,零手写锁,比 DCLP 简单且无坑。性能上也只在首次初始化时有同步开销。
8. 线程池与 Job System(游戏引擎核心概念)
Section titled “8. 线程池与 Job System(游戏引擎核心概念)”8.1 线程池是什么?为什么需要?
Section titled “8.1 线程池是什么?为什么需要?”问题:每次任务都 new std::thread 的代价:创建/销毁线程开销大(系统调用 + 栈分配),线程数失控。
线程池:启动时创建固定数量的工作线程,它们阻塞在一个任务队列上,有任务就取来执行,没任务就睡。
// 核心组件(理解结构即可,第 14 章有完整实现题)class ThreadPool { std::vector<std::thread> workers_; // 工作线程 std::queue<std::function<void()>> tasks_; // 任务队列 std::mutex m_; std::condition_variable cv_; bool stop_ = false;public: void enqueue(std::function<void()> task); // 提交任务 → 入队 → notify ~ThreadPool(); // 析构:置 stop_ → 唤醒全部 → join};8.2 游戏引擎的 Job System(加分项)
Section titled “8.2 游戏引擎的 Job System(加分项)”| 概念 | 说明 |
|---|---|
| Job/Task | 一个相对独立的小任务(如“更新这 100 个敌人 AI”) |
| Job System | 把大任务拆成互相独立的小 job,丢进多线程队列并行执行 |
| 依赖 | 有的 job 需要等别的 job 完成(依赖图) |
| 例子 | UE 的 TaskGraph、Unity 的 Job System + Burst |
为什么游戏用 job system:
- 现代 CPU 多核(8~16 核),单线程只用一个核
- 游戏主循环每帧有大量可并行的独立计算(动画、物理、粒子、AI)
- 拆成 job 后,引擎按核数动态调度,负载均衡
- 避免“每系统一个线程”的死锁/竞态地狱
9. 高频面试题 Q&A(合上书能讲)
Section titled “9. 高频面试题 Q&A(合上书能讲)”Q1:进程和线程的区别? 进程有独立内存空间、隔离性好、切换开销大;线程共享进程内存、切换快、一个崩全崩。游戏引擎用线程共享内存做数据传递,用 job system 并行化。
Q2:死锁的四个必要条件?怎么避免? 互斥、占有且等待、不可剥夺、循环等待。避免:按固定顺序加锁、scoped_lock 一次拿全部、try_lock 超时退让、减少嵌套锁。
Q3:lock_guard 和 unique_lock 的区别? lock_guard 构造加锁析构解锁,不能手动控制;unique_lock 可手动 lock/unlock、可移动、必须配条件变量(wait 需要原子地释放锁)。scoped_lock 可一次锁多个。
Q4:条件变量为什么要配 unique_lock?什么是虚假唤醒?
wait 内部需要“释放锁 → 挂起 → 重新上锁”,std::condition_variable 的 API 要求 unique_lock(lock_guard 不行;condition_variable_any 可用其他可锁类型);虚假唤醒 = 被唤醒但条件未满足,所以 wait 必须带谓词(内部 while 循环),避免空队列取数据。
Q5:atomic 和 mutex 的区别? atomic 是单变量原子操作(整型/指针在主流平台通常无锁,但标准不保证),快;mutex 保护任意临界区,慢。多行数据用 mutex,单变量/计数器用 atomic。
Q6:什么是内存序?简单说三种。 控制指令可见性顺序,防 CPU/编译器重排导致的问题。relaxed 只保证原子性(计数器);acquire/release 配对保证“发布前的数据发布后可见”(生产者-消费者);seq_cst 全局强一致(默认,最安全)。
Q7:C++11 怎么写线程安全单例?为什么不用 DCLP?
局部 static 变量(Magic Static),标准保证首次初始化线程安全。DCLP 的 new 可能乱序导致拿到未构造完的对象,C++11 前难修,现在没必要用。
Q8:线程池是什么?游戏 job system 是干嘛的? 线程池 = 固定工作线程 + 任务队列 + 条件变量,复用线程避免创建开销。job system = 把游戏任务拆成小 job 并行执行,充分利用多核,UE TaskGraph 就是例子。
Q9:std::thread 传引用要注意什么?
要显式 std::ref(x),否则线程拿到的是拷贝;线程析构前必须 join 或 detach,否则 terminate。
Q10:什么是数据竞争?读读算吗? 两个线程并发访问同一内存、至少一个是写、且无同步 → 未定义行为。读读不算竞争,读写/写写算。
10. 本章自测(10 题,限时 20 分钟,先自己答再看答案)
Section titled “10. 本章自测(10 题,限时 20 分钟,先自己答再看答案)”1. 进程和线程的本质区别是什么(内存与崩溃两个角度)?
2. 死锁四条件是什么?
我的原答:手写一个死锁场景。
3. 为什么 wait 必须用 unique_lock 而 lock_guard 不行?
4. 虚假唤醒怎么防?
我的原答:写正确的 wait 用法。
5. atomic 和 mutex 各适合什么场景?
6. 手写 C++11 线程安全单例(Magic Static 版)。
7. 线程池由哪几个核心组件构成?
我的原答:任务提交流程?
8. `memory_order_release` / `acquire` 配对解决什么问题?
9. 一个线程崩溃,进程里的其他线程会怎样?为什么?
10. 游戏引擎为什么要用 job system 而不是给每个系统开一个线程?
📖 答案(先自己答完再展开)
- 内存:进程独立地址空间,线程共享;崩溃:进程隔离(一个进程崩不影响别的),线程共享进程(一个段错误整个进程挂)。
- 互斥 + 占有且等待 + 不可剥夺 + 循环等待。死锁示例见第 4.2 节(两个线程反向拿两把锁)。
- wait 内部需要原子地“释放锁 + 挂起”,唤醒后重新上锁;lock_guard 不能手动 unlock,所以只有 unique_lock 能实现。
- 用 wait 的两参版本:
cv.wait(ul, predicate),内部是 while (!pred) 循环,被唤醒后重新检查谓词,不满足继续等。 - atomic:单变量、高频计数、通常无锁且快;mutex:多行代码临界区、保护数据结构。
static Singleton& instance() { static Singleton s; return s; }+ 禁拷贝 + 私有构造。- 工作线程数组 + 任务队列 + mutex + condition_variable + stop 标志;流程:enqueue 加锁入队 → notify → 工作线程 wait 被唤醒 → 取出任务执行 → 循环。
- 生产者的 release 保证“此前所有写”在消费者的 acquire 之后可见,解决“看到旧值/半成品”问题。
- 整个进程崩溃(线程共享进程地址空间;进程才是隔离单位,子进程互不影响)。
- job system 按核数动态调度小任务、负载均衡、避免每系统一线程的锁竞争与死锁;充分利用现代多核 CPU。
std::atomic的 fetch_add / CAS(compare_exchange)分别是什么?CAS 能实现什么(自旋锁、无锁队列)?- 自旋锁 vs 互斥锁:什么时候自旋更快?(临界区极短时)
std::async/std::future/std::promise怎么用?和手动线程的区别?- 线程安全的 shared_ptr 计数器是怎么做到无锁的?(引用计数用原子操作)
- 什么是协程?和线程池的关系?(见 08 章协程)
- 面试手撕:用条件变量实现一个“阻塞队列”(第 14 章会收录)。
- 死锁检测:Linux 下怎么排查(gdb thread apply、pstack、perf)?