面灵AI→

经纬恒润 C++ 后端一面:网络、STL 与智能指针

轮次
一面
时间
2026-09
来源
牛客网

《面试题目》

  1. 自我介绍
  2. 你项目里的通信是 TCP 还是 UDP?为什么?三次握手和四次挥手讲一下
  3. TCP 和 HTTP 是什么关系?你项目用的是长连接还是短连接?HTTP/1.1 和 HTTP/2 有什么区别?
  4. 你用了哪些 STL 容器?map 和 unordered_map 的底层实现和区别是什么?
  5. vector 的扩容机制是什么?reserve 和 resize 有什么区别?为什么 vector 的迭代器会失效?
  6. 智能指针的原理是什么?shared_ptr 线程安全吗?循环引用怎么解决?
  7. 为什么要用多线程?线程池是怎么设计的?线程间同步用了哪些手段?
  8. 为什么要用 epoll 或 IO 多路复用,而不是一个连接一个线程?LT 和 ET 的区别是什么?

《参考解析》

1. TCP、UDP 选型与握手挥手。选 TCP 的理由是业务需要可靠、有序,并且依赖流量控制和拥塞控制;UDP 适合实时性优先、可容忍丢包的场景,比如音视频、心跳包,以及 QUIC 这种「在 UDP 上自己实现可靠传输」的现代方案。三次握手是 SYN(x) → SYN+ACK(y, ack=x+1) → ACK(ack=y+1);之所以不能只握两次,一是防止历史失效的连接请求到达后服务端误建连接,二是只有三次才能双向确认双方的收发能力都正常并同步初始序号。四次挥手是 FIN → ACK → FIN → ACK,不能合并成三次是因为 TCP 全双工:一方发 FIN 只表示「我没有数据要发了」(半关闭),另一方可能还有数据要发,所以它的 ACK 和 FIN 必须分开。主动关闭方进入 TIME_WAIT 并等 2MSL,作用有两个:保证最后那个 ACK 能到达对方(对方超时会重传 FIN,还能响应),以及让本次连接的旧报文在网络中消散,避免污染复用同一四元组的新连接。排障经验也要能说:大量 CLOSE_WAIT 堆积说明代码里没调 close();大量 TIME_WAIT 说明主动关闭方太多,用连接池、SO_REUSEADDR、tcp_tw_reuse 缓解。

2. HTTP 与 TCP 的关系、版本差异。HTTP 是应用层协议、TCP 是传输层协议,HTTP 报文最终由 TCP 承载,HTTP 本身不负责可靠性。HTTP/1.0 默认短连接;HTTP/1.1 默认 Connection: keep-alive,一条 TCP 连接可以复用发多个请求,但同一连接上请求是串行的,存在应用层队头阻塞(因此流水线没普及)——RPC 或网关场景一般用连接池维护长连接,避免频繁握手和慢启动。HTTP/2 用二进制分帧加多路复用,一条 TCP 上可以并发多个 stream,配合 HPACK 头部压缩、服务端推送和流优先级,解决了应用层队头阻塞,但 TCP 层的队头阻塞仍然存在(一个丢包会卡住所有 stream)。HTTP/3 走 QUIC(基于 UDP),把可靠传输和流控做在用户态,各 stream 独立恢复丢包,还支持 0-RTT 建连和连接迁移。

3. map 与 unordered_map。map 是红黑树(自平衡二叉搜索树):按 key 有序,查找稳定 O(log n),支持 lower_bound 这类范围查询,每个节点要额外存颜色和三个指针,内存开销大,迭代器只在删除时失效。unordered_map 是哈希表(桶加链地址法,冲突严重时会树化):无序,平均 O(1)、最坏 O(n),rehash 时迭代器全部失效,自定义 key 需要提供 hash 和 operator==,可以用 reserve(n) 减少 rehash。选型依据是:需要排序、范围查询或要求稳定的最坏复杂度就用 map;纯 KV 高频单点查询、key 分布均匀就用 unordered_map。还要知道哈希攻击:恶意构造大量冲突 key 会让性能退化到 O(n),所以对不可信输入要做 key 限制或加盐。

