Appearance
多线程
进程和线程区别是什么?
一句话理解: 进程是“资源分配的基本单位”,线程是“CPU 调度执行的基本单位”。
零基础理解 进程像一间独立的房子。每个房子有自己的空间、家具、水电表,默认不能随便进别人家。 线程像同一间房子里的多个工人。工人各自干活,但共用同一间房子的东西。
所以:
进程之间:默认内存隔离,更安全,但通信麻烦
线程之间:共享内存,通信方便,但容易抢同一份数据核心区别表
| 对比点 | 进程 | 线程 |
|---|---|---|
| 本质 | 运行中的程序实例 | 进程内的一条执行流 |
| 资源 | 有独立地址空间和资源 | 共享所属进程资源 |
| 内存 | 进程之间默认隔离 | 同进程线程共享堆、全局变量 |
| 通信 | IPC,如管道、共享内存、socket | 共享变量即可,但要加锁 |
| 创建/切换成本 | 较高 | 较低 |
| 崩溃影响 | 隔离性较好 | 一个线程出错可能影响整个进程 |
线程共享内存的代码例子
c
#include <iostream> // 引入输入输出库,用来使用 cout
#include <thread> // 引入线程库,用来创建 std::thread
#include <mutex> // 引入互斥锁库,用来保护共享数据
using namespace std; // 使用标准命名空间,避免重复写 std::
int counter = 0; // 定义全局变量,多个线程会共享它
mutex counterMutex; // 定义互斥锁,用来保护 counter
void AddCounter() { // 定义线程要执行的函数
for (int i = 0; i < 1000; i++) { // 循环 1000 次,模拟多次修改共享数据
lock_guard<mutex> lock(counterMutex); // 构造时加锁,离开本轮作用域自动解锁
counter++; // 修改共享变量,必须在锁保护下进行
} // for 循环结束
} // AddCounter 函数结束
int main() { // 程序入口函数
thread t1(AddCounter); // 创建线程 t1,让它执行 AddCounter
thread t2(AddCounter); // 创建线程 t2,让它也执行 AddCounter
t1.join(); // 等待线程 t1 执行结束
t2.join(); // 等待线程 t2 执行结束
cout << counter << endl; // 输出最终结果,理论上是 2000
return 0; // 程序正常结束
} // main 函数结束这个例子说明:线程共享同一个 counter,所以必须加锁。 如果不加锁,两个线程可能同时改 counter,结果就不可靠。
进程为什么更“隔离”? 每个进程有自己的虚拟地址空间。进程 A 里的普通变量,进程 B 默认访问不到。 这让进程更安全:一个进程崩溃,通常不会直接破坏另一个进程的内存。
但代价是:进程之间想通信更麻烦,要用 IPC:
管道
消息队列
共享内存
socket
文件线程为什么更“轻”? 同一个进程里的线程共享大部分资源,比如:
代码段
堆内存
全局变量
打开的文件句柄
进程资源每个线程自己独有的主要是:
线程栈
寄存器上下文
程序计数器
线程局部存储因为共享资源多,所以线程创建和切换通常比进程轻。 但也因为共享资源多,线程同步更容易出问题。
NOTE
面试高分回答 进程是操作系统进行资源分配和保护的基本单位,每个进程有独立的虚拟地址空间、资源句柄和运行环境。线程是 CPU 调度执行的基本单位,一个进程可以包含多个线程,同一进程内的线程共享进程的内存和资源,但每个线程有自己的栈、寄存器上下文和执行路径。进程之间隔离性强,通信需要 IPC,创建和切换成本相对更高;线程之间通信方便,创建和切换成本更低,但共享数据需要同步,否则会产生数据竞争。简单说:进程重在资源隔离,线程重在并发执行。
互斥锁是什么?
一句话理解: 互斥锁 mutex 是用来保护共享资源的锁:同一时间只允许一个线程进入被保护的代码区域,避免多个线程同时修改同一份数据。
零基础理解 假设有两个线程同时改一个变量:
c
counter++;你可能以为这是一行代码,所以很安全。 但底层大概不是一步完成,而是:
读取 counter
把值加 1
写回 counter如果线程 A 和线程 B 同时做这三步,就可能发生“你刚写,我又覆盖掉你”的情况,这叫数据竞争。
互斥锁就是给共享数据加一道门:
线程先拿锁
拿到锁才能修改共享数据
修改完释放锁
下一个线程才能进来不加锁的危险例子
c
#include <iostream> // 引入输入输出库,用来使用 cout
#include <thread> // 引入线程库,用来创建 thread
using namespace std; // 使用标准命名空间,避免重复写 std::
int counter = 0; // 定义共享变量,两个线程都会修改它
void AddCounter() { // 定义线程要执行的函数
for (int i = 0; i < 100000; i++) { // 循环很多次,让问题更容易出现
counter++; // 不加锁地修改共享变量,这里有数据竞争
} // for 循环结束
} // AddCounter 函数结束
int main() { // 程序入口函数
thread t1(AddCounter); // 创建线程 t1,让它执行 AddCounter
thread t2(AddCounter); // 创建线程 t2,让它也执行 AddCounter
t1.join(); // 等待线程 t1 执行结束
t2.join(); // 等待线程 t2 执行结束
cout << counter << endl; // 输出结果,可能不是 200000
return 0; // 程序正常结束
} // main 函数结束这段代码理论上希望输出:
200000但实际可能不是,因为两个线程同时修改 counter,产生了数据竞争。
用 mutex 修正
c
#include <iostream> // 引入输入输出库,用来使用 cout
#include <thread> // 引入线程库,用来创建 thread
#include <mutex> // 引入互斥锁库,用来使用 mutex 和 lock_guard
using namespace std; // 使用标准命名空间,避免重复写 std::
int counter = 0; // 定义共享变量,多个线程都会访问它
mutex counterMutex; // 定义互斥锁,用来保护 counter
void AddCounter() { // 定义线程要执行的函数
for (int i = 0; i < 100000; i++) { // 循环很多次,模拟大量并发修改
lock_guard<mutex> lock(counterMutex); // 构造时自动加锁,离开作用域自动解锁
counter++; // 在锁保护下修改共享变量,所以是安全的
} // for 循环结束,本轮 lock 离开作用域并自动解锁
} // AddCounter 函数结束
int main() { // 程序入口函数
thread t1(AddCounter); // 创建线程 t1,让它执行 AddCounter
thread t2(AddCounter); // 创建线程 t2,让它也执行 AddCounter
t1.join(); // 等待线程 t1 执行结束
t2.join(); // 等待线程 t2 执行结束
cout << counter << endl; // 输出结果,通常会是 200000
return 0; // 程序正常结束
} // main 函数结束为什么推荐 lock_guard? 你也可以手动写:
c
counterMutex.lock();
counter++;
counterMutex.unlock();但这种写法有风险:中间如果 return 或抛异常,unlock() 可能执行不到。
推荐用:
c
lock_guard<mutex> lock(counterMutex);它是 RAII 思想:
构造时加锁
析构时解锁所以更安全。
互斥锁保护的是什么? 互斥锁本身不是保护变量,而是保护一段代码区域,这段区域叫临界区。
c
{
lock_guard<mutex> lock(counterMutex);
counter++;
}这一块就是临界区。 同一时间只能有一个线程进入这个区域。
常见使用场景
| 场景 | 为什么需要锁 |
|---|---|
| 多线程修改全局变量 | 防止数据竞争 |
多线程操作同一个 vector / map | STL 容器通常不保证并发写安全 |
| 任务队列 | 一个线程 push,另一个线程 pop |
| 日志系统 | 多线程同时写日志会乱 |
| 游戏资源状态 | 加载线程和主线程可能访问同一份状态 |
常见坑
锁的范围不要太大。
锁住的代码越多,其他线程等待越久,性能越差。
不要忘记解锁。
所以推荐 `lock_guard` 或 `unique_lock`。
小心死锁。
比如线程 A 拿了锁 1 等锁 2,线程 B 拿了锁 2 等锁 1,两个线程互相等,就卡死了。
不要以为加锁会让程序更快。
锁的目标首先是正确性,不是性能。锁本身也有开销。NOTE
面试高分回答 互斥锁是多线程同步机制,用来保护共享资源或临界区。同一时间只有一个线程能成功持有互斥锁,进入临界区访问共享数据,其他线程必须等待。它可以避免多个线程同时读写同一份数据导致的数据竞争。C++ 中常用 std::mutex,实际写代码时通常不直接手动 lock/unlock,而是使用 std::lock_guard 或 std::unique_lock,利用 RAII 保证作用域结束时自动解锁,避免异常或提前返回导致忘记释放锁。互斥锁的代价是线程等待、上下文切换和可能的死锁,所以临界区要尽量短,锁的粒度要合理。
死锁是什么?如何避免?
一句话理解: 死锁就是多个线程互相等对方手里的锁,谁也不肯放,谁也继续不了,程序就卡住了。
零基础理解 想象有两把钥匙:
锁 1
锁 2线程 A 拿到了锁 1,然后想要锁 2。 线程 B 拿到了锁 2,然后想要锁 1。
结果:
线程 A:你先给我锁 2
线程 B:你先给我锁 1两边都等着对方释放,但都不释放自己手里的锁,于是卡死。
错误示例:很典型的死锁
c
#include <iostream> // 引入输入输出库,用来使用 cout
#include <thread> // 引入线程库,用来创建 thread
#include <mutex> // 引入互斥锁库,用来使用 mutex 和 lock_guard
using namespace std; // 使用标准命名空间,避免重复写 std::
mutex mutexA; // 定义第一把互斥锁
mutex mutexB; // 定义第二把互斥锁
void ThreadFunc1() { // 定义线程 1 要执行的函数
lock_guard<mutex> lock1(mutexA); // 线程 1 先锁住 mutexA
this_thread::sleep_for(chrono::milliseconds(100)); // 故意睡一会儿,让线程 2 有机会锁住 mutexB
lock_guard<mutex> lock2(mutexB); // 线程 1 再尝试锁住 mutexB,可能卡在这里
cout << "线程 1 完成" << endl; // 如果两把锁都拿到,输出完成信息
} // ThreadFunc1 函数结束
void ThreadFunc2() { // 定义线程 2 要执行的函数
lock_guard<mutex> lock1(mutexB); // 线程 2 先锁住 mutexB
this_thread::sleep_for(chrono::milliseconds(100)); // 故意睡一会儿,让线程 1 有机会锁住 mutexA
lock_guard<mutex> lock2(mutexA); // 线程 2 再尝试锁住 mutexA,可能卡在这里
cout << "线程 2 完成" << endl; // 如果两把锁都拿到,输出完成信息
} // ThreadFunc2 函数结束
int main() { // 程序入口函数
thread t1(ThreadFunc1); // 创建线程 1
thread t2(ThreadFunc2); // 创建线程 2
t1.join(); // 等待线程 1 结束,但可能因为死锁永远等不到
t2.join(); // 等待线程 2 结束,但可能因为死锁永远等不到
return 0; // 程序正常结束
} // main 函数结束这段代码危险点在于:
线程 1:先 A 后 B
线程 2:先 B 后 A加锁顺序不一致,就容易形成环形等待。
死锁的四个必要条件 面试里经常问“死锁产生条件”,可以这样答:
| 条件 | 含义 |
|---|---|
| 互斥 | 一个资源同一时间只能被一个线程占有 |
| 持有并等待 | 线程拿着一个资源,同时等待另一个资源 |
| 不可抢占 | 别的线程不能强制抢走它已经拿到的资源 |
| 循环等待 | A 等 B,B 等 C,C 又等 A |
只要破坏其中一个条件,就能避免死锁。 实际项目里最常破坏的是:
循环等待也就是:规定统一加锁顺序。
避免方法 1:固定加锁顺序
所有线程都按照同样顺序拿锁,比如永远先拿 mutexA,再拿 mutexB。
c
#include <iostream> // 引入输入输出库,用来使用 cout
#include <thread> // 引入线程库,用来创建 thread
#include <mutex> // 引入互斥锁库,用来使用 mutex 和 lock_guard
using namespace std; // 使用标准命名空间
mutex mutexA; // 定义第一把互斥锁
mutex mutexB; // 定义第二把互斥锁
void SafeFunc1() { // 定义安全函数 1
lock_guard<mutex> lock1(mutexA); // 永远先锁 mutexA
lock_guard<mutex> lock2(mutexB); // 再锁 mutexB
cout << "SafeFunc1 完成" << endl; // 输出完成信息
} // SafeFunc1 函数结束
void SafeFunc2() { // 定义安全函数 2
lock_guard<mutex> lock1(mutexA); // 也先锁 mutexA,保持顺序一致
lock_guard<mutex> lock2(mutexB); // 再锁 mutexB,保持顺序一致
cout << "SafeFunc2 完成" << endl; // 输出完成信息
} // SafeFunc2 函数结束
int main() { // 程序入口函数
thread t1(SafeFunc1); // 创建线程 1
thread t2(SafeFunc2); // 创建线程 2
t1.join(); // 等待线程 1 执行结束
t2.join(); // 等待线程 2 执行结束
return 0; // 程序正常结束
} // main 函数结束重点是:
所有地方都按同一个锁顺序这样不会出现 A 等 B、B 又等 A 的环。
避免方法 2:用 std::scoped_lock 一次锁多把锁
C++17 提供了 std::scoped_lock,可以一次锁多个 mutex,并帮助避免死锁。
c
#include <iostream> // 引入输入输出库
#include <thread> // 引入线程库
#include <mutex> // 引入 mutex 和 scoped_lock
using namespace std; // 使用标准命名空间
mutex mutexA; // 定义第一把互斥锁
mutex mutexB; // 定义第二把互斥锁
void SafeFunc() { // 定义线程函数
scoped_lock lock(mutexA, mutexB); // 一次性锁住两把锁,并自动避免常见死锁
cout << "安全地拿到两把锁" << endl; // 输出提示信息
} // SafeFunc 函数结束,lock 自动析构并解锁两把锁
int main() { // 程序入口函数
thread t1(SafeFunc); // 创建线程 1
thread t2(SafeFunc); // 创建线程 2
t1.join(); // 等待线程 1 结束
t2.join(); // 等待线程 2 结束
return 0; // 程序正常结束
} // main 函数结束能用 scoped_lock 的时候,优先用它,比自己手写多把锁更稳。
避免方法 3:减少锁的范围
锁住的代码越多,风险越高,性能也越差。 应该只锁真正访问共享数据的那一小段。
c
#include <vector> // 引入 vector 容器
#include <mutex> // 引入 mutex 和 lock_guard
using namespace std; // 使用标准命名空间
vector<int> values; // 定义共享容器
mutex valuesMutex; // 定义保护容器的互斥锁
void AddValue(int value) { // 定义添加数据的函数
int temp = value * 2; // 不访问共享数据,不需要加锁
{ // 创建一个小作用域,用来缩短锁的生命周期
lock_guard<mutex> lock(valuesMutex); // 只在访问共享容器时加锁
values.push_back(temp); // 修改共享容器
} // 小作用域结束,lock 自动解锁
} // AddValue 函数结束好习惯是:
能不锁就不锁
必须锁就锁短一点避免方法 4:拿不到锁就放弃或重试
有时可以用 try_lock,拿不到就不一直等。
c
#include <iostream> // 引入输入输出库
#include <mutex> // 引入 mutex
using namespace std; // 使用标准命名空间
mutex dataMutex; // 定义保护数据的互斥锁
void TryUpdate() { // 定义尝试更新函数
if (dataMutex.try_lock()) { // 尝试加锁,成功返回 true,失败不会阻塞
cout << "拿到锁,开始更新" << endl; // 输出成功信息
dataMutex.unlock(); // 手动解锁,因为 try_lock 成功后必须释放
} else { // 如果没有拿到锁
cout << "没拿到锁,本次跳过" << endl; // 输出跳过信息
} // if 结束
} // TryUpdate 函数结束这个方式适合某些“可以晚点再做”的任务,比如后台刷新、非关键统计。
常见避免原则
固定锁顺序。
多把锁时,所有线程必须按同一个顺序加锁。
使用 RAII。
用 `lock_guard`、`unique_lock`、`scoped_lock`,不要裸写 `lock/unlock`。
临界区尽量短。
不要持锁做耗时操作,比如 IO、网络请求、复杂回调。
避免持锁调用外部代码。
外部代码里可能又去拿别的锁,容易引入隐藏死锁。
能用无锁或消息队列,就减少共享状态。
比如游戏里常见:工作线程把结果投递给主线程,不直接乱改主线程数据。IMPORTANT
面试高分回答 死锁是指多个线程因为竞争资源而互相等待,形成环形等待,导致线程都无法继续执行。典型例子是线程 A 持有锁 1 等锁 2,线程 B 持有锁 2 等锁 1。死锁通常需要四个条件:互斥、持有并等待、不可抢占、循环等待。避免死锁的核心是破坏其中一个条件,实际开发中最常用的是破坏循环等待,也就是规定统一的加锁顺序;还可以使用 std::scoped_lock 或 std::lock 一次安全地获取多把锁,减少手写多锁逻辑;同时要缩小临界区,避免持锁做耗时操作或调用外部代码。简单说,死锁的本质是“锁的环形等待”,避免死锁的核心就是不要让这个环形成。
条件变量是什么?
一句话理解: 条件变量 condition_variable 是线程同步工具:当条件不满足时,让线程睡眠等待;当别的线程把条件改满足后,再通知它醒来继续执行。
零基础理解 互斥锁 mutex 解决的是:
同一时间只能一个线程访问共享数据条件变量解决的是:
条件没满足时,线程别一直傻等,先睡觉
条件满足后,别的线程通知它醒来比如一个任务队列:
队列为空:消费者线程没活干,应该睡眠
队列有任务:生产者线程通知消费者醒来处理为什么不用 while 一直检查? 如果这样写:
while (queue.empty()) {
// 一直检查队列有没有任务
}这叫忙等。它会一直占 CPU,但什么正事都没干。
条件变量的思路是:
队列为空 -> 消费者睡眠
生产者放入任务 -> notify
消费者醒来 -> 重新检查队列 -> 取任务完整生产者消费者示例
c
#include <condition_variable> // 引入 condition_variable,用来让线程等待和唤醒
#include <iostream> // 引入 cout,用来输出信息
#include <mutex> // 引入 mutex 和 unique_lock,用来保护共享数据
#include <queue> // 引入 queue,用来保存任务
#include <thread> // 引入 thread,用来创建线程
using namespace std; // 使用标准命名空间,避免重复写 std::
queue<int> tasks; // 定义任务队列,生产者放任务,消费者取任务
mutex tasksMutex; // 定义互斥锁,用来保护任务队列
condition_variable tasksCv; // 定义条件变量,用来等待“队列不为空”
bool finished = false; // 定义结束标记,用来告诉消费者没有更多任务了
void Producer() { // 定义生产者线程函数
for (int i = 1; i <= 5; i++) { // 循环生产 5 个任务
{ // 创建小作用域,让锁尽快释放
lock_guard<mutex> lock(tasksMutex); // 加锁,保护任务队列
tasks.push(i); // 把任务放入队列
cout << "生产任务: " << i << endl; // 输出生产信息
} // 小作用域结束,lock_guard 析构,自动解锁
tasksCv.notify_one(); // 通知一个等待的消费者:可能有任务了
} // for 循环结束
{ // 创建小作用域,准备修改结束标记
lock_guard<mutex> lock(tasksMutex); // 加锁,保护 finished 标记
finished = true; // 设置结束标记,表示生产者不再生产任务
} // 小作用域结束,自动解锁
tasksCv.notify_all(); // 唤醒所有消费者,让它们检查是否应该退出
} // Producer 函数结束
void Consumer() { // 定义消费者线程函数
while (true) { // 消费者持续工作,直到没有任务且生产结束
unique_lock<mutex> lock(tasksMutex); // 加锁,并且必须用 unique_lock 配合 condition_variable
tasksCv.wait(lock, [] { // 等待条件成立,wait 内部会自动释放锁并睡眠
return !tasks.empty() || finished; // 条件是:队列有任务,或者生产已经结束
}); // wait 返回时会重新拿到锁,并且条件已经重新检查过
if (tasks.empty() && finished) { // 如果队列为空且生产结束
break; // 退出 while 循环,消费者结束
} // if 结束
int task = tasks.front(); // 取出队首任务
tasks.pop(); // 从队列中删除这个任务
lock.unlock(); // 提前解锁,让其他线程可以继续访问队列
cout << "消费任务: " << task << endl; // 输出消费信息
} // while 循环结束
} // Consumer 函数结束
int main() { // 程序入口函数
thread producer(Producer); // 创建生产者线程
thread consumer(Consumer); // 创建消费者线程
producer.join(); // 等待生产者线程结束
consumer.join(); // 等待消费者线程结束
return 0; // 程序正常结束
} // main 函数结束wait 到底做了什么? 这一句很关键:
c
tasksCv.wait(lock, [] { return !tasks.empty() || finished; });它大概做了这些事:
先检查条件是否成立
如果不成立,就释放 mutex
然后当前线程进入睡眠
被 notify 唤醒后,重新抢 mutex
抢到 mutex 后,再检查条件
条件成立才继续往下执行所以 condition_variable 必须和 mutex 配合用。 因为条件本身通常依赖共享数据,比如:
队列是否为空
任务是否完成
资源是否加载好这些共享数据必须被锁保护。
为什么 wait 要用 unique_lock,不用 lock_guard? 因为 wait 需要临时释放锁,再在醒来时重新加锁。
lock_guard 太简单,只负责构造加锁、析构解锁,不能中途手动解锁再上锁。 unique_lock 更灵活,支持:
c
unlock
lock
被 condition_variable::wait 控制所以条件变量常见写法是:
c
unique_lock<mutex> lock(mtx);
cv.wait(lock, predicate);为什么要写 predicate 条件? 因为条件变量可能出现虚假唤醒。
虚假唤醒意思是:
线程可能没有真正等到条件满足,也从 wait 返回了所以不能这样写:
cv.wait(lock); // 不推荐单独这样用,容易忘记重新判断条件推荐这样写:
c
cv.wait(lock, [] { return 条件成立; });它内部会帮你反复检查条件,效果类似:
c
while (!条件成立) {
cv.wait(lock);
}notify_one 和 notify_all 区别
| 函数 | 含义 | 适合场景 |
|---|---|---|
notify_one() | 唤醒一个等待线程 | 一个任务只需要一个消费者处理 |
notify_all() | 唤醒所有等待线程 | 状态变化影响所有线程,比如退出、配置变化 |
注意:notify 只是叫醒线程,不代表线程马上执行。 被叫醒的线程还要重新竞争 mutex。
常见坑
WARNING
面试高分回答 条件变量是线程同步机制,用来让线程在某个条件不满足时阻塞等待,并在条件可能满足时由其他线程通知唤醒。它通常和 mutex 配合使用,因为条件往往依赖共享数据,必须在锁保护下检查和修改。wait 的关键行为是:如果条件不满足,它会原子地释放互斥锁并让线程睡眠;被 notify_one 或 notify_all 唤醒后,会重新获取锁并重新检查条件。实际写法推荐使用 cv.wait(lock, predicate),因为存在虚假唤醒,不能认为被唤醒就一定条件成立。简单说,mutex 负责保护共享数据,condition_variable 负责在条件不满足时让线程高效等待。
原子变量是什么?
一句话理解: 原子变量就是多线程环境下“单次操作不可被打断”的变量。C++ 里用 std::atomic<T> 表示,它可以避免多个线程同时读写同一个简单变量时产生数据竞争。
零基础理解 普通的:
counter++;看起来是一行,但底层大概是三步:
读取 counter
加 1
写回 counter如果两个线程同时执行,就可能这样:
线程 A 读到 0
线程 B 也读到 0
线程 A 写回 1
线程 B 也写回 1明明加了两次,结果却是 1,这就是数据竞争。
原子变量就是让这个操作变成:
读 + 改 + 写,一口气完成,中间不让别的线程插队不使用 atomic 的错误例子
c
#include <iostream> // 引入输入输出库,用来使用 cout
#include <thread> // 引入线程库,用来创建 thread
using namespace std; // 使用标准命名空间,避免重复写 std::
int counter = 0; // 定义普通整数,多个线程会同时修改它
void AddCounter() { // 定义线程要执行的函数
for (int i = 0; i < 100000; i++) { // 循环很多次,让并发问题更容易出现
counter++; // 普通变量自增,不是原子操作,会产生数据竞争
} // for 循环结束
} // AddCounter 函数结束
int main() { // 程序入口函数
thread t1(AddCounter); // 创建线程 t1,让它执行 AddCounter
thread t2(AddCounter); // 创建线程 t2,让它也执行 AddCounter
t1.join(); // 等待线程 t1 执行结束
t2.join(); // 等待线程 t2 执行结束
cout << counter << endl; // 输出结果,可能不是 200000
return 0; // 程序正常结束
} // main 函数结束这个结果可能小于 200000,因为 counter++ 不是原子操作。
使用 atomic 修正
c
#include <atomic> // 引入 atomic 原子变量
#include <iostream> // 引入输入输出库,用来使用 cout
#include <thread> // 引入线程库,用来创建 thread
using namespace std; // 使用标准命名空间,避免重复写 std::
atomic<int> counter = 0; // 定义原子整数,多个线程同时修改也不会数据竞争
void AddCounter() { // 定义线程要执行的函数
for (int i = 0; i < 100000; i++) { // 循环很多次,模拟并发自增
counter++; // 原子自增,读-改-写作为一个不可分割的操作完成
} // for 循环结束
} // AddCounter 函数结束
int main() { // 程序入口函数
thread t1(AddCounter); // 创建线程 t1
thread t2(AddCounter); // 创建线程 t2
t1.join(); // 等待线程 t1 执行结束
t2.join(); // 等待线程 t2 执行结束
cout << counter << endl; // 输出结果,通常稳定是 200000
return 0; // 程序正常结束
} // main 函数结束常用 atomic 操作
| 操作 | 作用 |
|---|---|
load() | 原子读取 |
store() | 原子写入 |
fetch_add() | 原子加法 |
fetch_sub() | 原子减法 |
exchange() | 原子交换 |
compare_exchange_weak/strong() | CAS,比较并交换 |
load 和 store 示例
c
#include <atomic> // 引入 atomic 原子变量
#include <iostream> // 引入 cout 输出
using namespace std; // 使用标准命名空间
int main() { // 程序入口函数
atomic<bool> ready = false; // 定义原子布尔值,表示是否准备好
ready.store(true); // 原子写入 true
bool value = ready.load(); // 原子读取 ready 的值
cout << value << endl; // 输出读取到的值
return 0; // 程序正常结束
} // main 函数结束fetch_add 示例
c
#include <atomic> // 引入 atomic
#include <iostream> // 引入 cout
using namespace std; // 使用标准命名空间
int main() { // 程序入口函数
atomic<int> value = 10; // 定义原子整数,初始值是 10
int oldValue = value.fetch_add(5); // 原子加 5,并返回加之前的旧值
cout << oldValue << endl; // 输出旧值 10
cout << value.load() << endl; // 输出新值 15
return 0; // 程序正常结束
} // main 函数结束原子变量适合什么场景?
适合简单共享状态:
c
atomic<int> counter; // 计数器
atomic<bool> stopFlag; // 停止标记
atomic<bool> ready; // 准备完成标记
atomic<int> state; // 简单状态机比如一个线程通知另一个线程停止:
c
#include <atomic> // 引入 atomic
#include <iostream> // 引入 cout
#include <thread> // 引入 thread
using namespace std; // 使用标准命名空间
atomic<bool> stopFlag = false; // 定义原子停止标记
void Worker() { // 定义工作线程函数
while (!stopFlag.load()) { // 原子读取停止标记,没停止就继续循环
cout << "工作中" << endl; // 输出工作信息
this_thread::sleep_for(chrono::milliseconds(100)); // 睡眠 100 毫秒,模拟工作间隔
} // while 循环结束
cout << "工作线程退出" << endl; // 输出退出信息
} // Worker 函数结束
int main() { // 程序入口函数
thread worker(Worker); // 创建工作线程
this_thread::sleep_for(chrono::milliseconds(300)); // 主线程等待 300 毫秒
stopFlag.store(true); // 原子写入 true,通知工作线程停止
worker.join(); // 等待工作线程结束
return 0; // 程序正常结束
} // main 函数结束atomic 不能替代 mutex 的情况 原子变量只适合“单个变量的单次操作”。 如果你要保护一段复杂逻辑,还是要用互斥锁。
比如这个逻辑:
c
if (stock > 0) {
stock--;
}它看起来简单,但整体不是一个原子操作。 两个线程可能都看到 stock > 0,然后都减库存。
这种情况更适合用 mutex,或者写更复杂的 CAS 循环。
atomic 和 mutex 的区别
| 对比 | atomic | mutex |
|---|---|---|
| 保护范围 | 单个变量的原子操作 | 一段临界区 |
| 是否阻塞 | 通常不阻塞,但可能自旋或硬件同步 | 拿不到锁会阻塞 |
| 适合 | 计数器、标志位、简单状态 | 多变量、一段复杂逻辑 |
| 难度 | 内存序理解较难 | 逻辑更直观 |
| 性能 | 轻量,但不是无成本 | 成本较高,但表达能力强 |
内存序是什么?std::atomic 不只保证“操作不可分割”,还涉及不同线程看到内存变化的顺序。
默认情况下:
c
memory_order_seq_cst这是最严格、最容易理解的顺序。初学和面试一般可以先说:
默认内存序最安全,性能可能不是最极致高级优化时才会用:
c
memory_order_relaxed
memory_order_acquire
memory_order_release但这些很容易写错,不建议初学者随便用。
NOTE
面试高分回答 原子变量是多线程编程中的一种同步机制,在 C++ 中用 std::atomic<T> 表示。它能保证对单个变量的读写或读-改-写操作是原子的,不会被其他线程插入打断,因此可以避免普通变量并发访问时的数据竞争。比如多个线程同时执行 counter++,普通 int 可能丢失更新,而 atomic<int> 的自增是安全的。原子变量适合计数器、标志位、简单状态等轻量同步场景。它不能完全替代互斥锁,因为它通常只保护单个变量的单次操作,如果要维护多个变量之间的一致性,或者保护一段复杂逻辑,仍然应该使用 mutex。另外,atomic 还涉及内存序,默认的顺序一致性最容易理解也最安全。
线程安全是什么意思?
一句话理解: 线程安全就是:一段代码或一个对象被多个线程同时访问时,结果仍然正确、稳定、可预期,不会因为线程交错执行而出错。
零基础理解 如果只有一个线程操作数据,一般不会抢。
如果多个线程同时操作同一份数据,就可能出问题。
比如:
c
counter++;它看起来是一行,但底层大概是:
c
读取 counter
加 1
写回 counter两个线程同时做这件事时,可能都读到旧值,然后互相覆盖结果。 这就叫“不线程安全”。
不线程安全的例子
c
#include <iostream> // 引入输入输出库,用来使用 cout
#include <thread> // 引入线程库,用来创建 thread
using namespace std; // 使用标准命名空间,避免重复写 std::
int counter = 0; // 定义共享变量,两个线程都会修改它
void AddCounter() { // 定义线程要执行的函数
for (int i = 0; i < 100000; i++) { // 循环很多次,让并发问题更容易出现
counter++; // 不加锁地修改共享变量,这不是线程安全的
} // for 循环结束
} // AddCounter 函数结束
int main() { // 程序入口函数
thread t1(AddCounter); // 创建线程 t1,让它执行 AddCounter
thread t2(AddCounter); // 创建线程 t2,让它也执行 AddCounter
t1.join(); // 等待线程 t1 结束
t2.join(); // 等待线程 t2 结束
cout << counter << endl; // 输出结果,可能不是 200000
return 0; // 程序正常结束
} // main 函数结束这段代码理论上希望输出:
200000但实际可能小于它,因为 counter++ 发生了数据竞争。
线程安全的写法:用互斥锁
c
#include <iostream> // 引入输入输出库,用来使用 cout
#include <thread> // 引入线程库,用来创建 thread
#include <mutex> // 引入互斥锁库,用来使用 mutex 和 lock_guard
using namespace std; // 使用标准命名空间,避免重复写 std::
int counter = 0; // 定义共享变量,多个线程会访问它
mutex counterMutex; // 定义互斥锁,用来保护 counter
void AddCounter() { // 定义线程要执行的函数
for (int i = 0; i < 100000; i++) { // 循环很多次,模拟并发修改
lock_guard<mutex> lock(counterMutex); // 构造时加锁,离开作用域自动解锁
counter++; // 在锁保护下修改共享变量,所以是线程安全的
} // 本轮循环结束,lock 自动析构并释放锁
} // AddCounter 函数结束
int main() { // 程序入口函数
thread t1(AddCounter); // 创建线程 t1
thread t2(AddCounter); // 创建线程 t2
t1.join(); // 等待线程 t1 结束
t2.join(); // 等待线程 t2 结束
cout << counter << endl; // 输出结果,通常稳定是 200000
return 0; // 程序正常结束
} // main 函数结束线程安全主要关心三件事
| 问题 | 含义 |
|---|---|
| 数据竞争 | 多个线程同时访问同一数据,至少一个线程在写 |
| 原子性 | 一个操作会不会执行到一半被别的线程插进来 |
| 可见性 | 一个线程修改的数据,另一个线程能不能正确看到 |
哪些情况通常是线程安全的?
只读共享数据通常安全:
c
#include <iostream> // 引入输入输出库
#include <thread> // 引入线程库
using namespace std; // 使用标准命名空间
const int maxHp = 100; // 定义只读常量,多个线程只读它通常安全
void PrintHp() { // 定义线程函数
cout << maxHp << endl; // 只读取常量,不修改共享状态
} // PrintHp 函数结束
int main() { // 程序入口函数
thread t1(PrintHp); // 创建线程 t1
thread t2(PrintHp); // 创建线程 t2
t1.join(); // 等待线程 t1 结束
t2.join(); // 等待线程 t2 结束
return 0; // 程序正常结束
} // main 函数结束多个线程操作自己的局部变量通常安全:
c
#include <iostream> // 引入 cout
#include <thread> // 引入 thread
using namespace std; // 使用标准命名空间
void Work() { // 定义线程函数
int localValue = 0; // 定义局部变量,每个线程都有自己的副本
localValue++; // 修改自己的局部变量,不共享,所以通常安全
cout << localValue << endl; // 输出局部变量
} // Work 函数结束
int main() { // 程序入口函数
thread t1(Work); // 创建线程 t1
thread t2(Work); // 创建线程 t2
t1.join(); // 等待线程 t1 结束
t2.join(); // 等待线程 t2 结束
return 0; // 程序正常结束
} // main 函数结束哪些情况通常不线程安全?
多个线程同时写同一个变量。 一个线程遍历容器,另一个线程修改容器。 多个线程同时 push_back 同一个 vector。 多个线程读写同一个对象的内部状态。 多个线程操作同一份资源句柄。
比如:
c
vector<int> values;如果一个线程:
c
values.push_back(1);另一个线程同时:
c
values.push_back(2);这通常不是线程安全的,需要加锁。
实现线程安全的常见方法
互斥锁:
c
mutex
lock_guard
unique_lock原子变量:
c
atomic<int>
atomic<bool>不可变数据:
const
只读共享消息传递:
任务队列
生产者消费者
不要直接共享可变状态线程局部存储:
thread_local
每个线程一份数据atomic 和 mutex 怎么选?
| 场景 | 推荐 |
|---|---|
| 单个计数器 | atomic<int> |
| 停止标记 | atomic<bool> |
| 多个变量要一起保持一致 | mutex |
| 修改容器 | mutex |
| 一段复杂逻辑 | mutex |
| 线程间等待条件 | condition_variable + mutex |
TIP
面试高分回答 线程安全是指代码在多线程环境下被并发访问时,仍然能保证结果正确,不会出现数据竞争、状态破坏或未定义行为。判断一个东西是否线程安全,要看它是否访问共享可变状态:如果多个线程只读同一份数据,一般没问题;如果多个线程同时访问同一份数据并且至少一个线程会写,就必须同步。常见做法包括使用 mutex 保护临界区,使用 atomic 处理简单计数器或标志位,使用条件变量协调线程等待,或者通过消息队列减少共享数据。需要注意,线程安全不是“能在线程里运行”,而是“多个线程同时运行时依然正确”。
读写锁适合什么场景?
一句话理解: 读写锁适合“读很多、写很少”的场景。它允许多个线程同时读,但写线程必须独占。
零基础理解 普通互斥锁 mutex 比较严格:
不管你是读还是写,同一时间只允许一个线程进来但有时候很多线程只是读数据,不会修改数据。 只读之间其实不会互相破坏,所以读写锁允许:
读 + 读:可以同时进行
读 + 写:不能同时进行
写 + 写:不能同时进行记忆版:
读读共享
读写互斥
写写互斥适合什么场景?
读写锁适合这些场景:
| 场景 | 为什么适合 |
|---|---|
| 配置表 | 大量读取,偶尔热更新 |
| 资源表 | 多线程查资源,少量修改 |
| 缓存系统 | 查询多,更新少 |
| 地图/场景静态数据 | 大量读,偶尔加载或替换 |
| 排行榜快照 | 读榜频繁,写榜较少 |
| 游戏服务器玩家数据索引 | 查询多,增删较少 |
比如游戏里:
配置表加载后,大部分时间只是读
资源缓存,大部分时间只是查
排行榜,大部分请求只是看这些都是读多写少。
C++ 里怎么写? C++17 开始可以用:
c
std::shared_mutex读的时候用:
c
std::shared_lock写的时候用:
c
std::unique_lock代码示例:配置表读多写少
c
#include <iostream> // 引入输入输出库,用来使用 cout
#include <shared_mutex> // 引入 shared_mutex 和 shared_lock
#include <mutex> // 引入 unique_lock
#include <string> // 引入 string 字符串
#include <unordered_map> // 引入 unordered_map 哈希表
using namespace std; // 使用标准命名空间,避免重复写 std::
class ConfigTable { // 定义配置表类
private: // private 成员开始
unordered_map<string, int> configs; // 保存配置数据,key 是配置名,value 是配置值
mutable shared_mutex configMutex; // 定义读写锁,mutable 允许 const 函数里加锁
public: // public 成员开始
int GetValue(const string& key) const { // 定义读取配置的函数,只读不修改对象逻辑
shared_lock<shared_mutex> lock(configMutex); // 加读锁,多个读线程可以同时进入
auto it = configs.find(key); // 在配置表中查找 key
if (it != configs.end()) { // 判断是否找到了配置
return it->second; // 找到了就返回配置值
} // if 结束
return 0; // 没找到就返回默认值 0
} // GetValue 函数结束
void SetValue(const string& key, int value) { // 定义写入配置的函数
unique_lock<shared_mutex> lock(configMutex); // 加写锁,写线程必须独占
configs[key] = value; // 修改配置数据
} // SetValue 函数结束
}; // ConfigTable 类结束
int main() { // 程序入口函数
ConfigTable table; // 创建配置表对象
table.SetValue("MaxHp", 100); // 写入一个配置
cout << table.GetValue("MaxHp") << endl; // 读取配置并输出
return 0; // 程序正常结束
} // main 函数结束为什么读用 shared_lock?shared_lock 表示共享读锁。
多个线程同时执行:
c
GetValue()它们都只是读,不改数据,所以可以一起进。
为什么写用 unique_lock?unique_lock 表示独占写锁。
当线程执行:
c
SetValue()它要修改 configs。 这个时候不能有其他线程读,也不能有其他线程写,否则可能看到半修改状态。
和普通 mutex 对比
| 对比 | mutex | shared_mutex |
|---|---|---|
| 多个读线程 | 不能同时进入 | 可以同时进入 |
| 写线程 | 独占 | 独占 |
| 复杂度 | 简单 | 更复杂 |
| 开销 | 通常更小 | 管理读写状态,有额外开销 |
| 适合 | 通用保护 | 读多写少 |
什么时候不适合读写锁?
写操作很多时不适合。
如果读和写都很频繁,读写锁会频繁阻塞,额外管理成本可能更高。
临界区很短时不适合。
如果只是保护一个很小操作,普通 `mutex` 可能更快更简单。
写线程不能等太久时要小心。
如果读线程源源不断,有些实现或策略下写线程可能长时间等不到机会,这叫写饥饿。
逻辑很复杂时要小心。
读锁升级成写锁容易出坑,不要随便“先读后写再升级”。NOTE
面试高分回答 读写锁适合读多写少的场景。它把访问分成共享读和独占写:多个读线程可以同时持有读锁,因为只读不会破坏数据;写线程必须持有独占锁,写的时候不能有其他读或写。C++17 中常用 std::shared_mutex,读操作用 std::shared_lock,写操作用 std::unique_lock。它适合配置表、缓存、资源表、排行榜快照等大量查询、少量更新的场景。需要注意的是,读写锁不是一定比普通 mutex 快,如果写操作频繁、临界区很短、竞争不明显,普通互斥锁可能更简单也更快。另外还要注意写线程饥饿和锁升级问题。
生产者消费者模型怎么写?
一句话理解: 生产者消费者模型就是:生产者线程负责“放任务”,消费者线程负责“取任务处理”;中间用一个线程安全队列连接,队列为空时消费者睡眠等待。
核心组成
| 组件 | 作用 |
|---|---|
queue | 保存任务 |
mutex | 保护队列,防止多个线程同时改 |
condition_variable | 队列为空时让消费者睡眠 |
finished | 通知消费者生产已经结束,可以退出 |
notify_one | 新任务来了,叫醒一个消费者 |
notify_all | 结束时叫醒所有消费者,让它们退出 |
完整 C++ 写法
c
#include <condition_variable> // 引入条件变量,用来让消费者线程等待和唤醒
#include <iostream> // 引入输入输出库,用来使用 cout
#include <mutex> // 引入互斥锁,用来保护共享队列
#include <queue> // 引入 queue,用来保存任务
#include <thread> // 引入 thread,用来创建线程
#include <vector> // 引入 vector,用来保存多个线程对象
using namespace std; // 使用标准命名空间,避免重复写 std::
class TaskQueue { // 定义一个线程安全任务队列类
private: // private 区域开始,外部不能直接访问内部数据
queue<int> tasks; // 保存任务的普通队列,里面放 int 类型任务
mutex tasksMutex; // 保护任务队列的互斥锁
condition_variable tasksCv; // 条件变量,用来等待“队列非空”或“生产结束”
bool finished = false; // 标记生产者是否已经结束生产
public: // public 区域开始,外部通过这些函数操作队列
void Push(int task) { // 生产者调用这个函数放入任务
{ // 创建一个小作用域,让锁尽快释放
lock_guard<mutex> lock(tasksMutex); // 加锁,保护 tasks 队列
tasks.push(task); // 把任务放入队列尾部
cout << "生产任务: " << task << endl; // 输出生产信息
} // 小作用域结束,lock_guard 自动解锁
tasksCv.notify_one(); // 通知一个正在等待的消费者:可能有任务了
} // Push 函数结束
bool Pop(int& task) { // 消费者调用这个函数取出任务,成功返回 true
unique_lock<mutex> lock(tasksMutex); // 加锁,并且使用 unique_lock 配合条件变量
tasksCv.wait(lock, [this] { // 等待条件成立,wait 会自动释放锁并让线程睡眠
return !tasks.empty() || finished; // 队列不为空或生产结束时,消费者才继续执行
}); // wait 结束时会重新持有锁,并且重新检查条件
if (tasks.empty() && finished) { // 如果队列空了,并且生产者已经结束
return false; // 返回 false,告诉消费者线程可以退出
} // if 结束
task = tasks.front(); // 取出队首任务
tasks.pop(); // 从队列中删除这个任务
return true; // 返回 true,表示成功取到任务
} // Pop 函数结束
void Finish() { // 生产者调用这个函数通知不再生产任务
{ // 创建一个小作用域,让锁尽快释放
lock_guard<mutex> lock(tasksMutex); // 加锁,保护 finished 标记
finished = true; // 设置结束标记,表示不会再有新任务
} // 小作用域结束,lock_guard 自动解锁
tasksCv.notify_all(); // 唤醒所有消费者,让它们检查是否应该退出
} // Finish 函数结束
}; // TaskQueue 类结束
void Producer(TaskQueue& queue) { // 定义生产者线程函数
for (int i = 1; i <= 10; i++) { // 循环生产 10 个任务
queue.Push(i); // 把任务 i 放入任务队列
this_thread::sleep_for(chrono::milliseconds(50)); // 睡眠 50 毫秒,模拟生产任务耗时
} // for 循环结束
queue.Finish(); // 通知消费者所有任务已经生产完毕
} // Producer 函数结束
void Consumer(TaskQueue& queue, int consumerId) { // 定义消费者线程函数
int task = 0; // 定义变量,用来接收取出的任务
while (queue.Pop(task)) { // 不断从队列取任务,取不到且结束时退出循环
cout << "消费者 " << consumerId << " 处理任务: " << task << endl; // 输出消费者处理信息
this_thread::sleep_for(chrono::milliseconds(100)); // 睡眠 100 毫秒,模拟处理任务耗时
} // while 循环结束
cout << "消费者 " << consumerId << " 退出" << endl; // 输出消费者退出信息
} // Consumer 函数结束
int main() { // 程序入口函数
TaskQueue queue; // 创建一个线程安全任务队列
thread producer(Producer, ref(queue)); // 创建生产者线程,并用 ref 传入队列引用
vector<thread> consumers; // 创建 vector,用来保存多个消费者线程
for (int i = 1; i <= 3; i++) { // 创建 3 个消费者线程
consumers.emplace_back(Consumer, ref(queue), i); // 启动一个消费者线程,并传入消费者编号
} // for 循环结束
producer.join(); // 等待生产者线程结束
for (thread& consumer : consumers) { // 遍历所有消费者线程
consumer.join(); // 等待当前消费者线程结束
} // for 循环结束
return 0; // 程序正常结束
} // main 函数结束执行逻辑
生产者执行:
c
Push(task)它会:
加锁
把任务放进队列
解锁
notify_one 唤醒一个消费者消费者执行:
c
Pop(task)它会:
加锁
如果队列为空且没结束,就 wait 睡眠
被唤醒后重新检查条件
如果有任务,就取出来
如果没任务且已经 finished,就退出为什么 wait 条件要写成这样?
c
return !tasks.empty() || finished;因为消费者醒来的原因有两个:
队列有任务了,需要处理
生产结束了,需要退出如果只写:
c
return !tasks.empty();那生产者结束后,队列又空了,消费者可能永远睡着。
为什么要用 while / 谓词? 因为条件变量可能虚假唤醒。 也就是:没有真正满足条件,线程也可能从 wait 醒来。
所以推荐永远这样写:
c
cv.wait(lock, 条件);不要单纯写:
c
cv.wait(lock);TIP
面试高分回答 生产者消费者模型通常用一个共享任务队列连接生产者线程和消费者线程。生产者负责生成任务并放入队列,消费者负责从队列取任务处理。为了保证线程安全,队列的 push 和 pop 要用 mutex 保护;为了避免消费者在队列为空时忙等,要使用 condition_variable。消费者调用 wait(lock, predicate),当队列为空且生产未结束时释放锁并睡眠;生产者放入任务后调用 notify_one 唤醒消费者;当所有任务生产完后设置结束标记并 notify_all,让消费者能正常退出。关键点是队列操作要加锁、等待要用谓词防止虚假唤醒、结束时要通知所有等待线程。
任务队列怎么设计?
一句话理解: 任务队列就是一个“线程安全的任务中转站”:外部线程把任务丢进去,工作线程从里面取任务执行。
核心组成
| 组件 | 作用 |
|---|---|
queue<function<void()>> | 保存任务 |
mutex | 保护队列 |
condition_variable | 队列为空时让工作线程睡眠 |
stopped | 标记队列是否停止 |
Push | 提交任务 |
Pop | 取出任务 |
Stop | 关闭队列并唤醒所有线程 |
基础线程安全任务队列
c
#include <condition_variable> // 引入条件变量,用来让线程等待和唤醒
#include <functional> // 引入 function,用来保存任意可调用任务
#include <iostream> // 引入输入输出库,用来使用 cout
#include <mutex> // 引入互斥锁,用来保护共享队列
#include <queue> // 引入 queue,用来保存任务
#include <thread> // 引入 thread,用来创建工作线程
#include <vector> // 引入 vector,用来保存多个线程
using namespace std; // 使用标准命名空间,避免重复写 std::
class TaskQueue { // 定义线程安全任务队列类
private: // private 区域开始
queue<function<void()>> tasks; // 保存任务的队列,每个任务都是一个 void() 函数
mutex tasksMutex; // 保护任务队列的互斥锁
condition_variable tasksCv; // 条件变量,用来在队列为空时等待
bool stopped = false; // 标记队列是否已经停止
public: // public 区域开始
void Push(function<void()> task) { // 提交一个任务到队列
{ // 创建小作用域,让锁尽快释放
lock_guard<mutex> lock(tasksMutex); // 加锁,保护 tasks 队列
if (stopped) { // 如果队列已经停止
return; // 不再接受新任务,直接返回
} // if 结束
tasks.push(move(task)); // 把任务移动进队列,减少拷贝
} // 小作用域结束,lock_guard 自动解锁
tasksCv.notify_one(); // 唤醒一个等待中的工作线程
} // Push 函数结束
bool Pop(function<void()>& task) { // 从队列取出一个任务,成功返回 true
unique_lock<mutex> lock(tasksMutex); // 加锁,并允许条件变量临时释放这把锁
tasksCv.wait(lock, [this] { // 等待队列非空或队列停止
return stopped || !tasks.empty(); // 队列停止或有任务时,wait 才返回
}); // wait 结束时会重新持有锁
if (stopped && tasks.empty()) { // 如果已经停止并且没有剩余任务
return false; // 返回 false,告诉工作线程退出
} // if 结束
task = move(tasks.front()); // 把队首任务移动出来
tasks.pop(); // 从队列中删除这个任务
return true; // 返回 true,表示成功取到任务
} // Pop 函数结束
void Stop() { // 停止任务队列
{ // 创建小作用域,让锁尽快释放
lock_guard<mutex> lock(tasksMutex); // 加锁,保护 stopped 标记
stopped = true; // 设置停止标记
} // 小作用域结束,lock_guard 自动解锁
tasksCv.notify_all(); // 唤醒所有等待线程,让它们检查停止条件
} // Stop 函数结束
}; // TaskQueue 类结束工作线程怎么用它
c
void Worker(TaskQueue& queue, int workerId) { // 定义工作线程函数
function<void()> task; // 定义变量,用来接收从队列取出的任务
while (queue.Pop(task)) { // 不断从队列取任务,队列停止且空时退出
try { // 开始捕获任务执行中的异常
task(); // 在锁外执行任务,避免阻塞其他线程操作队列
} catch (const exception& e) { // 捕获标准异常
cout << "工作线程 " << workerId << " 捕获异常: " << e.what() << endl; // 输出异常信息
} catch (...) { // 捕获未知异常
cout << "工作线程 " << workerId << " 捕获未知异常" << endl; // 输出未知异常信息
} // catch 结束
} // while 循环结束
cout << "工作线程 " << workerId << " 退出" << endl; // 输出线程退出信息
} // Worker 函数结束
int main() { // 程序入口函数
TaskQueue queue; // 创建任务队列
vector<thread> workers; // 创建线程数组,用来保存工作线程
for (int i = 0; i < 3; i++) { // 创建 3 个工作线程
workers.emplace_back(Worker, ref(queue), i); // 启动工作线程,并传入队列引用和线程编号
} // for 循环结束
for (int i = 1; i <= 5; i++) { // 提交 5 个任务
queue.Push([i] { // 提交一个 lambda 任务,捕获当前任务编号
cout << "执行任务 " << i << endl; // 输出任务执行信息
}); // Push 调用结束
} // for 循环结束
this_thread::sleep_for(chrono::milliseconds(200)); // 等待一会儿,让工作线程处理任务
queue.Stop(); // 停止任务队列
for (thread& worker : workers) { // 遍历所有工作线程
worker.join(); // 等待当前工作线程退出
} // for 循环结束
return 0; // 程序正常结束
} // main 函数结束最关键的设计点
不要持锁执行任务。 正确流程是:
加锁取任务
从队列 pop 出来
解锁
执行 task()原因是任务可能很慢,甚至任务内部可能再提交任务。 如果你拿着队列锁执行任务,会导致其他线程不能提交、不能取任务,严重时还可能死锁。
Stop 为什么要 notify_all? 因为工作线程可能都睡在:
c
cv.wait(...)如果你只设置:
c
stopped = true;但不通知它们,它们可能永远睡着。 所以停止时要:
c
stopped = true;
notify_all();基础版和进阶版区别
基础版任务队列是无界队列:
生产者一直可以 Push进阶版可以做有界队列:
队列满了,生产者阻塞
队列满了,直接丢弃任务
队列满了,丢最旧任务
队列满了,返回 false游戏里常见做法是: 重要任务不能丢,比如资源加载完成回调。 不重要任务可以合并或丢弃,比如重复刷新 UI、重复统计上报。
NOTE
面试高分回答 任务队列的核心是把任务生产和任务执行解耦。通常用 queue<function<void()>> 保存任务,用 mutex 保护队列的 push/pop,用 condition_variable 在队列为空时阻塞工作线程,避免忙等。生产者提交任务后调用 notify_one 唤醒一个工作线程;工作线程通过 wait(lock, predicate) 等待“队列非空或停止标记成立”,取出任务后必须先释放锁,再执行任务。停止时设置 stopped 标记并 notify_all,让所有等待线程能退出。工程上还要考虑异常捕获、队列容量限制、任务优先级、任务取消、统计监控,以及不要在持锁状态下执行任务。
游戏引擎中的 Job System 大概是什么?
一句话理解
游戏引擎里的 Job System,就是把一大坨本来要主线程顺序执行的逻辑,拆成很多小任务,交给多个工作线程并行执行。它的重点不是“开线程”,而是:任务拆分、任务调度、依赖关系、线程安全、等待同步。
零基础理解
普通写法像这样:主线程一个人干所有活。
比如一帧里要更新 10000 个怪物:
主线程:怪物1 -> 怪物2 -> 怪物3 -> ... -> 怪物10000Job System 的思路是:
主线程:把 10000 个怪物分成很多批
线程1:处理 0-2499
线程2:处理 2500-4999
线程3:处理 5000-7499
线程4:处理 7500-9999这样 CPU 多核就能一起干活。游戏引擎里大量逻辑都是“同一种操作处理很多数据”,非常适合这种模式,比如大量角色移动、粒子更新、动画采样、AI 感知、寻路计算、物理检测等。
它解决什么问题
游戏主线程通常很忙:输入、脚本、动画、物理、UI、渲染提交都可能挤在一帧里。假设目标是 60 FPS,一帧只有大约 `16.67ms`,主线程一旦超时,玩家就会感觉卡顿。
Job System 的价值是:把能并行的计算挪到 worker thread 上,让多个 CPU 核心同时工作,降低主线程压力。
但是要注意,Job System 不是万能加速器。它更适合“纯数据计算”,不适合一堆共享状态互相改来改去的逻辑。Unity 里的 Job System 大概是什么
Unity 的 Job System 通常配合这些东西一起用:
`IJob`:一个普通任务。
`IJobParallelFor`:适合处理数组,每个元素可以并行处理。
`NativeArray`:Job 里常用的数据容器,比普通 `List<T>` / 数组更适合跨线程访问。
`JobHandle`:表示一个 Job 的执行句柄,可以用来等待完成,也可以表达依赖关系。
`Burst`:高性能编译器,可以把 Job 编译成更快的机器码。
核心思想是:Job 线程里不要直接操作 `GameObject`、`Transform`、`Renderer` 这些 Unity 对象,而是操作 `NativeArray` 里的纯数据。简单代码示例
下面是一个“批量移动位置数据”的 Unity Job 示例。注意:Job 里改的是 NativeArray<Vector3>,不是直接改 Transform。
c
using Unity.Burst; // 引入 Burst 命名空间,用来让 Job 有机会被高性能编译
using Unity.Collections; // 引入 NativeArray 等 Unity 原生容器
using Unity.Jobs; // 引入 Unity Job System 的核心接口
using UnityEngine; // 引入 Vector3、Time、MonoBehaviour 等 Unity 类型
[BurstCompile] // 标记这个 Job 可以使用 Burst 编译优化
public struct MoveJob : IJobParallelFor // 定义一个并行 Job,每个数组元素都会执行一次
{ // MoveJob 结构体开始
public NativeArray<Vector3> Positions; // 存储位置数据,Job 中不要直接操作 Transform
public Vector3 Velocity; // 存储移动速度,所有元素都使用同一个速度
public float DeltaTime; // 存储本帧时间,用来保证移动不受帧率影响
public void Execute(int index) // Unity 会为每个数组下标调用一次 Execute
{ // Execute 方法开始
Positions[index] = Positions[index] + Velocity * DeltaTime; // 根据速度和时间更新当前位置
} // Execute 方法结束
} // MoveJob 结构体结束调度时大概长这样
c
MoveJob job = new MoveJob(); // 创建一个移动 Job
job.Positions = positions; // 把 NativeArray 位置数组传给 Job
job.Velocity = new Vector3(1f, 0f, 0f); // 设置所有对象向右移动的速度
job.DeltaTime = Time.deltaTime; // 把当前帧时间传给 Job
JobHandle handle = job.Schedule(positions.Length, 64); // 调度 Job,64 表示每批大约处理 64 个元素
handle.Complete(); // 等待 Job 完成,完成后主线程才能安全读取结果什么时候适合用 Job System
适合大量重复、互相独立、以数据为中心的计算:
角色批量移动。
大量怪物 AI 感知。
大量粒子位置更新。
动画姿态采样。
寻路或导航计算。
碰撞粗检测。
大量实体的状态刷新。
地图分块数据处理。
简单说:如果一段逻辑可以变成“对数组里的每个元素做类似计算”,它通常就适合 Job System。什么时候不适合
不适合任务太小的逻辑。因为提交 Job 本身也有成本,任务太小反而可能更慢。
不适合频繁访问 Unity 主线程 API。比如在 Job 里直接 `transform.position = xxx` 通常是不行的。
不适合共享数据特别多的逻辑。如果每个 Job 都要抢同一把锁,并行价值就没了。
不适合强顺序逻辑。比如必须先 A 再 B 再 C,而且每一步都依赖前一步,那并行空间很小。面试高分回答
TIP
Job System 是游戏引擎为了利用多核 CPU 而设计的一套任务调度系统。它不是简单手动创建线程,而是把逻辑拆成很多小 Job,由调度器分配给 worker threads 执行,并通过 JobHandle 或类似机制管理依赖和同步。它适合大量数据并行计算,比如动画、粒子、AI、物理粗检测和 ECS 中的批量实体更新。以 Unity 为例,Job 通常操作 NativeArray 这类纯数据容器,不能随便在子线程访问 GameObject、Transform 等 UnityEngine 对象。使用时要注意任务粒度、同步点、数据竞争、主线程 API 限制,以及过早 Complete 导致主线程等待的问题。