STAGE 01 / 八阶段学习路线 C++ 基础与语法 先分清语言语义、对象生命周期与编译器实现。能解释一段小程序,比记住十条口号更有用。
← 回到路线图 抽题练习本章 本章目录 · 12 个知识点
S1-01 / REVIEW NOTES ○ 标为掌握
封装、继承、多态:三大特性的边界 封装把状态与维护状态合法性的操作放在一起。private 只是访问控制工具;真正的封装是对象始终满足不变量,例如余额不能因任意外部赋值变成非法值。继承表达可替代关系,组合表达拥有关系;单纯为了复用几行代码,并不一定需要继承。
多态让相同接口有不同实现。虚函数属于运行期多态,模板和重载体现编译期选择。选择时看类型是否在编译期确定、是否需要运行期扩展、是否接受间接调用和对象布局成本。设计接口还要交代谁拥有对象、是否允许为空以及失败如何报告。
代码片段 / 原理示意
struct Shape { virtual double area() const = 0; virtual ~Shape() = default; };
DEEP DIVE / 从结论到推演 机制推演 接口的可替代性要落实为输入前提、输出承诺和状态不变量。假设基类允许重复调用 close,派生类却在第二次调用时崩溃,即使虚函数签名完全匹配,也破坏了调用者依赖的契约。虚分派只负责选择实现,不能证明行为兼容。设计继承前先写出调用序列与错误处理,再决定哪些操作应成为稳定接口。
边界与取舍 模板可以暴露具体类型给优化器,却会把实现依赖传给使用方,扩大实例化数量和重新编译范围。虚接口让运行时替换实现更自然,但对象分布、间接跳转和分配方式可能影响缓存。两种方案都能做到批量处理;应先比较数据布局与调用粒度,再测分派成本,不能仅凭一个空函数循环决定整个系统架构。
进阶依据:C++20 草案 N4861:虚函数与最终覆写者
面试自测 先说结论,再补条件与反例 基础复习 Q1 什么时候使用组合而非继承?参考答案 当关系是“拥有一个”、只需要委托部分行为,或子类无法满足基类契约时,优先组合。例如汽车拥有引擎,不应继承引擎。组合让依赖可替换,并降低基类改动对派生类的影响。
☆ 收藏 基础复习 Q2 静态多态和动态多态怎样选?参考答案 编译期知道类型且希望内联时可用模板;运行期要把不同实现装进同一接口集合时可用虚函数。还要比较编译时间、代码体积、ABI 稳定性与可测试性,不能单凭一次调用开销下结论。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 为何一个公开继承、所有虚函数都 override 的类仍可能不适合作为基类替代品?参考答案 编译器检查的是类型关系与覆盖规则,无法替我们检查业务契约。例如基类承诺 send 接受任意长度的合法消息,派生类偷偷要求长度不超过一千字节,就强化了输入前提;原调用者无需改变代码也可能失败。应把合法输入、失败形式、状态转换及并发要求写入接口,并让不同实现运行同一组契约用例。若某实现确实需要更强前提,应拆出能力接口或用组合适配,而不是靠强制转换绕过检查。性能允许不同,但接口明确承诺的期限或阻塞行为同样属于可替代性边界。
答到这些关键点 区分语法覆盖与行为契约 给出前提强化的实际反例 用契约测试验证不同实现 ☆ 收藏 边界反例 Q4 把虚接口全部换成模板是否就消除了抽象成本?参考答案 只能消除一部分运行期开销,并不保证总成本下降。模板实例化让具体类型可见,编译器有机会内联和常量传播;若函数主体很大、类型种类很多,也可能放大代码段,增加指令缓存压力和编译时间。模板还不能直接用一个普通容器容纳任意运行时类型,往往仍需 variant 或类型擦除。可把热路径计算做成模板,把插件装载与配置边界保留为虚接口。验证时同时记录吞吐、尾延迟、可执行文件体积和构建时间,并确认基准没有把整个计算常量折叠掉。
答到这些关键点 说明代码膨胀与运行时异构限制 提供分层混合设计 同时测运行和构建代价 ☆ 收藏 工程场景 Q5 行情处理程序每秒处理百万条记录,规则需运行时切换,如何选择多态方案?参考答案 先把规则切换频率与逐条计算频率分开:可在批次开始取得稳定策略快照,再让整批记录调用一次策略入口,减少逐元素虚调用与锁竞争。若规则集合编译时封闭,可以用 variant 选择对应批处理模板;若需要独立部署插件,则保留有版本的动态边界。记录应尽量连续存储,避免每条消息单独分配多态对象。用真实消息分布测缓存未命中、分配次数、切换期间延迟和吞吐,检查策略快照的寿命及并发更新协议。只有确认分派为瓶颈后,再增加专用快路径。
答到这些关键点 按批次降低分派频率 区分封闭类型集与插件扩展 测布局成本并处理策略寿命 ☆ 收藏
动手验证 分别用模板和虚函数实现两个面积计算器,比较调用方式。
+ 我的笔记 用自己的话解释这个知识点
S1-02 / REVIEW NOTES ○ 标为掌握
指针与引用:语义和生命周期 指针是可存储地址的对象,可为空,可重新赋值,在合法数组范围内可做算术。引用在初始化时绑定对象,此后赋值改变的是被引用对象,不能把原引用改绑。引用常用于表达必需的参数;指针可表达可选参数,但两者都不自动拥有资源。
引用是否需要独立存储由实现决定。参数引用常通过地址传递,也可能被优化消除;sizeof(ref) 测得的是被引用类型大小。返回局部变量的引用、保存已析构容器元素的引用都会悬空。判断安全性要追踪对象寿命,而不是只看变量有没有名字。
代码片段 / 原理示意
int x=1, y=2; int& r=x; r=y; // x 变成 2,r 仍绑定 x
int* p=&x; p=&y; // p 改指向 y
DEEP DIVE / 从结论到推演 机制推演 引用绑定临时对象时,先判断绑定发生的位置,再判断初始化表达式。局部 const T& 直接绑定临时量通常延长其寿命;临时量绑定函数引用形参则只活到包含调用的完整表达式结束。函数再返回该引用,调用者接住它并不会重新延长寿命。引用变量自身的作用域和被引用对象的存活区间必须分别画出。
边界与取舍 C++20 中,含引用成员的聚合采用花括号与圆括号直接初始化,临时寿命可能不同;new 初始化器中的引用绑定也有例外。因此把所有 const 引用归为安全借用并不成立。长期保存数据的对象通常应拥有值或明确共享所有权,引用更适合短期访问;避免让调用者仅从参数类型猜测被借用对象要存活多久。
进阶依据:C++20 草案 N4861:临时对象寿命与例外
面试自测 先说结论,再补条件与反例 基础复习 Q1 引用的底层是不是 const 指针?参考答案 语言只规定绑定与使用语义,不规定必须实现为指针。某个 ABI 可使用地址实现引用参数,但局部引用也可能没有独立存储;不能把实现习惯当成标准保证。
☆ 收藏 基础复习 Q2 const 引用能保证对象始终有效吗?参考答案 不能。它限制经该引用修改对象,不管理任意对象的生命周期。某些直接绑定临时量的场景会延长寿命,但返回局部引用、转交已有悬空引用和容器失效仍不安全。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 const T& r = identity(T{}); 中 identity 原样返回引用形参,r 能延长临时对象寿命吗?参考答案 不能。T{} 绑定的是 identity 的引用形参,临时对象持续到包含此次调用的完整表达式结束,也就是该声明的分号处。identity 返回同一对象的引用,没有创建新的可被延长寿命的临时对象;r 随后悬空。与此不同,局部 const T& r = T{} 的直接绑定通常会延长临时对象到 r 的寿命。可用构造析构日志观察销毁时刻,并用返回值版本消除借用链。日志只用于展示既定规则,不能通过对悬空引用继续读取的输出证明安全;即使数据尚未被覆盖,后续访问仍不合法。
答到这些关键点 找到完整表达式结束点 解释寿命延长不可转授 区分观察析构与执行未定义行为 ☆ 收藏 边界反例 Q4 C++20 的 A a{T{}}、A b(T{}) 和 new A{T{}},若 A 只有 const T& 成员,寿命相同吗?参考答案 不相同,假设 A 是可按这些形式初始化的聚合。局部 A a{T{}} 中,花括号聚合初始化可以把绑定成员的临时对象寿命延长到 a 的寿命;C++20 新增的圆括号聚合初始化 A b(T{}) 只保留临时对象到该完整表达式结束。new A{T{}} 中的临时量也只活到含 new 的完整表达式结束,堆上 A 仍在却已有悬空成员。应按具体初始化规则分析,而非依据对象在栈还是堆。接口若要持久保存输入,改为值成员更直接,并分别在 C++17 与 C++20 模式检查语法与诊断。
答到这些关键点 区分三种初始化寿命 明确圆括号聚合的C++20背景 给出拥有值的修复方案 ☆ 收藏 工程场景 Q5 解析器返回指向输入缓冲的 string_view,调用方要把结果排队异步处理,怎样设计?参考答案 异步队列会延长使用时间,单纯返回 string_view 不能保证底层存储仍在。若消息较小,最稳妥的是入队时复制为拥有的 string;若复制成本经过测量确实显著,可让任务持有共享缓冲的拥有者,同时保存偏移和长度,执行时再构造视图。不能只把 view 包进 shared_ptr,因为那只延长视图对象寿命。还需规定缓冲不被回收或并发改写,记录取消任务的释放路径。用延迟执行、生产者提前退出、缓冲复用等测试验证,并测复制字节量、队列峰值内存与尾延迟再作取舍。
答到这些关键点 拥有底层缓冲而非只拥有view 覆盖异步取消与复用路径 以复制量和峰值内存作取舍 ☆ 收藏
动手验证 写一个返回局部引用的反例并开启编译警告,然后改为返回值。
+ 我的笔记 用自己的话解释这个知识点
S1-03 / REVIEW NOTES ○ 标为掌握
struct、class 与访问控制 struct 和 class 都能有构造、析构、成员函数、模板和虚函数。主要语法区别是默认成员访问和默认继承权限:struct 默认 public,class 默认 private。public 是对外契约,protected 允许派生类参与,private 将实现细节限制在类及友元中。
工程中常把单纯记录数据的类型写成 struct,把维护不变量的类型写成 class,这是一种约定。是否为聚合类型、标准布局或可平凡复制,要看具体成员、基类和特殊成员函数等条件,不能只看声明关键字。
代码片段 / 原理示意
struct A { int x; };
class B { int x; public: explicit B(int v): x(v) {} };
DEEP DIVE / 从结论到推演 机制推演 聚合、标准布局与可平凡复制是不同问题的分类。聚合决定能否使用特定的成员初始化方式;标准布局提供部分布局互操作保证;可平凡复制允许在规定条件下复制对象表示。一个带私有数据成员的简单类仍可能可平凡复制,却不是聚合。需要哪种性质就检查对应类型特征,不能把一个泛称的 POD 当作全部证明。
边界与取舍 即使类型可平凡复制,原始字节也不自动成为稳定文件格式。填充字节可能不同,整数宽度和端序依赖目标环境,指针值跨进程通常无意义。C++17 与 C++20 的聚合构造条件也有变化。对外协议应逐字段编码、规定字节序和版本;对象布局检查更适合验证受控 ABI,而不是替代序列化设计。
进阶依据:C++20 草案 N4861:类的性质 · C++20 草案 N4861:聚合初始化
面试自测 先说结论,再补条件与反例 基础复习 Q1 struct 和 class 在内存布局上一定不同吗?参考答案 不一定。默认权限以外,布局取决于成员、继承、虚函数和实现 ABI 等。同样结构的两者不能仅因关键字不同就推断 sizeof 不同;涉及序列化应明确格式,不直接发送对象原始字节。
☆ 收藏 基础复习 Q2 private 能保护进程内存安全吗?参考答案 private 主要是编译期访问控制,不是对恶意代码的内存隔离。它帮助维护接口与不变量,但越界写、悬空指针或未定义行为仍能破坏内存。进程隔离和权限需要操作系统机制。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 “结构体能 memcpy,所以可以直接作为网络报文”为什么推理不成立?参考答案 这里混合了进程内对象复制与跨环境表示协议。对于适用的可平凡复制对象,按标准条件复制其对象表示可以保留值;这并没有规定另一台机器怎样解释整数端序、填充、指针或枚举值。即使两端 sizeof 相同,字段含义与字节顺序也可能不同,填充还可能泄露未初始化内容。应定义固定宽度字段、范围、字节序与版本,然后显式编码解码。可以在受控共享内存方案中约定同一 ABI,但需把工具链与版本作为部署条件,并用跨架构样例、损坏输入和边界值验证协议。
答到这些关键点 区分对象表示复制与传输格式 指出填充端序和指针问题 给出显式编码与验证条件 ☆ 收藏 边界反例 Q4 把 class 的所有字段改为 public 后,就一定既是聚合又是标准布局吗?参考答案 不一定。访问权限只是条件之一:虚函数、虚基类、成员类型和继承结构仍可能破坏标准布局;构造函数等因素影响聚合资格,而且标准版本有区别。例如在 C++20 中,用户声明的构造函数会阻止类成为聚合,不能沿用某些 C++17 例子的判断。可平凡复制又有独立条件,不能由前两者推断。应分别用 is_aggregate、is_standard_layout、is_trivially_copyable 检验目标性质,并在项目的标准模式下编译静态断言。测试通过只说明该类型及该构建配置,不代表所有未来修改都保持 ABI。
答到这些关键点 三类性质分别分析 指出C++20聚合构造条件 使用类型特征锁定工程假设 ☆ 收藏 工程场景 Q5 共享内存记录因新增一个 private 字段导致消费者读错,如何排查与改进?参考答案 先核对生产者与消费者是否使用同一份协议版本、编译选项、对齐规则和目标架构,再比较 sizeof、alignof 以及适用标准布局类型的成员偏移。不能简单把故障归因于 private 本身;字段位置变化、填充和两端头文件不同都可能改变布局。短期应回滚到匹配版本或增加版本检查拒绝不兼容映射。长期把共享记录定义为明确的字节格式,保存长度与版本,并用同步协议发布完整记录。验证用两版本互读测试、越界长度输入和并发更新压力,避免仅以当前机器的一次正常读写证明兼容。
答到这些关键点 核对两端构建与偏移证据 不把private当安全或布局开关 加入版本与并发发布协议 ☆ 收藏
动手验证 将一个 public 数据类改成验证输入的封装类。
+ 我的笔记 用自己的话解释这个知识点
S1-04 / REVIEW NOTES ○ 标为掌握
static、const 与 constexpr 函数内 static 对象具有静态存储期,初始化发生在首次经过声明时(适用动态初始化的情况),C++11 起该初始化具备线程安全保证。命名空间作用域 static 通常表达内部链接;类的 static 成员归类共享,static 成员函数没有普通 this 指针。
const 表达经此访问路径不可修改;顶层 const 限定指针本身,底层 const 限定所指对象。constexpr 变量要求常量表达式初始化,constexpr 函数在满足条件时能用于常量求值,也可在运行期调用。初始化安全不等于 static 对象后续修改安全。
代码片段 / 原理示意
const int* p = nullptr; // 指向只读 int,可改 p
int* const q = nullptr; // q 不可改
constexpr int square(int x) { return x*x; }
DEEP DIVE / 从结论到推演 机制推演 局部 static 的首次动态初始化包含等待协议:一个线程初始化时,其他到达同一声明的线程等待完成;若初始化抛出异常,后续进入可再次尝试。成功发布对象并不意味着其后续字段更新也受保护。若初始化过程中递归再次进入同一声明,则触及未定义行为,不能指望递归锁替它兜底。初始化、使用和退出应分成三段审查。
边界与取舍 C++20 的 constinit 用来约束具有静态或线程存储期的变量进行静态初始化,既不把变量变成只读,也不要求后续访问在常量求值中发生。constexpr 变量则有常量表达式与只读要求。它们解决不同问题:前者能提前暴露动态初始化风险,后者表达可供编译期使用的值;都不能单独处理全局对象析构顺序和并发修改。
进阶依据:C++20 草案 N4861:块作用域静态初始化 · C++20 草案 N4861:constinit
面试自测 先说结论,再补条件与反例 基础复习 Q1 局部 static 单例是否自动线程安全?参考答案 首次初始化受保护,但对单例内部共享可变状态的访问仍需要同步;析构顺序、递归初始化和跨模块寿命也要设计。把初始化保证扩大为对象所有操作安全是常见错误。
☆ 收藏 基础复习 Q2 const 与 constexpr 的区别是什么?参考答案 const 限制修改,值可能来自运行期;constexpr 变量要求编译期可用的初始化。constexpr 函数不是每次都编译期执行,调用能否常量求值还取决于实参和表达式上下文。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 两个线程同时首次调用局部 static 工厂,其中一次构造抛异常,会留下半初始化单例吗?参考答案 语言保证其他并发到达声明的线程等待初始化完成;初始化若抛出,视为尚未完成,下一次进入可以重新尝试,而不是把半成品作为成功对象返回。已成功构造的成员会按异常清理规则销毁,但构造函数对外部系统造成的副作用不会自动回滚,例如已经注册的回调必须由局部守卫撤销。成功之后,对单例内容的修改仍需锁或原子协议。应做首次构造失败、再次成功和多线程同时首次访问的测试,统计构造成功次数与外部资源释放次数,并检查初始化路径没有递归回到同一声明。
答到这些关键点 解释失败后可重试 区分成员清理与外部副作用 检查递归初始化与后续同步 ☆ 收藏 边界反例 Q4 constinit int counter = 0 与 constexpr int counter = 0 是否都禁止运行期自增?参考答案 不是。C++20 的 constinit 约束初始化阶段,声明的变量仍可修改;constexpr 变量则为 const,不能执行普通自增。前者适合要求程序启动时已得到静态初始化值、但运行期仍需变化的状态;共享自增是否安全还要看它是否为原子或受到互斥保护。constexpr 函数也不同于 constexpr 变量,它仍可接受运行期参数并在运行期执行。验证时把初始化约束和写入权限分开写编译样例:让 constinit 初始化器调用非常量函数应被拒绝,让可变 constinit 计数自增则可通过,随后单独测试并发协议。
答到这些关键点 区分初始化时间与可变性 说明C++20constinit用途 将编译约束与并发正确性分开 ☆ 收藏 工程场景 Q5 程序退出时日志单例偶发崩溃,运行期间正常,如何定位?参考答案 首先检查是否有静态对象析构时继续写日志,以及后台线程是否在日志对象销毁后仍运行。局部 static 保证首次构造同步,却不会自动安排所有全局依赖的退出顺序。可在入口函数显式创建日志服务,将引用传给依赖者,退出时先停止接单、唤醒并等待工作线程,再销毁依赖者和日志。若采用进程寿命内不析构的方案,也要明确缓冲刷盘和测试隔离代价。记录各组件构造析构及线程结束顺序,反复执行快速启动退出和异常退出路径,比较堆栈与最后一次合法日志调用,而非盲目增加锁。
答到这些关键点 识别析构顺序与存活线程 给出显式退出依赖顺序 用退出路径测试而非只测运行 ☆ 收藏
动手验证 用运行期输入初始化 const,再尝试初始化 constexpr,解释编译结果。
+ 我的笔记 用自己的话解释这个知识点
S1-05 / REVIEW NOTES ○ 标为掌握
extern "C"、链接与 ABI extern "C" 为函数或变量声明指定 C 语言链接,常用于 C++ 调用 C 库或导出 C 风格接口。它主要约束语言链接,不会把函数体变成 C,也不保证任意编译器、平台、编译参数之间二进制兼容。
稳定边界通常使用整数、字节缓冲区与不透明句柄,并明确谁分配、谁释放、长度单位和错误码。不要随意跨库边界传 std::string、抛出 C++ 异常或混用运行库分配和释放;不同工具链 ABI 和运行库可能不同。
代码片段 / 原理示意
#ifdef __cplusplus
extern "C" {
#endif
int parse_data(const char* data, unsigned long length);
#ifdef __cplusplus
}
#endif
DEEP DIVE / 从结论到推演 机制推演 链接成功只证明符号能被找到,不证明调用协议一致。调用约定决定参数和返回值如何传递;结构布局决定接收方如何解释字节;运行库决定异常和内存管理如何协作。extern C 提供语言链接层的约束,却无法替代这些约定。排查二进制边界时应先核对符号,再核对架构、调用约定、字段宽度和所有权。
边界与取舍 在同一进程中传递不透明句柄,可以隐藏内部 C++ 类型并减少布局耦合,但句柄仍需有效性、线程安全和释放协议。创建方提供销毁函数能避免调用方猜测分配器。接口升级可用版本号及结构体尺寸协商;仅在末尾新增字段也不能假定旧调用方会提供足够空间。跨边界异常应在入口内转换成约定错误码。
进阶依据:C++20 草案 N4861:语言链接 · Itanium C++ ABI:对象代码接口范围
面试自测 先说结论,再补条件与反例 基础复习 Q1 为什么 C++ 连接 C 库时会找不到符号?参考答案 常见原因是声明与定义的语言链接不同,导致期望的符号名不匹配。也应检查库顺序、架构、可见性和导出表。先查看真实符号与链接命令,而不是盲目添加关键字。
☆ 收藏 基础复习 Q2 extern "C" 接口可重载吗?参考答案 不能像普通 C++ 同名函数那样依赖不同参数形成多个同名 C 链接入口。应使用不同导出名或一个明确的带版本接口;函数内部仍可以调用 C++ 的重载与模板实现。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 导出函数已标 extern C,两个编译器生成的动态库为什么仍可能不兼容?参考答案 extern C 不能统一平台 ABI、调用约定或对象布局。即使导出名一致,一端把 long 当成某种宽度、另一端采用不同数据模型,或双方对结构体使用不同对齐选项,都可能使参数被错误解释。若接口传 C++ 容器,还会涉及标准库布局与分配器,异常穿越边界也依赖运行时支持。应先使用符号查看工具检查实际导出,再核对目标架构与接口头文件,最后用最小往返测试验证字段值和分配释放归属。稳定接口通常选明确宽度的数值、缓冲长度和不透明句柄,并把兼容工具链范围写入发布条件。
答到这些关键点 指出语言链接不等于完整ABI 按符号架构布局逐层排查 明确接口类型与兼容范围 ☆ 收藏 边界反例 Q4 库返回 char*,调用者使用 delete[] 释放,只要机器相同就安全吗?参考答案 不安全,除非接口明确保证这就是匹配的释放方式及运行时环境。指针可能来自 malloc、自定义池、库内部对象或另一运行库实例;使用不匹配的 delete[] 会破坏释放协议。更稳妥的接口是由库同时导出 destroy_buffer,或让调用方提供缓冲区并通过长度查询获取所需容量。还需说明错误时指针是否为空、长度是否包括终止符、是否可跨线程释放及句柄重复释放如何处理。可在调试分配器下核对每次申请与释放的归属,并进行成功、失败、空结果和大结果测试;同机一次未崩溃不能证明协议正确。
答到这些关键点 区分分配器及运行库归属 提供同侧释放或调用方缓冲方案 覆盖错误与空结果契约 ☆ 收藏 工程场景 Q5 需给 C 程序发布一个会抛异常的 C++ 压缩库接口,怎样做到可升级且可排查?参考答案 导出带版本的 create、compress、destroy 入口,用不透明句柄隔离实现。compress 接收输入长度、输出容量并返回实际或所需长度,规定缓冲是否允许重叠;入口捕获内部异常并映射为稳定错误码,错误消息的存储寿命也要明确。版本协商要在读取可选字段前检查结构尺寸,避免新库越界读旧配置。日志保留内部异常类型和关联编号供排查,但不让 C 调用者解释 C++ 对象。验证用真正的 C 编译器构建客户端,覆盖容量不足、非法句柄、内存失败与旧版配置,并检查分配释放始终由约定一方完成。
答到这些关键点 设计有版本的不透明句柄协议 转换异常并明确缓冲长度 用C客户端和旧版本配置验证 ☆ 收藏
动手验证 设计 create/use/destroy 三函数接口,写清内存所有权。
+ 我的笔记 用自己的话解释这个知识点
S1-06 / REVIEW NOTES ○ 标为掌握
volatile 不等于线程同步 volatile 告诉实现某些访问具有不可随意省略的可观察意义,常见于平台定义的内存映射设备寄存器等场景。它不让普通读写成为原子操作,不建立线程间 happens-before,也不保证复合操作互斥。
线程通信应优先用互斥量、条件变量或 std::atomic。设备寄存器访问还涉及处理器内存屏障、总线和平台接口;仅有 volatile 也不能概括全部硬件约束。信号处理的适用类型与操作同样受限制,不宜用普通复杂容器。
代码片段 / 原理示意
#include <atomic>
std::atomic<bool> stop{false};
// 工作线程轮询 stop.load(),控制线程调用 stop.store(true)
DEEP DIVE / 从结论到推演 机制推演 volatile 访问是否发生,与多个线程怎样排序是两个层面。即使某平台把一个对齐的 volatile 整数读写实现成单条机器指令,语言层面也没有因此建立同步关系。一个线程写普通 payload 后再写 volatile ready,另一线程看到 ready 也不能据此安全读取 payload。正确发布需要同一互斥协议,或满足读取关系的 release/acquire 操作。
边界与取舍 把设备寄存器指针换成 atomic 也不是通用修复。设备可能要求特定宽度访问、读清零或写一清零,而原子读改写指令可能不被总线支持。驱动必须遵循平台提供的寄存器访问和屏障接口。普通内存中的线程通信则按 C++ 内存模型设计;同一个 volatile 关键字不能替代处理器、编译器与设备三层协议的分析。
进阶依据:C++20 草案 N4861:数据竞争与同步操作
面试自测 先说结论,再补条件与反例 基础复习 Q1 volatile 与 atomic 有什么区别?参考答案 volatile 关注特定访问的可观察语义,atomic 提供原子操作与可选择的内存序。两个线程同时读写普通 volatile 对象并不会自动同步;需要 atomic 或锁表达并发协议。
☆ 收藏 基础复习 Q2 原子变量是否让整段代码原子执行?参考答案 不会。每个原子操作有各自语义,先 load 再根据结果 store 仍可能被其他线程插入。计数使用 fetch_add,状态机使用比较交换或加锁,并检查相关非原子数据的可见性。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 volatile bool ready 已能让循环重新读取内存,为什么仍不能安全发布普通结构体?参考答案 因为可观察的访问不等于线程间同步。生产者对 ready 的写与消费者的读没有原子保护,本身就可能形成数据竞争;它也没有建立普通结构体写入与读取之间的 happens-before。一次性发布可先写完 payload,再对 atomic ready 执行 release store,消费者通过 acquire load 读到这次发布的值后再访问 payload。此方案还要求发布后生产者不继续并发修改 payload,且对象在读取结束前保持存活。验证要画出具体读自哪次写的关系,并用线程检测工具和重复压力辅助发现错误,不能以某架构长期运行正常作为证明。
答到这些关键点 识别标志自身的数据竞争 解释release/acquire读取关系 补充发布后不可并发修改与寿命 ☆ 收藏 边界反例 Q4 如果只用一个原子计数统计请求数,是否每次都必须用 acquire-release?参考答案 不一定。若计数只用于统计且不发布其他对象,relaxed fetch_add 已能保证每次加法以原子方式进入该计数器的修改顺序,避免普通读加写的丢失更新。它不能保证读到计数时对应请求的其他内存结果已经可见;若把计数达到阈值作为释放缓冲或读取结果的信号,就需要额外同步协议。统计快照还可能落后于正在执行的请求,不能把它误作业务事务提交数。可分别验证计数总和与结果发布条件,并在真实线程数下测缓存争用;高争用时分片统计可能比单纯调整内存序更有效。
答到这些关键点 用relaxed表达独立计数 区分统计与结果就绪信号 评估热点缓存争用 ☆ 收藏 工程场景 Q5 嵌入式程序用 volatile 轮询设备状态,又要把完成结果交给后台线程,如何分层设计?参考答案 设备侧先遵循芯片和平台文档,用规定宽度的寄存器访问及必要的设备屏障确认 DMA 或设备写入可见,不能直接套用普通内存的原子协议。取得结果后,把它整理到正常内存中的拥有对象,再通过受锁队列或正确的原子发布方式交给后台线程。设备缓冲的回收需等待消费者不再使用,尤其要避免下一次 DMA 覆盖未处理数据。排查时同时记录中断或轮询时序、队列深度和缓冲代次,用慢消费者与连续完成事件制造压力。测量轮询 CPU 占用和尾延迟后,再决定是否改为中断加批量处理。
答到这些关键点 区分设备可见性与线程同步 规定DMA缓冲回收时机 通过慢消费者测背压与时序 ☆ 收藏
动手验证 实现两个线程各增加十万次的计数器,对比加锁和 fetch_add。
+ 我的笔记 用自己的话解释这个知识点
S1-07 / REVIEW NOTES ○ 标为掌握
inline、宏与单一定义规则 宏在预处理期做文本替换,没有普通函数的类型检查、作用域和参数求值规则;带副作用实参可能被重复展开。函数模板与 constexpr 函数通常更适合泛型计算,宏仍可用于条件编译、源位置和必要的预处理场景。
inline 的重要作用是允许满足规则的同一实体在多个翻译单元中定义,适用于头文件函数。它不是强制机器码展开指令,编译器也可以内联未写 inline 的函数。必须保证各定义满足 ODR,不能随意让同一头文件因宏差异产生不一致定义。
代码片段 / 原理示意
template<class T> constexpr T twice(T x) { return x+x; }
// twice(i++) 只求值一次实参;宏可能重复求值
DEEP DIVE / 从结论到推演 机制推演 ODR 检查不止于比较函数文本。允许跨翻译单元出现的定义通常需要相同记号序列,而且定义中的名称查找也要指向符合规则的同一实体。两个文件包含同一 inline 函数,却提前引入不同的辅助函数,仍可能违反规则。链接器合并了符号不等于程序合法,优化级别变化只是让已有问题以不同方式暴露。
边界与取舍 C++17 inline 变量适合在头文件定义共享实体,但不是把每个文件都变成独立副本。相反,头文件中的内部链接变量通常在各翻译单元各有一份。需要全局唯一计数、注册地址或锁时,必须先确认实体身份。宏配置若改变类布局或 inline 函数体,应统一生成配置头并作为构建依赖,避免一部分目标文件继续使用旧定义。
进阶依据:C++20 草案 N4861:单一定义规则 · C++20 草案 N4861:inline
面试自测 先说结论,再补条件与反例 基础复习 Q1 为什么头文件函数常写 inline?参考答案 头文件可能被多个翻译单元包含。inline 在满足定义一致等规则时允许多处定义,避免普通外部函数的重复定义问题;这与调用是否被展开是两回事。
☆ 收藏 基础复习 Q2 宏 #define SQR(x) ((x)*(x)) 有什么问题?参考答案 即使括号完整,SQR(i++) 也会重复副作用并可能触发不安全的求值组合;宏还缺少类型和作用域约束。应让实参只求值一次,通常改用函数模板或 constexpr。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 同一个头文件的 inline 函数为何会因两个源文件的宏配置不同而产生错误?参考答案 头文件只是在预处理阶段被展开,最终参与 ODR 的是各翻译单元看到的定义。若宏让函数体一个返回一、另一个返回二,相同外部 inline 实体便出现不一致定义;跨翻译单元的这类错误常不要求实现诊断。未内联调用可能使用链接器选中的某份函数体,内联调用却保留本文件版本,所以表现会随优化变化。应比较预处理输出并统一配置来源,清理相关旧目标文件后重建。修复是让定义满足 ODR,而非强制关闭内联;若确实需要不同策略,应让差异成为参数、不同实体或合法的模板实参。
答到这些关键点 检查预处理后的定义 说明无需诊断与优化差异 以显式参数或实体区分配置 ☆ 收藏 边界反例 Q4 两份定义逐字相同,是否就一定满足 ODR?参考答案 不一定,名称查找结果也可能不同。例如头文件中的 inline 函数调用未限定的 helper,而不同翻译单元在包含前使查找命中各自的内部链接 helper,文本相同但绑定的实体不同,可能违反 ODR 的要求。还存在标准规定的特定例外,不能反过来声称所有内部链接名字都绝对禁止。工程上应避免头文件依赖包含顺序,显式限定辅助函数并保证其定义一致。验证时构造两个翻译单元分别调用,检查预处理结果和符号,再开启适用的链接时诊断作为辅助;一次链接成功并不是完整的 ODR 证明。
答到这些关键点 解释相同文本但查找不同 避免把例外绝对化 消除包含顺序依赖 ☆ 收藏 工程场景 Q5 插件注册表在不同源文件里各自显示不同数量,怀疑头文件 static,如何修复?参考答案 先查看注册表变量是否在头文件以命名空间 static 或匿名命名空间定义,这会让不同翻译单元拥有各自实体,注册与查询可能根本不操作同一张表。可改为一个源文件提供定义、头文件提供 extern 声明,或在符合条件的 C++17 设计中使用 inline 变量。跨动态库时仍需核查导出与符号可见性,不能把普通程序的实体合并假设直接推广。记录注册表地址、模块名与注册时序定位分裂来源,再用多个源文件及真实插件装载测试。还要处理并发注册与卸载后残留回调,实体唯一只解决了其中一层问题。
答到这些关键点 识别内部链接产生多份状态 明确C++17inline变量与DLL边界 记录地址并覆盖注册卸载寿命 ☆ 收藏
+ 我的笔记 用自己的话解释这个知识点
S1-08 / REVIEW NOTES ○ 标为掌握
auto、decltype 与推导 auto 由初始化表达式推导类型,普通按值 auto 会去掉引用和顶层 const;auto& 保留引用关系,const auto& 常用于只读遍历而不复制。auto 不是动态类型,推导完成后类型在编译期固定。
decltype 对未加括号的名字有特殊规则,通常得到声明类型;对其他表达式则依值类别得出 T、T& 或 T&&。decltype(auto) 保留这种差异,适合需要精确转发的接口,但也容易无意返回局部对象引用。
代码片段 / 原理示意
int x=1; const int& r=x;
auto a=r; // int
decltype(r) b=x; // const int&
decltype((x)) c=x; // int&
DEEP DIVE / 从结论到推演 机制推演 推导前先写出表达式类型和值类别。普通 auto 采用类似按值模板参数的推导,常去除引用和顶层 const;decltype 对未加括号的名字直接采用实体类型,对一般左值表达式则形成引用。decltype(auto) 的括号因此可能改变函数返回的所有权语义。泛型包装器是否保留引用,应由接口契约决定,而不是追求推导最精确。
边界与取舍 auto 保存的也可能是代理对象,例如 vector<bool> 的元素访问结果,而非独立 bool。代理看似按值复制,内部仍可能引用容器存储,后续赋值会修改原位,扩容还会让它失效。结构化绑定也应区分 auto、auto& 与 const auto&;观察变量表面类型不够,还需确认被复制的是值、句柄还是访问代理。
进阶依据:C++20 草案 N4861:decltype 规则 · C++20 草案 N4861:vector<bool> 特殊化
面试自测 先说结论,再补条件与反例 基础复习 Q1 auto 为什么会让修改循环变量不影响容器?参考答案 for(auto x : v) 通常复制元素,修改 x 只影响副本。需要修改原元素时写 auto&,只读且避免复制时写 const auto&;代理引用类型和临时范围还需要单独分析。
☆ 收藏 基础复习 Q2 decltype(auto) 返回 (local) 为什么危险?参考答案 带括号的局部变量表达式是左值,decltype((local)) 推导为引用,函数退出后引用悬空。应明确按值返回或返回寿命长于调用者使用期的对象。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 返回局部变量时,decltype(auto) 的 return x 与 return (x) 为什么含义不同?参考答案 在 C++17 和 C++20 中,若 x 是普通局部对象,未加括号的名字走 decltype 的特殊规则,得到 x 的声明类型;return (x) 则对左值表达式应用一般规则,推导出 T&。函数退出后局部对象销毁,后者返回的引用悬空。括号通常只影响表达式结合,但这里恰好参与类型推导语义。应在返回值契约为拥有值时直接声明 T 或使用普通 auto,并以 static_assert 检查返回类型。若确实返回外部成员引用,则需检查拥有者寿命及是否允许从临时对象调用,不能只凭删掉括号完成所有接口审查。
答到这些关键点 解释decltype特殊与一般规则 明确C++17/20局部寿命 用显式契约和类型断言验证 ☆ 收藏 边界反例 Q4 auto bit = flags[0] 后给 bit 赋值,为何可能修改 vector<bool> 本身?参考答案 vector<bool> 是特殊化,元素访问可以返回记录位置的代理引用对象。auto 复制的是该代理类型,不一定把位值转换成独立 bool;赋值通过代理写回原容器。若 flags 随后重分配,保存的代理也不能继续当有效句柄使用。需要值快照时明确写 bool bit = flags[0],需要修改时让访问发生在容器存活且未发生失效操作的范围内。可用类型断言和两个独立用例验证赋值是否影响原位,但不要故意解引用失效代理来判断实现。泛型代码应考虑代理引用,不能假定解引用迭代器必定得到 T&。
答到这些关键点 识别代理复制仍可借用 给出显式bool快照修复 补充失效与泛型引用限制 ☆ 收藏 工程场景 Q5 通用缓存接口原先按值返回,改成 decltype(auto) 后偶发悬空,如何排查?参考答案 逐个返回分支展开实际表达式和推导类型,尤其检查带括号局部变量、容器元素以及临时查询结果。若接口返回缓存元素引用,淘汰、扩容和并发更新都可能结束借用有效期;仅把局部变量改为成员仍不够。对于调用方要长期保存的数据,恢复按值返回或返回明确的共享拥有快照;短期访问可用带锁的回调接口,把有效使用限定在保护区间。添加返回类型静态断言,并构造查询后立即淘汰、扩容和异步保存结果的测试。测量复制成本后再决定快照策略,避免以隐藏的借用代价换取未经证实的性能收益。
答到这些关键点 逐返回分支分析推导类型 覆盖缓存淘汰并发寿命 用拥有快照或受保护访问修复 ☆ 收藏
动手验证 用 static_assert 与 is_same_v 检查四种推导结果。
+ 我的笔记 用自己的话解释这个知识点
S1-09 / REVIEW NOTES ○ 标为掌握
sizeof、strlen 与数组退化 sizeof 是运算符,得到对象或类型的字节大小,通常不求值其操作数;数组尚未退化时能得到整个数组占用。strlen 是库函数,沿字符序列寻找第一个零字符,输入必须指向有效的以零结尾序列,时间与长度相关。
数组作为普通函数参数会调整为指针,此时 sizeof 参数得到指针大小,无法恢复原数组长度。std::array 和 std::span 能在接口中更明确表达尺寸与视图;std::string::size 允许内容中含零字符,不等同于 strlen。
代码片段 / 原理示意
char text[]="abc";
// sizeof(text) == 4,std::strlen(text) == 3
void f(char a[]) { /* sizeof(a) 为指针大小 */ }
DEEP DIVE / 从结论到推演 机制推演 函数参数写成 T a[N] 仍会调整为指针,N 不成为运行时边界检查。若用数组引用模板 T(&a)[N],N 才能作为模板参数保留。C++20 span 把连续范围的指针与长度组合成视图,可减少参数错配,却不拥有内存,也不会自动验证任意传入指针确实拥有所声称的长度。边界证明仍从调用方实际分配开始。
边界与取舍 sizeof 的字节单位是 C++ 的 char 大小,标准不把所有机器都限定为八位字节;网络协议中的八位字节需要明确平台条件。sizeof 包含对象填充,也不能替代字符编码长度:UTF-8 的字节数、码点数与用户可见字符数可能不同。涉及缓冲容量、文本截断与屏幕长度时应给变量命名相应单位,避免同一个 length 混用。
进阶依据:C++20 草案 N4861:sizeof · C++20 草案 N4861:span 概述
面试自测 先说结论,再补条件与反例 基础复习 Q1 为什么 sizeof 不等于字符串长度?参考答案 sizeof 测量类型或对象的存储大小,包含字符数组的末尾零及其他空间;strlen 测量零之前的字符数。指针只存地址,sizeof 指针不会遍历所指数据。
☆ 收藏 基础复习 Q2 怎样安全传递二进制缓冲区?参考答案 显式传指针和长度,或在 C++20 使用 span 表达非拥有连续视图;接收方检查范围和生命周期。二进制数据允许零字节,不能把 strlen 当作长度计算方式。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 void f(char a[100]) 内用 sizeof(a) 检查容量,为什么不能保护拷贝?参考答案 数组形式的函数参数在声明时调整为指针,因此 sizeof(a) 测量的是指针类型大小,既不是一百也不能获知调用方缓冲区容量。声明中的一百没有生成自动边界检查,调用者甚至可能传来更小的数组或空指针。接口应接受明确容量,或在 C++20 使用 span,在只接受固定数组的场合使用数组引用模板保留 N。实现还要把已用长度与总容量区分,并在写入终止符前预留空间。可用不同大小数组、恰好填满及零容量输入测试,检查失败时是否保持输出契约,不能仅将 sizeof 换成 strlen 后继续写入。
答到这些关键点 说明参数类型调整 区分容量与已有字符串长度 给出span或数组引用接口 ☆ 收藏 边界反例 Q4 用 span<char> 替代指针长度后,是否就不可能越界或悬空?参考答案 仍然可能。span 只是非拥有视图,构造时若指针与长度描述的范围不真实,就已经破坏前提;底层 vector 扩容或局部数组离开作用域后,span 也不会替它续命。C++20 的下标访问还要求索引满足范围前提,不能假定所有标准库版本都在运行期抛出越界异常。接口应在可信边界建立正确范围,验证索引和子视图长度,并限制视图跨异步保存。可以在调试构建增加断言,结合地址检测工具测试边界,但未报告错误不构成寿命证明。需要持久存储时,应将视图转换成拥有容器或保留底层拥有者。
答到这些关键点 明确span不拥有且依赖真实范围 不虚构C++20下标运行时检查 补充异步与底层扩容失效 ☆ 收藏 工程场景 Q5 上传接口对二进制数据使用 strlen,含零字节后校验失败,怎样完整修复?参考答案 把接口长度改为随数据明确传递的字节数,并让读取、哈希、发送及日志统计都使用同一有效范围,不能只修复最外层一次 strlen。二进制缓冲允许任意零字节,也不要求末尾终止符。接收侧先验证声明长度不超过实际接收量和资源上限,再处理分段到达与截断;长度相加和分配乘法需检查溢出。若日志需要展示内容,使用有界十六进制摘要,避免把二进制当字符串打印。验证空数据、首中末位置零字节、最大合法长度及截断输入,并比较端到端摘要,确认每一层使用相同单位。
答到这些关键点 修复全链路显式字节长度 处理资源上限与算术溢出 用零字节和截断样本验证 ☆ 收藏
动手验证 用包含内嵌零的 string 比较 size 和 strlen。
+ 我的笔记 用自己的话解释这个知识点
S1-10 / REVIEW NOTES ○ 标为掌握
浮点数比较与误差预算 二进制浮点只能精确表示部分数值,运算会舍入。近似相等常采用绝对误差与相对误差的组合:误差不大于 max(abs_tol, rel_tol × max(|a|,|b|))。阈值由数据尺度、测量误差和业务容忍度决定,不是固定背一个 1e-6。
接近零时绝对误差重要,大数值时相对误差重要。还需单独处理 NaN 与无穷:NaN 通常不等于任何值,包括自己;同号无穷可先通过 a==b 处理。金额需要精确十进制语义时,常使用明确单位的整数或专用十进制类型。
代码片段 / 原理示意
bool near(double a,double b,double at,double rt) {
if(a==b) return true;
if(!std::isfinite(a)||!std::isfinite(b)) return false;
return std::abs(a-b)<=std::max(at,rt*std::max(std::abs(a),std::abs(b)));
} // 需 <cmath>、<algorithm>;at、rt 应非负
DEEP DIVE / 从结论到推演 机制推演 近似相等不能直接构成排序等价。取容差一,三个数零、零点七五、一点五满足前两对各自接近,但首尾不接近;由此推导的“不小于也不大于”不具传递性。排序和有序容器依赖严格弱序,应先定义稳定排序键,再把近似查询作为独立操作。需要分桶时必须固定桶边界,并接受边界两侧很近的数落入不同桶。
边界与取舍 在支持 NaN 的浮点环境里,普通小于比较也不能直接覆盖包含 NaN 的任意数据集:NaN 与各值都互不小于,会破坏等价关系的传递性。可明确把所有 NaN 放到末尾并视为同一类,正常值用小于排序;若要区分正负零或 NaN 载荷,则需更细的总序契约。排序键与业务误差判断应分别测试,不能共用一个含糊比较器。
进阶依据:C++20 草案 N4861:排序比较关系 · GCC:浮点与优化选项
面试自测 先说结论,再补条件与反例 基础复习 Q1 为什么不能总用 abs(a-b)<1e-6?参考答案 固定绝对误差无法同时适应极大与极小数值。应根据范围和误差传播设定绝对与相对阈值,并测试零附近、大量累加、NaN 和无穷等边界。
☆ 收藏 基础复习 Q2 近似比较可直接作为 set 的排序规则吗?参考答案 通常不行。容器比较器要求严格弱序,而基于误差的“相等”可能不传递,破坏容器假设。应采用明确排序、离散化键或独立的近似查询策略。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 比较器写成 a < b - eps,为什么可能让 std::sort 或 set 不满足要求?参考答案 问题在于严格弱序要求由互不小于导出的等价关系具有传递性。令 eps 为一,零与零点七五互不小于,零点七五与一点五也互不小于,但零却小于一点五减一,所以这三者破坏等价传递。排序和树形容器会据此建立不适用的假设,不能把结果不稳定解释为普通舍入误差。应使用真实排序关系或确定的离散键,近似匹配另做范围查询。验证比较器时对三元组检查非自反、传递及等价传递,覆盖边界值;只测随机两数比较或单次排序输出不足以发现这种结构性错误。
答到这些关键点 给出三元素传递性反例 区分排序等价与误差判定 测试比较器三元组性质 ☆ 收藏 边界反例 Q4 直接改回 double 的 operator<,包含 NaN 时就一定满足严格弱序了吗?参考答案 仍不能对任意含 NaN 的数据作这个保证。在常见 IEEE 浮点语义下,NaN 与任意数的两个方向小于都为假,因此看起来与一和二分别等价,但一与二并不等价,等价关系仍不传递。可写显式策略:两者都是 NaN 返回假;只有左侧 NaN 返回假;只有右侧 NaN 返回真;其余用普通小于。这样把 NaN 放末尾作为一个等价类,正负零也按等价处理。若业务需要区分载荷或零符号,另定总序。验证需包含 NaN、无穷、正负零,并确认构建未启用与这些语义冲突的激进浮点假设。
答到这些关键点 给出NaN等价传递反例 实际说明NaN末尾比较策略 明确零符号与编译选项边界 ☆ 收藏 工程场景 Q5 实时统计服务要求结果误差不超过业务阈值,同时要按结果排序,怎样设计验证?参考答案 计算层先根据输入尺度、累加次数和允许误差制定绝对与相对预算,可比较普通求和、补偿求和或分块求和在真实分布上的误差和成本。排序层使用独立的严格弱序,并明确 NaN、无穷和缺失值的位置;不要把验收误差直接塞进比较器。若业务要把接近值视作同档,先按明确精度规则生成离散等级,再用原始值或唯一编号打破所需并列。用高精度参考结果检查误差,用性质测试检查排序关系,同时测吞吐与最坏输入的延迟。记录编译选项和浮点环境,使回归结果具有可比性。
答到这些关键点 将误差预算与排序契约分离 用高精度参考和关系性质双重验证 记录性能与浮点构建条件 ☆ 收藏
动手验证 测试 0.1+0.2、接近零、大数与 NaN,并解释预期。
+ 我的笔记 用自己的话解释这个知识点
S1-11 / REVIEW NOTES ○ 标为掌握
作用域、存储期、链接与对象寿命 作用域决定名字在哪可见;存储期描述存储持续多久;链接决定不同声明是否指同一实体;对象生命周期决定何时能合法使用对象。这四者相关但不能混为一谈,例如局部 static 名字只有局部可见,却能跨多次调用保留状态。
自动变量通常随代码块退出销毁,动态对象由所有权策略决定销毁,thread_local 每线程一份。对象存储仍在并不意味着对象还活着,placement new 和显式析构尤其体现这一差别。阅读代码时应画出创建、别名、最后使用和销毁顺序。
代码片段 / 原理示意
int& count() { static int n=0; return n; }
// n 的名字只在函数体中可见,存储延续到程序结束阶段
DEEP DIVE / 从结论到推演 机制推演 存储复用需要同时证明大小、对齐、对象寿命与指针可用性。显式析构结束对象寿命但未必释放其存储;placement new 可以在合适位置创建新对象。满足透明替换条件时,旧名称与指针可自动指向新对象;基类子对象等情形不能机械套用。最直接的做法是使用创建操作返回的新指针,并严格记录每次构造与销毁。
边界与取舍 std::launder 不是让任何强转合法的清洗器:它不分配内存、不构造对象,也不修复对齐或越界。C++20 对部分隐式生命周期类型增加了隐式创建对象的机制,但不能据此跳过 string 等非平凡类型所需的构造。低层内存池应把槽位状态、拥有关系和代次分开管理;地址复用不代表旧句柄仍指向同一业务对象。
进阶依据:C++20 草案 N4861:对象生命周期与透明替换 · C++20 草案 N4861:launder
面试自测 先说结论,再补条件与反例 基础复习 Q1 作用域结束总会释放对象吗?参考答案 自动局部对象一般在正常退出或栈展开时销毁,但 static 对象不会随每次调用销毁,动态对象也不由指针变量作用域直接决定。应区分局部拥有者析构与所拥有资源释放。
☆ 收藏 基础复习 Q2 为什么 string_view 容易悬空?参考答案 它只保存视图,不延长底层字符串寿命。返回局部 string 的 view、绑定短命临时对象或原字符串扩容后继续使用视图都可能失效。接口必须明确底层存储由谁维持。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 对对象显式调用析构后,内存仍可读,为何不能继续调用成员函数?参考答案 一般对象寿命已经结束,存储还保留原字节并不让它继续成为可正常使用的对象。析构后的指针在受限情况下可用于表示存储,但不能据此访问成员或进行依赖活对象的虚调用。若确实要复用槽位,应在足够且正确对齐的存储上构造新对象,再使用新指针,并确保最终只销毁这次成功构造的对象。对于自动变量,还要防止作用域退出时再次隐式析构一个未重建的对象。可用状态记录与构造析构计数验证成功、失败和复用路径;刻意读取旧字节的结果既不能证明寿命恢复,也不能替代标准语义。
答到这些关键点 区分寿命结束与存储保留 处理自动对象二次析构风险 记录成功构造与对应销毁 ☆ 收藏 边界反例 Q4 placement new 在同一地址重建同类型对象后,所有旧指针都能直接复用吗?参考答案 不能不加条件地这样推断。完整的可变对象若满足标准的透明替换条件,旧指针和名称可自动指向新对象;但基类子对象、某些潜在重叠子对象以及涉及完整 const 对象的情形需要另外判断,有的存储复用本身就不允许。std::launder 只在已有适当对象且满足可达性等前提时提供新指针,不负责开始生命周期。工程上优先使用 placement new 的返回指针,并避免让旧借用跨越销毁重建边界。验证应针对具体对象结构和标准版本写最小示例,再检查所有外部句柄,而不是把地址相等当作身份和合法性证明。
答到这些关键点 限定透明替换条件 说明launder不能创造对象 审查跨重建边界的外部借用 ☆ 收藏 工程场景 Q5 对象池复用地址后旧回调操作了新连接,怎样修复这一寿命问题?参考答案 这不是只靠延迟释放即可彻底解决的问题:地址相同却对应不同代次的连接,旧回调可能误伤新对象。句柄可保存槽位索引与代次,每次复用递增代次,执行回调前在正确同步下验证两者匹配;验证之后还必须固定对象寿命,避免检查通过到使用之间再次回收。可用共享拥有、锁内执行短操作或专门的回收协议实现固定。关闭流程应取消回调并等待在途操作完成,再销毁和重建槽位。测试强制快速复用、延迟回调与并发关闭,记录代次及回调来源,并测句柄验证与回收等待带来的延迟。
答到这些关键点 识别地址复用与代次混淆 避免检查到使用间再次回收 压测延迟回调和快速复用 ☆ 收藏
动手验证 为一个含 vector、string_view、unique_ptr 的结构画寿命图。
+ 我的笔记 用自己的话解释这个知识点
S1-12 / REVIEW NOTES ○ 标为掌握
四种类型转换与安全边界 static_cast 用于明确的静态转换,如数值转换及满足约束的继承转换,但不会验证向下转换对象的真实动态类型。dynamic_cast 用于适用的多态层次运行期检查,失败的指针结果为空,引用转换失败抛 bad_cast。
const_cast 改变访问路径的 cv 限定,并不授权修改原本定义为 const 的对象。reinterpret_cast 用于低层表示层转换,不自动保证对齐、对象寿命和别名访问合法。需要按位转换可考虑 C++20 bit_cast,但仍要满足其大小和类型约束。
代码片段 / 原理示意
Base* p = /* 某多态对象 */ nullptr;
if (auto* d = dynamic_cast<Derived*>(p)) { /* 使用 d */ }
// 示例需定义 Base、Derived,Base 至少有一个虚函数
DEEP DIVE / 从结论到推演 机制推演 reinterpret_cast 主要改变表达式的类型解释,并不自动建立目标类型的对象。把 float 的地址转成 int* 再读取,既要面对别名访问限制,也可能存在大小和对齐问题。C++20 bit_cast 对满足条件的等尺寸可平凡复制类型产生按位转换的值;C++17 可采用受约束的 memcpy。两者都不自动完成端序转换或保证任意位模式是合法目标值。
边界与取舍 严格别名规则给优化器提供“这些不同类型访问通常不重叠”的推理空间,因此非法重解释可能只在优化构建失效。GCC 对部分 union 类型双关提供扩展,不应把扩展推广成可移植 C++。检查对象表示可使用标准允许的字符或字节类型;真正访问另一个类型的值还需证明生命周期、表示和对齐,关闭某个优化选项只能用于辅助定位。
进阶依据:C++20 草案 N4861:类型访问与别名规则 · C++20 草案 N4861:bit_cast · GCC:strict-aliasing 与 union 扩展
面试自测 先说结论,再补条件与反例 基础复习 Q1 static_cast 向下转换快,为什么不总用?参考答案 它省去动态检查,但正确性由调用者保证。实际对象不符合要求时使用会导致未定义行为。若不能证明动态类型,应使用合适的 dynamic_cast、虚接口或显式类型封装。
☆ 收藏 基础复习 Q2 const_cast 能修改 const int 吗?参考答案 若对象原本定义为 const,经去限定路径修改它是未定义行为。若原对象可变,只是通过 const 视图访问,则移除该路径限定后修改可能合法,但接口设计通常应避免这种绕过。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 float x 的地址转为 int* 后读取位模式,为什么调试版正常而发布版可能错误?参考答案 即使两种类型在当前平台大小相同,通过 int 类型的左值读取 float 对象也通常违反 C++ 的类型访问规则。优化器可依据不别名假设缓存或重排相关访问,所以调试版观察到的结果没有约束力。若目的是取得位表示,可在 C++20 使用满足等尺寸、可平凡复制条件的 bit_cast,在 C++17 用 memcpy 复制到合适目标;仍需确认目标位模式与平台表示。排查时比较优化前后行为并审查所有强转和对象创建点,编译器警告与检测器只能发现部分情况。不能把关闭 strict-aliasing 当成可移植语义证明。
答到这些关键点 说明类型访问而非仅大小问题 给出17/20按位转换路径 区分诊断手段与语义修复 ☆ 收藏 边界反例 Q4 已经用 bit_cast 取到整数,是否就能直接按网络协议发送,且任意字节都能转回对象?参考答案 不能。bit_cast 保留的是规定条件下的对象表示关系,不负责把主机端序转换成协议端序,也不替协议确定浮点格式。对于 C++20,某些目标类型的位模式没有对应值时还可能触及未定义行为,不能对任意复杂类型或任意输入字节套用。网络协议应先定义字段宽度、字节序及合法值域,收到字节后验证再构造业务对象。若只传输浮点原始表示,需要明确双方支持相同格式并决定 NaN 的处理方式。测试跨端序编码样例、异常位模式和截断输入,避免把“能编译”误当作协议或对象有效性检查。
答到这些关键点 区分按位转换与端序编码 指出合法表示与类型约束 对外部字节做字段级验证 ☆ 收藏 工程场景 Q5 解析网络包时直接把 char* 强转为 Header*,怎样兼顾正确性与速度?参考答案 先确认协议头的字节布局、长度、端序和输入对齐,通常不要让 C++ 对象布局直接充当协议。可逐字段读取固定宽度整数,或将适用字段 memcpy 到局部整数后转端序;编译器通常能把固定小长度复制优化为高效访问。包内长度需先验证,不能在强转解引用后才检查。若确实使用受控的零拷贝对象格式,则必须证明对象生命周期、对齐、别名与版本兼容,成本远不止省一次复制。用未对齐起始地址、短包、最大字段值和不同优化级别测试,并以真实包大小测吞吐后决定是否需要进一步特化。
答到这些关键点 先检查长度与协议布局 用局部读取避免未对齐别名访问 真实测量零拷贝收益 ☆ 收藏
动手验证 各写一个安全与不安全转换例子,标出需要证明的条件。
+ 我的笔记 用自己的话解释这个知识点
从理解到实践 阶段项目与验收 写一个命令行成绩统计器:读取姓名和分数,验证输入,输出均值和排序结果。用 const 表达只读参数,用引用表达必需对象,分别解释作用域、存储期和所有权。
SYSTEM INTERVIEW / 跨知识点综合题 只在 Release 崩溃的二进制协议解析器 你接手一个 C++17 网关:用 reinterpret_cast<Header*>(buffer) 直接读网络包,Debug 正常,Release 偶发崩溃。协议含 4 字节长度与变长消息体。面试官要求你说明问题假设、修复方案,以及如何证明改动有效。
展开方案与推理 先澄清缓冲区是否连续、最小输入长度、协议端序、Header 的布局与所有权,并保存触发样本和编译参数。将“优化器有问题”放在靠后位置:优先排查未满足对齐、对象生命周期和类型访问规则、包截断、无效长度与异步使用过期缓冲区。Debug 没崩溃只能说明该次运行未显露问题,不能证明代码合法。读取机器字还要考虑网络字节序,不能把本机 struct 布局直接当线格式。
修复为显式解码:先确认至少有 4 字节,再逐字节组合一个无符号 32 位长度,或复制到已存在的适当整数对象后转换端序。先比较长度是否超过协议与资源上限,再用 length > available - header_size 这种已经验证减法前提的形式判断完整性,避免 header_size + length 溢出。返回“数据不足、格式非法、成功”三个结果;成功时只消费一帧,多帧继续解析。借用 view 必须由拥有接收缓冲区的连接状态维持寿命,异步处理需要转移拥有权或复制所需字节。
验证不止检查“不再崩溃”:用相同字节流的随机切块和合并输入验证输出帧序列一致,加入空输入、最大长度、恶意超限、截断和跨页边界。通过模糊测试、地址与未定义行为检测寻找反例,再分别构建 Debug/Release。这些工具只能报告本次执行中是否检测到相应错误,不能证明所有输入正确。最后在相同流量与输入分布下记录吞吐、复制量、峰值缓冲和尾延迟。
验收证据 同一输入按任意分块得到一致的消息序列。 错误长度不导致溢出、越界或无界分配。 性能数据包含 CPU、内存与尾延迟,并固定测试条件。 继续追问 面试官继续问:直接加 packed 或关闭优化是不是最小修复? 都不能完整解决。packed 只影响实现特定的布局/对齐处理,不能建立对象寿命、保证输入完整、转换端序或延长缓冲寿命;关闭优化也只是可能改变错误暴露。最小修复应该移除无效访问并补足边界校验。若暂时降低优化用于定位,要把它标为诊断实验,保留可证明合法的最终实现。
追问:既然要求零拷贝,为什么你的方案还允许复制? 先定义零拷贝的目标路径和预算。几个头部字段的复制成本与整个消息体复制不同,不能为了省四个字节牺牲正确性。大消息体可返回受控的只读切片,但须保证它被消费前底层缓冲不重用;跨线程可转交共享不可变块或独占块。比较端到端时间、分配次数和内存滞留,证明少复制确实改善了目标指标。
追问:长度字段合法但连接故意每分钟只发一个字节怎么办? 长度校验只限制单帧上限,还要有每连接与全局缓冲预算、组帧截止时间和最小进展策略。到期关闭并释放占用,限制大量半完成连接形成的累计内存。不要无条件为声明长度立即分配最大缓冲;可分块增长并受全局准入控制。用慢速客户端测试资源占用是否有界,同时避免误杀允许的弱网场景。
参考依据:类型可访问性 · 编译器警告
CODE REVIEW / 动手审查 返回的 string_view 为什么会悬空? 先判断所有权和销毁时机,再说明为何有时打印正常。不要用“改成 const”作为修复。
C++17 · 接口审查片段 · 先自行判断,再展开核对。
std::string_view name() {
std::string local = "Ada Lovelace";
return local;
}
// 调用方保存 name() 后再读取字符。查看问题、修复与验证 string_view 只借用字符,local 在函数返回时销毁;返回的 view 不延长字符串寿命。短字符串优化可能让字符位于对象内部,但是否使用该优化都不改变结论。
按值返回 string 才把结果的拥有权交给调用者。返回值优化可以减少转移成本,不能以性能猜测为理由暴露短命存储。
接口若必须返回 view,应明确由谁拥有字符、哪些操作使其失效以及并发规则。返回静态不可变字符串是另一种契约,不能混同于任意运行期结果。
修正思路 std::string name() {
return "Ada Lovelace";
}
auto owned = name();
std::string_view view = owned;
// view 的使用期必须短于 owned,且期间不进行使字符地址失效的修改。怎样验证 开启编译警告和适用的地址检测器;分别使用短字符串和长字符串测试。记录拥有者销毁顺序;“检测器没有报警”不能证明借用关系合法。
这里提供审查练习与验证步骤,不代表已在本机执行 C++、数据库或 GPU 实验。
依据:string_view 的非拥有语义
本章参考资料与适用版本 正文为原创整理;链接用于进一步核对,不影响离线阅读。C++ 工作草案会持续更新,本文采用已标注的 C++17/20 功能范围。
STAGE 02 / 八阶段学习路线 面向对象 围绕对象的构造、复制、移动、调用与销毁建立统一模型;虚表仅作为常见 ABI 实现解释。
← 回到路线图 抽题练习本章 本章目录 · 9 个知识点
S2-01 / REVIEW NOTES ○ 标为掌握
构造函数与初始化顺序 默认、带参、拷贝、移动和委托构造解决不同的初始化需求。成员初始化列表直接初始化成员;在构造函数体中赋值是先初始化再赋值,不能代替 const 或引用成员所需的初始化。explicit 防止不期望的隐式转换。
实际顺序是虚基类、直接基类、按声明顺序的非静态成员,最后执行函数体;不是初始化列表的书写顺序。委托构造先让目标构造函数完成,再运行委托者函数体。非委托构造在完成前失败时,已成功构造的子对象会析构,整体对象的析构函数不会被调用;若委托目标已完成而委托者函数体抛出,则会调用整体对象析构。
容易答错 不要让前面声明的成员依赖后面尚未初始化的成员。
代码片段 / 原理示意
struct Pair {
int x; int y;
explicit Pair(int v): x(v), y(x+1) {}
Pair(): Pair(0) {}
};
DEEP DIVE / 从结论到推演 机制推演 初始化顺序决定依赖方向,而不是代码排版问题。若成员 buffer 的构造需要 capacity,capacity 必须已完成初始化;即使初始化列表把 capacity 写在前面,声明顺序相反仍可能读取未初始化值。非委托构造在完成前失败时,只清理已完成构造的基类和成员,尚未成为成员拥有物的裸资源则需要局部守卫。整体不变量应在构造成功返回时成立。
边界与取舍 委托构造有一个容易忽略的异常边界:目标构造已完成后,委托者函数体再抛异常,完整对象需要执行析构清理;这不同于普通成员尚在构造中就失败的情况。构造函数也不适合发布 this 到线程或回调,因为外部可能看到未建立的不变量。可用先构造、后注册的工厂封装两阶段操作,使失败时对象仍由局部拥有者回收。
进阶依据:C++20 草案 N4861:基类与成员初始化 · C++20 草案 N4861:异常中的构造与析构
面试自测 先说结论,再补条件与反例 基础复习 Q1 初始化列表顺序为什么重要?参考答案 真正决定成员初始化顺序的是声明顺序。列表写成 y(x),x(1) 并不会改变这一点。应保持列表和声明一致,并检查依赖方向,避免使用尚未初始化成员。
☆ 收藏 基础复习 Q2 构造函数抛异常会泄漏吗?参考答案 非委托构造在完成前失败时,已构造完成的成员和基类会销毁,整体析构函数不运行;委托目标已完成后委托体抛出是需要调用整体析构的例外。资源交给 RAII 成员或局部守卫通常能释放;先获取裸资源而没有拥有者,仍可能泄漏。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 成员初始化列表已写成 n(size), data(n),为何仍可能读到未初始化的 n?参考答案 因为非静态成员按类中的声明顺序初始化,而不是按初始化列表顺序。若 data 声明在 n 前,计算 data 的初始化参数时 n 尚未初始化,列表的书写顺序不能改变这个事实。应把被依赖成员声明在前,或让两个成员直接依赖已验证的构造参数,减少隐式耦合。若 size 来自外部输入,还需在分配前检查上限和乘法溢出。开启成员重排警告有助发现问题,但更重要的是审查声明依赖图。测试零长度、大长度和分配失败路径,确认构造成功时大小与缓冲一致、失败时已取得资源全部释放。
答到这些关键点 按声明顺序推演 减少成员间初始化耦合 补充输入验证和失败清理 ☆ 收藏 边界反例 Q4 “构造函数抛异常时对象析构永远不会执行”有何例外需要说清?参考答案 对普通构造在完成前失败,完整对象的析构函数不会被调用,只销毁已经完成构造的基类和成员。但委托构造不同:目标构造函数成功完成后,若委托者函数体抛出异常,完整对象已经完成相应构造,需要调用其析构函数进行清理。还应区分成员构造失败与外部副作用,后者不自动由对象析构覆盖。可设计一个目标构造成功、委托体故意失败的最小示例,记录对象及成员析构顺序验证规则。工程上仍应让资源尽早进入 RAII 成员或局部守卫,不依赖一个泛化口号推断所有构造失败路径。
答到这些关键点 区分普通失败与委托体失败 正确描述完整对象清理 通过日志验证明确路径 ☆ 收藏 工程场景 Q5 连接对象构造时注册回调并启动线程,线上偶发访问空成员,怎样改造?参考答案 构造期间发布 this 会让回调或新线程在对象不变量建立前访问成员,问题不仅是初始化列表顺序。可由工厂先构造完整对象并装入拥有者,再执行注册和启动;注册失败时由局部拥有者自动析构,启动成功后才向外返回。若使用共享拥有,需要在共享控制块建立后再取得共享引用,不能在普通构造中假定已经可用。析构时按相反协议注销、阻止新回调并等待在途工作结束。测试同步立即触发回调、启动失败和快速关闭,并记录事件顺序与活跃回调数,确保失败不会留下外部系统保存的悬空 this。
答到这些关键点 识别构造期逃逸 工厂控制完整构造后发布 覆盖同步回调与失败撤销 ☆ 收藏
动手验证 让第二个成员构造抛异常,记录第一个成员是否析构。
+ 我的笔记 用自己的话解释这个知识点
S2-02 / REVIEW NOTES ○ 标为掌握
析构函数与虚析构 析构负责结束对象生命周期并释放其拥有资源,顺序与构造相反。通过基类指针删除派生对象时,常规多态基类需要 public virtual 析构;若不允许通过基类销毁,可使用 protected 非虚析构等明确设计。
析构函数应避免向外传播异常,尤其栈展开期间再抛异常会导致 terminate。资源释放可能失败时,应提供显式 close/commit 返回错误,让析构执行尽力清理。析构与释放存储不同,delete 表达式通常把两者串联起来。
代码片段 / 原理示意
struct Base { virtual ~Base() = default; };
struct Derived : Base { ~Derived() override = default; };
DEEP DIVE / 从结论到推演 机制推演 常规 delete Base* 要把两件事接起来:先结束正确动态类型对象的生命周期,再释放对应存储。若 Base 没有虚析构而实际对象是 Derived,普通删除不是简单漏调一个函数,而可能构成未定义行为。unique_ptr<Base> 默认删除器最终也执行该表达式,并不会自动恢复动态类型信息。设计多态所有权时必须先确定允许从哪种静态类型销毁。
边界与取舍 shared_ptr 在由 Derived* 或 make_shared<Derived> 建立控制块时可以保存正确删除操作,随后转成 shared_ptr<Base> 仍保留它;若先丢掉类型信息成为 Base* 再构造控制块,就不能依赖这一点。C++20 的 destroying delete 又是特殊销毁机制,不应混入常规接口默认假设。最清晰的公共多态接口通常仍提供虚析构或显式受控销毁入口。
进阶依据:C++20 草案 N4861:delete 表达式 · C++20 草案 N4861:析构函数 · C++20 草案 N4861:shared_ptr 构造
面试自测 先说结论,再补条件与反例 基础复习 Q1 为什么基类析构要是 virtual?参考答案 当以基类指针拥有并 delete 一个派生对象时,需要按动态类型执行正确销毁流程。缺少所需虚析构会导致未定义行为,不能仅理解为“少调一次析构”。非多态值类型不必因此增加虚析构。
☆ 收藏 基础复习 Q2 纯虚析构函数还需要定义吗?参考答案 需要。派生对象析构后仍会进入基类析构链;将析构声明为纯虚可以让基类抽象,但不消除其函数体需求。可在类外提供定义。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 把 Derived 放入 unique_ptr<Base>,为何仍需要审查 Base 的析构函数?参考答案 默认 unique_ptr<Base> 退出时调用 default_delete<Base>,其核心是对 Base* 执行 delete。若实际动态类型为 Derived,常规删除要求合适的虚析构来销毁完整对象;有其他虚函数并不自动让析构变虚。否则不能仅解释为派生成员泄漏,而是可能发生未定义行为。可以为公共多态基类声明 virtual 析构,或禁止基类删除并提供保存正确动态删除操作的定制拥有者。验证时给基类、派生类及资源成员记录析构,检查正常与异常退出;日志观察只是辅助,真正依据应是删除表达式的类型规则。
答到这些关键点 追踪默认删除器到delete表达式 指出其他虚函数不够 给出虚析构或受控删除设计 ☆ 收藏 边界反例 Q4 shared_ptr<Base> 为什么有时能安全管理非虚析构的 Derived,有时不能?参考答案 关键在控制块创建时保留的删除类型。由 make_shared<Derived> 得到 shared_ptr<Derived> 后转换为 shared_ptr<Base>,控制块仍知道如何销毁 Derived;直接用 Derived* 构建 shared_ptr<Base> 的相应模板构造也可保留这个类型。若先把地址存入 Base*,再用它建立默认控制块,删除操作按 Base* 形成,非虚析构问题仍在。定制删除器可以显式表达规则,但要求动态类型和分配方式始终正确。应审查最初创建控制块的位置,而不是只看最终变量声明,并避免为同一裸指针重复建立多个控制块造成双重释放。
答到这些关键点 看控制块创建时的指针类型 区分先擦除类型的反例 补充单一控制块约束 ☆ 收藏 工程场景 Q5 文件事务析构可能因刷盘失败抛异常,怎样既不丢错误又保持安全清理?参考答案 把可失败的业务完成动作提为显式 commit 或 close,让调用者在正常控制流中处理错误;析构只做不向外传播的尽力清理和必要记录。若栈展开期间析构再抛出异常,会导致终止,不能把析构当可靠的错误返回通道。状态机应区分未提交、已提交、失败待清理和已关闭,确保重复清理不会重复提交。记录失败的系统错误与事务标识,并设计磁盘满、权限变化和底层写失败的注入测试。还要说明 close 成功是否包含持久化保证,这由具体 I/O 协议决定;资源释放成功不必然意味着数据已经可靠落盘。
答到这些关键点 显式提交承担可失败操作 析构清理不传播异常 用状态机和故障注入验证 ☆ 收藏
动手验证 用打印日志验证 Derived、成员、Base 的析构次序。
+ 我的笔记 用自己的话解释这个知识点
S2-03 / REVIEW NOTES ○ 标为掌握
重载、重写与名字隐藏 重载是在同一可见重载集合中按参数匹配选择函数,返回类型本身不能区分普通函数重载。重写是派生类提供基类虚函数的最终覆写者,要求签名规则匹配;override 让编译器检查意图。
派生类声明同名函数可能隐藏基类同名集合,即使参数不同。using Base::f 可把基类重载重新带入查找范围。const 成员限定、引用限定和参数类型差异都可能使期望的重写没有发生。
代码片段 / 原理示意
struct B { virtual void f(int) const {} };
struct D:B { void f(int) const override {} };
DEEP DIVE / 从结论到推演 机制推演 一次调用可拆为名字查找、重载决议与虚分派三个阶段。名字查找先决定哪些声明进入候选集合;重载决议依据静态类型选择签名;若选中虚函数,再依据适用的动态类型选择最终覆写者。默认实参在调用处根据可见声明补齐,不由虚分派重选。把这三步混成“运行期选函数”就容易错判隐藏、转换和默认参数。
边界与取舍 override 检查覆盖意图,却不把基类全部同名重载带回查找范围;using Base::f 才解决相应隐藏问题。协变返回仅适用于满足规则的类指针或引用,不允许把 unique_ptr<Derived> 直接当作 unique_ptr<Base> 的虚函数协变返回。异常说明、const 与引用限定也影响兼容性,接口演进应同时检查源代码调用和既有二进制契约。
进阶依据:C++20 草案 N4861:虚函数覆盖及协变返回 · C++20 草案 N4861:默认实参
面试自测 先说结论,再补条件与反例 基础复习 Q1 override 和 overload 的区别?参考答案 overload 是编译期对同名不同参数函数的匹配;override 是派生类替换基类虚接口的行为。前者不能单靠返回类型区分,后者要匹配虚函数的签名与限定条件。
☆ 收藏 基础复习 Q2 为什么派生类 f(double) 后调用 f() 失败?参考答案 名字查找可能在派生类找到 f 后隐藏基类同名函数,尚未进行你想象中的跨层重载选择。可用 using Base::f 导入基类集合或显式限定调用,随后再进行参数匹配。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 Base::f 默认参数为一,Derived::f 默认参数为二,通过 Base& 调用实际传入哪个值?参考答案 如果调用处省略参数且静态类型为 Base&,使用 Base 可见声明提供的默认值一;若 f 是虚函数且对象是适用的 Derived,则执行 Derived 的函数体并接收一。默认参数在调用处补齐,虚分派只选择函数实现,不重新读取派生声明的默认值。直接通过 Derived 类型调用则可能补上二,导致同一对象出现不同表现。应避免在覆盖链中给出互相矛盾的默认值,可用非虚公共包装器统一默认行为,再调用受保护虚实现。验证时分别通过基类引用和派生对象调用,并检查参数日志,而不是只比较函数名称。
答到这些关键点 默认值按静态声明确定 虚目标按动态类型确定 用非虚包装统一默认策略 ☆ 收藏 边界反例 Q4 clone 返回 unique_ptr<Base>,派生类能覆盖为 unique_ptr<Derived> 吗?参考答案 不能把智能指针模板的转换关系当作语言协变。虚函数返回协变规则适用于满足条件的类指针或引用,unique_ptr<Base> 与 unique_ptr<Derived> 是两个不同的类类型,因此这样的覆盖返回类型不匹配。可以让派生 clone 仍返回 unique_ptr<Base>,在函数内构造 unique_ptr<Derived> 后进行允许的移动转换;或者使用受控的原始指针协变接口,再在外层立即交给拥有者,但后者更容易表达不清。给覆盖函数加 override 可让编译器及时指出错误,并测试 clone 后动态类型与独立资源是否保持,而不只是能否成功编译。
答到这些关键点 界定语言协变的类型范围 实际给出智能指针返回修复 验证clone动态类型与所有权 ☆ 收藏 工程场景 Q5 新增 Derived::process(string) 后,原来的 process(int) 调用失效,如何定位而不乱加重载?参考答案 先在派生作用域进行名字查找:新增同名声明可能隐藏 Base 的整组 process,即使参数不同。若希望保留基类重载,可加入 using Base::process,再检查重载决议是否因隐式转换产生新歧义;若意图覆盖虚函数,核对参数、const 与引用限定并添加 override。不要仅为消除编译错误复制所有基类转发函数,那会形成维护分叉。用整数、字符串、字符串字面量及基类引用建立调用矩阵,检查每种静态类型下选中的签名。对公共库还需考虑已有客户端是否重编译和 ABI 约定,名字查找修复只解决源代码层的一部分兼容问题。
答到这些关键点 先查名字隐藏再查重载 正确使用using和override 用静态类型调用矩阵回归 ☆ 收藏
动手验证 故意漏写 const,再加 override,观察诊断。
+ 我的笔记 用自己的话解释这个知识点
S2-04 / REVIEW NOTES ○ 标为掌握
动态多态、虚表与去虚化 通过基类指针或引用调用虚函数,行为由对象动态类型中最终覆写者决定。常见实现为多态子对象保存虚表指针,虚表含函数入口和 ABI 所需信息,调用时间接定位目标;多重继承可能有多个指针和 this 调整。
标准不规定虚表的具体形状、存放位置或每个对象一定几个指针。优化器若能证明动态类型,可能直接调用甚至内联,称为去虚化。面试时先说语义,再说明特定编译器 ABI 的实现和代价。
代码片段 / 原理示意
基类引用 → 对象中的基类子对象
→ 常见实现的 vptr → 虚表槽位 → 派生实现
以上是示意,具体布局由 ABI 与优化决定。
DEEP DIVE / 从结论到推演 机制推演 以采用 Itanium C++ ABI 的实现为例,多态对象中的某个基类子对象可通过自身的虚指针找到对应表项;表项有时指向调整 this 后再跳转的短入口,而不直接指向函数主体。次级基类地址与完整对象地址可能不同,这解释了为何一次虚调用除了间接跳转还可能带调整。标准只规定调用结果,不要求所有平台使用该布局。
边界与取舍 去虚化依赖编译器能证明目标集合,例如已知完整对象类型、final 约束或链接时可见性。它可能生成直接调用,也可能保留类型检查后走快慢两条路径,不能从源码 virtual 一词推断最终指令。性能评价还需考虑对象离散分配、分支预测和函数体成本;改成模板后若数据布局未改善,瓶颈可能完全没有变化。
进阶依据:Itanium C++ ABI:虚表与调用调整 · GCC:去虚化与链接时优化选项
面试自测 先说结论,再补条件与反例 基础复习 Q1 虚函数表是标准强制的吗?参考答案 不是。标准要求可观察行为满足动态分派,虚表与虚指针是广泛采用的实现策略。内存布局推导需要指定 ABI、继承结构和编译参数,不能把一次 sizeof 实验泛化到所有平台。
☆ 收藏 基础复习 Q2 虚调用一定比普通调用慢吗?参考答案 间接分派可能增加跳转并影响内联,但实际代价取决于去虚化、预测和函数本身工作量。应在真实热点基准测量,而不是因为存在 virtual 就重写整个架构。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 多重继承中通过第二个基类指针调用覆盖函数,为何可能需要 thunk?参考答案 第二个基类子对象可能位于完整派生对象内部的非零偏移,调用方持有的是该子对象地址,而覆盖函数主体需要按派生类型理解 this。采用相关 ABI 的实现可在虚表入口放置调整短函数,将 this 修正到所需位置后再跳到主体;协变返回还可能要求调整返回指针。这个机制解释地址差异,却不是 C++ 标准强制的表结构。可针对具体编译器和目标平台检查记录布局及反汇编,比较两个基类地址与调用路径;避免手工把对象首字节当虚表指针,因为多继承、构造阶段及不同 ABI 都会破坏这种假设。
答到这些关键点 解释基类子对象偏移 说明this与返回值调整 限定ABI并用布局反汇编验证 ☆ 收藏 边界反例 Q4 把类标为 final 后,每一次虚调用是否都会成为直接调用?参考答案 不保证。final 使某条继承或覆盖关系不能继续扩展,为优化器提供信息,但具体调用点仍需知道对象属于该类型或证明可用目标;编译边界、可见性和优化设置都会影响结果。即使实现能够去虚化,也不一定选择内联大函数。可以比较有无 final、链接时优化和不同调用上下文的生成代码,同时确认语义与动态扩展需求没有被改变。性能基准应保留真实对象分布和函数体工作量,避免编译器把整个循环折叠。若间接调用不是热点,final 主要应表达设计上的封闭性,而不是充当未经验证的提速标记。
答到这些关键点 区分final提供信息与优化保证 检查调用点和编译可见性 防止基准折叠并尊重扩展契约 ☆ 收藏 工程场景 Q5 多态对象列表遍历很慢,如何判定是虚调用还是内存布局造成的?参考答案 先用剖析确认时间落在间接调用、函数主体、分配还是访存停顿,再保留同一计算逻辑比较连续对象、指针列表以及按具体类型分批的数据组织。若对象散布在堆上,逐项加载指针和跨缓存行访问可能比虚分派更贵;批处理或集中存储通常能同时减少调用与访存成本。可以在目标类型已知的对照组中测直接调用,但需防止工作量被优化掉。记录缓存未命中、分支未命中、分配数、吞吐和尾延迟,并将接口扩展需求纳入取舍。只有证据指向分派后,再考虑模板化、variant 或去虚化友好的边界。
答到这些关键点 用剖析分离分派与访存 保持计算逻辑一致做对照 同时测缓存分支和尾延迟 ☆ 收藏
动手验证 查看有/无 final 时的优化汇编;只作为该编译器的观测。
+ 我的笔记 用自己的话解释这个知识点
S2-05 / REVIEW NOTES ○ 标为掌握
纯虚函数与构造期虚调用 纯虚函数声明抽象接口,包含未实现纯虚接口的类型不能实例化。纯虚函数也可以有类外定义,在合适的显式限定调用中复用;这不改变类的抽象性。static 成员函数没有对象动态类型,不能是虚函数,构造函数也不能声明为 virtual。
构造和析构正在进行时,对该对象的虚调用按当前构造或析构层选择实现,不分派到尚未构造或已析构的更派生部分。由该路径虚调用纯虚函数会有未定义行为风险,因此不要在基类构造中依赖派生类完成业务初始化。
代码片段 / 原理示意
struct B { B(){ f(); } virtual void f() {} };
struct D:B { void f() override {} };
// B 构造函数中的调用选择 B::f
DEEP DIVE / 从结论到推演 机制推演 基类构造期间,派生部分尚未完成初始化;基类析构期间,派生部分已先被销毁。语言因此让相关虚调用使用当前构造或析构层的最终覆写者,避免依赖尚不存在的状态。经普通辅助函数间接发起虚调用也不能绕开这一规则。纯虚函数可有类外定义,但经构造析构路径虚调用纯虚函数仍不能靠这份定义变得安全。
边界与取舍 显式限定 Base::f 会抑制虚分派,若纯虚 f 有可用定义,可以在合法上下文调用它;这与调用抽象接口不是一回事。纯虚析构也需要定义,因为销毁派生对象仍会执行基类析构阶段。需要派生参与初始化时,可用工厂在完整构造后调用初始化操作,同时保证失败对象不被发布,让资源清理仍由拥有者负责。
进阶依据:C++20 草案 N4861:构造析构期间的行为 · C++20 草案 N4861:抽象类与纯虚函数
面试自测 先说结论,再补条件与反例 基础复习 Q1 为什么构造函数不能是 virtual?参考答案 虚调用依赖已有对象的动态行为,而构造在建立对象与各基类成员生命周期。语言不支持虚构造;需要运行期按类型创建时,通常用工厂函数返回多态智能指针。
☆ 收藏 基础复习 Q2 如何安全执行需要派生类的初始化?参考答案 让构造只建立本类不变量,再由工厂或明确的初始化阶段调用已构造完整对象的虚接口,并处理初始化失败。优先避免可见的半初始化对象。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 Base 构造函数调用非虚 helper,helper 再调用虚函数 f,会到 Derived::f 吗?参考答案 不会仅因增加一层非虚辅助调用就到达派生实现。调用仍发生在 Base 的构造期间,针对正在构造对象的虚调用按该阶段选择 Base 层的最终覆写者,派生部分尚未完成初始化。若这条路径虚调用了纯虚函数,行为未定义,不能依赖某个运行库报错形式。应把基类构造限制为建立自身不变量,完整对象需要的多态初始化放在受控工厂之后。验证可给每个阶段输出标记观察合法调用,但不要以一次纯虚调用的输出认定有保证;编译器警告和运行时终止只是可能的表现。
答到这些关键点 间接调用仍受构造期规则约束 正确处理纯虚调用未定义行为 工厂延后多态初始化 ☆ 收藏 边界反例 Q4 纯虚函数既然有类外定义,Base::f 的限定调用与普通 f() 有何不同?参考答案 Base::f 的显式限定调用抑制虚分派,直接调用所指定的定义;在对象和成员访问满足其他条件时,可以用它复用纯虚函数提供的公共实现。普通 f() 若进行虚调用,则仍依据当前对象阶段选择最终覆写者,构造析构路径中的纯虚调用不能因为存在定义而获得一般合法性。纯虚标记仍让相关类保持抽象,定义不取消这个性质。纯虚析构是常见用途,必须提供定义以完成派生销毁链。可分别编译限定调用、派生覆盖与抽象类实例化示例,验证三种规则,不要把链接成功当作所有调用路径合法的证据。
答到这些关键点 限定调用抑制虚分派 定义不取消抽象性 解释纯虚析构仍需定义 ☆ 收藏 工程场景 Q5 插件基类构造时自动调用虚 initialize,希望每个插件完成注册,怎样重构?参考答案 把自动注册从基类构造移到工厂:先构造派生对象并放入局部拥有者,再调用初始化接口,最后在初始化成功后加入全局注册表。注册表操作应具有明确失败回滚,避免部分条目已可见而对象随后销毁。若初始化需要回调,回调不得在发布前越过未完成状态,或必须有显式状态检查;失败时注销并等待在途回调结束。可以保留基类非虚包装器执行共同校验,再调用派生钩子。测试构造失败、初始化失败、注册失败和成功后立即卸载四条路径,统计注册项、活对象及回调数量,确认每一阶段拥有权清楚。
答到这些关键点 完整构造后调用派生钩子 发布前建立成功状态 逐阶段失败与卸载测试 ☆ 收藏
动手验证 记录基类构造、派生构造、派生析构、基类析构中虚调用结果。
+ 我的笔记 用自己的话解释这个知识点
S2-06 / REVIEW NOTES ○ 标为掌握
多重继承与菱形继承 普通菱形继承中,两条路径各含一份共同基类子对象,访问共同成员会产生歧义。虚继承让最终派生对象共享该虚基类子对象,最派生类负责初始化虚基类。它解决重复子对象问题,但增加布局和访问调整复杂度。
多继承不是自动错误,多个小型纯接口可有合理用途。若同时继承多个含状态实现,需要检查名字冲突、对象大小、指针调整与销毁方式。组合通常更容易控制共享状态,不应仅为消除编译错误就随意添加 virtual 继承。
代码片段 / 原理示意
struct A { int value=0; };
struct B:virtual A {}; struct C:virtual A {};
struct D:B,C {}; // 一个共享的 A 子对象
DEEP DIVE / 从结论到推演 机制推演 普通菱形的两个共同基类子对象具有独立身份,沿不同路径向上转换可能得到不同地址。虚继承则让最派生对象只保留一份相应虚基类,且由最派生构造函数负责初始化;中间层在构造完整中间对象时才使用自己的虚基初始化器。共享基类状态后,多个分支的覆写仍可能产生最终覆写者冲突,需要最派生类明确解决。
边界与取舍 多重继承中的指针调整由合法类型转换承担,不能用 reinterpret_cast 假设所有基类都从对象首地址开始。虚基类偏移还可能取决于最派生布局。跨接口转换可在适用的多态层次中使用 dynamic_cast 并处理失败,但如果业务只需要两个能力的组合,显式保存独立组件往往更容易解释状态归属和测试替换。
进阶依据:C++20 草案 N4861:多重基类 · C++20 草案 N4861:虚函数最终覆写者
面试自测 先说结论,再补条件与反例 基础复习 Q1 虚基类由谁构造?参考答案 最终最派生类负责构造虚基类,中间类的相关初始化在作为非最派生子对象时不主导该虚基类。顺序还遵循虚基类遍历规则,不由初始化列表任意决定。
☆ 收藏 基础复习 Q2 向不同基类转换,指针数值会变化吗?参考答案 可能变化,因为不同基类子对象可位于完整对象不同偏移。编译器可能执行地址调整;不能把所有基类指针当作同一原始地址或绕开合法转换规则。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 虚菱形中,中间类都给共同虚基传了不同参数,最终到底采用谁的参数?参考答案 当构造最派生对象时,虚基类由最派生类负责初始化,中间类构造函数中针对该虚基的初始化器在这次构造中不起作用。若最派生类未显式指定,则按适用的默认初始化规则处理,可能因没有可用默认构造而编译失败。中间类单独作为完整对象构造时,它自己的虚基初始化器才负责该虚基。应在层次图上标出完整对象边界,分别构造中间对象与最终对象记录参数和顺序。虚继承解决重复基类实例,不替业务决定共享状态应是什么值;这个决策应由拥有完整组合的最派生类型明确承担。
答到这些关键点 最派生类负责虚基初始化 区分中间完整对象与子对象 验证参数及默认构造失败 ☆ 收藏 边界反例 Q4 用了虚继承消除共同基类的重复子对象后,两个分支都 override 同一函数还会冲突吗?参考答案 仍可能冲突。若左右分支都覆盖共享虚基的同一虚函数,而最终类没有提供进一步覆盖,就可能出现同一个虚基子对象对应多个最终覆写者,程序不符合要求。只有一份状态不意味着只有一种行为选择。最派生类应明确覆盖并决定委托到哪一分支,或者重新拆分接口使职责不重叠。using 声明主要影响名字查找,不能自动替代最终覆写者规则。可用最小菱形分别测试普通继承、虚继承及最终类覆盖,观察编译诊断;随后测试两条基类访问路径,确认最终行为与共享状态设计一致。
答到这些关键点 区分状态共享与最终覆写者唯一性 using不代替覆盖冲突解决 验证两条基类调用路径 ☆ 收藏 工程场景 Q5 同一插件实现 Reader 与 Writer,接口指针地址不同却指向同一对象,如何安全管理?参考答案 两个接口指针可能指向完整插件的不同基类子对象,地址不同是合法布局结果,不能据此认定存在两份插件。应由一个明确的拥有者管理完整对象,借用接口通过正常向上转换取得;需要从一个接口寻找另一个接口时,可在适用多态层次用 dynamic_cast 并检查失败,或提供显式能力查询。不要分别把两个裸接口指针建立独立 shared_ptr 控制块,否则可能重复删除同一对象。验证接口查询、销毁次数与插件卸载顺序,并检查跨库 RTTI/ABI 的支持条件;若稳定插件边界更重要,可用句柄加函数表避免依赖具体类布局。
答到这些关键点 识别不同基类子对象地址 保持单一完整对象拥有权 检查跨库RTTI与卸载约束 ☆ 收藏
动手验证 对普通菱形和虚菱形分别输出 A 子对象地址,解释差异。
+ 我的笔记 用自己的话解释这个知识点
S2-07 / REVIEW NOTES ○ 标为掌握
深拷贝、浅拷贝与 Rule of Zero/Five 默认成员拷贝对裸指针只复制地址。若类把裸指针当作独占所有者,两个副本可能删除同一资源。深拷贝创建独立资源,共享所有权则使用明确的引用计数模型,两者都是设计选择,必须与接口一致。
Rule of Zero 建议用 vector、string、unique_ptr 等成员表达所有权,让成员负责资源管理;需要手动管理时要一起考虑析构、拷贝构造、拷贝赋值、移动构造与移动赋值。拷贝赋值还需处理自赋值和失败后的状态保证。
代码片段 / 原理示意
struct Buffer { std::vector<std::byte> bytes; };
// 默认复制 vector:副本拥有独立存储,避免手写 delete[]
DEEP DIVE / 从结论到推演 机制推演 特殊成员函数是一组相互影响的生成规则,不是缺什么编译器都补什么。用户声明析构函数会阻止隐式移动构造和移动赋值的自动生成;但右值仍可能绑定拷贝构造的 const 引用,因此代码可编译却在复制。is_move_constructible 为真也只说明能从右值构造,不证明实际存在或选中了移动构造。观察性能必须结合重载与成员行为。
边界与取舍 拷贝赋值的强保证可通过先准备完整新状态、再以不抛操作提交实现,但 copy-and-swap 并非无条件最优。它可能多分配并放弃已有容量;若 swap 会抛或分配器不兼容,提交步骤也未必安全。Rule of Zero 的价值是把这些组合条件交给成熟成员类型,而自定义深拷贝仍需说明身份、共享关系与失败后的不变量。
进阶依据:C++20 草案 N4861:拷贝与移动构造 · C++20 草案 N4861:拷贝与移动赋值
面试自测 先说结论,再补条件与反例 基础复习 Q1 拷贝构造与拷贝赋值有什么不同?参考答案 构造建立新对象;赋值操作已存在对象,需要处理原资源、可能的自赋值和异常。两者都应维护所有权约定,不能简单用 memcpy 替代含非平凡成员的复制。
☆ 收藏 基础复习 Q2 copy-and-swap 的优点和代价?参考答案 先构造临时副本,成功后无异常交换,可简化自赋值和强异常保证。代价可能是额外分配和无法复用容量;高性能容器需权衡,而不是机械套用。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 只加了一个打印日志的析构函数,容器扩容为什么突然出现大量复制?参考答案 用户声明析构函数会抑制相应隐式移动特殊成员的生成。此时从右值构造仍可能调用接受 const 引用的拷贝构造,所以程序不一定报错,却失去了原本可用的资源转移路径。即使显式补回默认移动,还要看所有成员是否可移动、移动是否真正不抛以及容器所需的异常保证。最佳修复可能是移除不必要的自定义析构,把观测放到专门成员;必须自定义时则整体审查五个特殊成员。用复制移动计数和类型特征检查回归,但不能仅凭 is_move_constructible 为真断言调用了移动,因为拷贝构造也能接受右值。
答到这些关键点 说明析构声明抑制隐式移动 识别右值仍可调用拷贝 整体检查特殊成员与异常性质 ☆ 收藏 边界反例 Q4 copy-and-swap 是否总能提供强异常保证,且一定比原地拷贝赋值好?参考答案 它需要提交用的 swap 满足不抛且交换语义合法等前提。先复制临时对象失败时,原对象可保持不变;但 swap 若抛出或错误交换了不兼容资源归属,就不能声称强保证。对含有状态分配器的容器还要检查传播与相等性条件,不能只交换裸缓冲指针。即使正确,临时副本会增加峰值内存,并可能无法复用目标原有容量。应结合类型大小、失败频率与容量复用需求选择策略。验证自赋值、分配失败、复制到一半失败和不同分配器实例,记录原值是否保持、资源是否成对释放,以及峰值分配量。
答到这些关键点 强保证依赖不抛合法提交 考虑分配器归属与容量复用 故障注入检查原状态及峰值内存 ☆ 收藏 工程场景 Q5 需要复制含 unique_ptr 树节点的配置对象,如何设计深拷贝并保证失败不破坏原值?参考答案 先定义复制语义:每个拥有节点复制成新节点,非拥有父指针应指向新树中的对应节点,外部共享资源则按明确策略继续共享。可用递归 clone 返回 unique_ptr,已完成子树始终由局部拥有者管理,后续分配失败时自动回收。赋值先生成完整新树,再通过不抛的拥有者交换提交,使原配置在准备阶段不变。若深度可能受外部输入影响,需要深度上限或迭代式遍历,避免栈溢出。测试空树、共享引用、极深结构和每个分配点失败,验证地址独立、父指针一致、原配置不变及销毁计数平衡。
答到这些关键点 明确拥有边与非拥有边复制 准备新树后不抛提交 覆盖深度和逐分配失败 ☆ 收藏
动手验证 设计可复制 Buffer 与不可复制 FileHandle,说明区别。
+ 我的笔记 用自己的话解释这个知识点
S2-08 / REVIEW NOTES ○ 标为掌握
this、const 成员函数与对象切片 普通非静态成员函数通过隐式对象参数访问当前对象,常用 this 表达地址;const 成员函数不能经 this 修改普通非 mutable 成员。它是接口层只读承诺,不自动提供线程安全,也不代表对象关联的所有外部状态都不可变。
按值把派生对象复制到基类对象只保留基类子对象,称为切片。动态多态需要通过合适的引用或指针维持完整对象。存储异构多态集合常用 vector<unique_ptr<Base>>,避免 vector<Base> 在插入时丢失派生信息。
代码片段 / 原理示意
void render(const Shape& s) { /* 经虚接口读取 s */ }
// 使用 Shape 值参数可能切片;抽象 Shape 则不能按值构造
DEEP DIVE / 从结论到推演 机制推演 const 成员函数限制经当前对象访问普通成员,却不是递归冻结整个对象图。指针成员本身不能被重新赋值,不代表其指向对象不可修改;mutable 成员也可以变化。若逻辑只读接口更新缓存,缓存依然属于共享可变状态,需要同步。接口中的 const 应解释为允许的观察语义,并明确底层资源是否共享及外部别名是否能够修改。
边界与取舍 对象切片是创建了一个完整 Base 值,派生额外状态未进入这个新对象;给基类增加虚函数也不能恢复已经丢失的信息。通过基类引用赋值则是另一问题,它可能只更新派生对象的基类子对象,破坏跨层不变量。需要多态复制时使用明确 clone,或禁止公开基类值拷贝,避免调用者误把可复制的接口视为完整业务对象。
进阶依据:C++20 草案 N4861:this · C++20 草案 N4861:成员赋值规则
面试自测 先说结论,再补条件与反例 基础复习 Q1 const 成员函数一定线程安全吗?参考答案 不一定。它可访问 mutable 缓存、全局状态或共享指向对象,也可能与其他线程的写入竞争。线程安全需要明确共享状态和同步策略,不能只检查 const 标记。
☆ 收藏 基础复习 Q2 为什么 vector<Base> 存不住多态?参考答案 它按 Base 值存储元素,派生部分会切片,或抽象基类根本不可实例化。使用多态拥有指针保存完整派生对象,并确保基类销毁策略正确。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 const 成员函数修改缓存为什么仍可能有数据竞争?参考答案 const 只限制特定访问路径上的修改,并不提供互斥或原子性。函数可以修改 mutable 缓存,也可能经指针成员修改外部对象;多个线程同时执行这些写入仍会竞争。可将缓存预先构造为不可变数据,或在缓存初始化与更新时使用 mutex、call_once 等匹配协议,之后再考虑无锁快路径。返回缓存引用时还需保证其寿命及更新时的失效规则。验证应让多个线程同时首次访问和强制失效缓存,使用线程检测工具辅助检查,并测锁等待与缓存命中率。不能因为接口签名是 const,就让所有成员读取绕过已有同步。
答到这些关键点 const不等于递归不可变或同步 为缓存选择具体同步协议 覆盖首次访问与失效并发 ☆ 收藏 边界反例 Q4 Base& a 引用一个 Derived,再执行 a = otherBase,会发生通常意义的切片吗?参考答案 它不会新建一个独立 Base 完整对象,因此与按值传参或 Base b = derived 的切片形式不同;但默认基类赋值通常只修改目标的基类子对象,派生部分保持原样。若派生字段依赖基类状态,就可能留下组合不一致的 Derived。虚函数仍可分派到 Derived,并不能证明这个状态合法。可限制基类赋值的可见性、删除不合适的拷贝操作,或提供保持整体不变量的业务更新接口。测试时不仅观察动态类型,还应检查跨层字段关系。需要多态复制则通过 clone 创建完整新对象,再替换拥有者,而不是通过基类赋值猜测派生行为。
答到这些关键点 区分新值切片与子对象赋值 识别跨层不变量破坏 限制基类赋值并使用完整复制 ☆ 收藏 工程场景 Q5 审计事件存入 vector<BaseEvent> 后丢失派生字段,怎样选择替代表示?参考答案 插入时按 BaseEvent 值构造发生切片,后续虚函数无法找回未被保存的派生状态。若事件类型开放且需要运行时扩展,可保存 unique_ptr<BaseEvent> 并提供虚析构,必要时用 clone 支持复制;若类型集合封闭,可用 variant 保存各具体值并进行访问;若只需持久化,直接建立稳定的事件记录格式往往更清晰。应比较分配次数、数据局部性和扩展成本,而非只比较语法方便程度。验证每种事件序列化后字段完整、异常插入无泄漏、复制后所有权独立,并用真实批量负载测内存峰值与扫描吞吐。
答到这些关键点 定位插入时按值切片 比较指针多态variant和记录格式 验证字段所有权与批量性能 ☆ 收藏
动手验证 将多态对象按值和按引用传递,比较调用目标。
+ 我的笔记 用自己的话解释这个知识点
S2-09 / REVIEW NOTES ○ 标为掌握
单例、依赖注入与全局状态 函数局部 static 是实现延迟初始化单例的常用方式,C++11 起初始化受同步保护。禁用拷贝和赋值可避免意外复制,但仍要明确析构顺序、线程退出、动态库卸载和内部状态同步。
单例让依赖隐式化,测试难以替换状态,多个测试之间可能互相影响。日志、配置或连接池即使业务上只有一份,也可以在主程序构造后通过引用传入,使所有权与生命周期可见。是否全局唯一与是否全局可访问是两个问题。
代码片段 / 原理示意
class Config {
public: static Config& get(){ static Config x; return x; }
Config(const Config&)=delete; Config& operator=(const Config&)=delete;
private: Config()=default;
};
DEEP DIVE / 从结论到推演 机制推演 单例初始化只解决“何时首次得到一个成功对象”,没有解决谁依赖它、谁能重置、何时退出。依赖注入可在进程入口构造唯一服务并向使用者传引用,因此全局唯一与全局访问可以拆开。对象图按依赖顺序构造、逆序销毁,更容易保证日志、线程池和连接池之间的退出关系,也能让测试为每个用例提供独立实例。
边界与取舍 双重检查锁的风险不是只少写一个 volatile,而是必须正确发布指针、保护构造与销毁、并保证读取方期间对象存活。即便 acquire/release 发布正确,运行时重置单例仍会引入回收问题。局部 static 或 call_once 更适合一次初始化;需要热更新时,应改为显式版本快照或拥有者交换,独立设计旧版本何时回收。
进阶依据:C++20 草案 N4861:局部静态对象初始化 · C++20 草案 N4861:shared_ptr 原子特殊化
面试自测 先说结论,再补条件与反例 基础复习 Q1 局部 static 单例有什么局限?参考答案 初始化线程安全不涵盖后续业务读写。全局生命周期使销毁顺序、后台线程退出和测试隔离变复杂;多模块或进程的“唯一”范围也需要明确。
☆ 收藏 基础复习 Q2 如何让单例服务易于测试?参考答案 将依赖提取为接口,调用者经构造参数接收服务。生产入口传共享实例,测试传独立假实现,避免测试直接依赖不可重置全局状态。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 局部 static 单例已线程安全,为何仍建议把服务从 main 注入?参考答案 语言保证的是首次初始化的同步,不是服务操作、退出顺序或测试隔离。显式在入口构造服务并传引用,可以把依赖关系暴露在类型与构造参数中,让后台线程、连接和日志按确定顺序退出。业务只需要一份实例时,入口仍然只创建一份,并不必依赖全局访问。测试则可注入替代实现或独立状态,避免前一用例污染后一用例。判断是否值得改造应看隐式依赖数量、重置需求和退出故障,而非机械禁止单例。可通过并行测试、快速退出和依赖构造失败用例验证收益,并保持传入引用的拥有者寿命足够长。
答到这些关键点 限定初始化保证范围 解释唯一实例仍可注入 验证退出与测试隔离 ☆ 收藏 边界反例 Q4 双重检查锁已用 atomic 指针和 acquire-release,能安全支持 reset 吗?参考答案 不能由发布正确直接推出回收安全。读线程 acquire 得到指针后,另一线程可能 reset 并销毁对象,前者随后解引用就悬空;原子指针保证指针读写原子,不会自动固定所指对象寿命。需要让读取获得拥有快照、在锁保护下完成使用,或采用经过证明的延迟回收机制。若对象从初始化后一直活到所有工作线程退出,问题可显著简化,通常不需要复杂 reset。验证应专门制造“读取指针后暂停、另一线程重置、随后恢复”的交错,检查协议如何阻止回收,而不是仅跑大量随机读写后以未崩溃宣称安全。
答到这些关键点 区分发布与内存回收 指出load到使用之间的悬空窗口 用确定性交错检验拥有协议 ☆ 收藏 工程场景 Q5 配置服务需热更新且读远多于写,如何替代可变全局单例?参考答案 把每版配置构造成不可变快照,更新线程先独立完成解析与校验,再发布共享拥有指针;读取方先取得快照并在整个请求中持有它,旧版直到最后一个读者结束才回收。C++17 可使用 shared_ptr 的适用原子操作函数,C++20 可考虑 atomic<shared_ptr<T>>,但它们不保证所有实现都无锁。这样避免读到更新一半的字段,也明确了寿命。需要限制大快照和长请求带来的旧版内存堆积。测试解析失败不发布、并发更新版本一致性与慢读者,测读取开销、更新时间、存活版本数和峰值内存再决定是否进一步优化。
答到这些关键点 先构造完整不可变快照再发布 持有快照解决读者寿命 区分17/20接口并测版本堆积 ☆ 收藏
+ 我的笔记 用自己的话解释这个知识点
从理解到实践 阶段项目与验收 设计 Shape 层次并用 unique_ptr 管理多态对象;验证析构顺序,加入 clone 深拷贝接口,用组合实现绘制策略。记录值传递导致的切片现象。
SYSTEM INTERVIEW / 跨知识点综合题 允许替换实现的插件接口,怎样安全卸载? 宿主用 C++ 加载第三方插件。插件返回 Base*,宿主用虚函数调用,后续希望热更新动态库。某版本在卸载后销毁对象时崩溃。假设不要求在任意时刻无等待卸载,设计可验证的接口与卸载协议。
展开方案与推理 先区分三层契约:C++ 类型接口、工具链 ABI、插件模块的运行生命周期。virtual 析构只解决适用的多态销毁语义,不保证不同编译器或运行库兼容,也不能让已经卸载的析构函数代码重新出现。检查对象、虚表、删除器、回调和线程是否仍引用模块代码。若 ABI 不能严格约束,可在边界采用带版本的 C 链接入口、不透明句柄和 create/use/destroy 函数,错误转换为明确状态,内部继续用 C++。
把模块句柄的寿命置于所有插件对象和任务之外。开始卸载时先拒绝新对象和新请求,再撤销回调注册,向后台任务请求停止并等待其退出;确认没有活跃调用和持有模块代码的删除器后,调用插件提供的 destroy 销毁其对象,最后释放模块句柄。不能仅数 Base* 个数,还要追踪任务、函数对象和外部持有者。协议超时时报告不能安全卸载,选择延后或进程级替换,而非强行释放。
以模块代际区分新旧请求:新请求进入新版本,旧请求在旧模块完成,旧代引用归零后退出。若无法对第三方代码给出可靠停止与 ABI 契约,独立子进程加 RPC 隔离通常比进程内热卸载容易定义失败边界,但引入序列化、进程监督与性能成本。结论应建立在停机时限和隔离要求上,而非“虚函数加 shared_ptr 就能解决”。
验收证据 模块卸载之前,插件对象、后台线程与回调都达到约定终止状态。 版本不兼容明确拒绝,失败不留下半注册状态。 在途请求与旧代资源有可观测的计数和生命周期日志。 继续追问 追问:宿主自己 delete 插件对象不就可以了吗? 只有接口明确保证销毁 ABI、分配器和运行库匹配,且模块仍加载时,才可依合同执行。更可控的边界是由插件导出的 destroy 回收插件创建的资源,宿主用带删除器的 RAII 包装。删除器自身也是插件代码,其执行前必须保持模块有效;销毁对象和卸载模块的顺序是接口的一部分。
追问:用 shared_ptr 持有模块句柄,为什么还是可能无法卸载? 它能表达部分寿命依赖,但若回调、任务或模块对象之间形成强引用环,计数不会归零;后台线程也可能永久等待而一直持有引用。需要明确的关闭操作来打破业务依赖,注销回调、取消并 join 任务,然后由最后拥有者释放模块。引用计数不能替代停止协议和异常路径分析。
追问:怎么验收热更新,而不是只演示正常切换? 在构造、调用、回调和销毁的每个阶段注入延迟与失败,反复切换版本,检查旧代没有新流量、在途请求按约定完成或取消、无卸载后调用。记录活跃代、对象数、回调数、任务数和等待时间;增加 ABI 版本拒绝测试。把无法安全卸载时的行为也写进测试,避免超时后悄悄强制关闭模块。
参考依据:动态装载生命周期 · Itanium ABI:一种具体实现约定
CODE REVIEW / 动手审查 多态销毁:virtual 应该放在哪里? 下面的动态调用能工作,为什么 delete 仍不安全?同时讨论公开虚析构和受保护非虚析构的用途。
C++17 · 类定义片段 · 先自行判断,再展开核对。
struct Base {
virtual void run() {}
~Base() = default;
};
struct Derived : Base { std::vector<int> buffer; };
Base* p = new Derived;
delete p; // 经不具虚析构的基类指针删除派生对象:未定义行为。查看问题、修复与验证 具有某个虚函数不会使析构函数自动变成虚函数。要支持普通 delete 经基类指针销毁派生对象,基类应提供可访问的虚析构。
如果接口明确禁止通过基类删除,可考虑受保护的非虚析构,由其他拥有者管理销毁。不能仅把析构改 protected 后继续用默认 unique_ptr<Base> 删除。
虚析构只处理适用的销毁语义,不能解决 ABI 不兼容、插件卸载过早或重复所有权。shared_ptr 的特定构造方式保存正确删除器,也不应被概括为裸指针 delete 安全。
修正思路 struct Base {
virtual ~Base() = default;
virtual void run() = 0;
};
struct Derived : Base {
void run() override {}
};
std::unique_ptr<Base> p = std::make_unique<Derived>();怎样验证 记录 Derived 和 Base 的析构顺序,检查资源只释放一次;开启非虚析构相关编译警告。插件场景还要验证销毁前模块仍然加载。
这里提供审查练习与验证步骤,不代表已在本机执行 C++、数据库或 GPU 实验。
依据:delete-expression
本章参考资料与适用版本 正文为原创整理;链接用于进一步核对,不影响离线阅读。C++ 工作草案会持续更新,本文采用已标注的 C++17/20 功能范围。
STAGE 03 / 八阶段学习路线 STL 与容器 从数据布局、复杂度、失效规则和异常保证四个角度选容器。不要把某一版本标准库的实现细节背成标准要求。
← 回到路线图 抽题练习本章 本章目录 · 7 个知识点
S3-01 / REVIEW NOTES ○ 标为掌握
六大组件与泛型算法 容器管理元素;迭代器连接数据与算法;算法描述操作;仿函数提供可调用策略;适配器改变接口;空间配置器参与存储分配与对象构造。理解这套分工,才能解释为什么 sort 不属于 vector 成员,而 list 有自己的 sort。
算法要求具体迭代器能力,例如随机访问才能高效做 std::sort。迭代器不一定是裸指针,可能含节点指针、段信息或代理引用。复杂度包括比较次数与移动/访问成本,链表的“插入 O(1)”需要已知插入位置。
代码片段 / 原理示意
std::vector<int> v{3,1,2};
std::sort(v.begin(),v.end(),std::greater<int>{});
DEEP DIVE / 从结论到推演 机制推演 allocator 把取得存储与构造元素分开,容器通过 allocator_traits 使用分配器能力。reserve 可以增加存储而不创建可访问元素,构造失败时则需要只销毁已完成的元素。分配器相等表示相互释放对方所分配存储的兼容性,不是它们地址相同。移动赋值能否直接接管缓冲,取决于传播策略与相等性,不能只由 std::move 推断常数成本。
边界与取舍 C++17 pmr 让容器把运行时内存资源作为分配策略,但容器通常不拥有该资源。局部资源销毁后返回依赖它的容器会留下无效存储或释放路径。单调资源适合整批构造整批释放,单次 deallocate 不回收空间,因此反复扩容与清空会积累占用。资源、初始缓冲和使用它的容器必须按正确顺序存活与销毁。
进阶依据:C++20 草案 N4861:分配器要求与传播 · C++20 草案 N4861:容器一般要求 · C++20 草案 N4861:单调内存资源
面试自测 先说结论,再补条件与反例 基础复习 Q1 迭代器为什么能解耦算法和容器?参考答案 算法通过迭代器约定的访问与移动操作处理范围,无需知道容器节点布局。容器只需提供满足能力要求的迭代器;算法依然有前提,不是所有迭代器都支持所有算法。
☆ 收藏 基础复习 Q2 allocator 和 new 有何关系?参考答案 分配器抽象让容器可定制存储来源与相关策略,而 new 表达式通常把分配和对象初始化结合。容器内部需区分尚未构造的容量与已存在的元素,不是对每个槽都默认构造。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 两个 vector 使用同一分配器类型,移动赋值为何仍可能逐元素移动?参考答案 同一分配器类型不代表两个实例能互相释放存储。若移动赋值不传播分配器,且源目标分配器比较不相等,目标不能简单接管源缓冲后再交给自己的分配器释放,因而需要按目标资源取得存储并逐元素转移。传播策略允许或分配器兼容时才可能走缓冲接管路径;异常与复杂度还取决于具体操作要求。排查应记录分配器实例、相等比较、传播类型特征及元素移动次数。测试相等和不相等资源两组,检查释放归属与峰值内存。不能用一个默认分配器基准证明所有自定义内存池上的移动都为常数时间。
答到这些关键点 区分分配器类型与实例兼容性 说明传播策略控制缓冲接管 测移动次数及释放归属 ☆ 收藏 边界反例 Q4 函数内创建 monotonic_buffer_resource,再返回使用它的 pmr::vector,为什么移动返回也救不了?参考答案 移动容器通常不会把内存资源本身变成拥有对象,返回值仍可能保存指向局部资源的分配器关系及依赖该资源的存储。函数退出时资源销毁,已分配块可能被释放,后续访问或容器析构就不再安全;返回值优化同样不能自动延长资源寿命。应让资源由更长寿命的拥有者管理,或把结果复制移动到调用方明确提供的资源。若资源使用外部初始缓冲,该缓冲也必须足够长寿。验证要同时记录资源与容器析构顺序,并测试结果离开函数后再增长和销毁;只在返回后立即读一次元素容易掩盖错误。
答到这些关键点 pmr容器不自动拥有资源 区分移动或消除与资源寿命 检查资源及初始缓冲的共同寿命 ☆ 收藏 工程场景 Q5 每个请求解析上万节点,分配器成为热点,如何评估改用 pmr?参考答案 先确认热点确实是频繁小分配,而非解析或哈希,再为每个请求配置独立资源,让节点容器的寿命严格短于资源。若所有节点同时释放,可试单调资源并一次性预估容量,减少扩容遗留块;若节点长期交错释放,则评估池资源而非强行使用单调策略。必须检查嵌套字符串和容器是否也使用目标资源,否则只优化外层分配。监控上游分配次数、峰值内存、请求尾延迟及慢请求存活时间,并注入资源耗尽失败。资源默认的线程使用约束也应明确,不能让多个工作线程无同步地共享本为单线程设计的资源。
答到这些关键点 按释放模式选择资源 检查嵌套对象分配传播 测上游分配与峰值并覆盖失败 ☆ 收藏
动手验证 分别对 vector 和 list 排序,比较接口与复杂度。
+ 我的笔记 用自己的话解释这个知识点
S3-02 / REVIEW NOTES ○ 标为掌握
vector:size、capacity 与扩容 vector 在连续存储中维护已构造元素,size 是元素数量,capacity 是无须重新分配可容纳的数量。reserve 改变预留能力而不增加元素,resize 改变实际元素数量。下标必须小于 size,即使某个地址落在 capacity 范围也不能当成已存在元素访问。
扩容通常分配新存储,将元素移动或复制过去,再销毁旧元素并释放旧存储。增长倍率由实现选择,不能固定说都是两倍。连续性有利于缓存和批量处理;尾插摊销常数不等于每次都常数,单次扩容可能是线性。
代码片段 / 原理示意
std::vector<int> v; v.reserve(100);
// v.size()==0,不能写 v[0]
v.push_back(7); // 现在才有第一个元素
DEEP DIVE / 从结论到推演 机制推演 vector 扩容可看作准备新存储、转移元素、提交新状态的过程。若元素能复制而移动可能抛出,实现通常复制旧元素,以便失败时保留原值;若移动确实不抛,则可安全转移资源。对于只有可能抛出的移动、又不满足可复制插入要求的类型,某些扩容异常路径不再保证原状态。异常保证由操作和元素条件共同决定,不能概括成“vector 永远强保证”。
边界与取舍 每次尾插前 reserve(size()+1) 可能强迫反复精确扩容,破坏通常依赖几何增长得到的摊销效果。应在掌握总量时一次 reserve,未知规模时让容器正常增长。shrink_to_fit 是非强制请求,也可能引发重分配与句柄失效。容量保留、峰值内存和扩容尾延迟之间存在取舍,清空 size 并不等于把容量归还系统。
进阶依据:C++20 草案 N4861:vector 容量操作 · C++20 草案 N4861:vector 修改与异常保证
面试自测 先说结论,再补条件与反例 基础复习 Q1 reserve 与 resize 的区别?参考答案 reserve 预留容量,不构造新增元素;resize 增减元素数量,增长时构造元素,缩小时销毁多余元素,通常不要求缩减容量。根据是否需要实际元素决定使用哪个。
☆ 收藏 基础复习 Q2 vector 为什么偏好 noexcept 移动?参考答案 搬移中若抛异常,可能难以恢复旧元素。无异常移动让转移过程可控;对可复制但移动可能抛出的类型,实现可能用复制维持强保证。不可复制类型要看具体操作的异常条款。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 尾插触发扩容时,vector 为什么可能复制已有元素而不是移动?参考答案 移动若会抛出,已经搬走的源元素可能改变,之后失败就难以恢复原容器。对于满足可复制插入要求的元素,实现可复制到新存储,全部成功后再提交,从而满足适用的无效果保证;移动不抛时则可安全转移。标准规定的是条件化的异常结果,不要求所有实现采用同一内部算法。应检查元素的复制能力、移动异常说明及自定义分配器的构造行为。用带计数的元素分别构造可复制且移动会抛、移动不抛两组,注入分配或元素构造失败,验证原值、size、资源计数,而不能只看性能日志中的 move 是否出现。
答到这些关键点 从回滚困难解释复制选择 区分标准保证与常见算法 按元素能力做故障注入 ☆ 收藏 边界反例 Q4 元素不可复制且移动构造会抛,扩容到一半失败后,能断言原 vector 完全不变吗?参考答案 不能。对不满足可复制插入要求的类型,若相关扩容或尾插路径由元素移动构造抛出,标准允许效果未指定,不能继续假设所有原元素值未变。应避免把这类类型交给需要可恢复强保证的操作:若资源转移实际不会失败,正确实现并声明 noexcept;否则考虑存储不抛移动的拥有指针,或在业务层先完成可失败准备。noexcept 不能虚报,逃出异常会终止程序。测试需要覆盖第几个元素移动时失败,但某实现表现可恢复也不构成跨实现保证;接口应按照最弱的标准承诺设计重试和错误处理。
答到这些关键点 准确指出抛出移动的条件化例外 不把未指定效果说成保持原值 用真实noexcept或间接拥有改善 ☆ 收藏 工程场景 Q5 百万条数据尾插的 p99 延迟突增,平均吞吐正常,如何改进并验证?参考答案 先把延迟尖峰与 capacity 变化、分配事件和元素转移次数关联,确认是否扩容而非锁或 I/O。若批量大小可预估,在进入延迟敏感区前 reserve 合理上限;若单次峰值仍不可接受,可按固定批次处理或选择满足访问需求的分段结构,明确连续性和随机访问代价。不要每次 reserve(size()+1),也不要为消除扩容无上限预留。测试真实大小分布、超出预估的尾部和内存失败,记录 p99、最大暂停、峰值内存及复制移动次数。对外保存的指针或视图应在可能扩容前重新设计,预留容量只是有条件延迟失效而非永久稳定。
答到这些关键点 将尾延迟与扩容事件关联 按估计批次预留并处理超量 同时验证内存与借用失效 ☆ 收藏
动手验证 记录自定义类型的复制/移动次数,分别在有无 reserve 下尾插。
+ 我的笔记 用自己的话解释这个知识点
S3-03 / REVIEW NOTES ○ 标为掌握
map、unordered_map 与容器选择 map 提供按比较器排序的键,查找与更新通常要求对数复杂度,常见实现是平衡树;unordered_map 组织哈希桶,常见查找平均常数、最坏线性。需要范围遍历、前驱后继或稳定排序时,map 更直接。
哈希函数与相等谓词必须一致:相等键应有相同哈希值。大键计算哈希、碰撞、节点分配和缓存行为都会影响实际性能。operator[] 可能插入缺失键;只查询应用 find、contains(C++20)或 at,避免读路径意外修改。
代码片段 / 原理示意
if (auto it=m.find(key); it!=m.end()) { use(it->second); }
// C++17 初始化语句;m、key、use 由业务定义
DEEP DIVE / 从结论到推演 机制推演 map 的键等价由比较器决定:两个方向都不小于即视为同一等价键,不必与 operator== 相同。unordered_map 则由相等谓词定义等价,并要求等价键哈希一致。自定义忽略大小写查询时,只改其中一处会破坏检索前提。存入容器后的键及比较规则必须保持语义稳定,不能通过外部可变配置悄悄改变已有索引的组织依据。
边界与取舍 查找成本不只有节点或桶数量。长字符串的哈希要扫描内容,树比较则可能在首个不同字符停止;小规模数据的连续排序数组也可能更省内存、更利于缓存。透明比较可减少临时键构造,C++20 无序容器的相应异构查找需要哈希和相等谓词共同满足要求。比较方案时应使用真实键长度、命中率和更新比例,而非只比较大 O。
进阶依据:C++20 草案 N4861:有序关联容器要求 · C++20 草案 N4861:无序关联容器要求
面试自测 先说结论,再补条件与反例 基础复习 Q1 有序表和哈希表如何选?参考答案 按操作集合选择:区间查询与有序输出用有序结构,精确键查询且哈希质量可控时考虑哈希。再测内存、构造成本和真实延迟;数据很少时连续数组线性扫描也可能更快。
☆ 收藏 基础复习 Q2 map 的比较器为什么不能用 <=?参考答案 有序容器需要严格弱序,某元素不应小于自身。<= 破坏这一性质,容器查找与结构假设不再成立。自定义比较还要处理多字段优先级并保持一致。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 大小写不敏感的 unordered_map 只修改 key_equal,却仍使用原字符串哈希,会有什么问题?参考答案 相等谓词若把 Ab 与 ab 视为等价,哈希也必须给这两个键相同结果。保留区分大小写的哈希可能把它们放进不同桶,查找先按桶定位后无法找到本应等价的已有键,程序不再满足容器前提。应让哈希与比较采用完全一致的规范化规则,或在入库时存储规范化键。字符编码与地域规则也必须固定,不能简单假定逐字节小写适用于所有 Unicode 文本。验证除了几个示例,还应检查任意等价键是否哈希一致、等价关系是否传递,以及插入后查询规则是否保持稳定;性能再测规范化成本和临时分配。
答到这些关键点 指出等价键哈希一致性要求 统一规范化与字符语义 验证等价性质及规则稳定性 ☆ 收藏 边界反例 Q4 map 比较器按对象指针指向的 score 排序,修改 score 后再 find 为什么可能失败?参考答案 虽然容器保存的指针值没变,但比较器依赖的实际排序键已经改变,现有树结构没有随之重排,因而违反已有键比较关系稳定的要求。把指针声明为 const 也不冻结所指对象。应在修改排序字段前从索引移除,修改后重新插入,或把不可变键副本存入容器并通过显式更新维护索引。C++17 节点提取可支持某些键修改场景,但仍须遵守重新插入与冲突处理规则。测试修改最小、中间、最大元素后遍历和查找的一致性,并用统一更新入口防止旁路写入;仅补一次 rebalance 不是标准容器提供的修复接口。
答到这些关键点 键稳定性包含间接依赖状态 移除后更新再插入 处理节点重插冲突和旁路修改 ☆ 收藏 工程场景 Q5 十万条长字符串配置,读占九成且需要前缀范围查询,怎样选容器?参考答案 前缀范围查询需要有序性,map 或排序连续数组比只支持哈希定位的容器更直接;若更新按批次进行,可构造新排序快照并用 lower_bound 定位区间,换取连续存储与较低节点开销。若频繁单条更新且需要稳定节点引用,map 更合适。精确查找特别热时可额外建立哈希索引,但必须衡量双索引内存及一致性维护成本。使用真实键长度、公共前缀分布和命中率测精确与范围查询的 p99、构建时间和内存,不默认平均常数就胜过对数。异构查找可避免为临时 string_view 构造拥有字符串,但要保证查询视图在调用期间有效。
答到这些关键点 由前缀与更新模式选择结构 评估连续快照和双索引成本 真实键分布测试且处理视图寿命 ☆ 收藏
动手验证 对随机、重复、碰撞键做查询实验,分别报告平均和最坏样本。
+ 我的笔记 用自己的话解释这个知识点
S3-04 / REVIEW NOTES ○ 标为掌握
迭代器、引用和指针失效 vector 重新分配时,旧元素的迭代器、指针与引用全部失效;未重新分配的插入会使插入点及其后的迭代器和引用失效,erase 影响删除点及之后,过去的 end 也要重新获取。list 插入通常不影响已有迭代器,删除使被删元素对应迭代器失效。
unordered 容器 rehash 使迭代器失效,但通常不使元素引用和指针失效;删除元素则会使对应引用失效。规则要按容器和操作分别记,不能用“节点容器全部安全”概括。循环删除时使用 erase 返回值继续。
代码片段 / 原理示意
for(auto it=v.begin();it!=v.end();) {
if(*it<0) it=v.erase(it); else ++it;
}
面试自测 先说结论,再补条件与反例 基础复习 Q1 为什么边遍历 vector 边 push_back 容易出错?参考答案 push_back 可能扩容并使当前迭代器失效;即使不扩容,旧 end 也不应沿用。应明确遍历原始范围或采用索引并控制范围增长,必要时分离收集与修改阶段。
☆ 收藏 基础复习 Q2 rehash 后能保留元素引用吗?参考答案 无序关联容器 rehash 一般保留元素引用与指针有效性,但迭代器失效。仍需保证元素未被 erase、容器未销毁,且并发访问有合适同步。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 unordered_map 执行 rehash 后,为什么元素指针可继续用,而原迭代器不可以?参考答案 标准明确区分两者:重哈希会重新安排桶与遍历次序,使迭代器失效,但不使已有元素的指针和引用失效。实现可以保持节点对象不动,同时改变迭代器推进所依赖的组织关系,因此地址稳定不足以支持继续 ++ 或比较旧迭代器。需要遍历时在操作后重新取得迭代器;若只借用一个仍存在元素,可在不发生删除且满足同步的前提下保留其引用。测试应强制触发重哈希并用调试迭代器辅助发现旧遍历位置使用,同时单独验证元素身份。不要把这项稳定性推广到 erase、clear 或容器销毁,它们仍会结束对应元素寿命。
答到这些关键点 准确区分迭代器与元素引用 说明桶组织变化的原因 不推广到删除或并发安全 ☆ 收藏 边界反例 Q4 deque 尾插与 list splice 都可能保留元素地址,迭代器处理是否一样?参考答案 不一样。deque 在任意一端插入会使所有迭代器失效,但已有元素引用保持有效;在中间插入则连已有元素引用也会失效。list 的合法 splice 会保留移走元素的迭代器及引用,但这些迭代器随后属于目标容器,不能继续拿它们与源容器 end 配合遍历。跨 list splice 还要求相应分配器相等,否则不满足操作要求。应按具体容器、具体操作和具体句柄记规则,而不是概括成节点或分段结构稳定。验证时分别检查元素访问、遍历推进和容器归属,并覆盖空容器、单元素及自拼接限制。
答到这些关键点 正确给出deque端插与中插区别 说明splice后迭代器归属变化 检查分配器和自拼接前提 ☆ 收藏 工程场景 Q5 遍历 vector 时删除匹配元素,程序偶发跳过记录,如何修复并审查性能?参考答案 erase 会使删除位置及之后的迭代器失效,继续对旧迭代器自增既可能错过元素,也违反使用前提。需要逐项处理时写成删除后使用 erase 返回的新迭代器,不删除时才递增;若只做批量过滤,C++17 可用 erase-remove 习惯用法,C++20 可用 erase_if。它们也会移动保留元素,因此外部保存的引用或业务索引必须更新。测试连续多个命中、全删、全保留及首尾删除,核对最终集合。测大批删除的移动次数,避免在循环中反复中间 erase 导致平方成本;若需要稳定业务身份,使用独立编号而不是长期保存数组位置。
答到这些关键点 使用erase返回值继续 比较逐删与批量过滤成本 检查外部引用和业务身份 ☆ 收藏
动手验证 分别验证 vector 扩容与 unordered_map rehash 前后地址变化,避免解引用失效值。
+ 我的笔记 用自己的话解释这个知识点
S3-05 / REVIEW NOTES ○ 标为掌握
push_back、emplace_back 与参数转发 push_back 接收一个元素对象并进行复制或移动;emplace_back 转发构造参数在容器位置构造元素,可能省去临时对象。对于已有 T 对象,emplace_back 不一定比 push_back 更高效,两者都可能触发容器扩容。
原地构造也有语义差异:它可能直接调用 explicit 构造函数,容易让模糊参数意外匹配另一个构造。应优先保证代码清晰和异常安全,例如把资源先装进 make_unique 的返回对象,再交给容器。
代码片段 / 原理示意
std::vector<std::string> v;
v.emplace_back(3,'x'); // 构造 "xxx"
std::string s="hello"; v.push_back(std::move(s));
DEEP DIVE / 从结论到推演 机制推演 emplace_back 把实参转发给元素构造函数,通常是直接初始化,选择的构造未必与花括号创建临时对象相同。例如 vector<vector<int>> 的 emplace_back(10,20) 构造十个二十,而 push_back({10,20}) 插入包含两个数的向量。性能讨论前必须先确认对象值是否一致。转发还不能自动推导所有花括号列表,必要时显式指定 initializer_list 或元素类型。
边界与取舍 把 new 的裸结果直接传给 emplace_back<智能指针元素>,在容器分配失败而智能指针尚未构造时可能泄漏;先用 make_unique 建立拥有者再传入更安全。对于关联容器,emplace 遇到重复键也可能构造并丢弃候选对象,C++17 try_emplace 有不同保证,但调用实参表达式仍会先求值。接口名称不能代替求值和拥有权分析。
进阶依据:C++20 草案 N4861:vector 插入操作 · C++20 草案 N4861:map try_emplace
面试自测 先说结论,再补条件与反例 基础复习 Q1 emplace_back 一定更快吗?参考答案 不一定。如果参数本来就是 T 对象,仍需要移动或复制;扩容也可能成为主要成本。原地构造的收益应针对具体构造方式与容量行为测量。
☆ 收藏 基础复习 Q2 为什么不直接 emplace_back(new T)?参考答案 若容器分配等步骤在所有权接管前失败,裸资源管理容易出错。先用 make_unique<T>() 建立 RAII 所有者,再放入 vector<unique_ptr<T>>,让失败路径也能自动清理。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 已有一个 T 对象时,用 emplace_back(std::move(x)) 是否一定优于 push_back(std::move(x))?参考答案 通常不能因此获得额外优势:两者都要从 x 构造末尾元素,重载决议可能选择同样的移动构造;容器若扩容,还需要转移已有元素。emplace 的主要机会是在传入构造参数时直接创建目标元素,而不是给现成 T 更换函数名。若 x 为 const,std::move 也可能导致复制,emplace 不会去掉 const 约束。应先检查所选构造、容量变化和元素异常性质,再用计数类型及真实负载比较。基准需保持同样的最终元素值和预留策略;若差异只是临时对象被编译器消除,复杂的改写未必带来可维护的收益。
答到这些关键点 已有T常选择同样构造 区分新增元素与旧元素迁移 控制基准语义和容量条件 ☆ 收藏 边界反例 Q4 map.try_emplace(key, expensive()) 在键已存在时,还会调用 expensive 吗?参考答案 会,函数调用的实参表达式在进入 try_emplace 前就需要求值。该接口在键已存在时不构造新的映射元素,也不会为插入而消耗所传对象,但它无法取消调用前已经发生的昂贵计算或副作用。若必须避免计算,可先查询,在不存在时才调用工厂并插入;并发场景下检查与插入要受同一协议保护,不能因此引入重复创建或数据竞争。另一选择是让映射类型接受惰性构造参数,但要把延迟执行契约写清。测试重复键时分别统计工厂调用次数与元素构造次数,避免把两个计数混在一起评估 try_emplace 的收益。
答到这些关键点 区分实参求值与元素构造 说明try_emplace不会取消预先工作 惰性方案需同步且分别计数 ☆ 收藏 工程场景 Q5 vector<unique_ptr<T>> 的 emplace_back(new T) 在内存紧张时泄漏,怎样修复?参考答案 new T 先成功产生裸指针,进入 emplace_back 后容器可能还需要分配新缓冲;若此时分配抛出,unique_ptr 元素尚未构造,没有拥有者负责删除该指针。应先使用 make_unique<T> 形成临时拥有者,再通过 push_back 或 emplace_back 移入容器,容器操作失败时临时拥有者会清理资源。预先 reserve 能减少某些失败窗口,却不是替代拥有权的完整修复。测试分别在 T 构造、容器分配与插入阶段注入失败,检查活对象数回到基线。并确认元素移动不抛及调用方没有另行 delete 已转移的指针,避免从泄漏变成双重释放。
答到这些关键点 定位裸资源先产生的异常窗口 先make_unique建立拥有者 逐失败点验证无泄漏和双删 ☆ 收藏
动手验证 用带日志的类型比较传已有对象和传构造参数。
+ 我的笔记 用自己的话解释这个知识点
S3-06 / REVIEW NOTES ○ 标为掌握
rehash、负载因子与预分配 负载因子是元素数除以桶数。插入可能超过 max_load_factor 对应阈值并触发 rehash,重新组织桶分布,因此批量插入前 reserve 预估元素数量可减少反复重建。rehash 的参数指向桶数量要求,reserve 更适合以元素数量规划。
降低负载因子可用更多桶换取更少碰撞,但有内存代价,也不能补救完全失衡的哈希。外部不可信键应评估碰撞攻击与资源上限。性能分析需要看 p95/p99 延迟,而非只看平均插入耗时。
容易答错 reserve 不是锁,也不使并发插入安全。
代码片段 / 原理示意
std::unordered_map<std::string,int> m;
m.max_load_factor(0.7f); m.reserve(10000);
DEEP DIVE / 从结论到推演 机制推演 reserve(n) 按预计元素数和当前最大负载因子计算桶需求,rehash(n) 则直接提出桶数下限;实现可选择更多桶,不能假定结果刚好等于参数。降低 max_load_factor 不等于立即重建已有桶结构,需按接口语义决定是否再 reserve 或 rehash。批量规划应先设负载策略再预留,并把所有可能失效的迭代器在预留后重新获取。
边界与取舍 更多桶只能减轻部分正常分布下的碰撞,无法分散哈希结果完全相同的键。外部可控制输入时需要容量上限、合适哈希策略及最坏路径监控。标准无序容器重哈希的平均复杂度为线性、最坏可达平方,不能把它隐藏在每次请求的延迟预算中。桶数组、节点和键内容都占内存,负载因子不是总内存使用率。
进阶依据:C++20 草案 N4861:无序容器桶接口与复杂度
面试自测 先说结论,再补条件与反例 基础复习 Q1 rehash 为什么会出现延迟尖峰?参考答案 它可能一次性重新组织大量元素的桶映射,成本随规模增长。批量构建前预留、限制单次请求写入量和选择适合的结构有助于控制尖峰,但需要测量内存与延迟权衡。
☆ 收藏 基础复习 Q2 哈希冲突与哈希相等是否等于键相等?参考答案 不是。不同键可以哈希相同,容器仍用相等谓词区分。反过来,若相等谓词认定相等的键哈希不同,会破坏无序容器的要求。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 unordered_map 的 reserve(10000) 与 rehash(10000) 有什么实际区别?参考答案 reserve 的参数表达计划容纳的元素总数,它依据当前最大负载因子折算需要的桶数;rehash 的参数直接给桶数下限,并同时满足现有元素与负载约束。实现可以选更大的合适桶数,所以两者都不承诺恰好得到一万个桶或节点,也不提前构造一万个元素。若打算降低最大负载因子,应先调整再 reserve,否则之前预算可能不足。操作可能重建桶并使旧迭代器失效,已有元素引用则遵守无序容器的稳定规则。验证时记录 size、bucket_count、load_factor 与分配量,在实际库实现上比较,避免把单个实现的桶数序列写成标准保证。
答到这些关键点 区分元素预算与桶数下限 顺序设负载因子再预留 记录实际桶数并处理迭代器 ☆ 收藏 边界反例 Q4 把 max_load_factor 降得很低,能否保证所有查找都接近常数时间?参考答案 不能。若大量不同键被哈希到相同结果,增加桶数也无法把它们分开,某些查找仍需检查大量候选,最坏复杂度不会因为低负载因子而消失。过低因子还会放大桶数组内存并损害缓存局部性。应检查哈希质量与真实键分布,统计最大桶长度而非只看全局平均负载;外部输入可被攻击者控制时,还需资源限制或合适的抗碰撞策略。验证既用均匀随机键,也用相似长键、刻意碰撞和高失败率查询,比较尾延迟与内存。不能仅凭平均值较小宣称最坏请求时间已经有界。
答到这些关键点 相同哈希值不会因加桶分开 测最大桶与恶意分布 明确最坏复杂度与内存代价 ☆ 收藏 工程场景 Q5 在线索引批量导入时出现长暂停,怎样降低 rehash 对业务延迟的影响?参考答案 先通过 bucket_count 变化与分配日志确认暂停对应重哈希,并记录导入规模及最大桶长度。若可预估总量,在导入前设置负载因子并 reserve,避免多轮增长;若查询不能承受构建暂停,可在独立索引中完成批量构建与校验,再按同步协议切换完整快照,旧快照等读者结束后回收。方案会增加峰值内存,不能忽略两份节点及桶同时存活的成本。测试导入超出估计、失败回滚、读者持有旧版和碰撞输入,测 p99、切换时间与峰值。reserve 只减少结构增长,不允许在同一标准容器上无同步并发插入和查找。
答到这些关键点 证据关联rehash暂停 比较提前预留与双索引切换 评估峰值和并发同步 ☆ 收藏
动手验证 统计插入过程中 bucket_count 变化并定位重建点。
+ 我的笔记 用自己的话解释这个知识点
S3-07 / REVIEW NOTES ○ 标为掌握
排序、二分、堆与适配器 sort 一般不保证等价元素原相对顺序,stable_sort 保持稳定性。lower_bound 找首个不小于目标的位置,upper_bound 找首个大于目标的位置;前提是范围对比较关系满足分区要求,通常先排序。
priority_queue 默认常作为大顶堆,top 取极值,push/pop 调整堆;它不提供任意删除的标准接口。stack 和 queue 是限制访问方式的适配器。要根据所需操作选结构,而不是看到“查找”就一律使用哈希表。
代码片段 / 原理示意
std::vector<int> a{1,2,2,4};
auto l=std::lower_bound(a.begin(),a.end(),2);
auto r=std::upper_bound(a.begin(),a.end(),2);
// r-l == 2
DEEP DIVE / 从结论到推演 机制推演 lower_bound 的核心前提是相对当前查询键形成真值在前、假值在后的分区,不必要求所有元素全局有序;排序是能对许多查询键重复满足前提的常见方式。比较次数对数不代表总耗时对数,链表迭代器推进仍可能线性。对 map 和 set 应优先用成员 lower_bound 利用树结构,而不是把其迭代器交给通用二分算法。
边界与取舍 priority_queue 的比较器与堆顶方向需要结合“谁的优先级最高”推演,使用 greater 通常得到最小堆。适配器不支持任意删除和修改优先级;外部修改比较器依赖的数据会让现有堆失序。可采用带版本的惰性删除,或使用支持索引更新的自建堆,但惰性方案会积累过期条目,必须限制内存并安排重建。
进阶依据:C++20 草案 N4861:二分查找与分区前提 · C++20 草案 N4861:priority_queue
面试自测 先说结论,再补条件与反例 基础复习 Q1 lower_bound 返回 end 表示什么?参考答案 说明没有满足“不小于目标”的元素,不能解引用。即使不等于 end,也应检查元素是否等于目标,才能把边界查找结果当作精确匹配。
☆ 收藏 基础复习 Q2 找海量数据前 K 大怎么做?参考答案 K 远小于 N 时可维护大小 K 的小顶堆,逐项比较并替换堆顶,时间 O(N log K)、额外空间 O(K)。若数据可装内存也可考虑 nth_element,并明确是否需要最终排序。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 std::lower_bound 用在 list 上有对数次比较,为什么查询仍可能是线性耗时?参考答案 算法用二分策略减少比较次数,但 list 的双向迭代器不能常数时间跳到中点,计算距离和推进位置需要逐节点行走,累计步数仍可能是线性。若数据本来在 map 或 set 中,成员 lower_bound 可直接沿树下降,避免通用算法忽略内部索引。若需要大量静态查询,排序 vector 的随机访问与缓存局部性通常更合适。验证时分别计数比较器调用和迭代器推进,再测不同规模下的实际时间;比较函数特别昂贵时,减少比较仍可能有价值,所以不能把线性推进简单等同于所有场景都无用。
答到这些关键点 分离比较数与推进步数 成员查找利用容器内部结构 结合比较成本评估而非机械判断 ☆ 收藏 边界反例 Q4 对未整体排序但按当前查询值分区的范围,lower_bound 一定非法吗?参考答案 不一定。对适用的 lower_bound 调用,只要范围关于 comp(element, value) 正确分区,即所有为真的元素在前、为假的元素在后,就满足该项核心前提;全局排序是充分但并非必要条件。例如查找五时,三、一、四、九、七仍按是否小于五分区,可找到第一个不小于五的位置。换一个查询值却可能不再分区,所以不能把一次合法调用推广到任意查询。还要使用满足相应接口要求的比较器和有效范围。验证可先检查当前谓词的分区性质,并对多个查询值做回归;生产中若要支持任意查询,维护完整有序性更容易建立契约。
答到这些关键点 给出仅分区仍合法的具体例子 说明合法性依赖当前查询值 区分充分条件与必要前提 ☆ 收藏 工程场景 Q5 超时任务可取消或延后,使用 priority_queue 怎样避免旧任务被错误执行?参考答案 堆条目保存到期时间、任务编号和版本,任务状态表保存当前有效版本。取消或延后时更新状态,并在需要时插入新版本;取堆顶时先核对版本与有效状态,丢弃过期条目后才执行。这样不必在标准 priority_queue 中任意删除,但旧条目会占内存,需在比例或容量达到阈值时重建。到期时间使用适合间隔判断的单调时钟,并由同一同步协议保护状态表与堆。测试重复延期、取消后编号复用、同一时刻大量任务及慢消费者,测堆增长、清理暂停和尾延迟;若更新频率很高,可改用带索引的堆以换取更复杂的维护逻辑。
答到这些关键点 版本校验阻止过期任务执行 控制惰性删除内存与重建暂停 同步状态表和堆并测更新负载 ☆ 收藏
动手验证 写 top-K,测试 K=0、K>N、重复值和负数。
+ 我的笔记 用自己的话解释这个知识点
从理解到实践 阶段项目与验收 实现一个 LRU 缓存:list 保存顺序,unordered_map 保存定位;读写命中移到表头,超容量淘汰尾部。测试容量为零、重复键、rehash 和迭代器维护。
SYSTEM INTERVIEW / 跨知识点综合题 从单线程 LRU 到高并发缓存 实现容量最多为 K 的 LRU 缓存,get 命中要更新最近使用顺序;value 可能较大,构造可能抛异常。随后面试官要求支持多个工作线程,并解释为何平均 O(1) 的实现仍会出现 P99 延迟尖峰。
展开方案与推理 单线程基线用 list 保存键值与访问顺序,用 unordered_map 从键定位 list 节点;命中后通过同一 list 的 splice 移到表头,尾部是淘汰候选。维护两个结构的一致性:索引中每条记录恰好对应一个节点,节点键在索引中可找到,提交后元素数不超过容量。K=0 直接不缓存。哈希平均常数不是最坏常数,rehash、碰撞、大键哈希和动态分配都可能制造尖峰。
插入新值先在临时状态完成易失败的构造,再按顺序建立节点与索引;若索引插入失败,回滚刚创建的节点。若需要强异常保证,不能先淘汰旧数据再做可能失败的分配,否则失败后旧值已丢失。容量提交阶段的哈希、比较、删除与析构是否会失败也要明确,必要时收紧键和删除器契约。预留桶容量可减少 rehash,但不消除节点分配,也不改变并发规则。
并发基线先用一把 mutex 保护 list 与索引共同不变量,因为 get 命中会改访问顺序,不能一概视为只读操作。返回引用出锁后可能被淘汰,应返回副本或可延长 value 寿命的不可变拥有者。测得争用后可分片或批量维护访问顺序;分片通常使全局精确 LRU 变为各片近似 LRU,热点片还会负载不均。应把命中率、内存和尾延迟一起比较,而非只减少锁数量。
验收证据 每次操作后检查 list/index 双向一致性与容量不变量。 失败注入后旧数据和拥有权符合声明的异常保证。 线程安全返回值不悬空;内存统计覆盖被淘汰后仍在使用的值。 继续追问 追问:为什么缓存保存 map 迭代器会在扩容后出错? unordered_map 的 rehash 使其迭代器失效,若另一个结构保留这种迭代器就会产生隐患。让 map 保存指向 list 节点的 list 迭代器,可以在 rehash 后仍定位 list;节点被淘汰时则必须删除对应索引。不要反过来以“节点地址通常不变”推断所有容器迭代器都不失效,须逐项对照容器契约。
追问:返回 shared_ptr 后总内存还能限制为 K 条吗? K 限制的是缓存持有的条目数,不包含已经被淘汰但仍由调用者持有的 value。共享拥有权解决悬空,却可能延长大对象存活并突破内存预算。若真正要求字节数上限,需要统计缓存外持有、限制请求并发或采用借用期协议;大值按字节计费和拒绝策略往往比只数条目更符合容量目标。
追问:怎么判断延迟是锁、rehash 还是内存分配造成的? 将命中与未命中分开统计,采集锁等待/持有时长、分配次数与桶数量变化,按时间关联尖峰。使用相同键分布做预留容量、减少大值复制、分片等单变量实验,比较 P99 和命中率。测试要包括热点、长键、冷启动和碰撞输入;平均吞吐提升而尾延迟变差不能直接算优化成功。
参考依据:list::splice 规则 · 无序容器规则
CODE REVIEW / 动手审查 边遍历 vector 边删除,哪些句柄失效? 对连续负数、全部为负数以及空 vector,分别手工推演这段循环。
C++17 · 容器操作片段 · 先自行判断,再展开核对。
for (auto it = values.begin(); it != values.end(); ++it) {
if (*it < 0) values.erase(it);
// erase 之后仍 ++it,会使用已失效迭代器。
}查看问题、修复与验证 vector::erase 使被删除位置及之后的迭代器和引用失效,旧 end 也不能继续当作稳定边界使用。不能从“内存容量没变化”推断迭代器有效。
用 erase 返回的新迭代器继续,不删除时才递增,避免遗漏连续待删除元素。循环修复正确性后,重复搬移仍可能造成二次复杂度。
批量删除可用 remove_if 加 erase,把保留元素集中移动,再删除尾部;remove_if 本身不缩短 size。若需要保留外部引用,必须另设计句柄或容器,不能靠这个惯用法保证引用稳定。
修正思路 for (auto it = values.begin(); it != values.end();) {
if (*it < 0) it = values.erase(it);
else ++it;
}
// 大量删除时可改为:
values.erase(std::remove_if(values.begin(), values.end(),
[](int x) { return x < 0; }), values.end());怎样验证 使用空、全删、连续删除、无删除四组输入,对比保留顺序与结果数量;用大数组计数元素移动次数,对比两种算法的规模增长。
这里提供审查练习与验证步骤,不代表已在本机执行 C++、数据库或 GPU 实验。
依据:vector 修改与失效规则 · remove 算法
本章参考资料与适用版本 正文为原创整理;链接用于进一步核对,不影响离线阅读。C++ 工作草案会持续更新,本文采用已标注的 C++17/20 功能范围。
STAGE 04 / 八阶段学习路线 内存管理 把“有地址”“有对象”“有所有权”分开。默认用 RAII 管理资源,低层分配只在明确需要时使用。
← 回到路线图 抽题练习本章 本章目录 · 7 个知识点
S4-01 / REVIEW NOTES ○ 标为掌握
存储区域、堆栈与内存布局 语言层面优先区分自动、静态、线程和动态存储期。栈、堆、代码段、只读段、数据段是操作系统、编译器与运行时常见组织方式,不是 C++ 对所有实现强制规定的五个物理区域。自动对象也可能被放在寄存器或优化消除。
典型线程栈适合嵌套调用,容量受运行环境限制;动态分配支持跨作用域寿命但有分配器元数据、同步和碎片成本。32 位地址空间的表达范围不等于可分配堆上限,还要减去映射、栈、内核划分并考虑地址碎片。
代码片段 / 原理示意
进程虚拟地址空间(示意)
代码/只读映射 | 全局数据 | 动态分配与 mmap | 线程栈
布局、增长方向和边界由平台决定。
DEEP DIVE / 从结论到推演 机制推演 把一次分配拆成四层:C++ 对象的存储期、分配器管理的块、进程虚拟地址映射、实际驻留的物理页。构造一个大 vector 可能先取得虚拟空间,逐页写入时才产生更多缺页和驻留页;销毁元素、归还分配器、解除映射又是不同动作。因此看见地址、容量或 RSS 的某一个数值,都不足以回答对象是否活着及内存是否泄漏。
边界与取舍 每线程缓存能减少分配锁竞争,却可能让多线程服务在峰值过后保留更多空闲内存;集中回收能降低常驻量,也可能增加后续分配和缺页开销。短命大对象放到自动存储期并不保证安全,递归深度和线程栈额度仍可能使程序失败。应按对象寿命、峰值并发及延迟目标选择存储策略,而不是给堆和栈作统一速度排名。
进阶依据:Linux malloc(3):分配器与过量承诺 · Linux proc_pid_smaps(5):映射与驻留统计 · C++ 工作草案:存储期
面试自测 先说结论,再补条件与反例 基础复习 Q1 栈上对象为什么通常管理方便?参考答案 自动对象跟随作用域,正常退出和异常栈展开时按规则析构,所有权路径清晰。但“大对象放栈上”“递归无限深”会触及栈容量,异步任务也可能超出局部对象寿命。
☆ 收藏 基础复习 Q2 为什么释放内存后 RSS 未立即下降?参考答案 分配器可能保留空闲块复用,页中仍有活跃对象或存在碎片,操作系统统计也按页和映射计算。应区分逻辑已释放、分配器保留与真正内存泄漏。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 一批大对象已经析构,为什么进程 RSS 几乎不变?如何区分泄漏与保留?参考答案 RSS 不变不能直接判定泄漏。首先核对业务活对象数和分配调用栈:对象析构可能只释放成员,容量较大的缓存或其他拥有者仍然存在。其次检查分配器是否把空闲块留在 arena,或者同一页还夹着少量活块,因而无法整页返还。最后用 Linux smaps 区分匿名驻留页与文件映射,并对比重复相同负载后的稳定平台。活对象持续增长指向所有权问题,活对象归零而驻留量稳定更像保留;回收接口实验只能作为证据,不能替代长期负载验证。
答到这些关键点 区分对象析构和页归还 结合活对象统计与 smaps 重复负载观察平台或增长 ☆ 收藏 边界反例 Q4 既然按需分配物理页,malloc 成功是否能保证之后写满缓冲区不会失败?参考答案 在采用内存过量承诺的 Linux 配置中,成功只说明此次分配请求通过,不等于全部物理页已经落实。随后首次写页可能触发缺页和物理内存分配,在系统或容器额度紧张时仍可能遭遇 OOM。还要区分虚拟地址不足、进程资源限制和系统内存不足,三者表现不同。关键服务应限制在途请求和缓冲总量,观察驻留量、缺页以及容器内存事件,并在受控环境测试峰值。预触页可提前暴露部分压力,但不能永久保证未来所有内存资源可用。
答到这些关键点 过量承诺是平台策略 分配成功不等于物理预留 背压与容器额度验证 ☆ 收藏 工程场景 Q5 64 个工作线程处理 1 MB 请求缓冲,吞吐稳定但峰值后内存迟迟不降,如何改造?参考答案 先把内存按执行中缓冲、排队请求、线程缓存和分配器保留分账,确认是哪部分随线程数放大。若每线程长期留一块最大缓冲,可改成容量分级且有总预算的缓冲池,空闲超时后回收大块;若排队请求已携带完整正文,应在接收入口施加字节预算。测试必须同时记录峰值 RSS、分配次数、缺页和尾延迟,因为更积极归还可能恶化下一轮突发。按真实请求尺寸分布比较方案,并保留大请求拒绝或限流路径,不能单靠减少线程掩盖无界内存增长。
答到这些关键点 按所有权来源分账 以字节预算限制在途量 同时测内存与尾延迟 ☆ 收藏
动手验证 观察进程映射与大分配前后 RSS,记录平台和分配器。
+ 我的笔记 用自己的话解释这个知识点
S4-02 / REVIEW NOTES ○ 标为掌握
new/delete 与 malloc/free new 表达式通常取得存储并构造对象,delete 通常先析构再释放匹配存储;new[]/delete[] 成对。malloc/free 提供原始存储管理,不自动执行 C++ 非平凡构造与析构。分配失败时普通 new 通常抛 bad_alloc,malloc 返回空指针。
operator new 是分配函数,和 new 表达式不同。自定义分配、对齐分配和对象生命周期规则需要分别处理。工程中优先 make_unique 和容器,减少手写成对释放;跨库边界必须约定由哪个运行库释放。
代码片段 / 原理示意
auto p=std::make_unique<std::string>("hello");
// 自动销毁 string 并释放匹配存储,无需手写 delete
DEEP DIVE / 从结论到推演 机制推演 new T 是分配与初始化组成的表达式,operator new 只是其中取得存储的函数。若存储已取得而 T 的构造抛出,new 表达式会按匹配规则寻找释放函数;整体 T 的析构不会执行,但已构造完成的成员会清理。若构造函数先把裸资源存进普通指针再抛出,释放对象外壳的存储并不能自动回收该资源,所以成员也应表达所有权。
边界与取舍 C++17 的过度对齐 new 支持与普通 malloc 的保证不能混用;释放时还需匹配分配接口和对齐形式。C++20 对部分原始存储操作引入隐式创建适用对象的规则,也没有让 malloc 自动调用 string 等非平凡类型的构造函数。教材中的通用安全路线仍是明确构造、明确析构、匹配归还;跨动态库还应由约定的模块完成释放。
进阶依据:C++ 工作草案:new 表达式与构造失败 · C++ 工作草案:delete 表达式 · C++ 工作草案:unique_ptr 删除器
面试自测 先说结论,再补条件与反例 基础复习 Q1 new 与 malloc 最大差异是什么?参考答案 不仅是返回类型。new 表达式把存储分配和对象初始化组合,并具相应失败处理;malloc 只申请字节,不能当成自动完成非平凡 C++ 对象构造。释放阶段也要遵守匹配规则。
☆ 收藏 基础复习 Q2 delete nullptr 安全吗?参考答案 合法,通常没有释放工作。但重复 delete 一个非空悬空指针不合法;将某个指针设空也不会修复其他仍指向原对象的别名。根本办法是建立唯一清晰的所有权。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 执行 new Widget 时第二个成员构造抛出,哪些析构和释放会发生?参考答案 先区分存储分配成功和 Widget 构造成功。第二个成员失败时,已经完成构造的基类与第一个成员按逆序析构,尚未完成的成员及整体 Widget 不会执行析构函数。随后 new 表达式按规则调用匹配的释放函数归还刚取得的对象存储。若第一个成员是 unique_ptr,它拥有的资源随成员析构归还;若只是裸指针,语言不会替它猜测所有权。可用独立计数器记录每个成员的构造、析构及分配释放次数,并在每个构造阶段注入异常,检验恰好一次清理。
答到这些关键点 整体对象未构造完成 已完成子对象逆序清理 存储释放不等于裸资源释放 ☆ 收藏 边界反例 Q4 把 new Widget 改成 new(std::nothrow) Widget,就能保证调用方无需处理异常吗?参考答案 不能。标准 nothrow 分配形式处理的是取得存储失败,通常以空指针报告;若存储成功后 Widget 的构造函数抛出业务异常,该异常仍会传播,已取得的存储则按匹配规则清理。调用方必须分别处理空结果和构造失败,或在统一工厂边界把它们转换成明确错误。还要注意类自定义分配函数会影响实际选择,不能只看调用处的拼写。验证时分别模拟分配失败和成员构造失败,确认没有泄漏,并确保工厂的 noexcept 承诺只在所有异常已被妥善转换后成立。
答到这些关键点 nothrow 针对分配失败 构造异常仍能传播 分开注入两类故障 ☆ 收藏 工程场景 Q5 C 库返回需专用 destroy 释放的句柄,C++ 服务怎样封装并避免跨库误释放?参考答案 用唯一拥有者保存句柄,并把 C 库的 destroy 作为删除器;若句柄不是指针,可写只可移动的专用 RAII 类型。构造工厂先检查 C 接口的失败表示,成功后立即交给拥有者,后续操作只借用句柄。移动必须使源对象进入明确的空状态,析构只对有效资源调用一次 destroy,不能换成 delete 或 free。还应确认库的线程归属及关闭错误约定,必要时提供显式 close 返回错误。用成功、创建失败、移动、提前返回和中途异常测试释放次数,并保持库模块寿命长于删除器调用。
答到这些关键点 删除器匹配 C 库接口 模块和句柄寿命约束 移动后空状态与故障测试 ☆ 收藏
动手验证 用自定义析构日志比较 RAII 与手写异常路径。
+ 我的笔记 用自己的话解释这个知识点
S4-03 / REVIEW NOTES ○ 标为掌握
placement new、对齐与对象寿命 placement new 在已有且足够大、对齐正确的存储中构造对象,通常不另行申请该存储。它常用于内存池、容器实现和设备共享区域。销毁对象与归还存储需要分开管理;调用析构并不自动释放那块外部存储。
对象可能有填充字节,alignof 描述对齐要求。把 char 缓冲区任意转成 T* 不自动满足对齐和寿命要求;序列化也不能默认原样复制所有对象字节就可跨平台恢复。现代代码可用 construct_at/destroy_at 表达构造销毁,但仍需正确的存储前提。
代码片段 / 原理示意
alignas(T) std::byte storage[sizeof(T)];
T* p = new (storage) T(/* 参数 */);
p->~T(); // 外部存储自行管理;示意需定义 T、包含 <new>/<cstddef>
DEEP DIVE / 从结论到推演 机制推演 对象池中的槽位先是一块满足大小与对齐要求的存储,成功 placement new 后才有可使用的目标对象。取出槽位、构造完成、交付调用者、析构、放回空闲链应是明确状态转换;构造抛出时不能把半成品发布出去。复用同一地址不会保留上一代对象的身份,外部句柄若只保存地址,就可能把新对象误认为旧对象,需要寿命约束或代际校验。
边界与取舍 std::launder 是在满足前提时获得新对象指针的工具,不会创建对象、修复对齐或把任意字节变成合法类型。最直接的写法是使用 placement new 返回的新指针;旧指针是否自动指向新对象,要按透明可替换条件判断。C++17 已有 launder,C++20 的 construct_at 则提供更明确的构造接口;两者都不替调用者管理原始存储及跨线程回收。
进阶依据:C++ 工作草案:对象生命周期与透明替换 · C++ 工作草案:std::launder · C++ 工作草案:construct_at
面试自测 先说结论,再补条件与反例 基础复习 Q1 placement new 后为什么不能普通 delete?参考答案 普通 delete 期待匹配可释放的分配来源;这里对象位于外部存储,比如局部数组。应显式销毁对象,再由原存储所有者按其分配机制回收。
☆ 收藏 基础复习 Q2 为什么结构体 sizeof 大于成员大小之和?参考答案 实现为满足成员对齐和数组中连续元素对齐可能插入填充。实际结果依类型和 ABI;网络协议应显式编码字段,不能依赖某次编译的内存布局。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 用 alignas(T) 字节数组保存一个 T,完整的构造与销毁协议是什么?参考答案 首先保证存储大小至少为 sizeof(T)、地址满足 alignof(T),并确认槽位当前没有仍需保留的活对象。其次通过 placement new 构造,保存其返回指针,只有构造成功后才记录槽位已占用;异常路径只归还空槽,不能对未完成对象调用析构。最后在所有使用者退出后显式析构 T,再由原存储拥有者回收槽位,不能对局部字节数组执行普通 delete。若对象可被异步访问,还必须额外证明最后使用早于析构。可给每个槽记录状态和代数,测试重复释放、构造失败及复用后的旧句柄访问。
答到这些关键点 大小对齐与空槽前提 构造成功后才发布 析构与归还存储分离 ☆ 收藏 边界反例 Q4 旧指针地址与新对象相同,调用 std::launder 后就一定能继续使用吗?参考答案 不一定。launder 要求目标地址已经存在处于合适寿命中的相应类型对象,并满足可达字节限制,它不会弥补尚未构造、类型错误或存储越界。对可透明替换的普通同类型对象,旧指针可能已按规则指向新对象,无需机械添加 launder;基类子对象等情况必须另行分析。更重要的是,即使取到了新对象的合法指针,业务上旧请求也不一定有权访问这一代对象。对象池应结合新构造返回值和代际句柄管理身份,并用专门测试区分语言层访问合法与业务层对象认错。
答到这些关键点 launder 不启动对象寿命 透明替换具有条件 地址合法不等于对象身份正确 ☆ 收藏 工程场景 Q5 低延迟对象池复用连接状态,偶尔出现旧请求修改新连接,怎样定位和修复?参考答案 先为每次分配记录槽位编号、递增代数及连接标识,串联请求提交、回调执行和归还日志,确认是否发生同址复用。修复不能只延后几毫秒释放,应让异步任务持有有效所有权,或在统一管理器中通过槽位与代数验证后取得受保护的对象。若回调读完指针后对象还能被回收,单次代数检查仍有窗口,需要锁或成熟的延迟回收协议。测量池命中率、待回收数量和尾延迟,评估安全持有造成的内存上升,并测试取消、超时、快速断连重连及代数边界。
答到这些关键点 同址复用的时间线证据 校验后仍需寿命保护 延迟回收的内存代价 ☆ 收藏
动手验证 对不同排列的成员测 sizeof/alignof,注明平台。
+ 我的笔记 用自己的话解释这个知识点
S4-04 / REVIEW NOTES ○ 标为掌握
泄漏、悬空、越界与排查 泄漏是仍需回收的资源失去可用所有者;悬空是指针或引用指向生命周期已结束的对象;野指针常指未初始化或无有效来源的指针;越界是访问对象或合法范围之外。它们可互相诱发,但修复手段不同。
先最小化复现并保留触发输入,再启用警告、地址/未定义行为检测工具定位第一处非法操作。多线程问题另用数据竞争检测;测试构建可开启检查而发布性能需独立测量。内存增长还要区分缓存、分配器保留与真实泄漏。
代码片段 / 原理示意
// GCC/Clang 示例命令,按本机工具链调整
// c++ -std=c++17 -g -fsanitize=address,undefined main.cpp -o app
DEEP DIVE / 从结论到推演 机制推演 内存错误常在破坏发生很久以后才暴露:越界写先损坏邻接对象,稍后的析构才崩溃。排查要寻找第一次非法访问,而不是只修最后一个异常栈。ASan 通过插桩与影子信息发现多类越界、释放后使用;UBSan 检查部分未定义行为;TSan 侧重数据竞争。它们覆盖的问题与运行成本不同,应建立分开的测试配置,而不是把所有失败统称为野指针。
边界与取舍 检测器没有报告只说明本轮执行未发现其覆盖范围内的问题,不能证明所有调度与输入安全。自定义对象池若不把释放告知检测器,同址重用可能削弱释放后访问的检测效果;逻辑上仍可达的无界缓存也未必被归为泄漏。排查要同时保存分配栈、对象代数和业务活跃量,并用最小复现、负载趋势及所有权审查互相验证。
进阶依据:Clang AddressSanitizer · Clang UndefinedBehaviorSanitizer · Clang ThreadSanitizer
面试自测 先说结论,再补条件与反例 基础复习 Q1 use-after-free 应如何排查?参考答案 获取分配、释放和非法访问的调用栈,追踪所有别名的寿命。优先修复所有权或异步任务退出协议,而不是只把出错行包 try/catch;这类未定义行为通常不能靠 C++ 异常捕获。
☆ 收藏 基础复习 Q2 内存池一定消除碎片吗?参考答案 不能。固定大小池可降低一类外部碎片与分配成本,但可能浪费块内空间、长期持有大块或形成跨尺寸空闲。应测业务分配分布和峰值,而非假设池越复杂越好。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 线上在 vector 析构中崩溃,为什么不能直接判断标准库有 bug?参考答案 析构只可能是首次使用已损坏元数据的位置,真正原因可能是早先越界写、重复释放、并发结构修改或 ABI 混用。先保留原始崩溃栈、编译选项和对象地址,判断指针及容量是否异常;随后在相同输入下启用 ASan 与 UBSan,追溯第一次非法写及其分配来源。若只在并发负载触发,再使用独立的 TSan 配置和最小调度场景。只有排除调用者违反容器寿命、线程安全及二进制契约后,才进一步缩减为库实现问题,修复也必须落在首个破坏不变量的位置。
答到这些关键点 崩溃点不等于破坏点 容器元数据与来源证据 分别验证内存和并发错误 ☆ 收藏 边界反例 Q4 ASan 压测通过,但对象池仍有偶发错对象访问,为什么?参考答案 ASan 主要识别被标记为不可访问的存储访问,未必知道业务对象已换代。若池一直保留整块内存,旧指针访问到重新构造的同类型对象时,地址可能仍合法,错误体现在身份而非越界。应在调试池中加入代数、占用状态与释放填充,并视工具支持标注槽位可访问区间;同时延迟复用可帮助复现,但不能作为最终正确性保证。通过日志匹配请求代数与槽位代数,再检查取消和回收协议。最终修复必须消除过期别名,而不是仅让检测器暂时看不到报错。
答到这些关键点 区分地址错误和身份错误 自定义分配器检测边界 代际日志与别名清理 ☆ 收藏 工程场景 Q5 服务每小时增加 200 MB 内存,重启恢复,应怎样安排一次有结论的诊断?参考答案 先固定请求速率和数据规模,采集活对象、缓存键数、队列长度与分配堆快照,避免只观察 RSS 曲线。比较两个稳定时刻之间增长最多的分配栈,追踪这些对象是否仍被容器、回调或共享所有者持有;可达但不再有业务价值的缓存同样需要容量或过期策略。接着用小规模固定输入反复执行与清理,验证增长是否随轮数线性累积。若活分配已稳定而 RSS 增长,则继续检查线程数量、映射和分配器保留。修复后用同样负载比较增长斜率、命中率及尾延迟,防止删缓存造成性能退化。
答到这些关键点 堆快照差分定位增长栈 可达滞留也会耗尽内存 同负载复测增长斜率 ☆ 收藏
动手验证 在独立测试程序里制造越界,用工具定位后修复。
+ 我的笔记 用自己的话解释这个知识点
S4-05 / REVIEW NOTES ○ 标为掌握
RAII 与异常安全 RAII 将资源获取与对象初始化绑定,将资源释放放入析构。资源可以是堆内存、文件、锁、连接或临时目录。拥有者退出作用域时执行清理,使正常返回、提前返回和异常路径共享同一套释放机制。
异常安全可分为无泄漏且不变量有效的基本保证、失败如同未执行的强保证,以及不抛异常保证。强保证常通过“先准备临时状态,成功后提交”实现,但可能需要额外资源。析构应尽量不抛,业务提交错误应由显式接口报告。
代码片段 / 原理示意
{ std::lock_guard<std::mutex> guard(m);
update_shared_state();
} // 所有正常作用域退出路径释放锁
DEEP DIVE / 从结论到推演 机制推演 RAII 的关键是从取得资源到拥有者建立之间不留下可能失败的裸露窗口。构造多个资源时,让每一项立即进入成员拥有者,后续步骤抛出即可自动逆序清理。强异常保证则多一步:在临时状态完成可能失败的准备,最后用已证明不抛出的操作替换旧状态。资源没有泄漏只满足部分正确性,业务不变量也必须在失败后成立。
边界与取舍 文件落盘、数据库提交和网络发送具有对象析构无法撤销的外部效果。把 flush 塞进析构并吞掉错误,会让调用方误以为提交成功;更清楚的接口是显式 commit 报告失败,析构负责不抛出的兜底释放。若强保证要求复制巨大状态,应比较内存峰值、提交延迟与恢复成本,可以公开提供基本保证,不能假装所有失败都等同于未执行。
进阶依据:C++ 工作草案:异常中的构造与析构 · C++ Core Guidelines:资源管理 · Linux close(2):失败与重试风险
面试自测 先说结论,再补条件与反例 基础复习 Q1 RAII 为什么比手动释放稳健?参考答案 每个资源交给一个生命周期明确的拥有者,异常和多返回路径无需重复写清理。正确性从“记住每条路径调用 free”变为“保证拥有者正确建立且不被错误复制”。
☆ 收藏 基础复习 Q2 强异常保证如何实现?参考答案 先在临时对象完成可能失败的计算与分配,成功后用不抛操作交换或提交。若原对象已被部分修改,必须有可靠回滚;无法做到时应明示基本保证而不虚假承诺。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 一个配置更新涉及解析、索引构建和替换,怎样提供强异常保证?参考答案 先在独立临时对象中完成解析、合法性检查和索引构建,让任何失败只销毁临时状态,原配置保持可用。提交阶段只能执行已经证明不抛出的替换,例如交换满足条件的拥有指针;若交换依赖分配器或自定义类型,必须核实其异常契约。并发读取还需要锁或原子快照发布,异常保证本身不建立同步。验证时在解析、每次分配和索引扩展位置注入失败,比较更新前后的配置版本、查询结果与资源计数。大配置可选择结构共享降低准备成本,但不能修改仍被旧读者使用的节点。
答到这些关键点 临时构建与单点提交 提交操作的不抛出前提 异常保证与同步分别验证 ☆ 收藏 边界反例 Q4 析构函数捕获所有异常后继续,是否就能保证文件写入正确?参考答案 不能。捕获异常只能避免异常越过析构边界,不能让已经失败的写入、同步或关闭重新成功。若调用者必须知道数据是否提交,应显式调用可报告错误的 finish 或 commit,并把成功状态作为业务结果;析构仅做释放和有限诊断。还需区分写入用户缓冲、写入内核和持久化确认,具体保证取决于文件系统及同步协议。测试可模拟磁盘满、短写和关闭失败,核对错误是否传回调用方。若提交结果不确定,重试还需要幂等标识或恢复日志,不能盲目重新执行外部副作用。
答到这些关键点 析构安全不等于业务成功 显式提交报告外部错误 不确定结果需要幂等恢复 ☆ 收藏 工程场景 Q5 RAII 文件描述符类在 Linux close 返回 EINTR 时应不断重试吗?参考答案 不应在 Linux 上无条件重试同一整数描述符。Linux 的关闭路径通常已释放描述符编号,后续报错时编号可能被另一线程复用,再 close 会误关新资源。拥有者应先转为空状态,按平台契约执行一次关闭并保存诊断;需要可靠报告写入错误时提供显式关闭或同步接口,析构采用不抛出的兜底策略。跨平台实现必须单独核实 EINTR 语义,不能把 Linux 行为扩成所有系统。测试应覆盖移动后析构、重复 close、并发打开导致的编号复用,以及显式关闭失败后的对象状态。
答到这些关键点 Linux close 错误后编号可能已复用 关闭前转移所有权状态 平台语义与错误报告分离 ☆ 收藏
动手验证 为一个替换配置操作设计先解析后交换的提交流程。
+ 我的笔记 用自己的话解释这个知识点
S4-06 / REVIEW NOTES ○ 标为掌握
unique_ptr、shared_ptr 与 weak_ptr unique_ptr 表达独占所有权,可移动而不可复制,适合作为默认动态资源拥有者。shared_ptr 表达共享所有权,控制块记录强弱引用等状态,最后一个强拥有者释放对象;weak_ptr 不增加强引用,需 lock 获得暂时强拥有权再使用。
两个对象互持 shared_ptr 会形成环,使强计数无法归零。应让所有权方向形成合理结构,用 weak_ptr 表达观察或回边。make_shared 常把对象与控制块合并分配,但对象销毁与控制块存储释放时机不同,长期 weak_ptr 可能延长合并存储保留。
代码片段 / 原理示意
auto owner=std::make_shared<Item>();
std::weak_ptr<Item> watch=owner;
if(auto keep=watch.lock()) { /* keep 保证本段使用期间对象存在 */ }
DEEP DIVE / 从结论到推演 机制推演 shared_ptr 可分别拥有一个对象并存储另一个用于访问的指针,例如别名构造让指针指向拥有对象的成员,删除时仍释放原拥有对象。最后一个强拥有者消失触发对象的释放动作;弱引用还存在时,控制块通常继续存在。make_shared 常见的合并分配会让对象已析构但整块分配尚未回收,因此分析内存必须区分对象寿命、控制块寿命与分配块寿命。
边界与取舍 shared_ptr<Base>(new Derived) 的构造能记住由 Derived 指针形成的删除动作,先把结果存成 Base* 再构造则丢失了这个条件;不能据此取消一般多态基类的虚析构设计。自定义删除器还可把释放转交特定线程,但要保证投递队列及目标线程活得更久。make_shared 不接收任意自定义删除器,需要特殊释放协议时应选择合适的构造方式。
进阶依据:C++ 工作草案:shared_ptr 构造与删除器 · C++ 工作草案:shared_ptr 析构 · C++ 工作草案:make_shared 与 allocate_shared
面试自测 先说结论,再补条件与反例 基础复习 Q1 什么时候应该用 shared_ptr?参考答案 存在真实且难以静态确定的多个拥有者时使用,例如异步任务共享资源寿命。仅为省事到处使用会隐藏生命周期和增加计数成本;可以独占时优先 unique_ptr,借用时用引用或非拥有指针。
☆ 收藏 基础复习 Q2 weak_ptr 怎样解决循环引用?参考答案 把不应拥有对方的边改成 weak_ptr,使它不贡献强引用计数。最后外部强拥有者离开后,对象能析构;访问观察对象前用 lock 检查并暂时取得所有权。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 Base 没有虚析构,shared_ptr<Base>(new Derived) 与从 Base* 构造为何不同?参考答案 直接传入 new Derived 时,构造模板按 Derived* 推导来源类型,建立的释放动作以该类型执行 delete,所以不能只看 shared_ptr 的模板参数判断删除类型。若先保存为 Base* 再构造,默认释放动作只能依据 Base*;在普通基类非虚析构的情形,经它删除实际 Derived 对象是未定义行为。该差异不意味着多态接口可以普遍省略虚析构,因为其他拥有方式仍可能经基类释放。可用析构日志验证安全路径,但危险路径即使一次日志正常也不能证明合法;接口应统一删除契约或显式指定正确删除器。
答到这些关键点 构造时来源指针类型 控制块保存释放动作 不把局部技巧当多态接口契约 ☆ 收藏 边界反例 Q4 只剩 weak_ptr 时,大对象已析构,为什么 make_shared 的内存还可能保留?参考答案 强计数归零结束对象的拥有寿命,弱引用仍需查询状态并尝试 lock,因此控制块还要存在。make_shared 常把对象和控制信息放在同一分配块;对象析构已经执行,但该分配块可能要等最后一个弱引用消失才整体归还。若对象内部拥有独立大缓冲,该缓冲通常会随析构释放;若大数组直接嵌在对象内,滞留更显著。应实测对象大小、弱引用保留期和峰值数量,再决定拆分分配或清理过期观察者。不能以 RSS 未下降推断析构没发生,也不能为了回收块而破坏观察者寿命协议。
答到这些关键点 强弱计数对应不同寿命 内嵌数据与独立缓冲差异 合并分配是常见实现策略 ☆ 收藏 工程场景 Q5 对象必须在事件循环线程销毁,但最后一个 shared_ptr 可能在工作线程释放,怎样设计?参考答案 把释放策略写进所有权协议:自定义删除器向仍存活的事件循环投递销毁任务,或保证最终拥有者始终由该循环管理。前一种方案必须解决投递失败、停机顺序和队列容量,删除器不能在无处投递时悄悄泄漏,也不能任意改在线程外析构。停机先停止新任务,再等待工作者释放引用并排空销毁队列,最后退出循环。用日志记录最后引用释放线程和实际析构线程,测试过载、异常及退出竞态。还应检查对象是否反向持有执行器形成环,必要时用非拥有引用并明确执行器寿命。
答到这些关键点 最后释放线程未必符合资源约束 销毁队列参与停机协议 投递失败及所有权环 ☆ 收藏
动手验证 画父子对象所有权图,标出哪些边是拥有、哪些是观察。
+ 我的笔记 用自己的话解释这个知识点
S4-07 / REVIEW NOTES ○ 标为掌握
智能指针的线程安全:分三层 共享同一控制块的不同 shared_ptr 实例可在不同线程独立复制或销毁,控制块计数协调不等于被指对象安全。同一个 shared_ptr 变量被多线程一边写一边读,需要锁或适用的 atomic<shared_ptr<T>>(C++20)。
第三层是 T 的业务状态,即使所有线程各持一个安全副本,修改 T 的字段仍可能数据竞争。use_count 只是瞬时观察,不能用“计数等于一”替代并发所有权协议。锁住指针也不自动锁住对象所有别名访问。
代码片段 / 原理示意
// C++20:发布指针本身可用 atomic<shared_ptr<T>>
// 但 T 若可变,其成员访问仍需独立同步策略。
DEEP DIVE / 从结论到推演 机制推演 只读配置快照可以先完整构造,再以 release 发布 shared_ptr,读者以 acquire 取得本地副本。发布同步让构造写入可见,本地强引用让旧版本在读取期间继续存活,这分别解决可见性与回收问题。C++20 可用 atomic<shared_ptr<const T>>;C++17 可用针对 shared_ptr 的原子自由函数或锁,并要求对同一共享句柄始终使用一致的同步协议。
边界与取舍 原子 shared_ptr 不保证无锁,其计数维护也可能造成热点;替换后旧对象的析构不是原子更新的一部分,释放成本仍会落在某个最后持有者身上。use_count 等于一只表示瞬间观察,无法阻止其他线程从 weak_ptr 获得拥有权,也不覆盖裸别名。因此读多写少优先考虑不可变快照;要求更低读成本时,才在测量后评估成熟的延迟回收方案。
进阶依据:C++ 工作草案:原子智能指针的计数与回收 · C++ 工作草案:atomic shared_ptr · WG21 P2545R4:RCU 安全延迟回收设计
面试自测 先说结论,再补条件与反例 基础复习 Q1 shared_ptr 是线程安全的吗?参考答案 要分层:不同实例共享控制块时的拥有权计数可协调;同一实例的冲突读写需同步;对象内部数据也需自己的并发保护。简单回答“是”或“不是”都不完整。
☆ 收藏 基础复习 Q2 为什么 expired 后再访问有竞态?参考答案 expired 只是检查时刻的状态,随后对象仍可能销毁。应直接 lock,并在返回的 shared_ptr 有效期间访问;检查与取得拥有权必须通过正确接口组合。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 热更新配置使用 atomic<shared_ptr<const Config>>,release/acquire 和引用计数各解决什么?参考答案 release/acquire 负责发布:读者取得匹配发布的版本后,能观察到发布前完成的配置初始化。取得本地 shared_ptr 则负责保活:写者替换全局指针后,旧配置仍要等持有旧版本的读者退出才可释放。这两条证明缺一不可,只有原子裸指针并不能阻止旧对象被删除。const 约束这条访问路径,写者也必须遵守发布后不再修改的规则。验证可并发交替发布带版本校验值的配置,检查读者一次操作只使用一个本地快照,并测量更新延迟、引用计数争用及最后释放时的析构开销。
答到这些关键点 发布同步与保活分开证明 单次操作固定一个快照 const 仍需所有别名遵约 ☆ 收藏 边界反例 Q4 读到 use_count()==1 后原地修改配置,为什么仍不能证明独占?参考答案 计数查询没有把观察和后续修改合成一次排他操作。另一个线程可以在查询之后通过 weak_ptr::lock 取得强引用,也可能已有未计入计数的借用指针;甚至旧拥有者的使用顺序也需要独立同步。因此数值一不是写锁,不能授权修改已发布对象。可行方案是始终创建新快照,或用覆盖全部访问路径的互斥协议取得写权限。测试应安排读计数、另一线程 lock、原地写三个步骤交错,展示协议窗口;工具未报告竞争也不构成证明,最终应从所有别名和所有权转换中排除并发访问。
答到这些关键点 观察与修改之间的竞态 弱转强及裸别名 不可变替换或真正排他协议 ☆ 收藏 工程场景 Q5 配置读取每秒百万次,原子 shared_ptr 出现热点,能直接换 atomic<Config*> 吗?参考答案 不能直接替换后立即 delete 旧对象,因为读者可能刚取得旧地址尚未解引用。先测热点是否来自原子句柄内部锁、引用计数缓存争用还是配置查询本身,再评估按请求持有一次快照、批量查询等较小改动。若仍需要裸指针读路径,应选成熟的 hazard pointer、epoch 或 RCU 类回收协议,并明确读者进入退出、退休对象及安全回收时机;它们不是 C++17/20 自带的通用容器方案。验证慢读者、线程退出和停机时退休对象的上界,同时比较吞吐、尾延迟和内存滞留,不能只比较单次 load。
答到这些关键点 原子裸指针不保护寿命 先定位计数或锁热点 回收协议及慢读者内存上界 ☆ 收藏
动手验证 实现只读快照发布,避免发布后再修改已共享对象。
+ 我的笔记 用自己的话解释这个知识点
从理解到实践 阶段项目与验收 实现一个只可移动的文件句柄类:构造获取资源、析构释放、移动转交所有权;用失败注入验证每条异常路径恰好释放一次。
SYSTEM INTERVIEW / 跨知识点综合题 服务 RSS 上涨,为什么不能直接判定泄漏? 一个容器部署的 C++ 服务,吞吐不变但 RSS 在高峰后长期较高,偶尔被 OOM kill。没有明确崩溃栈。给出定位步骤,并说明什么证据分别支持泄漏、缓存保留、碎片或请求积压。
展开方案与推理 先保存时间线:请求量、并发、队列长度、对象计数、RSS/PSS、虚拟映射以及容器内存事件。Linux 的 smaps 可按映射观察匿名/文件等占用与驻留信息;cgroup 的统计和限制则有自己的记账范围,不能只看单个进程 RSS 判断距离容器上限还差多少。确认是内核/容器 OOM、分配失败还是应用主动退出,再针对具体失败建立假设。
比较负载下降后的存活对象与分配器状态。活对象/可达资源持续随请求次数累加支持拥有权遗漏或无界缓存;活跃请求下降但队列或缓冲未减少支持背压缺失;逻辑对象释放后 RSS 不降可能来自分配器保留或碎片,需要堆采样与分配器指标区分。长期 weak_ptr 也可能使 make_shared 的合并存储保留,即使对象析构已发生。不要只在一个抓取时刻按总字节排序,更要看分配点的增长斜率。
按假设修复:真正泄漏修复拥有权,队列积压增加准入与取消,缓存制定字节预算和过期策略,碎片问题再考虑大小分组、分配器参数或隔离寿命。强制回收、换分配器或周期重启可以做受控实验,却不是解释所有问题的通用结论。验证应覆盖长时间稳定负载、突发后回落和极限并发,检查峰值是否有界、尾延迟是否受影响,以及失败时是否及时释放请求状态。
验收证据 形成负载—对象数—分配字节—驻留页—容器事件的时间关联。 稳定负载下资源收敛,突发后符合事先约定的保留预算。 修复没有靠隐藏统计或改变测试负载制造“下降”。 继续追问 追问:析构日志都打出来了,为何仍可能占大量内存? 析构说明某个对象生命周期结束,不等于其存储已经归还操作系统。控制块、池、分配器缓存以及同页其他活对象仍可能留住存储。还要检查日志是否只覆盖业务对象而未覆盖关联缓冲区和映射。把析构数与逻辑字节、分配器已用/保留量、页级驻留对应起来,才能找出哪一层没有回落。
追问:ASan 没报告泄漏能否排除问题? 不能。仍被容器或全局索引持有但业务上已经不需要的数据,可能是可达的逻辑泄漏;长时间请求积压也不是非法内存访问。检测器能捕捉其支持且已执行路径中的错误,但运行成本、环境与覆盖率会限制结论。应把工具报告和对象计数、队列上限、堆快照差分结合,而不是单看有没有告警。
追问:线上能否直接修改 allocator 参数观察? 先确认变更范围、可回退性和可用指标,在代表性测试或受控实例做单变量实验。某参数降低 RSS 可能增加系统调用、锁竞争或 P99 延迟,也可能不适用于实际分配器版本。明确预期:例如高峰后保留量下降多少、吞吐损失不能超过什么阈值;没有基线就难以判断是优化还是偶然负载变化。
参考依据:Linux smaps · cgroup v2 内存控制
CODE REVIEW / 动手审查 RAII 句柄为什么不能默认复制? 对象都有析构函数并不意味着所有权正确。推演复制后 fd 被关闭并复用的最坏交错。
C++17 / Linux · 文件描述符包装片段 · 先自行判断,再展开核对。
struct File {
int fd;
~File() { if (fd >= 0) ::close(fd); }
};
File a{open_file()};
File b = a; // 两个拥有者指向同一 fd。查看问题、修复与验证 默认复制只复制整数。第一个析构关闭 fd 后,该编号可能被新资源复用,第二个析构可能误关另一资源,而不只是返回一次错误。
独占包装应删除复制,移动时转交 fd 并把源置为无效值。若业务需要复制资源,应提供明确的 dup 接口,且说明复制出的描述符共享 open file description 的语义。
析构通常只能做不抛出的尽力清理;需要报告写入或关闭失败时,可另提供显式提交/关闭接口。Linux 上不能因 close 报错就无条件重试同一整数,编号可能已被释放并复用。
修正思路 class File {
int fd_ = -1;
public:
explicit File(int fd) noexcept : fd_(fd) {}
File(const File&) = delete;
File& operator=(const File&) = delete;
File(File&& other) noexcept
: fd_(std::exchange(other.fd_, -1)) {}
File& operator=(File&&) = delete; // 此最小示例不提供移动赋值。
~File() { if (fd_ >= 0) ::close(fd_); }
}; // 需要 <utility>、<unistd.h>;取得 fd 的失败也须由调用方处理。怎样验证 让复制在编译期失败,验证移动后源对象不会关闭句柄;注入打开失败、提前返回及异常路径,统计每个有效句柄恰好由其拥有者清理一次。
这里提供审查练习与验证步骤,不代表已在本机执行 C++、数据库或 GPU 实验。
依据:Linux close 与描述符复用 · Linux dup 语义
本章参考资料与适用版本 正文为原创整理;链接用于进一步核对,不影响离线阅读。C++ 工作草案会持续更新,本文采用已标注的 C++17/20 功能范围。
STAGE 05 / 八阶段学习路线 现代 C++ 以 C++17 作为主要示例基线,C++20 功能明确标注。理解值类别和寿命,再学习转发与协程。
← 回到路线图 抽题练习本章 本章目录 · 7 个知识点
S5-01 / REVIEW NOTES ○ 标为掌握
C++11/14/17/20 特性地图 C++11 引入自动类型推导、范围 for、lambda、移动语义、智能指针与标准线程库等;C++14 常用改进包括泛型 lambda 和 make_unique;C++17 增加结构化绑定、if constexpr、optional、variant、string_view 等。
C++20 的 concepts、ranges、span、jthread 和协程有助于表达约束、视图与异步流程。选特性时应检查实际工具链和标准库支持,优先改善接口和正确性。initializer_list 元素按 const 访问,花括号初始化还可能优先选择列表构造,与圆括号语义不同。
代码片段 / 原理示意
std::vector<int> a(3,7); // 3 个 7
std::vector<int> b{3,7}; // 两个元素:3 和 7
for (const auto& [k,v] : table) { /* C++17 */ }
DEEP DIVE / 从结论到推演 机制推演 语言版本、编译器前端和标准库是三个检查点:能识别某种语法,不代表配套头文件、链接支持和缺陷修正都齐全。升级时应把要使用的特性列成最小编译探针,结合功能测试宏与实际编译运行验证。概念约束改善接口诊断,span 与 string_view 表达借用,jthread 表达线程管理,这些能力解决的问题不同,不能以统一的“现代化”替代设计说明。
边界与取舍 花括号初始化既参与构造函数选择,又限制窄化,并不只是圆括号的美化写法。vector<int>{4,9} 是两个元素,而 vector<int>(4,9) 是四个九。initializer_list 的底层元素按 const 访问,因此即使每个初始表达式都是临时 unique_ptr,容器从列表元素构造时仍无法执行所需的独占移动;应采用逐项插入或合适的移动范围。
进阶依据:C++20 草案 N4861:列表初始化 · Clang C++ 特性支持状态 · C++ 工作草案:功能测试宏
面试自测 先说结论,再补条件与反例 基础复习 Q1 initializer_list 为什么可能阻碍移动?参考答案 其底层元素按 const 访问,普通移动构造通常需要非 const 右值引用,因而可能退回复制或不支持仅可移动类型。创建 move-only 容器常改用逐个 emplace 或移动范围等合适方式。
☆ 收藏 基础复习 Q2 optional 与用 -1 表示失败有何区别?参考答案 optional 在类型层明确“有值或无值”,不用占用一个有效业务值,也避免调用者忘记哨兵含义。但复杂错误原因需 error/result 设计,optional 本身不承载详细诊断。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 为什么 vector<unique_ptr<T>> 用花括号放两个 make_unique 往往无法编译?参考答案 make_unique 返回可移动对象本身没有问题,障碍发生在 initializer_list 这层中间表示。列表的底层元素以 const 元素提供给 vector,容器还要从这些元素构造自己的存储;普通 unique_ptr 移动构造需要可修改的源,而从 const 元素复制又被禁止。把列表整体 std::move 也不会移除元素的 const。可先 reserve 后逐次 push_back 临时 unique_ptr,或从可移动的非 const 范围转移。验证应看错误指向哪一层构造,并用只可移动的测试类型记录转移,避免把结论误写为花括号永远不能移动。
答到这些关键点 中间 initializer_list 的 const 元素 移动列表不改变元素限定 逐项插入建立可移动来源 ☆ 收藏 边界反例 Q4 把所有构造圆括号自动替换为花括号,会出现哪些可观察行为变化?参考答案 会改变重载选择和输入合法性。最直接的是 vector<int>(3,7) 创建三个七,而 vector<int>{3,7} 创建两个元素;若类后来增加 initializer_list 构造,既有花括号调用也可能选到新重载。另一方面,列表初始化会拒绝某些窄化转换,所以原来可编译的数值初始化可能失败。不能用单一风格规则替代语义审查。迁移时应按构造函数集合检查调用点,特别关注容器、数量与值成对参数及数值边界;以元素数量、内容和错误诊断为测试目标,而不是只证明整个项目仍能链接。
答到这些关键点 initializer_list 重载优先影响 数量值语义变化 窄化检查与迁移验证 ☆ 收藏 工程场景 Q5 团队要把 C++17 服务升到 C++20,如何让升级结果可验证且便于回退?参考答案 先列出升级的具体收益,例如用 concepts 限制模板接口或用 jthread 管理线程,再核对编译器、标准库与目标平台的实际支持。保留旧构建作为对照,用功能测试宏和小程序确认关键语法及库接口,不能只修改标准开关。随后分批替换代码,检查公开头文件、第三方库 ABI、告警变化和运行行为,避免把大量语义重构混入工具链升级。对并发和协程接口额外验证取消、异常及停机路径;用相同生产型负载对比体积、吞吐和尾延迟,记录可回退版本与数据兼容边界。
答到这些关键点 前端与标准库分别核实 功能探针而非仅标准开关 分批迁移与相同负载对比 ☆ 收藏
动手验证 把魔法失败值改为 optional,并测试空结果。
+ 我的笔记 用自己的话解释这个知识点
S5-02 / REVIEW NOTES ○ 标为掌握
左值、将亡值、纯右值 值类别属于表达式,不是变量的永久标签。lvalue 表达有身份的对象,xvalue 表达可被复用资源的对象,prvalue 用于计算值或初始化对象;rvalue 包含 xvalue 和 prvalue。const 对象也可以是左值,不能靠“能否放赋值号左边”定义。
具名右值引用变量在使用名字时是左值表达式。std::move 将表达式转为适合移动的右值类别,并不立即析构或缩短对象生命周期。理解这一点可以解释为什么移动构造函数内部对其参数成员仍经常需要 std::move。
代码片段 / 原理示意
std::string&& r=std::string("hi");
std::string a=r; // r 是左值表达式,复制
std::string b=std::move(r); // 可选择移动构造
DEEP DIVE / 从结论到推演 机制推演 值类别要对表达式逐个判断:声明 T&& x 得到右值引用变量,但表达式 x 有名字,是左值;std::move(x) 才产生将亡值。重载决议据表达式类别与 cv 限定选择参数绑定,真正的复制或移动随后才发生。C++17 的纯右值模型允许直接初始化结果对象,不能总画成“先造一个临时,再移动到目标”,否则会错误推导构造次数和所需成员函数。
边界与取舍 把表达式转成将亡值没有延长或缩短对象寿命,也不意味着资源已转移;std::move 后的源对象仍按原规则存在。临时对象直接绑定合适的局部引用可能延长寿命,但经函数返回的借用引用再绑定新引用并不会重新续命。涉及 getter、string_view 和引用成员时,应画出完整表达式结束点,明确拥有者何时销毁,不可只看引用类型。
进阶依据:C++ 工作草案:表达式值类别 · C++ 工作草案:临时对象寿命 · C++20 草案 N4861:复制消除
面试自测 先说结论,再补条件与反例 基础复习 Q1 为什么 T&& 参数在函数里传给重载会选左值版本?参考答案 因为参数的名字是左值表达式,即使声明类型为右值引用。若确实要转移资源,可显式 move;泛型转发接口则使用 forward 保留原调用方类别。
☆ 收藏 基础复习 Q2 移动后对象还能用吗?参考答案 标准库对象一般保持有效但未指定状态,除非具体接口有更强保证;可析构、重新赋值以及满足前提的操作。不要依赖它一定为空,自定义类型也应明确自己的移动后契约。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 函数 f(T&& x) 内调用 consume(x) 为什么可能复制,而 consume(std::move(x)) 才移动?参考答案 参数的声明类型与使用它的表达式类别不同:具名参数 x 是左值表达式,consume 的左值或 const 引用重载因此可能被选择。std::move(x) 只把表达式转成将亡值,让可用的右值重载参与;若 T 带 const 或目标没有适用移动操作,仍可能复制或编译失败。还要区分这里是固定类型的右值引用,还是当前模板推导形成的转发引用,后者通常应使用 forward 保留调用者意图。可用三组重载与构造计数分别测试左值、临时值和 const 值,逐步验证类别、绑定、操作三个阶段。
答到这些关键点 变量类型不等于表达式类别 转换只影响重载候选 固定右值引用与转发引用区分 ☆ 收藏 边界反例 Q4 const auto& r = identity(std::string(1,'x')),identity 返回参数引用,为什么 r 仍悬空?参考答案 临时 string 作为调用实参绑定到引用参数,其寿命只延续到包含该调用的完整表达式结束。identity 返回同一对象的引用,并没有创造一个可再次延长寿命的新临时对象;外层 r 只是接到已有借用,分号之后对象已销毁。对比直接写 const auto& r = std::string(1,'x'),该直接绑定满足局部寿命延长规则。修复可让 identity 按值返回,或由调用者先保存拥有者并确保它活得足够久。测试时应审查完整表达式边界并开启相关寿命诊断,不能以字符串仍能偶然打印作为安全证据。
答到这些关键点 引用参数的完整表达式边界 寿命延长不经引用传递 拥有值返回或明确拥有者 ☆ 收藏 工程场景 Q5 接口返回 const string& name(),调用 make_user().name() 后保存引用,怎样改 API?参考答案 问题是临时 User 在完整表达式结束时销毁,成员引用没有独立拥有权。可给 getter 加引用限定:对 const 左值对象返回 const string&,对右值对象提供按值返回 string 的重载,从临时对象转移或复制名字;也可以直接禁止右值调用,让调用者显式保存 User。选择取决于常用路径的复制成本和接口清晰度。应分别测试长期左值、const 对象、临时对象与移动后对象,确认重载返回类型和寿命。若下游改收 string_view,仍需保证底层 string 存活,视图不会修复这个问题。
答到这些关键点 临时拥有者导致成员引用失效 引用限定重载表达寿命差异 string_view 仍不拥有存储 ☆ 收藏
动手验证 为 f(T&)、f(const T&)、f(T&&) 写重载,预测并验证调用选择。
+ 我的笔记 用自己的话解释这个知识点
S5-03 / REVIEW NOTES ○ 标为掌握
移动语义、std::move 与返回值优化 移动构造或赋值可把缓冲区等资源的所有权从源对象交给目标,避免昂贵深拷贝;但只有类型提供适合的操作且重载选择到它时才会移动。std::move 本身是转换,源对象为 const 时通常不能绑定普通 T&& 移动重载,可能发生复制。
返回局部值时优先直接 return local,让编译器应用可用的返回值优化。C++17 对部分 prvalue 场景有保证的消除机制,而具名局部的 NRVO 不应简单称为永远保证。多写 return std::move(local) 可能阻碍 NRVO。
代码片段 / 原理示意
std::string make_name() {
std::string result="Ada";
return result; // 优先让返回值优化生效
}
DEEP DIVE / 从结论到推演 机制推演 移动是否便宜取决于资源表示与分配器契约。转交一块独占缓冲通常只改指针,固定数组却可能逐元素移动;容器移动赋值遇到不传播且不相等的分配器,也可能需要重新分配并转移元素。noexcept 移动让容器在扩容时更容易维持异常保证,但它必须来自实现确实不抛出的事实,不能靠加关键字把可能失败的操作变安全。
边界与取舍 C++17 中 return T{} 的同类型纯右值结果可以直接构造在目标处,和具名局部 return local 的可选 NRVO 不同。return std::move(local) 通常把具名返回变成普通将亡值返回,反而阻碍 NRVO。还需区分未声明移动构造与显式删除移动构造:后者可能被重载决议选中后导致编译错误,并不总会自动退回拷贝。
进阶依据:C++20 草案 N4861:复制与移动消除 · C++ 工作草案:vector 修改操作的异常约定 · C++ 工作草案:分配器感知容器
面试自测 先说结论,再补条件与反例 基础复习 Q1 std::move 一定调用移动构造吗?参考答案 不一定。它只调整表达式类别;是否移动由类型、const 限定、可用构造函数和重载决议决定。没有适用移动构造时,可能调用 const 引用拷贝构造。
☆ 收藏 基础复习 Q2 为什么移动构造常声明 noexcept?参考答案 当移动确实不会失败时,这个承诺让容器能更放心转移元素并维持异常保证。不能为了性能随意标注;若函数实际抛出且越过 noexcept 边界,会调用 terminate。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 vector 扩容时为什么有时复制元素,即使类型定义了移动构造?参考答案 扩容要把元素迁移到新存储,并尽量在失败时保留原容器。若移动构造可能抛出,前几个源元素已被改变后再失败,恢复原状态很困难;若复制可用,复制旧元素通常让失败时仍能保留旧存储,因此实现会倾向复制。确实不抛出的移动则便于安全迁移。对只有可能抛出移动、不能复制的类型,某些失败情形的保证会减弱,不能泛称 vector 永远有强保证。验证应使用带计数和故障注入的类型,观察扩容时的路径,并核对该标准版本对异常后效果的具体约定。
答到这些关键点 迁移失败后的源状态 可复制与 noexcept 共同影响选择 仅可移动类型的保证边界 ☆ 收藏 边界反例 Q4 类型删除复制与移动后,return T{} 与 return local 在 C++17 中都能工作吗?参考答案 不能一概而论。返回同类型 T{} 的纯右值时,C++17 规则允许结果对象直接构造,不需要先制造一个再复制或移动的中间对象,但析构函数仍须满足可访问且未删除等要求。return local 属于具名返回优化的候选,NRVO 并非必须发生,程序仍需满足相应返回初始化的合法性;不能依赖某个优化等级恰好消除操作来弥补不可用的构造函数。应分别写最小程序,固定 C++17 模式,并区分编译合法性与实际构造日志。加入 std::move(local) 也不会创造缺失的移动能力。
答到这些关键点 C++17 纯右值直接构造 NRVO 是可选优化 析构可访问性仍有要求 ☆ 收藏 工程场景 Q5 优化批量消息处理时,给所有局部返回值加 move 后反而变慢,怎样诊断?参考答案 先检查是否把 return local 改成了 return std::move(local),从而失去 NRVO;用编译器的悲观移动告警和构造计数验证,而不是只读源代码猜测。再看移动对象是否真能转交缓冲,短字符串、内嵌数组及分配器不兼容都可能使移动成本接近复制。基准应保留可观察输出,避免循环被优化掉,并以真实消息大小分布运行。修复以直接返回局部值为默认,只在明确需要所有权转移的赋值或参数位置保留 move,比较分配次数、吞吐和尾延迟,同时测试移动后源对象的合法使用。
答到这些关键点 return move 阻碍 NRVO 按对象表示分析移动成本 真实分布与分配计数验证 ☆ 收藏
动手验证 给类型记录复制/移动事件,对比 return local 与 move(local)。
+ 我的笔记 用自己的话解释这个知识点
S5-04 / REVIEW NOTES ○ 标为掌握
完美转发与引用折叠 模板中形如 template<class T> f(T&&) 且 T 在此调用中推导时,T&& 可成为转发引用;传左值会推导出引用类型。引用折叠遵循“只要有左值引用结果通常为左值引用,只有 && 与 && 得 &&”的规则。
std::forward<T>(x) 按推导到的 T 恢复调用者的值类别,std::move(x) 则统一表达可以转移。const T&& 不是通常的转发引用;类模板参数已确定后的 T&& 也不能机械认为可接所有类别。转发不负责延长参数寿命。
代码片段 / 原理示意
template<class F, class... Args>
decltype(auto) invoke(F&& f, Args&&... args) {
return std::forward<F>(f)(std::forward<Args>(args)...);
} // 需 <utility>
DEEP DIVE / 从结论到推演 机制推演 转发引用先由调用实参推导 T,再经引用折叠形成参数类型;函数体内具名参数是左值,forward<T> 据已推导的 T 恢复类别。这个过程保持调用表达式的绑定信息,不承诺目标一定移动,也不自动保留参数到未来。同步调用包装器与异步保存器因此是两种契约:前者转发,后者需要决定复制、移动或显式借用,并为保存状态安排寿命。
边界与取舍 完美转发存在语法和类型边界:裸花括号列表通常无法让普通 T&& 推导出类型,重载函数名也可能缺少唯一目标;位域不能像普通对象一样绑定到任意引用。无约束的转发构造还可能抢走本应匹配复制构造的调用。C++17 可用 SFINAE,C++20 可用 concepts 排除自身类型并限制可构造性;约束应描述真实接口,而非让错误延迟到函数体。
进阶依据:C++20 草案 N4861:调用中的模板推导 · C++ 工作草案:forward 与 move · C++ 工作草案:invoke
面试自测 先说结论,再补条件与反例 基础复习 Q1 forward 和 move 的核心差别是什么?参考答案 move 无条件表达右值类别;forward 根据模板推导类型保留左值或右值属性。转发包装器若使用 move,会把调用方的左值也当可转移对象,改变接口语义。
☆ 收藏 基础复习 Q2 万能引用这个叫法有什么误导?参考答案 不是所有 T&& 都满足转发引用条件,且它不绕过 const、寿命、重载约束。应明确是否在当前函数模板中推导无 cv 的 T,再分析引用折叠和转发。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 template<class T> wrap(T&& x) 中 T 如何随 int 左值、const 左值与临时量变化?参考答案 传入 int 左值时 T 推导为 int&,T&& 折叠为 int&;传入 const int 左值时 T 为 const int&,结果仍是 const 左值引用;传入 int 临时量时 T 为 int,参数为 int&&。但在函数体里表达式 x 都是左值,所以应使用 forward<T>(x) 才能按原类别调用下游。若改成 move(x),前两种调用也会被视作可转移表达式,其中 const 仍不会消失。可以用静态类型断言和三组重载同时验证推导与选择,且不要把已绑定引用保存到比调用者对象更长命的任务中。
答到这些关键点 左值导致 T 本身为引用 引用折叠保留 const forward 依赖原推导类型 ☆ 收藏 边界反例 Q4 转发构造函数为什么可能让 Wrapper 的复制调用报错,怎样约束?参考答案 无约束的 template<class U> Wrapper(U&&) 在接收非 const Wrapper 左值时,可推导出精确的 Wrapper&,其匹配可能优于接收 const Wrapper& 的复制构造。随后函数体尝试把整个 Wrapper 当成底层数据构造,便产生看似无关的模板错误。修复应在候选阶段排除去除引用与 cv 后等于 Wrapper 的类型,并要求底层成员确实可由 U 构造;C++17 用 enable_if 等机制,C++20 可写 requires。还要显式保留预期复制和移动操作,用自身左值、const 左值、右值及合法外部类型检查重载集合。
答到这些关键点 转发重载可能比 const 复制更精确 候选阶段排除自身类型 同时验证复制移动和外部构造 ☆ 收藏 工程场景 Q5 设计 C++17 submit(f,args...) 异步执行接口,能把所有参数直接转发后保存引用吗?参考答案 默认不能,因为 submit 返回后调用者的局部变量和临时量可能立即销毁。应把可调用对象和参数保存为明确拥有的衰减值,左值默认复制、右值默认移动;需要借用时让调用者显式使用 reference_wrapper,并承担被借用对象存活的契约。执行阶段再按一次性或可重复任务语义决定是否移动这些存储值,不能反复转发一个已消费参数。还应通过 invoke 支持成员指针等调用形式,并把结果或异常写入结果通道。测试临时 string、unique_ptr、显式引用、任务取消和重复提交,检查编译约束与析构时序。
答到这些关键点 保存契约不同于即时转发 拥有值默认与显式借用 一次消费及结果异常通道 ☆ 收藏
动手验证 实现一个调用包装器,分别传左值、const 左值和临时对象。
+ 我的笔记 用自己的话解释这个知识点
S5-05 / REVIEW NOTES ○ 标为掌握
lambda、仿函数与捕获寿命 lambda 形成带 operator() 的闭包对象,捕获内容成为其状态。按值捕获保存副本,按引用捕获借用外部对象;mutable 允许修改按值捕获的副本,但不会因此修改原变量。初始化捕获可把 unique_ptr 移入闭包。
异步执行时,闭包可能活得比创建它的栈帧更久,因此引用捕获容易悬空。捕获 this 通常只是保存对象指针,不延长对象寿命;按值默认捕获也不能误解成深拷贝整个对象。std::function 进行类型擦除,有可复制目标等约束和潜在分配代价。
代码片段 / 原理示意
auto task=[p=std::make_unique<int>(7)]() { return *p; };
// C++14 初始化捕获;闭包含 unique_ptr,通常不可复制
DEEP DIVE / 从结论到推演 机制推演 闭包按值保存的只是捕获表达式的值:捕获裸指针或 string_view 只复制地址和视图,捕获 this 也只保存对象指针;C++17 的 [*this] 才复制对象本身,但对象内部仍可能包含借用成员。把 lambda 放进队列时,需要同时追踪闭包、其成员及成员所指存储三层寿命。mutable 只改变闭包调用时修改捕获副本的权限,不会添加跨线程互斥。
边界与取舍 捕获 shared_ptr 可延长对象寿命,却也可能形成“对象拥有回调、回调拥有对象”的环;捕获 weak_ptr 并在执行时 lock 能避免该环,但不能保证对象还存在时它的状态允许处理事件。取消订阅是否等待正在执行的回调结束,也属于接口契约。可复制的 std::function 不是线程同步器,复制包装器后共享捕获的数据仍可能发生竞争。
进阶依据:C++20 草案 N4861:lambda 捕获 · C++ 工作草案:std::function 构造要求 · C++ 工作草案:packaged_task
面试自测 先说结论,再补条件与反例 基础复习 Q1 [=] 捕获 this 是否保证对象活着?参考答案 不保证。传统 this 捕获保存指针,对象销毁后回调仍可能悬空。可用受控的拥有权、weak_ptr 锁定或确保回调先于对象退出,具体策略取决于是否允许延长寿命。
☆ 收藏 基础复习 Q2 为什么某个 lambda 放不进 std::function?参考答案 例如闭包包含仅可移动资源时不满足 std::function 对目标可复制性的要求。可以调整拥有权、使用模板保存具体闭包,或在支持的标准与工具链选择适合的可移动类型擦除包装。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 异步回调使用 [=] 后仍访问已销毁对象,按值捕获为什么没保护住它?参考答案 按值捕获不等于递归复制所有可达对象。在成员函数中访问成员时,传统捕获形式可能保存的是 this 指针,原对象销毁后该指针悬空;捕获 string_view 或裸指针也只复制借用表示。修复应按需求选择保存所需字段的独立值、C++17 的对象副本、共享拥有权或 weak_ptr::lock,并核对副本内部是否仍含借用。若需要保留对象身份而非快照,简单复制整个对象可能改变语义。测试应让创建回调的作用域先退出,再执行回调,同时检查捕获数据的分配、移动和销毁时间,而不是只把捕获符号改为等号。
答到这些关键点 值捕获可仍是借用表示 快照语义与对象身份区分 延迟执行验证捕获寿命 ☆ 收藏 边界反例 Q4 把 this 改成 weak_ptr 捕获并成功 lock,能保证取消后的回调不会修改对象吗?参考答案 不能。lock 成功只保证对象在该局部强引用持有期间不被销毁,不代表逻辑请求仍有效,也不与取消状态自动互斥。回调可能先 lock,随后另一线程取消,而它仍继续修改对象。应为取消定义线性化点,用同一锁保护状态检查与必要提交,或用序列号拒绝旧回调;若取消要求返回后绝无回调执行,还需要等待已在执行的回调退出。验证时刻意交错 lock、取消和提交三个步骤,分别断言寿命安全与业务取消保证。还应避免在回调线程等待自己完成,否则取消流程会自锁。
☆ 收藏 工程场景 Q5 C++17 任务队列要保存捕获 unique_ptr 的 lambda,std::function 无法接受时如何取舍?参考答案 std::function 要求目标可复制,包含 unique_ptr 的闭包通常只可移动,因此拒绝是契约不匹配。若任务只执行一次,可采用只可移动的任务包装,例如符合接口的 packaged_task,或经过验证的移动类型擦除实现;也可让队列模板化保存具体任务类型。把 unique_ptr 改 shared_ptr 虽能满足复制,必须确实需要共享所有权,否则会扩大寿命并增加计数成本。C++23 的 move_only_function 不属于 C++17/20 基线。比较方案时测每任务分配、包装大小与吞吐,并测试队列取消后资源释放及异常写回结果的行为。
答到这些关键点 std::function 的目标可复制约束 一次任务采用移动拥有权 C++23 接口不混入既定基线 ☆ 收藏
+ 我的笔记 用自己的话解释这个知识点
S5-06 / REVIEW NOTES ○ 标为掌握
异常传播、noexcept 与错误模型 异常沿调用栈传播,自动对象在栈展开中析构,直到找到匹配处理器。捕获通常用 const 引用以避免切片和复制,重新抛出当前异常用 throw;。异常不是处理所有失败的唯一方式:可预期业务失败可用返回值,跨 ABI 边界通常转换成明确错误表示。
noexcept 是不让异常逃出函数的承诺,不是“编译器证明不会抛”。函数内部仍可抛出再自行捕获;一旦异常越过该边界会 terminate。设计错误模型时要统一调用者如何识别失败、资源是否保留以及能否重试。
代码片段 / 原理示意
try { work(); }
catch (const std::exception& e) { log(e.what()); throw; }
// log 本身的失败策略也需要定义
DEEP DIVE / 从结论到推演 机制推演 异常在当前线程中寻找处理器,栈展开负责销毁已构造的自动对象;它不会自动跨越线程边界到提交任务的线程。异步接口需要捕获 exception_ptr 或通过 promise 等结果通道传递,再由等待方观察失败。错误分类还决定重试:参数错误通常无需重试,暂时资源不足可能重试,外部副作用已发生但确认丢失时则需幂等协议,不能仅按异常类型统一重跑。
边界与取舍 noexcept 是调用契约,函数内部仍可以抛出并捕获异常,但未被处理的异常越过该边界会触发 terminate。是否在终止前展开部分栈存在标准允许的实现差异,不能依赖所有 RAII 清理必然发生。析构可显式声明 noexcept(false),却仍不适合在另一次异常展开期间再传播;需要报告关闭或提交错误时,应使用调用方能主动处理的显式操作。
进阶依据:C++20 草案 N4861:terminate 触发条件 · C++ 工作草案:exception_ptr · C++ 工作草案:future 结果与异常
面试自测 先说结论,再补条件与反例 基础复习 Q1 throw; 与 throw e; 有什么区别?参考答案 throw; 重新抛出当前异常并保留其动态类型;throw e; 构造新的抛出对象,若 e 以基类引用绑定派生异常,可能切片。应按是否有意转换错误类型选择。
☆ 收藏 基础复习 Q2 异常安全等于线程安全吗?参考答案 不等于。异常安全关注失败后的资源与状态,线程安全关注并发访问的正确性。一个操作可能无泄漏却有数据竞争,也可能有锁却在异常后破坏业务不变量。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 工作线程抛出的异常为什么不能被创建线程处的 try/catch 捕获?参考答案 try/catch 的动态传播路径沿当前线程调用栈寻找处理器,创建线程的调用点不在工作线程正在执行的栈上。异常逃出线程入口会导致终止,不能期待 join 把它自动重新抛出。正确做法是在入口捕获并存为 exception_ptr,或使用 promise、packaged_task 等把异常写入共享结果;调用方在 get 或明确检查时处理。还要给没有人读取的失败设置记录策略,避免被静默丢弃。测试正常结果、业务异常、取消和结果接收方提前退出,确认每个任务都有终态,并且一个任务失败不会意外结束整个工作池。
答到这些关键点 异常传播不跨线程调用栈 显式共享结果传递异常 无人读取失败的处理策略 ☆ 收藏 边界反例 Q4 一个 noexcept 函数调用可能抛出的日志库,外层 catch 能否保证服务继续运行?参考答案 若异常先越过 noexcept 函数边界,程序会触发 terminate,外层 catch 没有机会按普通传播方式恢复。可选择在该函数内部捕获日志异常并执行不会再次抛出的降级路径,或取消不真实的 noexcept 承诺,让调用方按接口处理失败。降级逻辑本身也要谨慎,分配字符串、获取不可靠资源或再次调用同一日志库都可能继续失败。验证可让日志后端主动抛出,并在独立子进程测试终止路径;不能把单元测试框架能捕获普通异常的经验套用到 noexcept 违约。还应记录日志丢失指标,避免静默掩盖持续故障。
答到这些关键点 外层 catch 无法越过 noexcept 违约 内部降级也须不抛出 独立进程验证终止路径 ☆ 收藏 工程场景 Q5 支付接口超时抛异常,通用重试器立即重发是否合理?怎样设计错误模型?参考答案 超时只说明未及时获得确认,不能证明远端没有完成扣款,因此直接重发可能重复产生副作用。错误模型应区分确定未执行、确定失败和结果未知;未知结果使用同一业务幂等键查询或重试,由服务端去重并记录最终状态。对参数不合法等永久错误直接返回,对可恢复资源问题采用有上限且服从截止时间的退避。异常或返回码只是承载形式,关键是保留失败类别、上下文与重试资格。验证应模拟远端已提交但响应丢失、重复请求及客户端重启,检查账务只产生一次效果并能追踪最终结果。
答到这些关键点 超时不证明未执行 未知结果与幂等键 截止时间内的有限重试 ☆ 收藏
动手验证 在三个不同位置注入失败,检查对象状态和释放次数。
+ 我的笔记 用自己的话解释这个知识点
S5-07 / REVIEW NOTES ○ 标为掌握
C++20 协程:挂起、恢复与销毁 包含 co_await、co_yield 或 co_return 的合适函数可成为协程。编译器将跨挂起点需要保留的局部状态放入协程状态中,并通过 promise 与 awaiter 协议控制返回对象、挂起与恢复。协程是流程组织机制,不天然创建线程或提供并行执行。
C++20 协程是无栈模型,不要求每个协程有独立线程式栈;帧分配在某些条件下可优化消除。真正异步 I/O 还需调度器、事件循环和库配合。谁持有 handle、谁负责销毁、取消时怎么唤醒和回收,是接口设计的核心。
代码片段 / 原理示意
task<Response> serve() {
auto request = co_await async_read();
co_return handle(request);
} // 协议示意:task、async_read 由协程库提供,非独立程序
DEEP DIVE / 从结论到推演 机制推演 co_await 先判断 await_ready,若需挂起则进入 await_suspend,把恢复权交给调度协议,恢复后由 await_resume 提供结果或失败。帧中保存跨挂起点所需的状态,不代表引用参数指向的对象也被复制进去。若 await_suspend 向另一线程发布 handle,对方可能在该函数返回前恢复并销毁 awaiter,因此发布后继续访问其成员必须格外小心。
边界与取舍 取消不是 destroy(handle) 的同义词:I/O 完成回调仍可能持有 handle,贸然销毁会让晚到回调恢复已释放帧。需要由库协议仲裁完成与取消,并在确认再无恢复来源后销毁。协程也可能迁移线程,跨挂起点持有线程所有权互斥锁会破坏解锁前提;即使始终单线程,持锁挂起仍可能堵住完成该操作所需的其他任务。
进阶依据:C++ 工作草案:协程定义与状态 · C++20 草案 N4861:await 协议 · C++20 草案 N4861:协程恢复与销毁前提
面试自测 先说结论,再补条件与反例 基础复习 Q1 协程与线程的区别是什么?参考答案 线程是操作系统常见调度实体,可在多核并行;协程在挂起点保存状态,由运行时或调用者恢复。多个协程可运行于一个线程或线程池,是否并行由调度方案决定。
☆ 收藏 基础复习 Q2 协程最常见的生命周期陷阱?参考答案 保存了比协程帧更短命的引用或 this,任务取消后帧仍被恢复,或 handle 被重复销毁。必须规定任务拥有权和恢复协议,并对失败、取消、超时路径逐一验证。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 协程局部变量保存在帧里,为什么传入 const string& 仍可能在恢复后悬空?参考答案 协程保存引用参数的绑定信息,并不因挂起而复制被引用的 string。如果调用者传入临时对象,或在协程暂停时退出拥有者作用域,帧虽然还活着,引用目标已经销毁。需要跨挂起点使用的数据应按值传入并存入帧,或明确由共享拥有者保活;仅把引用改成 string_view 仍然借用同一存储。成员协程的 this 与捕获式协程 lambda 的闭包寿命也要独立检查。测试应让调用方在任务尚未恢复时销毁参数,并观察析构时序,确保修复提供真实所有权而不是依赖调度恰好及时。
答到这些关键点 帧保留引用不复制目标 按值参数或明确共享拥有权 this 与闭包具有独立寿命 ☆ 收藏 边界反例 Q4 超时时直接 destroy 挂起协程,为何可能与 I/O 完成竞争?参考答案 挂起只是当前未执行,不能证明外部已失去恢复能力。I/O 完成可能已排入队列,超时线程销毁帧之后,该回调仍会 resume 原 handle,形成释放后使用;也可能完成与取消同时恢复同一协程。正确协议应在共享操作状态上仲裁唯一终态,取消向底层撤销操作,并等待撤销确认或完成回调交还恢复权,再由唯一拥有者回收帧。具体等待方式由库决定,C++20 语言本身不提供统一取消模型。验证需覆盖完成先到、取消先到、同时到达及晚到回调,并统计恰好一次完成和恰好一次销毁。
答到这些关键点 挂起不等于不存在恢复来源 完成取消仲裁唯一终态 确认恢复权归还后回收帧 ☆ 收藏 工程场景 Q5 单线程事件循环运行十万协程,偶发全部卡住,如何定位而非继续增加协程数?参考答案 先测事件循环一次调度耗时及最久不返回的调用栈,寻找同步文件 I/O、阻塞锁、长计算或同步 DNS 等占住唯一执行线程的操作。协程数量不会提供额外 CPU,也不会把阻塞调用自动变异步;把长工作移到有界执行池,并用异步结果恢复。再检查是否有协程持锁挂起,而完成路径也需要同一锁,或取消后遗留永不恢复的任务。记录活动帧数、等待类型、完成队列延迟和帧内存,复现停机与超时压力。修复后比较尾延迟和内存上界,必要时限制在途协程并向入口传递背压。
答到这些关键点 事件循环阻塞栈与调度延迟 持锁挂起的依赖循环 帧数量和有界异步执行 ☆ 收藏
动手验证 画出一次异步读取的创建、挂起、就绪、恢复、销毁时序。
+ 我的笔记 用自己的话解释这个知识点
从理解到实践 阶段项目与验收 写一个任务包装器:接收可调用对象和参数,保存所需值,执行后返回结果;说明何时复制、移动、借用,测试短命对象捕获和异常传播。
SYSTEM INTERVIEW / 跨知识点综合题 异步任务完成时,发起它的对象已经销毁 GUI 或网络对象用 lambda 捕获 this,交给线程池执行,用户关闭连接后偶发 use-after-free。任务耗时不可预测,也可能抛异常。如何设计值捕获、所有权、取消与结果回传?
展开方案与推理 先把“执行任务需要的数据”和“任务结果应该交给谁”拆开。独立计算使用拥有其输入的值或不可变共享块,不借用发起函数栈上的引用。若结果只在接收对象仍存在时才有意义,可捕获 weak_ptr,在回传阶段 lock 获取临时拥有权;失败则丢弃结果。若业务要求任务期间对象必须存活,可显式持有 shared_ptr,但这改变关闭时资源释放时机,需要在接口中说明。
取消不是强杀线程,也不是仅设置一个布尔值。任务应在可取消点检查令牌,阻塞等待应有可唤醒/超时路径;任务进入不可撤销副作用阶段后,需要定义取消请求返回什么状态。对结果回传增加请求代际或操作 ID,防止对象虽然还活着,但已经切换到新请求后接收旧结果。weak_ptr 保证的是对象寿命,不保证结果对当前业务状态仍然有效,更不保证成员访问线程安全。
在线程入口捕获异常并放入 future 或结果通道;回调执行的线程和锁策略必须明确。关闭流程先停止接单和注销回调,再取消/等待任务,最后销毁所依赖资源。若用协程,要同样分析协程帧、awaiter 与底层 I/O 的拥有关系,确保取消完成前不会销毁仍可能被恢复的帧。用延迟、异常、重复关闭和任务刚完成的竞态测试这些边界。
验收证据 所有异步输入都有可证明覆盖任务时长的寿命。 对象关闭、任务取消与最终副作用的状态区分清楚。 旧代结果不覆盖新请求,异常能被调用者观察。 继续追问 追问:改成捕获 shared_from_this 就彻底修复了吗? 它可让对象活到闭包释放,但前提是对象已经由合适的 shared_ptr 管理;构造阶段等时机不能任意调用。闭包若又被对象自身持有,还可能形成强引用环。即使寿命解决,成员竞争、旧结果覆盖新状态和关闭延迟仍存在,所以要继续设计任务退出与回调注销。
追问:先 if(!weak.expired()) 再用裸指针为什么不够? 检查结束后最后一个拥有者可能立即离开,检查与使用之间存在竞态。应以 lock 的结果作为整个使用阶段的强拥有者,并在释放前完成所需访问。如果拿到的对象在另一个线程还会修改同一成员,仍必须遵守该成员的同步协议;寿命安全与数据竞争安全不是一件事。
追问:请求取消后任务实际已经成功,接口该返回失败吗? 应按明确的提交点定义状态。提交前可取消且保证未产生副作用;提交后通常返回已完成或提供操作 ID 供查询,不能把“调用者不再等待”伪装为“业务没有发生”。对可能重试的操作配合幂等机制。测试取消与提交先后两种时序,让客户端能区分未知结果、取消成功和已完成。
参考依据:weak_ptr::lock · 协程生命周期
CODE REVIEW / 动手审查 延迟回调:捕获引用还是拥有数据? 回调在创建函数退出之后才执行。改成 [=] 是否总能解决 this 和指针成员的寿命?
C++17 · 回调接口片段 · 先自行判断,再展开核对。
std::function<void()> make_task() {
std::string text = "payload";
return [&text] { consume(text); };
}查看问题、修复与验证 闭包对象活着不代表其引用的 text 活着。正确方案应按需求复制或移动所需数据到闭包,让其寿命覆盖调用。
按值捕获 this 在相关语言规则下保存的仍是对象指针;按值捕获一个裸指针也只复制指针。捕获拥有对象、弱引用或值快照是不同的业务契约。
move-only 资源进入闭包后,闭包可能不可复制,不能直接塞进要求目标可复制的 std::function。可用具体模板类型保存,或在适用标准中选择支持仅移动目标的包装;同时定义调用次数、并发和取消行为。
修正思路 std::function<void()> make_task() {
std::string text = "payload";
return [text = std::move(text)] { consume(text); };
}
// consume 必须在调用期间正确使用该字符串;若继续异步借用,仍需自己的寿命协议。怎样验证 创建回调后先退出创建函数,再延迟执行;增加重复调用、并发调用与取消测试。若回调调用成员函数,还应验证对象关闭与回调执行的交错。
这里提供审查练习与验证步骤,不代表已在本机执行 C++、数据库或 GPU 实验。
依据:lambda 捕获 · std::function
本章参考资料与适用版本 正文为原创整理;链接用于进一步核对,不影响离线阅读。C++ 工作草案会持续更新,本文采用已标注的 C++17/20 功能范围。
STAGE 06 / 八阶段学习路线 并发与 I/O 先定义共享状态和同步关系,再选择锁、原子操作或事件循环。以 Linux I/O 为例,平台差异另行确认。
← 回到路线图 抽题练习本章 本章目录 · 8 个知识点
S6-01 / REVIEW NOTES ○ 标为掌握
线程创建、join 与结构化退出 std::thread 启动后要安排 join 或在明确的生命周期设计下 detach。销毁仍处于 joinable 状态的 thread 对象会 terminate,即使其线程函数可能已经运行完。join 不只是等待,还让完成前的效果与等待方形成规定的同步关系。
C++20 jthread 提供自动请求停止并 join 的管理方式,但停止是协作式:任务需检查停止条件并让阻塞等待可解除。detach 不等于免费后台任务,它使资源退出和错误收集更难。线程池应有停止接单、处理或取消待执行任务、唤醒和等待工作线程的明确次序。
代码片段 / 原理示意
std::thread worker([] { /* 有限工作 */ });
worker.join(); // 需 <thread>,示意需放在函数内
DEEP DIVE / 从结论到推演 机制推演 线程拥有者要同时管理执行寿命和结果可见性。成功 join 既等待线程结束,也把完成前的效果同步到等待方;因此没有其他并发访问时,可以在 join 后读取工作线程写好的普通结果。joinable 则表示句柄仍关联一个需处理的线程,即使线程函数早已返回也可能为真。对同一个 thread 句柄的并发 join 或其他修改还需要单独同步。
边界与取舍 C++20 jthread 的析构会在仍可连接时请求停止并 join,但请求停止是协作信号,不能让任意阻塞调用立即返回。退出顺序必须先解除队列、事件等待和业务锁依赖,再等待线程;若拥有者析构时仍持有工作线程退出所需的锁,会形成循环等待。成员析构顺序也重要:线程必须结束后,才可销毁它访问的队列、互斥量和配置。
进阶依据:C++ 工作草案:thread 成员与 join 同步 · C++20 草案 N4861:jthread · Linux eventfd(2):可供事件循环等待的通知
面试自测 先说结论,再补条件与反例 基础复习 Q1 为什么不把所有线程都 detach?参考答案 detach 后难以证明线程何时结束,局部引用和全局资源可能已销毁,异常与结果也难管理。应让线程寿命从属于显式拥有者,并建立退出与等待协议。
☆ 收藏 基础复习 Q2 jthread 是否保证程序快速停止?参考答案 不保证。它请求协作停止,若任务不响应或永久阻塞,析构时 join 仍可能等待很久。必须设计可取消等待、超时和可到达的终止条件。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 工作线程写普通 int result,主线程 join 后读取,为什么可以不把 result 做成 atomic?参考答案 在主线程仅于成功 join 返回后读取,且没有其他冲突访问的前提下,工作线程结束与 join 返回建立同步关系,之前对 result 的写入 happens-before 随后的读取,因此不需要额外原子化该结果。若主线程在 join 前轮询 result,或者其他线程仍会修改它,这条证明就不成立。还必须让 result 的对象寿命覆盖工作线程的使用,不能先退出作用域再等待。验证应画出写入、线程完成、join 返回、读取的顺序,并分别审查异常路径是否仍会 join;测试只能补充发现遗漏,不能代替同步关系说明。
答到这些关键点 join 提供完成同步 读取必须晚于成功 join 结果对象寿命与其他访问前提 ☆ 收藏 边界反例 Q4 把 thread 换成 jthread 后析构仍挂住,为什么自动 join 没解决退出问题?参考答案 jthread 自动处理的是请求停止和等待,不会强行中断不检查停止的任务。工作线程可能永久阻塞在普通条件变量、系统调用或锁上,停止标志即使变真也没有机会被观察。另一个典型原因是析构线程持有互斥量再触发 join,而工作线程退出也需要该锁。应先在短临界区更新关闭状态,释放锁,唤醒所有相关等待点,再在锁外等待线程。C++20 可按场景使用支持停止令牌的等待接口,但底层 I/O 仍需取消或唤醒设计。用线程栈和等待依赖图定位卡点,并测试每一种阻塞位置的退出路径。
答到这些关键点 协作停止不取消任意阻塞 持锁 join 的循环依赖 逐个等待点设计唤醒 ☆ 收藏 工程场景 Q5 服务要求两秒内停机,线程可能等待网络、队列和长计算,应怎样实现有界退出?参考答案 先停止接收新任务并传播统一截止时间,再分别给网络等待安排可唤醒事件或可取消接口,给队列等待加入关闭谓词及广播,给长计算设置可到达的停止检查点。排空与取消应由业务明确选择,不能同时假定所有任务完成且永不超时。管理线程在不持有工作锁时 join,并记录各阶段耗时;若第三方阻塞调用没有中断机制,两秒承诺就需要隔离执行或调整接口,不能靠 detach 假装完成。测试空闲、满载、对端不响应及任务异常,并确认线程退出后才销毁共享资源。
答到这些关键点 统一截止时间与分等待点退出 不可取消调用限制承诺 退出后销毁共享资源 ☆ 收藏
动手验证 测试线程创建后主路径抛异常,使用 RAII 避免遗漏 join。
+ 我的笔记 用自己的话解释这个知识点
S6-02 / REVIEW NOTES ○ 标为掌握
互斥锁、自旋锁与锁粒度 互斥锁争用时实现可让线程阻塞,自旋锁则反复检测等待,消耗处理器时间。自旋适合可证明非常短且持锁线程可及时运行的临界区,不能用来包裹网络、磁盘或长计算。系统负载与调度会改变其效果。
细粒度锁提高潜在并行度,但增加管理、死锁与缓存一致性成本;粗锁简单但可能串行化热点。先保护正确的不变量,再用争用分析决定拆锁。lock_guard 用于简单作用域锁,unique_lock 支持延迟加锁、解锁与条件变量配合。
代码片段 / 原理示意
std::scoped_lock guard(m1,m2); // C++17,多互斥量无死锁加锁算法
// 临界区仅放维护共同不变量所需的短操作
DEEP DIVE / 从结论到推演 机制推演 互斥量既排除同时进入,也让 unlock 与随后成功取得同一锁的操作建立同步。它没有普遍的先进先出保证,唤醒线程还要竞争 CPU 和锁,平均等待低也可能掩盖某些线程的长期饥饿。自旋把等待变为反复访问共享缓存行,只有持锁线程能及时运行且临界区足够短时才可能划算;在超额订阅或持锁线程被抢占时尤其容易浪费算力。
边界与取舍 拆锁或使用不同原子变量后仍可能变慢,因为逻辑上独立的数据可能共处一条缓存行,某线程写入使其他核心反复获取该行,形成 false sharing。对齐隔离能降低此类争用,却增加对象尺寸、缓存占用和内存带宽。缓存行大小属于平台因素,不能把固定填充常数当语言保证;应先定位热行,再比较分片计数、批量归并和必要的布局调整。
进阶依据:C++ 工作草案:互斥量要求 · Linux Kernel:False Sharing · Linux Kernel:NUMA Memory Policy
面试自测 先说结论,再补条件与反例 基础复习 Q1 自旋锁一定比 mutex 快吗?参考答案 不一定。持锁线程被抢占、CPU 过度订阅或临界区较长时,自旋会浪费 CPU 并拖慢系统。应测锁等待时间和上下文,用户态普通业务通常先用标准互斥量。
☆ 收藏 基础复习 Q2 怎样缩短锁持有时间?参考答案 把不依赖共享状态的计算移出临界区,用快照准备结果,提交时再次验证必要条件;避免锁内 I/O 与未知回调。不能为缩短时间把需要原子保持的不变量拆坏。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 std::mutex 能保证等待最久的线程下一个取得锁吗?如何分析尾延迟?参考答案 不能把 std::mutex 当成标准承诺的 FIFO 队列。接口提供互斥和同步语义,具体阻塞、唤醒及竞争策略由实现和调度环境影响,新到线程也可能先获得锁。要分析尾延迟,应分别记录锁等待时间、持有时间和线程被调度时间,观察是否存在长临界区、持锁 I/O或优先级失衡。缩短临界区、移走回调和按键分片通常比自行实现公平锁更直接;若业务确实要求公平,应明确队列策略并评估吞吐代价。验证需看每个线程的最大等待及分位数,不能只以总体吞吐判断没有饥饿。
答到这些关键点 标准不承诺 FIFO 公平 等待持有与调度延迟分开 公平性具有吞吐代价 ☆ 收藏 边界反例 Q4 临界区只有几十条指令,为什么自旋锁在 64 线程、8 核机器上仍可能更慢?参考答案 指令少不等于持锁时间总很短:持锁线程可能被操作系统抢占,而其他线程继续自旋占用有限核心,使真正能释放锁的线程更难及时运行。大量核心争夺同一锁字还带来缓存一致性流量,锁内访问缺页或慢内存也会拉长等待。普通 mutex 的阻塞或实现中的适应性策略可能因此更合适。应在相同负载下比较 CPU 使用率、锁等待分布、上下文切换与吞吐,并测试线程数低于和高于核心数两种情况。只跑无竞争微基准会遗漏调度和共享缓存成本,不能据此替换线上同步原语。
答到这些关键点 持锁线程被抢占 超额订阅与一致性流量 竞争和调度场景基准 ☆ 收藏 工程场景 Q5 16 个线程各自增加不同的 relaxed 原子计数器,吞吐扩展很差,怎样验证 false sharing?参考答案 先确认计数器确实独立,且没有其他共享锁或读取汇总热点,再检查它们的实际地址是否落在同一缓存行。用平台支持的 perf c2c 等工具观察热行及跨核缓存争用,并建立对照版本:让各计数器按合适间距布局,或在线程私有变量中累计后批量归并。relaxed 减少的是顺序约束,并不消除写同一缓存行的硬件成本。测量不同核心分布和汇总频率下的吞吐、尾延迟与内存占用;若填充无收益,就继续查 NUMA、频率或工作量失衡,不能把所有扩展失败都归为伪共享。
答到这些关键点 独立原子仍可能共享缓存行 布局对照与 perf c2c 证据 relaxed 不消除一致性成本 ☆ 收藏
动手验证 记录队列锁持有和等待时间分布,再决定是否优化。
+ 我的笔记 用自己的话解释这个知识点
S6-03 / REVIEW NOTES ○ 标为掌握
条件变量、信号量与生产消费 条件变量用于等待由共享状态表达的条件,必须配合互斥量和谓词检查。wait 会原子地释放锁并等待,返回前重新取得锁;可能发生虚假唤醒,因此使用 wait(lock,predicate) 或循环检查。通知本身不保存业务条件。
生产者先在同一协议下修改队列状态,再通知消费者;消费者等待“队列非空或已关闭”。信号量表达可用许可数,future/promise 表达一次结果,barrier/latch 用于阶段协作。选择同步原语应匹配等待关系,而不是靠 sleep 猜时间。
代码片段 / 原理示意
std::unique_lock<std::mutex> lock(m);
cv.wait(lock,[&]{ return closed || !queue.empty(); });
if(queue.empty()) return; // 关闭且无剩余任务
auto item=std::move(queue.front()); queue.pop();
DEEP DIVE / 从结论到推演 机制推演 条件变量的核心不是记住通知,而是把检查共享谓词、释放锁并进入等待衔接成正确协议。生产者在同一互斥量下改变状态,消费者持锁检查后调用 wait,避免在“看见不满足”和“真正等待”之间漏过状态变化。notify 负责让等待者重新竞争检查资格,数据可见性通常来自锁的释放和取得;被唤醒并不表示任务已经分配给该线程。
边界与取舍 通知可以在持锁期间或解锁后发送,前提是对象寿命和谓词协议完整;解锁后通知常减少立即争锁,但不能一概写成强制规则。超时等待若每次虚假唤醒都重新使用完整 wait_for 时长,可能无限推迟总截止时间。应计算基于稳定时钟的绝对截止点,并在每次返回后重新检查谓词,区分真正就绪、关闭与到期三种业务结果。
进阶依据:C++ 工作草案:condition_variable 等待与通知 · C++ 工作草案:条件变量通用顺序要求 · C++ 工作草案:steady_clock
面试自测 先说结论,再补条件与反例 基础复习 Q1 为什么条件变量必须检查谓词?参考答案 唤醒可能虚假发生,也可能另一消费者先取走任务。谓词描述真正可继续执行的状态;检查和修改共享状态使用同一锁,才能避免在检查与等待间出现遗漏。
☆ 收藏 基础复习 Q2 notify_one 与 notify_all 怎样选?参考答案 一次新增一个任务常可唤醒一个消费者;关闭队列或状态变化影响所有等待者时通常唤醒全部。过度广播会产生竞争,过少通知则可能让符合条件的等待者无法退出。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 谓词改成 atomic<bool> 后,生产者不拿 mutex 直接 store 再 notify,为何仍可能永久等待?参考答案 原子变量消除了该标志本身的数据竞争,却没有自动封闭条件变量的检查到等待窗口。消费者可先读到 false,生产者随后 store(true) 并通知,此时消费者尚未进入等待;消费者再睡下,而状态虽为 true却没有后续通知唤醒它。常规修复是在同一互斥量下修改和检查谓词,让 wait 的解锁并等待与生产者取得锁配合。也可设计完全不同的原子等待协议,但不能只替换变量类型。验证应控制线程恰好按上述顺序运行,并审查状态、等待登记和通知的关系;release/acquire 本身不等于唤醒保证。
答到这些关键点 原子性不封闭检查等待窗口 同锁谓词协议 可见性与通知保证区分 ☆ 收藏 边界反例 Q4 两个线程等待同一条件变量但谓词不同,状态变化后 notify_one 为什么可能无法推进?参考答案 notify_one 不负责挑选谓词已经满足的线程。它可能唤醒等待条件 B 的线程,而这次变化只满足条件 A;B 重新检查后睡回去,A 仍睡着,若没有新通知就失去进展。可以为不同等待关系使用不同条件变量,或在这种状态变化时 notify_all,让各线程自行检查。谓词必须仍在同一正确锁协议下访问,广播不能补救数据竞争。还要区分队列新增一个可消费任务与全局关闭等状态,后者常影响所有等待者。用只触发一次变化的测试验证所有应推进者,避免持续生产流量掩盖错误唤醒。
答到这些关键点 notify_one 不按业务谓词选线程 独立条件变量或广播 单次状态变化暴露进展漏洞 ☆ 收藏 工程场景 Q5 有界队列同时有等空位的生产者和等任务的消费者,close 如何唤醒并给出明确结果?参考答案 在同一互斥量下设置 closed,并为生产者使用 closed 或未满的谓词,为消费者使用 closed 或非空的谓词;关闭后广播两个条件变量。生产者醒来若已关闭就返回拒绝,不能继续入队;消费者按预定策略排空剩余任务,或明确取消并使结果可见,队列为空且关闭时退出。取走元素后通知空位,加入元素后通知任务。close 应可重复调用,销毁队列前还需等待所有调用者退出。测试空队列、多等待者、满队列及关闭与入队同时发生,记录每个已接收任务的最终状态,确认没有无结果的遗漏。
答到这些关键点 两个等待方向都检查关闭 拒绝新任务与排空策略 销毁前等待全部使用者退出 ☆ 收藏
动手验证 用空队列启动多个消费者,立即关闭并验证所有线程退出。
+ 我的笔记 用自己的话解释这个知识点
S6-04 / REVIEW NOTES ○ 标为掌握
数据竞争、atomic 与内存序 不同线程无合适同步地冲突访问同一内存位置、至少一次为写且存在非原子访问时,可能形成数据竞争并导致未定义行为。互斥量通过保护共享状态建立顺序;atomic 提供特定对象的原子访问,但不自动把多个变量组成事务。
relaxed 只提供相应原子性和该对象修改顺序,不负责发布其他普通数据。release 发布前序写入,读取到匹配发布值的 acquire 操作可建立可见性。seq_cst 提供更强的全序约束但仍需正确协议。原子操作是否 lock-free 依类型与平台,不能一概而论。
代码片段 / 原理示意
// 一次性发布示意;data 仅在发布前由生产者写
data=42; ready.store(true,std::memory_order_release);
// 消费者:若 ready.load(std::memory_order_acquire) 为 true,再读 data
DEEP DIVE / 从结论到推演 机制推演 release 与 acquire 不是仅靠名字配对:读者的 acquire 必须从相应 release 或其适用释放序列取得值,才能建立所需同步。一次性 ready 示例还隐含数据发布后不再被写者修改、对象寿命覆盖读者访问两个前提。把所有操作改为 seq_cst 也不能修复发布后继续无同步修改普通字段的问题,更不会自动防止指针所指对象被回收。
边界与取舍 CAS 成功是读改写,失败则只进行读取并更新 expected,因此失败内存序不能使用 release 或 acq_rel。weak 允许伪失败,重试时必须依据最新 expected 重算 desired,并避免把不可重复的外部副作用放进循环。C++17 与 C++20 的释放序列定义有差异,教学先用直接匹配的发布读取建立证明;更复杂算法应逐项核对所采用版本与操作序列。
进阶依据:C++20 草案 N4861:原子内存序 · C++ 工作草案:原子操作与比较交换 · WG21 P2530R3:Hazard Pointers · WG21 P2545R4:Read-Copy Update
面试自测 先说结论,再补条件与反例 基础复习 Q1 relaxed 计数器何时合适?参考答案 只需要计数本身原子更新、不用它证明其他数据已就绪时,可以考虑 relaxed fetch_add。若计数值作为对象发布或资源回收依据,就必须分析跨对象同步关系。
☆ 收藏 基础复习 Q2 CAS 为什么常写循环?参考答案 compare_exchange 在状态已被其他线程改变时失败,weak 还允许伪失败。循环需更新期望值并重新计算新值,还要考虑 ABA、重试成本和内存回收,不能只替换一条赋值。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 data=42 后 release 写 ready,读者 acquire 读 ready,什么条件下才能安全读 data?参考答案 读者必须实际读到这次 release 发布的值,或读到标准允许的相应释放序列中的值,才能把发布前的 data 写入同步到后续读取。若读到初始 false,不能据此访问尚未发布的数据;若多个生产者反复写相同 true,还需版本或独占协议区分哪次数据。一次性示例还要求发布后没有并发改写 data,且它在读取完成前仍存活。可把协议限定为单写者、单次发布、不可变数据,逐条画出 happens-before。跨架构压测与检测器能发现部分错误,但正确性来自这些前提和同步关系,不能由一次运行结果倒推。
答到这些关键点 acquire 实际读取匹配发布 重复相同标志需要版本协议 发布后不可变与对象保活 ☆ 收藏 边界反例 Q4 无锁栈的头指针从 A 变 B 又变回 A,CAS 为什么可能成功却仍错误?版本号够吗?参考答案 CAS 比较的是当前表示是否等于 expected,不会记录中途历史。线程早先根据 A 读取的 next 可能已经过期,而头地址再次为 A,使基于旧关系的更新错误成功,这就是 ABA。地址加版本可检测许多历史变化,但要考虑版本回绕和组合更新是否具有所需原子性。它也不能阻止线程在 CAS 前解引用已被释放的 A,所以 ABA 检测与安全回收是两层问题。需要同时使用合适的结构协议和成熟回收机制,测试暂停读者、快速弹出复用及回绕场景;仅让 CAS 更强或改 seq_cst 都不能完成这两种修复。
答到这些关键点 相同表示掩盖状态历史 版本回绕与组合原子性 ABA 与释放后访问分别解决 ☆ 收藏 工程场景 Q5 原子链表删除节点后想立即 delete,如何在 hazard pointer 与 epoch 回收间作取舍?参考答案 立即 delete 不安全,其他线程可能已读到节点地址但尚未完成访问。hazard pointer 的读者需发布保护并重新确认来源仍指向该节点,删除方先退休节点,再依据协议确认没有保护者后回收;epoch 类方案按读者进入退出阶段延迟回收,读路径可能更轻,但长期停顿读者可能阻止大量退休节点释放。C++17/20 项目应使用经过验证的库而非只拼几条原子指令。选择时测读写比例、扫描成本与退休内存上界,并测试线程停顿、退出及停机。算法的系统级进展保证也不等于每个线程都不会饥饿。
答到这些关键点 保护后重新确认来源 慢读者影响退休内存 成熟协议与进展保证边界 ☆ 收藏
动手验证 实现一次性发布示例,写出 release 与 acquire 建立关系的条件。
+ 我的笔记 用自己的话解释这个知识点
S6-05 / REVIEW NOTES ○ 标为掌握
死锁、锁顺序与并发容器 死锁的经典必要条件是互斥、占有并等待、不可抢占、循环等待。工程中固定全局锁顺序、使用多锁算法、缩短临界区以及避免持锁调用外部代码,可减少死锁机会。超时能帮助恢复或诊断,但不自动证明协议正确。
标准容器的并发保证应分情况:结构不变的只读访问、不同元素修改(vector<bool> 等例外)、以及插入删除等结构修改。共享元素内部若指向同一外部状态,仍可能冲突。读取 map 时 operator[] 可插入,不能当只读操作。
代码片段 / 原理示意
线程 A:先锁 account_1,再锁 account_2
线程 B:也按固定账户编号从小到大加锁
或用 std::scoped_lock 一次管理两把适用互斥量。
DEEP DIVE / 从结论到推演 机制推演 锁的范围应由不变量决定。例如账户转账要求扣款与入账在同一个提交步骤保持总额不变,分别锁住两个赋值并不足够。多锁同时持有时采用稳定顺序或适用的多锁算法,还必须处理两个参数实际指向同一账户的情况。并发容器则需区分结构和元素:独立元素的写入可能合法,但 size、桶、链和迭代器有效性仍受结构修改影响。
边界与取舍 锁顺序表只覆盖显式锁还不够:锁内调用回调、日志库、析构或等待 future,可能引入隐藏的反向依赖。递归互斥量只允许同一线程再次取得自身锁,不能消除跨线程循环等待,也可能掩盖重入时不变量尚未恢复的问题。死锁诊断应同时画锁等待和任务等待边,超时重试只能提供恢复机会,不能自动证明全系统最终会推进。
进阶依据:C++20 草案 N4861:容器数据竞争要求 · C++ 工作草案:多锁算法 · C++ Core Guidelines:并发与锁
面试自测 先说结论,再补条件与反例 基础复习 Q1 死锁与活锁、饥饿区别?参考答案 死锁是相互等待无法推进;活锁是线程不断改变或重试却无业务进展;饥饿是某些任务长期拿不到资源而其他任务仍推进。要结合线程栈、重试率与队列等待分析。
☆ 收藏 基础复习 Q2 vector 不扩容时并发 push_back 安全吗?参考答案 不能仅凭不扩容保证。push_back 仍修改 size 等共享结构状态,多个线程会竞争。可预先 resize 后分配互不重叠的元素范围,且确保无其他共享冲突,再进行并发写入。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 双账户转账按账户编号加锁,除了顺序一致,还必须证明哪些条件?参考答案 编号必须稳定且能给不同账户确定一致顺序,账户对象在持锁和转账期间必须存活。若来源与目标是同一账户,应单独处理,不能把同一非递归 mutex 当两把锁传给多锁操作。取得所需锁后统一检查余额、额度及数值溢出,再完成扣款入账,避免检查与提交之间被其他转账改变。可能抛出的记录或通知应在不破坏账务不变量的位置处理,最好先准备后提交并在锁外执行外部回调。测试反向转账、同账户、余额不足和故障注入,同时验证总额及账户约束,而不只验证程序没有卡住。
答到这些关键点 稳定顺序与账户保活 同一互斥量特殊处理 跨账户不变量与失败路径 ☆ 收藏 边界反例 Q4 vector 已 reserve 足够容量,多线程 push_back 是否安全?预先 resize 后分片写呢?参考答案 reserve 只改变容量,不消除 push_back 对 size 及构造位置等共享结构状态的修改,多个线程同时追加仍不能据此获得安全。预先 resize 建好元素,再让各线程只写互不重叠的已有元素,可以在满足标准容器并发要求时成立,但期间不能再执行会改变结构或失效引用的操作。vector<bool> 是例外,因为不同逻辑元素可能共享位存储;元素内部若指向同一外部对象,也需另行同步。工作完成后通过 join 等同步再汇总结果。验证应按元素范围及别名关系审查,不能只看是否发生扩容。
答到这些关键点 capacity 不保护 size 更新 已有独立元素与结构修改区别 vector<bool> 和外部别名例外 ☆ 收藏 工程场景 Q5 服务所有业务锁都按编号排序,仍偶发卡死,应怎样检查隐藏依赖?参考答案 保存所有线程栈,标出每个线程持有的锁、正在等待的锁,以及等待的 future、队列空位或线程结束事件,再构造完整等待图。常见隐藏边是锁内回调重入另一模块,或持锁等待某任务,而该任务需要原锁才能完成。先把不需保护状态的外部调用移到锁外,对提交前后需要一致的字段使用快照和复验;不能仅把 mutex 改成 recursive_mutex。增加锁持有时长与任务依赖日志,复现回调、停机及队列满的组合。超时报警有助于抓现场,但解除单次等待后仍需修复形成环的协议。
答到这些关键点 等待图包含任务与队列依赖 锁内回调与析构隐藏边 超时诊断不替代协议修复 ☆ 收藏
动手验证 为双账户转账写固定锁顺序,并检查相同账户的特殊情况。
+ 我的笔记 用自己的话解释这个知识点
S6-06 / REVIEW NOTES ○ 标为掌握
阻塞、非阻塞与 I/O 复用 阻塞描述一次调用在条件未满足时是否等待;同步/异步描述完成结果由谁在何时交付,两个维度不能简单画等号。非阻塞 socket 暂时无数据通常返回 EAGAIN/EWOULDBLOCK,不能把它当永久断开。
select/poll/epoll 让线程等待多个描述符的就绪事件。就绪不等于业务消息完整,也不保证后续一次读写处理全部数据。Linux select 有 fd_set 的使用限制,poll 传数组,epoll 维护关注集合并返回就绪项;实际成本取决于连接数、活跃比例和处理工作。
代码片段 / 原理示意
事件等待 → 处理就绪连接 → 非阻塞读写 → 更新状态机
消息边界由协议长度字段、分隔符或固定长度规则决定。
DEEP DIVE / 从结论到推演 机制推演 就绪通知表示某类操作现在有机会不阻塞,不表示完整业务请求已经到达,也不替应用完成数据搬运。事件返回到实际读取之间,其他线程可能先消费数据,因此事件驱动代码仍需非阻塞调用并处理 EAGAIN。非阻塞 connect 返回 EINPROGRESS 时也尚未连接成功,收到完成相关事件后还要读取 SO_ERROR 判断结果,随后才能进入正常收发状态。
边界与取舍 文件描述符整数是可复用索引,不是永久连接身份;dup 出的多个描述符又可能引用同一个 open file description。旧事件、超时任务或工作池结果若只携带 fd,就可能在关闭重开后作用到新连接。应用应使用连接对象及代数标识,并规定注销、关闭和在途回调回收的顺序。非阻塞模型解决等待方式,仍需缓冲上限、消息分帧和过载控制。
进阶依据:Linux recv(2):非阻塞与返回值 · Linux connect(2):非阻塞连接及 SO_ERROR · Linux close(2):描述符复用 · Linux poll(2):就绪语义
面试自测 先说结论,再补条件与反例 基础复习 Q1 为什么非阻塞 I/O 仍需要事件等待?参考答案 如果无数据就立即重复 read,会忙轮询消耗 CPU。通过复用机制等待有进展机会的连接,再执行非阻塞操作,并在暂不可继续时回到事件循环。
☆ 收藏 基础复习 Q2 多路复用适合什么场景?参考答案 很多连接大部分时间等待网络、每次处理可短小执行时适合。CPU 密集工作不应长期占据事件循环,可交给有界工作池,并设计背压避免队列无限增长。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 epoll 已报告可读,为什么 recv 还可能返回 EAGAIN?该连接应关闭吗?参考答案 不应因此关闭。就绪状态与随后调用不是一个不可分割的事务,另一个执行者可能已经读走数据,状态也可能在两步之间变化;非阻塞 recv 的 EAGAIN 或 EWOULDBLOCK 表示当前暂不能继续。事件循环应保留连接和解析状态,停止本轮读取并等待下一次可推进机会,区分 EINTR、真正错误与流式 EOF。最好让一个明确拥有者负责同一连接的读取,减少竞争和解析状态分裂。测试可安排两个消费者交错或分段到达数据,确认不会误报断连;同时检查读取长度大于零,避免把零长度请求的返回值当作对端关闭。
答到这些关键点 就绪与操作之间可变化 EAGAIN 不是永久错误 EOF 解释依赖流类型和请求长度 ☆ 收藏 边界反例 Q4 关闭 fd=42 后新连接又得到 42,旧工作任务怎样避免把响应发给新用户?参考答案 不能只按整数 fd 查当前连接并发送,因为编号复用会把旧任务错配给新身份。任务应携带稳定连接标识和代数,回到拥有连接的事件线程后先核对仍是同一代且处于可发送状态,再把响应加入该连接的发送队列。若任务持有连接对象,也要保证其关闭状态受同一协议保护;对象活着不代表 fd 仍可用。注销事件与 close 后还可能有已经取出的旧事件,需标记失效并延迟回收被引用的数据。测试快速断连重连、迟到超时和晚到任务结果,核对响应只落到原请求对应的连接代数。
答到这些关键点 fd 数字不等于连接身份 回到拥有者核对代数状态 已取出的旧事件仍需处理 ☆ 收藏 工程场景 Q5 十万连接中少数客户端极慢,事件循环如何避免缓冲耗尽与所有连接一起变慢?参考答案 为每连接设置读写缓冲上限,同时设置全局在途字节和任务预算,不能仅限制连接数。协议解析先验证长度字段、上限及加法溢出,再决定是否分配正文;慢接收者的发送队列达到高水位后暂停上游生产,超过期限则按业务断开。CPU 重任务交给有界工作池,结果返回仍服从连接存活与代数检查。监控事件循环延迟、每连接积压、全局字节量和拒绝率,混合快慢客户端压测,确认单个连接不会无限占用内存或每轮处理时间。背压解除后需明确恢复读取或生成响应的调度路径。
答到这些关键点 每连接与全局字节预算 分配前验证协议长度 背压恢复必须可调度 ☆ 收藏
动手验证 为一个长度前缀协议画出读头、读体和处理三态。
+ 我的笔记 用自己的话解释这个知识点
S6-07 / REVIEW NOTES ○ 标为掌握
epoll 的 LT、ET 与部分读写 LT 在就绪条件持续满足时可反复通知,易于渐进处理。ET 更依赖状态变化通知,通常必须配合非阻塞描述符,读取直到 EAGAIN 才能确信当前可读数据耗尽;accept 队列也要按类似思路处理。
发送可能只完成部分字节,应维护待发送缓冲与偏移,暂不可写时订阅 EPOLLOUT,清空后取消不必要写事件以免空转。读取返回零表示流式连接对端有序关闭读方向,错误、半关闭和应用协议结束应分别处理。
代码片段 / 原理示意
循环 recv:n>0 → 累积并解析;n==0 → 处理 EOF;
errno==EINTR → 重试;EAGAIN/EWOULDBLOCK → 回事件循环;
其他错误 → 记录并关闭相应连接。
面试自测 先说结论,再补条件与反例 基础复习 Q1 ET 为什么推荐读到 EAGAIN?参考答案 边沿通知不会因为旧数据仍留在缓冲区就保证重复提醒。读到暂不可读才能完成当前就绪处理。还需处理消息分帧、异常和公平性,不能只把循环 read 当完整服务端。
☆ 收藏 基础复习 Q2 为什么一直订阅 EPOLLOUT 容易高 CPU?参考答案 普通 socket 往往大部分时间可写,持续关注会反复唤醒却无待发送内容。只在缓冲有未发送数据且需要等待可写时订阅,清空后移除。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 ET 每次最多读取 64 KB 就返回事件循环,怎样同时保证公平性和不漏读?参考答案 达到配额时不能把连接当作已耗尽,只能视为本轮主动让出。保持它在应用层就绪集合中,并将其放到本地队列尾部,按轮转继续读取;只有读到 EAGAIN、EOF 或终止错误才按相应状态移出。新收到的内核事件应去重合并,避免同连接重复入队;本地还有待处理项时,epoll_wait 不能使用会无限睡眠的超时。还要给 accept、解析和写入设置相应预算,防止公平性只覆盖 read。测试一个持续高速连接与大量小请求并存,检查小请求尾延迟和高速连接总字节数,确认没有等待新边沿造成停滞。
答到这些关键点 提前让出后保留就绪状态 本地轮转继续而非等新边沿 去重与事件等待超时策略 ☆ 收藏 边界反例 Q4 给连接设置 EPOLLONESHOT 后,是否就无需锁,也无需考虑 fd 复用?参考答案 不是。ONESHOT 只限制该关注项的后续事件报告,处理者完成后要用 MOD 重新武装;它不排除其他线程经任务队列、超时回调或直接句柄访问连接状态。若在工作者尚未完成时重新武装,还可能把处理权提前交给另一工作者。需要明确唯一处理者状态、重武装时机和关闭协议,并让事件携带安全的连接身份。旧事件即使已从关注集合移除,也可能已存在于用户态事件批次中。验证处理、重武装、超时关闭和迟到结果的全部交错,不能把事件机制的保证扩张成对象寿命及所有字段的线程安全。
答到这些关键点 ONESHOT 只暂停事件报告 处理权交接与重武装时机 用户态旧事件及对象寿命 ☆ 收藏 工程场景 Q5 发送 1 MB 响应时只写出一部分,对端又关闭写方向,服务端应如何推进?参考答案 保存已发送偏移和剩余缓冲,继续非阻塞发送;遇到 EAGAIN 时关注可写,缓冲清空后取消无需求的写关注,避免空转。对端关闭写方向只表示它不再发送,不能据此断定它不接收响应;应先排空已接收数据并按协议决定是否发完再关闭。收到真正发送错误时终止该连接,Linux 可按接口使用 MSG_NOSIGNAL 或统一信号策略处理 SIGPIPE。还要设置发送积压及截止时间上限。用极小 socket 缓冲、慢读对端和半关闭测试,核对字节顺序、偏移推进及最终关闭时机,并区分内核接受与应用确认。
答到这些关键点 部分写偏移与可写订阅 半关闭的方向性 SIGPIPE 与发送预算 ☆ 收藏
动手验证 用分段发送模拟消息头与消息体跨多次 recv,验证解析完整性。
+ 我的笔记 用自己的话解释这个知识点
S6-08 / REVIEW NOTES ○ 标为掌握
线程池、背压与优雅停机 线程池复用工作线程减少频繁创建成本,但必须限制任务队列,否则过载只会从请求延迟变成内存耗尽。队列满时可拒绝、超时、让上游等待或按业务降级;策略应与调用链截止时间一致。
停机协议通常先停止接收新任务,标记关闭并唤醒等待者,再决定排空或取消待执行任务,最后等待工作线程结束并回收资源。异常不能逃出线程入口导致进程终止,应转化为任务结果或统一记录。监控队列长度、等待时间与执行时间比只看线程数有用。
代码片段 / 原理示意
接单 → 有界队列 → 工作者 → 结果/异常
过载 → 拒绝或有限等待
停机 → 停接单 → 唤醒 → 排空/取消 → join
DEEP DIVE / 从结论到推演 机制推演 线程池的容量要同时计算工作线程、排队任务和任务间依赖。到达速率长期高于处理能力时,无界队列只会把过载隐藏成不断增长的等待;有界队列则把拒绝或等待反馈给入口。任务本身的截止时间应包含排队时间,已经超时的任务不应占用执行资源。除队列长度外,还应记录排队、执行、取消及拒绝指标,区分算力不足和阻塞依赖。
边界与取舍 池内同步等待同池子任务会占住执行子任务所需的线程,增加线程数量只能推迟饱和。caller-runs 策略可缓解某些饱和情况,却可能把重工作压到事件线程、持锁调用栈或递归提交路径,必须写进接口契约。停机同样涉及依赖:关闭入口、处置队列、唤醒等待、完成结果通道和 join 需要一致顺序,不能只设置一个 stop 标志就销毁队列。
进阶依据:C++ 工作草案:future 共享状态与就绪 · C++ 工作草案:条件变量 · C++ 工作草案:thread::join
面试自测 先说结论,再补条件与反例 基础复习 Q1 线程池中为什么会出现自我等待死锁?参考答案 若所有工作线程都提交子任务到同一已饱和线程池并等待子任务完成,子任务可能没有线程执行。应避免在池内阻塞等待同池依赖,采用任务图、帮助执行或分离执行资源。
☆ 收藏 基础复习 Q2 如何证明停机没有遗漏等待者?参考答案 列出所有阻塞位置,为每处定义关闭条件和唤醒路径;让等待谓词同时检查任务与关闭标志。测试空队列、满队列、执行中、抛异常和多次关闭等状态,再确保 join 可结束。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 4 个工作线程各提交子任务并等待 future,为什么队列非空却没有进展?参考答案 四个线程都在等待子任务结果,已经没有空闲线程从同一队列取出这些子任务,形成执行资源上的循环等待,不需要任何 mutex 也会卡住。优先把父任务拆成异步续接或任务图,让等待依赖不占用工作线程;也可在明确协议下帮助执行任务,或把阻塞依赖分配到独立资源,但要继续分析交叉依赖。单纯扩成八个线程只能改变触发阈值。测试用屏障让所有父任务同时进入等待,可稳定重现;监控应同时看活跃工作者状态、队列长度和依赖关系,不能把队列有任务误认为系统能推进。
答到这些关键点 执行槽位造成的依赖死锁 续接任务释放等待线程 扩大线程数不消除结构问题 ☆ 收藏 边界反例 Q4 队列满时让提交者同步执行任务,是否总能避免死锁并改善延迟?参考答案 不能。caller-runs 会改变任务执行线程和调用深度:若提交者是事件循环,长任务会阻塞所有连接;若它持有任务还要获取的锁,内联执行可能自锁;递归提交还可能耗尽栈或破坏原有顺序。因此该策略只适用于调用方允许执行该任务且不会引入依赖环的接口。其他场景可明确拒绝、有限等待或按业务降级,等待上限服从请求截止时间。测试应覆盖事件线程提交、持锁提交及递归子任务,并记录提交耗时与线程归属。背压策略应是公开可预期的契约,不能作为隐藏的性能优化。
答到这些关键点 内联执行改变线程与调用深度 事件循环及持锁提交风险 背压策略必须进入接口契约 ☆ 收藏 工程场景 Q5 线程池停机要求已接收任务都有结果,且不能永久等待,如何设计与验证?参考答案 在同一接单协议下切换为关闭状态,使新提交立即拒绝;对已接收任务选择在截止时间内排空,超期未执行者写入明确取消结果。执行中的任务需协作停止或受可取消接口约束,否则无法承诺有界退出。广播所有等任务和等空位的等待者,在锁外等待工作线程,再销毁队列与结果状态。close 应幂等,也不能从工作线程同步 join 自己。用任务编号核对接收集合等于成功、失败和取消集合的并集,并在空队列、满队列、异常和嵌套依赖下反复停机,同时记录未完成任务及每阶段耗时。
答到这些关键点 接收任务集合与终态对账 执行中任务的有界停止前提 唤醒双向等待且避免自 join ☆ 收藏
动手验证 实现最小有界队列,使用随机短任务反复启动和关闭。
+ 我的笔记 用自己的话解释这个知识点
从理解到实践 阶段项目与验收 实现有界任务队列:多生产者、多消费者,满时背压,关闭后唤醒所有等待者并退出。再将非阻塞连接事件接入队列,测试部分读写、断连、停机和过载。
SYSTEM INTERVIEW / 跨知识点综合题 十万连接的事件循环,怎样避免“吞吐高但卡顿”? Linux 服务用 epoll ET 加线程池,连接数上升后少数请求延迟数秒;CPU 不一定满,队列却持续增长。要求解释事件处理、fd 生命周期、队列背压和停机协议,不允许用“多开线程”作为唯一答案。
展开方案与推理 先把时间拆成接入、事件等待、解析、排队、业务执行和写回,按请求 ID 关联 P50/P99、队列长度、每连接缓冲与锁等待。ET 需要非阻塞读取直到暂不可继续,但持续活跃连接如果一直被一个回调处理,会饿死其他连接;设置每轮字节/消息/时间预算后,未排空的连接必须进入用户态待处理队列并继续调度,不能退出后只等下一次边沿。
建立连接对象和 fd 代际:整数 fd 可被系统复用,旧任务不能仅凭旧整数把结果写给新连接。事件与任务持有稳定的连接身份及适用的拥有权/代际校验,由约定线程执行关闭与注册变更。业务线程返回结果到事件线程维护的发送队列,部分写入保留偏移,待发送时才等待可写;接收过快或下游变慢时限制队列与缓冲,必要时暂停读取、拒绝新工作或按截止时间取消。
停机要同时解决事件线程和工作线程的等待关系:停止接收新连接/任务,标记关闭,唤醒阻塞队列与事件等待,按策略排空或取消任务,等在途结果处理完,再销毁连接和共享资源。不要让事件线程持锁等待工作线程,而工作线程又等待事件线程消费结果。优化先控制无界积压,再比较调度预算、分片与线程数的收益;少量连接峰值吞吐不是十万连接负载下的服务承诺。
验收证据 极活跃连接与慢连接同时存在时,其他连接仍获得处理机会。 fd 反复关闭复用、任务晚到均不误写新连接。 过载与停机时队列、缓冲及线程等待有界。 继续追问 追问:ET 只读固定 N 字节就返回,怎样保证剩余数据不“消失”? 剩余数据没有消失,但如果没有新的状态变化,内核不保证再产生你依赖的边沿。达到公平性预算却尚未读到 EAGAIN 时,应把该连接放进用户态就绪队列,之后继续读;队列调度要去重并避免饿死其他连接。也可以选择更容易管理的 LT,但仍应限制每次处理工作并处理非阻塞返回。
追问:用 shared_ptr<Connection> 就能解决 fd 复用吗? 它可以保护 C++ 对象寿命,却不能阻止该对象内的 fd 被提前 close 或重置,也不能证明任务属于当前业务代际。需要把关闭权、代际和任务可执行状态一同纳入协议;旧结果到达时检查连接身份及状态,失败则释放结果。只持一个 shared_ptr 而继续使用单独保存的裸 fd 仍可能写错目标。
追问:拒绝请求会降低成功率,为什么仍要背压? 无界排队在持续过载时只会把可见拒绝变为更慢的超时、内存耗尽和全局失败。按容量与截止时间准入,让可服务的请求保持可预期延迟;向上游返回可识别的过载状态,配合有预算的退避重试。比较有效成功吞吐、尾延迟和资源上限,而不是只统计入口接受了多少请求。
参考依据:epoll ET 与公平性 · Envoy 过载管理
CODE REVIEW / 动手审查 atomic 标志为什么仍可能丢通知? 标志本身没有数据竞争,但消费者仍可能永远睡眠。请给出一次具体交错,而非只说“用 while”。
C++17 · 两线程协议片段 · 先自行判断,再展开核对。
// 共享:atomic<bool> ready{false}; mutex m; condition_variable cv;
// 消费者:
std::unique_lock<std::mutex> lock(m);
cv.wait(lock, [&] { return ready.load(); });
// 生产者未取得 m:
ready.store(true);
cv.notify_one();查看问题、修复与验证 消费者检查到 false 后、尚未完成进入等待前,生产者可能存 true 并通知;若通知先于消费者等待登记,之后没有通知就可能持续等待。谓词 wait 的循环不能补回这个窗口。
常规条件变量协议让读写谓词都在同一互斥量下进行。wait 释放互斥量并进入等待的组合行为,才能与生产者取得锁的动作衔接。
通知不携带持久任务数量;消费队列还需要队列非空/关闭谓词。关闭影响所有等待者时,应按等待关系广播,且在销毁 mutex 和 cv 前确保所有使用者已退出。
修正思路 // 共享 bool ready=false;两侧都使用同一 m。
// 生产者:
{ std::lock_guard<std::mutex> lock(m); ready = true; }
cv.notify_one();
// 消费者:
std::unique_lock<std::mutex> lock(m);
cv.wait(lock, [&] { return ready; });
// 持锁访问受该协议保护的数据。怎样验证 用屏障或测试钩子固定“检查后、等待前”的交错;再验证修正版、虚假唤醒和关闭。ThreadSanitizer 没有报告竞争,并不能证明程序具备进展保证。
这里提供审查练习与验证步骤,不代表已在本机执行 C++、数据库或 GPU 实验。
依据:条件变量等待与通知
本章参考资料与适用版本 正文为原创整理;链接用于进一步核对,不影响离线阅读。C++ 工作草案会持续更新,本文采用已标注的 C++17/20 功能范围。
STAGE 07 / 八阶段学习路线 计算机基础 把语言放回运行环境:线程怎样被调度,内存怎样映射,字节怎样可靠送达,数据怎样在故障后恢复。本阶段按操作系统、网络、数据库、缓存依次展开。数据库以 MySQL 8.4 / InnoDB 为参照,Redis 实现讨论以 7.2 为基线;网络语义以对应 RFC 为依据。
← 回到路线图 抽题练习本章 本章目录 · 按主题分组
S7-01 / REVIEW NOTES ○ 标为掌握
进程、线程与协程:资源和执行分开看 进程是资源隔离的重要边界,具有虚拟地址空间、文件描述符等资源;线程是进程内的执行流。Linux 的同一进程内线程共享堆和全局变量,但各有寄存器上下文、栈及线程局部存储。线程的栈属于共享地址空间,其他线程持有有效地址时也能访问它,‘各有一个栈’不等于栈自动隔离。
协程是在执行过程中主动挂起与恢复的组织方式。C++20 协程是无栈协程,用协程帧保存跨挂起点需要的状态,不自动创建线程,也不自动获得并行性。CPU 密集任务通常需要线程或进程利用多核;大量等待 I/O 的任务适合事件循环配合协程,关键是等待时释放执行线程。
代码片段 / 原理示意
进程:地址空间 + 文件等资源
线程:寄存器上下文 + 栈 + 可调度执行流
协程:挂起状态 + 恢复入口;由线程实际执行
DEEP DIVE / 从结论到推演 机制推演 把一个请求拆为等待网络、解析、计算和等待下游四段,只有可运行段实际消耗执行线程。协程挂起保存的是继续执行所需状态,恢复仍必须由某个线程驱动;线程池的任务数、可运行线程数与 CPU 同时执行数是三个不同指标。进程边界保护地址空间,线程边界组织调度,协程边界组织异步控制流,因此三者可以组合而不是互相替代。
边界与取舍 异步接口若在底层等待同步 DNS、磁盘或互斥锁,仍可能占住事件循环线程。把所有工作改成协程不能消除这些阻塞;应把不可异步的长操作放到有界工作池,并保持取消与对象生命周期一致。协程暂停期间局部引用可能指向已经销毁的请求对象;线程安全与生命周期安全需要分别证明,不能用‘单线程事件循环’覆盖所有问题。
进阶依据:Linux pthreads 共享与线程属性
面试自测 先说结论,再补条件与反例 基础复习 Q1 一个线程崩溃一定只影响自己吗?参考答案 不一定。线程共享进程的地址空间,越界写可以破坏其他线程的数据。Linux 下未处理的致命信号通常终止整个进程,C++ 线程函数逃逸的异常会调用 std::terminate。若需要故障隔离,应使用进程边界、进程监控和明确的通信协议,而不是只增加线程。
☆ 收藏 基础复习 Q2 为什么线程比进程轻量,却不能无限创建?参考答案 线程通常不需要独立复制整个地址空间,但仍需要栈、内核管理结构和调度开销。线程过多会增加切换、缓存失效和锁竞争,并消耗地址空间。设计线程池时应结合 CPU 核数、任务阻塞比例及延迟目标压测;I/O 并发量可以远大于实际执行线程数。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 一个单线程协程服务器能同时处理一万个连接,却不能把一万个 CPU 任务同时算完,为什么?参考答案 连接数量描述活跃状态数,不等于同时执行的指令流数量。网络等待期间协程保存状态并让出线程,事件就绪后再恢复,因此大量空闲连接只占描述符、缓冲区和协程状态。CPU 密集计算如果持续运行且不让出,唯一线程就无法处理其他就绪事件;即使主动切换,单核执行能力也没有增加。要利用多核,应把可并行计算提交到多个执行线程或进程,同时限制队列长度与在途任务数。验证时分别统计连接数、可运行任务、事件循环延迟和 CPU 利用率,而不是只看连接压测成功。
☆ 收藏 边界反例 Q4 前述服务只运行一个事件循环,为什么仍会出现悬空引用和业务竞态?参考答案 没有线程同时执行,并不代表跨挂起点的状态不会改变。任务 A 取得订单状态后等待 I/O,任务 B 可以在此期间修改或删除订单;A 恢复后使用的旧判断已经失效,引用也可能悬空。应让跨挂起点的数据具有明确所有权,必要时保存值或共享生命周期,并在提交动作时重新检查版本。不能长期持有阻塞锁跨越挂起点,否则其他需要该锁的任务可能卡住执行器。测试应人为延迟 I/O,在挂起期间触发取消、删除与更新,检查最终状态和资源释放,而非仅运行顺序请求。
☆ 收藏 工程场景 Q5 服务改为协程后平均延迟下降,但每分钟出现一次两秒停顿,怎样定位?参考答案 先用事件循环心跳确认停顿发生在调度线程,再关联线程栈、CPU、系统调用和应用分段耗时。高 CPU 时检查长循环、序列化或压缩;低 CPU 时检查同步 DNS、文件操作、锁等待和同步日志。若某类操作阻塞,移入有界工作池,避免无限提交把等待转移成内存膨胀;CPU 任务可拆分或并行处理。保留超时和取消传播,记录排队与执行时间。用包含原来周期任务的持续压测验证 P99、最大事件循环延迟和队列长度,不能只比较平均值就宣布修复。
☆ 收藏
动手验证 在 Linux 测试进程内创建两个线程,打印线程 ID、局部变量地址和共享变量地址;用 ps -L 查看线程,再解释哪些地址属于同一地址空间。
本节依据:资料 1
+ 我的笔记 用自己的话解释这个知识点
S7-02 / REVIEW NOTES ○ 标为掌握
进程间通信:复制、共享与同步 管道适合有亲缘关系的进程传输字节流;命名管道可以让独立进程通过路径相遇;Unix 域 socket 支持本机双向通信,网络 socket 可跨主机。消息队列带消息边界;共享内存让多个进程映射同一存储区域,减少数据复制,却要求双方约定布局、生命周期和并发访问协议。
选型时先问数据规模、生产消费速度、是否跨机器以及是否需要故障恢复。共享内存不是天然安全队列:一个进程在更新长度后崩溃,另一个可能读到未完成内容。需要版本号、状态机或事务式提交,并使用适合进程共享的同步原语;普通进程私有 mutex 不能仅靠放到共享区就保证跨进程可用。
代码片段 / 原理示意
大块数据:共享映射 → 明确所有权 → 同步/提交标记
命令与结果:Unix socket → 长度字段 → 请求 ID → 超时处理
DEEP DIVE / 从结论到推演 机制推演 共享内存队列可以把每个槽位理解为所有权状态机:空闲、生产者写入、已提交、消费者读取、可复用。数据写完之后才能发布提交标记,消费者确认提交后才能读取内容,读完才可归还槽位。双方必须约定进程共享同步、布局版本和索引范围;若直接把进程内裸指针写进共享区,另一进程的映射基址不同就会失效,通常应使用相对偏移。
边界与取舍 零复制常以更复杂的所有权和故障协议为代价。生产者在写一半时死亡,消费者必须区分未提交内容与完整消息;进程重启后还要避免把旧槽位当作新数据。跨进程原子能力及对齐需要按平台验证,不能只因 C++ 类型写着 atomic 就假设所有实现均支持任意共享映射。中小消息用 socket 换取更清楚的边界,往往比自行实现无锁共享队列更稳妥。
进阶依据:Linux mmap 映射与共享语义
面试自测 先说结论,再补条件与反例 基础复习 Q1 共享内存没有 read/write,为什么仍然需要锁或原子操作?参考答案 共享只解决双方访问同一份字节的问题,没有定义写入先后与读取时机。消费者可能看到新长度和旧内容的组合;多个生产者也可能覆盖同一槽位。应设计互斥或具有正确发布、获取语义的协议,并处理崩溃中断更新、槽位回收和进程重启。
☆ 收藏 基础复习 Q2 管道读不到数据时会怎样?参考答案 取决于阻塞模式和写端状态。阻塞读通常等待数据;非阻塞且还有写端时可返回 EAGAIN;全部写端关闭且缓冲已读空时读返回零,表示 EOF。必须关闭不再需要的描述符副本,否则一个遗留写端会使读端一直等待,难以判断上游已经退出。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 设计单生产者单消费者共享内存队列,怎样保证消费者不会看到新长度和旧内容?参考答案 生产者先取得空槽位,写入长度与负载,完成后再发布可读状态;消费者先观察已提交状态,再读取负载,最后发布槽位可复用。发布和获取之间需要正确的同步关系,不能只用 volatile。共享索引应满足目标平台的进程共享原子或同步原语要求,并避免索引绕回时混淆代际。槽位保存偏移而非其他进程不可用的裸指针,长度须先检查边界。测试应在写入各阶段暂停生产者、随机切换执行和循环复用槽位,验证内容校验值,不能只用一次顺序收发证明协议正确。
☆ 收藏 边界反例 Q4 生产者已经取得槽位但写一半就崩溃,消费者应直接跳过这个槽位吗?参考答案 不能无条件跳过。消费者需要区分生产者只是慢、已经死亡,还是新进程正在恢复同一槽位。可为槽位设置代际、提交状态和生产者会话标识,由明确的恢复者在确认旧会话失效后回收未提交数据;已提交消息则按协议继续消费或重放。若存在业务副作用,还需要消息 ID 和幂等记录,因为读取后崩溃可能造成重复处理。直接靠超时覆盖槽位会与迟到写入冲突。设计应明确允许丢失、重复还是阻塞,并用杀进程实验验证每个状态转换,不能把故障策略留给读端猜测。
☆ 收藏 工程场景 Q5 图像进程每秒传输数百 MB,socket 占用大量 CPU;如何评估换共享内存是否值得?参考答案 先分解 CPU 消耗,确认主要成本确实是复制与序列化,而非解码或锁竞争。可采用共享图像缓冲池加小型 socket 控制消息,控制消息携带偏移、长度、格式和代际,消费完成后归还缓冲。限定池容量形成背压,定义消费者死亡后的回收机制,防止映射泄漏。对比两方案的吞吐、端到端 P99、CPU、峰值内存与恢复时间,包含小图、超大图和慢消费者。若传输不是主要瓶颈,保留 socket 更易维护;若收益明显,再承担共享状态与异常恢复的复杂度。
答到这些关键点 先证实复制瓶颈 数据与控制通道分工 慢消费者与崩溃验收 ☆ 收藏
动手验证 实现父进程向子进程发送三条带长度前缀的消息;分别测试正常关闭、只关闭部分写端以及非阻塞读取,记录返回值。
本节依据:资料 1 · 资料 2
+ 我的笔记 用自己的话解释这个知识点
S7-03 / REVIEW NOTES ○ 标为掌握
CPU 调度:公平、吞吐和响应时间 先来先服务实现简单,但长任务可能让短任务排队;短作业优先能改善平均等待,却需要预测运行时间且可能饿死长任务;时间片轮转让任务轮流运行,时间片太小会增加切换成本,太大又损害交互响应。优先级调度还需考虑低优先级饥饿和优先级反转。
真实 Linux 需结合调度类和内核版本分析。普通任务追求加权公平,实时策略有不同规则;较新内核的公平调度逐步采用 EEVDF,不能把历史 CFS 描述当作所有版本的固定实现。负载高不等于 CPU 都在工作:任务还可能等待磁盘、锁或可运行队列中的执行机会。
代码片段 / 原理示意
性能诊断顺序:CPU 利用率 → runnable 数量 → 上下文切换 → 阻塞位置 → 锁等待
DEEP DIVE / 从结论到推演 机制推演 调度延迟是任务已经可运行到真正获得 CPU 的等待时间,与任务执行时间不同。线程数量超过可用 CPU 容量时,运行队列和切换开销可能增加;任务还会因共享缓存、内存带宽竞争变慢。现代 Linux 公平调度要结合内核版本理解,EEVDF 使用公平欠账与虚拟截止时间组织选择,但业务请求的真实截止时间不会自动成为内核调度参数。
边界与取舍 线程池容量不能只抄物理核数。容器 CPU 配额、亲和性、SMT、NUMA、阻塞比例与临界区都会改变有效并行度。增加线程能掩盖部分等待,也可能在配额耗尽后一起被节流,造成周期性长尾。应把业务队列等待、线程 runnable 等待与实际执行耗时分别观测;平均 CPU 不高仍可能有单核热点或配额周期问题。
进阶依据:Linux EEVDF 调度设计
面试自测 先说结论,再补条件与反例 基础复习 Q1 线程池大小等于 CPU 核数是否总是最好?参考答案 不总是。纯 CPU 任务可从接近可用核数开始测试;如果任务经常等待 I/O,可以适当增加线程或改为异步处理。还要考虑容器 CPU 配额、任务粒度、内存带宽和锁竞争。以吞吐、P95/P99 延迟、CPU 利用率和切换次数一起评估,不能只看总任务数。
☆ 收藏 基础复习 Q2 什么是优先级反转?参考答案 高优先级线程等待低优先级线程持有的锁,而中优先级线程又不断抢占后者,使高优先级任务迟迟不能继续。可以缩短临界区、避免高优先级路径依赖低优先级资源,或使用支持优先级继承的互斥机制;提高所有线程优先级并不能消除依赖关系。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 四核机器上把 CPU 线程池从四个扩大到四十个,吞吐为什么可能下降?参考答案 如果任务主要做计算,四十个线程仍共享有限执行资源,却增加调度、上下文保存和缓存工作集切换。任务同时访问共享数据时还会增加锁竞争与缓存行迁移,内存带宽瓶颈也不会因线程变多消失。应先确认有效 CPU 配额与任务粒度,再从接近可用核数的配置扫描测试,记录完成吞吐、CPU 时间、切换次数与尾延迟。若任务存在较长 I/O 等待,更多线程可能有收益,但应根据等待比例实测,并把等待型与计算型工作分池,避免用一个极大线程池掩盖不同瓶颈。
☆ 收藏 边界反例 Q4 监控显示 CPU 只有 40%,为什么仍可能是调度导致的 P99 抖动?参考答案 整体平均值可能掩盖某个核心始终满载,事件循环或锁持有者只能在该核上运行;也可能监控时间粒度过粗,忽略容器在短配额窗口内迅速耗尽预算后的节流。线程还可能长期 runnable 却排不上 CPU,而其他核因亲和性或 NUMA 策略不能分担。应查看逐核使用率、调度等待、cgroup 节流统计和任务亲和性,再关联慢请求时间线。只有证据指向这些因素,才调整配额、绑核或线程数;否则低 CPU 更应排查锁和外部 I/O,不能只凭平均值判断。
☆ 收藏 工程场景 Q5 同进程的批量报表使在线接口尾延迟翻倍,不能加机器,怎样隔离?参考答案 先确认两类任务竞争的是 CPU、内存带宽、磁盘还是锁,并分别标记队列和执行耗时。对 CPU 竞争,可给批量任务独立小工作池、限制并发和每轮工作量,并在在线压力升高时暂停准入;必要时用进程与 cgroup 建立更明确资源边界。若共享数据库或同一把锁才是瓶颈,仅分线程池没有用,应限制下游并发或缩短临界区。验收同时报告在线 P99 与报表完成时间,确保没有把在线收益换成批处理永久饥饿;使用持续混合负载验证限额与恢复行为。
☆ 收藏
动手验证 用同一 CPU 密集任务测试不同线程数,记录耗时与上下文切换。再加入等待操作,比较最佳线程数如何变化,注明机器核数和配额。
本节依据:资料 1 · 资料 2
+ 我的笔记 用自己的话解释这个知识点
S7-04 / REVIEW NOTES ○ 标为掌握
死锁:画出等待图再解决 经典资源死锁需要互斥、占有并等待、不可抢占和循环等待同时成立。线程 A 持锁 X 等待 Y,线程 B 持锁 Y 等待 X,是最直观的环。实际工程也会发生跨层等待:主线程持锁等待任务结束,任务又等待主线程释放该锁,虽然表面上只有一把 mutex,也能形成等待环。
预防方法包括统一加锁顺序、一次取得所需多把锁、把外部调用移出临界区以及减少共享状态。超时能暴露问题,但超时重试必须回滚部分状态,否则可能变成活锁。排查时保留全部线程栈,建立线程到资源、资源到持有者的关系,而不是只查看 CPU 占用最高的线程。
代码片段 / 原理示意
A → 等待锁 Y → B → 等待锁 X → A
破环策略:所有路径统一先 X 后 Y;或 std::scoped_lock(X, Y)
DEEP DIVE / 从结论到推演 机制推演 等待图不仅包含 mutex,还应包含线程结束、任务队列槽位、连接池许可和远程响应。主线程持锁等待 future,任务要取得该锁才能完成,形成与两把锁相同的环。统一锁顺序只能覆盖遵守同一资源排序的路径;隐藏的回调和同步等待可能引入新边。审查时把‘谁拥有资源、谁等待谁、谁能释放’逐项写出,才能找全环。
边界与取舍 超时不会自动恢复对象的不变量。线程已经修改一半共享状态后放弃第二把锁,必须撤销或完成第一部分修改,否则避免了死锁却损坏数据。try_lock 重试若所有参与者保持相同步调,会持续释放又争抢形成活锁。退避与随机抖动能改善进展概率,但对硬性正确性还需要无环协议;数据库事务重试则应以完整事务为单位。
进阶依据:MySQL 8.4 死锁处理与事务重试
面试自测 先说结论,再补条件与反例 基础复习 Q1 用了 recursive_mutex 就不会死锁吗?参考答案 不会。递归互斥量允许同一线程重复取得同一把锁,只解决特定的自重入问题。两线程以相反顺序取得两把锁仍会死锁;持锁等待另一个线程结束也可能产生环。递归锁还可能掩盖状态不变量尚未恢复就重入的设计问题,应先梳理调用关系。
☆ 收藏 基础复习 Q2 数据库死锁和 C++ 锁死锁如何应对?参考答案 共同点是并发资源依赖形成环,都应缩短占锁时间并统一访问顺序。数据库可检测死锁并选择事务回滚,因此业务需要整笔事务的有限重试和幂等设计。普通 C++ mutex 通常不会替程序自动回滚对象状态,不能仅照搬数据库的异常重试做法。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 只有一把 mutex,程序为什么仍然可以死锁?参考答案 死锁需要循环等待,不要求存在两把不同的 mutex。例如线程 A 持锁后等待线程 B 的 future,B 完成任务前必须取得该锁,于是 A 等 B,B 等 A;单线程执行器内同步等待排在自己后面的任务,也会形成无法前进的依赖。应把锁、线程结束和调度资源统一加入等待图。修复通常是把等待移出临界区、改为异步续接,或让被等待任务不依赖持有者资源。仅换 recursive_mutex 无效,因为 B 是另一个执行流;还要测试异常退出与取消路径是否重新引入等待环。
答到这些关键点 等待资源不限锁 future 与调度依赖 等待移出临界区 ☆ 收藏 边界反例 Q4 所有加锁都设置 100 毫秒超时,能否认为系统不会出现死锁问题?参考答案 它可能让等待不再无限持续,却没有证明业务有进展和状态正确。超时后若线程保留其他资源继续重试,依赖环仍可存在;若部分更新已执行,简单返回会破坏业务不变量。多个线程同步重试还会形成活锁并放大负载。应明确取得资源失败后的释放、回滚和幂等策略,限定重试总预算并加入适当退避。正确协议仍应优先消除循环依赖。验证不仅检查请求最终返回,还要检查转账总额、队列项目数等不变量,以及持续竞争时是否每个参与者都能取得进展。
☆ 收藏 工程场景 Q5 线上进程几乎不耗 CPU,所有请求超时,重启恢复;怎样保留证据并修复?参考答案 重启前在可行情况下获取全部线程栈、锁等待、任务队列和连接池状态,连续采样判断栈是否稳定不变。构建等待图,寻找持锁者是否等待 future、外部 I/O 或资源许可;不要只查看某个请求线程。复现具体资源顺序后,缩小临界区或调整为无环流程,并增加有界超时作为保护。修复测试应强制交错到原来的等待点,检查成功、取消和异常路径。上线后观察阻塞线程数、队列年龄和超时率;重启只是清除现场,不能作为证明死锁已经消失的验收。
☆ 收藏
动手验证 画出两个转账任务以相反顺序锁账户的等待图;用统一账户 ID 顺序或同时锁定消除环,并检查失败路径是否释放全部资源。
本节依据:资料 1
+ 我的笔记 用自己的话解释这个知识点
S7-05 / REVIEW NOTES ○ 标为掌握
虚拟内存、缺页与页面置换 虚拟地址通过页表映射到物理页,TLB 缓存近期地址转换。虚拟地址连续不代表物理页连续;进程看到同一数值地址,也不代表访问同一物理内容。地址空间隔离、按需分配、文件映射和写时复制都依赖这些机制。malloc 成功通常只说明分配器返回了可用范围,不代表每页都已经驻留内存。
缺页既可能是合法的按需映射,也可能触发文件读盘,还可能是非法访问。内存紧张时系统回收页面;干净文件页可丢弃后重读,脏页可能需要写回。LRU 是理解局部性的模型,真实内核采用近似和分代等机制。工作集长期超过可用内存会产生频繁回收和重新装入,表现为抖动。
代码片段 / 原理示意
虚拟地址 → TLB/页表 → 物理页
合法缺页:分配或调入后重试指令
非法访问:信号/异常处理路径
DEEP DIVE / 从结论到推演 机制推演 从数组访问到物理内存至少涉及地址转换、权限检查和缓存层次。TLB 未命中通常先进行页表遍历,不等同于缺页;合法映射尚未建立所需物理页时才进入缺页处理。首次写入匿名页可能分配实际页面,写时复制则在共享映射被修改时复制页面。把 TLB miss、minor fault、major fault 和非法访问分开,才能正确解释延迟和内存指标。
边界与取舍 减少缺页可以通过预触页或锁页等方式换取更高启动成本和驻留内存,但不能无限占用系统资源。大页可能改善 TLB 覆盖,却也改变分配、回收与写时复制成本。NUMA 上物理页面所在节点影响访问延迟,虚拟地址连续并不能保证靠近执行核。优化应根据工作集、访问模式与尾延迟目标选择,不能把更大的页或更多预分配视为普适答案。
进阶依据:Linux mmap 映射 · Linux fork 写时复制说明
面试自测 先说结论,再补条件与反例 基础复习 Q1 为什么分配了 1 GiB,RSS 却未立即增加 1 GiB?参考答案 操作系统和分配器可能先保留虚拟地址范围,在实际访问时才建立对应物理页。共享零页、延迟分配、文件映射和分配器缓存也影响观测。因此应区分虚拟空间、RSS、共享页和提交量;逐页写入后再观察,才能理解实际驻留成本。
☆ 收藏 基础复习 Q2 fork 为什么不立即复制父进程全部内存?参考答案 典型 Linux fork 使用写时复制,让父子最初共享可共享的物理页,同时具有独立的虚拟映射视图。某一方写入时触发相应页面复制。这样减少启动时复制,但父进程持续修改大量页面时,后台子进程仍可能导致显著内存放大,不能把 fork 认为零成本。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 TLB miss、缺页异常和段错误之间是什么关系?参考答案 TLB miss 表示地址转换缓存没有命中,处理器或系统需要查询页表;若页表已有合法有效映射,通常可完成转换而不发生需要操作系统补页的缺页处理。缺页可能由合法的按需分配、写时复制或文件页调入触发,处理成功后指令继续执行。若地址不属于有效映射或违反访问权限,系统可能向进程发送 SIGSEGV 等信号。诊断时应结合缺页计数、I/O 和错误地址,不能把每次 TLB miss 都算成一次磁盘访问,也不能因为出现缺页就断言程序越界。
☆ 收藏 边界反例 Q4 给全部内存预触页后,是否就能消除请求中的内存长尾?参考答案 不能。预触页主要把首次建立映射与分配页面的成本提前,后续仍可能发生回收、NUMA 远端访问、缓存未命中、分配器锁竞争和写时复制。预触过多还会占满内存,挤掉其他服务工作集;如果预触线程与实际工作线程不在同一 NUMA 节点,甚至可能建立不理想的物理布局。应测量请求阶段具体停顿来源,在内存预算内预分配高频固定缓冲,并让回收策略可控。验证需包含稳态压力与内存竞争,不能只比较进程启动后第一轮请求。
☆ 收藏 工程场景 Q5 服务 RSS 在后台快照期间突然翻倍,写请求延迟也变高,怎样解释和处理?参考答案 若快照通过 fork 创建子进程,初始共享页会在父进程继续写入时触发写时复制;写入范围越广、快照越慢,保留的旧页和复制成本可能越高。先关联 fork 时间、写速率、子进程持续时间、缺页及 RSS/PSS 指标,避免把共享页重复统计误认为完全私有增长。处理可以预留峰值内存、减少快照期间无必要写放大、降低快照持续时间或调整持久化策略。不能仅调低监控阈值。以真实写入分布做快照压测,验收峰值内存、P99 与恢复能力。
答到这些关键点 fork 写时复制 区分 RSS 与共享统计 峰值容量验证 ☆ 收藏
动手验证 测试程序先分配大数组但不访问,再每页写一个字节,比较 /proc/self/status 的 VmSize 和 VmRSS;仅在有足够资源的测试环境运行。
本节依据:资料 1 · 资料 2
+ 我的笔记 用自己的话解释这个知识点
S7-06 / REVIEW NOTES ○ 标为掌握
用户态、内核态与系统调用成本 用户态程序受权限与地址访问限制,需要内核提供文件、网络、调度等服务时,通过系统调用进入内核。系统调用包含受控入口、参数检查和必要的状态保存;它可能很快返回,也可能让线程等待资源。库函数与系统调用并非一一对应,例如缓冲输出可能累计多次库调用后才执行一次 write。
从用户态进入内核态不等于切换到另一个进程。只有调度到其他任务时,才涉及任务上下文切换;后者还可能影响缓存和地址转换状态。优化可以减少小粒度调用、批量处理和复制次数,但须保持正确的背压、错误处理与延迟约束。零拷贝通常表示减少某些复制步骤,而非整个路径完全没有复制。
代码片段 / 原理示意
应用缓冲区 → 库函数 → 系统调用入口 → 内核服务
无竞争锁:用户态快路径
竞争锁:可能通过 futex 等机制等待/唤醒
DEEP DIVE / 从结论到推演 机制推演 一次调用的成本可分为用户态封装、权限切换、内核实际工作、数据复制与可能的睡眠调度。只有观察到等待和调度证据,才应把延迟归因于上下文切换。futex 类机制把无竞争路径尽量留在用户态,竞争时才请求内核等待或唤醒,因此锁的平均成本与竞争分布强相关;只测空临界区单线程循环不能预测线上加锁延迟。
边界与取舍 批处理把多次固定开销摊薄,但会形成等待队列;零复制减少特定路径的复制,却可能延长缓冲区被内核或设备使用的时间。调用返回、数据传输完成和业务可见是三个完成点。采用异步或零复制接口时必须为缓冲区设计完成通知与回收机制,并处理部分成功和取消,不能因少了一次 memcpy 就省掉生命周期管理。
进阶依据:Linux futex 用户态快路径 · Linux write 完成语义
面试自测 先说结论,再补条件与反例 基础复习 Q1 系统调用和普通函数调用有什么不同?参考答案 普通函数调用通常在同一权限级别按 ABI 转移控制;系统调用通过专门入口请求内核服务,内核检查参数和权限,并可能阻塞当前线程。其成本不仅是入口指令,还包括实际工作、复制和调度。不能用一个固定纳秒数描述所有系统调用。
☆ 收藏 基础复习 Q2 为什么批量发送能提升吞吐,却可能增加延迟?参考答案 批量处理减少每条消息重复付出的调用、封装和调度成本,但为了形成批次需要等待,单条消息从产生到发送的时间可能变长。设计时可同时限制批量大小和最大等待时间,并记录吞吐及尾延迟;对交互请求和后台日志应采用不同参数。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 为什么系统调用次数下降一半,CPU 却没有明显下降?参考答案 系统调用次数只是频率指标,不代表它们占据主要成本。一次调用可能处理更多数据,复制、协议处理或设备等待仍然存在;程序也可能把成本转移到批量构造、锁竞争或序列化。应先用采样与分段计时确认用户态、内核态及等待占比,再比较每请求 CPU 时间、字节数和吞吐。若系统调用原本只占很小比例,减少一半也不会产生很大总收益。只有在相同业务负载和延迟约束下,总成本确实下降,才能认为优化有效,不能把调用计数本身当作最终目标。
☆ 收藏 边界反例 Q4 异步发送已经返回成功,为什么复用发送缓冲区会导致数据错乱?参考答案 异步接口返回通常只说明操作被接受或排队,设备或内核可能仍在读取原缓冲区;立即写入新内容会让前一个操作观察到被改写的数据。具体允许复用的时刻必须按接口的完成语义判断,有些接口复制数据后可早释放,有些需要完成事件。解决方法是每个在途操作持有独立或引用计数缓冲,收到成功、失败或取消完成后统一归还池。超时并不自动意味着底层停止访问。测试应人为延迟传输并快速复用内存,用校验值检查内容,覆盖取消与部分完成路径。
☆ 收藏 工程场景 Q5 日志逐条写入拖慢接口,要求最多延迟 50 毫秒且进程异常尽量少丢,怎样设计?参考答案 先确认业务允许的丢失窗口与真正持久性要求,再使用有界日志队列和按大小或时间双阈值刷写的工作线程。满队列时明确选择阻塞、降级或丢弃低等级日志,不能无限占内存。把写入操作系统与刷盘分开:批量 write 降低调用成本,fsync 策略决定崩溃持久边界,但其延迟不能承诺绝对实时。关键审计若要求强持久,应单独走可确认路径。测量日志队列年龄、接口 P99、丢弃数及异常退出恢复结果,保证 50 毫秒目标不是只在低负载下成立。
答到这些关键点 批量大小和时间双阈值 write 与持久化区分 队列溢出策略 ☆ 收藏
动手验证 在 Linux 用 strace -c 比较逐字符无缓冲写入和批量写入文件的系统调用次数;不要只比较一个很小样本的总耗时。
本节依据:资料 1 · 资料 2
+ 我的笔记 用自己的话解释这个知识点
S7-07 / REVIEW NOTES ○ 标为掌握
TCP 建连、关闭与 TIME_WAIT 典型 TCP 三次握手是 SYN、SYN+ACK、ACK,双方交换初始序列号并确认对方发送能力。序列号参与区分新旧数据和重传数据;建立连接并不代表业务认证、TLS 握手或数据库事务已经完成。服务端的半连接与已完成连接队列管理属于操作系统实现,需要结合具体系统参数讨论。
TCP 是全双工,双方发送方向可分别关闭,因此常见关闭包含两次 FIN 及对应确认;ACK 与 FIN 可以合并,不能断言抓包一定看到四个独立包。主动关闭一方通常进入 TIME_WAIT,保留状态以处理关闭重传并降低旧报文影响新连接的风险;同时关闭等路径还需分别分析。
代码片段 / 原理示意
客户端 服务端
SYN(seq=x) ---------->
<---------- SYN(seq=y), ACK(x+1)
ACK(y+1) ---------->
业务数据与应用确认随后进行
DEEP DIVE / 从结论到推演 机制推演 TCP 状态变化反映双方字节流方向是否可用,业务状态机则反映请求有没有执行。客户端建立连接、发送请求、服务端提交事务、响应返回是四个不同事件;网络可在任意相邻事件之间断开。尤其是服务端已经提交但响应丢失时,客户端得到超时却不能判定失败。应通过稳定请求 ID 和结果查询把未知状态收敛,不能从 FIN、RST 或连接关闭直接推导业务回滚。
边界与取舍 TIME_WAIT 保护连接关闭与序列空间复用,减少它的占用需要理解端口、连接方向和内核行为;不能套用过时参数粗暴关闭保护。长连接减少握手,但增加空闲连接、失效检测和连接池管理。连接可用性检查也只是某一时刻观察,真正发送时仍可能失败,因此连接池必须保留失败重试与业务幂等边界,而非把探活成功当作后续调用保证。
进阶依据:RFC 9293 TCP 状态与关闭
面试自测 先说结论,再补条件与反例 基础复习 Q1 为什么不能把第三次握手省略成两次?参考答案 服务端需要确认客户端已经收到自己的序列号及握手响应,才能完成双方初始状态的确认。两次交互会让服务端仅凭收到 SYN 和发出响应就认为连接建立,对旧请求与状态同步的处理不充分。回答应围绕双向序列空间和状态确认,而不是笼统说‘三次更安全’。
☆ 收藏 基础复习 Q2 CLOSE_WAIT 很多通常查哪里?参考答案 CLOSE_WAIT 表示本端已经收到对方 FIN,但本地应用尚未完成自己的关闭流程。应检查是否遗漏 close、是否某条异常路径泄漏连接、工作线程是否卡住,以及连接对象的所有权。直接调整 TIME_WAIT 参数通常解决不了这个问题,因为两者处在不同的状态转换阶段。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 订单请求超时,同时 TCP 连接被重置,能否认定订单未创建?参考答案 不能。重置可能发生在请求到达前,也可能发生在服务端事务提交后、响应传回前;客户端仅知道没有取得可确认结果。应使用客户端生成且重试时保持不变的请求 ID,服务端以唯一约束或原子事务记录业务结果,客户端先查询或用同一 ID 重试。只有明确的业务失败响应或查询结果才能帮助收敛状态。若新生成 ID 直接重试,就可能重复下单。测试需分别在接收前、提交前、提交后和响应中途断开连接,验证结果一致,而不是只模拟服务器完全不可达。
答到这些关键点 传输失败不等于业务失败 稳定幂等标识 提交前后故障注入 ☆ 收藏 边界反例 Q4 既然连接重建可重试,是否可以对任何接口自动重发最后一条请求?参考答案 不可以。读取接口可能允许重试,具有副作用的接口则需要业务幂等协议;即使 HTTP 方法声明幂等,也要检查实际实现、条件头和资源版本。重试还必须受剩余截止时间与次数预算约束,否则服务恢复时会形成请求风暴。对部分发送状态不能猜测服务端收到多少业务内容,协议解析器应只执行完整且校验通过的消息。推荐区分确定未发送、结果未知、明确失败与明确成功四种状态,逐类制定策略。重连解决传输通道,不能自动修复业务状态。
☆ 收藏 工程场景 Q5 短连接客户端大量报端口耗尽,服务端正常,怎样改善而不破坏连接语义?参考答案 先确认错误发生在客户端临时端口分配还是服务器监听队列,查看连接四元组分布、TIME_WAIT 数量和每秒新建率。若确为高频短连接,优先复用长连接或连接池,并限制池大小与最大在途请求;池需处理服务端关闭、空闲超时和请求取消。必要时评估可用本地地址和端口范围,但不能靠不明参数绕过安全复用规则。压测应保持同等请求速率,比较端口占用、握手成本、P99 和连接失效后的恢复,确保复用没有引入请求串线或无界排队。
☆ 收藏
动手验证 在本地测试客户端和服务端中分别执行正常关闭、半关闭和异常退出,使用抓包与 ss 对照状态;画出每种情况下应用可继续读写的方向。
本节依据:资料 1
+ 我的笔记 用自己的话解释这个知识点
S7-08 / REVIEW NOTES ○ 标为掌握
TCP 可靠字节流与应用分帧 TCP 利用序列号、确认、重传、校验和及乱序重组提供有序字节流服务,但保留的不是应用 send 的边界。发送两条消息,接收端可能一次收到合并内容,也可能多次收到一条消息。应用必须定义固定长度、分隔符或长度前缀等分帧规则,并处理半包、超大长度和缓冲区上限。
write 或 send 成功只表示数据被本地协议栈接收了相应数量,不证明对端业务已经处理。网络在对端提交之后、响应到达之前断开,客户端无法只靠 TCP 判断是否成功。需要请求 ID、幂等键、状态查询或明确事务协议;可靠传输和业务恰好执行一次是不同层次的问题。
代码片段 / 原理示意
帧格式示例:4 字节网络序长度 + 消息体
接收缓冲:不足 4 字节 → 等待
长度超限 → 关闭/报错
不足完整帧 → 等待;完整帧 → 提取并继续解析
DEEP DIVE / 从结论到推演 机制推演 流式解码器应维护独立读写游标与协议状态:先读取固定大小头,检查长度与版本,再等待完整负载,最后一次性提交消息。缓冲中可能同时有上一帧尾部和下一帧头部,因此每次 recv 后应循环提取零到多条消息。解析状态与业务执行状态分开,可以在消息尚未完整时只消耗有限缓冲,避免半包触发部分业务副作用。
边界与取舍 长度前缀需要防止整数溢出、超大声明长度、慢速逐字节输入与压缩后膨胀。限制单帧大小仍不够,还应限制每连接累计缓冲、未完成帧等待时间和全局连接预算。一次 send 或 recv 都可能只处理部分字节;非阻塞 EAGAIN 表示现在不能继续,不是永久错误。关闭连接时要说明未完整帧如何丢弃、已经提交的请求如何查询。
进阶依据:Linux send 部分发送 · Linux recv 流读取
面试自测 先说结论,再补条件与反例 基础复习 Q1 recv 返回零和 EAGAIN 有什么区别?参考答案 对通常的 TCP 流读取,在请求读取正长度数据时,返回零表示对端进行了有序关闭且已有数据读完。非阻塞读取返回 EAGAIN 或 EWOULDBLOCK 表示当前暂时没有可读数据,不是连接结束。还应处理 EINTR、其他错误和部分读取,不能把所有非正返回统一当作断线。
☆ 收藏 基础复习 Q2 重试支付请求怎样避免重复扣款?参考答案 给业务操作分配稳定的幂等键,在服务端将幂等记录和扣款状态放入一致的事务约束中。重复请求返回已存在结果或查询状态,而不是再次执行副作用。超时只能说明客户端没有及时取得结果;仅检查 TCP 是否断开,无法判断服务端事务是否已经提交。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 发送一千字节,send 返回四百,接下来怎样做才正确?参考答案 记录已接受的四百字节,从原缓冲偏移四百继续发送剩余六百,不能重发整个消息,也不能把四百视为失败而丢弃。非阻塞场景遇到 EAGAIN 时保存剩余缓冲和偏移,等待可写再继续;EINTR 等情况按接口语义处理。缓冲必须活到全部字节被接受或操作终止。即使全部发送完,也只是本地传输层接受了数据,不证明对端业务完成。需要应用响应确认业务结果,并用明确请求 ID 处理断线后的未知状态。测试应主动限制发送缓冲和接收速度以触发部分写入。
答到这些关键点 部分写入维护偏移 可写事件继续 传输接受与业务完成分离 ☆ 收藏 边界反例 Q4 已限制每帧最多 1 MiB,攻击者为什么还能耗尽服务器内存?参考答案 他可以建立大量连接,每个连接声明接近上限的帧并极慢发送,导致服务端为未完成消息保留大量缓冲;还可能持续发送合法帧让业务队列积压。应按实际收到数据逐步分配,限制每连接与全局缓冲、活跃连接、待处理消息数,并设置读入进度和总请求期限。若允许压缩,还要限制解压后大小。达到上限时暂停读取或关闭连接,把背压传播回发送方。验证应覆盖很多慢连接与少量高速连接混合负载,观察正常用户尾延迟及资源上界,而非只测单帧越界。
☆ 收藏 工程场景 Q5 上线后偶发两条消息被当成一条,如何构造稳定复现和修复验收?参考答案 先保留接收字节流与解析状态,确认代码是否把一次 recv 当作一条消息。编写不依赖网络的增量解码测试,对同一串多帧数据枚举分割点,再用随机小块、一次整块和跨帧混合输入,预期输出顺序与内容完全一致。修复应循环解析完整帧,保留未完成尾部,并验证长度和溢出。另测试零长度、超限、截断关闭及坏头。最后通过真实 socket 调整发送节奏复验。不能仅靠增加 sleep 或固定 recv 缓冲大小解决,因为分段方式不属于应用可依赖的协议边界。
答到这些关键点 不依赖 recv 边界 分割组合测试 保留尾部与循环解析 ☆ 收藏
动手验证 编写长度前缀解码器,把同一字节序列按每次 1 字节、随机小块、一次全部输入,验证产生完全相同的消息;补测长度超限与截断。
本节依据:资料 1 · 资料 2
+ 我的笔记 用自己的话解释这个知识点
S7-09 / REVIEW NOTES ○ 标为掌握
流量控制与拥塞控制 流量控制保护接收端:接收窗口 rwnd 表达接收缓冲可承受的数据量。拥塞控制保护网络:拥塞窗口 cwnd 根据网络反馈约束在途数据。发送方可用窗口还受已经发送但未确认的字节等因素影响,不能只根据带宽或 socket 缓冲大小决定持续发送速度。
经典 Reno 模型用慢启动、拥塞避免、快速重传和快速恢复理解反馈控制;实际系统可能使用 CUBIC、BBR 等其他算法,不能把每次丢包后的窗口变化写成通用定律。性能分析需要 RTT、丢包率、吞吐和带宽时延积;仅扩大缓冲可能造成队列积压并拉长交互延迟。
代码片段 / 原理示意
粗略在途约束:unacked ≤ min(rwnd, cwnd)
带宽时延积 BDP ≈ 带宽 × RTT
窗口远小于 BDP 时,链路可能吃不满
DEEP DIVE / 从结论到推演 机制推演 流控约束接收端可承受的数据,拥塞控制约束网络可承受的在途量,应用背压约束业务处理速度。三层可能依次传播:处理慢使读取变少,接收缓冲被占满,接收窗口缩小,发送端写入受阻。若应用不断把 socket 数据搬进无界业务队列,网络窗口看似正常,内存和业务延迟却持续增长,所以应用不能把 TCP 流控当作全部容量管理。
边界与取舍 增大缓冲能提高高带宽高时延链路利用率,也可能把拥塞隐藏为更长排队。交互流量与批量传输的目标不同;最大吞吐不一定对应最好的 P99。经典 Reno 的窗口变化只是某种算法模型,不能套用到所有内核拥塞算法。判断应结合 RTT、重传、发送受限原因和应用队列,区分接收方太慢、网络拥塞和服务端计算不足。
进阶依据:RFC 5681 拥塞窗口与经典算法
面试自测 先说结论,再补条件与反例 基础复习 Q1 接收窗口很大,为什么下载仍然慢?参考答案 接收窗口只排除了部分接收端限制,发送端还可能受拥塞窗口、丢包、RTT、应用生产速度、磁盘或加密计算限制。应先确认瓶颈所在层,再检查重传、在途数据和服务端指标。盲目继续增大 socket 缓冲通常无法解决网络拥塞或应用处理不足。
☆ 收藏 基础复习 Q2 超时重传和快速重传如何区分?参考答案 超时重传依赖重传计时器到期;经典快速重传通过重复确认等反馈推断某段可能丢失,可以在计时器到期前重传。实际实现还可能结合 SACK、时间信息等机制。回答时要说明具体算法,不能把任何重复 ACK 都理解为必须立即重传。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 TCP 已有流量控制,应用为什么还需要有界队列?参考答案 TCP 流控主要约束接收缓冲,不知道消息进入业务队列后还要等待多少计算和下游请求。如果读取线程不断清空 socket 并把消息放入无界队列,接收窗口持续开放,生产速率高于消费速率时,内存与等待时间仍会无限增长。应限制在途请求或队列总字节,达到上限时暂停读取、拒绝新请求或降低上游速率,让压力真正反馈到发送方。容量应按处理能力和截止时间设置,并监控队列年龄而不仅是长度。这样才能避免‘网络很顺畅、业务全超时’的假象。
☆ 收藏 边界反例 Q4 扩大 socket 缓冲后吞吐上升,但 P99 变差,是否说明实现错误?参考答案 不一定。更大缓冲可以容纳更多在途数据并提高链路利用率,也可能允许积累更多排队字节,慢消费者因此更晚获得处理机会。应分开观察排队时间、传输时间与处理时间,比较同等到达率和数据大小下的结果。若目标是交互请求,可限制队列和每连接在途量,给批量任务独立通道或更低并发;如果是离线传输,接受更高延迟可能合理。验收必须同时设定吞吐和尾延迟约束。只凭吞吐增加判定成功,或只凭 P99 上升判定代码错误,都忽略了明确的取舍。
☆ 收藏 工程场景 Q5 跨地域大文件上传吞吐低,RTT 80 毫秒、链路 1 Gbps,怎样判断窗口是否太小?参考答案 先估算带宽时延积:1 Gbps 乘 0.08 秒约为 80 Mbit,即约 10 MB。若可用在途窗口远低于这个量,即使没有丢包也难以填满链路。但仍需检查实际带宽、接收窗口、拥塞窗口、重传和应用供数速率,不能直接把所有缓冲设成十兆。再确认磁盘、TLS 和读取线程不是瓶颈,逐步调整并测量吞吐、RTT 与内存。若扩大窗口后 RTT 大幅增加,可能只是积累队列;应结合拥塞反馈和限速实验,而不是无限增加缓冲。
☆ 收藏
动手验证 在隔离测试环境为连接加入延迟和丢包,记录吞吐、RTT 和重传率;比较固定大窗口与应用层限速对尾延迟的影响。
本节依据:资料 1
+ 我的笔记 用自己的话解释这个知识点
S7-10 / REVIEW NOTES ○ 标为掌握
HTTP 语义、缓存与协议版本 HTTP 方法表达资源操作语义:GET 用于获取表示,PUT 通常表示用请求表示替换目标状态,POST 由目标资源定义处理方式。安全性与幂等性是协议语义属性,不等同于加密。状态码区分成功、重定向、客户端错误和服务端错误;缓存用新鲜度控制与条件请求减少传输,ETag 可用于表示验证。
HTTP/1.1 常见持久连接上仍需处理响应顺序;HTTP/2 用二进制帧和多路复用降低应用层排队,但共享 TCP 连接的丢包仍会影响多个流的交付。HTTP/3 基于 QUIC,使流间可靠交付更独立,但仍共享网络拥塞资源。升级协议不能代替消除后端慢查询、过大响应或错误缓存策略。
代码片段 / 原理示意
缓存有效:直接复用
缓存过期且有 ETag:If-None-Match → 304 或新内容
HTTP/2:多个流 / 单一 TCP
HTTP/3:多个 QUIC 流 / UDP 承载
DEEP DIVE / 从结论到推演 机制推演 HTTP/2 把不同请求的帧交错放入同一 TCP 字节流,流 ID 解决应用消息归属,但 TCP 仍按字节序交付。丢失一个 TCP 段会让其后的字节暂时不可交付,即使那些字节属于其他流。HTTP/3 使用 QUIC 的独立流交付减少这种跨流阻塞,但连接级拥塞窗口、服务器执行器和数据库池仍可能成为共享瓶颈,因此传输层改进不等于服务完全隔离。
边界与取舍 幂等是相同请求重复执行的预期资源效果,不是‘绝不产生任何重复日志’;缓存则必须匹配方法、身份、Vary 和新鲜度约束。带用户权限的响应如果错误进入共享缓存,即使内容本身合法也会泄漏数据。协议重试还应处理请求体是否可重放、截止时间和服务端已执行的未知状态,不能在代理层不区分语义地重试全部请求。
进阶依据:RFC 9113 HTTP/2 流与流控 · RFC 9114 HTTP/3
面试自测 先说结论,再补条件与反例 基础复习 Q1 幂等请求能否无条件自动重试?参考答案 幂等表示重复相同请求的预期效果与一次相同,不代表没有日志、计费等附带行为,也不代表任何时机重试都合适。客户端还要考虑超时预算、退避、负载放大和身份有效期。对非幂等业务应先增加幂等键或状态查询,再决定重试机制。
☆ 收藏 基础复习 Q2 HTTP/2 是否彻底消除了队头阻塞?参考答案 它通过流复用改善了 HTTP/1.1 顺序响应造成的应用层阻塞,但多个流共享一个 TCP 字节流;传输层丢失数据时,后续字节不能越过缺口交付,仍会影响多个流。HTTP/3 改变了这个传输结构,但连接级拥塞和服务器资源争用依然存在。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 HTTP/2 开了多路复用,为什么丢包会让多个无关请求同时卡住?参考答案 多路复用发生在 HTTP 帧层,不同流的帧最终编码到同一个有序 TCP 字节流。TCP 在缺少前面字节时不能先交付后面的字节,因此一个丢失段可能延迟多个流,虽然应用层知道它们相互独立。HTTP/3 依靠 QUIC 的流级可靠交付减少这种跨流等待,但仍共享连接拥塞控制。定位时应把网络重传与后端处理时延关联,确认卡顿是否同步发生在传输端;若数据库连接池耗尽,换 HTTP/3 不能解决。不能把一个协议特性解释成整个请求链路没有队头阻塞。
答到这些关键点 HTTP 流与 TCP 字节流分层 QUIC 改进边界 后端共享资源 ☆ 收藏 边界反例 Q4 代理只重试 GET,是否就保证没有副作用重复或信息泄漏?参考答案 仍不能保证。应用可能错误地用 GET 执行状态变更,重试会放大这种设计问题;即使服务遵守安全语义,日志、计量和限额也可能变化。代理还必须保留认证与缓存隔离,不能把用户 A 的响应错误复用给用户 B。请求重试应受总时间预算、次数和退避限制,缓存键应考虑影响表示的头和身份策略。测试要覆盖带授权请求、重定向、Vary 和服务端处理后连接断开。方法语义提供设计约束,但不能替代验证实际应用与代理配置是否遵守它。
☆ 收藏 工程场景 Q5 切到 HTTP/2 后长下载拖慢短查询,如何判断该拆连接还是改服务调度?参考答案 先按请求分解服务端排队、首字节、传输与完成时间,查看重传、连接窗口、流窗口和服务器并发限制。若所有流因 TCP 丢包同步停顿,可评估 HTTP/3 或隔离大流量连接;若短查询在服务端迟迟未开始,应调整执行器、公平调度或下游连接池;若客户端不消费某流导致连接级流控受限,应修正读取策略。拆连接会增加握手和资源占用,不能无界扩张。用大小请求混合负载比较短请求 P99、大文件吞吐与连接数,证明改动命中了实际瓶颈。
☆ 收藏
动手验证 用浏览器网络面板观察同一资源第一次 200、命中缓存、条件请求 304 三种结果,记录 Cache-Control、ETag 和传输字节数。
本节依据:资料 1 · 资料 2 · 资料 3
+ 我的笔记 用自己的话解释这个知识点
S7-11 / REVIEW NOTES ○ 标为掌握
HTTPS 与 TLS:身份、密钥和完整性 HTTPS 是在受 TLS 保护的连接上使用 HTTP。TLS 解决通信保密性、完整性与对端身份认证等问题;证书把公钥与身份声明连接起来,客户端验证信任链、域名和有效期。业务数据通常使用协商出的对称密钥保护,因为它适合大量传输,不能简单说‘所有数据都用服务器公钥加密’。
TLS 1.3 握手协商参数并建立共享密钥材料,典型完整握手使用临时密钥交换与签名验证。恢复会话可减少建立成本,0-RTT 早期数据具有重放风险,不能未经额外设计就执行不可重复的副作用。HTTPS 也不能阻止服务端主动泄露数据,或修复应用本身的权限漏洞。
代码片段 / 原理示意
域名验证 + 证书链验证 → 身份可信
握手密钥协商 → 会话密钥
认证加密 → 内容保密与篡改检测
DEEP DIVE / 从结论到推演 机制推演 TLS 的身份认证把‘正在通信的密钥持有者’绑定到预期服务身份,密钥协商再建立本次连接的加密材料;二者不可互相替代。临时密钥交换可提供前向保密的基础,但具体保证取决于所用握手与密钥保管。业务权限仍发生在 TLS 之后:服务器证书验证成功不代表用户有权调用任意接口,mTLS 识别客户端也不自动等于细粒度业务授权。
边界与取舍 TLS 1.3 的 0-RTT 早期数据需要单独考虑重放,不能仅因连接加密就当作请求唯一。TLS 终止在代理时,代理到后端是另一条信任边界,原连接保护不会自动延伸到明文内网。日志、转储和错误回显仍可能暴露敏感内容,因此安全评审需要覆盖证书验证、终止位置、应用权限与数据留存,而非只确认地址栏有 HTTPS。
进阶依据:RFC 8446 TLS 1.3 与早期数据
面试自测 先说结论,再补条件与反例 基础复习 Q1 已经加密了,为什么还要验证证书域名?参考答案 加密只能保证你与某个持有相应密钥的对端通信;如果没有确认其身份,对端可能就是攻击者。证书链和域名检查把连接对象与用户实际想访问的服务绑定起来。跳过验证可以让攻击者提供自己的证书,并建立两段分别加密的中间人连接。
☆ 收藏 基础复习 Q2 为什么 TLS 1.3 的 0-RTT 需要谨慎使用?参考答案 早期数据可能被重放,服务端不能只因传输经过 TLS 就假设请求只执行一次。适合允许重复的读取操作;支付、下单等副作用请求需要服务端幂等控制、反重放设计或拒绝早期数据。降低一个往返延迟必须与业务安全语义一起评估。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 攻击者也能创建加密连接,证书验证究竟增加了什么保证?参考答案 单纯加密只保证你与当前密钥持有者之间的通信不易被旁观者读取,没有证明该持有者就是目标服务。证书链验证和域名匹配将公钥与受信任的身份声明关联,握手再证明对端控制相应私钥。若跳过校验,中间人可以分别与客户端和真实服务器建立加密连接并转发修改内容。还需要检查有效期、用途等实现要求;证书通过后仍要验证业务授权。测试应包含不受信任签发者、错误域名、过期证书和正常证书,不能仅测试能否完成加密握手。
☆ 收藏 边界反例 Q4 使用 mTLS 后,是否可以允许客户端提交任意用户 ID 查询订单?参考答案 不可以。mTLS 通常证明某客户端或工作负载的身份,不直接证明它有权访问任意最终用户的订单。服务器应把证书身份映射到可信主体,再结合租户、角色和资源归属进行授权,不能相信请求参数中的用户 ID。若代理终止 mTLS,身份转发头也只能由可信代理设置并保护,外部同名头应清除。还需处理证书轮换、撤销和权限变化。验收必须测试合法证书请求其他租户资源的场景;传输认证成功与业务越权拒绝应同时成立,而不是二选一。
☆ 收藏 工程场景 Q5 希望用 0-RTT 降低下单首包延迟,如何决定是否启用?参考答案 先确认下单具有不可重复副作用,不能直接把早期数据作为可执行指令。可让边缘拒绝这类 0-RTT 请求并在正常握手后重发,或仅允许可安全重放的读取;若业务必须接收,服务端要有稳定幂等键、原子去重和明确反重放策略,并验证跨节点状态一致。比较节省的往返时间与新增复杂度,很多场景保持连接复用更简单。测试要重复投递相同早期请求、跨节点路由并模拟响应丢失,确保只有一次业务效果。降低延迟不能以‘客户端不会重发’为正确性前提。
☆ 收藏
动手验证 检查一个测试站点的证书链、SAN 域名和 TLS 版本;再对照错误域名证书的失败结果,解释握手失败与 HTTP 4xx 的层次区别。
本节依据:资料 1
+ 我的笔记 用自己的话解释这个知识点
S7-12 / REVIEW NOTES ○ 标为掌握
DNS、连接地址与访问路径 DNS 将域名映射到记录,常见 A/AAAA 对应 IPv4/IPv6 地址,CNAME 表达别名。客户端通常先问递归解析器,解析器查询缓存或沿根、顶级域、权威服务器逐级寻找答案。TTL 控制缓存可复用时间,但各层缓存、负缓存和连接复用会让切换观察复杂化,不能用修改 DNS 记录瞬间切走全部流量。
DNS 可以使用 UDP 和 TCP,现代系统还可通过加密传输;‘DNS 只用 UDP’是错误说法。地址解析完成后,连接还涉及路由、邻居地址解析、代理和负载均衡。HTTP Host 或 TLS SNI 可以让同一 IP 承载多个站点,所以直接访问 IP 不一定等价于访问原域名。
代码片段 / 原理示意
域名 → 本地/递归缓存 → 权威记录 → IP
IP + 端口 → TCP/QUIC
TLS 身份 → HTTP 主机/路径 → 业务响应
DEEP DIVE / 从结论到推演 机制推演 域名解析、连接建立和虚拟主机选择是三个独立阶段。DNS 返回地址后,客户端可能复用已有连接而不再查询;TLS SNI 与 HTTP Host 又把同一地址上的请求路由到不同站点。迁移时观察权威记录已改变,只能证明解析源更新,不能证明递归缓存、进程缓存和连接池都已切换。排障应记录实际连接地址和握手主机名,而非只看一次 nslookup。
边界与取舍 降低 TTL 可缩短部分 DNS 缓存保留时间,却增加解析流量,也不能强制关闭存量长连接。负缓存会影响新建记录首次可见时间,IPv4 与 IPv6 路径可能有不同故障。DNS 失败、TCP 超时、证书错误和 HTTP 404 应分别归类;自动切换到 IP 会改变 SNI、Host 与证书验证,可能掩盖真实问题并引入错误服务访问。
进阶依据:RFC 1034 DNS 缓存与解析 · RFC 7766 DNS TCP 支持
面试自测 先说结论,再补条件与反例 基础复习 Q1 修改 DNS 后为什么一部分用户仍访问旧机器?参考答案 递归解析器或客户端仍可能持有尚未到期的旧缓存,浏览器和连接池也可能继续复用旧连接。还需排除多个权威节点配置不一致和本地 hosts 覆盖。迁移应提前降低 TTL、保留旧节点一段过渡时间,并结合权威查询与客户端实际连接地址排查。
☆ 收藏 基础复习 Q2 有 IP 后为什么浏览器访问仍失败?参考答案 问题可能发生在路由、防火墙、端口监听、TLS 证书、HTTP 虚拟主机或应用层。应逐层检查 DNS 结果、连接建立、握手与 HTTP 状态。直接访问 IP 还可能缺少正确 SNI 或 Host,因此不能据此断言原域名网站宕机。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 DNS 记录已经切到新 IP,为什么进程还持续访问旧机器?参考答案 进程可能仍复用指向旧地址的长连接,根本没有发起新的 DNS 查询;也可能客户端、系统或递归解析器保留旧缓存。应先记录实际连接四元组、连接创建时间和解析结果,再比较权威与递归查询。迁移需要提前调低 TTL、保留旧节点过渡服务,并为连接池设置合理轮换或主动排空策略。不能在修改记录后立刻关闭旧节点,因为 TTL 不控制已有连接的生命周期。验证应从不同客户端网络持续观测新连接去向,并把新建连接与存量连接分开统计。
☆ 收藏 边界反例 Q4 把域名换成解析出的 IP 后访问成功,能否证明 DNS 是唯一故障?参考答案 不能。访问 IP 可能绕过代理、改变目标虚拟主机或跳过正常证书验证,实际访问到的资源未必与原域名相同。要在保持原 Host、TLS SNI 和证书验证语义的前提下仅固定解析地址,才更接近隔离 DNS 变量的实验。还要比较 IPv4、IPv6 和不同递归解析器的结果。若固定 IP 成功而正常域名失败,也可能是缓存陈旧或多地址中的部分节点异常。诊断应控制单一变量并记录握手与 HTTP 状态,不能凭一个不同请求路径的成功直接下结论。
答到这些关键点 控制 Host 与 SNI 单变量实验 多地址与 IPv6 ☆ 收藏 工程场景 Q5 只有部分地区用户打不开网站,解析、连接和页面都可能失败,如何安排排查?参考答案 按地域收集失败阶段和实际目标地址,先比较权威、递归解析及 A/AAAA 记录,再测 TCP/QUIC 建连、TLS 握手和 HTTP 状态。若某地址或 IPv6 路径集中失败,检查路由、节点健康与负载均衡;若证书失败,检查域名匹配、证书链与终止节点;若返回 404,则检查 Host 和路由配置。应保留成功地区作为对照,避免同时改 DNS、证书和应用。临时降级要限定范围并有回滚,最终用多个地区的新旧连接验证,确认不只修复了本地缓存中的偶然路径。
☆ 收藏
动手验证 对测试域名查询 A、AAAA、CNAME 和 TTL,并对比递归解析器与权威服务器结果;画出从域名到最终 HTTP 响应的逐层诊断清单。
本节依据:资料 1 · 资料 2
+ 我的笔记 用自己的话解释这个知识点
S7-13 / REVIEW NOTES ○ 标为掌握
B+ 树、聚簇索引与二级索引 B+ 树用较高扇出控制树高,叶节点顺序组织数据,适合页式存储和范围扫描。InnoDB 聚簇索引的叶节点保存行数据;存在主键时通常由主键组织聚簇索引。二级索引记录索引列以及对应的聚簇键,查到候选项后可能还需访问聚簇索引,这个过程常被称为回表。
覆盖索引让查询需要的列可从所用索引取得,从而减少额外访问;索引仍有存储、维护、页分裂和缓存成本。宽主键会扩大二级索引,随机键可能增加写入局部性问题,但是否采用递增键还取决于分布式生成、热点和业务约束。索引设计应从查询与写入负载共同出发。
代码片段 / 原理示意
二级索引 (email, 主键) → 得到主键 → 聚簇索引 → 行
SELECT email, id:可能覆盖
SELECT email, profile:可能需要回表
DEEP DIVE / 从结论到推演 机制推演 一次二级索引查询可分为沿索引树定位叶页、扫描候选项、按聚簇键取行和执行剩余过滤。覆盖索引减少取行,但无法保证扫描候选很少;一个低选择性覆盖扫描仍可能访问大量索引页。聚簇键宽度会传播到二级索引体积,影响缓存驻留和写入维护。因此评价索引应同时计算访问行数、每项宽度、页局部性与写入频率,不能只看计划里是否出现索引名。
边界与取舍 顺序主键往往改善插入局部性,随机主键可能分散写入,却带来更多页访问与碎片;高并发下热点页、分裂和缓存竞争需要实测。把所有字段放入覆盖索引会扩大索引及更新成本,还可能超过键长度限制。数据库页式 B+ 树的优势来自存储访问模式,纯内存的小数据结构或特殊检索需求可能适合其他结构,不能把 B+ 树当成所有数据系统的唯一答案。
进阶依据:MySQL 8.4 聚簇与二级索引
面试自测 先说结论,再补条件与反例 基础复习 Q1 为什么数据库索引常用 B+ 树,而不是普通二叉搜索树?参考答案 页式存储中一次 I/O 能读取一整页,高扇出让 B+ 树以较少层数定位记录,叶节点顺序结构也适合范围查询。普通二叉树每节点分支少、树高大,逐节点访问可能产生更多随机读取。实际性能还取决于缓存、页大小和数据分布,不能仅比较大 O。
☆ 收藏 基础复习 Q2 给每个查询字段都建索引会更快吗?参考答案 不一定。写入和更新要维护相关索引,额外索引占用磁盘与缓存,也会增加优化器选择空间。低选择性字段的单列索引可能收益很小;合理的联合索引可能覆盖多个查询。应结合真实过滤、排序、投影和写入比例评估,而不是按字段数量机械添加。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 同一个 WHERE 条件,SELECT id 和 SELECT * 为什么可能有明显性能差距?参考答案 如果所用二级索引包含过滤列和主键,SELECT id 可以从索引取得需要的信息,而 SELECT * 常需按每个候选主键访问聚簇记录。候选多且数据页不在缓存时,额外访问会放大 I/O 和 CPU。还要考虑大字段传输与反序列化成本。应比较实际扫描行数、回表量、缓冲命中及返回字节,而非仅看两条语句都使用同一索引。覆盖索引也不是零成本;若候选范围很大,它仍会扫描很多页。优化先收紧查询投影与过滤,再评估是否值得新增覆盖索引。
☆ 收藏 边界反例 Q4 为了避免回表,把整张表所有列都放进联合索引,为什么通常不可取?参考答案 索引项越宽,单页可容纳的条目越少,树和叶页总量增加,缓存效率下降;插入及被索引列更新也要维护更大的结构。大字段还受索引类型和长度限制,并非都能原样加入。实际查询可能只需要少量列,为它们设置有针对性的覆盖索引通常更划算。应统计高频查询收益、写入放大、磁盘和缓冲占用,删除重复或低价值索引。验收不能只看一个查询变快,还需测写入延迟、备份体积和其他查询命中率,避免局部优化拖慢整个负载。
☆ 收藏 工程场景 Q5 订单表把主键从短整数换成长字符串后,查询和写入都变慢,怎样定位?参考答案 先比较表和各二级索引体积、缓冲池命中、页分裂与写入分布,确认主键变宽是否扩大了所有二级索引条目;再区分字符串比较成本、随机插入局部性与业务查询变化。可评估保留短内部聚簇键,把外部订单号建立唯一二级索引,但需承担一次额外定位与迁移复杂度。用真实长度和分布的数据测试,不能只比较少量内存内记录。验收包含查询 P99、批量写入、索引大小及唯一约束正确性,并明确方案是否影响对外 ID、复制与历史数据兼容。
答到这些关键点 主键传播到二级索引 局部性与比较成本 内部键与业务键分离 ☆ 收藏
动手验证 建立测试表的主键和 email 二级索引,对比只查询 email/id 与查询其他大字段的计划,再测量增加索引后批量写入的成本。
本节依据:资料 1
+ 我的笔记 用自己的话解释这个知识点
S7-14 / REVIEW NOTES ○ 标为掌握
联合索引与查询计划 联合 B-tree 索引按列顺序进行字典序组织。对 (a,b,c),先用 a 的等值条件,再结合 b 的范围通常能缩小连续扫描区间;后续列不一定继续缩短同一个查找范围,但仍可能参与索引条件下推、过滤或覆盖。‘范围之后所有列完全失效’过于绝对。
索引可用于定位、排序或覆盖扫描,使用了索引也不一定快。优化器会根据统计信息、估计行数和成本选择计划;!=、IS NULL 或 OR 不能一概判定必然不走索引。对列做函数、隐式类型转换、前导通配符等需要具体分析,最终用 EXPLAIN 与真实运行数据验证。
代码片段 / 原理示意
索引:(tenant_id, created_at, id)
WHERE tenant_id = ? AND created_at >= ?
ORDER BY created_at, id LIMIT 20
观察:访问类型、使用键、估计/实际行数、排序与回表
DEEP DIVE / 从结论到推演 机制推演 联合索引先按第一列排序,再在相同前缀内按后续列排序,因此前缀条件决定能否形成紧凑扫描区间。范围条件之后的列未必继续缩小同一个连续区间,但可能用于过滤、索引条件下推或覆盖。执行计划中的使用索引不代表每个条件都用于定位;应把访问条件、残余过滤、排序与取行分别解释,并用实际行数检查估计是否失真。
边界与取舍 同一 SQL 对不同参数可能命中完全不同的数据比例,固定强制索引可能帮助一个参数却伤害另一个。统计信息陈旧、列间相关性和热点租户都可能误导成本估计。改索引之前先保存原 SQL、参数和数据分布,比较估计行数与实际行数;不要只在均匀测试数据上验证,也不要把一次执行计划当成永远不变的数据库规则。
进阶依据:MySQL 8.4 范围优化
面试自测 先说结论,再补条件与反例 基础复习 Q1 为什么有索引,优化器仍选择全表扫描?参考答案 可能是表很小、筛选后命中比例很高、回表代价大,或统计信息与真实分布偏离。索引存在只提供一个候选路径,并不强制使用。应检查实际选择性、投影列、统计数据及计划,先理解成本,再判断是否需要改写查询或调整索引。
☆ 收藏 基础复习 Q2 深分页为什么慢,如何改进?参考答案 使用大 OFFSET 时,数据库通常仍需找到并跳过前面的记录,偏移越大成本越高。若业务允许,可用上页最后的排序键与唯一键进行游标分页,并建立匹配的联合索引。必须提供稳定、唯一的排序规则,处理并发插入和删除,不能只把 OFFSET 换成一个非唯一时间条件。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 索引为 (a,b,c),a 等值、b 范围、c 等值时,c 是否完全失效?参考答案 不能这样概括。a 等值和 b 范围通常确定主要连续访问区间,c 可能不能把整个区间进一步变成同样紧凑的边界,但仍可在扫描索引条目时参与过滤,或使查询成为覆盖扫描,具体还取决于优化器和条件形式。应查看访问范围、索引条件及残余条件,而不是只数 key_len。若 c 选择性很强且查询模式稳定,可比较 (a,c,b) 的方案,但同时检查排序与其他查询需求。用真实参数测扫描与返回行数,才能判断列顺序变更是否带来整体收益。
☆ 收藏 边界反例 Q4 EXPLAIN 显示走索引,就能排除慢查询与锁范围过大吗?参考答案 不能。走索引仍可能扫描数百万条目、反复回表或产生排序临时结果;更新与锁定读还可能锁住扫描路径涉及的范围。索引名只说明候选访问结构,不说明访问量和等待成本。应检查估计与实际行数、过滤比例、循环次数、排序、锁等待及返回数据量。EXPLAIN ANALYZE 会实际运行支持的语句,需先控制测试成本。对于锁问题还要观察并发会话与锁记录,不能用单线程查询速度代替判断。只有定位到扫描或等待的具体来源,才能选择索引、SQL 或事务边界优化。
☆ 收藏 工程场景 Q5 某租户查询耗时十秒,其他租户只需十毫秒,同 SQL 同索引,怎样处理?参考答案 先比较租户数据量、时间范围和返回量,检查慢租户是否因高度倾斜导致扫描与排序放大,再查看实际计划与估计偏差。可针对租户和排序键建立匹配索引,收紧查询投影,采用稳定游标分页;若业务允许,再分离超大租户或设置查询预算。不要直接强制某索引给所有租户,因为普通租户可能已有更优路径。测试需包含最大租户、普通租户、冷热缓存和不同页深度,记录扫描行数、P99 与写入代价。若主要是锁等待,则应进一步缩短事务而非继续堆索引。
☆ 收藏
动手验证 准备倾斜分布数据,比较不同过滤值的执行计划;实现 OFFSET 与游标两种分页,记录第 1 页和较深页的扫描行数。
本节依据:资料 1
+ 我的笔记 用自己的话解释这个知识点
S7-15 / REVIEW NOTES ○ 标为掌握
ACID 与隔离级别 原子性让事务修改整体成功或按回滚规则撤销;一致性要求事务前后满足约束,也依赖业务代码;隔离性限制并发交错可见的结果;持久性描述提交后的故障承诺。数据库提供事务并不自动证明所有业务不变量成立,例如先检查库存再扣减仍需要正确的并发控制。
SQL 隔离级别包括读未提交、读已提交、可重复读和可串行化。MySQL 8.4 InnoDB 默认可重复读,但实际异常与操作类型相关:普通一致性读、锁定读和写入的行为不同。更高隔离通常增加冲突或等待,不应只按‘越高越好’选择,应从业务不变量和性能目标设计。
代码片段 / 原理示意
库存扣减示例:
UPDATE inventory SET stock = stock - 1
WHERE sku = ? AND stock > 0;
检查受影响行数,再决定订单事务是否继续
DEEP DIVE / 从结论到推演 机制推演 隔离级别限制某些交错,但业务不变量可能跨越多行。两名值班医生都读取‘当前有两人值班’,各自把自己的行改为离岗,写入不同记录不会必然形成直接写冲突,却可能共同违反‘至少一人值班’。这类写偏差说明稳定快照不等于所有业务可串行化;需要让检查与修改保护同一约束,例如锁定共同守卫记录、使用可串行化并处理冲突,或重新设计约束表达。
边界与取舍 MySQL 8.4 InnoDB 的 RR 普通一致性读与锁定读采用不同读取规则,不能把‘第一次 SELECT 的快照’推广为整个事务所有操作的视图。提高隔离可能引入更多等待与重试;降低隔离则要增加业务并发控制。选择时先写出不变量和允许结果,再枚举两个事务的交错,而非先挑一个听起来更强的隔离名称,再假设业务自然正确。
进阶依据:MySQL 8.4 事务隔离级别 · MySQL 8.4 一致性读
面试自测 先说结论,再补条件与反例 基础复习 Q1 事务有一致性,为什么还会超卖?参考答案 ACID 中的一致性并不替应用推导全部业务规则。如果两个事务都先读到库存为一,再独立写入,就可能违反业务预期。可把条件与扣减合并为受约束的更新,或在事务中使用正确锁定及检查;同时加入数据库约束、失败回滚和幂等请求设计。
☆ 收藏 基础复习 Q2 读已提交与可重复读的普通快照读有什么区别?参考答案 在 InnoDB 中,读已提交通常为每次一致性读建立新的快照,可重复读则在同一事务中复用由首次一致性读建立的快照,因此后续外部提交的可见性不同。要另外讨论本事务自己的写入和锁定读,不能推断整个事务的每种语句都读相同历史版本。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 两个 RR 事务各自更新不同的行,为什么仍可能一起破坏跨行约束?参考答案 两个事务可以基于各自看到的旧状态做决定,而最终写入的是不同记录,因此未必发生同一记录的写冲突。例如都判断另一个医生仍值班,然后分别将自己设为离岗,最终无人值班。RR 的稳定普通读视图不能自动保护这个跨行谓词。可以锁定一条共同的班次守卫记录,再重新检查并更新,或采用适当的可串行化方案并重试冲突;若能把约束表达为数据库支持的原子约束更好。测试要用屏障让两个事务同时完成检查再写,检查最终不变量,而不是只测试单行扣减。
☆ 收藏 边界反例 Q4 把普通 SELECT 改成 FOR UPDATE,是否就自动修复上述问题?参考答案 要看锁定了哪些实际记录、是否使用同一事务,以及所有写路径是否遵守协议。若只锁自己的医生行,两事务仍可能各自持有不同锁;若查询条件漏掉未来可能插入的相关记录,也可能没有保护完整约束。可显式锁定共同的班次记录,再在锁内读取当前状态并修改,保证所有相关操作都经过同一守卫。还需统一锁顺序避免死锁,处理死锁回滚和超时。FOR UPDATE 是构造正确协议的工具,不是替业务分析的开关;应画出它覆盖的资源集合并验证并发插入与删除。
☆ 收藏 工程场景 Q5 秒杀库存只能非负,同时请求可能因网络超时重复,如何给出完整方案?参考答案 使用条件更新 stock=stock-1 WHERE stock>0,检查影响行数,并在同一数据库事务中写订单与唯一幂等键,使扣减和业务结果一起提交。相同幂等键再次出现时返回已有结果,不能再次扣减。事务死锁时有限重试整笔事务,网络提交结果未知时先按幂等键查询或重复提交同一操作。缓存库存只用于展示或减压,不能成为最终非负约束。测试同时覆盖高并发、重复键、提交后断线和死锁;验收库存、成功订单数和扣减总量一致,而不仅是接口返回成功率。
☆ 收藏
动手验证 开两个 MySQL 测试会话,在 RC 和 RR 下分别执行读—他人提交—再读;记录普通 SELECT 与 SELECT FOR UPDATE 的差异。
本节依据:资料 1
+ 我的笔记 用自己的话解释这个知识点
S7-16 / REVIEW NOTES ○ 标为掌握
MVCC、锁定读与范围锁 MVCC 通过版本信息和可见性规则,让一致性读访问适合当前快照的历史版本,从而减少读写直接阻塞。InnoDB 使用 undo 等信息重建需要的旧版本;长事务可能让旧版本迟迟不能清理。快照不是把整张表复制一遍,也不是数据库完全不需要锁。
SELECT FOR UPDATE 等锁定读用于读取并保护当前要处理的记录,与普通一致性读不同。InnoDB 的记录锁锁住索引项,gap lock 约束间隙插入,next-key lock 结合记录与前方间隙。具体锁范围取决于索引、查询条件和隔离级别;缺少合适索引可能把锁影响扩大到远超业务预期的范围。
代码片段 / 原理示意
普通 SELECT → 一致性视图 → 可见历史版本
SELECT ... FOR UPDATE → 锁定当前读取路径
范围查询的锁影响:查询条件 + 索引路径 + 隔离级别
DEEP DIVE / 从结论到推演 机制推演 MVCC 读视图决定某个版本对当前一致性读是否可见,历史版本沿 undo 信息重建。锁定读与更新需要处理当前可操作记录,不能简单沿用普通 SELECT 的旧视图。因此同一 RR 事务里混合快照读、锁定读和自己的写入,可能观察到不同于某个单一历史时刻的组合。分析时必须给每条语句标注读取类型与建立快照的时刻,不能只标一个‘RR 事务’就结束。
边界与取舍 范围锁保护的是实际索引区间,唯一键命中、唯一键缺失、非唯一范围和全扫描的锁影响不同,隔离级别也改变间隙锁使用。MVCC 减少普通读写冲突,却不会消除写写冲突、元数据锁与长事务成本。为降低锁冲突应改善访问路径、缩短事务和减少交互等待,但不能在拆事务时破坏原来的业务原子性,需要重新定义提交边界。
进阶依据:MySQL 8.4 一致性非锁定读 · MySQL 8.4 锁与间隙锁
面试自测 先说结论,再补条件与反例 基础复习 Q1 为什么一个只读长事务也可能影响系统?参考答案 它可能持有较旧的一致性视图,使清理过程必须保留后续读可能需要的 undo 版本,从而增加历史积累与存储压力。事务还可能持有其他锁或占用连接。应监测事务持续时间、历史版本积累及锁等待,避免无意打开事务后长时间不提交。
☆ 收藏 基础复习 Q2 WHERE id=5 FOR UPDATE 一定只锁一行吗?参考答案 需要检查 id 是否是可用的唯一索引、目标记录是否存在、隔离级别及实际访问计划。完整唯一键命中现有记录时通常可以缩小为记录锁;范围、缺失记录或非唯一条件可能涉及间隙等不同范围。只看 SQL 表面写了等号不足以确定全部锁行为。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 RR 事务第一次普通 SELECT 读到旧值,随后 FOR UPDATE 可能读到新值吗?参考答案 可能。普通一致性读按事务的快照可见性取得历史版本,而 FOR UPDATE 属于锁定读,需要读取并锁定当前可操作记录,可能等待其他事务提交后看到较新的值。还要考虑本事务自己的修改对后续读取可见。因而不能把 RR 理解成所有语句都冻结在事务开始瞬间。若业务要基于最新状态修改,应在同一事务里使用适当锁定或条件更新,并明确并发协议。验证可用两个会话在首次普通读后提交更新,再对比普通读和锁定读结果,同时记录谁持锁、谁等待。
☆ 收藏 边界反例 Q4 唯一键查询没有查到记录,为什么也可能阻止另一事务插入?参考答案 在相应隔离级别和锁定读取路径下,查找不存在的键可能需要保护其所在索引间隙,防止满足条件的新记录在事务完成前插入。与完整唯一键命中现有记录时的记录锁相比,这是不同情况。具体范围还取决于索引、条件和执行计划,不能仅凭 WHERE id=某值断言只锁一行。应在测试库查看锁等待并尝试插入目标值和相邻值,确认实际影响。若业务只需原子创建,可考虑唯一约束加插入冲突处理,避免先查不存在再自行假设没有竞争。
☆ 收藏 工程场景 Q5 一个报表事务持续两小时,写入没有明显被锁住,为什么仍需处理?参考答案 长时间持有旧读视图可能迫使系统保留后续读取仍可能需要的历史版本,增加 undo 历史积累、清理压力和磁盘占用;它也可能长期占用连接或元数据锁。先检查事务年龄、历史版本长度、存储增长与 DDL 等待,确认影响来源。可使用明确一致性需求的报表副本、快照导出或分批策略,但分批读取可能失去全局同一时点的一致性,必须向业务说明。验收比较报表正确性、历史清理速度与线上 P99,不能只因为普通写没被阻塞就认为长事务没有代价。
☆ 收藏
动手验证 在测试表上分别执行唯一键命中、唯一键缺失和非唯一范围的 FOR UPDATE,同时尝试插入相邻值,记录阻塞与成功结果。
本节依据:资料 1 · 资料 2
+ 我的笔记 用自己的话解释这个知识点
S7-17 / REVIEW NOTES ○ 标为掌握
Undo、Redo、Binlog 与提交持久性 Undo 记录支持回滚及历史版本访问;redo 支持崩溃恢复,使已提交修改不必等待所有数据页立即写回;binlog 是服务器层的变更记录,常用于复制与时间点恢复。它们承担不同职责,不能用‘都是日志’混为一谈。WAL 的核心是遵守日志与数据页写入的先后约束。
提交成功后的丢失风险与刷盘配置有关:写入进程缓冲、写入操作系统缓存和真正刷到持久设备是不同阶段。MySQL 的 innodb_flush_log_at_trx_commit 与 sync_binlog 需配合评估;组提交可以合并刷盘成本。磁盘控制器与存储设备是否兑现刷盘语义,也影响最终承诺。
代码片段 / 原理示意
修改 → 缓冲池/日志缓冲 → 按配置写入与刷盘 → 提交确认
崩溃后:检查持久日志 → 恢复必要修改
备份 + 保留的 binlog → 时间点恢复
DEEP DIVE / 从结论到推演 机制推演 提交路径应区分事务状态、日志写入、刷盘、复制确认和客户端收到响应。redo 使数据页可以稍后写回,binlog 支持复制与恢复,两者在提交时需要协调一致;具体内部协议按版本分析。即使本机日志已持久,客户端仍可能因响应丢失而处于未知状态;即使客户端收到成功,异步副本也可能尚未应用,因此本地持久与故障切换可见性需要分别承诺。
边界与取舍 批量或组提交可以合并刷盘成本,不等于必然降低持久性;真正的保证来自配置与设备是否兑现刷盘。发生死锁通常需要重试整个业务事务,而锁等待超时的回滚范围受配置与错误类型影响,不能把所有异常统一当作已经完整回滚。应用应显式结束失败事务、恢复连接状态,并使用稳定幂等标识,防止残留事务与重复副作用。
进阶依据:MySQL 8.4 Redo 日志 · MySQL 8.4 死锁处理
面试自测 先说结论,再补条件与反例 基础复习 Q1 为什么有 redo,还需要 binlog?参考答案 二者处于不同层次并服务不同用途。redo 主要服务 InnoDB 的本地崩溃恢复;binlog 描述服务器执行的变更,用于复制和恢复流程。它们的格式、保留方式和生命周期不同。只保存 redo 不能替代完整备份加 binlog 的恢复方案,也不能简单替代复制协议。
☆ 收藏 基础复习 Q2 事务提交返回成功,断电后一定不会丢吗?参考答案 必须带条件回答:需要相应的持久化设置、日志真正刷盘,以及底层设备正确兑现持久化请求。为性能放宽刷盘频率时,故障可能丢失最近提交。还应区分本机恢复与主从故障切换;本机持久不代表副本已接收到该事务。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 数据页还没有写到磁盘,数据库为什么可以返回提交成功?参考答案 在正确的预写日志与持久化配置下,恢复所需日志可以先持久保存,数据页稍后批量写回。崩溃后系统根据持久日志恢复必要修改,因此提交不必等待每个脏数据页同步落盘。这个保证要求日志刷盘顺序和存储设备语义可靠,并与相应 binlog 配置协调;仅把日志写到进程或操作系统缓冲不等于抗掉电。应区分提交日志持久点、数据页检查点和复制应用点。验收需要故障恢复与数据一致性检查,不能因为正常重启能恢复,就推断突然掉电和主从切换也完全相同。
答到这些关键点 WAL 与延迟写页 缓冲写入不等于持久 区分本机与复制 ☆ 收藏 边界反例 Q4 事务报死锁后,只重试失败的最后一条 UPDATE 是否正确?参考答案 通常不正确。InnoDB 检测到死锁后会选择事务回滚,前面已经执行的查询与修改不能被假设仍然有效;应从业务事务入口重新读取必要状态并重做整笔事务。还要区分死锁与锁等待超时等错误,确认回滚范围并显式清理事务。重试采用有限次数、退避和相同业务幂等键,外部消息或支付副作用不应在可重试区间内无保护重复发送。可用事务内 outbox 记录待发送事件,再由幂等消费者处理。测试检查最终业务不变量,而不是最后一条 SQL 是否成功。
☆ 收藏 工程场景 Q5 提交延迟 P99 很高,怀疑 fsync;如何优化并保持原有持久性承诺?参考答案 先关联提交分段耗时、日志刷盘延迟、存储队列与组提交批次,排除锁等待和大事务本身。可减少不必要的小事务、优化存储延迟、提升适当并发以利用组提交,或缩小事务内无关工作;这些与直接放松刷盘频率不同。若考虑改变配置,必须明确可能丢失的故障窗口并取得业务接受,不能称为等价优化。对比同负载下 P99、吞吐与恢复结果,包含突然进程终止等受控故障。保留原配置作为回滚点,并验证 binlog 与 redo 的持久策略共同满足承诺。
☆ 收藏
动手验证 在独立测试实例记录刷盘相关配置,设计故障恢复实验与备份还原步骤;先验证能够恢复,再评估吞吐,勿在生产实例模拟断电。
本节依据:资料 1 · 资料 2
+ 我的笔记 用自己的话解释这个知识点
S7-18 / REVIEW NOTES ○ 标为掌握
Redis 数据类型、编码与线程模型 String 可用于计数和序列化值,Hash 适合字段集合,List 支持顺序队列,Set 用于去重与集合运算,Sorted Set 用分值组织排序成员,Stream 支持带 ID 的事件记录。Bitmap 和 HyperLogLog 解决位集合与近似去重计数问题。选择类型时要考虑命令复杂度、值大小和原子更新需求。
逻辑类型和内部编码是两层:以 Redis 7.2 为例,小对象可使用紧凑编码,达到条件后转换;List 与 ZSet 都不能用一个固定结构概括所有大小。普通命令主要在主线程执行,网络 I/O 和后台工作可有其他线程或进程。慢命令、大 key 和同步释放仍可能影响其他请求的延迟。
代码片段 / 原理示意
计数:INCR
字段:HSET / HGET
去重:SADD / SISMEMBER
排行榜:ZADD / ZRANGE
观察实现:TYPE key;OBJECT ENCODING key
DEEP DIVE / 从结论到推演 机制推演 Redis 操作成本由命令、元素数量、编码与返回体共同决定。一个小 Hash 的紧凑表示可提高局部性,但元素增长触发转换时会产生额外工作;大集合遍历和大值传输又可能拖慢主线程或网络输出。应同时观察命令执行时间、排队、输出缓冲和客户端解码。TYPE 说明逻辑能力,OBJECT ENCODING 说明当前表示,两者都不能单独预测端到端延迟。
边界与取舍 以 Redis 7.2 为基线,网络 I/O 并行与后台释放不等于普通命令任意并行。使用 SCAN 可拆分扫描工作,却不是固定时点一致快照;集合变化时需容忍重复或变化并做幂等处理。拆大 key 可以改善单次延迟,却增加多键一致性和路由复杂度。设计应给单命令工作量、返回大小和集合规模设上限,而不只限制 key 的总数量。
进阶依据:Redis 7.2 配置与线程模型 · Redis SCAN 迭代保证
面试自测 先说结论,再补条件与反例 基础复习 Q1 排行榜为什么常用 Sorted Set?参考答案 Sorted Set 维护成员唯一性以及分值顺序,适合更新分数、查询名次和分值范围。需要明确同分排序规则、分值精度及分页方式;大规模榜单还要控制热点与扫描大小。内部编码会随规模变化,不能只背‘底层永远是跳表’就结束设计。
☆ 收藏 基础复习 Q2 开了 Redis I/O 线程,慢命令还会阻塞吗?参考答案 按 Redis 7.2 的模型,网络 I/O 并行不等于普通命令执行全面并行。耗时命令仍可能占用主线程,影响其他客户端。应缩小单次工作量、避免超大集合遍历和大 key,并用慢日志与延迟指标定位;升级版本后还应重新核对实际线程职责。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 Redis CPU 只占一个核,为什么简单 GET 也会突然很慢?参考答案 普通命令主要串行执行时,一个耗时命令可能让后续简单 GET 排队;此外大响应、客户端输出缓冲和网络拥塞也能增加端到端时延。应把服务端执行耗时与排队、网络耗时分开,关联慢日志、延迟事件、命令大小和客户端指标。慢日志中 GET 很快并不能排除它在前一条命令后等待。可缩小单次遍历、拆分大值或隔离重负载实例,并限制输出。验证要混合慢命令与短请求,比较 GET 的 P99,而不是只用独立 GET 基准推断线上体验。
☆ 收藏 边界反例 Q4 把 KEYS 换成 SCAN 后,能否保证完整且不重复地处理一个变化中的集合?参考答案 不能把 SCAN 当作事务快照。扫描过程中数据变化会影响返回,调用方需要理解其迭代保证,并容忍重复项目;COUNT 也是工作量提示而非严格每次条数。若处理有副作用,应使用稳定业务 ID 去重或使操作幂等,必要时基于额外版本生成一致快照。还要设置扫描速率和总预算,避免很多客户端同时扫描仍造成压力。测试应在扫描时持续插入删除,并检查业务处理结果而非仅计数返回条目。换接口降低单次阻塞,不会自动提供强一致遍历语义。
☆ 收藏 工程场景 Q5 一个排行榜有千万成员,需要前一百名和用户名次,怎样控制延迟与内存?参考答案 先确认成员 ID 长度、分值更新率、同分排序规则和可接受新鲜度,用 ZSet 提供排序能力,用户详情放到独立存储,避免在成员中塞大文本。读取前一百名应限制结果大小,必要时缓存热点榜单;更新高峰评估主线程成本与热点。若分片,会增加全局排名合并难度,不能简单按用户分片后直接得到准确总名次。测量真实规模的内存、更新吞吐和查询 P99,评估只保留前若干名是否满足业务。若必须全量精确排名,就应为其资源代价制定容量计划。
☆ 收藏
动手验证 创建小 Hash、Set、ZSet,观察 OBJECT ENCODING;逐步增加元素后再查看编码,记录 Redis 版本和相关阈值配置。
本节依据:资料 1 · 资料 2
+ 我的笔记 用自己的话解释这个知识点
S7-19 / REVIEW NOTES ○ 标为掌握
Redis 持久化、过期与淘汰 RDB 保存时间点快照,适合紧凑备份和恢复,但故障时可能丢失最近一次快照后的变化。AOF 记录恢复所需的写操作,持久性受 fsync 策略影响,重写可压缩恢复所需表示。Redis 7.2 的具体文件组织与配置应按版本查看;不要把任意一次命令成功理解为数据已经持久保存。
过期和淘汰解决不同问题。TTL 到期让键逻辑失效,通过访问检查和后台清理回收;物理删除不保证恰在到期毫秒发生。淘汰用于达到内存限制时选择释放对象,策略可能考虑最近访问、频率或带 TTL 的键。noeviction 会令需要额外内存的写入失败,而不是无限占用内存。
代码片段 / 原理示意
持久化:重启后能恢复哪些状态?
过期:这个键何时不再有效?
淘汰:达到内存预算时释放谁?
三者分别配置、分别监控
DEEP DIVE / 从结论到推演 机制推演 数据有效性、内存占用和重启恢复是三条独立时间线:TTL 到期使键逻辑失效,主动或被动清理释放内存,持久化决定重启时如何重建状态。淘汰则在内存预算压力下提前删除仍未过期的缓存。对业务而言,缓存条目随时可能缺失,因此不能把 TTL 当作至少保存多久的承诺;若数据必须存在,应放入有明确持久性保障的权威存储。
边界与取舍 RDB 与 AOF 的组合仍需考虑 fork 写时复制、重写峰值磁盘、刷盘延迟与恢复耗时。降低持久化频率可能改善延迟却扩大数据丢失窗口,增加内存限制也可能挤压子进程或操作系统缓存。容量规划应覆盖正常运行与备份重写峰值,而非只测空闲时 used_memory。真正的恢复能力需要从备份还原并核对数据,不能用‘文件存在’替代。
进阶依据:Redis EXPIRE 过期语义 · Redis 7.2 持久化与内存配置
面试自测 先说结论,再补条件与反例 基础复习 Q1 AOF everysec 是不是绝对最多丢一秒?参考答案 它表达通常的每秒刷盘策略,但调度延迟、I/O 故障、系统阻塞等会影响实际执行,不能当作严格实时上界。还需区分进程崩溃与整机掉电。选择策略时应明确可接受的数据丢失窗口、存储性能和备份方案,并通过恢复演练验证。
☆ 收藏 基础复习 Q2 过期键没被立刻删除,正常 GET 会读到旧值吗?参考答案 在正常主节点访问语义下,访问会检查过期状态,已逻辑过期的键不会仅因内存尚未回收而继续当作有效值返回。物理回收与可见性是两回事。复制节点、时钟变化及特定执行上下文需另行分析,不能拿 TTL 保证精确的业务定时。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 键设置一天 TTL,是否保证一天内一直能读到、一天后准时释放内存?参考答案 两点都不能保证。一天内键可能被主动删除、覆盖、淘汰或因故障丢失,TTL 不是最低保留时间;到期后正常访问语义会把它视为过期,但物理内存清理可能通过后续访问或后台周期完成,不承诺那一毫秒立即回收。应把逻辑有效性和物理释放分开,业务若需要可靠保存或准点任务,应使用权威存储与专门调度机制。测试可观察 TTL、GET 和内存指标的不同变化,但不要把单次观察到的清理延迟当成稳定上界,还需考虑复制与时钟相关边界。
答到这些关键点 TTL 不是保留承诺 逻辑过期与回收分离 非可靠定时器 ☆ 收藏 边界反例 Q4 设置 noeviction 后是不是所有数据都安全且读写永不失败?参考答案 不是。noeviction 主要表示内存达到限制时不按淘汰策略删除现有键,部分需要新增内存的写命令会失败;数据仍可能过期、被删除或因持久化窗口与故障丢失。读请求也可能受整体内存压力、网络或进程故障影响。应用必须处理明确的写失败并保持业务状态一致,不能先写数据库后假设缓存更新一定成功。配置应结合数据用途:缓存通常允许淘汰,关键状态则需要更强持久与容量控制。验收要逼近内存上限并测试不同命令返回,而不是只检查配置值。
☆ 收藏 工程场景 Q5 AOF 重写时磁盘与内存峰值同时升高,如何做容量与恢复验收?参考答案 先记录重写前文件大小、写入速率、重写持续时间和父进程修改页面量,评估新旧文件共存及写时复制可能带来的峰值。保留足够磁盘和内存余量,降低无必要写放大并检查存储延迟;不能只按稳态数据集大小配资源。测试中模拟重写期间进程退出,按该版本文件组织恢复,核对已确认写入与所选持久性策略的允许窗口。再测恢复耗时与业务启动依赖,防止数据能恢复却超过故障恢复目标。任何放宽 fsync 的优化都要单独说明风险变化。
☆ 收藏
动手验证 在测试实例设置短 TTL,观察 GET、TTL 与内存回收的差异;分别从 RDB 和 AOF 恢复测试数据,记录实际保存边界。
本节依据:资料 1 · 资料 2
+ 我的笔记 用自己的话解释这个知识点
S7-20 / REVIEW NOTES ○ 标为掌握
Redis 复制、Sentinel 与 Cluster 主从复制让副本跟随主节点数据变化,初次或条件不满足时需要全量同步,具备足够复制历史时可以增量追赶。复制通常是异步的,因此主节点确认后、变化到达副本前发生故障,切换可能丢失部分已确认写入。读副本也可能看到旧值,业务要为一致性要求选择读取路径。
Sentinel 主要负责监控与故障转移,不负责把数据自动分片。Redis Cluster 通过哈希槽组织分片,并处理节点与迁移路由;涉及多个键的操作通常需要满足同槽等约束。高可用、扩容和强一致不是同一个功能,需要分别讨论故障检测、客户端重试、热点 key 和数据恢复。
代码片段 / 原理示意
复制:数据冗余与读扩展
Sentinel:监控 + 主从切换
Cluster:哈希槽分片 + 集群路由
备份:独立恢复历史;不能由复制替代
DEEP DIVE / 从结论到推演 机制推演 异步复制中,主节点执行、回复客户端、副本接收和副本应用不是同一个原子事件。主节点在回复后立刻故障,尚未传播的写入可能在故障切换后消失;客户端看到过成功也无法改变这个时序。等待一定副本确认可以缩小部分风险,但仍需讨论确认含义、持久化与选择哪个副本提升,不能把复制确认直接称为无条件强一致。
边界与取舍 Sentinel 解决监控与主从故障转移,Cluster 还承担分片路由;两者都不自动消除热点键、跨槽事务和缓存一致性问题。故障期间客户端可能连接旧主、收到重定向或遇到未知结果,应有有限重试、拓扑刷新与幂等约束。分区容忍下的可用性选择会影响数据安全,面试回答应明确允许丢失、陈旧与不可用的范围,而不是只罗列组件名称。
进阶依据:Redis 复制保证 · Redis WAIT 语义 · Redis Cluster 规范
面试自测 先说结论,再补条件与反例 基础复习 Q1 Sentinel 和 Cluster 怎样选?参考答案 若单实例容量与写入能力足够、主要需要主从故障切换,可评估 Sentinel。需要横向分片时再考虑 Cluster,同时审视多键事务、Lua、热点和客户端兼容性。两者都需要备份、监控与故障演练,不能只看部署节点数决定可靠性。
☆ 收藏 基础复习 Q2 为什么缓存读写分离会读到刚更新前的值?参考答案 主节点写入成功时,副本可能尚未收到或应用该变化,随后从副本读取就会看到旧状态。可对需要读己之写的路径读取主节点,或采用明确的一致性协议与版本检查。客户端重试也要保持请求语义,不能通过反复随机换节点保证正确性。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 主节点已经返回 SET 成功,故障切换后为什么可能读不到?参考答案 主节点回复时,异步副本可能尚未收到或应用该写入;若主节点随即失效,提升的副本只包含它已获得的状态,最近成功写入可能缺失。主节点本机 AOF 持久与副本复制是不同维度,即使原节点磁盘有数据,切换后的服务也未必立即使用它。需要根据业务容忍度选择复制确认、持久配置和权威存储,关键业务不应依赖未经定义的强一致缓存承诺。故障演练应在回复与复制之间注入延迟和失效,并记录客户端成功集合与恢复集合,量化实际风险。
☆ 收藏 边界反例 Q4 每次写后调用 WAIT,是否就能把 Redis 当作完全不会丢写的强一致数据库?参考答案 不能。WAIT 能等待一定数量副本对当前连接相关写入的复制确认,改善数据已传播的程度,但超时返回、确认副本随后故障、持久化状态和故障转移选择仍需分析。它也没有把所有读写自动放入线性一致协议。客户端必须检查实际确认数,并明确不足时业务处于什么状态,不能盲目再写一次。若要求严格一致的资金或唯一分配,应使用满足该模型的存储或协调系统。应以具体故障集合定义承诺,而不是把增加一个命令当成通用一致性升级。
☆ 收藏 工程场景 Q5 Cluster 扩容后请求错误与重复写增加,如何定位客户端行为?参考答案 先按错误类型区分 MOVED、ASK、超时和业务失败,检查客户端是否正确刷新槽映射、执行临时重定向流程,以及是否把所有失败都当作未执行。再关联迁移时段、目标节点和幂等键,确认重复写来自重试还是应用本身。限制重试预算与并发,避免拓扑变化触发风暴;多键操作需检查同槽约束。用迁移期间的固定幂等请求集验证每个操作结果与最终状态,记录短暂不可用窗口。不要仅靠延长超时掩盖协议处理错误,也不要把缓存迁移误报为业务必须重复执行。
☆ 收藏
动手验证 在隔离环境人为延迟复制,记录主写后主读与从读结果;再模拟主节点故障,明确哪些确认不能等价为跨节点持久承诺。
本节依据:资料 1 · 资料 2 · 资料 3
+ 我的笔记 用自己的话解释这个知识点
S7-21 / REVIEW NOTES ○ 标为掌握
缓存穿透、击穿、雪崩与一致性 穿透是请求不存在的数据持续落到数据库,可使用参数验证、短期空值缓存或布隆过滤器,注意误判与数据更新。击穿是热点键失效时大量请求同时回源,可用单航班合并、受控重建、预热与过期旧值服务。雪崩是大量缓存同时失效或服务整体不可用,需要错峰 TTL、限流、降级和容量预案。
常见 cache-aside 流程是读时缓存未命中再查数据库并回填,写时先更新数据库再删除缓存。但并发回填仍可能把旧值写回,因此它不是强一致证明。可结合版本号、可靠失效事件和重试修复,明确允许多长时间的旧数据;高价值不变量应由数据库或权威存储保障。
代码片段 / 原理示意
读取:缓存命中 → 返回
未命中:合并同键回源 → 查库 → 有界 TTL 回填
写入:提交数据库 → 可靠失效缓存 → 失败重试/补偿
热点:监控回源率 + 重建并发 + 旧值年龄
DEEP DIVE / 从结论到推演 机制推演 缓存一致性需要按时序推演:读 A 未命中并取得数据库旧值;写 B 更新数据库并删缓存;读 A 最后回填旧值,缓存重新变旧。先更新数据库再删除缓存只是常用顺序,并没有排除这个交错。可使用单调版本、条件回填与可靠失效事件降低风险,但版本比较本身也必须原子执行,并考虑缓存被淘汰后版本元数据是否仍能阻止旧写入。
边界与取舍 缓存与数据库之间缺少统一原子提交时,通常需要接受有界陈旧或引入更强协调。短 TTL 可以缩短部分旧值存活,却不能保证严格陈旧上界,因为旧请求可能在稍后再次回填。单航班只合并某个范围内的并发回源,多进程和多节点需要额外协调。故障降级还必须保护数据库容量,否则缓存故障会被重试放大为整个系统故障。
进阶依据:Redis 过期机制
面试自测 先说结论,再补条件与反例 基础复习 Q1 先更新数据库再删缓存,就完全一致了吗?参考答案 不是。旧的读请求可能在删除之后把之前读取的旧数据重新写入缓存,失效操作也可能失败。应根据容忍的陈旧窗口选择版本校验、失效消息、重试与短 TTL 等组合;需要严格一致的关键判断应走权威数据源,而不是把缓存当作唯一事实来源。
☆ 收藏 基础复习 Q2 热点键重建加锁会带来哪些新问题?参考答案 锁能减少并发回源,却会引入等待、超时、持有者崩溃和重建失败处理。应限制等待时间,避免锁内执行无界慢操作,可在允许时返回有年龄上限的旧值,并控制数据库回源并发。若采用分布式租约,还需处理过期后旧持有者继续写入的情况。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 更新数据库后删除缓存仍会出现旧值,请给出一个完整时序和应对方式。参考答案 读请求 A 缓存未命中,先从数据库读到版本一但尚未回填;写请求 B 提交版本二并删除缓存;随后 A 把版本一写入缓存,旧值再次出现。可以让回填携带数据版本并通过原子比较拒绝过旧写入,或用可靠失效事件和补偿修复,具体要保证版本元数据不会随缓存淘汰丢失必要约束。若业务只允许短时陈旧,可结合 TTL 与读权威源策略,但不能声称严格一致。测试应使用屏障固定这个交错,并验证更新、删除、回填失败及延迟重试场景,而非只做顺序读写。
☆ 收藏 边界反例 Q4 设置五秒 TTL,能否保证缓存最多只旧五秒?参考答案 不能无条件保证。一个早先读到旧值的请求可能停顿很久,恢复后才把旧值写入并重新获得五秒 TTL;多个迟到请求还可能重复延长陈旧状态。若需要严格按数据版本或提交时间限制陈旧,应保存版本与有效时间,拒绝过期回填,并让校验信息具有可靠生命周期。还应考虑客户端本地缓存、读副本延迟与时钟误差。五秒 TTL 是缓存条目从某次写入起的有效期,不自动等于源数据更新后的最大陈旧时间。应按最坏交错验证,而不是只观察正常请求。
答到这些关键点 TTL 起点与提交时间不同 迟到请求重置寿命 版本和时间校验 ☆ 收藏 工程场景 Q5 Redis 整体不可用,数据库最多承受正常流量的两成,如何保证核心功能存活?参考答案 在入口实施有界并发与限流,按业务优先级保留核心读写,对非关键查询返回可接受旧值、简化结果或明确降级。回源不能无限重试,应设置熔断和总预算,合并同键请求,避免缓存恢复时同时重建热点。数据库写入的正确性仍由原事务保证,不能为可用性跳过约束。容量演练把缓存完全隔离,逐步增加流量,检查数据库连接数、P99、拒绝率与核心成功率。恢复阶段逐步放量并预热高价值数据,防止第二次回源峰值。方案必须明确哪些功能允许不可用。
☆ 收藏
动手验证 给测试缓存同时发送同一冷键的 100 个请求,对比有无请求合并的数据库查询次数;再模拟回源失败,确认不会无限等待或无限重试。
本节依据:资料 1 · 资料 2
+ 我的笔记 用自己的话解释这个知识点
S7-22 / REVIEW NOTES ○ 标为掌握
Redis 原子性、事务与租约锁 INCR 这类单条命令可原子更新,而 GET—计算—SET 是多次请求,可能交错导致丢失更新。MULTI/EXEC 把排队命令按序执行,期间不插入其他客户端命令,但某条命令执行时报错不会通用回滚此前成功命令。WATCH 可检测被关注键的变化,配合有限重试实现乐观并发控制。
单实例租约锁通常用 SET key token NX PX ttl 原子完成抢占与期限设置,释放时原子检查 token 再删除,避免删掉别人的锁。租约到期后旧持有者可能仍在运行;保护外部资源时需要资源端校验、递增 fencing token 或其他一致性协议。网络分区与主从切换让正确性超出一个 SET 命令。
代码片段 / 原理示意
SET lock:job unique-token NX PX 10000
释放思路:同一原子脚本中比较 value 与 token;匹配才 DEL
业务还需:超时预算、续租失败处理、过期持有者隔离
DEEP DIVE / 从结论到推演 机制推演 租约锁的 token 表达所有者身份,释放时比较 token 只能防止误删其他人的锁,不能阻止旧持有者继续访问外部资源。若旧持有者暂停超过 TTL,新持有者取得锁后,双方都可能执行。fencing 的核心是资源端识别单调递增代际并拒绝旧代际操作;仅在客户端检查剩余 TTL 存在检查后再暂停的窗口,无法替代资源端约束。
边界与取舍 Redis Lua 与 MULTI/EXEC 的隔离执行不等于数据库式自动回滚。脚本前半段写入成功后发生运行错误,已完成修改可能保留,因此应先校验再写入并设计可重试结果。跨外部数据库、对象存储或支付服务的副作用不能被 Redis 原子块覆盖。需要明确每个系统的事务边界,再用幂等键、版本检查或可靠事件协调,而非把所有正确性寄托于一把分布式锁。
进阶依据:Redis 分布式锁与故障假设 · Redis 事务错误与回滚边界
面试自测 先说结论,再补条件与反例 基础复习 Q1 Redis 单线程,为什么 GET 加一再 SET 会丢更新?参考答案 每条命令分别执行,但两条命令之间可以处理其他客户端请求。两个客户端都 GET 到十,分别算十一再 SET,最后仍是十一。使用 INCR 可把读改写合成单次原子操作;复杂逻辑可用适当的脚本或 WATCH 重试,并另行评估故障持久性。
☆ 收藏 基础复习 Q2 锁加了过期时间为什么仍可能两个人同时工作?参考答案 原持有者可能因长暂停或网络阻塞超过租约,其他客户端取得新锁后,旧持有者恢复并继续执行。过期时间防止永远占锁,却不能撤销旧进程的执行能力。需要让被保护资源识别所有者版本、拒绝过期持有者,或使用满足业务一致性要求的协调机制。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 锁使用唯一 token 并正确续租,为什么仍不能保证外部文件只被一个客户端修改?参考答案 唯一 token 防止错误释放,续租减少正常任务超期,但客户端可能因长暂停或网络分区失去租约后仍继续运行。新客户端获得锁后,旧客户端恢复并写文件,两个执行者都产生副作用。必须让被保护资源识别所有者代际,例如接受单调 fencing token 并拒绝旧值,或使用资源本身提供的条件更新与事务。若普通文件接口无法校验代际,就不能凭 Redis 锁宣称强互斥。测试应在写入前暂停旧持有者直到租约过期,再让新持有者完成后恢复旧任务,检查是否被拒绝。
答到这些关键点 租约过期旧执行者 token 与 fencing 不同 资源端校验 ☆ 收藏 边界反例 Q4 把检查和两次写入放到 Lua 脚本中,第二次写入报错,第一次一定撤销吗?参考答案 不一定。Redis 脚本执行期间与其他命令隔离,但运行错误不提供通用的已执行写入回滚。脚本应尽量在修改前检查类型、参数和业务前置条件,并使结果可识别、可幂等重试;仍不能把涉及外部系统的动作纳入同一原子边界。若操作必须全有全无,应重新组织数据和命令,或使用具备所需事务能力的权威存储。测试故意构造第二步错误,检查第一步实际状态,再设计补偿。仅因为脚本以一个 EVAL 调用提交,就推断具有完整 ACID,是混淆了隔离与原子回滚。
☆ 收藏 工程场景 Q5 需要每小时生成一次报表并上传,多个实例竞争执行,怎样防止重复发布?参考答案 把任务标识设为报表类型与时间窗口,持久记录执行状态、产物版本和发布结果。锁可减少重复计算,但最终发布应依赖唯一任务键或对象存储条件写入,使同一任务只有一个可接受结果;旧执行者发布时要检查版本或 fencing。上传成功但状态回写超时属于未知结果,应查询已有产物再重试,不能重新发布新版本。失败任务有租约与恢复策略,输出先写临时位置再原子切换可见引用。测试包含暂停超租约、上传后断线和进程重启,验收只出现一个有效发布结果,允许内部重复计算。
答到这些关键点 执行去重与发布幂等分离 未知上传结果查询 产物条件发布 ☆ 收藏
动手验证 用两个客户端复现 GET/SET 丢失更新,再替换成 INCR;模拟持锁者暂停超过 TTL,解释为什么唯一 token 释放检查仍不足以阻止旧业务写入。
本节依据:资料 1 · 资料 2
+ 我的笔记 用自己的话解释这个知识点
从理解到实践 阶段项目与验收 综合实验:制作一个本地商品库存服务,C++ 网络层采用长度前缀协议或成熟 HTTP 库;MySQL 用条件更新保证库存不为负,Redis 只缓存允许短时陈旧的商品展示信息。验收应包括:并发请求不会超卖;同一幂等键重试不会重复扣减;断开连接后可以查询结果;缓存失效与回源失败不会压垮数据库;记录吞吐、P95/P99 延迟、连接数与慢 SQL。所有故障实验只在独立测试环境执行,明确哪些保证来自数据库,哪些仅为缓存策略。
SYSTEM INTERVIEW / 跨知识点综合题 下单接口超时重试:库存、订单和缓存如何一致? 客户端支付前创建订单,可能因网络超时重试。数据库使用 MySQL 8.4/InnoDB,Redis 缓存库存,服务实例可在任意步骤崩溃。要求“不因重试重复建单,不因并发把数据库库存扣为负”,缓存允许短暂滞后。
展开方案与推理 把数据库作为库存与订单的权威状态,在同一事务中建立唯一的业务请求键、订单记录与条件扣库存。可用 UPDATE ... SET stock=stock-qty WHERE sku=? AND stock>=qty 的原子条件更新检查影响行数;qty 必须为合法正数,sku 有合适唯一索引。唯一请求键必须绑定业务主体和参数摘要,相同键不同参数要拒绝,不能简单返回别人的历史结果。事务失败或死锁时按规定回滚整笔操作。
超时不等于事务失败。客户端使用同一幂等键重试,服务端根据已提交订单或可查询状态返回结果,不能重新生成键再扣一次。并发相同键可通过唯一约束/事务协调,让一个操作提交,其他读取同一业务结果;若事务回滚,相关扣减也一起撤回。跨外部支付系统时,本地事务不能包住对方提交,需要对方幂等键与状态查询、补偿或工作流,不能声称一次 TCP 重连实现恰好一次。
缓存不参与决定能否扣库,事务成功后使相关缓存失效,并用同事务写入的 outbox 记录或可靠变更流推进后续失效/刷新。投递可能重复,消费者需幂等;要处理先后错序与旧值回填,可用受保护的版本元数据与回填期限约束陈旧;TTL 只限制条目从写入起的有效期,不能单独保证源数据提交后的陈旧上限。仅靠“提交后删一次缓存”无法处理提交后立刻崩溃。验收以数据库不变量和故障注入时序为主,不要求每个瞬间缓存都等于数据库。
验收证据 并发相同幂等键只产生一份业务结果;键复用不同参数被拒绝。 库存非负,扣减与订单提交原子绑定。 提交后崩溃、重复/乱序通知和响应丢失都有可恢复路径。 继续追问 追问:先查库存,再 UPDATE,包在事务里就安全吗? 不自动安全。普通一致性读得到的库存不能阻止其他事务消耗同一库存,两个事务可能基于同一旧值作决定。使用合适的锁定读并在同一事务保持锁,或把约束写入原子条件更新并检查结果。还要考虑索引、事务长度和多 SKU 的固定锁顺序,避免把正确性换成无界锁等待。
追问:Redis 分布式锁能替代数据库约束吗? 不能直接替代。租约到期、暂停和切换可能使多个客户端都以为可执行,数据库本身仍应拒绝违反库存不变量的写入。锁可减少争用,但唯一约束与条件更新是资源端的最后防线。跨系统资源若需要防过期持有者写入,还应考虑资源端认可的 fencing 或等效校验,而非只检查 Redis 锁是否存在。
追问:outbox 消费了两次会重复刷新或倒退库存吗? 消费协议应以事件 ID 和实体版本组织幂等与顺序,避免直接用旧事件覆盖新版本状态。失效操作通常可重复,但旧请求在失效后回填旧值仍是单独竞态,需要版本校验、回填期限及明确的陈旧约束。对每个提交后崩溃点、重复投递和乱序投递做实验,证明最终收敛且数据库核心不变量始终成立。
参考依据:MySQL 锁定读 · AWS:安全幂等重试
CODE REVIEW / 动手审查 幂等下单:去重记录必须放在哪里? 要求数据库库存不为负,重复请求不重复建单。缓存允许滞后,订单数量必须是已校验的正数。
MySQL 8.4 / InnoDB · 事务伪代码 · 先自行判断,再展开核对。
扣库存并提交事务
创建订单并提交事务
写入“这个请求已经处理”的记录
// 任意两步之间崩溃,重试都可能再次扣减。查看问题、修复与验证 把作用域内唯一幂等键、参数摘要、订单结果和条件扣库存放在同一权威数据库事务中。相同键不同参数要拒绝,不同用户的键不能串用。
先在事务中抢占唯一键,再执行带 stock >= qty 条件的更新并检查影响行数;失败则整笔回滚。若唯一键已由另一事务提交,查询并返回该业务结果。所有分支都要结束事务,避免连接回池时残留状态。
提交响应丢失时结果未知,重试必须沿用原键或查询结果。外部支付不在本地数据库事务内,须依赖对方的幂等协议和查询/补偿;不能把两套系统写成一次原子提交。
修正思路 校验身份、sku、qty > 0、幂等键与参数摘要
BEGIN
INSERT request_key(tenant, key, params_hash) -- 唯一约束
UPDATE inventory SET stock = stock - :qty
WHERE sku = :sku AND stock >= :qty
要求影响行数为 1,否则 ROLLBACK
INSERT order_record(...)
INSERT outbox(...) -- 如需可靠推动后续通知
COMMIT
// 重复键:结束当前失败事务,再读取已提交结果并核对参数。怎样验证 覆盖并发同键、同键不同参数、库存不足、死锁、提交后断线、重复事件;核对库存差额与成功订单数量,而不是只看 HTTP 成功数。
这里提供审查练习与验证步骤,不代表已在本机执行 C++、数据库或 GPU 实验。
依据:InnoDB 锁定读与事务 · 幂等重试的业务意图
本章参考资料与适用版本 正文为原创整理;链接用于进一步核对,不影响离线阅读。C++ 工作草案会持续更新,本文采用已标注的 C++17/20 功能范围。
STAGE 08 / 八阶段学习路线 大模型与 AI 编程 本阶段把大模型放回软件工程语境:理解输入输出、工具调用、检索、评估与推理服务,再把前七阶段的内存、并发和性能知识用起来。不同 C++ 岗位对 AI 的要求不同;应用开发优先学习可靠集成,推理与高性能岗位再深入 GPU 和模型部署。以下为原创复习内容,不代表某家公司题库,也不把论文中的历史实验成绩当成当前产品能力。
← 回到路线图 抽题练习本章 本章目录 · 按主题分组
S8-01 / REVIEW NOTES ○ 标为掌握
Token、Prompt 与 Transformer:模型究竟接收什么 Token 是分词器输出的离散编号,不等于一个汉字或一个英文单词;同一句话用不同分词器可能得到不同数量的 token。Prompt 是实际提交给模型的任务指令、材料和示例,聊天产品还会加入角色模板等内容。估算输入容量时应使用目标模型的分词器或服务提供的计数接口,不能把字符数直接当作 token 数。[8-01]
Transformer 用注意力机制建立位置之间的关联;在常见的自回归语言模型中,生成过程根据已有上下文得到下一个 token 的分布,再按解码策略选择输出。模型权重承载学习到的规律,推理时的上下文提供当前任务材料。训练、微调和一次对话是不同过程,正常推理请求不会把本次文字直接写进模型权重。[8-02]
代码片段 / 原理示意
文本与角色模板 → 分词后的 token ID → 模型计算 → 下一 token 分布
↓
解码选择 → 追加到上下文 → 继续
停止条件:结束标记、输出预算、服务规则或请求取消
DEEP DIVE / 从结论到推演 输入不是一段裸字符串 一次聊天请求最终要转成模型接受的序列。角色边界、工具说明、历史消息和生成起始标记都可能占用输入预算;模板不匹配会使同一模型表现下降。分词器与权重应作为配套产物管理,而不是随意替换。排查时记录模板版本、实际 token 数、截断位置和特殊标记,先验证进入模型的材料,再讨论模型能力。以下追问均为原创模拟题,工程案例是用于推理的假设场景。
把注意力计算与实现代价分开 对普通稠密自注意力,长度增加会扩大两两位置关联的计算规模;但不能据此断言所有实现都必须把完整注意力矩阵写入显存。FlashAttention 用分块和片上存储降低内存读写,保留精确注意力语义;它与稀疏注意力、KV 缓存分别解决不同问题。面试分析应先声明模型结构、阶段和实现,再分别估算算术量、存储量与数据搬运,避免用一个复杂度概括整个推理系统。
进阶依据:Hugging Face:Chat templates;模板与角色标记 · FlashAttention 原始论文;精确注意力的 I/O 优化
面试自测 先说结论,再补条件与反例 基础复习 Q1 Token 和单词有什么区别,为什么 C++ 日志会特别占上下文?参考答案 Token 是特定分词器的编码单位,可能是完整单词、子词、标点或字节片段。日志常含长路径、时间戳、随机标识和十六进制数据,这些内容可能被拆成很多 token。工程上应保留必要的错误位置和关联记录,删除无关重复行,再用目标分词器计数;压缩前后还要确认没有丢掉触发问题的证据。
☆ 收藏 基础复习 Q2 给模型发一段新资料,为什么不等于完成了微调?参考答案 这段资料作为当前推理的输入影响输出,没有因此进行梯度更新,也不自动成为所有未来会话都可访问的知识。微调则通过训练过程改变参数;外部知识库通过检索在请求时提供材料。选择方案应先看目标是临时任务、持续更新的事实,还是稳定的行为模式,再比较更新成本和验证方法。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 同一段消息换了聊天模板后回答明显变差,你如何从输入机制解释?参考答案 模型训练时学习了特定角色分隔与结束标记的统计模式。模板改变后,消息虽然人类看起来相同,实际 token 序列和角色边界可能已经不同,模型可能把指令当作待续写材料,或不知道从哪里开始生成。应比较模板展开后的文本与 token ID,检查特殊标记是否重复、系统消息是否丢失以及生成前缀是否正确,再用固定题集验证恢复效果。这属于输入协议兼容问题,不宜直接通过更长提示或提高采样随机性掩盖。最终还应把权重、分词器和模板一起固定版本。
答到这些关键点 指出模板改变实际 token 序列与角色边界 提出检查重复特殊标记和生成前缀 将权重、分词器、模板作为配套版本验证 ☆ 收藏 边界反例 Q4 注意力是 O(n²),是否意味着长度翻倍时显存一定变成四倍?参考答案 不一定。这个说法混合了算术复杂度、临时注意力存储和整个进程的显存占用。普通稠密注意力存在两两交互,但分块实现可以避免将完整得分矩阵长期物化;权重等固定开销不会随输入长度翻倍,KV 缓存在常见模型中又主要线性增长。应说明讨论的是训练、prefill 还是 decode,是否保留中间激活,以及使用的注意力内核。然后测量增量显存并分解权重、缓存和临时工作区,不能由一个渐近符号直接推出整机比例。
答到这些关键点 区分运算量、矩阵物化和总显存 提到 FlashAttention 类分块实现不改变精确语义 按训练/prefill/decode 和内存组成分析 ☆ 收藏 工程场景 Q5 一个 C++ 日志分析助手突然超出上下文上限,如何缩减且保留根因证据?参考答案 先按真实模板计数,确认超限来自日志、工具描述还是历史重复。对日志保留首次错误、关联请求标识、必要前后文和版本信息,合并重复栈与循环输出,并建立可按位置重新读取的原文索引。不要只截取最后几千行,因为根因可能在最早的一次资源失败。缩减后用已知故障样本验证证据是否仍可定位,并记录截断规则。若模型需要更多材料,再按问题补读相关区间,同时为最终答案保留输出预算,避免输入刚好塞满窗口而无空间回答。
答到这些关键点 按真实请求而非字符数定位预算 保留首次失败和请求关联信息 原文可回读并验证压缩后的故障定位效果 ☆ 收藏
动手验证 选择一个已安装的分词器,对普通中文、英文说明、C++ 源码和日志各计数一次;记录字符数/token 数比例,不安装或下载付费模型也能完成概念实验。
+ 我的笔记 用自己的话解释这个知识点
S8-02 / REVIEW NOTES ○ 标为掌握
上下文窗口、幻觉与可验证回答 上下文窗口约束模型一次能处理的信息量,具体怎样计算输入、输出和内部预算由模型与服务决定。能够放进去不等于能稳定使用全部信息;长上下文研究曾发现答案所在位置会影响效果,但不能把某篇旧论文的幅度直接套到今天的所有模型。对重要任务应做自己的长度、位置和干扰信息测试。[8-03]
幻觉可以表现为编造 API、引用不存在的文件、把猜测写成确定事实。工程处理方式是把关键结论绑定到可核验对象:源码位置、编译结果、数据库查询或原始文档。要求模型表达不确定性有帮助,但真正的可靠性来自外部验证、失败回退和人工复核;降低随机性也不能证明输出正确。
DEEP DIVE / 从结论到推演 幻觉要按可观察错误分类 回答错误可能来自缺证据、读错证据、证据版本过期或组合推理失败,不同原因需要不同修复。扩大窗口只能改变可提供的信息,不能替代证据质量;增加引用也不能证明引用支持结论。可以把答案拆成可核验主张,对每条标注证据和适用前提,再分别测缺失、冲突与无答案样本。拒答阈值也需评估,过度拒答会使系统看起来谨慎,却无法完成正常任务。
可重复不等于正确,也不等于绝对确定 降低采样随机性可能减少输出变化,但错误结论也能被稳定重复;数值内核、批处理和服务更新还可能影响复现。技术问答应保存模型与资料版本、检索结果和执行证据,形成可重放的案例。对重要结论优先使用编译器、查询和官方文档核验。所谓置信度若只是模型随口给出的百分比,并未自动获得校准意义,必须在带真实标签的数据上验证它与错误率的关系。
进阶依据:Lost in the Middle;长上下文利用率的受控评测 · Anthropic:Reduce hallucinations;事实核验边界
面试自测 先说结论,再补条件与反例 基础复习 Q1 为什么把整个仓库塞给模型,效果可能不如只提供少量文件?参考答案 整仓库可能充满与问题无关的代码、重复生成文件和过时说明,增加阅读成本并稀释关键依赖。先明确触发路径,提供错误、相关接口、实现与必要测试,再按缺口补充,通常更容易验证。这个判断应通过固定任务集比较,而不是假定所有大上下文模型一定会忽略中间内容。
☆ 收藏 基础复习 Q2 模型说一个 C++ API 存在,你会怎样核查?参考答案 先锁定项目使用的库版本,查看头文件声明和对应版本的官方文档,再写最小可编译调用验证参数、返回值与错误路径。编译通过后仍需确认线程、安全和生命周期约束。若无法获得该版本资料,应明确记录未验证项,不能因为名称看起来合理就直接接入生产代码。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 如何区分“没有知识”和“没有正确使用已给证据”?参考答案 为同一问题构造对照:第一组使用正常检索,第二组直接提供人工确认的充分证据,第三组移除关键证据或加入冲突资料。固定其他条件并多次运行。若人工证据能恢复结果,问题更可能在检索或上下文组织;若仍出错,则检查指令、证据理解和推理步骤。还要确认标准答案自身成立,避免把不同版本的合理答案判错。这个实验不能完全解释模型内部机制,但能把工程改进指向可干预环节,而不是笼统归因于模型幻觉。
答到这些关键点 使用人工充分证据作为对照 固定条件并包含缺失/冲突样本 不把外部实验夸大为内部机制证明 ☆ 收藏 边界反例 Q4 temperature=0 且每次都给同一答案,为什么仍不能上线?参考答案 输出稳定只说明在当前条件下重复性较高,并不说明它符合事实。模型可能稳定地记住旧接口,或在所有请求中重复同一误解。即使设置为零,服务实现和硬件计算路径也未必承诺逐字完全一致。上线前应以有证据的任务集检查正确率、拒答与错误后果,核对关键 API 和代码执行结果,并针对服务版本变化做回归。若错误风险集中在特定领域,可增加确定性校验或限制适用范围,而不能用多次相同输出来替代验收。
答到这些关键点 区分重复性和正确性 指出采样参数不等于端到端确定性承诺 要求有标签评测与外部事实校验 ☆ 收藏 工程场景 Q5 助手回答了一条已废弃的 C++ 库 API,却引用了真实官方链接,你如何定位?参考答案 先定位引用实际支持的版本与段落,再核对项目锁定的依赖版本和头文件。真实网址可能只是首页,或页面描述的是旧版本,因此引用存在与结论成立必须分开。检查检索索引是否混合多个版本、重排是否偏向旧高频页面,以及生成时是否丢失版本条件。修复可以包括版本过滤、在片段中保留发布日期与接口签名,并用最小编译样例验证。最后加入旧新版本同名 API 的回归用例,要求资料不足时明确说明,不能自动把最新文档当成项目真实环境。
答到这些关键点 检查引用支持关系而非链接存活 按项目依赖版本过滤和验证 加入跨版本同名 API 的回归案例 ☆ 收藏
动手验证 把同一条关键约束分别放到长文开头、中间和结尾,运行相同问题并记录命中率;保留输入与结果,区分一次成功和稳定成功。
+ 我的笔记 用自己的话解释这个知识点
S8-03 / REVIEW NOTES ○ 标为掌握
Function Calling:模型提议动作,程序负责执行 Function Calling 通常让模型按工具描述生成工具名和结构化参数;真正访问数据库、文件或网络的仍是应用或服务端执行器。应用接收请求后必须检查工具是否存在、参数是否合规、调用者是否有权限,再执行并把结果返回模型。不同平台的消息格式不同,但这条责任边界不因模型更强而消失。[8-04]
工具接口应暴露有限、清楚的业务能力,例如按订单号查询状态,而不是让模型直接拼接任意 SQL。执行层需要超时、错误类型、结果大小限制与可观测记录。涉及写操作时,要给请求稳定的幂等键,并区分工具超时、工具明确失败、执行成功但响应丢失,避免重试把一次业务操作变成两次。
代码片段 / 原理示意
模型提出调用 → 白名单/参数/权限校验 → 执行器
↓
模型接收观察结果 ← 截断和结构化错误 ← 工具返回
写操作额外考虑:幂等键、审计、事务边界、必要时确认
DEEP DIVE / 从结论到推演 工具授权是执行时约束 模型只能提出动作,授权应由执行器依据已认证身份、资源归属与操作类型重新判定。检索材料或工具返回里的“请忽略限制”属于数据,不能变成新的用户授权。参数即使满足模式,也可能指向其他租户或绕过业务前置条件。接口最好提供明确业务动作,约束资源范围,并记录实际操作与结果。高风险动作的可执行性由程序控制,不能仅依赖提示词告诫模型谨慎。
重试必须保存同一业务意图 网络超时留下的是结果未知,而不是确认失败。幂等键应标识一次业务意图,重试沿用同一键,新的合法操作使用新键;服务端还应核对同键参数是否冲突。幂等记录和业务写入若分两步且无一致性保障,会留下重复执行窗口。需要说明键的作用域、保留时间和结果查询协议,并把执行中、成功、失败和未知状态明确返回给运行层。
进阶依据:AWS Builders Library:幂等 API 与重试 · Anthropic:间接提示注入与不可信工具内容
面试自测 先说结论,再补条件与反例 基础复习 Q1 Function Calling 和让模型输出一段函数代码,有什么本质区别?参考答案 前者是选择事先定义的能力并提交参数,应用可以在受控边界内执行;后者生成新的程序,仍需编译、审查并决定是否运行。两者都不能绕过权限校验。工具描述承担接口说明作用,执行层承担实际权限和资源约束,模型的自然语言解释不能替代这些检查。
☆ 收藏 基础复习 Q2 下单工具调用超时后,Agent 应该立即重试吗?参考答案 先判断是否有幂等协议和订单查询能力。请求可能已在服务端成功,只是结果丢失;盲目重试会重复下单。可靠做法是携带同一幂等键查询或重试,由服务端保证一次业务效果,并把未知状态作为显式结果处理。仅靠模型记得不要重复,无法形成正确性保证。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 工具参数经过 JSON Schema 校验,为什么仍要做资源级授权?参考答案 Schema 主要验证形状与部分数值范围,不知道调用者能否操作某条订单。攻击者可以提供格式完全正确的他人资源编号,模型也可能从文档中误取该编号。执行器必须从可信会话获得身份,查询资源归属及当前业务状态,再决定允许的操作,不能接受模型自行声明管理员身份。需要同时限制返回字段,防止只读工具泄露多余数据。校验失败应返回明确、可恢复但不暴露敏感信息的错误,并保留审计,以便区分模型选错资源与权限系统本身失效。
答到这些关键点 区分格式验证与身份/资源授权 可信身份不取自模型参数 覆盖返回数据最小化和审计 ☆ 收藏 边界反例 Q4 同一用户两次提交完全相同的购买参数,可以直接判重吗?参考答案 不能仅按参数判重,因为用户可能确实想买两次。应让调用协议为一次业务意图分配稳定幂等键,网络重试复用该键,新的购买使用新键。服务端以用户或租户加键建立作用域,保存参数摘要与结果;同键不同参数应拒绝并提示冲突。记录和订单创建需具备一致性,否则先写订单后写去重记录时崩溃仍会重复。还要定义键过期后的语义与查询路径,避免把有限保留时间的去重机制夸大成永远严格的一次执行保证。
答到这些关键点 相同参数不代表相同意图 同键参数冲突与作用域明确 解释记录与副作用的一致性及保留期 ☆ 收藏 工程场景 Q5 RAG 返回的文档要求 Agent 上传项目密钥到一个网址,你如何拦截?参考答案 首先把检索文档保持在外部资料通道,附带来源与不可信标记,不将其拼接成系统指令。模型应继续执行用户原任务;但真正的防线是执行器不给该任务读取密钥或向任意域发送数据的能力,并对网络目标和输出数据做必要限制。即使模型被误导提出上传请求,也应在权限层失败。记录触发文档与被拒动作,加入回归攻击样本,同时避免将密钥本身写进日志。仅用关键词过滤或让第二个模型判断安全不足以形成完整边界。
答到这些关键点 把文档中的指令视为不可信数据 用工具权限和出站限制阻断实际副作用 回归与日志不泄露密钥 ☆ 收藏
动手验证 用普通函数模拟只读库存工具,分别测试不存在的工具名、越界参数、无权限请求和超时;确认每种情况都返回可识别的错误。
+ 我的笔记 用自己的话解释这个知识点
S8-04 / REVIEW NOTES ○ 标为掌握
Structured Output 与业务校验 结构化输出把结果限定在指定格式或模式内,便于程序读取。JSON Schema 可描述字段类型、必填字段、枚举与部分取值范围;具体模型服务只支持其中哪些功能,需要查对应实现。严格解码可以减少格式错误,但仍要处理拒答、中断、预算耗尽等结果,不能假定所有响应都能按业务对象读取。[8-05][8-06]
语法正确与事实正确是两层问题。一个订单总价可以是合法数字,却与商品明细不一致;一个日期字符串可以格式正确,却不符合业务期限。服务端应重新计算派生字段、核对数据来源并执行跨字段约束。适合机器计算的结果由代码计算,模型主要负责理解、分类或从材料中提取候选信息。
代码片段 / 原理示意
{
"type": "object",
"properties": {
"category": {"type": "string", "enum": ["bug", "question"]},
"evidence": {"type": "string"}
},
"required": ["category", "evidence"],
"additionalProperties": false
}
// 此为 Schema 示意;接入前检查服务支持的子集。
DEEP DIVE / 从结论到推演 约束解码只覆盖被表达的规则 一个严格模式只能约束它支持并实际声明的属性。必填、允许的额外字段与空值语义要显式设计;金额合计、库存实时状态和跨记录唯一性通常由业务层检查。模型拒答或输出被截断时,也可能没有预期对象,需要先识别响应状态再解析。把外部输出转成领域类型时采用明确的失败分支,避免用默认零值或空字符串掩盖提取失败。
提取系统需要表达未知与证据 缺失信息不能与真实的零或空字符串混为一谈。可以设计状态枚举表示已找到、缺失和冲突,再提供原文位置,后端按规则接受或转人工确认。证据片段应能追溯到固定文档版本,模型生成的页码也要验证。模式升级应有版本字段和兼容策略,防止新增枚举值被旧客户端当作成功默认值,从而把格式层面的演进变成业务错误。
进阶依据:JSON Schema:对象、required 与额外字段 · Anthropic:结构化输出及异常完成状态
面试自测 先说结论,再补条件与反例 基础复习 Q1 已有严格结构化输出,为什么后端仍要校验?参考答案 输出约束只覆盖特定模式,不能证明字段来自真实材料,也不能保证用户有权执行相应操作。后端仍需核对身份、范围、数据库状态和跨字段关系,并识别拒答或截断响应。即使提供者承诺符合 Schema,业务服务也应把它视为外部输入,避免一个上游故障污染持久化数据。
☆ 收藏 基础复习 Q2 让模型提取发票金额,怎样设计返回结果更便于核查?参考答案 将金额、币种、原文证据位置和缺失状态分开表示;金额计算采用业务规定的定点或十进制规则,避免浮点舍入误差。提取后与明细合计、税额规则和重复发票记录交叉检查。证据缺失或字段冲突时应进入待确认状态,不能让模型随意补一个看似合理的金额。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 请解释 JSON 解析、Schema 校验与领域校验之间的责任边界。参考答案 JSON 解析确认文本能形成合法语法树;Schema 校验检查对象字段、类型、枚举和声明的局部约束;领域校验结合业务状态与跨字段关系判断是否可接受。例如金额必须是数字属于模式层,明细合计必须等于总额属于领域层,是否有权修改该发票又属于授权层。三层不能互相替代,应返回不同错误类型,便于决定重试、纠正输入或人工处理。响应中断和拒答必须在转换领域对象前识别,不能把解析失败吞掉后创建一张金额为零的合法发票。
答到这些关键点 三层责任用具体例子区分 授权独立于结构和领域一致性 不以默认值掩盖拒答或解析失败 ☆ 收藏 边界反例 Q4 模型输出符合 Schema 的引用列表,是否就能证明回答有依据?参考答案 不能。模式可能只保证引用是字符串数组,无法证明文档存在、用户有权访问,或原文支持相应结论。即使引用编号来自已检索集合,模型仍可能把正确编号配给错误主张。应将答案拆成待核验的事实,校验每个引用的文档版本、片段范围和支持关系,并记录无法支持的部分。对金额或配置值等可确定提取的内容,可以直接从证据执行规则核验。引用数量更多不是更可靠,反而可能用无关材料遮蔽核心证据缺失。
答到这些关键点 区分引用格式、存在性与支持关系 按事实主张绑定证据范围 指出引用数量不能替代正确性 ☆ 收藏 工程场景 Q5 提取服务升级新增“有冲突”状态,旧客户端只认识成功和失败,如何安全演进?参考答案 先为协议声明版本与未知枚举处理规则,不能把未知值映射成成功。服务端可在过渡期按客户端支持能力返回兼容表示,或明确拒绝不支持的版本;持久化记录保留原始状态与证据以便复核。发布前用契约测试覆盖旧客户端、新字段和缺失字段,灰度观察解析与业务失败率。对于已经提交的数据,冲突状态应阻止自动入账并进入确认流程。回滚也要考虑新版本已写出的记录,不能只回滚程序而让旧程序错误解释新数据。
答到这些关键点 未知枚举采取明确失败或兼容策略 契约测试覆盖双向版本组合 考虑持久化数据与回滚兼容 ☆ 收藏
动手验证 给同一个 Schema 构造三种结果:非法 JSON、模式不合规、模式合规但业务矛盾;写明每一层校验负责拦住哪种错误。
+ 我的笔记 用自己的话解释这个知识点
S8-05 / REVIEW NOTES ○ 标为掌握
RAG:从检索证据到生成答案 RAG 将外部资料检索与语言生成结合。典型应用先解析和切分文档,建立关键词或向量索引;回答时检索候选片段,按相关性与权限筛选,再把证据交给模型。原始 RAG 论文研究了参数化模型与可检索记忆的结合,今天的工程系统有许多变体,不能把 RAG 等同于某一种向量数据库。[8-07]
复习项目可为每段内容保存来源、版本、标题与更新时间,并用问题集分别评估是否检索到证据、答案是否忠于证据。若检索没有命中,先检查切分、查询和索引;若证据已命中但答错,再分析提示与生成。资料更新需要同步删除旧索引,访问权限还要在召回或返回前实施,不能只写在提示词里。
代码片段 / 原理示意
离线:文档 → 清洗/切分 → 来源与版本标记 → 索引
在线:问题 → 权限范围 → 检索 → 筛选/重排 → 证据上下文 → 回答
评估:证据召回率 + 答案支持率 + 引用正确率 + 拒答是否合理
DEEP DIVE / 从结论到推演 召回、排序与答案归因分别负责什么 关键词检索适合精确符号和错误码,向量检索有助于语义相近表达;混合检索可以融合候选,再以重排提高前列相关性。重排无法凭空补回从未召回的证据,因此先看候选召回率,再看排序和上下文选择。生成阶段还要检查主张是否被引用片段支持。原始相似度与不同检索器的分数通常不能直接相加,融合方法和参数应由同一问题集验证。
权限必须覆盖索引、缓存和回答 权限过滤需要基于认证身份生成,不能让模型或用户任意填写组标识。先全库检索再过滤可能使可用候选不足,而把未授权候选交给模型或外部重排器又会泄露内容。应按信任边界选择过滤位置,最终取文档时再核对权限,并将租户、授权范围和资料版本纳入缓存设计。撤权后还需使旧缓存失效,避免索引权限正确却从回答缓存返回敏感摘要。
进阶依据:Microsoft:检索安全过滤模式及其认证边界 · Microsoft:混合检索与 RRF 排序融合 · RAG 原始论文;检索与生成结合
面试自测 先说结论,再补条件与反例 基础复习 Q1 RAG 和微调怎样选择?参考答案 需要频繁更新、展示来源或按权限访问的事实,通常先考虑检索;需要稳定改变任务行为、格式或领域习惯时,可评估微调。两者能组合,并非互斥。选择前应建立相同测试集,看错误来自缺知识还是行为不稳定,再比较维护成本;不应把微调当作可以准确更新和删除任意事实的数据库。
☆ 收藏 基础复习 Q2 RAG 系统总答错,如何定位问题?参考答案 把链路拆成文档解析、切分索引、检索筛选和最终生成。先人工检查标准证据是否入库,再查看召回候选与排序,确认版本和权限正确。证据没有进入上下文时调整检索;证据已在上下文却答错时再改提示或模型。每次只改变一个主要变量,并用留出的题集观察是否真正改善。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 为什么提高重排模型质量,不一定提高 RAG 的最终正确率?参考答案 重排只在已有候选集合中调整顺序。如果正确证据没被解析入库、被切断或未进入召回集合,再强的重排也无法恢复它。即使相关证据排在前列,生成仍可能漏掉限定条件或引用错位。应分别统计标准证据的候选召回率、前列命中率和最终主张支持率,用人工候选与人工证据对照定位上限,再决定改切分、召回、排序还是生成。还要观察延迟与成本,避免为了微小排序提升增加大量调用,却没有改善用户实际任务的成功率。
答到这些关键点 指出重排受候选集合上限约束 提出三个分阶段指标和人工对照 把端到端收益与成本一起评估 ☆ 收藏 边界反例 Q4 搜索结果做了权限过滤,为什么答案缓存仍可能越权?参考答案 若缓存只以问题文本作为键,管理员先问出的敏感答案可能被普通用户命中,绕过后续检索。即使缓存加入用户编号,用户撤权后仍可能读到旧结果。缓存应绑定可信租户与授权范围,返回前依据权威权限状态验证全部内容依赖;撤权和文档删除触发失效。不能只校验最终引用,因为摘要可能包含未显示来源的内容。若无法可靠追踪依赖并验证当前权限,就不复用该缓存,改为重新执行经过授权的检索和生成。缩短 TTL 只能限制部分滞后,不能代替授权,也不能保证同一用户撤权后仍有资格访问旧答案。
答到这些关键点 说明问题文本缓存键的跨用户泄漏 撤权后依据权威权限验证,无法验证则不复用 TTL 和引用列表都不能替代完整授权 ☆ 收藏 工程场景 Q5 企业知识库有旧版与新版接口说明,搜索命中很多却常答错,你怎么改?参考答案 先收集真实错误问题,为每题标注目标版本和权威证据,再检查当前召回是否混入过期内容。索引层保存产品、版本、有效时间和文档关系;查询能确定版本时过滤,无法确定时请求澄清或并列说明差异。排序对相同主题的重复版本去重,但不简单删除仍被旧项目使用的资料。生成上下文保留版本标签,答案按结论引用具体片段。用跨版本同名接口的留出集验证,分别观察召回、排序和归因,避免仅修改提示词后用同一批调试问题证明成功。
答到这些关键点 版本元数据和权威证据可追溯 保留合法旧版本而非一刀切删除 跨版本留出集分阶段验收 ☆ 收藏
动手验证 用十篇本地技术笔记构造十个可回答问题和五个无资料问题;手工记录正确证据,再分别评估检索命中与回答可靠性。
+ 我的笔记 用自己的话解释这个知识点
S8-06 / REVIEW NOTES ○ 标为掌握
Agent 与工作流:状态、行动、反馈和终止 普通问答主要产生一次回答;Agent 系统还会根据目标和环境反馈选择行动,并在多轮中继续推进。ReAct 研究展示了推理与行动交替的方式,但工程实现不需要把内部思考逐字公开,也不应把生成了计划当作已经执行。可靠记录应包含实际工具调用、返回结果、状态变化和最终验收证据。[8-08]
固定流程由程序预先规定路径,Agent 则在一定范围内动态选择下一步。边界清楚、步骤稳定的任务适合先做普通工作流;开放问题才可能需要更多自主决策。无论哪种方式,都要定义最大步骤、成本、截止时间、取消信号与失败出口,并避免在相同错误上无限循环。[8-09]
代码片段 / 原理示意
state = {goal, evidence, completed, remaining, budget}
while not finished(state) and within_budget(state):
action = choose_next_allowed_action(state)
observation = execute_with_limits(action)
state = update_from_actual_result(state, observation)
return verified_result_or_explicit_remaining_work(state)
# 控制流程伪代码,非特定 SDK。
DEEP DIVE / 从结论到推演 把 Agent 当成会失败的状态机 行动开始、外部副作用发生和结果被记录可能在不同时间完成。进程重启时若只保存对话摘要,就可能忘记某个已成功操作并重复执行。可靠运行层要保存业务操作标识、已确认状态和待核实结果,以幂等或查询恢复。失败分类也很重要:参数错误应修正,临时故障可有限重试,权限不足需停止或改变方案,不能把所有错误统一变成再调用一次。
评估最终状态,也检查中间路径 Agent 自述完成不等于任务完成,验收应查询环境中实际存在的结果。只看终态也不够:可能最终得到正确文件,却曾读取越权数据或浪费大量成本。评测应同时覆盖结果、约束、调用次数和恢复能力,并为随机性重复试验。评分器自身要用人工核查校准;不同方案如果都满足任务,不应因路径不符合某个固定脚本而一律判错。
进阶依据:Anthropic:Agent 评测中的任务、轨迹与真实终态 · AWS:幂等操作与超时恢复
面试自测 先说结论,再补条件与反例 基础复习 Q1 什么时候不值得引入 Agent?参考答案 当输入输出和执行步骤已经稳定,例如固定字段校验、简单报表或确定的审批规则,普通程序更容易控制延迟、成本和错误。可只在模糊理解环节调用模型,其余保留确定逻辑。引入 Agent 前应证明动态行动选择带来的收益足以覆盖额外调用、状态管理和失败恢复成本。
☆ 收藏 基础复习 Q2 Agent 连续修复同一个编译错误却没有进展,怎么设计停止条件?参考答案 保存每轮错误签名、补丁和结果,识别同类失败重复出现或验收指标没有改善。达到预设次数、预算或时间后,停止继续修改,输出已验证事实、失败原因和所缺信息。也可回到最后一个可工作版本。停止是运行时规则,不能仅在提示中写一句遇到问题别死循环。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 Agent 执行到一半崩溃,重启时为什么不能简单重放全部工具调用?参考答案 历史中可能存在已完成但未收到或未持久化结果的写操作。全部重放会重复创建资源,跳过全部操作又可能漏掉尚未完成的步骤。应为每项业务动作记录稳定标识和状态,重启后对未知状态先查询,必要时按幂等协议重试,并从已确认检查点继续。对无法查询或不支持幂等的外部动作,应明确转人工核实,不能凭模型推断是否执行。还要把状态存储与本地副作用协调起来,识别保存完成之前崩溃的窗口,使恢复规则可通过故障注入验证。
答到这些关键点 识别执行成功但结果未知的窗口 稳定标识、查询和幂等恢复 对不可判定外部操作保留显式人工路径 ☆ 收藏 边界反例 Q4 一个 Agent 所有题都能在十次尝试内成功,是否说明它已达到生产要求?参考答案 这只说明在给定预算和任务集下至少一次成功,不能说明单次成功率、稳定性或成本可接受。若每次成功前都可能产生不可逆错误,最后修正也不能抵消此前损失。应报告首次成功率、固定重试策略下的成功率、总耗时与违规动作数,并检查失败是否集中在关键场景。多次试验应使用一致的初始状态,避免上一轮留下答案或缓存。生产门槛由具体服务目标决定,不能把宽松的多次命中指标替代每次都需要可靠完成的业务承诺。
答到这些关键点 区分至少一次成功与单次稳定成功 检查错误副作用和总成本 重置环境防止尝试之间污染 ☆ 收藏 工程场景 Q5 新增自动重试后成功率上升,但用户投诉更多,你会怎样实验定位?参考答案 先按任务类型拆分投诉,检查是否出现重复副作用、等待变长或失败原因被隐藏。保留相同模型和题集,对照关闭重试、只重试暂时性错误及现有策略,记录终态、次数、延迟和重复操作。注入超时、权限拒绝与明确参数错误,验证分类是否正确。对于结果未知的写操作,单独检查幂等协议,不能当普通网络失败重试。最后用真实目标设定预算和终止条件,必要时宁可早返回可解释的失败,也不要自动耗尽资源后才报告没有进展。
答到这些关键点 按错误类别做受控重试实验 终态之外测延迟和重复副作用 区分超时未知、权限拒绝、参数错误 ☆ 收藏
动手验证 为“分析编译失败并提出补丁”画一张状态图,标出成功、工具故障、预算耗尽和用户取消四种结束路径。
+ 我的笔记 用自己的话解释这个知识点
S8-07 / REVIEW NOTES ○ 标为掌握
模型、Agent 运行层、产品界面如何分工 可以把 AI 编程产品分成三层理解:模型负责理解材料和生成候选输出;运行层管理上下文、工具、权限、重试和任务状态;产品层承载编辑器、差异审阅、会话和用户反馈。这是分析软件的工程视角,不是某种强制行业标准,也不能据此推断任意商业产品的内部实现。
出现故障时按边界排查:模型提出不存在的接口可能是知识或上下文问题;正确调用未执行可能是工具或权限问题;已执行结果没有显示可能是状态同步问题。同样的模型接入不同运行层会有不同效果,因为文件搜索、输出截断和工具反馈都改变了它能观察到的任务环境。
DEEP DIVE / 从结论到推演 分层的价值是建立可替换契约 三层划分是一种分析方法,不是对所有产品内部实现的断言。模型层提供输入输出契约,运行层规定工具和状态语义,产品层呈现进展与可操作结果。模型替换时应验证工具参数、停止原因和上下文格式;运行层升级时应保证任务恢复与事件顺序。把契约写清后,才能用模拟模型或模拟工具隔离故障,而不是每次端到端失败都归因于模型变差。
观测链路要保留因果关系 一次用户操作可能触发多次模型请求和并行工具调用,应以任务、步骤和调用标识关联起来。界面里的“完成”应由已验证状态驱动,不能只根据文本包含成功字样。工具开始、结束、超时和取消必须有明确事件语义,并处理迟到结果。保存排障信息时也要限制敏感内容,优先记录版本、耗时、错误类别与必要证据,避免把完整私有仓库默认复制到日志系统。
进阶依据:Anthropic:工作流与 Agent 的架构区分 · Anthropic:Agent 评测框架与运行环境
面试自测 先说结论,再补条件与反例 基础复习 Q1 为什么同一个模型在两个编程工具里的表现会不同?参考答案 两个工具可能提供不同的文件检索、上下文裁剪、编辑器状态和执行反馈;工具超时、权限与补丁应用方式也会改变任务能否完成。评估时应同时固定模型版本、任务和运行预算,再记录实际输入和工具轨迹。只比较最后一句回答,无法判断差异来自模型还是运行环境。
☆ 收藏 基础复习 Q2 如果让你实现最小 AI 编程助手,你会保留哪些模块?参考答案 保留任务输入、受限文件读取、差异生成与应用、构建测试工具、状态记录和结果展示。文件访问限制在工作目录,操作结果必须反馈给模型,失败不能伪装成成功。先完成一次可复现的读取—修改—验证闭环,再考虑记忆和复杂规划,避免系统复杂度超过实际需求。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 同一模型在两个 Agent 里成功率差一倍,如何判断差异来自哪里?参考答案 先固定任务、模型版本、预算和初始环境,再比较真正发给模型的输入、工具定义与执行反馈。两个系统可能在检索范围、输出截断、错误表示或重试策略上不同。可逐项替换运行层组件:使用相同候选文件、相同工具结果和相同上下文整理策略,观察差异是否缩小。必要时用记录的轨迹重放模型调用,但要承认重放改变了交互环境,不能完全替代真实执行。结论应说明被控制和未控制的变量,避免把产品成绩当作模型固有能力的直接测量。
答到这些关键点 控制模型、预算、环境和任务 比较实际输入与工具契约 说明消融与轨迹重放的适用边界 ☆ 收藏 边界反例 Q4 模型说已保存文件,产品界面是否可以立即显示任务完成?参考答案 不能把自然语言声明当成文件系统事实。运行层需要确认写文件工具返回成功,必要时读取文件或验证内容,再确认任务其余验收条件已满足。工具可能只生成了补丁而未应用,也可能写入错误路径或在返回后发生失败。界面应区分正在处理、已执行、已验证与失败等状态,并关联对应任务标识。若存在迟到工具结果,不能把已取消任务重新显示成成功。用户看到的完成语义必须与系统实际承诺一致,否则看似友好的界面会掩盖数据未落盘的问题。
答到这些关键点 完成由工具与验收事实驱动 区分生成补丁、应用和验证 处理取消与迟到结果的状态一致性 ☆ 收藏 工程场景 Q5 如何在不调用真实付费模型的情况下测试 Agent 运行层?参考答案 定义可替换的模型接口,用脚本化响应模拟正常答案、工具调用、非法参数、截断和取消;工具也提供可控制延迟与失败的替身。验证运行层是否按契约更新状态、限制重试、保留幂等标识并向界面发送正确事件。再用少量真实模型集成测试检查模拟未覆盖的协议差异。模拟测试证明的是控制逻辑,不证明模型能正确完成任务,因此应与真实任务评测分开报告。这样能快速定位运行层回归,也避免每次修改界面都承担完整模型调用成本。
答到这些关键点 模拟模型和工具的正常及异常契约 检查状态、重试、幂等和事件 明确模拟逻辑测试不等于模型能力评测 ☆ 收藏
动手验证 为一个熟悉的 AI 编程工具列出可观察能力,按三层分类;把公开证据与自己对内部实现的猜测分两栏记录。
+ 我的笔记 用自己的话解释这个知识点
S8-08 / REVIEW NOTES ○ 标为掌握
工程约束下的 AI 编程与代码评估 给 AI 的开发任务应包含可复现问题、目标行为、接口约束和验收方式。对于 C++,还需说明语言标准、平台、编译器、依赖版本及所有权约定。先让模型读相关实现与调用点,再做范围明确的修改;审阅差异时重点检查生命周期、并发、错误路径和 ABI,而不是只看命名是否漂亮。
真实代码评估需要把修改放进可执行环境,观察失败用例是否恢复、既有行为是否受影响。SWE-bench 等研究以真实仓库问题和测试来评估修复能力,这比只看生成代码的外观更接近工程任务,但任何公开分数都受任务集与评测设置限制,不能直接替代自己项目的验收。[8-10]
代码片段 / 原理示意
任务描述模板
1. 触发条件与最小复现:输入、实际结果、期望结果
2. 约束:C++ 标准/平台/接口兼容/所有权/允许修改范围
3. 验收:具体行为检查,必要的回归与性能阈值
4. 交付:差异、执行过的命令、结果、仍未验证的边界
DEEP DIVE / 从结论到推演 测试与实现不能共享唯一假设 如果 AI 同时写实现和测试,两者可能把同一个错误理解编码进去,从而得到虚假的全绿结果。应从需求、已知失败样本或独立参考实现建立验收,再让工具补充边界。C++ 还要区分功能正确与未定义行为:普通用例没有崩溃,不代表对象生命周期和同步关系成立。审阅时检查所有权、异常路径和关闭协议,并按风险选择编译诊断、动态检测与必要的性能验证。
小改动也要验证真正的行为差异 有效补丁应解释具体触发条件下的前后行为,而不是只展示改动行数。可以先证明用例在旧代码失败,再证明修复后通过,并检查相关回归。对性能修复,需要同样负载和环境的基线;对并发修复,需要说清消除竞争的同步关系。AI 的作用是提出和实现候选方案,最终证据来自程序执行与代码推理,不能用生成的文字说明替代实际运行结果。
进阶依据:SWE-bench 原始论文;真实仓库行为验收 · C++ 工作草案:数据竞争与 happens-before · C++ 工作草案:shared_ptr 构造与所有权
面试自测 先说结论,再补条件与反例 基础复习 Q1 AI 写完线程池并且测试通过,你还会审查什么?参考答案 检查停止协议、任务拒绝策略、异常传播、等待谓词、对象销毁与工作线程退出顺序,以及满队列时的背压。测试还应覆盖关闭期间提交、任务抛异常和空闲等待等真实边界。若使用线程检测器,需理解其覆盖受执行路径限制;一次无告警不能证明所有并发交错都安全。
☆ 收藏 基础复习 Q2 如何证明 AI 提高了团队效率,而不是只是增加代码量?参考答案 在相近复杂度的任务上记录从需求到通过验收的总耗时、审阅修正时间、缺陷回流和维护成本,并保留未使用 AI 的基线。结果应按任务类型分析,避免只挑成功案例。生成速度和代码行数是过程指标,真正重要的是正确交付所需的总成本及后续质量。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 AI 写的代码和测试全部通过,怎样发现二者共享同一个错误假设?参考答案 先从需求或协议独立推导可观察行为,再检查测试断言是否只是复制实现细节。选择边界输入、反例和性质检验,例如排序结果不仅要等于预期样例,还需保留元素多重集并满足顺序。若有独立参考实现,可在小输入上做差分测试;对缺陷修复,确认新增测试在旧代码确实失败。C++ 还需检查语言前提,未定义行为不能靠样例通过证明安全。最后让审阅者解释为何断言来自需求,而不是因为当前实现刚好返回这个结果,避免测试成为自证正确的装饰。
答到这些关键点 从独立需求或参考实现建立断言 使用反例、性质或差分测试 旧版本失败与 C++ 语义审查并重 ☆ 收藏 边界反例 Q4 模型把所有裸指针都改成 shared_ptr,能否认为内存问题已经解决?参考答案 不能。裸指针可能只是非拥有观察者,机械改成共享所有权会改变销毁时机、产生环或增加同步开销;如果多个控制块错误地管理同一地址,还会重复释放。应先画出资源的创建、拥有、借用和销毁关系,唯一拥有优先考虑 unique_ptr,确有共享生命周期才使用 shared_ptr,回边可用 weak_ptr。对象内部的并发读写也不会因引用计数安全而自动安全。修复应针对原来的悬空路径,并验证异常与关闭顺序,不能以类型名称看起来更现代作为正确性证据。
答到这些关键点 区分拥有与借用关系 指出环、双控制块和生命周期改变 引用计数安全不等于对象线程安全 ☆ 收藏 工程场景 Q5 让 AI 修复服务关闭时偶发崩溃,怎样组织任务和验收?参考答案 提供最小复现、线程与对象关系、关闭步骤和崩溃证据,要求先解释可能的存活期或同步缺口,再做有限修改。验收需覆盖空闲关闭、任务执行中关闭、并发提交与异常任务,明确是排空任务还是取消任务,并确认所有工作线程在依赖对象销毁前退出。可结合地址或线程检测器扩大可观察范围,但要说明一次无告警的局限。审阅补丁时检查新增锁是否引入死锁,反复执行代表性场景并保留失败次数和环境。最终报告未覆盖的交错,避免声称绝无并发缺陷。
答到这些关键点 定义关闭语义与生命周期顺序 覆盖关闭和提交交错及异常 动态检测与同步推理结合且说明边界 ☆ 收藏
动手验证 选一个小型 C++ 缺陷,先独立写出失败复现和验收,再让工具修复;把生成时间、审阅时间、修改次数和最终证据分别记录。
+ 我的笔记 用自己的话解释这个知识点
S8-09 / REVIEW NOTES ○ 标为掌握
上下文管理、成本预算与离线评测 一次任务的总成本包含输入、输出、工具、重复尝试和等待时间,具体计价取决于服务合同;自部署还要计入硬件利用率与维护成本。减少重复材料、按需检索、限制工具结果、给高成本环节设置预算,往往比把所有提示词压缩成缩写更可控。摘要应保留约束、已验证事实、未完成事项和证据位置。
建立小而稳定的离线题集,覆盖常见请求、边界输入、资料不足和失败恢复。每次只变更主要因素,例如模型、检索策略或提示模板,比较成功率、延迟分位数、成本和人工修正量。评测题不要与调试样例完全重合,否则可能只优化熟悉题目而没有改善泛化表现。
DEEP DIVE / 从结论到推演 以完整任务为计量单位 输入价格更低的方案可能因失败重试、输出冗长或人工返工而更贵。比较时固定质量门槛,把模型调用、工具、排队与审阅成本放进同一任务账本;成功和失败样本都应计入,避免只计算成功请求。缓存收益也要按真实命中率评估,并说明冷启动条件。对于自部署,还需要考虑低负载时的空闲成本,不能把峰值吞吐下的单价外推到全天业务。
评测设计要防止泄漏和混杂 提示优化所用样本属于开发集,最终判断需要留出集;同一仓库或模板高度相近的题目应避免跨集合泄漏。比较两种方案时保持相同任务、预算和环境,随机化运行顺序以减少服务负载变化的影响。对非确定系统使用重复试验并报告波动,检查提升是否来自少数异常样本。自动评分规则应抽样人工复核,尤其警惕模型评审偏好长答案而忽略真实正确性。
进阶依据:Anthropic:Agent 评测、多次试验与评分器 · SWE-bench;以任务环境与可执行验收衡量代码能力
面试自测 先说结论,再补条件与反例 基础复习 Q1 如何在不明显损伤答案质量的情况下缩短上下文?参考答案 优先去除重复日志、生成文件和已经被新证据替代的信息,保留当前接口、约束、关键错误与来源。把长任务整理成结构化状态,再按需读取原文。用固定题集比较压缩前后效果,尤其检查跨文件约束与失败案例;不能仅凭 token 数下降就宣布优化成功。
☆ 收藏 基础复习 Q2 比较两个模型时,为什么只看每百万 token 价格不够?参考答案 完成相同任务所需的输入、输出和重试次数可能不同,还可能需要额外工具和人工审阅。应按完整任务统计成功交付成本,并在相同质量门槛下比较延迟和稳定性。对自部署还要考虑并发、显存、空闲时段和运维工作,避免把资源采购价格误当作真实使用成本。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 怎样计算一个 AI 编程方案的“每个成功任务成本”?参考答案 先定义成功门槛与最大尝试预算,再累计整个评测期间所有任务的模型、工具和必要人工成本,包括最终失败的任务,而不是只加成功请求的账单;总成本除以成功任务数得到该负载下的指标。同时单独报告成功率、总耗时和失败分布,否则一个只解决简单任务的方案可能看起来非常便宜。对自部署把设备折旧、服务资源和空闲时段纳入约定口径,对人工成本说明估算方法。比较结果只对任务分布和质量门槛成立,不能当作模型的永久固定价格属性。
答到这些关键点 失败尝试成本进入分子 成功率和任务分布单独披露 明确云调用、自部署和人工成本口径 ☆ 收藏 边界反例 Q4 同一批十道题连续调提示直到全对,为什么不能直接宣称准确率 100%?参考答案 这十道题已经参与选择提示,属于开发过程的一部分,结果带有对样本的适配偏差,无法估计新问题上的表现。应冻结提示后使用事先保留且不参与调整的题集,并检查相似模板或同一问题改写是否泄漏;必要时按仓库或问题来源划分。样本量太小还会有较大随机波动,应报告数量和重复试验结果。若新题失败后继续修改,则这些题也转成开发集,需要新的最终评估。原有全对结果仍可作为回归检查,但不应被包装为普遍能力证明。
答到这些关键点 指出开发集反复调优造成偏差 留出集防相似题泄漏 区分回归通过与泛化准确率 ☆ 收藏 工程场景 Q5 团队更换便宜模型后账单下降但交付变慢,你怎样找最合适的路由策略?参考答案 先按任务复杂度和风险分组,记录旧新模型的通过率、尝试次数、耗时与人工修正。简单格式提取和复杂跨文件修复可能适合不同方案,不能只看总体平均。设计固定路由条件或低成本先尝试、失败再升级的策略时,要计入两次调用和等待成本,并限制升级循环。用留出任务比较直接强模型与分级路由,确保失败检测本身可靠,不让错误答案因表面格式合规被当成成功。最终选择满足质量与时限目标的最低总成本,而不是单 token 最便宜的模型。
答到这些关键点 按任务类型分组评估 路由包含失败检测与升级成本 固定质量及时限比较完整任务成本 ☆ 收藏
动手验证 为十个固定问题建立 CSV:任务、是否通过、输入量、输出量、耗时、重试数和人工修正分钟;用它比较两种提示设计。
+ 我的笔记 用自己的话解释这个知识点
S8-10 / REVIEW NOTES ○ 标为掌握
C++ 与 ONNX Runtime:把模型变成可维护组件 ONNX 表示模型计算图,ONNX Runtime 提供执行环境及 C/C++ 接口,并通过 Execution Provider 接入不同计算后端。接入前先检查模型算子、输入名称、形状与数据类型;推理流程应复用会话,准备有效缓冲区,执行,再按输出约定解释结果。导出成功并不保证每个算子都能在目标后端运行。[8-11]
C++ 推理集成特别关注资源所有权与数据搬运:输入张量若借用外部缓冲区,缓冲区必须活到运行结束。GPU 计算很快时,CPU/GPU 拷贝反而可能占主导;I/O Binding 能指定输入输出所在设备,减少不必要搬运,但不能自动消除所有同步与传输。优化前先把预处理、拷贝、执行和后处理分别计时。[8-12]
代码片段 / 原理示意
集成检查顺序
模型文件与版本 → 输入/输出元数据 → 预处理一致性
→ 长寿命 Session → 有效张量缓冲区 → Run → 输出解释
→ 与参考结果比较 → 分阶段计时 → 再选择 Provider 与 I/O Binding
此处为流程示意,具体 C++ 构建参数以本机安装版本为准。
DEEP DIVE / 从结论到推演 张量包装与底层存储不是同一个生命周期 C++ 张量 API 可能包装调用者提供的缓冲区,也可能由运行时分配,必须逐个重载确认所有权。包装对象存在,不代表借用的 vector 数据仍有效;扩容、移动或提前释放都可能使地址失效。异步后端还要求缓冲区活到设备使用结束,而不是活到提交函数返回。集成时把输入、输出、会话和设备事件的依赖画清楚,使用明确的拥有对象约束释放点。
后端选择与图分区影响端到端性能 Execution Provider 根据支持能力承接图中的节点或子图,注册了 GPU 后端不代表所有计算都在 GPU。回退可能造成跨设备拷贝,单个算子加速并不一定抵消传输。应查看实际节点分配与性能分析,区分会话初始化、预处理、拷贝、执行和后处理。I/O Binding 只解决指定输入输出位置的问题,仍要确认设备、数据类型、形状与同步关系一致,不能把绑定视作零拷贝的统一保证。
进阶依据:ONNX Runtime:Ort::Value 与张量创建重载 · ONNX Runtime:Execution Providers 的图承接机制 · ONNX Runtime:I/O Binding 的存储位置与拷贝
面试自测 先说结论,再补条件与反例 基础复习 Q1 同一 ONNX 模型在 C++ 中的结果和训练代码不同,先查什么?参考答案 先核对输入的尺寸、维度顺序、颜色通道、归一化、数据类型和预处理参数,再核对模型导出版本、算子支持及后处理。用固定小输入比较中间或最终张量,设置合理数值误差范围。先定位正确性差异,再调整性能选项;直接更换 GPU 后端可能让问题更难复现。
☆ 收藏 基础复习 Q2 为什么 GPU 推理比 CPU 更慢?参考答案 可能是请求太小、模型加载和初始化占比高、数据拷贝或同步成本大,也可能部分算子回退造成跨设备搬运。应预热并复用会话,分别测量预处理、传输、设备执行和后处理,使用真实批量与形状分布。确认瓶颈后再评估 I/O Binding 或批处理,不能只凭 GPU 名称推断一定加速。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 用 vector.data() 创建张量后,为什么 vector 扩容可能使推理失败?参考答案 如果所用 API 只是包装外部存储,张量持有的是当时的数据地址,而不是对 vector 的动态跟踪。扩容可能分配新空间并释放旧地址,张量随后访问的就是失效缓冲区;张量包装器还存在也不能延长旧存储寿命。应在创建张量前确定大小,推理使用期间禁止影响地址的操作,或选择由运行时拥有存储的接口并显式复制数据。若执行异步,还要等待相关设备工作完成才允许复用。排查时核对具体 API 重载,不能把所有 CreateTensor 变体都理解成相同所有权语义。
答到这些关键点 借用地址不跟随 vector 扩容 具体重载决定分配和所有权 生命周期覆盖异步实际完成点 ☆ 收藏 边界反例 Q4 已经启用 CUDA Provider 和 I/O Binding,是否可以断言推理全程零拷贝?参考答案 不能。图中仍可能有算子回退到 CPU,输入也可能经过预处理或格式变换;输出位置、设备编号和内存类型不匹配时仍需搬运。I/O Binding 表达的是输入输出存储安排,并不证明整条链路没有复制。应查看实际图分区与 trace,把主机预处理、主机到设备、跨设备和设备到主机传输分别统计。即使没有显式拷贝语句,框架也可能执行隐式搬运。优化目标是端到端延迟和资源效率,必要的数据转换可以保留,但必须清楚它发生在哪个阶段以及由谁同步。
答到这些关键点 图回退与格式转换仍可能拷贝 绑定作用范围不等于全链路保证 用分区和 trace 验证隐式传输 ☆ 收藏 工程场景 Q5 C++ 服务复用了 Session,但并发后延迟抖动明显,如何排查?参考答案 先确认并发调用方式符合具体 Provider 与版本的线程安全约定,并检查是否错误共享可变输入、输出缓冲区。然后分解请求排队、线程池调度、预处理、设备执行与同步时间,观察 CPU 线程过度订阅、GPU 资源争用或动态形状导致的分配。用固定形状和单请求基线逐步增加并发,对比复用独立缓冲区与当前方案,并记录延迟分位数和显存峰值。不要直接每线程新建 Session,因为重复权重和初始化可能加剧问题;应根据瓶颈选择限流、批处理或线程配置调整。
答到这些关键点 先核对 Provider 并发约定与缓冲区隔离 分阶段测量排队及资源争用 不盲目每线程新建会话 ☆ 收藏
动手验证 如果已有合法可用的 ONNX 模型,用固定输入完成 CPU 基线;记录首轮、预热后运行和端到端耗时。没有模型时先画清张量缓冲区的所有权图。
+ 我的笔记 用自己的话解释这个知识点
S8-11 / REVIEW NOTES ○ 标为掌握
TensorRT:构建引擎、执行上下文与精度取舍 TensorRT 的部署流程通常区分构建和运行:构建阶段解析网络并选择优化实现,运行阶段加载引擎,通过执行上下文绑定张量地址和启动计算。引擎能否跨设备、版本或平台复用取决于兼容设置和支持范围,不能把序列化引擎当作任意机器通用的模型文件。[8-13]
动态输入需要让实际形状落在构建时支持的范围,并在执行前设置对应形状;具体 profile 和 API 以使用版本为准。降低精度可能降低带宽和显存成本,但收益依赖硬件、算子与模型,必须在代表性数据上比较误差与业务指标。异步提交后要等完成再复用输入输出缓冲区,不能把提交返回当作计算结束。[8-14]
面试自测 先说结论,再补条件与反例 基础复习 Q1 为什么构建 TensorRT 引擎适合与在线请求路径分开?参考答案 构建通常包含图处理、优化和实现选择,耗时与资源使用不适合每个请求承担。部署时可提前构建并验证目标环境可加载的产物,在线只创建必要运行状态并复用。仍需保存原模型、构建配置和环境信息,方便硬件或版本变化时重建,而不是只留下一个无法追溯的引擎文件。
☆ 收藏 基础复习 Q2 输入 shape 不固定时,如何设计性能实验?参考答案 从生产分布挑选常见、最小和最大形状,确保它们处于配置支持范围;记录每类形状的延迟、显存和吞吐。不要只测一个最优尺寸就宣布整体提升。对不支持或超预算的形状定义拒绝、分批或其他后端回退方案,并核对每种路径的数值输出。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 动态形状 profile 已经覆盖输入,为什么仍可能性能差或内存不足?参考答案 覆盖只表示形状落在允许范围,不代表每个尺寸都接近优化目标。宽范围可能改变可选 tactic 与工作区需求,某些实现只有固定或较窄形状时才可用;并发执行还会叠加上下文和缓冲区开销。应按真实形状分布分别测试,而不是只测 opt 尺寸,必要时把常见形状分成多个 profile 或独立产物。运行时核对所有输入之间的维度约束和实际输出大小,并预留资源预算。最终选择需要平衡产物数量、内存与延迟,不能靠把 max 一味调大解决所有请求。
答到这些关键点 允许范围不等于每个形状最优 解释 tactic、工作区及并发内存影响 按真实分布选择 profile 并验证输出形状 ☆ 收藏 边界反例 Q4 FP16 只比 FP32 少些有效位,为什么结果有时会直接出现 Inf 或 NaN?参考答案 精度降低不仅影响舍入,浮点格式的指数范围也不同。FP16 的有限范围较窄,归约或中间激活超过可表示值时可产生 Inf,后续运算再传播成 NaN。因此不能只用最终误差阈值处理,应检查异常值首次出现的层和输入范围。可让敏感运算保持较高精度、使用合适的混合精度或修正归一化,但必须在代表性和极端输入上重新比较。BF16 有不同的范围与尾数取舍,也不是对任何模型自动更准确;选择应由实际硬件支持和业务误差容忍共同决定。
答到这些关键点 区分指数范围与尾数精度 定位首次溢出而非只看最终误差 混合精度按模型和输入重新验收 ☆ 收藏 工程场景 Q5 在开发机生成的 TensorRT 引擎部署到另一台 GPU 无法加载,怎样处理?参考答案 收集构建与运行的 TensorRT、CUDA、驱动、GPU、平台和插件信息,读取明确错误,先判断是版本、硬件、依赖还是资源不匹配。不要修改二进制头绕过检查;应按目标环境重建,或在文档支持范围内启用适当兼容配置后重新构建。兼容模式可能排除部分优化实现,所以还要复测延迟、显存与精度。部署产物应带模型摘要、构建配置和验证报告,并保留原模型及回退路径。成功反序列化只是第一步,必须在目标机器上跑代表性输入与并发负载后才算可交付。
答到这些关键点 建立构建/运行环境差异清单 按支持范围重建而不绕过检查 兼容模式仍需性能、显存和精度验收 ☆ 收藏
动手验证 设计一张部署清单:原模型摘要、TensorRT/CUDA/驱动版本、GPU 型号、允许形状、精度、验证数据集和回退方式;不需要运行 GPU 即可完成。
+ 我的笔记 用自己的话解释这个知识点
S8-12 / REVIEW NOTES ○ 标为掌握
KV Cache:时间与显存之间的交换 在典型自回归 Transformer 推理中,过去 token 在各注意力层的 K、V 可缓存,后续生成只追加新 token 的 K、V,避免反复计算相同历史信息。KV Cache 缓存的是中间张量,不是最终答案,也不是模型权重;新的 query 仍需访问相应历史缓存,因此缓存不代表每步工作量与序列长度完全无关。[8-15]
对普通稠密全注意力模型,忽略对齐和管理开销,可用 2×层数×批大小×序列长度×KV 头数×头维度×每元素字节数估算缓存。GQA/MQA 应使用 KV 头数,不能直接套 query 头数。分页管理可减少碎片并便于共享,但不取消长序列本身的存储需求;滑动窗口、压缩和量化需要另按结构估算。[8-16]
代码片段 / 原理示意
示例:32 层,batch=1,4096 token,8 个 KV 头,head_dim=128,FP16
KV 字节 ≈ 2 × 32 × 1 × 4096 × 8 × 128 × 2
= 536870912 B = 512 MiB
不含权重、激活、临时工作区、块管理和对齐开销。
DEEP DIVE / 从结论到推演 prefill 与 decode 的资源侧重点 prefill 处理已有输入并建立缓存,decode 逐步生成并访问历史缓存;两者在典型部署中的算术密度和调度需求不同,不能假定同一批策略总是最好。长输入 prefill 可能推迟已有会话的下一 token,过多 decode 又可能压缩新请求进入机会。分块 prefill 或不同资源安排是可选策略,收益需按长度与并发分布验证;模型结构、批大小和硬件也会改变瓶颈位置。
KV 预算必须包含未来增长 准入不能只看请求当前占用,因为输出继续生成会增加 KV 缓存。常见稠密模型可先用层数、KV 头数、头维度和元素字节数估算每 token 成本,再考虑块粒度、共享、量化和临时工作区。若按实际增长分配,就必须设计额度、抢占或拒绝策略;若一次预留最大长度,则会牺牲利用率。分页减少碎片,不会凭空消除活跃 token 的真实存储需求。
进阶依据:Hugging Face:KV 缓存机制与按层存储 · PagedAttention 原始论文;KV 分页与共享 · Hugging Face TGI:分块与缓存设计,性能数字不作通用保证
面试自测 先说结论,再补条件与反例 基础复习 Q1 权重已经放得下,为什么并发一提高还是会显存不足?参考答案 每个活跃请求还需要 KV Cache,大小随序列长度和并发增长,此外有临时工作区、输出与分配器开销。容量规划要根据输入和输出长度分布以及最大同时运行请求数估算,必要时设置准入与排队。只计算模型参数乘以元素字节数,会系统性低估推理服务的内存需求。
☆ 收藏 基础复习 Q2 PagedAttention 为什么对服务并发有帮助?参考答案 它把 KV 存储按块组织,以逻辑到物理块映射管理增长的序列,减少大块连续分配造成的浪费,并支持适当的共享。释放的块可供其他请求使用,从而改善显存利用率。它没有改变模型需要历史 K、V 的基本事实,具体收益仍取决于长度分布、批处理、硬件和实现。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 如何从模型结构推导 KV Cache 的容量,而不是只背一个公式?参考答案 每层为每个历史 token 保存 K 和 V 两组向量,每组由 KV 头数乘头维度组成,再乘元素字节数。因此把两组、层数、活跃序列和各自长度累加,就得到普通稠密全注意力的基础容量。GQA 使用 KV 头数而非 query 头数,长短请求不能简单用平均长度代替峰值。实际还需加入块取整、元数据和运行时工作区;滑动窗口或压缩缓存要按实现修正。容量规划最终以真实显存峰值校准,并为输出增长和其他任务保留余量,不能用理论最小值当硬上限。
答到这些关键点 由每层每 token 的 K/V 张量推导 GQA 与变长请求使用正确维度 加入块开销、工作区和未来增长 ☆ 收藏 边界反例 Q4 两个用户提示词前缀完全相同,是否一定可以共享 KV Cache?参考答案 仅凭文本相同不足以判定,还需确认实际 token 序列、模型权重、适配器、位置处理及相关运行配置一致,否则同一前缀也可能对应不同中间状态。共享对象必须不可变或采用正确的写时复制,分支生成不能覆盖其他请求的缓存。多租户还需考虑缓存是否泄露访问或时序信息以及隔离策略。应由服务的已验证缓存键和权限策略决定共享,而不是由应用仅比较字符串。即使能共享前缀,各请求新增部分仍独立增长,不能把共享命中率直接换算成总显存减少比例。
答到这些关键点 共享条件超出文本相等 分支生成隔离及写时复制 多租户边界和新增 KV 仍独立增长 ☆ 收藏 工程场景 Q5 模型权重占 12 GiB,显卡 24 GiB,如何决定能接多少并发请求?参考答案 不能直接把剩余 12 GiB 全分给缓存。先测固定运行时和临时工作区峰值,保留安全余量,再按模型每 token KV 成本估算可容纳的活跃 token 总量。结合输入长度与最大输出预算制定准入,而不是只有固定请求数限制;一条超长请求可能比多条短请求更贵。压测时覆盖长短混合、同时增长、取消与缓存碎片,记录实际峰值并校正估算。若动态超额承诺资源,应明确抢占或拒绝规则,并评估重算代价与延迟,避免所有请求接入后同时增长导致 OOM。
答到这些关键点 先扣运行时与工作区并留余量 用活跃 token 和输出预算而非只用请求数 压测增长、取消和超额承诺策略 ☆ 收藏
动手验证 用给定公式分别计算 batch 从 1 到 8、长度从 4K 到 16K 的缓存;再将 KV 头数由 8 改成 32,解释增长来自哪个维度。
+ 我的笔记 用自己的话解释这个知识点
S8-13 / REVIEW NOTES ○ 标为掌握
CUDA 基础:线程映射、访存与同步 CUDA 程序通常由 CPU 主机代码组织数据和启动 GPU kernel,GPU 线程按 block 和 grid 编排。线程用索引映射数据,例如一个线程处理一个数组元素;block 之间不能随意假设执行顺序。GPU 适合大量相似工作,任务很小、依赖强或传输密集时,启动与拷贝成本可能抵消计算收益。[8-17]
性能和正确性要同时检查:相邻线程访问相邻地址有利于访存合并;同一 warp 的分支分歧可能降低执行效率;共享内存协作需要恰当的块内同步。普通 __syncthreads 不是整个 grid 的屏障,也不能放在只有部分应参与线程执行的分支中。越界、竞争与错误同步比单纯调 block 大小更值得先排查。[8-18]
代码片段 / 原理示意
// CUDA kernel 片段;需有效设备缓冲区、正确分配与主机启动代码。
__global__ void add(const float* a, const float* b, float* out, int n) {
const int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i < n) out[i] = a[i] + b[i];
}
// n > 0 时:add<<<(n + 255) / 256, 256>>>(a, b, out, n);
// 主机须检查启动错误,并在读取结果/释放资源前确认执行完成。
DEEP DIVE / 从结论到推演 先判断算力瓶颈还是带宽瓶颈 算术强度是完成一定工作所需运算量与内存传输量的比值。向量加法通常每元素读两个数并写一个数,算术很少,因此更可能受带宽限制;此时增加算术单元利用率未必有用。优化先减少无效访问、改善连续访问并避免多余中间数组,再用实际传输量和时间估算有效带宽。缓存命中和编译优化会影响流量,不能只用源码读取次数代替性能分析器观测。
异步执行要求明确依赖与释放点 同一 stream 提交的工作有顺序,不同 stream 的相关工作需要事件等依赖连接;多 stream 也不保证硬件一定并行。主机函数返回可能只代表已提交,输入复用、输出读取和释放都必须等待实际使用完成。事件既可表达依赖也可辅助计时,但跨 stream 的测量要明确覆盖范围。错误处理同样分启动失败和执行阶段失败,不能只检查一次提交返回值就认为设备工作成功。
进阶依据:NVIDIA:CUDA Best Practices,带宽与性能分析 · NVIDIA:CUDA Asynchronous Execution,stream 与 event
面试自测 先说结论,再补条件与反例 基础复习 Q1 为什么 CUDA 代码有边界判断 i < n?参考答案 线程块大小通常固定,网格向上取整以覆盖所有元素,最后一个块可能包含超出数组范围的线程。边界判断防止它们访问无效位置。还要检查索引类型和乘法是否可能溢出,以及输入输出长度是否一致;边界判断只能解决这一种越界来源,并不能替代完整的主机侧参数校验。
☆ 收藏 基础复习 Q2 怎样测量 kernel 的运行时间,避免把异步提交时间当计算时间?参考答案 在相应 CUDA stream 中放置开始和结束 event,确认结束 event 完成后读取设备计时;另测包含传输和等待的端到端时间。测量前预热,并记录输入大小、设备和重复次数。如果仅用 CPU 时钟包住启动语句而不等待完成,测到的通常主要是提交成本,不能据此声称计算加速。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 为什么向量加法常常不能靠增加计算线程获得无限加速?参考答案 每个元素只有一次加法,却至少需要读取两个输入和写出一个结果,算术强度很低。当并行度已足以覆盖访存延迟并接近可用带宽时,继续增加线程不会提高数据通道容量,反而可能增加调度或资源压力。应估算总字节数除以执行时间得到有效带宽,并结合分析器检查访问合并、缓存和实际事务。优化重点可能是融合相邻操作、减少中间写回或避免主机设备往返,而不是盲目把 block 调大。仍需用目标 GPU 和数据规模实测,因为小输入也可能主要受启动开销限制。
答到这些关键点 从低算术强度推导带宽限制 有效带宽和实际事务验证 区分小输入启动成本与大输入带宽瓶颈 ☆ 收藏 边界反例 Q4 两个 CUDA stream 分别拷入数据和启动 kernel,代码提交顺序正确就安全吗?参考答案 不同 stream 之间不能仅凭主机提交先后推断设备上的数据依赖。若 kernel 读取的缓冲区尚未完成拷入,就可能看到未完成数据。可把有依赖的操作放在同一 stream,或在拷贝 stream 记录 event,让计算 stream 等待该 event;输出和缓冲区复用也要建立对应完成关系。默认 stream 具有不同配置下的特殊同步语义,更不应依赖偶然行为。完成正确性后再测是否发生实际重叠,因为并行程度还受硬件资源、内存类型和调度影响,多个 stream 本身不是性能保证。
答到这些关键点 跨 stream 无隐含业务依赖 用同 stream 或 event 建立先后关系 默认 stream 及实际重叠有适用条件 ☆ 收藏 工程场景 Q5 GPU kernel 测得只需 0.2 ms,但接口延迟 20 ms,怎么分析?参考答案 先确认 0.2 ms 是正确同步后的设备时间,而不是异步提交耗时。为请求建立时间线,分别统计排队、预处理、内存分配、主机设备传输、kernel、同步、后处理与网络输出。检查是否每次创建上下文或分配缓冲区,是否有小传输和不必要的全设备同步。对主要耗时阶段做单变量优化,例如复用资源或合并传输,再比较端到端分位数。还要控制并发与预热条件,避免拿空载 kernel 时间对比拥塞接口。只有解释剩余的 19.8 ms,优化 kernel 才有明确优先级。
答到这些关键点 先验证 GPU 计时语义 建立完整请求时间线 按瓶颈而非局部最小时间决定优化 ☆ 收藏
动手验证 在有 CUDA 环境时实现向量加法,与 CPU 参考结果比较并检查 n=1、非块大小整数倍等输入;没有环境时手工推导 n=1000、block=256 的线程范围。
+ 我的笔记 用自己的话解释这个知识点
S8-14 / REVIEW NOTES ○ 标为掌握
推理服务:批处理、背压、流式输出与验收 推理服务除了模型执行,还要管理请求队列、超时、批处理、取消与资源上限。普通动态批处理把兼容请求凑成一批提高吞吐,但等待凑批会增加延迟;有状态请求和自回归生成需要适合其状态生命周期的调度。不能把普通无状态模型的动态 batch 配置直接当成所有大模型服务的最佳策略。[8-19]
面试中应明确服务目标:首 token 延迟衡量开始响应的等待,后续 token 间隔影响流式体验,整体吞吐描述单位时间完成工作量。必须同时看延迟分位数、失败率和资源利用率。持续过载时需要有界队列、准入或降级;客户端断开后还应传播取消并回收请求资源,避免后台继续无用生成。
代码片段 / 原理示意
请求 → 输入限制/鉴权 → 有界队列 → 调度与合批 → 推理 → 流式响应
↓ ↓
超时/拒绝 取消传播与资源回收
最低观测项:排队时间、首 token 延迟、token 间隔、吞吐、失败率、显存峰值
DEEP DIVE / 从结论到推演 背压保护的是整个请求生命周期 推理入口可同时限制排队请求数、等待 token 量和预计显存,避免少量超长输入绕过简单计数。队列满时应有明确拒绝或降级,不把无限排队伪装成可靠接受。调度还需平衡首 token 延迟和已开始请求的生成间隔,并限制长请求对短请求的影响。验收使用长短混合与突发负载,分别观察排队、吞吐、失败和公平性,不以单一平均延迟作结论。
取消不是立即强杀 GPU 工作 客户端断开只是一条信号,网关、调度器和模型后端都要传播并检查取消。已经提交的设备工作未必能立即安全中止,通常应在支持的边界停止后续步骤,等待仍在使用缓冲区的工作结束再回收。具体服务器和后端支持不同,不能只测试 HTTP 连接关闭。要验证取消后不继续排新任务、KV 与输出缓冲最终释放、状态只完成一次,并记录释放延迟。
进阶依据:NVIDIA Triton:批处理、队列与调度 · NVIDIA Triton:请求取消与后端支持边界 · Hugging Face TGI:分块及缓存调度的实现示例
面试自测 先说结论,再补条件与反例 基础复习 Q1 如何设计一个 C++ 推理网关,避免请求把 GPU 压垮?参考答案 在入口限制请求大小和输出上限,建立有界等待队列,按显存与并发预算做准入;调度层只合并兼容请求,并为每个请求维护截止时间和取消状态。达到上限时明确拒绝或排队,不能无限缓存。监控队列长度、资源峰值和超时率,再用负载测试决定容量与回退阈值。
☆ 收藏 基础复习 Q2 吞吐提高了 30%,能否说明优化成功?参考答案 还要比较同等正确性和服务目标下的延迟分位数、错误率、显存占用与成本。如果吞吐提升来自大幅延长排队,交互体验可能变差。实验需固定硬件、模型、请求长度和并发分布,并说明是否包含预处理与传输;只有达到约定的多项验收指标,才能认为优化对该场景有效。
☆ 收藏
连续追问:再往下问三层 开始本节追问 按原理、边界、工程场景依次回答。以下是模拟深入面试的原创题,不代表某家公司的真实题目。
原理追问 Q3 为什么推理服务限流不能只看每秒请求数?参考答案 请求的输入长度、输出预算、模型和批处理兼容性不同,资源需求可能相差几个数量级。相同请求数下,长 prefill 会占用更多计算,长输出会持续占用 KV 和解码调度。因此应结合请求率、队列深度、活跃 token、预计显存和截止时间做准入,并在运行中跟踪实际增长。预算估计不准时还要有保守余量及拒绝或抢占策略。限流规则应向调用者返回清晰状态和重试建议,避免客户端立即重试放大拥塞;最终以真实混合负载验证延迟与稳定性。
答到这些关键点 请求成本随长度和阶段差异变化 多维准入及运行时资源跟踪 考虑重试风暴和混合负载验收 ☆ 收藏 边界反例 Q4 客户端已取消请求,服务可以立即释放输入和 KV 缓存吗?参考答案 不能一概立即释放。取消意味着不再需要结果,但已提交的 kernel 或拷贝可能仍在访问这些内存,过早回收会造成使用已释放内存或污染其他请求。应将请求标记取消,阻止未来调度,在后端支持的检查点停止后续生成,并等相关设备事件完成后释放仍被使用的资源。若与正常完成同时发生,需要原子化或受锁保护的终态转换,确保释放和回调各执行一次。不同后端取消能力不同,必须测试实际资源回收而不是只看到连接断开或返回取消状态就认为工作停止。
答到这些关键点 取消与设备完成不同 安全检查点及事件后回收 完成/取消竞争下只释放一次 ☆ 收藏 工程场景 Q5 连续批处理提升吞吐,却让短问题的首 token 延迟恶化,怎样改进?参考答案 先分解短请求是在队列等待、prefill 还是调度切片中耗时,并按长短请求组合复现实验。可能是长输入 prefill 占用过长,也可能解码批持续满载使新请求难以进入。可评估分块 prefill、预留新请求预算、长度分桶或更明确的公平调度,但每种策略都需检查吞吐、生成间隔和显存变化。用相同到达率与长度分布比较分位数,避免降低负载后误认为算法改进。最终围绕约定服务目标选择折中,并保留有界队列和超时,不能以无限等待换取漂亮吞吐数字。
答到这些关键点 定位排队、prefill、decode 的具体阻塞 提出可验证的分块或公平调度策略 同负载比较多个延迟及资源指标 ☆ 收藏
动手验证 先用固定延迟函数模拟模型,编写有界队列和取消流程;在负载超过处理能力时验证队列不无限增长、超时可见且资源会释放。
+ 我的笔记 用自己的话解释这个知识点
从理解到实践 阶段项目与验收 阶段项目:为本复习手册设计一个带来源的问答服务原型。最小版先用本地关键词检索和模拟生成器跑通接口、资料不足处理、有界队列、超时和取消;已有模型环境时再接入实际推理。验收至少包含 10 个有依据问题、5 个无资料问题、错误输入、断开连接、并发超载和内存回收,并报告证据命中率、答案支持率、延迟分位数及资源峰值。AI 编程辅助只负责提出与实现候选方案,最终以执行结果和人工审阅验收。本章代码均为明确标注的片段或流程示意,未声称完成 GPU 实测。
SYSTEM INTERVIEW / 跨知识点综合题 推理吞吐提高了,为什么用户体验反而下降? C++ 推理网关增大 batch 后总 tokens/s 上升,但首 token P99 翻倍,长请求挤占显存,取消请求仍在生成。假设模型正确性不可降低,要求提出诊断、资源预算和调度改进方案。
展开方案与推理 首先固定硬件、模型、精度、输入/输出长度分布与到达过程,分别记录排队、prefill、decode、拷贝和输出阻塞。首 token 延迟反映等待与预填充,后续 token 间隔反映生成体验,总吞吐不能替代两者。区分普通无状态模型的动态批处理与生成模型的持续批处理/请求状态调度,不能把某个 batch 参数的结论直接移植到所有推理系统。
给权重、KV、临时工作区和分配器开销留出预算,准入根据同时活跃 token 与每请求最大生成量,而不是只按请求数。长上下文可能导致显著 prefill 工作与 KV 占用,可在服务目标允许时采用长度分组、分块预填充、每轮预算和公平调度;这些选择会影响吞吐、首 token 与短请求等待。需说明具体运行时是否支持这些机制,不能把设计方案描述成已存在的默认能力。
取消必须从客户端连接传播到队列与后端任务:未执行任务移除,在途执行在安全点停止继续调度,等待设备仍使用的缓冲完成后再归还;防止双重释放和使用未完成 GPU 数据。性能验证同时报告质量、有效完成吞吐、首 token/P99 间隔、拒绝率、显存峰值和取消后的回收时延。若在相同服务目标下没有提升,应承认此次 batch 调整并未改善该场景。
验收证据 比较在相同正确性与延迟目标下进行,负载含长短混合请求。 KV 与排队预算有界,过载时可解释地拒绝或延迟。 取消后设备访问安全结束,资源在约定时限内回收。 继续追问 追问:显存足够,为什么仍不能把 batch 一直加大? 硬件资源、带宽、算子实现、队列等待和公平性都会限制收益。更大 batch 可能让单轮执行更长,短请求等待更久,也可能触发不同内核或临时工作区需求。用多组长度与到达负载扫描 batch 上限和等待预算,在满足延迟目标的区域选择工作点,而非只选择吞吐曲线最高点。
追问:CPU 计时很短,是不是 kernel 已经非常快? 如果仅包住异步提交,测到的主要是主机发起成本。应使用同一 stream 上合适的设备事件并等待结束测设备时间,同时单独记录含传输、排队和同步的端到端时间。不要在每步无条件同步改变实际执行重叠,再用那个结果代表生产路径;计时方式本身也应作为实验条件记录。
追问:用户取消后马上释放 KV 为什么可能崩溃? 取消意图到达不等于设备已停止访问。正在执行的 kernel 或后续已经排入 stream 的工作可能仍引用该缓存。应由调度状态机阻止新的依赖工作,等待适当完成信号后统一回收,并用请求代际防止迟到回调释放新请求资源。验收需覆盖取消与完成竞争、批内部分取消和重复取消。
参考依据:Triton 批处理 · CUDA 性能测量
CODE REVIEW / 动手审查 异步拷贝尚未结束,能重用主机缓冲吗? host 是合法的 pinned host memory。调用返回不代表设备已读完,如何安全复用并保留流水线并行?
CUDA · 单 stream 缓冲生命周期片段 · 先自行判断,再展开核对。
cudaMemcpyAsync(device, host, bytes, cudaMemcpyHostToDevice, stream);
fill_next_request(host); // 可能与设备读取同一 host 缓冲并发。
kernel<<<grid, block, 0, stream>>>(device);查看问题、修复与验证 异步拷贝对 host 缓冲仍可能有未完成读取。修改同一缓冲会破坏本次请求的输入,不能靠主机提交语句的先后证明拷贝完成。
在拷贝后记录 event,等待该 event 完成再让 CPU 重写此 host 缓冲;设备缓冲还须活到使用它的 kernel 完成。两种缓冲的可复用时刻不同,不应全部混成一个“请求已经发送”状态。
为减少等待可用多组缓冲与各自完成事件,形成有界流水线。多个 stream 的依赖需显式连接;取消后仍要等在途访问结束再归还缓冲。错误检查必须包括启动与执行完成阶段。
修正思路 cudaMemcpyAsync(device, host, bytes, cudaMemcpyHostToDevice, stream);
cudaEventRecord(copy_done, stream);
kernel<<<grid, block, 0, stream>>>(device);
cudaEventSynchronize(copy_done);
fill_next_request(host); // 此时本次拷入不再使用 host。
// 复用 device 前,还须等待使用它的 kernel 完成。
// 每个 CUDA API 的返回状态与 kernel 执行错误都应检查。怎样验证 为每个请求填入不同校验图案,混合大小输入、延迟和取消;对照 CPU 结果,记录事件顺序与缓冲代际。计时同时报告设备时间和端到端时间,不能只报 kernel 提交耗时。
这里提供审查练习与验证步骤,不代表已在本机执行 C++、数据库或 GPU 实验。
依据:CUDA 异步执行与 stream
本章参考资料与适用版本 正文为原创整理;链接用于进一步核对,不影响离线阅读。C++ 工作草案会持续更新,本文采用已标注的 C++17/20 功能范围。