4. vector 扩容与迭代器失效。size == capacity 时 push_back 会申请一块更大的连续内存(GCC 是 2 倍,MSVC 是 1.5 倍),把元素搬过去再释放旧块,因此 push_back 的均摊复杂度是 O(1)。reserve(n) 只把 capacity 提升到不小于 n,不改变 size、不构造元素,用来提前避免多次扩容拷贝;resize(n) 会把 size 改成 n,多出来的部分值初始化(构造元素),变少则析构多余元素。迭代器失效有三种情况:扩容后内存重新分配,所有迭代器、指针、引用全部失效;insert/erase 造成元素搬移,插入点之后的迭代器失效(erase 会返回下一个有效迭代器,应该接住它)。补充一句为什么有的实现用 1.5 倍:2 倍增长无法复用之前释放的内存块,1.5 倍可以,内存碎片更少;而 vector 连续内存、随机访问 O(1)、对 CPU 缓存友好,这正是它常常比其他容器快的原因。

5. 智能指针与 shared_ptr 的线程安全边界。三种智能指针的本质都是 RAII,把资源生命周期绑到栈对象上,析构时自动释放。unique_ptr 独占所有权、不可拷贝只可移动、零开销,能用就优先用;shared_ptr 的引用计数存在堆上的控制块里(make_shared 会把控制块和对象一次分配,缓存更友好),计数增减是原子的;weak_ptr 不增加计数,用 lock() 提升为 shared_ptr,提升失败说明对象已析构,用来打破循环引用和实现缓存。线程安全要分三句话说:①引用计数的增减是原子的,多线程各自拷贝、析构不同的 shared_ptr 实例是安全的;②被管理对象本身的读写不是线程安全的;③同一个 shared_ptr 实例被多线程同时赋值或 reset() 不是原子的(要同时改指针和计数),需要加锁或用 std::atomic<std::shared_ptr<T>>。循环引用是 A 持有 B 的 shared_ptr、B 又持有 A 的 shared_ptr,计数永不归零导致泄漏,解法是把其中一方改成 weak_ptr(典型是父持有子的强引用、子持有父的弱引用)。

6. 线程池设计与同步手段。为什么要多线程:充分利用多核 CPU(CPU 密集型)、一个线程阻塞在 IO 上时其他线程还能干活(IO 密集型,也是服务端的主要动机)、线程共享地址空间所以通信成本低于多进程;代价是上下文切换、锁竞争、竞态和调试难度。线程池的要点:一个任务队列(queue<function<void()>> 加 mutex 保护)、启动时创建 N 个 worker 循环等待、用条件变量在队列空时 wait 挂起并在 push 后 notify_one;wait 必须带谓词(wait(lock, pred))防止虚假唤醒;退出时置 stop 标志加 notify_all 再 join,并且要明确定义「队列里剩下的任务跑不跑完」这两种语义;队列最好有界,满了要有策略(阻塞生产者、丢弃、或让调用者线程执行)避免 OOM;线程数按任务类型定,CPU 密集取核数上下、IO 密集可以更多。同步手段选型:mutex 配 lock_guard/unique_lock 用 RAII 防漏解锁、condition_variable 配 while 判谓词、atomic 做轻量计数和标志位、shared_mutex 适合读多写少、future/promise/async 用来取异步结果。死锁四条件是互斥、持有并等待、不可剥夺、循环等待,破坏任何一个即可,工程上最常用的是统一加锁顺序或用 scoped_lock 一次性锁多个。

7. epoll 与 LT/ET。一连接一线程的问题在于:线程栈默认 8MB 虚拟内存、每个线程都是独立调度实体、上下文切换成本高,连接数一上来直接崩。select 有 fd 上限 1024,每次调用都要把 fd 集合从用户态拷到内核态并做 O(n) 轮询;poll 没有数量上限但仍然 O(n)。epoll 用红黑树管理 fd、用就绪链表存就绪事件,epoll_wait 直接返回就绪的 fd,epoll_ctl 时只拷贝一次,复杂度与总连接数无关。LT 是水平触发(默认):只要 fd 上还有数据可读,每次 epoll_wait 都会通知,编程简单、可以一次只读一部分;ET 是边沿触发,只在状态变化时通知一次,必须把 fd 设成非阻塞并循环读到 EAGAIN,否则会丢事件、饿死其他连接,好处是通知次数少、系统调用少。ET 常配 EPOLLONESHOT 防止多个工作线程同时读同一个 fd。要主动说一个经典错误:ET 配阻塞 fd,最后一次 read 会永久阻塞住工作线程。