跳转到内容

09 · 并发与多线程

定位:游戏引擎是典型的“多线程重灾区”——渲染线程、主线程、物理线程、job system。并发题是 C++ 面试第二梯队的大头(第一梯队是内存/多态),死锁、条件变量、内存序是高频三连。 学完标准:能讲清进程/线程区别;能手写死锁并说明避免方法;能解释 lock_guard/unique_lock 区别与虚假唤醒;能手写 C++11 线程安全单例;能说出线程池与 job system 是什么。


进程 Process 线程 Thread
资源 独立内存空间、独立文件描述符 共享所属进程的内存空间
切换开销 大(页表/缓存全换) 小(只换寄存器/栈)
通信 需要 IPC(管道/共享内存/消息队列) 直接共享变量(但要同步)
崩溃影响 进程间隔离,一个崩不影响另一个 一个线程崩(如段错误)整个进程崩
关系 一个进程至少一个主线程 线程是进程内最小执行单元

为什么游戏引擎用多线程而不是多进程? 共享内存(大世界数据、资源)用线程直接读写零拷贝;进程间通信开销太大、太慢。代价是必须处理数据竞争。

什么是并发 vs 并行? 并发 = 宏观上“同时”处理多件事(可单核时间片轮转);并行 = 微观上同时执行(多核同时跑)。游戏里二者都要:job system 把任务拆成可并行单元。


#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;
}

三条铁律

  1. 线程对象析构前必须 join()detach(),否则 std::terminate 崩溃。
  2. 传引用必须显式 std::refstd::thread t(f, std::ref(x));(直接传 x 是拷贝);同时要保证 x 的生命周期比线程长,否则引用悬垂。
  3. 把 lambda 丢进线程:std::thread t([] { ... });

数据竞争(data race):两个线程同时读同一变量(读读没事)→ 未定义行为,结果不可预测。解决办法就是下面的同步机制。


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)
场景 绝大多数加锁 需要手动控制/条件变量 同时锁多个互斥量

  1. 互斥(Mutual Exclusion):资源同一时刻只能被一个线程占用
  2. 占有且等待(Hold and Wait):拿着一个资源等另一个
  3. 不可剥夺(No Preemption):资源不能强行抢走
  4. 循环等待(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 → 循环等待 → 死锁
}
  1. 按固定顺序加锁:所有线程都先 a 后 b,循环等待消失
  2. 一次性拿所有锁std::scoped_lock(a, b) 内部用死锁避免算法(等价 std::lock)一次获取
  3. try_lock + 超时:拿不到就退让重试(std::try_lock
  4. 减少锁粒度/避免嵌套锁
  5. 破坏任意一个必要条件即可(最常见:破坏循环等待)

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 循环检查,天然免疫虚假唤醒。


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.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

  1. 现代 CPU 多核(8~16 核),单线程只用一个核
  2. 游戏主循环每帧有大量可并行的独立计算(动画、物理、粒子、AI)
  3. 拆成 job 后,引擎按核数动态调度,负载均衡
  4. 避免“每系统一个线程”的死锁/竞态地狱

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 而不是给每个系统开一个线程?

📖 答案(先自己答完再展开)
  1. 内存:进程独立地址空间,线程共享;崩溃:进程隔离(一个进程崩不影响别的),线程共享进程(一个段错误整个进程挂)。
  2. 互斥 + 占有且等待 + 不可剥夺 + 循环等待。死锁示例见第 4.2 节(两个线程反向拿两把锁)。
  3. wait 内部需要原子地“释放锁 + 挂起”,唤醒后重新上锁;lock_guard 不能手动 unlock,所以只有 unique_lock 能实现。
  4. 用 wait 的两参版本:cv.wait(ul, predicate),内部是 while (!pred) 循环,被唤醒后重新检查谓词,不满足继续等。
  5. atomic:单变量、高频计数、通常无锁且快;mutex:多行代码临界区、保护数据结构。
  6. static Singleton& instance() { static Singleton s; return s; } + 禁拷贝 + 私有构造。
  7. 工作线程数组 + 任务队列 + mutex + condition_variable + stop 标志;流程:enqueue 加锁入队 → notify → 工作线程 wait 被唤醒 → 取出任务执行 → 循环。
  8. 生产者的 release 保证“此前所有写”在消费者的 acquire 之后可见,解决“看到旧值/半成品”问题。
  9. 整个进程崩溃(线程共享进程地址空间;进程才是隔离单位,子进程互不影响)。
  10. job system 按核数动态调度小任务、负载均衡、避免每系统一线程的锁竞争与死锁;充分利用现代多核 CPU。

## 11. 进阶追问(答不上来就回来复习)
  • 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)?