百度基础安全部后端一面 · 参考答案
结合个人简历(腾讯搜狗输入法 C++ 客户端实习 / 科大讯飞 C++ 开发 / 杀戮尖塔2 Game Agent 项目)整理。
使用方式:每题先看「参考回答」(面试时说的大纲),再看「要点/追问」(防止被深挖)。
1. 自我介绍
参考回答(1~1.5 分钟):
面试官好,我叫江岸伟,安徽大学计算机技术硕士在读(211),本科是河北农业大学电子信息工程。
目前我在腾讯搜狗输入法 PC 客户端做 C++ 开发实习,主要做了三块事情:一是搭建了覆盖 5 个核心进程的资源占用监控链路,负责 CPU、内存、磁盘等指标的采集聚合上报,并用它定位了升级流程里的旧版 IME 文件残留问题;二是设计了 Watchdog 线程做 UI 卡顿检测,通过 PostMessage 探测和原子 ACK 时间戳三级判定,最后定位到 UI 热路径上跨进程互斥锁无限等待导致的卡死;三是针对这个问题做了流畅性优化,在保持 ABI 兼容的前提下新增超时锁接口,把无限等待改成有界等待,并用焦点驱动的 TLS 配置快照把按键路径上的加锁次数降了 50%。
之前还在科大讯飞智能汽车事业部做过 APA 自动泊车相关开发,用滑动窗口融合车位角点、GJK 碰撞检测判断通道占用。项目上做了一个基于 LangGraph 的《杀戮尖塔2》自主决策 Game Agent,多 Agent 协作 + MCP 接入游戏,可以无人干预跑完整局。
技术栈方面,我 C/C++ 比较扎实,熟悉 C++17、STL、RAII、多线程和 Linux 系统编程、Socket、epoll;同时很熟 AI 编程工具链,Claude Code、Codex、MCP、Subagent 这些,日常开发里 AI 编码占比比较高,也参与过组内 AI 知识沉淀机制的设计。
我对后端和基础方向很感兴趣,之前的经历偏客户端和系统编程,但底层的东西是相通的,希望能在基础安全部把 C++/Linux/性能这块的功底用在后端场景上。
要点:
- 按「教育 → 实习(重点)→ 项目 → 技能 → 为什么匹配岗位」的结构走,不要背简历,讲”做了什么 + 结果量化”。
- 主动往”基础/性能/系统”上靠,呼应百度基础安全部的方向。
2. 怎么保证你的监控对性能本身没有影响呢,有没有什么策略
参考回答:
我们监控的目标是”能感知问题,但自身不能成为问题”,主要从几个层面控制监控开销:
- 采样而不是全量:所有指标都是周期采样,比如每 5~10 秒采一次 CPU/内存快照,而不是持续统计,采样本身是瞬时读系统接口,开销很小。像 CPU 利用率这种用两次采样间隔内的增量来算,避免高频轮询。
- 采集与业务隔离:采集逻辑放在独立线程里,并且用低优先级,绝不放在 UI 线程或按键热路径上;被监控的 5 个进程之间通过系统级接口(性能计数器/进程查询 API)读数据,不向目标进程注入任何代码,所以对目标进程零侵入。
- 批量聚合、低频上报:采集到的指标先在本地聚合(比如 30 秒一个周期聚合成一个点),再批量上报,上报失败就缓存、降频重试,避免频繁建连和刷网络。
- 云控开关 + 自保护:监控策略本身是云控的,可以远程调采样率、开关某个指标;监控线程自己也有超时上限,防止监控任务堆积反过来拖垮业务。
- 原子操作代替锁:统计计数用原子变量,采集线程和业务线程之间共享的最小化,不加锁或只加极短锁。
要点/追问:
- 追问”CPU 利用率怎么算”:两次采样读进程 CPU 时间(kernel+user),差值 / 两次采样间隔墙钟时间差。
- 追问”采样间隔怎么定”:经验值 + 实验对比,云控可调;间隔越小越灵敏但开销越大,是 trade-off。
- 追问”怎么证明监控没影响性能”:A/B——开监控和关监控跑同一基准(按键延迟、CPU 基线),对比差异;监控自身开销也作为指标上报,有兜底开关。
3. 对于内存优化,有没有策略是不能达到上限这种
参考回答:
有,核心思路是”永远别等到撞上限,用水位线提前干预”,我在输入法场景会这么设计:
- 高/低水位线机制:给进程内存设一个软上限,比如目标上限的 80% 为高水位、60% 为低水位。到达高水位触发主动回收(清缓存、释放空闲资源),降到低水位才停止,避免频繁抖动。这比”到上限再处理”(那时已经可能 OOM 或被系统杀)安全得多。
- 缓存总量受限:所有缓存都设容量上限 + 淘汰策略(LRU),而不是无限增长;新数据进来先淘汰旧数据。
- 对象池/内存池:高频创建销毁的对象(比如按键事件、候选词结构)用池化复用,减少堆分配和碎片。
- 按需/懒加载:大资源(词库、皮肤资源)用的时候才加载,空闲一段时间可以卸载,用 CPU/IO 换内存。
- 内存监控 + 降级预案:就是我在腾讯做的资源监控链路,实时上报内存占用,接近水位线时业务主动降级:比如降低缓存上限、关闭非核心功能、压缩内存中的数据结构(词库分片压缩,用的时候解压)。
- 控制 commit 而不是只看 RSS:Windows 上关注 committed memory,避免虚拟内存无限增长;预留/承诺分开。
要点/追问:
- 追问”水位线数值怎么定”:压测 + 线上分位数(比如 P99),留 20% 安全余量;云控可调。
- 追问”内存泄漏怎么查”:dump 对比(两次抓 dump 看增长对象)、UMDH/valgrind/ASAN,配合 AI 分析 dump 的流程(见第 8、9 题)。
- 这和手撕 LRU(第 16 题)可以呼应:LRU 就是”缓存总量受限”的具体实现。
4. PostMessage 和 SendMessage 区别
参考回答:
这是两个语义完全不同的消息投递方式:
- PostMessage 是异步的:把消息丢进目标线程的消息队列后立即返回,不等待对方处理。发送方不阻塞,但如果目标线程一直不处理,消息会积压。
- SendMessage 是同步的:会直接调用目标窗口的窗口过程,并且等待处理完成才返回。同线程内 SendMessage 相当于直接函数调用;跨线程时发送线程会阻塞,直到接收线程处理完这条消息才继续。
在我做的 UI 卡顿检测里正好用到了这个区别:Watchdog 线程用 PostMessage 每 200ms 发一条探测消息,发完就继续计时,不依赖 UI 线程是否空闲;UI 线程收到后更新一个原子 ACK 时间戳。如果 UI 线程卡死(比如阻塞在锁上),消息不会被处理,ACK 时间戳不更新,Watchdog 就能根据响应延迟做三级判定。
而 SendMessage 的危险之处正是跨线程阻塞:如果接收线程恰好卡死,发送线程会跟着挂起,甚至互相 SendMessage 会形成死锁。所以跨进程/跨线程的同步调用一般要带超时(比如 SendMessageTimeout),我们后面做超时锁也是同样的思路——把无限等待变成有界等待。
要点/追问:
- PostMessage 的局限:消息队列容量有限,队列满会丢消息;所以不能把 PostMessage 当可靠传输。
- SendMessage 跨线程时,如果接收线程的队列里也有 SendMessage 等待,系统会直接调用窗口过程(不进队),这是细节,能说出来加分。
- 追问”那你怎么保证探测消息不被积压影响判定”:ACK 时间戳是判断依据,积压正好说明 UI 没在处理消息,本质上就是卡。
5. 怎么判断卡死的呢,有没有可能是处理其他东西然后导致的呢
参考回答:
判断卡死我用的是 Watchdog 探针 + 分级判定 + 抓栈确认 三步:
- 探针机制:独立 Watchdog 线程每 200ms 通过 PostMessage 发一条探测消息,UI 线程收到后更新原子 ACK 时间戳。Watchdog 用”上次 ACK 距今多久”衡量响应延迟。
- 三级判定:延迟超过阈值 A 判”可疑”,超过阈值 B 且连续 N 次判”卡死”,避免偶发抖动误报;判定卡死后暂停 UI 线程采集调用栈。
- 抓栈定性:光看”没响应”不能下结论,必须看调用栈确认卡在哪。我们当时就是抓栈发现 UI 线程阻塞在跨进程互斥锁的等待上,才定位到根因。
关于”有没有可能是处理其他东西导致的”——非常有可能,而且这是判定里最容易误判的点,我总结过几类:
- 长任务占用:UI 线程正在同步处理一个很长的任务(比如全量扫描文件),消息循环根本没机会跑,但这不是死锁;
- 锁等待/死锁:阻塞在互斥锁、临界区上,典型特征是CPU 占用不高但无响应;
- 死循环/高 CPU 忙等:典型特征是CPU 飙高且无响应;
- 外部依赖阻塞:磁盘 I/O 卡住、跨进程调用对方不响应、系统资源耗尽。
所以区分手段是组合拳:ACK 是否更新 + 抓栈 + CPU 使用率 + 卡死时刻上下文。比如:ACK 一直不更新但 CPU 很低 → 大概率在等锁;CPU 很高 → 大概率死循环。抓到的调用栈是最终证据。当时我们就是先通过 ACK 判定卡死、抓栈看到锁等待、再看栈上等的是跨进程锁,最终定位到是缺少超时机制的跨进程互斥锁导致的,后续才做了超时锁优化。
要点/追问:
- 追问”三级阈值怎么定”:200ms 探测下,正常响应在毫秒级;阈值取经验值(如 500ms 可疑、连续 3 次超 1s 判卡死),云控可调,还要做实验避免误报。
- 追问”卡死和假死怎么区分”:抓栈 + dump 分析,栈上能看到是等锁还是执行中。
- 追问”跨进程锁为什么会导致 UI 卡”:UI 线程去获取宿主进程持有的跨进程互斥锁,如果对方持有锁期间崩溃或长时间不释放,UI 线程无限期阻塞。
6. 你这个 agent,听起来多 agent 协作没有实际发生,只是逻辑上的,为什么不用单 agent 呢
参考回答:
这个问题问得很尖锐,我承认如果”多 agent”的定义是多个模型实例并发互相讨论,那我这个项目确实不是那种形态。但我认为职责拆分本身就是多 agent 的核心价值,我当时的考量是:
- 上下文隔离:战斗决策要的是”当前手牌、血量、敌人意图”这种实时状态;卡组构建要的是”整局构筑方向、已拿的牌”这种长期信息;路线规划要的是地图信息。三者的信息时效和粒度完全不同,塞进一个 agent 的上下文里会互相污染,而且上下文窗口有限,塞多了决策质量下降。拆成三个 agent,每个的 system prompt 和上下文都更短、更聚焦。
- 确定性路由:每个 agent 都有明确职责和输入输出(通过类型化消息总线传结构化数据),路由是确定性的,这比让一个大模型自己决定”现在该干什么”更可控、更可追踪。
- 可测试、可调试:拆开后每个 agent 可以单独验证(卡组构建 agent 单独跑 100 局看构筑质量),消息总线有日志,复盘时可以看完整协作链路。单 agent 是个黑盒,出问题很难定位。
- 成本与稳定性:战斗决策是实时路径,如果每步都由一个装了大量上下文的大 agent 决策,延迟和出错率都更高;拆开后关键路径上的推理更轻。
反过来,单 agent 确实更简单、token 更省,如果任务简单我也不会硬拆。我这个场景的取舍是:用多 agent 的编排成本换职责隔离和可追踪性。另外我也想请教下面试官,从你们后端工程的角度看,多 agent 协作的落地形态一般怎么评估?
要点/追问:
- 别嘴硬,先承认形态上的事实(没有并发互聊),再讲清楚”为什么这么设计是对的”。
- 如果被追问”多 agent 到底多在哪”:多个独立 LLM 实例、不同 system prompt、不同工具集、不同记忆上下文,通过消息总线协作——这就是工程上的多 agent 系统(AutoGen、Swarm 也是这个模式),不是”逻辑上假装多 agent”。
- 能提一句”真正的并行协作在任务可并行时才用”(比如路线规划可以和战斗复盘并行)。
7. AI 写代码的比例
参考回答:
分场景说实话:整体上我日常开发大约 50%~60% 的代码有 AI 参与生成,但分布很不均匀:
- 样板/胶水代码、工具脚本、单元测试:80% 以上直接让 AI 写,我 review。
- 核心逻辑(锁、并发、性能敏感路径、算法):大概 20%~30%,而且 AI 生成的版本我一定会逐行 review + 压测,很多时候自己重写。
- 调试排错:AI 帮助很大,60% 以上的报错定位是 AI 辅助的(给它日志和栈,它缩小范围),但根因判断和修复方案我自己把关。
- 设计和架构:基本 100% 自己主导,AI 只做信息检索和方案对比。
举个例子:Watchdog 卡顿检测的框架(探测线程、ACK 协议、三级判定)是我设计的,消息收发和日志这类代码 AI 生成后我 review 改了判定阈值和超时逻辑。另外我也在参与组内 SG Flow 的知识沉淀机制,把 AI coding 过程中暴露的代码库约束、历史故障沉淀成领域 Memory,让 AI 在后续编码时少犯同样的错。
我的原则是:AI 负责”写得多”,我负责”想得清楚”——需求拆解、方案设计、并发和性能的关键决策、以及最终 review 一定是人。
要点/追问:
- 追问”AI 写的有 bug 怎么办”:review 清单(边界条件、并发安全、资源释放、异常路径)+ 测试兜底;关键代码不用 AI 直出。
- 追问”AI 代码风格/质量怎么保证”:code review、静态检查、知识库注入规范(SG Flow Memory)。
8. 有没有处理死锁、崩溃的经验
参考回答:
有,而且就是我实习里最核心的一个 case:
死锁/卡死:我们线上反馈输入法 UI 卡死,我用 Watchdog 抓栈定位到 UI 线程阻塞在一个跨进程互斥锁的无限等待上——UI 热路径会获取一个和宿主应用共享的跨进程锁,而它没有超时机制,一旦对方持有锁异常(崩溃、长时间不释放),UI 线程就无限期挂起,表现为输入法甚至宿主应用卡死。处理方式是:在保持 ABI 兼容的前提下新增超时锁接口,把无限等待改成有界等待,等待超时走降级路径;同时配合焦点驱动的 TLS 配置快照,让 UI 热路径直接读线程本地副本,把按键路径的加锁次数降了 50%。这是典型的”锁等待导致假死”案例,排查链路是:Watchdog 判卡 → 抓栈看到锁等待 → 确认跨进程锁无超时 → 加超时 + 减少加锁。
崩溃:处理过空指针、野指针类的崩溃,流程是复现后抓 dump(minidump)+ 符号文件,用调试器看异常代码和调用栈定位。另外我也用过组内搭建的 AI 工作流分析 dump 文件,把 dump 交给 AI 工具,它结合符号信息输出调用栈分析和根因猜测,我们人工复核后确认。
理论上死锁的四个必要条件(互斥、持有并等待、不可剥夺、循环等待)我也熟悉,所以日常写代码会注意:锁顺序一致、加锁尽量细粒度、用 scoped_lock 一次拿多把锁、等待一律带超时。
要点/追问:
- 追问”超时锁怎么做 ABI 兼容”:新增接口而不是改旧接口签名,旧模块继续用旧接口(无限等待),新模块用带 timeout 的接口;或者通过配置灰度切换。
- 追问”跨进程锁超时后怎么办”:降级——跳过本次同步、走异步重试、UI 不阻塞直接返回”稍后重试”。
- 追问”抓 dump 的时机”:卡死持续超过阈值(如 5s)自动抓 dump,避免偶发;崩溃则用 WER/自定义 handler 抓。
- 呼应第 4 题:SendMessage 无限等待本质和跨进程锁无限等待是一类问题——同步跨进程调用必须带超时。
9. 底层 AI 怎么分析的你知道吗(AI 分析 dump 的原理)
参考回答:
我了解整体链路,但坦诚说内部细节我不是全懂,我把我知道的讲一下:
传统 dump 分析本身是成熟工具链:抓取 minidump 后,用 WinDbg/cdb 这类调试器加载符号文件,做**栈展开(stack unwinding)**拿到线程调用栈,再结合
!analyze -v(自动分析)、!locks(锁等待链)、!threads(线程列表)、异常记录(异常代码、出错地址、寄存器)等命令输出结构化的诊断信息。AI 工作流本质上是在这个工具链之上加了一层 LLM 理解:
- 先把 dump 的关键信息文本化/结构化——调用栈、异常代码、线程状态、锁等待链,喂给 LLM 作为上下文;
- LLM 基于大量训练过的崩溃/死锁模式做模式识别——比如”线程 A 等在锁 X 上,线程 B 持有锁 X 又在等锁 Y,线程 A 持有锁 Y”就是循环等待死锁的典型栈形;比如”栈顶是 memcpy 且参数异常大”往往指向越界写;
- 更进一步的工作流是 agentic 的:AI 不只是看一份静态输出,而是像人一样迭代执行调试命令,根据上一步结果决定下一步查什么(先看 !analyze,再针对可疑线程看栈,再查它持有的锁),最后生成根因报告和建议。
所以底层支撑是两块:传统调试工具链负责”把二进制变成结构化的可读证据”(符号解析、栈展开、锁链分析),LLM 负责”从证据里识别模式、生成假设”。分析质量取决于喂给它的证据质量,所以 dump 抓得好不好(有没有符号、是不是抓在关键状态)很关键。
要点/追问:
- 诚实承认深度有限,但把”工具链 + LLM 两层”的框架讲清楚就够。
- 如果面试官追问具体命令:
!analyze -v是 WinDbg 的自动分析,kb看栈回溯,!locks看锁,.ecxr切到异常上下文。 - 可补一句:Linux 侧对应的是 core dump + gdb/eu-stack,AI 分析同理。
10. Linux 操作系统有 pid 复用的问题,你知道怎么避免吗
PID 复用问题的根源是:PID 数量有限(默认上限
/proc/sys/kernel/pid_max,一般是 32768),进程退出后它的 PID 会被新进程复用。如果程序缓存了某个进程的 PID,之后再用这个 PID 操作,可能操作到的是一个无关的新进程,这就是经典的 check-then-use 竞态。避免手段按推荐程度:
- pidfd(最推荐,Linux 5.3+):用
pidfd_open()拿到一个进程的文件描述符,pidfd 是稳定句柄,不会随 PID 复用而失效;之后用pidfd_send_signal()发信号、用poll()监听进程退出,全程不依赖 PID。这是内核为”安全地持有进程引用”提供的正解。- 启动时间(starttime)比对(经典兜底):
/proc/<pid>/stat的第 22 个字段是进程启动时间(jiffies)。操作前先读一次 starttime 并打开/proc/<pid>的引用,操作后再读一次比对,不一致就说明 PID 已被复用、本次操作作废。注意要先打开 fd 再比较,把竞态窗口缩到最小。- 进程内自己管理生命周期:如果是自己 fork 的子进程,用
waitpid()回收、用管道/信号量做”存活心跳”,不要依赖裸 PID;父进程死了子进程要处理孤儿进程的 reparent 问题。- 提升 pid_max 只是缓解:能降低复用频率,但解决不了复用本身,而且 pid_max 太大有安全风险(PID 可被用于侧信道)。
一句话总结:能不用 PID 就不用 PID,要长期持有进程引用就用 pidfd;必须用 PID 时用 starttime 校验防复用竞态。
要点/追问:
- 追问”为什么 kill(pid, 0) 不够”:它只能确认”当前有个进程存在”,无法确认”是不是原来那个进程”,复用窗口内照样误判。
- 追问”内核里 PID 怎么分配”:
next_pidmap在 pid 位图上循环查找,绕一圈回来就复用;pid_max限制范围。 - 追问”容器里 PID namespace”:不同 namespace 里 PID 独立编号,
/proc看到的是 namespace 内的视图。
11. 讲讲 C++17 你用过的特性
参考回答:
我在实习代码里实际用到过的 C++17 特性,挑几个有代表性的:
std::scoped_lock:一次锁多把互斥锁且按统一顺序加锁,从语言层面避免多锁死锁,我们多线程代码里用它替代手写 lock_guard + 顺序约定。std::shared_mutex/shared_lock:读多写少的场景(比如我的 TLS 配置快照、配置表),读用共享锁、写用独占锁,比普通 mutex 并发度高。std::optional:表示”可能不存在的值”,比如缓存查询、配置项缺省,比”返回 -1 / 空指针”语义清晰。std::string_view:只读字符串视图,零拷贝传参,避免临时 string 的堆分配——性能敏感的路径上用。- 结构化绑定:
auto [k, v] = map_entry,解构 pair/tuple,遍历 map 和解析返回结构很方便。if constexpr:模板里按类型编译期分支,比如泛型工具函数里对 POD 和自定义类型走不同实现,编译期就裁掉无用分支。std::filesystem:遍历目录、清理文件,我们定位 IME 残留问题、写清理逻辑时用的就是它。- 还有
std::variant、[[nodiscard]](强制检查返回值,我们监控上报接口都标了)、CTAD(类模板实参推导)这些日常也在用。顺带一提,从 C++11 到 C++17 的主线其实是更安全的内存和并发模型:智能指针、移动语义、scoped_lock、shared_mutex——这跟我们做客户端稳定性、性能优化的诉求很契合。
要点/追问:
- 挑 3~5 个讲深(能说出”为什么需要、解决什么问题”),别背清单。
- 追问”shared_mutex 什么时候用”:读远多于写;写频繁时共享锁反而慢(缓存行伪共享、读者争用)。
- 追问”string_view 的坑”:它不拥有内存,引用源必须比它活得久,不能存 string_view 指向临时对象。
12. 智能指针是有什么需求提出来的
参考回答:
智能指针是被裸指针的四个经典问题逼出来的:
- 内存泄漏:忘了 delete,或提前 return / 抛异常导致 delete 执行不到;
- 重复释放(double free):两处代码都认为所有权是自己的;
- 悬垂指针(use-after-free):对象已被释放,还有指针在用它;
- 所有权语义不清:谁该释放、什么时候释放没有约定。
而 C++ 恰好有一个机制可以解决这些:RAII——对象析构是确定性的。于是把”堆上的资源”包进一个”栈上的对象”(智能指针本体),这个包装对象离开作用域时析构函数自动释放底层资源,异常安全也随之解决(栈展开时析构一定执行)。
具体演进:
- C++98 的
auto_ptr因为拷贝语义有坑(拷贝即转移所有权,容易悬垂)被废弃;- C++11 引入
unique_ptr:独占所有权 + 移动语义,零额外开销,表达”只有一个所有者”;shared_ptr:共享所有权 + 引用计数,表达”多个所有者”,控制块管理计数;配套weak_ptr打破循环引用。所以一句话:智能指针是”资源所有权管理”这个需求提出来的——让所有权显式化(独占/共享/弱引用),并借助 RAII 自动、确定性地释放,从语言层面消灭泄漏、double-free 和悬垂。
要点/追问:
- 追问”shared_ptr 线程安全吗”:引用计数本身原子操作线程安全,但指向的对象不是,多线程读写对象仍需加锁。
- 追问”循环引用”:A 持 shared_ptr<B>、B 持 shared_ptr<A>,引用计数永不归零;用 weak_ptr 打破。
- 追问”unique_ptr 怎么转 shared_ptr”:直接构造,移动语义零成本;反向不行(所有权降级不允许隐式)。
- 追问”性能”:unique_ptr 无额外开销;shared_ptr 每次拷贝有原子操作计数开销。
13. RAII 是只利用栈来管理生命周期吗
参考回答:
不是。RAII 的核心定义是”构造函数获取资源、析构函数释放资源”,它的前提是析构函数在确定性的时机被调用,而”确定性析构”并不只发生在栈上:
- 栈对象(最常见):局部变量离开作用域析构——这是 RAII 最经典的形态(lock_guard、智能指针本体都是)。
- 堆上对象 + 智能指针:
unique_ptr/shared_ptr管理的对象本身在堆上,但它的释放时机由智能指针的析构(栈上/成员上的析构)确定——所以”堆资源 + 栈上的 RAII 包装”才是完整形态,资源在堆、管理在栈。- 类成员对象:成员对象的生命周期绑定在所属对象上,所属对象析构时成员依次析构(比如类的 mutex 成员)。
- 容器元素:
vector/map析构时,其中元素(如果元素本身是 RAII 类)逐个析构,资源随之释放。所以准确的说法是:RAII 靠”确定性的析构时机”管理生命周期,栈只是最常见的承载者。真正让 RAII 成立的是 C++ 的确定性析构语义——这也是 C++ 对比 Java/Go 这类 GC 语言的优势:GC 语言有 finalizer 但时机不确定,无法用它做资源管理。
还有一点,RAII 也不只是”释放”:它可以管理任何”需要成对操作”的资源——锁的加/解、文件的开/关、事务的 begin/commit、甚至监控探针的挂载/卸载,都可以用 RAII 包装成”构造时做、析构时还原”,析构函数里做恢复操作也是 RAII 的常见用法(scope guard)。
要点/追问:
- 追问”那怎么保证析构一定执行”:栈展开(正常离开或异常抛出)都保证局部对象析构;进程级异常(崩溃、exit)不保证——所以 crash 时要靠 dump 而不是析构清理。
- 追问”scope guard 是什么”:C++17 没有标准实现(提案未进),可以用宏 + 局部 RAII 对象模拟,析构里执行”收尾动作”,保证异常路径也执行。
- 呼应简历:超时锁的”有界等待”如果用 RAII 包装,析构时判断是否超时并做降级。
14. 讲讲 IO 多路复用,epoll 底层是什么
参考回答:
IO 多路复用的本质是用一个线程同时监听大量 fd 的就绪事件,解决”每个连接一个线程”的 C10K 问题。演进是 select → poll → epoll:
- select:fd 集合是位图,有 1024 上限;每次调用要把全部 fd 从用户态拷贝到内核态,内核线性扫描所有 fd 检查就绪,返回后用户态还要再扫描一遍找就绪的——O(n) 且频繁拷贝,n 大了很浪费。
- poll:把位图换成链表,取消 1024 上限,但仍然是”全量拷贝 + 线性扫描”。
- epoll:改成事件驱动 + 回调,复杂度从 O(n) 变 O(就绪数)。
epoll 的底层结构(以 Linux 内核实现为准):
epoll_create在内核创建一个 eventpoll 对象,核心是两个数据结构:一棵红黑树(存放所有注册监听的 epitem,增删改查 O(log n))和一个就绪链表 rdllist(存放已就绪的 epitem)。epoll_ctl(ADD/MOD/DEL)就是在红黑树上插/改/删节点;ADD 时还会把回调函数注册到被监听文件的等待队列上(ep_ptable_queue_proc→ep_poll_callback)。- 关键在回调:当文件就绪时(比如网卡收到数据,内核把 skb 挂到 socket 接收队列并唤醒),会调用注册的
ep_poll_callback,把该 epitem 挂到就绪链表上,并唤醒在epoll_wait上睡眠的进程。epoll_wait做的事:检查就绪链表,有就直接把就绪事件拷贝到用户态缓冲区返回;没有就把当前进程挂到 eventpoll 的等待队列睡眠,超时或被打醒再检查。因为内核只遍历就绪链表,所以效率与总 fd 数无关。触发模式:LT(水平触发)——只要缓冲区还有数据就每次 epoll_wait 都通知;ET(边缘触发)——只在状态变化(从无到有)时通知一次,所以 ET 必须循环读直到 EAGAIN,否则丢数据。ET 效率更高(通知次数少),但编程要求更高,是经典的坑。
回到我的经历:客户端和后端在这一点是相通的——我在实习里做 Socket 网络编程时就用的这套模型(epoll + 非阻塞 + ET/LT 取舍),后端高并发服务也是同一套底层。
要点/追问:
- 追问”epoll 为什么比 select 快”:① 无全量拷贝 ② 内核不扫描全部 fd,只碰就绪的 ③ 回调机制避免轮询。
- 追问”惊群问题”:多个线程同时 epoll_wait 同一个 epoll fd 时,一个就绪事件可能唤醒多个线程;内核 4.5+ 加了
EPOLLEXCLUSIVE,或用 SO_REUSEPORT + 各自 epoll。 - 追问”epoll 本身是阻塞还是非阻塞”:epoll_wait 可以设超时;监听 fd 要设非阻塞配合 ET。
- 追问”和 io_uring 对比”:io_uring 是异步 IO 模型(提交/完成队列,内核态直接完成 IO,免系统调用),epoll 是”事件通知 + 同步读写”模型;能说出 io_uring 一句就加分。
15. 在 AI coding 过程中,你参与了哪些部分呢
参考回答:
我用 Claude Code、Codex 这类工具做日常开发,参与的部分按职责分:
- 需求拆解与方案设计(我主导):把需求拆成任务、定技术方案、定接口和数据结构。AI 在这阶段只做信息检索(查 API 用法、对比方案)。
- 架构与关键代码(我主导,AI 辅助):并发、锁、性能敏感路径我自己写或深度 review。比如 Watchdog 的探测协议、ACK 时间戳、三级判定逻辑,是我定框架,AI 生成初版后我逐行 review,重点改了超时参数、边界条件、线程安全。
- 样板与重复代码(AI 主导,我 review):消息收发、日志、序列化、单元测试、工具脚本这类,AI 生成效率很高,我 review 边界和错误处理。
- 调试排错(人机协作):报错、崩溃、卡死问题,我先喂给 AI 日志/栈,它缩小范围给假设,根因判断和修复方案我把关。比如 dump 分析那条链路,AI 给分析结果,我复核栈和锁链。
- 知识沉淀(我参与设计):这是我在腾讯参与的一个亮点——SG Flow 的领域记忆机制。我们把 AI coding 复盘里暴露的代码库特有约束、历史故障、验证经验提取成领域 Memory,带来源追溯,按模块作用域管理,再通过工作流阶段 Hook 注入到 AI 的方案设计、编码和检查阶段。相当于给 AI 建了一个关于这个代码库的长期记忆,让它越写越贴合项目,也让我在”教 AI”的过程中加深对代码库的理解。
我的总结是:AI 提升的是我的”写”的效率,我负责”想”和”守”——设计、判断、review 和知识管理。而且这个经历恰恰让我对 AI 工具的内部机制(MCP、Subagent、上下文工程)比一般开发者理解更深。
要点/追问:
- 追问”你怎么 review AI 代码”:清单式——边界条件、并发安全、资源释放、异常路径、性能(避免隐式拷贝)、风格一致。
- 追问”AI 写代码的坑”:上下文幻觉(编造 API)、重复代码、过度设计、忽略错误处理;靠约束注入(Memory)+ review 兜底。
- 追问”比例”:见第 7 题,别吹太高也别贬太低,强调关键部分人把关。
16. 手撕 LRU
参考回答:
LRU 要求 get/put 都是 O(1),标准做法是 哈希表(O(1) 查找)+ 双向链表(O(1) 移动/删除):哈希表定位节点,双向链表维护访问顺序,头部是最近使用、尾部是最久未使用;put 超容量时淘汰尾部。
C++17 实现(STL 版,面试先写这个):
class LRUCache {
public:
explicit LRUCache(int capacity) : cap_(capacity) {}
int get(int key) {
auto it = mp_.find(key);
if (it == mp_.end()) return -1;
// 命中:移到链表头部(最近使用)
lst_.splice(lst_.begin(), lst_, it->second);
return it->second->second;
}
void put(int key, int value) {
auto it = mp_.find(key);
if (it != mp_.end()) {
// 已存在:更新值并移到头部
it->second->second = value;
lst_.splice(lst_.begin(), lst_, it->second);
return;
}
if ((int)lst_.size() == cap_) {
// 淘汰最久未使用的尾部节点
auto& back = lst_.back();
mp_.erase(back.first);
lst_.pop_back();
}
lst_.emplace_front(key, value);
mp_[key] = lst_.begin(); // 哈希表存迭代器,O(1) 定位
}
private:
int cap_;
std::list<std::pair<int, int>> lst_; // 双向链表:头部最近、尾部最久
std::unordered_map<int, std::list<std::pair<int, int>>::iterator> mp_;
};
手写双向链表版(面试常要求,体现功底):
struct Node {
int key, val;
Node *prev = nullptr, *next = nullptr;
Node(int k = 0, int v = 0) : key(k), val(v) {}
};
class LRUCache {
public:
explicit LRUCache(int capacity) : cap_(capacity) {
head_ = new Node(); tail_ = new Node();
head_->next = tail_; tail_->prev = head_;
}
int get(int key) {
auto it = mp_.find(key);
if (it == mp_.end()) return -1;
moveToHead(it->second);
return it->second->val;
}
void put(int key, int value) {
auto it = mp_.find(key);
if (it != mp_.end()) {
it->second->val = value;
moveToHead(it->second);
return;
}
if ((int)mp_.size() == cap_) {
Node* rm = tail_->prev; // 淘汰尾部(最久未使用)
removeNode(rm);
mp_.erase(rm->key);
delete rm;
}
Node* n = new Node(key, value);
addToHead(n);
mp_[key] = n;
}
private:
void removeNode(Node* n) {
n->prev->next = n->next;
n->next->prev = n->prev;
}
void addToHead(Node* n) {
n->next = head_->next; n->prev = head_;
head_->next->prev = n; head_->next = n;
}
void moveToHead(Node* n) { removeNode(n); addToHead(n); }
int cap_;
std::unordered_map<int, Node*> mp_;
Node *head_, *tail_; // 哑结点,避免空链表边界判断
};
/**
* 复杂度:get/put 均 O(1)
* 追问1 线程安全版:外部包一层 std::mutex;读多写少用 shared_mutex,
* 注意 shared_mutex 时 splice/改链表要独占锁,get 命中也要改链表(写操作)。
* 追问2 为什么不用 vector:中间插入/删除 O(n)。
* 追问3 为什么哈希表存迭代器/指针:O(1) 定位链表节点,否则查找节点要 O(n)。
* 追问4 和项目关联:缓存总量受限 + 水位线(第3题)里,淘汰策略就是 LRU 这类。
*/
要点/追问:
- 先讲思路再写代码;写完说一句”get/put 都是 O(1),空间 O(capacity)”。
- 追问”get 也算写操作”:命中要移动节点,所以并发下 get 也要独占/写锁。
- 追问”LFU 呢”:LRU 看”最近用没用”,LFU 看”用得频不频”,LFU 要维护频率桶(每频率一个链表),淘汰频率最低且最旧的。
- 追问”为什么用双向链表不用单向”:删除节点需要前驱指针,单向链表删尾要 O(n) 找前驱。
附:本场面试的整体策略
- 问题集中在你简历的真实经历上(监控、卡顿、AI coding),所以第 2、3、5、8、15 题务必用真实案例回答,别背概念——面试官明显在验证简历真实性。
- 穿插的八股(4、10、11、12、13、14、16)都是”基础且常用”级别,没有偏题,说明面试官看重底子;每题都要能讲出”为什么存在/解决什么问题”,而不是背定义。
- AI 相关题目(6、7、9、15)是这个岗位的差异化考点:百度基础安全部显然在用 AI 提效,你的 SG Flow 知识沉淀经历是加分项,准备好讲清楚”人机分工”和”多 agent 为什么这么设计”。
- 手撕 LRU 建议两种写法都练熟,先 STL 后手写链表,边写边讲思路。