绿盟科技软件研发秋招二面:C++ 内存排查与推理平台设计
- 轮次
- 二面
- 时间
- 2026-09
- 来源
- 牛客网
《面试题目》
- 自我介绍
- C++ 服务偶发堆损坏,但崩溃位置每次都不同,你会怎么排查?
- 介绍一下你在实习中负责的系统,最难的问题是什么?
- TCP 已经有确认和重传机制,为什么应用层仍然可能不知道一条消息是否被对方真正处理?
- 介绍多租户推理流量治理与 KV Cache 调度平台的整体设计
- valgrind 是怎么发现非法内存访问的?它有哪些局限?
- epoll 的边沿触发模式下,为什么必须持续读取到 EAGAIN?多线程下还要注意什么?
《参考解析》
1. 堆损坏但崩溃位置每次都不同怎么排查。崩溃点飘移本身就是最重要的线索:它说明真正破坏内存的写操作发生得更早,free/malloc 或 STL 容器析构只是恰好在检查堆元数据时踩到了已经坏掉的结构,报错位置是「发现者」而不是「肇事者」。排查顺序上,第一步是让错误暴露在发生现场:用 AddressSanitizer 重新编译(-O1 -g -fno-omit-frame-pointer -fsanitize=address,undefined,链接同样带上 sanitizer),ASan 会在每次内存访问时检查红区与影子内存,能直接报出越界写、释放后使用、重复释放的调用栈;建议同时开 UBSan 抓未定义行为。如果问题只在优化版本或线上复现、无法用 ASan,就保留 core dump 用 gdb 看崩溃线程栈、寄存器与相关对象内容,并给关键对象加 magic number、长度字段和分配时的调用栈记录,用这些自证字段判断对象是否被踩坏。第二步是收窄嫌疑:重点检查越界写(数组下标、memcpy 长度、字符串拼接)、重复释放、释放后使用、跨线程生命周期错误(一个线程析构了另一个线程仍在用的对象)、以及缓冲区与结构体对齐问题。第三步是提复现概率:ASan 不查数据竞争,要用 TSan 单独跑;再用压测、线程绑核、故障注入、把内存分配器换成调试版(glibc 的 MALLOC_CHECK_、MALLOC_PERTURB_)提高命中率。最后要建立一个判断原则:定位时不要停在最后一次 free,要顺着最早破坏内存的那次写往下找。
2. TCP 有确认重传,为什么应用层仍无法确定消息被处理。因为 TCP 的确认语义只到「字节进入了对端协议栈的接收缓冲区」,它既不保证对端进程读到了,更不保证业务逻辑执行完成。典型场景是:服务端收下请求、写库成功,但在返回响应前进程崩溃或网络中断,客户端超时后无法区分「请求没执行」和「执行了但响应丢了」,于是重试会导致重复扣款、重复下单。要做到业务上的「恰好一次」,只能在应用层补三件事:一是给每个请求带全局唯一请求 ID,服务端用唯一索引或幂等表保证同一 ID 只落一次业务效果,重试直接返回首次结果;二是提供状态查询接口,客户端超时后按请求 ID 查最终状态,而不是盲目重试;三是跨系统时用本地事务表或 Outbox 模式,把业务数据与待发送事件放进同一个本地事务,再由后台补偿投递,消费端同样做幂等去重。把结论压缩成一句话:所谓 exactly-once 在传输层并不存在,它是由「至少一次投递 + 消费端幂等」组合出来的业务效果。
3. 多租户推理流量治理与 KV Cache 调度平台的分层设计。分成四层讲最清楚:入口层做鉴权、租户识别、配额与限流、请求标准化(统一成内部推理请求结构,标记模型、输入长度、优先级、截止时间);调度层按模型、输入长度、优先级和 SLA 组批,把长短请求分开排队,避免一个超长请求拖住整批;执行层管理模型实例、显存与 KV Cache,负责实际的批推理与流式输出;控制层做模型发布、灰度切换、指标采集与告警。调度上要讲清动态批处理的权衡:增大 batch 能提升吞吐,但每个请求的排队延迟和首 token 延迟都会涨,所以调度器要同时限定最大批大小、最大等待时间(例如 20~50ms 的攒批窗口)和单批 token 上限,并区分在线请求(优先保首 token 延迟)与离线请求(优先保吞吐)。KV Cache 的管理方式是显存利用率的关键:把连续的逻辑 token 序列切成固定大小的块,再把块映射到不连续的物理显存页(分页式管理),消除因长度不一造成的显存碎片,也便于按块共享前缀和回收;按租户设置显存配额与水位阈值,水位过高时优先淘汰低优先级、最久未访问的会话。模型热更新用新旧实例并存 + 引用计数:新请求切到新版本,旧版本继续把手上的序列生成完,等引用归零再释放显存,避免更新时打断正在生成的请求。这套设计的面试加分项是主动给出可观测指标:各租户的 QPS、排队时长、首 token 延迟 P99、显存水位、缓存命中率与淘汰次数。
4. Valgrind 怎么发现非法访问,局限在哪。以 Memcheck 为例,它在动态翻译阶段插桩:把机器指令翻译成中间表示并在每次内存读写前插入检查代码,同时为进程的每一字节内存维护「影子状态」——记录该地址是否可访问(对应分配块的红区与未映射页),以及该字节的值是否已初始化。读写发生时同步比对影子状态,因此能发现越界访问、释放后使用、读未初始化值以及部分重复释放;泄漏检测则是进程退出时从寄存器、栈和全局区出发做可达性分析,报告不再可达的已分配块。代价和局限同样明显:插桩导致运行速度通常下降 10~50 倍、内存开销成倍上涨,不适合压在高负载生产环境;它不擅长发现数据竞争、栈上复杂生命周期问题和只在特定时序下出现的错误(线程错误要换 Helgrind/DRD,且误报较多)。实际用法上常配合完整选项跑:valgrind --tool=memcheck --leak-check=full --show-leak-kinds=all --track-origins=yes ./server,其中 --track-origins 用于追溯未初始化值的来源。开发阶段一般优先用 ASan/UBSan/TSan,因为它们快得多、错误栈更直接;Valgrind 更适合无法重新编译、或需要精确定位「未初始化值从哪来」的场景。
5. epoll 边沿触发为什么必须读到 EAGAIN。ET 模式只在文件描述符状态发生变化时通知一次(不可读 → 可读)。如果一次事件只读走了部分数据,socket 缓冲区里仍有剩余数据可读,此时状态没有发生新的变化,epoll 不会再投递事件,剩余数据就可能一直躺在缓冲区里直到超时——表现为「偶发卡住、请求不返回」。所以 ET 模式必须配非阻塞 fd,并在收到事件后循环读直到返回 EAGAIN/EWOULDBLOCK(此时确认缓冲区已空,内核下次收到数据会重新触发通知);循环里还要正确处理 n > 0(消费数据)、n == 0(对端正常关闭,关闭连接)、EINTR(被信号打断,继续重试)以及其它 errno(视为错误并关闭连接)。写侧同理:要一直写直到 EAGAIN,并把没写完的数据挂到该连接的发送缓冲区,等注册 EPOLLOUT 后再续写,否则会丢数据或让写阻塞整个事件循环。多线程同时 epoll_wait 同一个实例时,主要风险是「惊群」和「同一连接被多个线程处理」:前者可以用 EPOLLEXCLUSIVE 缓解,后者用 EPOLLONESHOT——线程取到事件后该 fd 暂时不再上报,处理完并更新完连接状态后再用 EPOLL_CTL_MOD 重新注册。此外要保证连接对象的状态变更与读写在同一个线程内串行(常见的做法是连接绑定到某个 event loop 线程),跨线程只传任务不直接操作 fd,避免数据竞争。