面灵AI→

经纬恒润 C/C++软件开发面经(凉经)

时间
2026-09
来源
牛客网

《面试题目》

架构与通信选型

  1. 设计一个高频行情聚合与回放平台时,为什么不直接使用 Kafka 作为所有模块之间的通信组件?
  2. 如果要实现一个跨平台媒体采集网关,如何设计采集线程、编码线程和发送线程?

C++ 语言与并发

  1. 数组、链表、map 和 unordered_map 在工程中应该如何选择?
  2. 如何理解智能指针的所有权语义,并避免错误使用 shared_ptr?
  3. 线程池中的任务对象为什么容易出现悬空引用?

网络编程

  1. epoll 事件循环中为什么不能一直监听写事件?
  2. TCP 半包和粘包问题应该在协议层还是业务层解决?

《参考解析》

1. 为什么不把 Kafka 当作唯一的低延迟总线:Kafka 的定位是持久化事件流,一次投递要经过序列化、TCP 收发、Broker 端的分区调度、页缓存与落盘、消费者批量拉取,端到端落在毫秒级甚至更高;行情数据如果在同机进程间要求微秒级传递,这条路径上的每一跳都是纯开销。低延迟段应该换成共享内存、无锁环形队列或 Unix Domain Socket。但正确的结论不是「弃用 Kafka」,而是「别让 Kafka 出现在唯一的快路径上」:它的持久化、多消费组订阅、可回放、水平扩展和故障恢复能力,在事件分发和历史重放上是很难自研替代的。面试里要讲清的是判据——延迟敏感度、是否必须持久化、消费者数量、是否要重放、故障恢复要求,而不是背组件优缺点。

2. 行情平台按数据类别分层选通信方式:实时行情走共享内存或专用内存队列,允许在压力下丢帧,消费者靠序号追赶并做落后告警;重要事件(成交回报、风控触发、订单状态)走 Kafka 或可靠消息队列,要求不丢且可重放;历史回放走文件、对象存储或列式数据库(Parquet/ORC),回放时按时间戳重放而不是再走一遍消息总线;控制命令走 TCP、Unix Domain Socket 或 RPC,频率低但要求可靠和可观测。这样分层的好处是每段链路只为自己那类数据的语义负责:低延迟段不为持久化付出代价,可靠段不因为追求极低延迟而牺牲一致性。

3. 用共享内存自建通路的代价:一旦放弃 Kafka,这些事就全落到自己头上——数据版本兼容(结构体加 version 与长度字段,支持新旧进程混跑);读写同步(seqlock、双缓冲加序号校验,避免读到写了一半的记录);消费者落后(环形缓冲写指针追上读指针时必须决策:丢最旧、丢最新还是阻塞生产者,并上报落后水位);进程异常退出(靠心跳和 pid 检测回收写者位置,否则死进程会一直占着缓冲区);缓冲区覆盖(容量、水位与降级策略要能压测出来)。这也是「为什么不用 Kafka」这个问题的真正答案:换来的是延迟,付出的是把 Kafka 已经解决过的可靠性问题重新实现一遍。

4. 媒体采集网关的四级流水线:设备采集 → 帧缓冲池 → 编码与格式转换 → 网络发送与重试,四段各由独立线程承担,中间用有界队列连接。核心纪律是采集线程绝不执行耗时操作:编码一旦变慢,反压会顺着队列顶回设备读取,轻则驱动缓冲溢出丢帧,重则设备断流重连。队列必须有界,帧内存走预分配缓冲池(运行期频繁 malloc 会造成延迟抖动和内存碎片)。背压策略要显式选择:优先丢帧而不是无限排队,并把丢帧计数、队列水位、编码耗时暴露成监控指标。还要处理时间戳回退(设备时钟漂移要单调化)、设备断开重连、分辨率或像素格式变化导致的编码器重建,以及发送端拥塞时的降码率或丢帧。

5. 丢帧时为什么不能丢关键帧:视频帧分 I 帧和 P 帧,P 帧只记录相对参考帧的残差,丢掉它前面的关键帧,后续帧就失去参考、解码器只能花屏或请求 IDR 重建,整段画面反而更糟。所以队列满时的策略是:扫描队列找出第一个非关键帧删掉,若全是关键帧则拒绝入队并让上游感知压力。实现上队列元素要带 sequence、timestamp 和 keyFrame 标记,保证按序号单调出队;配合「关键帧请求」(向上游要 IDR)而不是硬塞。工程细节还包括:队列操作要加锁或用无锁环形队列,条件变量唤醒要防止丢通知,出队侧要有超时以免解码线程永久阻塞,队列深度和丢帧率要能通过配置按网络状况动态调整。

