工程与系统
C++ 工程、调试与性能 从可重复构建、故障证据到服务容量与一致性;以操作流程、反例和边界回答深入追问
10 个专题 31 道深入问答 1 个完整实验
先独立推演,再展开答案。自评不会自动判断代码正确;完整实验的验证环境在各节注明。
ENG-01 / 构建与交付 ○ 标为掌握
编译、链接、ODR 与 ABI:定位二进制边界问题 把预处理、翻译单元、符号解析和运行时装载分开,再判断是缺少定义、违反 ODR,还是 ABI 不兼容。
一个 .cpp 文件连同预处理展开的头文件组成翻译单元。编译器通常先为各翻译单元生成目标文件,链接器再解析符号与重定位,动态链接程序还要经过运行时装载。因此“头文件找不到”“undefined reference / unresolved external symbol”“启动时缺少动态库”属于不同阶段。排查从失败命令、完整诊断、目标架构和实际输入文件开始,先确认实现文件确实加入目标,再比对声明与定义的命名空间、参数、const 限定及链接属性;不要先向链接参数里反复追加库。
ODR 即单一定义规则,并不等于“每个函数只能在整个工程出现一次文本”。C++17 允许满足条件的 inline 函数、inline 变量、类和模板在多个翻译单元中定义,但这些定义必须符合相应的一致性要求。头文件中由不同宏展开出不同类布局、不同 inline 函数体,是常见隐蔽问题。某些跨翻译单元的 ODR 违例不要求实现诊断,链接成功也不能证明程序合法。统一影响公共头文件的编译宏,比依赖链接器挑中某一份实现更可靠。
API 描述源码调用方式,ABI 还涉及名字修饰、参数传递、对象布局、虚函数机制和运行时约定。源码能重新编译,并不代表旧插件能直接加载新库。接口暴露 std::string、异常或由一方分配另一方释放的对象时,要验证编译器、标准库、配置及所有权协议的兼容性。libstdc++ 的 dual ABI 是具体例子:_GLIBCXX_USE_CXX11_ABI 的选择与 -std=c++17 不是同一个开关。extern "C" 可以约束语言链接,却不能自动统一结构体布局、调用约定和内存分配器。
排查链应留下可复核的证据:保存编译和链接命令、依赖版本、目标文件与最终二进制;检查预期符号是否定义、符号实际名称和运行时加载路径。在 Linux GNU 工具链可用 nm -C、readelf;Windows 的 MSVC 开发者终端可用 dumpbin。静态库是否被提取、顺序规则以及导出符号规则依赖链接器,不能把某平台上的解决办法直接当成 C++ 标准规则。修复后用原发布配置重新构建,并验证旧客户端或插件的兼容性测试。
# Linux,GNU Binutils;仅为排查命令示例,尚未执行。
# 将路径换成实际构建产物。
nm -C build/libcalc.a
readelf -Ws build/server
readelf -d build/server
# Windows,MSVC 开发者 PowerShell;仅为示例。
dumpbin /symbols build\calc.obj
dumpbin /exports build\calc.dll
dumpbin /dependents build\server.exe深入面试与自测 Q1 · 第三方库里明明有这个函数,为什么仍然报 undefined reference? 先取得链接器真正使用的库路径,排除同名旧库和错误架构。再用符号工具分别查看调用方需要的符号和库提供的符号,比较命名空间、参数类型、成员 const 限定及 ABI 标签。确认静态库提取顺序、符号导出/可见性和依赖库是否完整。若出现 std::__cxx11 等差异,应检查 libstdc++ ABI 配置,而不是仅修改 -std。最终证据应是匹配的符号、正确的链接输入及可运行的回归用例。
回答应覆盖 先定位真实产物和链接输入 能比对需要与提供的符号 区分语言版本、ABI 与链接器行为 ☆ 收藏 自评 需重学 不完整 能解释
Q2 · 头文件中的 inline 函数在两个 .cpp 里由不同宏生成不同实现,程序能运行,是否可以接受? 不能以能运行判断合法。先按 ODR 检查多重定义要求;此情形若形成不允许的不同定义,就违反规则,而且未必被诊断。链接器合并、优化内联与链接顺序可能让症状变化。修复是统一公共编译定义,或让差异成为明确的参数、不同命名的实体或内部链接实现;不是依赖某次链接恰好选择哪一份。
回答应覆盖 知道跨翻译单元违例可能无诊断 能解释优化后症状改变 提出消除定义分歧的设计 ☆ 收藏 自评 需重学 不完整 能解释
Q3 · 如何设计可长期升级的 C++ 插件接口? 先明确受支持的编译器、平台、运行时与兼容版本。需要跨多种工具链时可采用带版本号的窄 C 接口和不透明句柄,固定长度类型与显式长度缓冲区,规定错误码和线程安全语义,由创建方提供销毁函数。避免让 STL 布局或 C++ 异常穿越不兼容边界;同时保留大小字段或版本协商以便扩展。这降低 ABI 耦合,但仍要验证调用约定、对齐、字符编码与生命周期,并建立旧宿主加载新插件等兼容矩阵。
回答应覆盖 明确 ABI 兼容范围 定义跨边界所有权和错误协议 承认 C 接口也需要版本与平台约束 ☆ 收藏 自评 需重学 不完整 能解释
动手验证 设计一个两翻译单元的小工程,分别制造“声明但没有定义”和“公共头文件宏导致定义不一致”。记录每个问题出现的阶段及诊断;ODR 违例可能没有诊断,不以某次运行结果为正确性依据。再写出插件接口的对象创建、销毁和版本协商协议。
ENG-02 / 构建与交付 ○ 标为掌握
现代 CMake 与 CTest:让依赖和测试跟着目标走 使用 target 表达依赖与使用要求,明确配置、构建和测试三个步骤,保证换机器、换配置时仍能复现。
把库和可执行文件建模成 target,并把头文件路径、编译特性和依赖挂到对应 target。PRIVATE 用于自身实现需要,PUBLIC 用于自身与使用者都需要,INTERFACE 用于使用者需要。判断依据是公共接口实际暴露了什么:若公开头文件包含某依赖的类型,使用者通常也需要该依赖的使用要求。尽量使用依赖包导出的命名目标,而不是散落的绝对路径;这让编译参数和传递依赖能随目标传播。
配置、构建和执行测试是不同工作。源码目录与构建目录分开,不同编译器、架构和 Sanitizer 配置使用独立目录。单配置生成器通过 CMAKE_BUILD_TYPE 选择 Debug/Release;Visual Studio 等多配置生成器通常在构建、测试阶段用 --config / -C 选择配置。缓存了旧编译器或错误依赖路径时,应新建干净构建目录验证,不要把偶然修好缓存当成根因。需要长期复现时,还应记录工具链版本、依赖锁定与配置参数。
include(CTest) 为项目提供 BUILD_TESTING 选项,启用时可用 add_test 注册测试。下面示例以 CMake 3.20+ 的常用接口为基线,先构建测试目标,再由 CTest 执行;注册测试不等于已经产生了测试程序。测试程序应通过返回值或测试框架明确报告失败。仅用 assert 充当全部测试,在定义 NDEBUG 的发布配置下会丢失检查。异步测试还应设置合理超时并清理线程、端口和临时目录,避免把残留进程造成的偶现失败误判成业务错误。
构建是否“可复现”需要分层描述:相同输入能得到同样行为,不自动等于二进制逐字节相同。时间戳、绝对路径、依赖下载来源和工具链差异都可能影响产物。面试讨论应说清自己保证的是依赖可追溯、干净机器可构建,还是位级可复现;再给出相应的验收办法。最有价值的验证是从空构建目录执行规定流程,并检查测试确实被发现且失败能使流水线失败。
边界与易错点 把所有 include 路径和编译宏设为全局,会隐藏依赖关系;“本机 IDE 点运行成功”不能替代干净构建和 CTest 验证。PRIVATE 也不等于静态库的所有链接需求都不再传递,应以目标类型和生成的链接关系为准。
# CMakeLists.txt 示例;需要自行提供 src/calc.cpp、include/ 和 tests/calc_test.cpp。
# 此配置和以下命令尚未在本资料编写阶段执行。
cmake_minimum_required(VERSION 3.20)
project(Calc LANGUAGES CXX)
include(CTest)
add_library(calc src/calc.cpp)
target_include_directories(calc PUBLIC include)
target_compile_features(calc PUBLIC cxx_std_17)
if(BUILD_TESTING)
add_executable(calc_tests tests/calc_test.cpp)
target_link_libraries(calc_tests PRIVATE calc)
add_test(NAME calc_contract COMMAND calc_tests)
set_tests_properties(calc_contract PROPERTIES TIMEOUT 20)
endif()
# Linux 或 Windows:已安装 Ninja 及对应编译器,单配置生成器。
cmake -S . -B build-release -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build-release
ctest --test-dir build-release --output-on-failure
# Windows:已经使用 Visual Studio 多配置生成器完成配置时。
cmake --build build-vs --config Release
ctest --test-dir build-vs -C Release --output-on-failure深入面试与自测 Q1 · PUBLIC、PRIVATE、INTERFACE 怎么选?是不是所有第三方库都设 PRIVATE 更好? 根据接口与实现的依赖关系选择。仅在 .cpp 使用的头文件和编译要求通常是 PRIVATE;出现在公开头文件且使用者编译需要的要求应传播;纯头文件库常用 INTERFACE target。对于静态库,最终可执行文件仍可能需要链接其实现依赖,不能简单把 PRIVATE 理解为整个依赖从下游消失。检查公共头文件能否被独立使用、下游是否获得必要要求,以及生成的实际链接命令。
回答应覆盖 以公开接口而非个人偏好判断 区分编译使用要求与最终链接需求 能通过小型调用方验证边界 ☆ 收藏 自评 需重学 不完整 能解释
Q2 · 本地测试通过,CI 的 Release 测试却失败或者根本没执行,如何排查? 先查看配置与构建产物是否来自相同目录、架构和配置,再用 CTest 的测试列表确认实际发现了测试,检查 BUILD_TESTING、add_test 和生成阶段。多配置生成器要给出 -C Release,并先构建该配置。对实际失败则比较环境变量、工作目录、时区、测试资源与并发;若只是“全绿”,要确认断言未被 NDEBUG 去掉,不能把零测试或空检查当作成功。
回答应覆盖 区分未发现、未构建和执行失败 知道多配置与 NDEBUG 问题 要求检查数量和失败传播 ☆ 收藏 自评 需重学 不完整 能解释
Q3 · 如何证明你写的构建系统别人也能用? 用新构建目录和受支持的第二套环境执行文档化命令,验证依赖来源和版本可追溯,不能依赖开发者机器上的偶然全局路径。做一个只链接公开 target 的最小消费者,验证公开头文件及传递要求完整。保存构建日志、工具链信息与产物标识;若要求位级可复现,再另外控制时间戳、路径映射和依赖输入并比较散列。
回答应覆盖 有干净环境和外部消费者验证 明确可复现的层次 不把锁依赖等同于位级相同 ☆ 收藏 自评 需重学 不完整 能解释
动手验证 创建 calc 库与独立测试程序,分别进行 Debug 和 Release 的干净构建。人为让一个契约检查失败,确认 CTest 和 CI 都返回失败;再将一个必要的 PUBLIC 使用要求改为 PRIVATE,观察调用方的构建失败并解释原因。
ENG-03 / 构建与交付 ○ 标为掌握
警告、静态分析与 CI:建立能解释的质量门槛 诊断必须基于真实构建参数,门槛必须能发现实际缺陷,同时保留工具版本和误报处理的证据。
编译警告首先用于暴露可疑转换、未初始化路径、生命周期和接口误用,不是“无 bug 证明”。GCC/Clang 的 -Wall 并不表示所有警告,-Wextra 也不是语言规则的全集;部分诊断依赖优化或数据流分析。先固定受支持工具链,对自己的 target 启用经验证的警告集合,再逐步处理历史问题。全局 -Werror 可能让一次编译器升级因新警告阻断构建,因此可把工具链升级与业务变更分开,并对例外记录最小范围和原因。
静态分析器需要知道实际 include 路径、宏、语言版本和平台,否则分析的是另一个程序。clang-tidy 可以读取 compile_commands.json;用 CMake 导出时应选择支持该功能的生成器,例如 Ninja 或 Makefiles,而不是假定任意生成器都会产生该文件。先分析一小组有代表性的目标,核对诊断位置和触发条件,再决定是否加入门禁。自动修复也要审阅:编译通过不能证明所有权、锁顺序或业务语义没有改变。
CI 应覆盖不同失败机制,而不是堆叠近似检查。可将快速构建与契约测试放在每次变更上,将 ASan/UBSan、TSan 和较长故障场景放到独立作业,按项目规模安排频率。每个作业保存编译器版本、提交标识、随机种子和必要日志。测试失败时首先保证能定位到具体场景;重跑通过只能说明症状不稳定,不能自动删除失败记录。使用第二编译器有助于暴露可移植性问题,但不是每个小变更都需要任意扩张平台矩阵。
测试设计从可观察契约出发:错误输入是否被拒绝,异常后状态是否一致,停止后线程是否退出,重试是否重复产生副作用。覆盖率只是执行到代码的线索;一行被执行过不代表边界、并发交错和断言足够。对修复类变更,最有力的回归测试能在修复前触发原问题,并在修复后验证行为,而不只是照抄实现分支。对不稳定并发问题,使用可控同步点、超时和状态断言,比增加随机 sleep 更容易解释。
# Linux,Clang + Ninja;在项目根目录执行的示例,尚未执行。
cmake -S . -B build-tidy -G Ninja -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
clang-tidy -p build-tidy src/calc.cpp
# 以下为 GCC/Clang 警告的示意集合,应按项目和工具链验证后用于 target:
# -Wall -Wextra -Wpedantic
# Windows MSVC 常见基线为 /W4,不能将 GNU 参数原样套用。深入面试与自测 Q1 · 静态分析报了 3000 个问题,你会直接把它变成阻断门禁吗? 先按风险、规则类别和重复根因抽样,确认使用了正确编译参数,分清真实缺陷、历史债务和误报。可先修复确定的高风险问题,对新增问题设门槛,并给历史基线设置负责人和清理计划。例外要限定规则与代码位置,保留理由;不能永久忽略所有旧代码。门禁是否有效要用已知缺陷验证,而不是只看问题数量下降。
回答应覆盖 验证分析上下文 有增量治理与例外边界 用已知缺陷检验工具效果 ☆ 收藏 自评 需重学 不完整 能解释
Q2 · 一个并发测试一百次偶尔失败一次,怎么处理? 保存失败种子、线程状态和版本,先确认是资源冲突、测试超时还是产品竞争。用屏障、条件变量或测试钩子控制关键交错,把依赖时机的 sleep 改成可观察条件,并设总超时防止挂死。若涉及数据竞争,单独运行 TSan;若涉及死锁,分析等待关系和锁顺序。可以临时隔离并标记影响范围,但不能通过无限重跑让失败消失。
回答应覆盖 保留首个失败证据 区分数据竞争、死锁和测试环境 设计可控交错而非靠 sleep ☆ 收藏 自评 需重学 不完整 能解释
Q3 · 为什么 Release 还需要测试?Debug 和 Sanitizer 都通过不够吗? Release 的优化、宏和内联行为可能不同,assert 可能被移除,未定义行为也可能在优化后呈现不同症状。Sanitizer 配置又改变了布局、时序与运行时。因此应保留发布配置下的关键契约与集成验证;Sanitizer 作业负责增加特定缺陷的可见性,不能替代真实发布产物验证。测试的通过含义只覆盖实际执行的输入和断言。
回答应覆盖 理解配置改变程序行为和可观察性 区分工具构建与发布构建 不给测试结果过度保证 ☆ 收藏 自评 需重学 不完整 能解释
动手验证 为一个真实缺陷写出“触发输入—预期状态—失败信号”,选择编译警告、静态分析或运行测试中最合适的一层。再故意改变一个必要构建宏,比较带真实 compilation database 与缺失参数时分析结果的差异。
ENG-04 / 诊断与性能 ○ 标为掌握
GDB 与 core dump:从崩溃现场建立因果链 保存匹配的二进制、符号和上下文;把最后崩溃的位置与最早破坏状态的位置分开。
core dump 是特定时刻进程内存状态的快照,通常能帮助还原寄存器、调用栈和部分对象,不是完整的执行录像。调查前先确认 core 对应哪个构建,获取准确的可执行文件、调试符号和共享库版本。拿最新本地代码配旧 core,可能得到看似合理但实际错误的源码位置。发布产物宜保留构建标识与符号归档,使线上问题能映射回确切源码,而不是临时用 Debug 重编译替换证据。
进入 GDB 后先核对文件和共享库,再看异常信号、崩溃线程和所有线程调用栈。选择有意义的帧,查看参数、对象生命周期与附近控制流。优化构建出现变量被优化掉、内联帧或栈回溯不完整时,应把这些记为证据限制,而不是猜一个值。若崩溃发生在 malloc、容器析构或字符串复制中,既可能是当前调用参数错误,也可能是更早的越界写、重复释放或数据竞争;需要结合 Sanitizer、最小复现和时间线缩小范围。
死锁与高延迟不一定会生成崩溃 core。分析无响应进程时,要区分线程在正常等待任务、等待 I/O、争抢锁还是形成环形等待。多次采样若总停在同一位置只是线索,仍需还原哪个线程持有哪个资源、谁负责唤醒以及谓词是否能改变。采集现场会影响时序,在线附加调试器也可能暂停业务,应在有明确处置流程的环境中操作;本节只给离线 core 的分析示例。
Linux 没有生成 core 时,要检查进程资源限制、core_pattern 的目标或收集程序、目录权限、磁盘空间及进程属性,而不只执行一次 ulimit。core 可能包含用户输入、令牌和其他内存数据,排障归档应按敏感诊断产物控制访问与保留期限。Windows 常用转储与 WinDbg/Visual Studio 对应流程;Linux 的 core 文件路径和 GDB 命令不能原样等价于 Windows 的全部行为。
# Linux + GDB:已有受控保存的匹配产物和 core;以下命令尚未执行。
gdb ./release/server ./diagnostics/core
# 以下输入 GDB 交互界面:
info files
info sharedlibrary
info threads
thread apply all bt
frame 0
info args
info locals
# 再按实际堆栈选择 frame N,不要机械停留在 frame 0。
# bt full 等命令可能展示敏感内存内容,分享前须审查。深入面试与自测 Q1 · 线上只在 -O2 下崩溃,关闭优化就好了,该怎么修? 关闭优化只改变了复现条件。保留原发布配置与符号,优先检查未定义行为、数据竞争、生命周期和依赖 ABI;用匹配 core 与日志确定失效对象,再尝试在带优化的 Sanitizer 构建或最小复现中触发。若最终怀疑编译器问题,需要一个符合语言规则的最小程序、编译器版本和优化差异证据。直接长期关闭优化可能掩盖缺陷,也不能证明编译器有错。
回答应覆盖 不把症状消失当根因修复 重视匹配发布产物与 UB 排查 给出怀疑编译器所需证据 ☆ 收藏 自评 需重学 不完整 能解释
Q2 · 看到多个线程都卡在 futex,是死锁吗? 不能仅据此判断。futex 可能对应互斥、条件变量或正常空闲等待。需要映射上层锁与谓词,确认谁应唤醒、持有者是否仍能推进,并构建等待关系。如果线程都在等待外部依赖,也可能是资源耗尽或下游超时没有配置。结合线程栈、队列长度、活动请求和时间序列,区分正常等待、长期阻塞与环形等待。
回答应覆盖 知道底层等待不是死锁结论 能还原唤醒条件与资源关系 结合业务进展指标 ☆ 收藏 自评 需重学 不完整 能解释
Q3 · core 中某指针非空,是否就能证明对象还活着? 不能。非空地址可能指向已释放、已重用或未完整构造的存储,甚至只是被破坏后的数值。要追踪所有权、构造与销毁时刻、并发访问和分配记录;core 本身通常不能还原全部历史。通过 ASan、受控复现或对象生命周期日志补证,并修复所有权或同步关系,而不是加一个非空判断。
回答应覆盖 区分地址、存储和对象生命周期 理解快照的信息边界 针对所有权修复而非空值补丁 ☆ 收藏 自评 需重学 不完整 能解释
动手验证 为一次假设的“容器析构时崩溃”写一页证据记录:构建标识、崩溃信号、线程栈、对象所有权、最后可信事件、待验证假设及下一步实验。明确哪些证据能排除假设,哪些只能说明相关性。
ENG-05 / 诊断与性能 ○ 标为掌握
ASan、UBSan、TSan:检测能力、盲区与修复顺序 选择与失败机制对应的检测器,用报告定位第一处违规,并明确“没有报告”不等于程序正确。
ASan 通过编译插桩和运行时检测一类内存访问错误,例如越界、释放后使用和非法释放;UBSan 检测被启用的特定未定义行为类别。它们不能覆盖全部 C++ 未定义行为,也不能验证业务结果。编译和最终链接都需要相应参数,最好使相关自有代码与可重编译依赖都进入同一检测配置。完全未插桩的代码、未执行路径和工具不能观察到的行为,仍可能隐藏问题。
TSan 主要用于动态检测数据竞争,要与“逻辑竞态”区分。两个原子变量分别读写可能没有数据竞争,却可能破坏多字段业务不变量;线程安全队列也可能因关闭协议错误导致永远等待。TSan 不提供死锁、饥饿和所有内存序算法正确性的通用证明。官方支持平台和架构有范围,本节命令针对 Linux Clang;不要假定原生 Windows 上可以直接使用同一套 TSan 工作流。
把 ASan/UBSan 与 TSan 放在独立构建目录和测试作业中。工具会增加内存、运行时间开销并改变布局与调度,超时阈值应针对检测环境有依据地设置。遇到报告先检查第一处违规访问及其分配、释放或竞争线程栈,修复后重跑原用例;后续崩溃可能只是状态被破坏的连锁反应。忽略项应只用于已理解且能解释的边界,不要为获得绿色结果屏蔽整个模块。
整数溢出也要按语义分类:有符号整数溢出属于需要避免的未定义行为,普通无符号运算按模运算定义,并不因为回绕就自动属于同一种 UB。但长度、价格或容量计算即使使用无符号数,业务上也可能不允许回绕。应在运算前用上界检查、受检运算或更宽且足够的类型处理,而不是期待默认 UBSan 配置替业务定义范围。诊断报告最终必须落到输入验证和状态不变量上。
# Linux + Clang,源文件名为示例;以下命令尚未执行。
# ASan 与 UBSan 组合;编译和链接由 clang++ 一次完成。
clang++ -std=c++17 -O1 -g -fno-omit-frame-pointer -fsanitize=address,undefined app.cpp -o app-asan
./app-asan
# 多线程程序的 TSan 独立构建,不与 ASan 放在同一个二进制。
clang++ -std=c++17 -O1 -g -fno-omit-frame-pointer -fsanitize=thread -pthread threaded_app.cpp -o app-tsan
./app-tsan
# 实际工程应把插桩同时应用到相关编译与链接步骤,
# 并核查依赖库、Clang 版本及目标平台的支持范围。深入面试与自测 Q1 · TSan 没有报告,但偶尔重复扣库存,可能是什么原因? 无数据竞争不等于业务原子性。比如先用原子变量检查库存、再单独扣减,两个线程可同时通过检查;还可能是请求被重复投递,或数据库事务边界不正确。先界定“一次扣减”的线性化点与幂等键,再用锁、合适的 CAS 循环或数据库条件更新保证约束,使用受控并发场景验证最终库存和成功次数。TSan 负责补充数据竞争证据,不能证明整个协议。
回答应覆盖 区分 data race 与业务 race 能指出线性化点或事务条件 验证结果不变量而非只读工具输出 ☆ 收藏 自评 需重学 不完整 能解释
Q2 · ASan 报告在标准库内部越界,应直接怀疑标准库吗? 先检查传入迭代器、指针、长度及对象生命周期,结合报告里的分配/释放栈定位第一处无效使用。标准库常是错误参数最终被解引用的位置。将输入和调用序列缩到最小,保留原编译器与依赖配置;只有排除调用方契约违例并形成合法最小程序后,才有足够依据报告实现缺陷。
回答应覆盖 从接口前置条件入手 利用分配释放栈而非只看顶帧 给实现缺陷准备最小复现 ☆ 收藏 自评 需重学 不完整 能解释
Q3 · 第三方二进制不能插桩,还值得使用 Sanitizer 吗? 值得,但要说明覆盖边界。自有代码中的受检访问仍可能暴露接口误用,某些运行时拦截也能提供信息,但不能据此保证第三方内部的每次访问都受检。通过接口契约测试、输入边界测试和厂商的检测构建补充证据;能获得源码时尽量使用一致配置重编译。避免把“测试全部通过”写成“整个依赖链已无内存错误”。
回答应覆盖 承认未插桩代码盲区 提出边界测试和可获得的补证 不过度解读通过结果 ☆ 收藏 自评 需重学 不完整 能解释
动手验证 列出四类问题:越界访问、两个线程非同步写同一普通对象、两次原子操作之间的不变量破坏、停止后无人唤醒的等待。分别选工具和状态断言,并解释为什么最后两类不能仅靠 TSan 判定。
ENG-06 / 诊断与性能 ○ 标为掌握
基准测试与火焰图:证明优化改善了什么 先定义指标和负载,再测量、提出假设、修改、对照;明确 CPU 时间与端到端等待时间的区别。
性能问题先用业务可观察的目标描述:固定吞吐下的 p99 延迟、达到某延迟目标时的容量,或每个请求的 CPU 与内存成本。明确输入分布、并发模型、缓存冷热、数据规模与测量边界;只测一次平均耗时,无法支撑尾延迟结论。比较前后版本时保持硬件、编译配置和负载一致,记录重复运行波动,避免把热机、CPU 频率或后台任务变化误认为代码收益。
微基准适合隔离某个假设,但要防止被测工作被优化掉,也要判断初始化、分配和缓存预热是否属于真实成本。Google Benchmark 提供计时与防优化辅助工具;这些工具并不自动让一个不合理的输入变得真实。对可在编译期确定的表达式,即使使用 DoNotOptimize,也仍应检查实际生成代码与数据依赖。小规模热点的加速还要回到端到端负载验证:局部节约可能被锁竞争、I/O 或内存占用抵消。
CPU 火焰图把采样到的调用栈聚合展示,横向宽度代表采样权重,纵向代表调用深度,横轴不是请求时间线。父帧宽度包含子调用成本,不能把各层宽度相加当作独立耗时。颜色也不自动表示温度或严重程度。CPU 采样主要回答 CPU 在执行什么;服务慢却 CPU 不忙时,要补充等待、调度、锁、I/O 和请求追踪,不能只根据一张 CPU 火焰图排除所有瓶颈。
优化要有可反驳的假设,例如“重复解析占用 30% CPU,缓存后可减少该部分,但会增加内存并改变失效路径”。每次修改保留正确性检查、资源消耗和延迟分布对照。Amdahl 定律可约束预期:若固定工作负载中仅 20% 时间可被优化,即使该部分耗时降为零,理论整体加速上限也只有 1.25 倍;这依赖工作量和其他成本不变。容量与负载结构改变后,不能机械沿用这个数字。
深入面试与自测 Q1 · 火焰图里某函数占 40%,把它删掉是否就能快 40%? 先确认采样事件和宽度含义。该宽度可能是包含子调用的累计 CPU 样本占比,不等于可移除的自耗时,更不等于端到端延迟比例。分析其子栈与调用次数,判断必要工作和可消除重复,再做对照实验。即使真实消除固定总耗时的 40%,加速比也应按 1/(1-0.4) 计算,而非混淆耗时下降比例与速度倍数;实际还受其他瓶颈和负载变化限制。
回答应覆盖 区分累计与自身成本 区分 CPU 份额与端到端延迟 正确表达耗时下降和加速比 ☆ 收藏 自评 需重学 不完整 能解释
Q2 · 微基准快了一倍,线上却没有变化,如何解释? 检查该代码是否真是线上主要成本,线上数据、缓存状态和并发是否与微基准一致。确认基准是否漏掉分配、序列化或失效处理,是否被编译器简化,优化是否增加锁竞争或内存压力。用请求追踪和系统计数把端到端成本拆开,再在可代表线上分布的压测中复核。不能通过只挑选受益输入来宣布优化成功。
回答应覆盖 验证基准代表性和优化消除 联系系统成本与新增代价 回到真实负载复核 ☆ 收藏 自评 需重学 不完整 能解释
Q3 · 压测吞吐提高但 p99 变差,该不该上线? 先按产品目标判断,而不是单指标胜负。确认发送模型、排队位置与超时请求是否完整计入延迟,观察错误率和队列增长。若更高批量或更深队列换来了吞吐,可能违背尾延迟目标;可调整并发、批量上限和等待时间,比较同一负载下的曲线。若没有明确定义的延迟和成本预算,就不足以作上线判断。
回答应覆盖 以目标和固定负载比较 统计超时与排队成本 识别吞吐和尾延迟的权衡 ☆ 收藏 自评 需重学 不完整 能解释
动手验证 为一个 JSON 解析优化写实验计划:小/大对象、冷热缓存、单线程/目标并发、正常/异常输入,选择吞吐、p50/p99、CPU/请求和峰值内存作为指标。明确哪些步骤属于被测范围,给出回归阈值及波动处理方法;不填写未经实际测量的收益数字。
ENG-07 / 诊断与性能 ○ 标为掌握
缓存、NUMA 与伪共享:把硬件假设变成证据 关注访问模式和跨核一致性开销,区分真正的数据共享、伪共享与内存带宽瓶颈。
CPU 访问内存的成本不仅取决于元素个数,还取决于空间与时间局部性、工作集、访问顺序、依赖链和硬件预取能力。连续容器在顺序遍历时通常更有利于局部性,但不能据此断言它在所有增删查场景都更快。先从实际访问模式解释假设,再测量吞吐、CPU 周期、缓存失效和带宽等证据;计数器名称与可用性依赖硬件和权限,不应把一次高 miss 数直接等同为唯一瓶颈。
伪共享发生在多个线程修改逻辑上独立、却位于同一缓存一致性粒度中的数据时。即使每个字段使用 atomic,语言层面没有数据竞争,缓存行仍可能在核心间频繁转移所有权。常见改法是每线程局部累计后合并、分离高频写字段或调整布局;填充会增加工作集,读者又可能失去局部性。优化应证明跨核流量下降且端到端指标改善,而不是默认给所有成员加 alignas(64)。
NUMA 系统的内存位置会影响远端访问成本。Linux 的内存策略与页面实际分配/缺页时机相关,改变线程运行位置或策略不代表已有页面自动迁移;线程策略的作用范围也需要明确。可先比较单节点和跨节点负载,记录线程、数据的归属与远端访问,再评估 first-touch 初始化、分片或显式策略。把全部线程固定到少量核心可能降低迁移,也可能造成负载不均和排队,必须与未绑定基线对照。
布局实验容易混入其他变量。比较两个计数器实现时要保持工作量、线程数、同步语义和编译配置一致,记录核心拓扑,并区分真正争用同一个计数器与各自计数器同处一行。若需要运行时汇总局部计数,要补充同步或快照协议,不能为了性能引入普通变量并发读写。缓存行大小和破坏性干涉粒度不是所有平台固定 64 字节,布局要求还可能成为公开类型 ABI 的一部分。
深入面试与自测 Q1 · 线程从 4 个增加到 16 个反而变慢,如何判断是否伪共享? 先排除工作量太小、线程创建成本、真正共享锁和带宽饱和。检查高频写字段是否由不同线程更新但位于相邻缓存行布局,用可用的性能计数或 perf c2c 等工具观察一致性流量。做仅改变布局或局部聚合方式的对照,并验证计算结果相同。若数据只是同时读取,或者大家确实修改同一逻辑变量,就不能把问题简单叫伪共享。
回答应覆盖 区分伪共享与真正争用 提出只改变一个关键因素的对照 同时验证结果和硬件证据 ☆ 收藏 自评 需重学 不完整 能解释
Q2 · 为什么每线程计数器通常更快?实时读取总数有什么代价? 局部更新减少共享所有权转移,但总数的读取需要合并。若只在线程 join 后读取普通局部计数,结束同步可提供清晰边界;若运行中读,则需原子或锁等同步,不能无同步读取正在写的普通整数。多个原子分片的顺序读取也未必是同一时刻的全局快照。应先明确允许近似统计还是要求线性一致,再选择快照协议和汇总频率。
回答应覆盖 说明减少一致性开销的原因 避免引入新的 data race 区分近似汇总与一致快照 ☆ 收藏 自评 需重学 不完整 能解释
Q3 · 把线程绑到节点后,为什么 NUMA 性能仍未改善? 线程位置与页面位置是两件事,数据可能已由其他节点首次分配,新的策略不一定迁移既有页。还应检查共享数据访问、内存带宽、工作分配不均与跨节点通信。记录页面和线程位置,在受控初始化与固定工作量下比较,再决定数据分片、迁移或重新分配;不要只看绑核命令执行成功。
回答应覆盖 分清线程和页面位置 知道既有页不自动迁移 从访问模式与容量共同判断 ☆ 收藏 自评 需重学 不完整 能解释
动手验证 设计每线程计数器的三组对照:共用一个原子计数器、相邻独立原子计数器、局部累计后同步合并。保持总工作量和最终计数一致,测量不同线程数和拓扑下的结果;若工具可用,再用缓存一致性相关事件补证。不要预先填写固定加速倍数。
ENG-08 / 正确性与服务设计 ○ 标为掌握
异常安全、错误协议与故障注入:明确提交点 把可能失败的准备工作放在状态提交前,说明失败后保留什么,并区分内存回滚与外部副作用。
异常安全保证描述失败后的状态:基本保证要求对象不变量仍成立且资源不泄漏;强保证要求操作失败时可观察状态不变;不抛保证要求操作不以异常逃出。RAII 能在栈展开时释放资源,但自动析构不会撤销已经修改的业务字段、已发送的邮件或已提交的数据库事务。设计时列出状态不变量、所有可能失败点和对外可见的提交点,再选择能实际实现的保证。
实现强保证的常见方式是先在临时对象上完成分配、校验与计算,成功后用确定不会抛出的步骤提交。下面实验对 std::vector<std::string> 使用默认分配器,在候选副本上校验,再 swap 替换;复制和校验失败都发生在修改原状态之前。若换成具有特殊传播语义的自定义分配器、带副作用的元素或不同提交步骤,必须重新检查前提,不能机械推广“copy-and-swap 永远安全”。
错误协议要帮助调用方决策。区分输入非法、资源不足、可重试的短暂故障和结果未知;为错误附加阶段、操作标识和必要上下文,而不是用一个布尔值吞掉信息。C++17 可以用项目定义的结果类型或异常,std::expected 是 C++23 标准库设施,不能默认在 C++17 中可用。noexcept 是边界承诺:异常逃出会调用 terminate,并不代表编译器已经证明实现不会抛出。析构或清理应有明确的失败处理,不能在已有异常传播时再次抛出导致意外终止。
故障注入针对实际失败边界进行:候选复制失败、校验失败、提交前失败、外部提交成功但响应丢失,需要不同断言。本节实验只验证内存内更新的两个人工注入点和输入拒绝,未模拟所有分配失败,也不覆盖并发访问。外部系统的结果未知要依靠幂等键、查询状态、日志或补偿协议处理,不能把抛出异常等同于远端操作未发生。
深入面试与自测 Q1 · 函数修改了两个成员,第二次分配抛异常,如何提供强保证? 不要先把第一项写回对象。先在临时状态中完成两项新值及跨字段约束校验,成功后通过不抛的整体交换或受保护的版本切换提交。若无法构造无抛提交,就应重新设计表示方式,或明确只能提供基本保证。还要检查临时资源的销毁、分配器条件和并发读者是否会看到半完成状态;强异常保证本身不自动提供线程安全。
回答应覆盖 明确准备与提交分离 检查无抛提交前提 区分异常安全与并发原子性 ☆ 收藏 自评 需重学 不完整 能解释
Q2 · 为什么不能给所有函数加 noexcept 来提高性能? noexcept 首先是可观察的错误承诺。函数若可能分配、调用抛异常的回调或执行未捕获的校验逻辑,错误地标注后会把原本可处理的失败变成 terminate。应根据实现、调用链和接口契约决定,特别关注移动操作与清理边界;性能收益要在具体类型和库行为中验证,不能用终止进程掩盖错误设计。
回答应覆盖 知道异常逃出 noexcept 的行为 审查调用链和回调 以接口契约而非口号选择 ☆ 收藏 自评 需重学 不完整 能解释
Q3 · 向远端保存成功,但读取响应时抛异常,能否回滚本地状态然后直接重试? 本地回滚无法撤销远端提交。此时应标记结果未知,携带相同操作标识查询或幂等重试,避免重复副作用。若跨系统需要一致性,应设计持久状态机、事务消息或补偿操作,并明确哪些步骤可撤销、哪些只能追加修正。测试要包括“提交后响应丢失”,而不是仅模拟连接前失败。
回答应覆盖 区分调用失败与远端未执行 稳定操作标识和状态查询 测试提交后丢响应的窗口 ☆ 收藏 自评 需重学 不完整 能解释
完整实验 已验证 · MSVC 19.37 / x64 · c++17
跨平台 C++17 完整程序;编写本草稿时尚未编译或执行。两个注入异常与非法输入必须保持原值,成功路径必须提交新值。check 同时包含 assert 和显式失败分支,因此定义 NDEBUG 也不会移除契约检查。此实验仅覆盖内存内配置更新,不证明任意分配失败、并发更新或外部持久化的事务安全。
查看完整源代码 #include <cassert>
#include <iostream>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
class Config {
public:
explicit Config(std::vector<std::string> initial)
: values_(std::move(initial)) {
validate(values_);
}
const std::vector<std::string>& values() const noexcept {
return values_;
}
void replace(const std::vector<std::string>& next, int fail_point = 0) {
auto candidate = next; // Allocation/copy may throw; values_ is unchanged.
if (fail_point == 1) {
throw std::runtime_error("injected after copy");
}
validate(candidate);
if (fail_point == 2) {
throw std::runtime_error("injected before commit");
}
// std::vector<std::string> uses the default allocator here.
// swap is the non-throwing commit; there are no concurrent readers.
values_.swap(candidate);
}
private:
static void validate(const std::vector<std::string>& values) {
if (values.empty()) {
throw std::invalid_argument("empty configuration");
}
for (const auto& value : values) {
if (value.empty()) {
throw std::invalid_argument("empty entry");
}
}
}
std::vector<std::string> values_;
};
static void check(bool condition) {
assert(condition);
if (!condition) {
throw std::logic_error("check failed");
}
}
int main() {
Config config({"old-a", "old-b"});
const auto original = config.values();
const std::vector<std::string> replacement{"new-a", "new-b"};
for (int point : {1, 2}) {
bool caught = false;
try {
config.replace(replacement, point);
} catch (const std::runtime_error&) {
caught = true;
}
check(caught);
check(config.values() == original);
}
bool invalid_caught = false;
try {
config.replace({"valid", ""});
} catch (const std::invalid_argument&) {
invalid_caught = true;
}
check(invalid_caught);
check(config.values() == original);
config.replace(replacement);
check(config.values() == replacement);
std::cout << "transaction checks passed\n";
}
预期行为 transaction checks passed
下载源代码 这里的通过记录只证明指定编译器和测试输入上的结果,不证明所有平台或全部输入都正确。
动手验证 为一个“加载配置并替换当前配置”的接口列出 5 个失败点,逐一写明原配置、候选配置、文件句柄与调用方收到的错误。把外部持久化加入流程后,重新定义提交点,并说明“已写入但响应失败”如何恢复。
ENG-09 / 正确性与服务设计 ○ 标为掌握
背压、优雅停机与容量:为服务设计有限资源边界 入口、队列、执行和下游各自有预算;过载时明确拒绝与恢复,停机时明确谁停止、谁唤醒、谁回收。
服务过载时,增加线程或无限排队通常只把压力转移到内存、连接或下游。先给系统定义可接受的在途请求数、队列长度、排队字节数、任务期限与下游并发上限;只限制任务数量不足以约束大请求的内存。入口达到预算后要选择有语义的拒绝、降级或延迟接纳,并让调用方知道是否适合重试。已经过期的任务应尽早取消或跳过,避免继续花费资源完成无人等待的结果。
背压应沿资源链传播,而不是只在一个队列上加锁。生产速度长期高于消费速度时,有限队列必须让生产者等待、失败或丢弃,具体策略由业务决定;日志、支付和视频帧不能使用同一种丢弃规则。阻塞生产者要考虑其是否占有消费者需要的锁或线程,否则可能造成资源环路。取消还需要释放请求预算、连接和临时缓冲区,不能仅把一个标志改为 true。
优雅停机是协议:先停止接收新任务,标记队列关闭并唤醒等待者;再根据策略排空已有任务或取消未执行任务,给在途 I/O 和任务设置总截止时间;最后等待工作线程退出后销毁共享资源。关闭状态与队列谓词必须在相同同步规则下访问,消费者应能区分“暂时为空”和“关闭且已空”。不要持有工作线程退出所需的互斥锁执行 join。C++20 的 jthread/stop_token 提供协作取消机制,也不会自动打断所有阻塞系统调用。
容量规划需要稳定负载曲线:随着到达率增加,观察有效吞吐、排队时间、错误率、CPU、内存和下游饱和点。Little 定律 L = λW 使用一致系统边界下的长期平均量,且要求系统处于合适的稳定条件。例如平均 2000 请求/秒、系统内平均停留 0.05 秒,对应平均约 100 个系统内请求;这不是“队列必须设 100”的结论,也不能将 p99 直接代入求精确容量。峰值突发、超时、故障容量和安全余量需另行评估。
深入面试与自测 Q1 · 有界队列满了之后,阻塞生产者是否总是正确的背压? 不总是。若生产者是唯一事件循环、持有消费者所需的锁,或占满了执行消费者的同一线程池,阻塞可能使系统停止推进。应分析等待资源图,并按业务选择立即失败、异步等待、有限等待或可丢弃策略。等待必须有期限和关闭唤醒机制,还要把队列外缓冲区及在途任务计入预算,否则只是把无界积压移到另一层。
回答应覆盖 检查线程池与锁的资源环路 定义超时关闭行为 覆盖所有积压位置 ☆ 收藏 自评 需重学 不完整 能解释
Q2 · 怎么证明你的线程池能优雅退出? 明确接收任务的截止点、关闭后的提交返回值、排空/取消策略与总超时。用可控同步让生产者阻塞在满队列、消费者阻塞在空队列、任务阻塞在依赖上,逐一触发关闭并验证全部等待者被唤醒、每个已接收任务有明确最终状态、线程能 join、资源只释放一次。任何不能协作取消的任务都要作为停机保证的边界,不能承诺无限制任意任务都能安全强制退出。
回答应覆盖 状态机与任务最终状态明确 覆盖不同等待点 不承诺安全强杀任意线程 ☆ 收藏 自评 需重学 不完整 能解释
Q3 · CPU 只有 30%,请求为什么还是超时? 可能在等待数据库连接、锁、磁盘/网络 I/O、受限下游并发,或者某个单线程阶段已经饱和。先分解排队与执行耗时,检查各层在途量、连接池等待和线程栈;整机平均 CPU 也可能掩盖单核饱和或容器限额。通过与异常时段一致的时间序列确定瓶颈,再调整资源或逻辑,不能先盲目加工作线程。
回答应覆盖 区分 CPU 和等待瓶颈 关注单核、配额与分层队列 先证据后扩容 ☆ 收藏 自评 需重学 不完整 能解释
动手验证 画出请求从入口到下游的资源表,列出每层数量/字节/时间预算与超限行为。再设计三个停机场景:空闲消费者等待、队列已满生产者等待、下游长时间不响应;给出每个等待者的唤醒条件和最终资源回收断言。
ENG-10 / 正确性与服务设计 ○ 标为掌握
项目面试与分布式故障:超时、幂等和一致性的边界 用需求、状态机、失败窗口和验证证据回答项目追问;每个“保证”都要说明适用范围。
项目介绍先给出可验证的约束:服务处理什么请求,数据规模和到达率如何测量,允许多大延迟和不一致窗口,自己负责哪部分。随后解释关键路径、状态存储、并发模型和最困难的失败场景。没有实际测量的数据应标注为估算或设计目标,不把本地实验写成线上收益。本节题目是工程推演题,不声称来自任何公司的真实面试。
超时表示调用方没有在期限内取得结果,并不证明远端未执行。重试前要确认操作是否安全重复、剩余总期限和重试预算。连接、TLS、请求传输、排队和响应读取的计时范围应根据客户端实现核查;不同层各自重试会放大请求。用有上限的退避和抖动分散重试,选定负责重试的一层,并区分明确拒绝与结果未知。过载时盲目重试可能让系统更难恢复。
幂等设计需要稳定操作标识、请求语义与持久状态。同一操作重试使用同一键;相同键但不同参数应按协议拒绝或明确处理。若业务写入与幂等记录分开提交,崩溃窗口仍可重复执行,因此要在同一事务中提交,或使用能恢复的持久状态机。键的作用域、并发重复请求、保留时间和响应重放都要设计。仅在内存 set 中记录已处理 ID 无法抵抗进程重启,也不能自动解决跨服务副作用。
消息“至少一次”通常意味着消费者必须面对重复,“至多一次”可能牺牲丢失后的恢复;exactly-once 需要明确是哪段读、处理和写入受到协议约束。Kafka 事务可以支持其规定范围内的消费与生产原子关系,但不能自动让外部邮件或支付只发生一次。跨数据库与消息系统常用事务 outbox 保证业务变更与待发事件同事务写入,再重试投递;投递仍可能重复,消费者需要幂等或 inbox。最终一致性也要有可追踪的收敛条件、积压监控与修复流程。
深入面试与自测 Q1 · 下单接口超时,客户端该换一个请求 ID 再试吗? 若仍是同一次业务操作,应保留同一幂等键,否则服务端无法辨认重复意图。服务端需原子地保护幂等记录和业务结果,对并发同键请求返回已有结果、处理中状态或按协议等待;同键参数不同要拒绝或明确规定。客户端还能查询状态。幂等记录过期后的迟到重试需要业务期限或唯一约束兜底,不能承诺无限期去重。
回答应覆盖 同意图同键 处理并发、参数差异和保留期限 强调持久原子性及查询恢复 ☆ 收藏 自评 需重学 不完整 能解释
Q2 · 五层调用链,每层最多尝试三次,会有什么问题? 若每层都把下层失败重试,最坏情况下底层一次顶层请求可被放大到 3 的 5 次方,即 243 次尝试;这是特定重试嵌套模型下的上界示例,不是所有系统的固定结果。应统一总期限与重试预算,在了解操作语义的一层集中处理,限制可重试错误,增加退避和抖动,并在过载时及时拒绝。观察重试率与有效成功率,避免用更多请求掩盖下游故障。
回答应覆盖 能计算且说明模型前提 统一预算和总期限 将重试与过载反馈联系起来 ☆ 收藏 自评 需重学 不完整 能解释
Q3 · 消费者先更新数据库再提交消息位点,怎样避免重复扣款? 两步之间崩溃会导致消息重投。若业务数据库支持事务,可以把消息 ID 的唯一去重记录与扣款结果放在同一事务内;重复消息查询已有结果,不再次扣款。若扣款发生在另一个支付服务,需要其幂等接口或持久工作流,单个本地数据库事务无法包住远端效果。提交位点的顺序只能改变丢失或重复窗口,不能凭空获得跨系统原子性。
回答应覆盖 指出数据库提交到位点提交的窗口 同事务记录去重与业务效果 说明远端副作用的额外协议 ☆ 收藏 自评 需重学 不完整 能解释
Q4 · 面试官问“为什么不用更复杂的分布式方案”,如何给出可信回答? 先列需求和故障模型:数据量、可接受延迟、部署规模、恢复目标、团队运维能力,再说明当前方案在哪些负载和故障下成立。例如单库事务已满足一致性与容量,分布式拆分会增加重试和跨边界失败,不应只因技术名称更先进而引入。给出容量监控、压测或故障演练证据,并定义何时需要迁移的阈值。若尚未做实验,明确提出验证计划而不虚构收益。
回答应覆盖 以约束与证据支撑选择 讨论复杂度和失败成本 给出可观察的演进触发条件 ☆ 收藏 自评 需重学 不完整 能解释
动手验证 为“创建订单后发送确认消息”画出持久状态和每个提交点,逐一注入:事务前失败、事务后进程崩溃、发送成功后确认丢失、消费者提交后确认丢失。说明订单、outbox、消息和消费者副作用各自怎样恢复,哪些情况会重复,以及用什么指标发现无法收敛。
启用 JavaScript 后可保存记录、搜索和安装。以下正文可以直接阅读。