Skip to content

操作系统

什么是上下文切换?

cpp-context-switch

标准答案

上下文切换就是:CPU 暂停当前正在执行的线程或进程,保存它的执行现场,然后恢复另一个线程或进程的执行现场,让 CPU 去执行新的任务。

这里的“上下文”可以理解成“下次还能从原地继续跑所需要的信息”,比如:

  • 程序计数器 PC:代码执行到哪里了
  • 栈指针 SP:当前函数调用栈在哪里
  • CPU 寄存器:临时计算数据
  • 线程状态:运行、就绪、阻塞
  • 进程切换时还可能涉及页表、地址空间、TLB 等

底层原理

一次典型上下文切换大概是:

  1. 线程 A 正在 CPU 上运行。
  2. 时间片用完、等待 IO、锁阻塞、主动 yield,或者被高优先级线程抢占。
  3. CPU 进入内核调度逻辑。
  4. 操作系统把线程 A 的寄存器、PC、SP 等现场保存到线程控制块里。
  5. 调度器选择线程 B。
  6. 操作系统把线程 B 上次保存的现场恢复到 CPU。
  7. 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 命中率。游戏开发里如果线程太多、锁竞争严重、主线程频繁等待子线程,就容易造成帧时间抖动和卡顿。

线程越多越快吗?

cpp-more-threads-faster

标准答案

线程不是越多越快。线程只是“执行任务的单位”,真正能同时计算的是 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-coroutine-thread-lightweight

标准答案

协程比线程轻量,是因为协程通常不是操作系统线程。 以 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。

线程池解决什么问题?

cpp-thread-pool-purpose

标准答案

线程池解决的核心问题是:不要每来一个任务就创建一个线程,而是提前创建一批固定 worker 线程,让任务排队,由空闲线程复用执行。

它主要解决 4 个问题:

  1. 减少线程创建和销毁成本:线程创建、栈分配、系统调度都有成本。
  2. 控制并发数量:避免任务一多就无限开线程,导致线程爆炸。
  3. 任务排队与削峰:高峰时任务先进队列,worker 慢慢消费。
  4. 提高系统稳定性:减少上下文切换、锁竞争、内存占用抖动。

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,无限任务队列还可能导致内存上涨。

什么是忙等?

cpp-busy-wait

标准答案

忙等就是:线程在条件没有满足时,不阻塞、不睡眠,而是在循环里不停检查条件。

典型写法:

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,或者子线程条件变量,而不是在主线程空转。

什么是饥饿?

cpp-thread-starvation

标准答案

饥饿就是:系统还在正常运行,但某个线程、任务或请求长期拿不到 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、优先级老化、超时重试、限流、减少锁粒度,或者避免某类任务无限插队。

什么是优先级反转?

cpp-priority-inversion

标准答案

优先级反转是指:高优先级线程因为等待低优先级线程持有的资源,反而被低优先级线程间接阻塞,导致实际执行顺序和优先级设计相反。

经典三线程场景:

  • 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,就可能造成主线程卡帧。面试里可以说:我会重点看锁等待时间、持锁时长、线程优先级和阻塞调用栈。

什么是自旋锁?

cpp-spinlock

标准答案

自旋锁就是:线程抢不到锁时不睡眠、不阻塞,而是在原地循环反复尝试抢锁。

普通互斥锁 mutex 抢不到时,线程通常会被挂起,等锁释放后再被唤醒;自旋锁抢不到时,会一直占着 CPU 检查锁有没有释放。

底层原理

自旋锁一般依赖原子操作,比如 CAStest_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。游戏开发里主线程长时间自旋会直接卡帧,移动端还会增加发热,所以通常只在非常短的底层同步场景使用。

自旋锁和互斥锁怎么选?

cpp-spinlock-vs-mutex-choice

标准答案

自旋锁和互斥锁的选择标准是:等待时间极短选自旋锁;等待时间不确定或可能较长选互斥锁。

自旋锁是“抢不到就继续占着 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 使用率,再决定用自旋、互斥锁,还是改成无锁队列、双缓冲或任务队列。

什么是内存屏障?

cpp-memory-barrier

标准答案

内存屏障,也叫 Memory BarrierMemory 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:最强顺序,最容易理解,但可能更贵。

常见坑

内存屏障不是锁,不能自动解决数据竞争。 如果多个线程同时读写同一个普通变量,仍然需要锁、原子变量或其他同步机制。

什么是缓存一致性?

cpp-cache-coherence

标准答案

缓存一致性是指:多核 CPU 中,同一个内存地址可能被多个核心缓存,但这些副本不能长期不一致。一个核心修改后,其他核心要么看到新值,要么让旧缓存失效后重新加载。

简单说:大家不能各自拿着同一个变量的不同版本。

底层原理

每个 CPU 核心通常都有自己的 L1/L2 缓存。 如果 Core 0Core 1 都缓存了变量 x,然后 Core 0x 改成 1,那么 Core 1 不能一直读自己缓存里的旧 x = 0

硬件通常通过类似 MESI 的缓存一致性协议维护:

  • M Modified:当前核心修改过,内存可能不是最新。
  • E Exclusive:只有当前核心独占,值和内存一致。
  • S Shared:多个核心共享同一缓存行。
  • I Invalid:缓存行失效,下次要重新加载。

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 独占或尽量独占另一个缓存行。
}; // 结构体结束。

重要区别

缓存一致性解决的是:同一个地址的多个缓存副本是否一致。

但它不等于:

  • 不等于锁。
  • 不等于没有数据竞争。
  • 不等于保证所有操作顺序都符合你的代码顺序。

如果多个线程同时读写同一个变量,仍然要用 mutexatomic、内存序或其他同步手段。

游戏开发里的意义

在 Job System、ECS、多线程 AI、大量实体更新里,如果多个线程频繁写同一个缓存行,会导致缓存行在核心之间来回失效,性能会掉得很明显。常见优化是数据分块、减少共享写、避免 false sharing、使用 SoA 布局和按线程分配独立缓冲。

什么是伪共享?

cpp-false-sharing

标准答案

伪共享就是:多个线程明明在写不同变量,但这些变量刚好落在同一条 CPU 缓存行里,导致缓存一致性协议频繁让整条缓存行失效,性能被拖慢。

它“伪”在这里:

  • 代码层面:线程 A 写 counterA,线程 B 写 counterB,看起来没有共享同一个变量。
  • 硬件层面:CPU 按 cache line 管理缓存,counterAcounterB 在同一条缓存行,所以它们会互相影响。

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?

cpp-dma

标准答案

DMA 是 Direct Memory Access,直接内存访问。 它的作用是:让外设或 DMA 控制器直接和内存搬数据,CPU 不需要一字节一字节拷贝。

CPU 主要负责:

  1. 配置源地址、目标地址、数据长度、传输方向。
  2. 启动 DMA。
  3. DMA 搬运完成后,通过中断或回调通知 CPU。
  4. 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?

cpp-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?

cpp-copy-on-write

标准答案

Copy-on-Write,简称 COW,意思是“写时复制”。它的核心思想是:拷贝时先不真正复制大块数据,而是让多个对象共享同一份数据;只有当其中一个对象要修改数据时,才复制出一份新的数据再修改。

一句话记:读共享,写分裂。

底层原理

常见实现有两类:

  1. 用户态对象:用引用计数记录有多少对象共享同一份数据。写入前检查引用计数,如果 ref > 1,先复制一份,再修改。
  2. 操作系统:比如 forkMAP_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、私有内存映射、虚拟内存页管理。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

本站访客数0总站访问量0本页访问量0