6. 容器选型:复杂度只是入场券:vector 是连续内存,随机访问 O(1)、缓存局部性最好,顺序遍历通常比 list 快一个数量级;代价是中间插入删除 O(n),扩容会让迭代器、指针、引用全部失效。list 迭代器稳定、中间增删 O(1),但节点分散、每插一个节点分配一次内存,缓存不友好,现代 CPU 上常常跑不过 vector。map 是平衡树,O(log n),支持有序遍历和范围查询,除被删元素外迭代器稳定。unordered_map 平均 O(1),但无序、有哈希冲突、rehash 会让迭代器失效、元素地址不稳定、内存开销更大。真正的决策维度是:数据量能否预估、是否需要有序、是否保存元素地址或跨容器引用、增删与遍历的频率比、是否有实时性要求、是否要控制分配次数(可以考虑 pmr、自定义分配器或预留 capacity)。

7. 智能指针的所有权语义:默认用 unique_ptr——所有权唯一、零开销,适合资源边界清晰的场景;shared_ptr 只在确实存在多个生命周期管理者时才用,滥用会让对象生命周期不可控、堆上多一个控制块、拷贝带来原子计数开销,还容易写出循环引用。非拥有关系用裸指针或引用(或 weak_ptr),但必须写清生命周期契约:谁保证比谁活得久。打破循环的经典写法是一侧持有 shared_ptr、另一侧持有 weak_ptr,例如连接持有会话的强引用、会话反向只持有连接的弱引用。还要记住 shared_ptr 的线程安全只覆盖控制块的引用计数(原子操作),对象成员仍需自己同步;同一个 shared_ptr 实例被多线程读写也不是安全的,需要用锁或 atomic<shared_ptr>。传参用 const& 避免无意义的计数增减。

8. 线程池任务悬垂引用的成因与修法:任务入队时如果 lambda 按引用捕获栈上局部变量(如 [&value]),submit 返回后栈帧销毁,工作线程真正执行时访问的就是已析构对象,属未定义行为,且往往只在压力下偶发。修法有三层:能拷贝就按值捕获([value]);对象较大或需要共享时按 shared_ptr 捕获,把生命周期延长到任务执行完;对象生命周期不被任务控制时,捕获 weak_ptr 并在任务里 lock() 后再用,拿不到就安全退出。更根本的是接口契约:enqueue 应该接受可拷贝的可调用对象,禁止跨线程传递指向栈对象的指针;对象自身若可能被异步回调访问,用 enable_shared_from_this 获取 weak_ptr,而不是裸露 this。另外注意 shutdown 时未执行任务的析构顺序,以及任务队列积压导致的延迟放大。

9. epoll 为什么不能常驻监听写事件:只要 socket 发送缓冲区还有空间,内核就认为它「可写」,如果一直注册 EPOLLOUT,水平触发下每轮事件循环都会立刻返回可写事件,此时又没有数据要发,CPU 就空转烧掉。正确做法是按需注册:有数据要发时用 EPOLL_CTL_MOD 加上 EPOLLOUT,发送队列清空后立刻摘掉;EPOLLIN 常驻,配合 EPOLLRDHUP 感知对端关闭。发送侧必须处理部分写:维护发送缓冲区与已发送偏移,send 返回 EAGAIN 就停手,等下一次可写事件续发,绝不能假设一次 send 写完整个包。缓冲区超过上限时执行背压(暂停上层生产、停止注册 EPOLLIN 读)或主动断开,防止内存无界增长。用边沿触发时还要在一次事件里循环 read/send 到 EAGAIN,否则会丢事件。

10. 粘包半包该由协议解析层负责:TCP 只保证有序字节流,不提供消息边界,所以拆包逻辑要收敛在协议解析层,而不是散落在各业务 handler 里(否则每个人都要维护自己的残留缓冲)。方案有固定长度、分隔符(文本协议如 \n,注意转义和二进制不安全)和长度字段三种,二进制协议首选长度字段:magic + version + type + body_length + body + checksum。magic 用于定位同步起点,body_length 必须做上限校验,否则一个伪造的超长长度就能把内存吃爆;checksum 用来发现脏数据并主动断开重连。解析器自身持有缓冲区和解析状态,feed 只追加字节、next 反复产出完整帧,不依赖任何一次 recv 的边界;对端发来的半包留在缓冲等下一次数据。缓冲积压超过阈值说明对端有问题或自己处理不过来,应该断开或背压,而不是无限增长。