智能指针相关
Cpp 智能指针是什么?请简单介绍一下。
参考回答
智能指针是一个封装了原始指针的类模板,它的主要作用就是通过 RAII 原则,在指针对象析构时自动释放资源,从而避免“忘记手动 delete”导致的资源泄漏,或者调用野指针的问题。
所谓的 RAII,指的就是:
- 构造时:接过原始指针,这代表着接管了原始指针的资源。
- 析构时:在析构函数里自动释放资源。
- 它将原本需要人工管理的“内存申请与释放”,变成了编译器自动管理的“局部变量生命周期”。
C++11 及以后,主要提供了三种智能指针,代表三种管理资源的逻辑:
(1) std::unique_ptr —— “独占权”
- 所有权:同一时间只能有一个
unique_ptr指向该资源。 - 性能:零开销(Zero Overhead)。它的执行效率和原始指针几乎一模一样。
- 限制:不能拷贝,只能移动(
std::move)。 - 场景:最常用的指针。适用于明确知道“谁是主人”的场景。
(2) std::shared_ptr —— “共享权”
- 所有权:多个指针可以指向同一个资源。
- 机制:正如我们刚才聊的,它通过控制块里的引用计数来工作。
- 性能:有一定开销(需要原子操作增减计数,需要额外堆内存存控制块)。
- 场景:资源需要被多个模块共享,且无法确定谁最后退出的场景。
(3) std::weak_ptr —— “观察者”
- 所有权:不拥有所有权。它只是一个旁观者。
- 机制:指向
shared_ptr管理的对象,但不增加强引用计数。 - 场景:解决
shared_ptr的循环引用问题,或者作为缓存监控(防悬空)。
你提到了 shared_ptr,请问它是线程安全的吗?
参考回答
最准确的回答是:shared_ptr 的引用计数是线程安全的,但 shared_ptr 对象本身以及它管理的数据不是。
我们可以把这个问题拆解为三个层面:
账本(引用计数)是安全的
正如我们之前讨论的,控制块里的引用计数使用的是原子操作(Atomic Operations)。
- 场景:线程 A 拷贝了一个
shared_ptr,线程 B 也拷贝了一个。 - 物理表现:两个线程同时去增加同一个控制块里的计数。
- 结果:安全。C++ 标准保证原子操作下计数不会错乱,对象会被正确释放。
管理的数据(对象本身)是不安全的
shared_ptr 只负责管“生死”,不负责管“读写”。
- 场景:线程 A 通过
shared_ptr修改对象成员obj->value = 1,同时线程 B 也在修改obj->value = 2。 - 结果:不安全。这会发生数据竞争(Data Race)。你依然需要
std::mutex或其他同步机制来保护对象内部的数据。
指针句柄(Handle)本身是不安全的
这是最容易被忽略的一点。shared_ptr 是一个由“数据指针”和“控制块指针”组成的胖指针。
- 场景:线程 A 正在执行
p1 = p2(写操作),同时线程 B 也在执行p1 = p3。 - 物理表现:由于
p1有两个内部指针,线程 A 可能刚改完第一个,就被线程 B 抢占去改第二个了。 - 结果:不安全。这会导致
p1内部的两个指针指向了不匹配的控制块和数据,程序会立刻崩溃。
那么, shared_ptr 的计数是怎么保证线程安全的?
参考回答
它是通过控制块里的原子整型变量实现的。具体来说,利用 CPU 的 CAS (Compare-And-Swap) 或 Fetch-and-Add 指令来保证计数的增减是原子的。同时,利用内存屏障来确保在最后一个线程销毁对象时,内存的可见性是正确的。
具体来说,在 shared_ptr 的控制块里,引用计数通常是一个 std::atomic<long> 类型。
- 增加计数:当执行拷贝构造时,调用的是类似
fetch_add的操作。 - 减少计数:当析构或重新赋值时,调用的是
fetch_sub。
关键点:这些操作是由 CPU 的原子指令(如 x86 架构下的 LOCK 前缀指令)保证的,不需要经过内核态的互斥锁(Mutex),所以开销非常小。
为了保证性能,shared_ptr 的实现通常使用了不同的内存序(Memory Order):
- 增加计数(Increment):通常使用
memory_order_relaxed。因为增加计数只是为了标记“有人在用”,不涉及复杂的同步。 - 减少计数(Decrement):必须使用
memory_order_acq_rel(获取-释放语义)。- 原因:当你把计数减到 0 准备
delete对象时,必须保证所有其他线程之前对该对象的修改都对当前线程可见。
- 原因:当你把计数减到 0 准备
原子操作不仅保证了数字加减是对的,还像一个“哨兵”一样,确保在销毁对象前,内存里的数据已经同步完成了。
举个例子:线程A修改完了数据并且析构,但由于在不同核心,这个修改的数据可能还在这个核心的缓存中,而没有同步到主存(Main Memory)中,导致线程B不知道这份数据修改了,在线程B析构自己的指针时,就会出现状态不一致导致的内存泄露的或者double free的情况。
而通过acquire-release这个顺序,强制插入内存屏障指令,CPU 可以保证,在线程 B 这里计数归零的时候,线程 A 的修改已经被线程 B 看见了。
那么为什么不直接加锁?
- 开销对比:
std::mutex涉及系统调用,可能导致线程上下文切换,耗时在 微秒(μs) 级别;而原子操作是 CPU 指令级的,耗时在 纳秒(ns) 级别。 - 量化需求:在低延迟交易系统中,任何一次不必要的上下文切换都是不可接受的。
如何在类中创建一个方法,使得这个方法能获取 this 指针的 shared_ptr?
参考回答
要在类中获取指向自身的 shared_ptr,绝对不能直接用 this 构造 shared_ptr(比如 return std::shared_ptr<MyClass>(this))—— 这是致命错误:如果该类对象已经被一个 shared_ptr 管理,直接用 this 构造会生成第二个独立的控制块,两个 shared_ptr 各自维护引用计数,最终析构时会重复释放同一块内存,引发程序崩溃(未定义行为)。
正确的做法是让类继承std::enable_shared_from_this<T>模板类(C++11 起),然后在成员方法中调用shared_from_this(),就能安全获取指向当前对象的shared_ptr。
| |
原理:std::enable_shared_from_this内部维护了一个weak_ptr<T>成员,当该类对象被shared_ptr管理时,这个weak_ptr会自动关联到shared_ptr的控制块;调用shared_from_this()时,会通过weak_ptr::lock()生成新的shared_ptr,共享同一个控制块和引用计数,避免重复创建控制块。
关键注意点:调用shared_from_this()的前提是 —— 当前对象已经被至少一个shared_ptr接管(比如构造函数中调用会报错,因为此时对象还没被shared_ptr管理)。
为什么直接用 this 指针创建 shared_ptr 是不安全的?
参考回答
面试回答:直接用 this 创建 shared_ptr 会导致同一个对象被两个独立的控制块管理,最终引发 Double Free,导致程序崩溃。
当你执行 shared_ptr<T> p(raw_ptr) 时,标准库会认为你是第一次接管这个原始指针,于是为它创建一个全新的控制块,并将引用计数设为 1。
崩溃路径:
- 外部创建:你已经在外部用
shared_ptr<T> external(ptr)管理了这个对象(创建了控制块 A)。 - 内部创建:在成员函数里,你又执行了
shared_ptr<T> internal(this)。 - 致命错误:
internal并不知道external的存在,它会为this创建一个全新的控制块 B。 - 释放灾难:
- 当
internal析构时,控制块 B 计数归零,执行delete this。对象被销毁。 - 当
external析构时,控制块 A 计数也归零,它尝试再次delete已经销毁的对象。 - 结果:Segmentation Fault (Double Free)。
- 当
你提到了“控制块”这个概念,请你具体解释一下什么是控制块。
参考回答
在内存中,一个 std::shared_ptr 实际上是一个 “胖指针” (Fat Pointer)。它占两个指针的大小(在 64 位系统上是 16 字节):
- 第一个指针:指向你实际存储的对象(Data)。
- 第二个指针:指向一个堆上的结构体,这个结构体就是控制块。
控制块里存了什么?
- Strong Reference Count (强引用计数):当前有多少个
shared_ptr指向这个对象。 - Weak Reference Count (弱引用计数):当前有多少个
weak_ptr指向这个对象。 - Custom Deleter (自定义删除器):如果创建时指定了如何销毁对象。
- Allocator (分配器):如果指定了内存分配方式。
核心逻辑:当你拷贝 shared_ptr 时,只有两个指针被拷贝,然后控制块里的引用计数进行原子加减。
那么,请问该怎么从内部安全地获取 this 指针的 shared_ptr?
参考回答
为了安全地从内部获取自己的 shared_ptr,C++ 提供了 std::enable_shared_from_this<T> 模板类。
正确做法:
- 继承:让你的类继承自
std::enable_shared_from_this<YourClass>。 - 调用:在成员函数中使用
shared_from_this()而不是直接构造。
| |
那么,我想继续问,平时在类外我们是如何使得两个 shared_ptr 由同一个控制块管理的呢?
参考回答
在类外,要让两个 shared_ptr 共享同一个控制块,核心只有一条路径:通过一个已有的 shared_ptr 进行拷贝或者赋值。
场景 A:拷贝构造。
| |
当你执行 p2 = p1 时,编译器内部执行的操作是:
- 拷贝数据指针。
- 拷贝控制块指针。
- 在同一个控制块里把引用计数原子加 1。
场景 B:错误的“自以为是” (再次敲响 Double Free 警钟)
如果你试图通过同一个原始指针创建两个 shared_ptr,就会重演 this 指针的悲剧:
| |
总结一下
shared_ptr实际上由两个不同的指针组成,一个指针指向实际数据(数据指针),而另一个指针则指向堆上的共享指针的元数据(控制块指针),这个元数据被称为“控制块”。- 控制块中有强引用计数和弱引用计数,表示有多少的
shared_ptr和weak_ptr指向当前的资源。 - 当我们创建一个新的共享指针时,只有上面提到的两个指针被拷贝,并且控制块中的对应计数增加。
- 当控制块中的计数归零时,
shared_ptr的资源就会被自动释放。 - 在类的内部,如果直接使用
this指针创建一个shared_ptr,那么会导致一份数据被两个不同的控制块管理,从而导致 double free 的问题。
你了解 make_shared 方法吗?请简单介绍一下。
参考回答
make_shared 不仅仅是 new 的语法糖,它是性能优化的核心。简单来说,它不需要分配两次内存,而是一次分配足够大且连续的内存,同时放下对象数据和控制块。
传统的 new 方式:
| |
这种方式需要两次内存分配:
new int在堆上分配一次内存给数据。shared_ptr的构造函数在堆上另开一块内存给控制块。
- 缺点:两次分配开销大;数据和控制块在内存中可能离得很远,对 CPU 缓存不友好。
make_shared 方式:
| |
这种方式只需要一次内存分配:
- 标准库会开辟一块足够大且连续的内存,同时放下对象数据和控制块。
- 优点:
- 更高效:只有一次内存分配请求。
- 缓存友好:数据和计数器挨在一起,减少 Cache Miss。
- 更安全:避免了
new成功但shared_ptr构造失败导致的内存泄漏。
什么情况下我们不应该用 make_shared?
参考回答
当有 weak_ptr 且对象很大时。
原因:控制块里存了强引用和弱引用,只有当两者都归零时,控制块才会被释放。
- 如果你用
make_shared,数据和控制块是在同一块内存里面的。 - 即使强引用归零(对象已经析构了),但只要还有一个
weak_ptr指向它,整块内存(包括已经没用的对象空间)都无法被释放。 - 这会导致内存占用时间过长,如果对象是个巨大的
vector,这可能是个问题。 - 如果使用裸指针来构造
shared_ptr,那么在强引用归零时就会先把对象空间释放了,只保留控制块空间,这样更加节省内存。
虚函数
虚函数是什么?请介绍一下。
参考回答
虚函数(Virtual Function)是 C++ 中用于实现 运行时多态(Runtime Polymorphism) 的核心机制。它是在基类中通过 virtual 关键字声明的成员函数,允许在派生类中被 重写(Override)。
其关键特征是 动态绑定(Dynamic Binding):当我们通过基类的指针或引用调用虚函数时,编译器不会在编译期确定调用哪个函数,而是在 运行期 根据对象的 实际类型(Actual Type) 来决定执行哪个版本的函数。这使得我们能用统一的接口处理不同的派生类对象。
虚函数表与虚函数指针:
- Vtable (虚函数表):是每个类(Class) 一份。它存在于只读数据段,记录了该类所有虚函数的地址。
- Vptr (虚函数指针):是每个对象(Object) 一份。它是一个隐藏的指针,占 8 字节(64 位系统),指向该类对应的虚表。
虚函数的存在是为了实现 开闭原则(Open-Closed Principle):我们可以在不修改现有代码(使用基类接口的部分)的情况下,通过增加新的派生类来扩展系统的功能。这在构建大型框架(如量化回测系统或数据库内核)时至关重要。
你认为析构函数可以是虚函数吗?
参考回答
回答“析构函数必须是虚函数”太绝对了,但逻辑是对的。
- 修正:只有当这个类会被作为基类指针指向,并执行
delete时,析构函数才必须是虚函数。也可以简单地说,在多态场景下,析构函数必须是虚函数。 - 后果:如果析构不是虚的,
delete base_ptr只会调用父类的析构。如果子类在构造时new了一块内存,这块内存就永远释放不掉了,造成内存泄漏。
你认为虚函数的性能如何?
参考回答
虚函数的实际调用过程:
- 获取对象的 Vptr(一次指针解引用)
- 根据偏移量在 Vtable 中找到函数地址(第二次指针解引用)
- 跳转到该地址执行(间接调用
call)
真正的性能损耗在哪里?
- 无法内联 (Inlining):这是最大的痛点。因为编译器在编译期不知道你要调哪个,所以无法把函数代码直接“塞”进调用处,导致频繁的函数调用开销。
- 分支预测失败:CPU 喜欢预测下一条指令。虚函数的地址是动态的,CPU 很难猜准,一旦猜错,整个流水线就要清空重启。
- 缓存不友好:虚表指针的跳转可能导致 Cache Miss。
如果需要优化虚函数的性能问题,你认为该怎么做?
参考回答
(1) final 关键字 (C++11)
如果你确定一个类或函数不会再被继承/覆盖,一定要加 final。
- 原理:编译器看到
final后,会意识到这个调用是确定的。它会执行 “去虚化(Devirtualization)”,直接把虚函数调用优化成普通函数调用,甚至直接内联。
(2) CRTP (模板静态多态) 这是量化开发最常用的技术。
- 方法:使用 Curiously Recurring Template Pattern。在编译期就确定调用哪个子类的方法。
- 收益:零开销多态。既能像虚函数一样用,又没有任何运行时开销。
Cpp 的新技术
Cpp17 和 Cpp20 的有什么新技术?
参考回答
C++17 的核心是简化开发和减少运行时开销。
- 结构化绑定 (Structured Bindings):
auto [iter, inserted] = my_map.insert(...);- QD 价值:让返回多个值的代码(如处理交易订单的状态)变得极其简洁,不再需要繁琐的
std::tie。
std::string_view(性能杀手锏):- 它是字符串的“只读视图”,不产生内存拷贝。
- QD 价值:在解析高频行情数据(大量字符串截取)时,能实现零拷贝(Zero-copy),极大地降低延迟。
std::optional/std::variant/std::any:- 提供了类型安全的“空值”或“多选一”容器。
- QD 价值:替代了危险的
NULL指针或void*,在编译期捕捉类型错误。
- 带初始化的 if/switch:
if (auto it = map.find(k); it != map.end()) { ... }- QD 价值:限制变量作用域,防止变量在不需要的地方污染命名空间。
C++20 是自 C++11 以来最大的变革,它引入了四个支撑 C++ 未来的“大支柱”。
- Concepts (概念):
- 对模板参数进行约束。比如:
template<typename T> requires std::integral<T>。 - QD 价值:彻底终结了晦涩难懂的模板报错信息,让通用算法(如泛型交易逻辑)的编写和纠错变得简单。
- 对模板参数进行约束。比如:
- Coroutines (协程):
- 无栈协程,允许函数暂停和恢复执行。
- QD 价值:在处理高并发的网络请求或异步 IO 时,能用同步的代码写异步的逻辑,性能极高。
- Ranges (范围库):
- 像 Python/Rust 一样进行管道操作:
auto res = data | filter(f) | transform(t); - QD 价值:极大地增强了代码的可读性和函数式编程能力。
- 像 Python/Rust 一样进行管道操作:
- Modules (模块):
- 取代传统的
#include。 - QD 价值:极大提升编译速度(大型量化回测系统的编译时间可能从小时级降到分钟级),彻底解决头文件循环依赖。
- 取代传统的
std::span:- 对连续内存(如
std::vector或数组)的轻量级视图。 - QD 价值:比
string_view更通用,保证了数组访问的安全且无拷贝。
- 对连续内存(如
并发编程简介
你了解并发编程吗?就是多线程编程?
参考回答
简单来说,并发编程就是让 CPU 的多个核心同时(或者交替)去处理不同的任务,以榨干硬件的性能。
先说说并发(Concurrency)和并行(Parallelism)的区别:
- 并发:只有一个核心,两个不同的任务交替使用,宏观上两个任务都在进行,但微观上每个时刻只有一个任务在使用核心。
- 并行:存在多个核心,多个任务在多个核心上同时运行,是真正意义上的同时进行。
当多个线程共享数据时,并发编程存在三个经典的问题,需要克服:
- 原子性 Atomicity——“账本不能记一半”
- 问题:如果某个线程的操作执行到一半,被其他线程抢占了,数据就乱了。
- 解决方法:通过原子操作或者互斥锁,来保证修改的原子性。
- 可见性 Visibility——“你改了内容,我得知道”
- 问题:线程 A 在 Core 1 修改了变量,线程 B 在 Core 2 还没看到最新值,还拿着旧值在算。
- 解决方法:利用内存屏障或者
volatile来保证
- 有序性 Ordering——“别调乱我的顺序”
- 问题:编译器和 CPU 为了快,会乱序执行指令。
- 解决方法:内存模型 (Memory Model) 规定的同步原语。
并发编程需要的工具:
- Mutex (互斥锁):就像厕所的门锁。一次只能进一个人,其他人只能在门口挂起等待(会触发我们聊过的上下文切换,开销大)。
- Spinlock (自旋锁):不挂起等待,而是在门口死循环(忙等)。适合锁定时间极短的情况,省去了上下文切换的开销。
- Atomic (原子变量):硬件级的加锁。利用 CPU 指令保证操作完整,速度极快(QD 的最爱)。
- Condition Variable (条件变量):线程间的铃铛。比如:“数据还没到,你先睡会,到了我摇铃叫你”。
在顶级量化公司的核心链路里,大家会尽量避免用锁。
- 为什么? 因为锁太慢了(会引起内核态切换、缓存失效)。
- 怎么办? 使用 无锁数据结构 (Lock-free Data Structures),比如基于原子操作实现的“无锁队列”。这是 QD 笔试的高级考点。
Lambda 函数和 std::function
请介绍一下 lambda 函数
- 我说自己不太了解具体的定义,只是知道 lambda 函数是匿名函数,可以不用写出一个函数的名字而直接定义这个函数,用起来比较方便。
参考回答
在编译器眼里,Lambda 其实是一个自动生成的临时结构体(仿函数)。
核心语法:
$$capture \rightarrow return_type { body }$$
三个关键组成部分:
- 捕获列表
[](最重要): 这是 Lambda 和普通函数最大的区别。它允许函数“捕获”它外面的局部变量。[&]:按引用捕获(外面变量变了,里面也变;小心野指针!)。[=]:按值捕获(里面拷贝一份,外面变了里面不变)。[this]:捕获当前类的成员变量。
- 参数列表
():和普通函数一样。 - 函数体
{}:具体的逻辑。
为什么量化开发爱用它?
- 局部性:逻辑就在调用处,不用跑去别的文件找函数名。
- 性能:Lambda 是内联(Inline) 的绝佳对象。编译器知道它的确切逻辑,没有函数调用的开销。
你了解 std::function 吗?请介绍。
- 我回答完全不了解
参考回答
既然 Lambda 是匿名的,那如果你想把一个 Lambda 存起来以后用,或者传给别的函数,该用什么类型来接收它呢?答案就是 std::function。
它是一个类模板,可以存储、复制和调用任何“可调用对象”(Callable Objects),包括:
- 普通函数指针。
- Lambda 表达式。
- 仿函数(重载了
operator()的类)。 - 类成员函数(配合
std::bind)。
两者对比
Lambda 函数和 std::function 的对比:
- Lambda 是“快”的:
- 它本质上是编译期确定的类型。
- 编译器可以直接把它的代码内联进去。零额外开销。
std::function是“重”的:- 它使用了 类型擦除(Type Erasure) 技术。
- 动态分配:如果 Lambda 捕获的变量太多,
std::function会在堆上申请内存(SBO 优化除外)。 - 间接调用:它类似于虚函数,需要通过指针跳转找到真正的执行代码。
- QD 结论:在极高频的循环逻辑里,严禁使用
std::function,必须使用 Lambda 或者模板。
什么是 std::function 的类型擦除?
参考回答
这是一个非常高级的词汇。简单来说:类型擦除就是把“具体的千变万化”藏起来,只露出一副“统一的面具”。
为什么需要它?
- 我们在前面聊过,每个 Lambda 表达式其实都有一个全世界唯一且无名的类型。 如果你想写一个函数去接收不同的 Lambda,你怎么写参数类型?你根本写不出来!
它怎么实现的?(重点)
std::function 在内部玩了一个“狸猫换太子”:
- 定义一个抽象接口(基类),里面有一个虚函数
call()。 - 定义一个模板派生类,去包装你传进来的那个具体的 Lambda。
- 擦除过程:
std::function内部持有一个基类指针,指向这个派生类。
结果:不管你传进来的是 Lambda、函数指针还是仿函数,std::function 都把它们的原始类型“擦除”了,统一装进了自己的盒子里。
“擦除”是有代价的:
- 性能损耗:由于它内部使用了虚函数和间接指针跳转,它无法在编译期被内联。这也就是为什么我之前说,在量化核心高频路径上,大家宁愿用复杂的模板(保持类型信息),也不愿意用优雅的
std::function(擦除类型信息)。 - 内存开销:为了“装”下不同的对象,它可能还得在堆上额外申请一小块内存(这就是所谓的 SBO 优化之外的情况)。
操作系统
请介绍一下进程和线程的区别。
参考回答
在 Linux 内核中,其实并没有“进程”和“线程”两个截然不同的代码类,它们本质上都是通过 clone() 系统调用创建的 task_struct。
它们的区别在于共享标志位的不同。进程是资源分配的最小单位,拥有独立的地址空间;而线程是 CPU 调度的最小单位,它们共享进程的地址空间(虚拟地址空间)和资源。
在量化场景下,我们倾向于多线程模型,因为上下文切换的开销更小;线程切换不需要切换页表(TLB),只需要切换寄存器和栈,这能极大降低交易系统的延迟。
为什么线程崩溃会导致整个进程挂掉?
参考回答
答案是:
- 信号处理是共享的:在 Linux 中,很多致命信号(比如
SIGSEGV段错误)是发送给进程的。 - 资源是绑定的:因为线程只是进程内部的一个执行流,它们共享内存。如果一个线程非法访问内存导致崩溃,内核为了防止这个发疯的线程继续污染共享内存中的其他数据(比如你的交易订单),会选择直接“熔断”,把整个进程杀掉。
上下文切换中的上下文是什么?
参考回答
“上下文”就类似游戏的存档文件。为了让 CPU 停下一个任务,去跑另一个任务,它必须把当前任务的所有“状态”打包带走,下次回来才能无缝衔接。
主要可以分为下面三层内容。
寄存器状态(CPU Context):最直接、最表层的上下文
- 程序计数器(PC):记录程序执行到了哪一行指令。
- 通用寄存器:存放正在计算的中间数据。
- 状态寄存器(EFLAGS):存放运算结果的状态(比如是否为零、是否溢出)
- 栈指针(SP/ESP):指向当前函数调用的栈顶位置。
内存上下文(Memory Context):这是进程切换和线程切换最大的区别所在。
- 页表:虚拟地址到物理地址的映射图。
- TLB:地址转换的快表(可以理解为页表的缓存)。
- 切换进程时,TLB 会失效,也就是之前的“记忆”都作废了,这就是为什么进程上下文切换更慢的原因。
系统资源上下文
- 文件描述符表:进程打开了哪些文件、哪些 socket。
- 信号处理设定:进程如何响应各种信号。
- 权限/内核栈:进程在内核态运行的各种状态信息。
上下文是恢复任务运行所需的最小状态集。它主要由三部分组成:第一是处理器状态,包括通用寄存器、PC 和栈指针;第二是内存环境,主要是页表映射和 TLB 缓存;第三是资源状态,如文件描述符和内核栈。
顺便提一句,这也是为什么线程切换比进程快,因为线程共用地址空间,不需要切换页表和刷新 TLB。
| 切换类型 | 切什么? | 性能代价 |
|---|---|---|
| 线程切换 | 只切 寄存器 和 内核栈。 | 轻量。地址空间没变,TLB 不需要刷新。 |
| 进程切换 | 切 寄存器 + 整个地址空间 (页表)。 | 昂贵。必须刷新 TLB,导致后续指令执行由于 Cache Miss 而变慢。 |
介绍一下现代操作系统中的多级缓存。各级缓存的大小和访问速度?大概是多少量级?
参考回答
记住一个核心物理限制:离 CPU 越快的东西,信号传输距离必须越短,造价越贵,所以容量必须越小。
三条记忆点:
- Cache 都在 100ns 以内(纳秒级)。
- 内存是 100ns 左右。
- 微秒($\mu s$)是给 SSD、网络 IO 或者上下文切换准备的。
更多的细节:
- L1 的分家 (Split Cache): L1 缓存通常分为 L1i (Instruction) 和 L1d (Data)。这样 CPU 可以同时读取“指令”和“数据”,互不干扰,提高流水线效率。
- 包含性 (Inclusivity):
- Inclusive Cache:L3 包含所有 L2 的内容,找东西快,但浪费空间。
- Exclusive Cache:L2 和 L3 不重叠,利用率高,但查找逻辑复杂。
完整表格:
| 级别 | 大小 (典型值/每核心) | 访问速度 (时钟周期) | 访问时间 (绝对时间) |
|---|---|---|---|
| 寄存器 | 几百 字节 | 0 周期 | 即时 |
| L1 缓存 | 32 KB - 64 KB | ~4 周期 | ~1 ns |
| L2 缓存 | 256 KB - 1 MB | ~12 周期 | ~3 - 10 ns |
| L3 缓存 | 2 MB - 8 MB (共享) | ~40 - 60 周期 | ~20 - 50 ns |
| 主存 (RAM) | GB 级别 | ~200 - 300 周期 | ~100 ns |
| SSD / 网络 | TB 级别 | 几百万周期 | 10 - 100 $\mu s$ |
请简单介绍一下“伪共享”及其导致的性能崩溃问题。
参考回答
CPU 并不是一个字节一个字节地从内存拿数据,而是成块成块地拿。这“一块”,就叫 Cache Line,也就是缓存行。在现代 Intel/AMD CPU 上,它的大小通常是 64 字节。
那么,什么是伪共享?简单来说,就是两个完全无关的变量,不幸地被操作系统分到了同一个 64 字节的缓存行里,导致多个 CPU 核心在修改它们时产生相互干扰。
假设你有一个结构体:
| |
在内存里,这两个 uint64_t 共占 16 字节,肯定会被塞进同一个 64 字节的 Cache Line。
- 核心 1 正在疯狂更新
thread_a_count。 - 核心 2 正在疯狂更新
thread_b_count。 - 冲突发生:虽然它们逻辑上互不干扰,但由于 CPU 的缓存一致性协议 (MESI),核心 1 每次修改 A,都会导致核心 2 手里整个 Cache Line 失效(Invalid)。核心 2 必须重新去 L3 甚至主存拉取数据。
为什么叫“伪”共享?
- 真共享:两个线程竞争同一个变量。你必须加锁(Lock)或用原子操作,这是逻辑上的必然代价。
- 伪共享:两个线程操作的是不同的变量。它们本该像并行的赛道一样快,却因为被捆在了同一个“物理包装袋(Cache Line)”里,跑出了比加锁还慢的速度。
量化直觉:在高性能交易系统中,如果你发现某个多线程计数器的性能莫名其妙地随线程数增加而下降, 90% 的概率是撞上了伪共享。
如何解决伪共享? 在 Modern C++ 中,解决办法非常简单粗暴:空间换时间(填充/对齐)。
方案一:手动填充 (Dirty Way)
- 在两个变量之间插入一堆没用的字节,强行把它们挤到不同的 Cache Line 去。
方案二:alignas 关键字 (C++11/17 推荐)
- 告诉编译器,这个变量必须站在 64 字节的边界上:
| |
这样,两个变量绝对会横跨两个不同的 Cache Line,核心 1 和核心 2 就能真正互不干扰地起飞了。