Appearance
操作系统
什么是上下文切换?
标准答案
上下文切换就是:CPU 暂停当前正在执行的线程或进程,保存它的执行现场,然后恢复另一个线程或进程的执行现场,让 CPU 去执行新的任务。
这里的“上下文”可以理解成“下次还能从原地继续跑所需要的信息”,比如:
- 程序计数器
PC:代码执行到哪里了 - 栈指针
SP:当前函数调用栈在哪里 - CPU 寄存器:临时计算数据
- 线程状态:运行、就绪、阻塞
- 进程切换时还可能涉及页表、地址空间、TLB 等
底层原理
一次典型上下文切换大概是:
- 线程 A 正在 CPU 上运行。
- 时间片用完、等待 IO、锁阻塞、主动
yield,或者被高优先级线程抢占。 - CPU 进入内核调度逻辑。
- 操作系统把线程 A 的寄存器、PC、SP 等现场保存到线程控制块里。
- 调度器选择线程 B。
- 操作系统把线程 B 上次保存的现场恢复到 CPU。
- CPU 从线程 B 上次暂停的位置继续执行。
C++ 小例子
c
#include <iostream> // 引入 iostream,用来输出线程执行信息。
#include <thread> // 引入 thread,用来创建线程。
void Worker(const char* name) // 定义线程函数,name 表示线程名字。
{ // 函数开始。
for (int i = 0; i < 3; ++i) // 每个线程循环执行 3 次。
{ // for 循环开始。
std::cout << name << " running " << i << std::endl; // 输出当前线程正在执行。
std::this_thread::yield(); // 主动让出 CPU,提示调度器可以切换到其他线程。
} // for 循环结束。
} // 函数结束。
int main() // 程序入口函数。
{ // main 函数开始。
std::thread t1(Worker, "Thread A"); // 创建线程 A。
std::thread t2(Worker, "Thread B"); // 创建线程 B。
t1.join(); // 等待线程 A 执行结束。
t2.join(); // 等待线程 B 执行结束。
return 0; // 返回 0,表示程序正常结束。
} // main 函数结束。注意
yield() 不保证一定立刻发生上下文切换,它只是告诉操作系统:“我愿意让出 CPU,你可以调度别人。”真正是否切换,由操作系统调度器决定。
面试加分说法
上下文切换本身不产生业务价值,它只是调度成本。切换太频繁会带来 CPU 调度开销,还可能破坏 CPU Cache、TLB 命中率。游戏开发里如果线程太多、锁竞争严重、主线程频繁等待子线程,就容易造成帧时间抖动和卡顿。
线程越多越快吗?
标准答案
线程不是越多越快。线程只是“执行任务的单位”,真正能同时计算的是 CPU 核心。如果线程数量超过合适范围,反而会因为上下文切换、锁竞争、缓存失效、内存带宽竞争变慢。
怎么判断
CPU 密集型任务: 比如寻路计算、物理计算、图片压缩、AI 批量计算,主要消耗 CPU。线程数通常接近 CPU 核心数比较合理。
IO 密集型任务: 比如网络请求、磁盘读写、资源下载,大量时间在等待。线程数可以适当多于核心数,因为线程等待时 CPU 可以去跑别的任务。
C++ 示例:按硬件核心数控制线程数量
c
#include <algorithm> // 引入 algorithm,用来使用 max 函数。
#include <iostream> // 引入 iostream,用来输出信息。
#include <thread> // 引入 thread,用来获取硬件线程数。
#include <vector> // 引入 vector,用来保存线程对象。
void Work(int id) // 定义工作函数,id 表示任务编号。
{ // 函数开始。
std::cout << "Task " << id << " running" << std::endl; // 输出当前任务正在运行。
} // 函数结束。
int main() // 程序入口函数。
{ // main 函数开始。
unsigned int coreCount = std::thread::hardware_concurrency(); // 获取硬件支持的并发线程数。
coreCount = std::max(1u, coreCount); // 如果获取失败返回 0,就至少使用 1。
std::vector<std::thread> workers; // 创建线程数组,用来保存工作线程。
for (unsigned int i = 0; i < coreCount; ++i) // 创建接近核心数量的线程。
{ // for 循环开始。
workers.emplace_back(Work, static_cast<int>(i)); // 创建一个线程执行 Work 函数。
} // for 循环结束。
for (std::thread& worker : workers) // 遍历所有线程。
{ // for 循环开始。
worker.join(); // 等待当前线程执行完成。
} // for 循环结束。
return 0; // 返回 0,表示程序正常结束。
} // main 函数结束。游戏开发里的回答
在 Unity 或游戏客户端里,我不会给每个小任务都开一个线程,而是更倾向于:
- 用线程池或 Job System。
- 把大任务拆成批处理。
- 减少锁竞争。
- 避免主线程等待子线程。
- 子线程只做纯数据计算,Unity API 回到主线程执行。
面试里可以这样总结:线程提高的是并发能力,不是凭空创造 CPU 核心。CPU 密集型接近核心数,IO 密集型可以多一些,但线程过多会被上下文切换和锁竞争反噬。
协程为什么比线程轻量?
标准答案
协程比线程轻量,是因为协程通常不是操作系统线程。 以 Unity 协程为例,它本质上更像一个 IEnumerator 状态机,由 Unity 主线程在每帧合适的位置调用 MoveNext() 推进。
线程是系统级执行单元,需要独立栈、寄存器现场、内核调度、上下文切换;协程一般只保存“执行到哪一步”和局部状态,所以创建和切换成本更低。
底层原理
线程切换大概是:
c
保存线程 A 的寄存器 / 栈 / PC
调度器选择线程 B
恢复线程 B 的执行现场Unity 协程大概是:
c
主线程调用 MoveNext()
执行到 yield return
保存状态,暂停
下一帧或等待条件满足后继续 MoveNext()所以协程轻量的原因是:没有独立内核线程,没有 OS 抢占式调度,没有线程级上下文切换。
Unity C# 示例
c
using System.Collections; // 引入 IEnumerator,用来编写 Unity 协程。
using UnityEngine; // 引入 UnityEngine,用来使用 MonoBehaviour 和 Unity API。
public class CoroutineLightExample : MonoBehaviour // 定义一个 Unity 脚本类。
{ // 类开始。
private void Start() // Unity 在脚本启用后的第一帧调用 Start。
{ // Start 函数开始。
StartCoroutine(PlayFlow()); // 启动协程,但不会创建新的操作系统线程。
} // Start 函数结束。
private IEnumerator PlayFlow() // 定义一个协程函数,本质会被编译成可暂停的状态机。
{ // 协程函数开始。
Debug.Log("第一步:开始播放动画"); // 在主线程执行第一段逻辑。
yield return null; // 暂停到下一帧,当前执行状态会被保存。
Debug.Log("第二步:下一帧继续执行"); // 下一帧 Unity 再调用 MoveNext 后继续执行。
yield return new WaitForSeconds(1f); // 暂停约 1 秒,等待结束后再继续。
Debug.Log("第三步:等待结束"); // 等待完成后继续执行后续逻辑。
} // 协程函数结束。
} // 类结束。面试加分点
协程轻量,但它不是后台线程。 如果你在协程里写一个很重的循环,比如一帧内算大量寻路、解析大文件、生成大量对象,它依然会卡主线程。
协程适合:
- 等待几帧
- 延迟执行
- 等动画播放完
- 分帧处理任务
- 控制资源加载流程
线程适合:
- 真正的后台计算
- IO 阻塞任务
- 不依赖 Unity API 的纯数据处理
常见坑
协程不是并行,不能把重 CPU 计算直接丢进协程就以为不卡了。频繁创建 WaitForSeconds、闭包、临时对象,也可能带来 GC。
线程池解决什么问题?
标准答案
线程池解决的核心问题是:不要每来一个任务就创建一个线程,而是提前创建一批固定 worker 线程,让任务排队,由空闲线程复用执行。
它主要解决 4 个问题:
- 减少线程创建和销毁成本:线程创建、栈分配、系统调度都有成本。
- 控制并发数量:避免任务一多就无限开线程,导致线程爆炸。
- 任务排队与削峰:高峰时任务先进队列,worker 慢慢消费。
- 提高系统稳定性:减少上下文切换、锁竞争、内存占用抖动。
C++ 简化版线程池
c
#include <condition_variable> // 引入条件变量,用来让 worker 在线程队列为空时休眠。
#include <cstddef> // 引入 size_t,用来表示线程数量。
#include <functional> // 引入 function,用来保存可执行任务。
#include <mutex> // 引入 mutex,用来保护任务队列。
#include <queue> // 引入 queue,用来保存等待执行的任务。
#include <thread> // 引入 thread,用来创建工作线程。
#include <utility> // 引入 move,用来移动任务对象。
#include <vector> // 引入 vector,用来保存多个 worker 线程。
class ThreadPool // 定义线程池类。
{ // 类开始。
public: // 公有成员区域开始。
explicit ThreadPool(std::size_t workerCount) : stopped(false) // 构造函数,创建指定数量的 worker。
{ // 构造函数开始。
for (std::size_t i = 0; i < workerCount; ++i) // 循环创建 worker 线程。
{ // for 循环开始。
workers.emplace_back([this]() { WorkerLoop(); }); // 创建线程,并让它执行 WorkerLoop。
} // for 循环结束。
} // 构造函数结束。
~ThreadPool() // 析构函数,用来安全停止线程池。
{ // 析构函数开始。
{ // 加锁作用域开始。
std::unique_lock<std::mutex> lock(mutex); // 锁住互斥量,保护 stopped 状态。
stopped = true; // 标记线程池准备停止。
} // 加锁作用域结束。
condition.notify_all(); // 唤醒所有等待中的 worker。
for (std::thread& worker : workers) // 遍历所有 worker 线程。
{ // for 循环开始。
if (worker.joinable()) // 如果线程还可以 join。
{ // if 代码块开始。
worker.join(); // 等待线程执行结束。
} // if 代码块结束。
} // for 循环结束。
} // 析构函数结束。
void Enqueue(std::function<void()> task) // 提交一个任务到线程池。
{ // 函数开始。
{ // 加锁作用域开始。
std::unique_lock<std::mutex> lock(mutex); // 锁住任务队列。
tasks.push(std::move(task)); // 把任务移动进队列。
} // 加锁作用域结束。
condition.notify_one(); // 唤醒一个空闲 worker 来处理任务。
} // 函数结束。
private: // 私有成员区域开始。
void WorkerLoop() // worker 线程循环执行的函数。
{ // 函数开始。
while (true) // worker 会一直循环等待任务。
{ // while 循环开始。
std::function<void()> task; // 创建一个变量,用来保存取出的任务。
{ // 加锁作用域开始。
std::unique_lock<std::mutex> lock(mutex); // 锁住任务队列。
condition.wait(lock, [this]() { return stopped || !tasks.empty(); }); // 队列为空时休眠,有任务或停止时醒来。
if (stopped && tasks.empty()) // 如果线程池已停止,并且没有剩余任务。
{ // if 代码块开始。
return; // 退出 worker 循环。
} // if 代码块结束。
task = std::move(tasks.front()); // 取出队首任务。
tasks.pop(); // 从队列中移除这个任务。
} // 加锁作用域结束。
task(); // 在锁外执行任务,避免任务执行期间阻塞其他 worker 取任务。
} // while 循环结束。
} // 函数结束。
std::vector<std::thread> workers; // 保存所有 worker 线程。
std::queue<std::function<void()>> tasks; // 保存等待执行的任务队列。
std::mutex mutex; // 保护任务队列和停止状态的互斥锁。
std::condition_variable condition; // 用来唤醒等待任务的 worker。
bool stopped; // 标记线程池是否准备停止。
}; // ThreadPool 类结束。游戏开发里的回答
在线程池里适合做:资源解压、配置解析、日志上传、网络包解析、寻路纯数据计算。 但 Unity 里要注意:worker 线程不要直接调用 Unity API,结果要派发回主线程处理。
常见坑
线程池不是越大越好。CPU 密集任务一般接近核心数;IO 密集可以稍多,但要压测。长时间阻塞任务会占满 worker,无限任务队列还可能导致内存上涨。
什么是忙等?
标准答案
忙等就是:线程在条件没有满足时,不阻塞、不睡眠,而是在循环里不停检查条件。
典型写法:
c
while (!ready) { }它的问题是:线程虽然什么有用的事都没做,但 CPU 仍然一直在执行这个循环,所以会浪费 CPU、增加功耗、导致发热,严重时还会影响其他线程调度。
底层理解
忙等和阻塞等待的区别是:
- 忙等:线程一直占着 CPU,反复问“好了没?”
- 阻塞等待:条件没满足就把线程挂起,等别人通知后再醒来。
C++ 示例:忙等写法
c
#include <atomic> // 引入 atomic,用来保证 ready 在线程之间可见。
#include <thread> // 引入 thread,用来创建线程。
std::atomic<bool> ready = false; // 定义原子布尔值,表示条件是否满足。
void BusyWait() // 定义忙等函数。
{ // 函数开始。
while (!ready.load()) // 只要 ready 还是 false,就一直循环检查。
{ // while 循环开始。
} // while 循环结束,这里没有休眠,所以会持续占用 CPU。
} // 函数结束。
int main() // 程序入口函数。
{ // main 函数开始。
std::thread worker(BusyWait); // 创建一个线程执行忙等逻辑。
ready.store(true); // 把 ready 设置为 true,让忙等线程退出循环。
worker.join(); // 等待 worker 线程结束。
return 0; // 返回 0,表示程序正常结束。
} // main 函数结束。更好的写法:条件变量
c
#include <condition_variable> // 引入 condition_variable,用来阻塞等待和唤醒线程。
#include <mutex> // 引入 mutex,用来保护共享条件。
#include <thread> // 引入 thread,用来创建线程。
std::mutex mutex; // 定义互斥锁,用来保护 ready。
std::condition_variable condition; // 定义条件变量,用来让线程等待通知。
bool ready = false; // 定义普通布尔值,表示条件是否满足。
void WaitWithoutBusyLoop() // 定义非忙等等待函数。
{ // 函数开始。
std::unique_lock<std::mutex> lock(mutex); // 加锁,准备检查共享条件。
condition.wait(lock, []() { return ready; }); // 条件不满足时线程睡眠,满足后才继续执行。
} // 函数结束。
int main() // 程序入口函数。
{ // main 函数开始。
std::thread worker(WaitWithoutBusyLoop); // 创建一个线程执行阻塞等待逻辑。
{ // 加锁作用域开始。
std::lock_guard<std::mutex> lock(mutex); // 加锁,准备修改 ready。
ready = true; // 修改条件,表示数据已经准备好。
} // 加锁作用域结束。
condition.notify_one(); // 唤醒一个正在等待的线程。
worker.join(); // 等待 worker 线程结束。
return 0; // 返回 0,表示程序正常结束。
} // main 函数结束。游戏开发里的坑
主线程忙等最危险,比如:
c
while (!assetLoaded) { }这会直接卡住主线程,画面不刷新,输入不响应。Unity 里如果要等资源、等动画、等网络,通常用回调、async/await、协程 yield return,或者子线程条件变量,而不是在主线程空转。
什么是饥饿?
标准答案
饥饿就是:系统还在正常运行,但某个线程、任务或请求长期拿不到 CPU、锁、队列机会或资源。
它和死锁不一样:
- 死锁:大家互相等,整体卡住。
- 饥饿:别人一直能运行,只有某个线程长期轮不到。
常见原因有:优先级过低、不公平锁、读写锁偏向读者、线程池任务队列不公平、高优先级任务不断插队。
C++ 示例:用“优先级老化”缓解饥饿
c
#include <algorithm> // 引入 algorithm,用来使用 max_element 查找最高评分任务。
#include <string> // 引入 string,用来保存任务名字。
#include <vector> // 引入 vector,用来保存任务队列。
struct Task // 定义任务结构体。
{ // 结构体开始。
std::string name; // 任务名字。
int priority; // 任务原始优先级,数值越大越重要。
int waitTicks; // 任务已经等待的时间片数量。
}; // 结构体结束。
int Score(const Task& task) // 计算任务当前调度分数。
{ // 函数开始。
return task.priority + task.waitTicks; // 原始优先级加等待时间,等待越久分数越高。
} // 函数结束。
Task PickTaskWithAging(std::vector<Task>& tasks) // 从任务队列中选择一个任务。
{ // 函数开始。
auto best = std::max_element(tasks.begin(), tasks.end(), [](const Task& a, const Task& b) // 找到评分最高的任务。
{ // Lambda 开始。
return Score(a) < Score(b); // 如果 a 分数小于 b,说明 b 更应该被选中。
}); // max_element 调用结束。
Task selected = *best; // 保存被选中的任务。
tasks.erase(best); // 从等待队列中移除这个任务。
for (Task& task : tasks) // 遍历剩余任务。
{ // for 循环开始。
++task.waitTicks; // 没被选中的任务等待时间增加,避免一直被饿住。
} // for 循环结束。
return selected; // 返回本次被调度执行的任务。
} // 函数结束。游戏开发里的例子
资源加载队列里,如果永远优先加载战斗资源、UI 资源,而低优先级的场景装饰、音频、远处模型一直排不上,就可能出现饥饿。解决时不能只看总吞吐,还要看任务等待时间,比如 P95/P99 等待时长、队列长度、超时次数。
面试加分说法
饥饿关注的是公平性,不是单纯性能。解决方式通常是公平队列、FIFO、优先级老化、超时重试、限流、减少锁粒度,或者避免某类任务无限插队。
什么是优先级反转?
标准答案
优先级反转是指:高优先级线程因为等待低优先级线程持有的资源,反而被低优先级线程间接阻塞,导致实际执行顺序和优先级设计相反。
经典三线程场景:
Low:低优先级线程,先拿到一把锁。High:高优先级线程,需要这把锁,于是被阻塞。Medium:中优先级线程,不需要这把锁,但一直抢占 CPU。- 结果:
Low没机会运行并释放锁,High就一直等。
C++ 示意代码
c
#include <chrono> // 引入 chrono,用来模拟任务执行耗时。
#include <mutex> // 引入 mutex,用来模拟共享资源锁。
#include <thread> // 引入 thread,用来创建线程。
std::mutex resourceMutex; // 定义共享资源锁。
void LowPriorityTask() // 定义低优先级任务。
{ // 函数开始。
resourceMutex.lock(); // 低优先级线程先拿到锁。
std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 模拟低优先级线程在临界区里工作。
resourceMutex.unlock(); // 低优先级线程释放锁。
} // 函数结束。
void HighPriorityTask() // 定义高优先级任务。
{ // 函数开始。
resourceMutex.lock(); // 高优先级线程想拿锁,如果锁被 Low 持有,就会阻塞。
resourceMutex.unlock(); // 拿到锁后很快释放。
} // 函数结束。
void MediumPriorityTask() // 定义中优先级任务。
{ // 函数开始。
for (int i = 0; i < 1000000; ++i) // 模拟中优先级线程持续占用 CPU。
{ // for 循环开始。
int temp = i * i; // 做一点计算,表示它在消耗 CPU 时间。
(void)temp; // 避免编译器提示变量未使用。
} // for 循环结束。
} // 函数结束。这段代码只是示意结构,真正的线程优先级和调度行为依赖操作系统 API,不是标准 C++ 本身能完全控制的。
解决方式
常见解决方案是 优先级继承: 当 High 等待 Low 持有的锁时,系统临时把 Low 的优先级提升到接近 High,让 Low 尽快运行完临界区并释放锁。释放锁后,Low 的优先级恢复。
也可以通过这些工程手段减少问题:
- 缩短临界区。
- 避免高优先级线程等待低优先级线程持有的锁。
- 使用无锁队列、消息队列或双缓冲降低共享锁。
- 对关键线程的锁等待时间做监控。
游戏开发里的例子
主线程或渲染相关线程等待资源锁,而后台加载线程持锁过久,中间又有其他任务占用 CPU,就可能造成主线程卡帧。面试里可以说:我会重点看锁等待时间、持锁时长、线程优先级和阻塞调用栈。
什么是自旋锁?
标准答案
自旋锁就是:线程抢不到锁时不睡眠、不阻塞,而是在原地循环反复尝试抢锁。
普通互斥锁 mutex 抢不到时,线程通常会被挂起,等锁释放后再被唤醒;自旋锁抢不到时,会一直占着 CPU 检查锁有没有释放。
底层原理
自旋锁一般依赖原子操作,比如 CAS 或 test_and_set:
如果 flag 原来是 false,把它改成 true,说明抢锁成功
如果 flag 原来是 true,说明别人持锁,继续循环等待所以自旋锁的等待方式就是“忙等”。
C++ 简化实现
c
#include <atomic> // 引入 atomic,用来使用原子标志位实现自旋锁。
class SpinLock // 定义一个自旋锁类。
{ // 类开始。
public: // 公有成员区域开始。
SpinLock() = default; // 使用默认构造函数。
SpinLock(const SpinLock&) = delete; // 禁止拷贝构造,避免两个锁对象共享错误状态。
SpinLock& operator=(const SpinLock&) = delete; // 禁止赋值,避免锁状态被复制。
void lock() // 加锁函数。
{ // lock 函数开始。
while (flag.test_and_set(std::memory_order_acquire)) // 原子地把 flag 设为 true,如果之前已经是 true,就说明抢锁失败。
{ // while 循环开始。
} // while 循环结束,空循环表示一直自旋等待。
} // lock 函数结束。
void unlock() // 解锁函数。
{ // unlock 函数开始。
flag.clear(std::memory_order_release); // 原子地把 flag 清为 false,让其他线程可以抢到锁。
} // unlock 函数结束。
private: // 私有成员区域开始。
std::atomic_flag flag = ATOMIC_FLAG_INIT; // 原子标志位,false 表示没加锁,true 表示已加锁。
}; // SpinLock 类结束。什么时候适合
适合临界区非常短、锁竞争很低、等待时间可预测的场景。 不适合临界区很长、会做 IO、会等待资源、线程数远超 CPU 核心数的场景。
面试加分说法
自旋锁的优点是避免线程睡眠和唤醒成本;缺点是等待期间会一直消耗 CPU。游戏开发里主线程长时间自旋会直接卡帧,移动端还会增加发热,所以通常只在非常短的底层同步场景使用。
自旋锁和互斥锁怎么选?
标准答案
自旋锁和互斥锁的选择标准是:等待时间极短选自旋锁;等待时间不确定或可能较长选互斥锁。
自旋锁是“抢不到就继续占着 CPU 反复抢”,互斥锁是“抢不到就睡眠,等系统唤醒”。所以它们本质是在做取舍:
- 自旋锁:用 CPU 换低延迟。
- 互斥锁:用调度成本换 CPU 释放。
怎么选
选自旋锁:
- 临界区非常短。
- 只改几个变量或状态位。
- 锁竞争很低。
- 等待时间可预测。
- 不会在锁里做 IO、内存分配、sleep、复杂逻辑。
- 多核环境下,持锁线程很快能释放。
选互斥锁:
- 临界区较长。
- 等待时间不可预测。
- 锁竞争较高。
- 线程数大于 CPU 核心数很多。
- 临界区里可能做复杂逻辑、IO、资源加载、等待。
- 不希望等待线程一直烧 CPU。
C++ 示例
c
#include <atomic> // 引入 atomic,用来实现自旋锁。
#include <mutex> // 引入 mutex,用来使用互斥锁。
class SpinLock // 定义一个简单自旋锁。
{ // 类开始。
public: // 公有成员区域开始。
void lock() // 定义加锁函数。
{ // lock 函数开始。
while (flag.test_and_set(std::memory_order_acquire)) // 如果锁已被占用,就一直循环尝试。
{ // while 循环开始。
} // while 循环结束,抢到锁后退出循环。
} // lock 函数结束。
void unlock() // 定义解锁函数。
{ // unlock 函数开始。
flag.clear(std::memory_order_release); // 清除锁标志,让其他线程可以进入。
} // unlock 函数结束。
private: // 私有成员区域开始。
std::atomic_flag flag = ATOMIC_FLAG_INIT; // 原子标志位,表示锁是否被占用。
}; // SpinLock 类结束。
SpinLock spinLock; // 创建自旋锁,适合非常短的临界区。
std::mutex mutexLock; // 创建互斥锁,适合等待时间不确定的临界区。
void UseSpinLock() // 演示使用自旋锁。
{ // 函数开始。
spinLock.lock(); // 加自旋锁,抢不到会一直占 CPU 尝试。
int value = 1; // 模拟非常短的临界区,只做简单赋值。
(void)value; // 避免未使用变量警告。
spinLock.unlock(); // 立即释放自旋锁。
} // 函数结束。
void UseMutexLock() // 演示使用互斥锁。
{ // 函数开始。
std::lock_guard<std::mutex> guard(mutexLock); // 使用 RAII 自动加锁和解锁。
int value = 1; // 模拟普通临界区。
(void)value; // 避免未使用变量警告。
} // 函数结束。游戏开发里的说法
主线程尽量不要自旋,因为自旋会直接占用这一帧的 CPU 时间,可能造成卡顿。移动端也要慎用,因为自旋会增加发热和掉频风险。实际项目里我会先看锁等待时间、持锁时长、竞争率、CPU 使用率,再决定用自旋、互斥锁,还是改成无锁队列、双缓冲或任务队列。
什么是内存屏障?
标准答案
内存屏障,也叫 Memory Barrier 或 Memory Fence,它的作用是:限制编译器和 CPU 对内存读写的重排序,保证多线程之间看到的读写顺序符合预期。
它不是“锁”,也不是“互斥”。 它解决的是:
- 读写顺序问题
- 跨线程可见性问题
- CPU / 编译器重排序问题
底层原理
CPU 和编译器为了优化性能,可能会把代码顺序调整。单线程下通常看不出问题,但多线程下就可能出事。
比如生产者线程:
c
data = 42;
ready = true;消费者线程:
c
if (ready)
read data;如果没有顺序约束,另一个线程可能先看到 ready == true,但还没看到 data = 42 的结果。
C++ 示例:Release / Acquire
c
#include <atomic> // 引入 atomic,用来使用原子变量和内存序。
#include <iostream> // 引入 iostream,用来输出结果。
#include <thread> // 引入 thread,用来创建线程。
int data = 0; // 普通共享数据,由 ready 的 release/acquire 同步保护。
std::atomic<bool> ready(false); // 原子标志位,表示 data 是否已经写好。
void Producer() // 定义生产者线程函数。
{ // Producer 函数开始。
data = 42; // 先写入真正的数据。
ready.store(true, std::memory_order_release); // 再用 release 发布 ready,保证 data 写入不会被重排到 ready 后面。
} // Producer 函数结束。
void Consumer() // 定义消费者线程函数。
{ // Consumer 函数开始。
while (!ready.load(std::memory_order_acquire)) // 用 acquire 读取 ready,看到 true 后能同步到 release 前的数据写入。
{ // while 循环开始。
} // while 循环结束,这里只是演示内存序,实际长等待不建议忙等。
std::cout << data << std::endl; // 这里能看到 Producer 发布前写入的 data。
} // Consumer 函数结束。
int main() // 程序入口函数。
{ // main 函数开始。
std::thread producer(Producer); // 创建生产者线程。
std::thread consumer(Consumer); // 创建消费者线程。
producer.join(); // 等待生产者线程结束。
consumer.join(); // 等待消费者线程结束。
return 0; // 返回 0,表示程序正常结束。
} // main 函数结束。面试加分说法
release 像是“我数据写完了,再举旗”。 acquire 像是“我看到旗以后,再读数据”。
所以:
memory_order_release:保证它前面的写,不会跑到它后面。memory_order_acquire:保证它后面的读,不会跑到它前面。memory_order_seq_cst:最强顺序,最容易理解,但可能更贵。
常见坑
内存屏障不是锁,不能自动解决数据竞争。 如果多个线程同时读写同一个普通变量,仍然需要锁、原子变量或其他同步机制。
什么是缓存一致性?
标准答案
缓存一致性是指:多核 CPU 中,同一个内存地址可能被多个核心缓存,但这些副本不能长期不一致。一个核心修改后,其他核心要么看到新值,要么让旧缓存失效后重新加载。
简单说:大家不能各自拿着同一个变量的不同版本。
底层原理
每个 CPU 核心通常都有自己的 L1/L2 缓存。 如果 Core 0 和 Core 1 都缓存了变量 x,然后 Core 0 把 x 改成 1,那么 Core 1 不能一直读自己缓存里的旧 x = 0。
硬件通常通过类似 MESI 的缓存一致性协议维护:
MModified:当前核心修改过,内存可能不是最新。EExclusive:只有当前核心独占,值和内存一致。SShared:多个核心共享同一缓存行。IInvalid:缓存行失效,下次要重新加载。
C++ 示例:避免 False Sharing
c
#include <atomic> // 引入 atomic,用来定义线程安全计数器。
#include <cstddef> // 引入 cstddef,用来使用 std::size_t。
struct BadCounters // 定义一个容易产生伪共享的结构体。
{ // 结构体开始。
std::atomic<int> a; // 计数器 a,可能和 b 落在同一个缓存行。
std::atomic<int> b; // 计数器 b,不同线程写它也可能影响 a 所在缓存行。
}; // 结构体结束。
struct alignas(64) PaddedCounter // 定义一个按 64 字节对齐的计数器,常见缓存行大小是 64 字节。
{ // 结构体开始。
std::atomic<int> value; // 真正的原子计数值。
}; // 结构体结束。
struct GoodCounters // 定义一个减少伪共享的结构体。
{ // 结构体开始。
PaddedCounter a; // 计数器 a 独占或尽量独占一个缓存行。
PaddedCounter b; // 计数器 b 独占或尽量独占另一个缓存行。
}; // 结构体结束。重要区别
缓存一致性解决的是:同一个地址的多个缓存副本是否一致。
但它不等于:
- 不等于锁。
- 不等于没有数据竞争。
- 不等于保证所有操作顺序都符合你的代码顺序。
如果多个线程同时读写同一个变量,仍然要用 mutex、atomic、内存序或其他同步手段。
游戏开发里的意义
在 Job System、ECS、多线程 AI、大量实体更新里,如果多个线程频繁写同一个缓存行,会导致缓存行在核心之间来回失效,性能会掉得很明显。常见优化是数据分块、减少共享写、避免 false sharing、使用 SoA 布局和按线程分配独立缓冲。
什么是伪共享?
标准答案
伪共享就是:多个线程明明在写不同变量,但这些变量刚好落在同一条 CPU 缓存行里,导致缓存一致性协议频繁让整条缓存行失效,性能被拖慢。
它“伪”在这里:
- 代码层面:线程 A 写
counterA,线程 B 写counterB,看起来没有共享同一个变量。 - 硬件层面:CPU 按 cache line 管理缓存,
counterA和counterB在同一条缓存行,所以它们会互相影响。
C++ 示例:用 padding / alignas 避免伪共享
c
#include <atomic> // 引入 atomic,用来定义线程安全计数器。
#include <thread> // 引入 thread,用来创建测试线程。
struct BadCounters // 定义容易产生伪共享的计数器结构。
{ // 结构体开始。
std::atomic<int> counterA{0}; // 计数器 A,可能和 counterB 在同一条缓存行。
std::atomic<int> counterB{0}; // 计数器 B,另一个线程写它时可能让 counterA 所在缓存行失效。
}; // 结构体结束。
struct alignas(64) PaddedCounter // 定义按 64 字节对齐的计数器,常见缓存行大小是 64 字节。
{ // 结构体开始。
std::atomic<int> value{0}; // 真正的计数值。
}; // 结构体结束。
struct GoodCounters // 定义减少伪共享的计数器结构。
{ // 结构体开始。
PaddedCounter counterA; // 计数器 A 尽量独占一条缓存行。
PaddedCounter counterB; // 计数器 B 尽量独占另一条缓存行。
}; // 结构体结束。
void Add(std::atomic<int>* counter, int times) // 定义累加函数,counter 指向要累加的计数器。
{ // 函数开始。
for (int i = 0; i < times; ++i) // 循环累加指定次数。
{ // for 循环开始。
counter->fetch_add(1, std::memory_order_relaxed); // 原子加 1,这里只统计次数,不需要额外同步顺序。
} // for 循环结束。
} // 函数结束。
int main() // 程序入口函数。
{ // main 函数开始。
GoodCounters counters; // 创建优化后的计数器,避免两个高频写变量挤在同一缓存行。
int times = 1000000; // 设置每个线程累加次数。
std::thread t1(Add, &counters.counterA.value, times); // 线程 1 只写 counterA。
std::thread t2(Add, &counters.counterB.value, times); // 线程 2 只写 counterB。
t1.join(); // 等待线程 1 结束。
t2.join(); // 等待线程 2 结束。
return 0; // 返回 0,表示程序正常结束。
} // main 函数结束。面试加分说法
伪共享不是数据竞争。它是缓存行粒度导致的性能问题。 多个线程没有写同一个变量,也可能因为变量相邻、落在同一条 cache line 上,造成 cache line 在核心之间来回失效,也就是常说的 cache line ping-pong。
游戏开发里的例子
在 Job System、ECS、多线程 AI、大量实体更新里,如果每个线程都更新自己的计数器,但这些计数器存在一个连续数组里,就可能产生伪共享。常见处理方式是:每线程独立缓冲、按 cache line 对齐、padding、分块处理,最后再统一归并结果。
什么是 DMA?
标准答案
DMA 是 Direct Memory Access,直接内存访问。 它的作用是:让外设或 DMA 控制器直接和内存搬数据,CPU 不需要一字节一字节拷贝。
CPU 主要负责:
- 配置源地址、目标地址、数据长度、传输方向。
- 启动 DMA。
- DMA 搬运完成后,通过中断或回调通知 CPU。
- CPU 再处理结果、错误状态、缓存同步等收尾工作。
C++ 伪代码示意
c
#include <cstddef> // 引入 size_t,用来表示传输字节数。
#include <cstdint> // 引入 uintptr_t,用来表示硬件寄存器地址或物理地址。
struct DmaRequest // 定义一个 DMA 请求结构体。
{ // 结构体开始。
std::uintptr_t deviceAddress; // 外设缓冲区地址。
std::uintptr_t memoryAddress; // 内存缓冲区地址。
std::size_t byteCount; // 要搬运的数据字节数。
}; // 结构体结束。
void StartDmaTransfer(const DmaRequest& request) // 启动一次 DMA 传输的伪代码函数。
{ // 函数开始。
WriteDmaRegister("DEVICE_ADDR", request.deviceAddress); // 配置外设地址。
WriteDmaRegister("MEMORY_ADDR", request.memoryAddress); // 配置内存地址。
WriteDmaRegister("BYTE_COUNT", request.byteCount); // 配置传输长度。
WriteDmaRegister("CONTROL", 1); // 写控制寄存器,启动 DMA。
} // 函数结束。这只是伪代码。真实 DMA 通常由操作系统内核、驱动或硬件抽象层控制,普通应用层代码一般不能直接操作 DMA 寄存器。
游戏开发理解
资源异步加载、音频播放、网卡收包、纹理上传到 GPU,都可能在底层用到 DMA 思想。它能减少 CPU 参与大块数据搬运的成本,但仍会占用总线和内存带宽,还要注意缓存一致性和完成通知。
什么是 mmap?
标准答案
mmap 是 memory map,叫“内存映射”。它的核心作用是:把文件的一段内容映射到进程的虚拟地址空间里,之后程序可以像访问内存一样访问文件内容。
它不是把整个文件立刻读进内存,而是先建立“虚拟地址 -> 文件页”的映射关系。真正访问某个地址时,如果对应页还没在内存里,就触发缺页异常,由操作系统把对应文件页调入内存。
底层原理
普通 read 大概是:
磁盘/文件 -> 内核 Page Cache -> 拷贝到用户缓冲区 -> 程序读取 buffer而 mmap 更像是:
文件页 -> 映射到进程虚拟地址空间 -> 程序通过指针访问所以 mmap 的优势是少一次显式用户态拷贝,适合大文件、随机访问、资源包索引、日志、回放文件等场景。
但是它也不是万能更快。它可能带来缺页开销、TLB/cache 压力、内存压力;如果映射期间文件被截断,访问非法页还可能出问题,比如 POSIX 下可能触发 SIGBUS。
常见模式
MAP_SHARED:多个进程可以共享映射,修改可能写回文件。
MAP_PRIVATE:写时复制,修改只影响当前进程,不直接改原文件。
Windows 下类似机制是 CreateFileMapping + MapViewOfFile。
代码示例:POSIX mmap 读取文件
c
#include <fcntl.h> // 提供 open 函数和 O_RDONLY 常量。
#include <sys/mman.h> // 提供 mmap、munmap、PROT_READ、MAP_PRIVATE。
#include <sys/stat.h> // 提供 stat 结构体和 fstat 函数。
#include <unistd.h> // 提供 close 函数。
#include <cstddef> // 提供 std::size_t 类型。
#include <iostream> // 提供 std::cout 和 std::cerr。
int main() // 程序入口函数。
{ // main 函数体开始。
int fd = open("data.bin", O_RDONLY); // 以只读方式打开要映射的文件。
if (fd == -1) // 判断文件是否打开失败。
{ // 打开失败分支开始。
std::cerr << "open failed\n"; // 输出打开失败提示。
return 1; // 返回错误码结束程序。
} // 打开失败分支结束。
struct stat fileStat; // 创建文件状态结构体,用来保存文件大小等信息。
if (fstat(fd, &fileStat) == -1) // 获取文件信息并判断是否失败。
{ // 获取文件信息失败分支开始。
close(fd); // 关闭已经打开的文件描述符。
return 1; // 返回错误码结束程序。
} // 获取文件信息失败分支结束。
std::size_t length = static_cast<std::size_t>(fileStat.st_size); // 把文件大小转换成映射长度。
if (length == 0) // 判断文件是否为空文件。
{ // 空文件分支开始。
close(fd); // 空文件不需要映射,直接关闭文件。
return 0; // 正常结束程序。
} // 空文件分支结束。
void* mapped = mmap(nullptr, length, PROT_READ, MAP_PRIVATE, fd, 0); // 把文件从偏移 0 开始映射到进程虚拟地址空间。
if (mapped == MAP_FAILED) // 判断 mmap 是否映射失败。
{ // 映射失败分支开始。
close(fd); // 关闭文件描述符,避免资源泄漏。
return 1; // 返回错误码结束程序。
} // 映射失败分支结束。
const char* data = static_cast<const char*>(mapped); // 把映射地址转换成字符指针,方便按字节访问。
std::cout << data[0] << std::endl; // 访问第一个字节,可能触发缺页加载。
munmap(mapped, length); // 解除映射,归还虚拟地址区间。
close(fd); // 关闭文件描述符。
return 0; // 正常结束程序。
} // main 函数体结束。什么是 Copy-on-Write?
标准答案
Copy-on-Write,简称 COW,意思是“写时复制”。它的核心思想是:拷贝时先不真正复制大块数据,而是让多个对象共享同一份数据;只有当其中一个对象要修改数据时,才复制出一份新的数据再修改。
一句话记:读共享,写分裂。
底层原理
常见实现有两类:
- 用户态对象:用引用计数记录有多少对象共享同一份数据。写入前检查引用计数,如果
ref > 1,先复制一份,再修改。 - 操作系统:比如
fork或MAP_PRIVATE mmap,父子进程先共享物理页,页表标记为只读;一方写入时触发页保护异常,内核复制新物理页,再更新页表。
它的优点是拷贝成本低、省内存,特别适合“读多写少”的场景。缺点是第一次写入可能突然变慢,而且多线程下要处理同步问题。
C++ 示例:简化版 COW 字符串
c
#include <iostream> // 引入标准输出库,用来打印结果。
#include <memory> // 引入智能指针库,用 shared_ptr 做引用计数。
#include <string> // 引入 string 字符串类型。
class CowString // 定义一个支持写时复制的简化字符串类。
{ // 类定义开始。
private: // 私有成员区域开始。
std::shared_ptr<std::string> data_; // 多个 CowString 可以共享同一个 string 数据。
void Detach() // 写入前调用,用来确保当前对象独占数据。
{ // Detach 函数开始。
if (!data_.unique()) // 如果当前数据不是独占,说明还有别的对象共享它。
{ // 需要复制的分支开始。
data_ = std::make_shared<std::string>(*data_); // 复制一份新字符串,让当前对象独占。
} // 需要复制的分支结束。
} // Detach 函数结束。
public: // 公有成员区域开始。
explicit CowString(std::string s) // 构造函数,接收一个普通字符串。
: data_(std::make_shared<std::string>(std::move(s))) // 把字符串放进 shared_ptr 管理的数据块中。
{ // 构造函数体开始。
} // 构造函数体结束。
char At(std::size_t index) const // 只读访问字符,不需要复制数据。
{ // At 函数开始。
return (*data_)[index]; // 返回指定下标的字符。
} // At 函数结束。
void Set(std::size_t index, char value) // 修改指定下标的字符。
{ // Set 函数开始。
Detach(); // 写入前先分离,避免修改到其他对象共享的数据。
(*data_)[index] = value; // 当前对象已经独占数据,可以安全修改。
} // Set 函数结束。
long UseCount() const // 查看当前数据被多少个对象共享。
{ // UseCount 函数开始。
return data_.use_count(); // 返回 shared_ptr 的引用计数。
} // UseCount 函数结束。
}; // CowString 类定义结束。
int main() // 程序入口函数。
{ // main 函数开始。
CowString a("hello"); // 创建字符串 a,此时 a 独占数据。
CowString b = a; // 拷贝 a 得到 b,此时 a 和 b 共享同一份数据。
std::cout << a.UseCount() << "\n"; // 输出引用计数,此时通常是 2。
b.Set(0, 'H'); // 修改 b,触发写时复制,b 拥有自己的新数据。
std::cout << a.At(0) << " " << b.At(0) << "\n"; // 输出 h 和 H,说明 a 没被 b 的修改影响。
return 0; // 程序正常结束。
} // main 函数结束。面试补充
现代 C++ 的 std::string 通常不再依赖 COW,因为 C++11 之后对迭代器、引用、多线程语义要求更严格,COW 字符串会带来额外复杂度。操作系统层面的 COW 仍然非常常见,比如 fork、私有内存映射、虚拟内存页管理。