面试题
从各篇笔记的“面试常问”汇总而来,每题附参考回答和常见追问。先自己说一遍,再点开对照。参考回答是答题思路,面试时用自己的话讲,并尽量结合自己写过的代码和测出来的数据。
任务 1:装好 WSL2、Ubuntu、VS Code,建好 GitHub 仓库
2 题 · 出自笔记 任务 1:装好 WSL2、Ubuntu、VS Code,建好 GitHub 仓库
你平时在什么环境下开发?用过 Linux 吗?
参考回答: 我在 Windows 上用 WSL2 跑 Ubuntu 24.04 做开发,编辑器是 VS Code 连 WSL,编译用 g++ 和 CMake,调试用 gdb。项目部署在一台 Linux 云服务器上,平时用 SSH 登录维护,常用的命令比如看进程、看端口、看日志都比较熟。
提醒: 只说你真的会的。说了“熟悉 Linux”,面试官很可能接着问“怎么查一个端口被谁占用”“怎么看一个进程打开了多少个 fd”。
WSL2 和虚拟机有什么区别?
参考回答: WSL2 本质上也是虚拟化,它在一个轻量级的 Hyper-V 虚拟机里运行真正的 Linux 内核,所以系统调用和原生 Linux 一致,epoll 这些都能用。和 VMware 这类传统虚拟机相比,它启动快、占内存少,和 Windows 的文件系统、网络集成得更好,不需要单独装桌面系统。WSL1 则不是虚拟机,它是把 Linux 系统调用翻译成 Windows 的调用,兼容性差一些。
任务 2:学会 g++、gdb,写出第一个 CMakeLists.txt
3 题 · 出自笔记 任务 2:学会 g++、gdb,写出第一个 CMakeLists.txt
从源码到可执行文件经历了哪几步?
参考回答: 四步。预处理:展开 #include 和宏,去掉注释(g++ -E);编译:把 C++ 翻译成汇编(-S);汇编:把汇编变成目标文件 .o(-c);链接:把多个目标文件和库合在一起,把函数调用和函数定义对上,生成可执行文件。undefined reference 就是链接阶段找不到定义。
程序段错误了,你怎么定位?
参考回答: 先用 -g 重新编译,在 gdb 里运行,崩溃后用 bt 看调用栈,frame 切到出问题的那一层,print 看相关变量。如果是线上程序,先 ulimit -c unlimited 打开 core dump,崩溃后用 gdb ./server core 分析。如果是越界、use-after-free 这类问题,崩溃的位置往往不是出错的位置,这时用 -fsanitize=address 编译再跑一次,ASan 会报出第一次非法访问的位置,以及那块内存在哪分配、在哪释放。
可能的追问: 段错误的常见原因?空指针解引用、数组越界、访问已释放的内存、栈溢出(比如递归太深)。
头文件里为什么要写 #pragma once?
参考回答: 防止同一个头文件在一次编译中被包含多次。比如 a.h 和 b.h 都包含了 common.h,main.cpp 又同时包含了 a.h 和 b.h,没有保护的话 common.h 里的类定义会出现两次,编译报重复定义。传统写法是 #ifndef XXX_H / #define XXX_H / #endif 的头文件保护,#pragma once 更简洁,主流编译器都支持。
任务 3:现代 C++ 五个小例子(RAII、智能指针、移动、lambda)
4 题 · 出自笔记 任务 3:现代 C++ 五个小例子(RAII、智能指针、移动、lambda)
什么是 RAII?你在项目里怎么用的?
参考回答: RAII 是在构造函数里获取资源、在析构函数里释放资源,让资源的生命周期和对象绑定。C++ 保证局部对象离开作用域时一定调用析构函数,包括抛异常的情况,所以资源不会泄漏。我的网络库里 Socket 类在析构时 close fd,锁用 std::lock_guard,堆对象用智能指针,代码里基本没有手写的 delete 和 close。
shared_ptr 是怎么实现的?线程安全吗?
参考回答: shared_ptr 里有两个指针,一个指向对象,一个指向控制块。控制块里有强引用计数、弱引用计数和删除器。拷贝时强引用计数加一,析构时减一,减到 0 销毁对象;弱引用计数也到 0 时释放控制块。
线程安全分三层说:引用计数的增减是原子操作,多个线程各自持有自己的拷贝是安全的;通过它访问的对象本身不受保护,要自己加锁;多个线程同时读写同一个 shared_ptr 变量也不安全。
可能的追问: make_shared 有什么好处?只分配一次内存(对象和控制块在一起),更快;缺点是只要还有 weak_ptr,整块内存都不能释放。
std::move 做了什么?
参考回答: std::move 只是一个类型转换,把左值转换成右值引用,让编译器在重载决议时选择移动构造或移动赋值。真正的资源转移是移动构造函数做的,通常是把对方的指针拿过来,再把对方置空。移动之后原对象处于有效但未指定的状态,可以析构或重新赋值,但不应该再依赖它的值。
什么情况下会用到 weak_ptr?
参考回答: 两种情况。一是打破 shared_ptr 的循环引用,比如双向链表、父子节点互相指着,其中一个方向用 weak_ptr。二是“观察”一个对象但不延长它的寿命,比如回调里需要访问一个连接,但连接可能已经断开被销毁了,就保存 weak_ptr,用的时候 lock(),拿到空指针说明对象已经没了,直接返回。
任务 4:亲眼看到三次握手、四次挥手和端口
4 题 · 出自笔记 任务 4:亲眼看到三次握手、四次挥手和端口
TCP 为什么是三次握手,两次不行吗?
参考回答: 两个原因。第一,双方都要确认对方能收能发,并且交换各自的初始序列号:第一次客户端发 SYN 带上自己的序列号,第二次服务器回 SYN+ACK 确认它并带上自己的序列号,第三次客户端 ACK 确认服务器的序列号,三次刚好够。第二,防止历史连接:一个很久以前发出、在网络里滞留的旧 SYN 到达服务器时,如果两次握手就建立连接,服务器会白白分配资源;三次握手时客户端发现这个 SYN+ACK 对不上,会回 RST 拒绝。
为什么挥手要四次?TIME_WAIT 为什么要等 2MSL?
参考回答: TCP 是全双工的,两个方向要分别关闭。一方发 FIN 只表示自己不再发数据,对方先回 ACK,但可能还有数据要发,发完了才发自己的 FIN,所以一般是四次。如果对方没有数据要发,ACK 和 FIN 可以合并,我抓包时就看到过只有三个包的情况。
TIME_WAIT 在主动关闭方,等 2MSL 是为了:一是最后一个 ACK 如果丢了,对方重发 FIN 时还能再回一次 ACK;二是让这条连接在网络里的旧包全部过期,不会干扰之后相同四元组的新连接。
大量 TIME_WAIT 和大量 CLOSE_WAIT 分别说明什么?
参考回答: 大量 TIME_WAIT 说明这台机器在主动关闭大量连接,通常是短连接太多,本身是正常的,问题主要是占用端口;可以改用长连接、连接池,服务器用 SO_REUSEADDR 保证能立刻重启。大量 CLOSE_WAIT 说明对方关了连接,但我们的程序没有调用 close,基本就是代码 bug,要查哪里漏了关闭。
Connection refused 和连接超时有什么区别?
参考回答: Connection refused 是对方机器收到了 SYN,但那个端口上没有程序监听,内核直接回了 RST,所以会立刻失败。连接超时是 SYN 发出去一直没有回应,可能是对方机器不存在、防火墙把包丢了、或者网络不通,客户端会重试几次 SYN,最后超时失败,通常要等几十秒。
任务 5:跑通阻塞版 echo server,亲眼看到它的问题
3 题 · 出自笔记 任务 5:跑通阻塞版 echo server,亲眼看到它的问题
说一下 TCP 服务器端的编程流程。
参考回答: socket 创建套接字;setsockopt 设置 SO_REUSEADDR;bind 绑定 IP 和端口;listen 开始监听,内核开始处理三次握手;循环里 accept 取出已建立的连接,得到一个新的 fd;用这个 fd read / write 收发数据;read 返回 0 表示对方关闭,close 释放 fd。客户端是 socket、connect、read / write、close。
listen 的 backlog 参数是什么意思?
参考回答: 内核为监听 socket 维护两个队列:半连接队列存收到 SYN、还没完成握手的连接;全连接队列存已经完成握手、等着被 accept 的连接。现在 Linux 上 backlog 限制的是全连接队列的长度,实际值是它和 net.core.somaxconn 中较小的那个。服务器 accept 太慢导致队列满了,新的连接就会被丢弃,客户端表现为连接超时。
为什么要用网络字节序?
参考回答: 不同 CPU 存多字节整数的顺序不同,x86 是小端(低位字节在前),有些是大端。网络上的两台机器可能架构不同,所以协议规定统一用大端,也就是网络字节序。发送前用 htons / htonl 转换,收到后用 ntohs / ntohl 转回来。端口和 IP 地址写进 sockaddr_in 之前都要转换。
任务 6:跑通 epoll 版 echo server,500 个连接全部回显
3 题 · 出自笔记 任务 6:跑通 epoll 版 echo server,500 个连接全部回显
select、poll、epoll 的区别是什么?
参考回答: select 用位图存 fd,默认上限 1024,每次调用都要把整个集合从用户态拷到内核,返回后还要遍历所有 fd 找出就绪的,复杂度 O(n),而且集合会被修改,每次都要重新设置。poll 用数组代替位图,去掉了数量上限,但拷贝和遍历的问题还在。epoll 把注册和等待分开:epoll_ctl 注册一次一直有效,epoll_wait 只返回就绪的 fd,开销和活跃连接数有关,和总连接数无关,所以适合大量连接、少量活跃的场景。
epoll 的 LT 和 ET 有什么区别?你用的哪个?
参考回答: LT(水平触发,默认)只要 fd 上还有数据没读完,每次 epoll_wait 都会报告;ET(边缘触发)只在状态变化时报告一次,比如新数据到达,所以必须一次循环读到 EAGAIN,否则剩下的数据不会再有通知。我用的是 LT,逻辑简单不容易漏数据,Redis 和 muduo 也是 LT;Nginx 用的是 ET。
什么是 Reactor 模式?
参考回答: Reactor 是一种事件驱动的设计:用 I/O 多路复用(比如 epoll)同时等待很多 fd 上的事件,事件发生时分发给对应的处理函数,处理完再回去等。它让一个线程就能处理大量连接。我的 echo server 里那个 while (true) { epoll_wait; 分发 } 的循环就是最简单的 Reactor,阶段 1 会把它封装成 EventLoop、Channel、Poller 三个类。
任务 7:课后练习(打印客户端地址、群聊、输出缓冲区)
3 题 · 出自笔记 任务 7:课后练习(打印客户端地址、群聊、输出缓冲区)
非阻塞 socket 上 write 返回的值比要写的少,怎么处理?
参考回答: 说明内核发送缓冲区满了,剩下的数据要先存进这个连接的应用层输出缓冲区,然后在 epoll 上注册 EPOLLOUT。等可写事件到来时,接着把输出缓冲区的数据写出去,写完后取消 EPOLLOUT,否则 LT 模式下会一直触发导致忙等。同时要给输出缓冲区设一个上限,防止慢客户端把服务器内存撑爆。
服务器往一个已经断开的连接 write,会发生什么?
参考回答: 第一次 write 通常能写进内核缓冲区,对方会回 RST;再写的话进程会收到 SIGPIPE 信号,默认行为是终止进程,表现为服务器莫名其妙退出。所以服务器启动时要 signal(SIGPIPE, SIG_IGN),或者用 send 带 MSG_NOSIGNAL,这样 write 只会返回 -1、errno 为 EPIPE,按正常流程关闭连接。
如果一个客户端只发不收,服务器会怎样?
参考回答: 发给它的回复写不出去,会堆在服务器为它准备的输出缓冲区里,越积越多,最后可能耗尽内存。解决办法是设置输出缓冲区的高水位:超过之后暂停读取这个客户端的请求,或者直接断开它。Redis 的 client-output-buffer-limit 就是这么做的。
任务 8:不看代码,从零重写 epoll echo server
2 题 · 出自笔记 任务 8:不看代码,从零重写 epoll echo server
现场手写一个 epoll 服务器的主循环。
参考回答思路: 面试时一般不要求写完整代码,而是写主干并说清楚关键点。按顺序写:创建监听 socket、SO_REUSEADDR、bind、listen、设非阻塞;epoll_create1;把监听 fd 用 EPOLL_CTL_ADD 注册 EPOLLIN;while 循环里 epoll_wait,遍历就绪事件:监听 fd 就循环 accept 到 EAGAIN,新连接设非阻塞并注册;普通 fd 就 read,处理返回值大于 0、等于 0、EAGAIN 和其他错误四种情况。写完主动补充:write 可能写不完,需要输出缓冲区和 EPOLLOUT;要忽略 SIGPIPE。主动说出这些坑,比代码写得一字不差更加分。
你说你写过 epoll 服务器,遇到过什么问题?怎么解决的?
参考回答(按你的真实经历改): 举两三个具体的。比如:用测试脚本发 20 MB 数据时发现只收回来 4 MB,原因是非阻塞 write 只写进去一部分,剩下的被丢了,后来给每个连接加了输出缓冲区,写不完就注册 EPOLLOUT,写完再取消;还有一次服务器莫名其妙退出,退出码是 141,查到是对端关闭后继续写触发了 SIGPIPE,加了 signal(SIGPIPE, SIG_IGN) 解决。
提醒: 这道题一定要说你真的遇到过的问题,面试官会顺着细节追问。这也是为什么要自己从零写一遍。
阶段 0 教程:从 socket 到 epoll echo server
4 题 · 出自笔记 阶段 0 教程:从 socket 到 epoll echo server
什么是 C10K 问题?
参考回答: 指单台服务器同时处理一万个连接的问题。早期“一个连接一个线程(或进程)”的模型做不到,因为每个线程要占几 MB 的栈,线程切换的开销也会随数量暴涨。解决思路就是非阻塞 I/O 加 I/O 多路复用(epoll),用少量线程管理大量连接,也就是 Reactor 模型。现在讨论的已经是 C10M 了,要靠内核旁路(DPDK)之类的技术。
单线程 epoll 服务器的瓶颈在哪?怎么扩展?
参考回答: 所有连接都在一个线程里处理,只能用满一个 CPU 核;而且只要某个请求处理得慢(比如做了一次耗时计算或阻塞调用),所有连接都会被拖住。扩展方式是多 Reactor:一个主线程只负责 accept,把新连接轮流分给几个 I/O 线程,每个 I/O 线程有自己的 epoll 循环(one loop per thread),耗时的业务逻辑再交给单独的线程池。这是阶段 1 要做的事。
可能的追问: Redis 为什么用单线程还这么快?因为数据在内存里,每个命令都很快,瓶颈在网络 I/O 而不是 CPU;单线程还省掉了锁和线程切换。Redis 6 开始把网络读写放到了多个 I/O 线程里,命令执行仍是单线程。
如果一个客户端只发不收(或者收得很慢),服务器会怎样?
参考回答: 服务器给它的回复写不进对方的接收窗口,内核发送缓冲区满了之后,write 只能写一部分或者返回 EAGAIN,剩下的数据会堆在这个连接的输出缓冲区里,越堆越多,最后可能把内存吃光。所以要给输出缓冲区设一个上限(高水位),超过之后停止读这个客户端的新请求,或者直接断开它。Redis 的 client-output-buffer-limit 就是做这个的。
介绍一下你写的这个服务器(项目介绍题)
参考回答思路: 按“做了什么、怎么做的、结果如何、遇到什么问题”四段来讲,每段一两句,控制在一分钟内,把细节留给面试官追问。例如:“我用 C++ 和 epoll 写了一个单线程 Reactor 的 echo server,所有 socket 都是非阻塞的,epoll 用 LT 模式。我写了一个测试脚本,同时连 500 个客户端都能正确回显,而阻塞版本只能服务一个。过程中遇到过 write 写不完、对端关闭导致 SIGPIPE 退出这些问题,后来分别用输出缓冲区和忽略 SIGPIPE 解决。”
提醒: 只说自己真正做过、能讲清楚的内容。面试官一定会顺着你说的每个词追问,比如你说了 LT,就会问为什么不用 ET。
Linux 命令行入门
4 题 · 出自笔记 Linux 命令行入门
进程和线程有什么区别?
参考回答: 进程是资源分配的单位,有独立的地址空间、fd 表等资源;线程是 CPU 调度的单位,同一进程内的线程共享地址空间和 fd,只各自拥有栈和寄存器。所以线程创建和切换更便宜,通信也方便(直接读写共享内存),但一个线程崩溃会拖垮整个进程,共享数据还要加锁。
可能的追问: 进程间通信有哪些方式?答管道、消息队列、共享内存、信号量、信号、socket,其中共享内存最快,但需要自己做同步。
线上一个服务 CPU 跑到 100%,你怎么排查?
参考回答: 先用 top 找到占 CPU 的进程,再用 top -H -p <pid> 看是哪个线程。然后用 perf top -p <pid> 或 perf record -g 看热点函数;没有 perf 的话,gdb -p <pid> 挂上去执行 thread apply all bt,多抓几次调用栈,总停在同一处的就是热点。常见原因是死循环、忙等(比如 epoll_wait 超时设成 0)、或者 LT 模式下一直关注 EPOLLOUT。
僵尸进程和孤儿进程是什么?
参考回答: 子进程退出后,父进程没有调用 wait / waitpid 回收,它的进程表项就一直留着,这就是僵尸进程(ps 里状态是 Z)。父进程先退出、子进程还在跑,子进程就成了孤儿进程,会被 init(1 号进程)收养,没有危害。处理僵尸进程的办法是父进程调用 waitpid,或者处理 SIGCHLD 信号。
怎么看一台机器上的 TCP 连接状态?
参考回答: 用 ss -tanp(老系统用 netstat -tanp)列出所有 TCP 连接、状态和所属进程。想统计各状态的数量,可以 ss -tan | awk '{print $1}' | sort | uniq -c。查某个端口被谁占了用 ss -ltnp | grep 6379 或 lsof -i :6379。
现代 C++:写网络库要用到的部分
5 题 · 出自笔记 现代 C++:写网络库要用到的部分
什么是 RAII?你在项目里怎么用的?
参考回答: RAII 是“资源获取即初始化”:在构造函数里拿资源,在析构函数里释放资源,让资源的生命周期跟对象绑定。对象离开作用域时析构函数一定会执行,哪怕中途抛了异常,所以不会泄漏。我的项目里 Socket 类在析构时 close(fd),锁用 std::lock_guard,内存用智能指针,代码里基本没有手写的 close 和 delete。
shared_ptr 是怎么实现的?它线程安全吗?
参考回答: shared_ptr 里有两个指针,一个指向对象,一个指向控制块。控制块里存强引用计数、弱引用计数和删除器。拷贝时强引用计数加一,析构时减一,减到 0 就销毁对象;弱引用计数也到 0 时再释放控制块。
线程安全要分开说:引用计数的增减是原子操作,所以多个线程各自持有一份拷贝是安全的;但通过它访问的对象本身不受保护,要自己加锁;多个线程同时读写同一个 shared_ptr 变量(比如一个线程给它赋值,另一个线程拷贝它)也不安全。
可能的追问: make_shared 和 shared_ptr<T>(new T) 有什么区别?make_shared 只分配一次内存,把对象和控制块放在一起,更快,也没有异常时泄漏的风险;缺点是只要还有 weak_ptr 在,整块内存就释放不了。
std::move 做了什么?移动之后原对象还能用吗?
参考回答: std::move 本身什么都不移动,它只是一个类型转换,把左值转成右值引用,让编译器去匹配移动构造或移动赋值。真正的“搬运”是移动构造函数做的,比如把 std::string 内部的指针拿过来,再把原对象的指针置空。移动之后原对象处于“有效但未指定”的状态,可以重新赋值或者析构,但不应该再依赖它的值。
可能的追问: 完美转发是什么?配合模板里的万能引用 T&& 和 std::forward<T>,把参数原本是左值还是右值的属性原样传给下一层函数,emplace_back 就是这么实现的。
虚函数是怎么实现的?析构函数为什么要声明成 virtual?
参考回答: 有虚函数的类,编译器会生成一张虚函数表(vtable),每个对象里多存一个指向这张表的指针(vptr)。通过基类指针调用虚函数时,先通过 vptr 找到表,再找到实际类型的函数,这就是运行时多态。
如果基类析构函数不是 virtual,用基类指针 delete 一个派生类对象时只会调用基类的析构函数,派生类的部分不会被析构,造成资源泄漏,标准上算未定义行为。所以只要类打算被继承并通过基类指针删除,析构函数就要是 virtual。
unique_ptr 和 shared_ptr 怎么选?
参考回答: 默认用 unique_ptr,它没有额外开销,所有权清楚。只有确实需要多个地方共同拥有同一个对象时才用 shared_ptr,比如网络库里一个 TcpConnection 既被连接表持有,回调执行期间又要保证它不被销毁。两个对象互相持有 shared_ptr 会循环引用导致泄漏,这时其中一方要改成 weak_ptr。
编译、调试和版本管理
5 题 · 出自笔记 编译、调试和版本管理
从源码到可执行文件经历了哪几步?
参考回答: 四步。预处理:展开 #include 和宏,去掉注释(g++ -E);编译:把 C++ 翻译成汇编(-S);汇编:把汇编变成目标文件 .o(-c);链接:把多个目标文件和库合在一起,解析符号引用,生成可执行文件。常见的 undefined reference 就是链接阶段找不到符号的定义。
静态库和动态库有什么区别?
参考回答: 静态库(.a)在链接时整个拷进可执行文件,运行时不依赖外部文件,但体积大,库更新了要重新编译。动态库(.so)在运行时才加载,多个进程可以共享同一份,体积小、升级方便,但部署时要保证目标机器上有对应版本,否则报找不到 .so。
程序段错误了,你怎么定位?
参考回答: 先 ulimit -c unlimited 打开 core dump,用 -g 重新编译,复现后用 gdb ./server core 打开,执行 bt 看崩在哪一行、调用链是什么,再用 p 看相关变量。如果是越界、use-after-free 这类问题,崩溃点往往不是出错点,这时用 -fsanitize=address 编译再跑一次,ASan 会直接报出第一次非法访问的位置和那块内存在哪里分配、在哪里释放。
可能的追问: 段错误常见原因?空指针解引用、数组越界、访问已经释放的内存、栈溢出(递归太深)。
怎么检查内存泄漏?
参考回答: 开发时用 ASan 自带的 LeakSanitizer,程序退出时会列出没释放的内存和分配位置;也可以用 valgrind --leak-check=full,不用重新编译但会慢几十倍。长期运行的服务可以观察 RSS 是否一直涨。从根上说,用 RAII 和智能指针管理资源,泄漏就很少出现。
git merge 和 git rebase 有什么区别?
参考回答: 两者都是把一个分支的改动合到另一个分支。merge 会生成一个合并提交,保留完整的分叉历史;rebase 把当前分支的提交“挪”到目标分支最新提交的后面重新应用一遍,历史是一条直线,更干净。rebase 会改写提交记录,所以不要对已经推送、别人也在用的分支做 rebase。
TCP/IP 基础
7 题 · 出自笔记 TCP/IP 基础
TCP 为什么是三次握手,两次不行吗?
参考回答: 主要有两个原因。第一,双方都要确认对方能收也能发,并且交换各自的初始序列号(ISN),三次刚好够:客户端发 SYN 带上自己的序列号,服务器回 SYN+ACK 确认它并带上自己的序列号,客户端再 ACK 确认服务器的。第二,防止历史连接:如果一个很早以前发出、在网络里滞留的旧 SYN 到了服务器,两次握手的话服务器会直接建立连接、白白占用资源;三次握手时客户端发现这个 SYN+ACK 对不上自己当前的请求,会回 RST 拒绝掉。
为什么挥手要四次?TIME_WAIT 为什么要等 2MSL?
参考回答: 因为 TCP 是全双工的,两个方向要分别关闭。一方发 FIN 只表示“我不再发了”,对方先回 ACK,但可能还有数据没发完,等发完了才发自己的 FIN,所以 ACK 和 FIN 一般分两次发,总共四次。
TIME_WAIT 出现在主动关闭的一方,等 2MSL(报文最大生存时间的两倍)有两个目的:一是如果最后那个 ACK 丢了,对方会重发 FIN,这边还能再回一次 ACK;二是让这个连接在网络里残留的旧报文全部过期,避免被之后用同一个四元组建立的新连接误收。
服务器上有大量 TIME_WAIT,怎么处理?
参考回答: 先说明 TIME_WAIT 本身是正常的保护机制,只说明这台机器在主动关闭大量连接。它的危害主要是占用端口和内存,在作为客户端频繁发起短连接时会把本地端口耗尽。处理思路:能用长连接或连接池就用,从根本上减少关闭次数;服务端设置 SO_REUSEADDR 保证重启时能马上 bind;作为客户端时可以打开 net.ipv4.tcp_tw_reuse。不要用 tcp_tw_recycle,它在 NAT 环境下会丢连接,Linux 4.12 已经删掉了。
可能的追问: 大量 CLOSE_WAIT 呢?那是被动关闭方收到 FIN 后程序没有 close,基本是代码 bug,要去查哪里漏了关闭。
什么是 TCP 粘包?怎么解决?
参考回答: 严格说 TCP 没有“包”的概念,它是字节流,不保留消息边界。发送方连发两条消息,接收方一次 read 可能读到一条半,也可能两条粘在一起。所以要在应用层自己分帧,常见三种做法:定长消息;用分隔符,比如 HTTP 头部和 Redis 内联命令用 \r\n;在消息前加长度字段,比如 RESP 的 $5\r\nhello\r\n。服务器为每个连接维护一个输入缓冲区,读到的数据先追加进去,凑够一条完整消息再处理,剩下的留到下次。
TCP 是怎么保证可靠传输的?
参考回答: 几个机制配合:序列号和确认应答,保证不丢、不重、有序;超时重传和快速重传(收到三个重复 ACK 就立即重传);滑动窗口做流量控制,接收方通过窗口大小告诉发送方自己还能收多少;拥塞控制防止把网络打满,包括慢启动、拥塞避免、快重传和快恢复;另外还有校验和检查数据是否损坏。
TCP 和 UDP 有什么区别?各自适合什么场景?
参考回答: TCP 面向连接、可靠、有序、有流量和拥塞控制,是字节流;UDP 无连接、不保证送达和顺序,保留消息边界,头部只有 8 字节,开销小、延迟低。需要数据完整的场景用 TCP,比如 HTTP、数据库、Redis;能容忍少量丢包但对延迟敏感的用 UDP,比如音视频通话、游戏、DNS 查询。QUIC(HTTP/3)是在 UDP 上自己实现了可靠传输。
在浏览器输入一个网址到看到页面,中间发生了什么?
参考回答: 按顺序说:浏览器解析 URL;DNS 解析拿到 IP(先查浏览器缓存、系统缓存、hosts,再问递归 DNS 服务器);和服务器 TCP 三次握手;如果是 HTTPS 再做 TLS 握手,协商密钥并校验证书;发送 HTTP 请求;服务器(可能先经过 CDN、反向代理如 Nginx / Caddy)处理后返回响应;浏览器解析 HTML,加载 CSS、JS 和图片,构建 DOM 和渲染树,排版绘制。答题时可以挑自己熟的一段讲深,比如结合自己的项目讲 Caddy 怎么自动申请证书、怎么做反向代理。
socket API 与文件描述符
5 题 · 出自笔记 socket API 与文件描述符
listen 的 backlog 参数是什么意思?
参考回答: 内核为每个监听 socket 维护两个队列:半连接队列(收到 SYN、还没完成握手)和全连接队列(握手完成、等着被 accept 取走)。在现在的 Linux 上,backlog 限制的是全连接队列的长度,实际值是 backlog 和 net.core.somaxconn 中较小的那个。服务器 accept 不及时导致全连接队列满了,新的握手就会被丢弃,客户端表现为连接超时,或者连上了却迟迟收不到回复。半连接队列的大小由 tcp_max_syn_backlog 控制,SYN Flood 攻击打的就是它,防御手段是 SYN Cookie。
accept 发生在三次握手的哪一步?connect 什么时候返回?
参考回答: accept 不参与握手。三次握手完全由内核完成,完成后连接进入全连接队列,accept 只是从队列里取出一个已经建立好的连接。所以即使服务器从不调用 accept,客户端的 connect 也能成功返回。客户端这边,connect 在收到服务器的 SYN+ACK、发出最后的 ACK 之后就返回了。
close 和 shutdown 有什么区别?
参考回答: close 是把 fd 的引用计数减一,计数到 0 才真正关闭连接、发送 FIN,并且读写两个方向都关掉。shutdown 不管引用计数,直接作用于连接本身,还可以只关一个方向:shutdown(fd, SHUT_WR) 只关闭写,发出 FIN 告诉对方“我发完了”,但还能继续读对方发来的数据,这叫半关闭。网络库在主动断开时常用 shutdown 写端,等对方把数据收完、也关闭后再 close。
服务器往一个已经断开的连接 write,会发生什么?
参考回答: 对方已经关闭时,第一次 write 通常能成功写进内核缓冲区,但对方会回一个 RST;再写的话,进程会收到 SIGPIPE 信号,默认处理方式是直接终止进程,表现为服务器莫名其妙退出。所以服务器启动时要 signal(SIGPIPE, SIG_IGN) 忽略它,或者用 send 时带上 MSG_NOSIGNAL,这样 write 只会返回 -1、errno 为 EPIPE,按正常的出错流程关闭连接就行。
TCP_NODELAY 是干什么的?什么时候要打开?
参考回答: 它用来关闭 Nagle 算法。Nagle 算法会把小数据攒一攒再发,在还有数据没被确认时,新的小包要等 ACK 回来才发,能减少网络上的小包数量;但和对端的延迟确认叠加,会出现几十毫秒的延迟。Redis 这种请求响应都很小、又在意延迟的服务,一般都会打开 TCP_NODELAY。
I/O 模型:从阻塞到 epoll
6 题 · 出自笔记 I/O 模型:从阻塞到 epoll
Linux 有哪几种 I/O 模型?
参考回答: 五种:阻塞 I/O、非阻塞 I/O、I/O 多路复用(select / poll / epoll)、信号驱动 I/O、异步 I/O(POSIX AIO、io_uring)。一次读操作分两个阶段:等数据就绪,和把数据从内核拷到用户空间。前四种在第二阶段都要进程自己调用 read 去拷,所以都算同步 I/O;只有异步 I/O 是内核拷完再通知你。
可能的追问: 多路复用为什么能提高并发?因为一个线程可以同时等很多 fd,哪个就绪处理哪个,不用为每个连接开一个线程。
select、poll、epoll 的区别是什么?
参考回答: select 用位图存 fd,默认上限 1024 个,每次调用都要把整个集合从用户态拷到内核,返回后还要遍历全部 fd 才知道哪些就绪,复杂度 O(n),而且集合会被修改,每次都要重新设置。poll 用数组代替位图,去掉了数量上限,但拷贝和遍历的问题还在。epoll 把“注册”和“等待”分开:epoll_ctl 注册一次就一直有效,epoll_wait 只返回就绪的 fd,开销只和活跃连接数有关,适合大量连接但同时活跃的不多的场景。
可能的追问: epoll 一定比 select 快吗?不一定,连接数很少并且几乎都活跃时,差别不大;epoll 的优势在连接多、活跃少。
epoll 底层是怎么实现的?
参考回答: epoll_create 在内核里创建一个 eventpoll 对象,里面主要有两个结构:一棵红黑树,存所有注册的 fd,增删改都是 O(log n);一个就绪链表。epoll_ctl 添加 fd 时,会在这个 fd 的等待队列上挂一个回调函数,网卡收到数据、socket 变成可读时,内核调用这个回调,把对应的节点加进就绪链表。epoll_wait 只要看就绪链表是否为空,不为空就把就绪的事件拷给用户态。
注意: 网上常说 epoll 用 mmap 共享内存来避免拷贝,这是错的,就绪事件还是通过 copy_to_user 拷出来的。面试时别这么说。
LT 和 ET 有什么区别?你的项目用的哪个,为什么?
参考回答: LT(水平触发)只要 fd 上还有数据没读完,每次 epoll_wait 都会报告它;ET(边缘触发)只在状态变化时报告一次,比如新数据到达。用 ET 必须把 fd 设成非阻塞,并且一次就循环读到返回 EAGAIN,否则剩下的数据就再也收不到通知了。ET 的系统调用次数少一些,但更容易写出 bug。
我的项目用的是 LT,和 muduo、Redis 一样:逻辑简单,不容易漏数据,性能差距在我的压测里不明显(可以拿自己的压测数据说话)。Nginx 用的是 ET。
可能的追问: LT 模式下写数据要注意什么?只有输出缓冲区里有数据没发完时才关注 EPOLLOUT,发完就取消,不然 socket 一直可写,epoll_wait 会不停返回,CPU 打满。
Reactor 和 Proactor 有什么区别?
参考回答: Reactor 是“就绪通知”:I/O 多路复用告诉你哪个 fd 可以读写了,由你自己调用 read / write 去完成 I/O,然后处理业务,epoll 网络库、Redis、Netty 都是这种。Proactor 是“完成通知”:你先发起异步读写,内核把 I/O 做完后通知你结果,你只处理数据,Windows 的 IOCP 是典型,Linux 上可以用 io_uring 实现。
什么是惊群?epoll 怎么解决?
参考回答: 多个进程或线程同时在等同一个事件,事件来了全被唤醒,但只有一个能处理成功,其余的白白醒来又睡回去,浪费 CPU。accept 的惊群在 Linux 2.6 就已经解决了。多个线程各自用 epoll 监听同一个 listen fd 时还会有惊群,解决办法有:只让一个线程负责 accept 再分发连接(muduo 的做法);Linux 4.5 之后的 EPOLLEXCLUSIVE;或者用 SO_REUSEPORT 让每个线程有自己的 listen socket,由内核做负载均衡。