网易互娱游戏研发二面面经
- 轮次
- 二面
- 时间
- 2026-09
- 来源
- 牛客网
《面试题目》
- 一个数据包从应用层发送到对端网卡,中间会经过哪些主要环节?
- TCP 的可靠性机制和拥塞控制分别解决什么问题?
- TCP 发送缓冲区、接收缓冲区和应用层消息队列之间有什么区别?
- 如何设计一个高性能的网络连接对象?
- epoll 的 ET 模式下如何正确处理读写事件?
- 如何避免多线程网络服务器中的惊群问题?
- Reactor、Proactor 和线程池在服务端中如何组合?
- 单线程事件循环为什么也可能达到很高的 QPS?
- 多核服务器上应该如何决定线程数量?
- 多线程更新共享状态时,为什么加锁仍然可能有性能问题?
- memory_order_relaxed、acquire 和 release 应该如何选择?
- 如何处理共享数据结构中的伪共享问题?
- 为什么「读多写少」不一定适合直接使用读写锁?
《参考解析》
1. 一次 send 到对端网卡的数据路径:应用层调用 send/write 后先陷入内核,数据从用户态拷贝进 socket 发送缓冲区(SO_SNDBUF)。TCP 层完成分段、填序列号与确认号,并按「发送窗口 = min(rwnd, cwnd)」决定这一轮能发多少;随后 IP 层查路由表、加 IP 头(必要时分片),邻居子系统解析下一跳 MAC,驱动把 skb 挂到网卡发送环,DMA 交给网卡硬件发出以太网帧。中间还会经过交换机、路由器等设备。对端按相反顺序逐层解封装,数据落入内核接收缓冲区,等应用 read 才进入用户态。要记住 send 返回成功只代表数据被本机内核接收,既不等于对端已收到,也不等于对端业务处理完毕。
2. 可靠性、拥塞控制、流量控制各管一段:可靠性解决「字节流对不对、全不全、顺序乱不乱」,靠序列号、确认应答、超时重传、快速重传、校验和与乱序重组实现。拥塞控制解决「网络这条链路能不能承受」,通过慢启动、拥塞避免、快速恢复以及 CUBIC/BBR 这类算法调整拥塞窗口 cwnd,防止所有发送端一起把中间链路打爆。流量控制解决「接收方处理得过来吗」,由接收方通过窗口字段通告 rwnd,避免撑爆对端接收缓冲区。三者常被混为一谈:可靠性的代价是重传与队头阻塞,所以对已过期的状态同步数据(比如玩家位置),更合理的是走 UDP 加序号、过期即丢,而不是让旧包无限等待重传。
3. 三层缓冲区的分工与背压:内核发送缓冲区暂存「应用已交付但还没被确认」的数据,内核接收缓冲区暂存「已经到达但应用还没读」的数据,两者大小由 SO_SNDBUF/SO_RCVBUF 与自动调优决定;应用层消息队列是业务自己维护的,装的是已经从字节流里解码出来的完整消息。层次是「网卡 → 内核接收缓冲 → 协议解析 → 应用队列」。风险点在于应用层队列往往是无界的:生产者是 IO 线程、消费者是逻辑线程,一旦处理变慢就会持续堆积直到 OOM。工程上必须给每一层定水位与策略——丢弃、阻塞、降级还是直接断开连接,并且要能观测到队列深度。
4. 高性能连接对象怎么设计:对象里至少要放 fd、读缓冲、发送队列、当前发送偏移、连接状态机、最后活跃时间、心跳状态、协议解码状态和会话身份。设计要点有三:一是发送队列不要存一堆独立字符串,用 deque<{shared_ptr<Buffer>, offset}> 只记指针加偏移,避免大块连续内存拷贝与频繁分配;二是读缓冲要复用(可扩容 vector 或环形缓冲),不要每读一次新申请一块;三是关闭路径必须单点化——注销 epoll 事件、停掉定时器与心跳、清空发送队列、解绑业务回调,并用 shared_ptr/weak_ptr 保证不会出现「socket 已关闭但异步任务还在访问连接对象」的悬垂访问。
5. epoll ET 下的读处理:ET 只在状态发生变化时通知一次,所以 fd 必须设为非阻塞,收到 EPOLLIN 后要循环 recv 直到返回 EAGAIN/EWOULDBLOCK 才算读干净。循环里的分支要分清:n > 0 就喂给协议解码器继续读;n == 0 表示对端发了 FIN,走关闭流程;errno == EINTR 是被信号打断,直接重试;EAGAIN 才是正常收尾;其余 errno 走错误关闭。用一个固定大小的栈上缓冲反复读即可,别为了「一次读完」去猜长度。注意 EPOLLIN 与 EPOLLOUT 可能在同一轮一起返回,读和写要各自循环到 EAGAIN,不能读一次就返回。
6. epoll ET 下的写处理:写侧不能只调用一次 send 就完事。要维护发送偏移,循环写直到数据发完或返回 EAGAIN;没发完就把剩下的挂回队列并注册 EPOLLOUT,等可写事件再继续。关键在于数据全部发完之后必须立刻用 EPOLL_CTL_MOD 摘掉 EPOLLOUT(或者一开始就不注册、只在有积压时才加),否则可写条件长期成立,事件循环会一直收到通知空转烧 CPU。send 的部分写、EINTR、EPIPE/ECONNRESET 都要分类处理;批量发送可以用 writev/sendmsg 把多个小块合成一次系统调用。
7. 惊群的两类与处理方式:一类是 accept 惊群,多个线程阻塞在同一个监听 fd 上,新连接到来时全被唤醒但只有一个 accept 成功;另一类是 epoll_wait 惊群,多个线程等同一个 epoll 实例。常见解法:用 EPOLLEXCLUSIVE 让内核只唤醒一个等待者;用 SO_REUSEPORT 让内核按四元组哈希把连接分流到多个 listener;更常见也更可控的是「单 acceptor 收连接 + 分发到 worker」;或者每个线程各自一个 epoll 实例,用连接哈希把同一连接固定到同一线程——这样连接状态天然线程封闭、免锁,也保住了同一玩家消息的顺序。固定线程之后要额外解决负载不均,可以靠连接迁移、工作窃取或按玩家分片再均衡。
8. Reactor、Proactor 与线程池的组合:Reactor 是「事件循环告诉你 fd 就绪了,你自己去 read/write 并解析」,Linux 上 epoll 就是这一套;Proactor 是「内核或框架替你把 I/O 做完,你只处理完成回调」,典型是 io_uring 与 Windows IOCP。生产上的常见编排是:IO 线程只负责收发包和协议解析,尽量不碰业务 → 解析出的完整消息进业务任务队列 → 逻辑线程池或分片线程处理 → 结果进发送队列 → 回到 IO 线程写出去。如果连接状态和业务状态高度相关,就按玩家或 session 分片固定线程,避免频繁加锁;寻路、匹配、压缩这类 CPU 密集任务必须投递到专用线程池,不能阻塞主事件循环。模型选择取决于任务特性,不是线程越多越好。
9. 单线程事件循环为什么能做到高 QPS:QPS 高不代表「同时在算很多东西」。当连接数很多但每条连接每次要做的计算极少时,瓶颈在就绪通知与内存拷贝而不是算力,一个线程完全可以管住几万条连接。它的优势是:没有线程切换、没有锁竞争、连接状态天然线程封闭、事件批处理摊薄了 epoll_wait 的单次开销、缓存局部性好。代价是尾延迟——任何一次阻塞(慢查询、同步写日志、大对象分配)都会拖住全部连接。所以必须严格限制单次任务耗时、把磁盘/数据库/外部 RPC 全部移出事件线程、给慢任务做异步化,并监控事件循环延迟(loop lag)与超时任务隔离。真实系统通常是多进程或多线程分片,而不是一个线程扛全部。
10. 多核上的线程数怎么定:不能按核数线性往上堆,要分任务类型。事件线程一般取物理核数,必要时做 CPU 亲和绑定,避免跨 NUMA 访问;CPU 密集线程池约等于逻辑核数;I/O 密集型(下游 RPC、DB)可以高于核数,但上限取决于下游承载能力,通常从核数的 2~4 倍起步再压测收敛。线程过多的代价是上下文切换变多、CPU 缓存失效、锁竞争加剧、调度延迟上升,每个线程栈还占虚拟内存。最终依据只能是压测数据:吞吐、P99 延迟、上下文切换次数、CPU 利用率与锁等待时间,而不是拍脑袋。
11. 加了锁为什么还是慢:锁解决的是互斥与内存可见性,不解决竞争成本。高竞争下线程会阻塞、排队、被唤醒,这里面有 futex 系统调用和上下文切换;临界区里的数据在多个核之间来回修改,会让缓存行不断失效(cache bouncing);另外还有伪共享与锁本身的原子操作开销。优化顺序是:先无锁化(线程封闭、消息传递、按 key 分片),再缩小临界区(能在锁外算的都在锁外算,锁内只做指针交换),然后降低竞争(分片锁、读写锁、原子变量、不可变快照),最后才考虑无锁队列。判断瓶颈不能只看持锁时间,要同时看锁竞争次数、等待线程数和每核 cache miss。
12. 内存序怎么选:relaxed 只保证该原子操作本身不可分割,不建立任何跨线程的先后关系;release 用于「发布」——在它之前的所有普通写,都会被之后对它做 acquire 的线程看到。所以「先准备好数据、再置标志位」的经典模式必须用 release/acquire 配对:生产者写 message 后 ready.store(true, release),消费者 ready.load(acquire) 为真时才能安全读 message。如果只是一个纯计数器、不依赖其他内存数据,relaxed 就够了,而且不插内存屏障、更便宜。只有需要全局单一顺序时才用默认的 seq_cst。最常见的错误是标志位用了 relaxed,读端看到 true 就去访问尚未写完的数据,属于数据竞争,行为未定义。
13. 伪共享的成因与处理:伪共享发生在两个线程修改的是不同变量,但这两个变量恰好落在同一条 64 字节缓存行里——任一方写入都会让另一方的缓存行失效,于是缓存行在两个核之间反复来回,表现为 cache miss 飙升、多核扩展性变差。最典型的是「每个线程一个计数器」却把它们放在同一个数组或相邻字段里。处理方式是用 alignas(64)(必要时 128,避开相邻预取)把热点变量各自独占缓存行,或者干脆改成线程本地累加、最后归并。要注意别盲目填充:内存占用会明显膨胀、缓存命中率下降,上之前先用 perf c2c、perf stat 之类的工具确认伪共享确实是瓶颈。
14. 读多写少不一定就该上读写锁:读写锁的收益前提是临界区足够长、读远多于写。如果写并不少,读者与写者会互相阻塞甚至出现写者饥饿(许多实现默认读优先),吞吐反而不如普通互斥锁;而且 rwlock 的原子计数与唤醒逻辑比 mutex 更重,短临界区下这部分开销会吃掉并发收益,还额外带来锁升级死锁的风险。另一个现实问题是临界区里的数据在多核间共享,读者再多也要付出缓存一致性代价。如果数据可以整体替换(配置表、路由表、词典),最优解通常是不可变快照:写侧构造新的 shared_ptr<const T> 后原子发布,读侧 acquire 一次拿到指针即可,全程无锁。