网络:面试速记 + 从零学习
这份文档分两层用:
| 场景 | 读哪里 |
|---|---|
| 面试前 10 分钟 | 第一部分速记页:一张总图 + 高频考点表 + TCP 状态机 |
| 面试前一天 | 第二部分易混对照 + 第三部分 30 段口述稿,出声念一遍 |
| 平时学习 | 第四部分逐个机制详解,按数据流顺序排的 |
| 想动手 | 第五部分:可编译实验 + 排查手册 |
| 检验是否真会 | 第六部分闭卷自测 |
全文围绕一个问题:你按下发送,这批字节接下来经过谁、谁保证了什么、哪一步可能出错。 网络面试的追问几乎都落在”谁保证”和”哪一步”上。
第一部分 速记页
0.1 一条 hello 的完整旅程
这张图是全文骨架。后面每一节都在补其中一个环节的细节。
flowchart TB
A["你的程序:send(fd, #quot;hello#quot;, 5)"] --> B[内核发送缓冲区]
B --> C["TCP:切段,加序号,等 ACK,受 cwnd/rwnd 限制"]
C --> D["IP:加源/目的 IP,按路由表选下一跳"]
D --> E["链路层:ARP 查下一跳 MAC,加帧头"]
E --> F[网卡 → 交换机 → 路由器/NAT → ...]
F --> G[对端网卡 → 中断 → 内核协议栈]
G --> H["按五元组找到连接,重排序,去重,回 ACK"]
H --> I[内核接收缓冲区]
I --> J["对端程序:recv() 取走字节"]
看图,记四句话:
send返回只表示”拷进了内核发送缓冲区”,不表示对方收到,更不表示对方处理完。- TCP 交付的是字节流,不是你的函数调用边界——发两次可能收一次,这就是”粘包”的来源。
- 中间每一跳只看它该看的那一层:路由器看 IP,交换机看 MAC,谁都不理解你的业务。
- 出问题时先定位在这张图的哪一段,而不是先去抄内核参数。
0.2 分层:每层解决什么、出问题什么样
| 层 | 干什么 | 关键协议 | 地址 | 设备 | 这层出问题的典型现象 |
|---|---|---|---|---|---|
| 应用层 | 业务怎么请求和响应 | HTTP、DNS、SSH | 域名/URL | — | 404、业务超时、乱码 |
| 传输层 | 端到端:给哪个程序、可靠不可靠 | TCP、UDP | 端口 | — | Connection refused、RST、粘包 |
| 网络层 | 跨网段找路 | IP、ICMP、路由 | IP 地址 | 路由器 | 连接超时、ping 不通、Network unreachable |
| 链路层 | 同一网段内送到下一跳 | 以太网、ARP | MAC 地址 | 交换机、网卡 | 丢包率高、ARP 冲突、MTU 相关卡顿 |
面试常问 OSI 七层:物理、数据链路、网络、传输、会话、表示、应用。实际用的是上面这个 TCP/IP 四层(或五层,把物理层单列)。会话层和表示层在现实里被 TLS、序列化库这些吸收了。答的时候两套都提一句,重点讲四层。
0.3 高频考点一页表
面试前只需要看这张。第三列是最容易答错的地方。
TCP 建连与断连
| 考点 | 一句话答案 | 别答错 |
|---|---|---|
| 三次握手干什么 | 交换并确认双方初始序号,同时确认双方收发都通 | 不是”证明双方活着”这么简单 |
| 为什么不能两次 | 服务端的初始序号得不到确认;旧的重复 SYN 会让服务端单方面建连、白占资源 | |
| 为什么不用四次 | 服务端的 ACK 和 SYN 可以合并成一个包 | |
| 半连接队列 | SYN queue,存 SYN_RCVD 的连接;满了丢包,防护靠 SYN cookie | 不是 accept 队列 |
| 全连接队列 | accept queue,存已 ESTABLISHED 等应用 accept 的;大小 min(backlog, somaxconn) | listen(fd, 8) 的 8 不是”最多 8 人在线” |
| SYN flood | 大量伪造源 IP 的 SYN 占满半连接队列;开 tcp_syncookies | |
| 四次挥手为什么四次 | TCP 全双工,FIN 只关自己的发送方向,对端可能还有数据要发,所以 ACK 和 FIN 不能合并 | 无数据时可能合并成三次 |
| TIME_WAIT | 在主动关闭方,持续 2MSL(Linux 固定 60s)。两个作用:①最后的 ACK 丢了能重传 ②让旧连接的迷路包在网络中消失 | 不是泄漏;也不是”服务端才有” |
| CLOSE_WAIT 堆积 | 在被动关闭方,说明应用收到 FIN(recv 返回 0)后没有 close | 这是应用 bug,不是内核参数问题 |
| FIN_WAIT_2 堆积 | 对端应用不 close,只回了 ACK;Linux 用 tcp_fin_timeout 兜底 | |
| SO_REUSEADDR | 允许 bind 处于 TIME_WAIT 的本地地址,用于服务重启 | 不是允许抢别人的端口 |
| SO_REUSEPORT | 多个 socket bind 同一 ip:port,内核做负载均衡,顺带解决 accept 惊群 | 和 REUSEADDR 不是一回事 |
| RST 什么时候来 | 连到没监听的端口、连接已被销毁还收到数据、SO_LINGER 置 0 后 close | 收到 RST 是 ECONNRESET |
TCP 可靠性与效率
| 考点 | 一句话答案 | 别答错 |
|---|---|---|
| TCP 靠什么可靠 | 序号 + 确认 + 超时重传 + 重排序去重 + 校验和 + 流量控制 + 拥塞控制 | 少说一半就不完整 |
| 滑动窗口 | 接收方通告 rwnd,发送方按 min(cwnd, rwnd) 决定能发多少未确认数据 | |
| 流量控制 vs 拥塞控制 | 流量控制保护接收方(rwnd);拥塞控制保护网络(cwnd) | 这两个是最常混的一对 |
| 拥塞控制四阶段 | 慢启动(指数)→ 拥塞避免(线性)→ 快重传(3 个重复 ACK)→ 快恢复 | Linux 默认算法是 CUBIC,不是 Reno |
| 超时重传 vs 快重传 | 超时:cwnd 打回 1 重新慢启动;快重传:ssthresh = cwnd/2,cwnd 减半后线性增长 | |
| 零窗口 | 接收方通告 rwnd = 0,发送方启动持续计时器发窗口探测 | |
| Nagle + 延迟 ACK | Nagle 攒小包等 ACK;延迟 ACK 等捎带最多 40ms。两个一起用会出现40ms 延迟阶梯,关掉用 TCP_NODELAY | 高频追问 |
| MSS / MTU | MSS = MTU - 20(IP) - 20(TCP),以太网 1500 → 1460;握手时双方各自通告 | MSS 是 TCP 协商的,MTU 是链路属性 |
| IP 分片为什么要避免 | 丢一片,整个 IP 包都要重传;IPv6 只在源端分片,靠 PMTUD | |
| 粘包/半包 | TCP 是字节流,没有消息边界。解法:定长、分隔符、长度头(4 字节长度 + 正文,长度必须设上限) | UDP 不粘包(保留数据报边界) |
| SO_KEEPALIVE vs 应用心跳 | 内核 keepalive 默认 7200s 才开始探测,太慢;所以业务用应用层心跳 | 心跳超时只说明没响应,不证明对端已死 |
UDP 与 TCP 选型
| 考点 | 一句话答案 |
|---|---|
| TCP 给什么 | 面向连接、可靠、有序、字节流、有流控拥塞控制;开销大、有队头阻塞 |
| UDP 给什么 | 无连接、不可靠、保留消息边界、开销小、可广播多播;丢包乱序要应用自己管 |
| UDP 包该多大 | 尽量 ≤ 1500 - 20 - 8 = 1472 字节,避免 IP 分片放大丢包影响 |
| 谁用 UDP | DNS、音视频实时流、游戏同步、QUIC/HTTP3 |
I/O 模型与高并发
| 考点 | 一句话答案 | 别答错 |
|---|---|---|
| 五种 I/O 模型 | 阻塞、非阻塞、I/O 多路复用、信号驱动、异步 I/O | 前四种都是同步——数据拷贝阶段都要等 |
| select 的问题 | fd 上限 FD_SETSIZE(1024)、每次要把整个集合拷进内核、返回后 O(n) 遍历找就绪、集合会被改写要重填 | |
| poll | 用数组,没有 1024 限制;仍然是 O(n) 拷贝 + O(n) 遍历 | |
| epoll 为什么快 | 内核侧红黑树存注册的 fd(epoll_ctl 一次,O(log n))+ 就绪链表(有事件时回调塞进去);epoll_wait 只返回就绪的那些 | 不要说”mmap 共享内存免拷贝”——现代内核是 copy_to_user |
| LT vs ET | LT:只要还有数据就反复通知,可以一次只读一点。ET:只在状态变化时通知一次,必须循环读到 EAGAIN,必须配非阻塞 | ET 不是”更高级”,是更省唤醒、更难写 |
| 惊群 | 多个线程 epoll_wait 同一个 listen fd,来一个连接唤醒全部。解法:EPOLLEXCLUSIVE 或 SO_REUSEPORT | accept 本身的惊群早已在内核解决 |
| Reactor vs Proactor | Reactor:多路复用报告就绪,应用自己读(Linux 主流)。Proactor:异步 I/O 报告完成,内核已经读好(Windows IOCP、io_uring) | |
| Reactor 三种形态 | 单 Reactor 单线程(Redis)→ 单 Reactor + 线程池 → 主从 Reactor 多线程(muduo、Netty) | |
| 零拷贝 | 传统 read+write 4 次拷贝 4 次切换;mmap+write 3 次;sendfile 2 次(配 SG-DMA 可到 1 次),全程不进用户态 | ”零”指不经过用户态,不是真的 0 次 |
| 一万连接怎么支撑 | 先问:同时活跃比例、消息大小、请求速率、处理耗时。再看 fd 上限、内存、CPU、延迟 | 连接建得起来只是第一步 |
应用层
| 考点 | 一句话答案 |
|---|---|
| DNS 解析顺序 | 浏览器缓存 → 系统缓存/hosts → 本地 DNS(递归解析器)→ 根 → TLD → 权威 |
| 递归 vs 迭代 | 我问本地 DNS 是递归(你负责给我最终答案);本地 DNS 问根/TLD/权威是迭代(每次给下一步线索) |
| DNS 用什么传输 | UDP 53;响应超 512 字节(EDNS0 可扩到 4096)或区域传送用 TCP |
| ARP | 同网段用 IP 查 MAC,广播问、单播答,有缓存;跨网段查的是网关的 MAC |
| HTTPS 握手 | 非对称加密交换出对称密钥,之后用对称密钥加密数据。TLS 1.2 需 2 RTT,TLS 1.3 只需 1 RTT,会话复用可 0-RTT |
| 证书验证什么 | CA 签名链 + 域名匹配 + 有效期 + 吊销状态 |
| HTTP/1.1 的队头阻塞 | 一条连接上请求必须按序响应;浏览器靠开 6 条连接绕 |
| HTTP/2 解决了什么 | 二进制分帧 + 多路复用(解决 HTTP 层队头阻塞)+ HPACK 头压缩;TCP 层队头阻塞仍在——丢一个包阻塞所有 stream |
| HTTP/3 | QUIC over UDP,流之间真正独立,0-RTT,支持连接迁移 |
| 从 URL 到页面 | DNS → TCP 握手 → TLS 握手 → 发 HTTP 请求 → 服务端响应 → 解析渲染 |
0.4 只背一张图:TCP 状态机
这是网络面试性价比最高的一张图。 会看状态机,握手、挥手、TIME_WAIT、CLOSE_WAIT 四道题一起解决。
stateDiagram-v2
[*] --> CLOSED
CLOSED --> LISTEN: 服务端 listen()
CLOSED --> SYN_SENT: 客户端 connect() 发 SYN
LISTEN --> SYN_RCVD: 收 SYN,回 SYN+ACK
SYN_SENT --> ESTABLISHED: 收 SYN+ACK,发 ACK
SYN_RCVD --> ESTABLISHED: 收 ACK
ESTABLISHED --> FIN_WAIT_1: 主动关闭,发 FIN
ESTABLISHED --> CLOSE_WAIT: 收到 FIN,回 ACK
FIN_WAIT_1 --> FIN_WAIT_2: 收到 ACK
FIN_WAIT_2 --> TIME_WAIT: 收到对端 FIN,回 ACK
TIME_WAIT --> CLOSED: 等 2MSL
CLOSE_WAIT --> LAST_ACK: 应用 close(),发 FIN
LAST_ACK --> CLOSED: 收到 ACK
看图,左右两条路要分清:
- 左路是主动关闭方:
FIN_WAIT_1 → FIN_WAIT_2 → TIME_WAIT,最后停在 TIME_WAIT 等 2MSL。 - 右路是被动关闭方:
CLOSE_WAIT → LAST_ACK,从 CLOSE_WAIT 走到 LAST_ACK 靠的是应用调用close()。
排查用的对照表——ss -tan 看到什么状态说明什么:
| 大量出现 | 在哪一方 | 说明什么 | 怎么办 |
|---|---|---|---|
SYN_RCVD | 服务端 | 半连接队列积压,可能被 SYN flood | 开 tcp_syncookies,调队列 |
ESTABLISHED 但不动 | 双方 | 连接建了但没数据;可能对端挂了没发 FIN | 应用层心跳 + 超时 |
CLOSE_WAIT 堆积 | 被动关闭方(常是你的服务端) | 应用忘了 close()——recv 返回 0 后没释放 fd | 改代码,不是调参数 |
FIN_WAIT_2 堆积 | 主动关闭方 | 对端应用不 close,只回了 ACK | 找对端;本地靠 tcp_fin_timeout 兜底 |
TIME_WAIT 很多 | 主动关闭方(常是发起大量短连接的一侧) | 正常现象。真占满本地端口才是问题 | 用长连接;客户端可开 tcp_tw_reuse |
LAST_ACK 卡住 | 被动关闭方 | 自己的 FIN 没被确认,可能对端已消失 | 等超时 |
一句话记忆:TIME_WAIT 是协议要求你等,CLOSE_WAIT 是你的代码忘了关。
第二部分 易混对照
答错的绝大多数情况不是”没听过”,而是”答成了隔壁那个”。
1.1 同步 / 异步 / 阻塞 / 非阻塞
这四个词是网络面试最容易被绕进去的地方。分两个阶段看就清楚了:一次读取分为「等数据到达内核」和「把数据从内核拷到用户态」两步。
flowchart TB
subgraph P1[阶段一:等数据到内核缓冲区]
A[阻塞:调用不返回,线程睡眠]
B[非阻塞:立刻返回 EAGAIN,自己再来]
C[多路复用:交给 select/poll/epoll 一起等]
end
subgraph P2[阶段二:内核 → 用户态拷贝]
D[同步:这一步一定要自己等着拷完]
E[异步:内核拷好了才通知你,你不参与]
end
| 模型 | 阶段一(等数据) | 阶段二(拷数据) | 是否同步 |
|---|---|---|---|
| 阻塞 I/O | 阻塞 | 阻塞 | 同步 |
| 非阻塞 I/O(轮询) | 不阻塞,反复试 | 阻塞 | 同步 |
| I/O 多路复用 | 阻塞在 epoll_wait | 阻塞 | 同步 |
| 信号驱动 | 不阻塞,信号通知 | 阻塞 | 同步 |
异步 I/O(io_uring、IOCP) | 不阻塞 | 不阻塞 | 异步 |
必答的一句: epoll 是同步 I/O 多路复用——它只帮你等和报告就绪,字节还是你自己 read 的,那次拷贝你得等着。只有阶段二也不用你参与,才叫异步 I/O。
1.2 TCP vs UDP
| TCP | UDP | |
|---|---|---|
| 连接 | 需要握手 | 无连接,直接发 |
| 可靠 | 确认+重传+去重+重排序 | 不保证到达、不保证顺序、不去重 |
| 边界 | 字节流,无消息边界(要自己切) | 保留数据报边界(一次发一次收) |
| 流控拥塞 | 有 rwnd + cwnd | 没有,会把网络打满 |
| 头部 | 20 字节起(有选项) | 8 字节 |
| 一对多 | 只能一对一 | 支持广播、多播 |
| 典型 | HTTP、SSH、数据库 | DNS、实时音视频、游戏、QUIC |
面试别只背优缺点口号,要能答这两个反问:
- “UDP 会粘包吗?“——不会,它保留数据报边界;但会丢、会乱序、会重复。
- “既然 UDP 快,为什么不都用?“——可靠性不会消失,只是从内核挪到你的应用里,你得自己实现确认重传,通常做得不如内核。QUIC 就是在 UDP 上重做了一套。
1.3 select / poll / epoll
| select | poll | epoll | |
|---|---|---|---|
| 数据结构 | fd_set 位图 | pollfd 数组 | 内核红黑树 + 就绪链表 |
| fd 上限 | FD_SETSIZE = 1024 | 无硬上限 | 无硬上限 |
| 每次调用的拷贝 | 整个集合拷进内核 | 整个数组拷进内核 | 只在 epoll_ctl 注册时拷一次 |
| 找就绪的开销 | O(n) 遍历 | O(n) 遍历 | O(1) 拿就绪链表 |
| 集合是否被改写 | 会,每次要重填 | 不会(用 revents) | 不会 |
| 触发模式 | 只有 LT | 只有 LT | LT 和 ET |
| 跨平台 | 广泛 | 广泛 | Linux 专有(BSD 是 kqueue) |
epoll 快在两点,答的时候都要说:
- 注册和等待分离——
epoll_ctl把 fd 加到红黑树里一次,之后每次epoll_wait不用重传 fd 集合。select/poll 每次都要把整个集合拷进内核。 - 就绪链表——fd 上有事件时内核回调把它挂进就绪链表,
epoll_wait直接取链表,不需要遍历所有 fd。
同时要主动否掉一个流传很广的错误说法: “epoll 用 mmap 和内核共享内存所以免拷贝”——现代内核不是这样,epoll_wait 返回结果走的是 copy_to_user。主动纠正这一点,比背对了更能说明你看过实现。
1.4 LT vs ET
| LT(水平触发,默认) | ET(边缘触发) | |
|---|---|---|
| 通知条件 | 缓冲区还有数据就一直通知 | 只在状态变化(新数据到达)时通知一次 |
| 读法 | 可以一次只读一部分,下次还会通知 | 必须循环读到 EAGAIN,否则剩下的数据再也不通知 |
| 是否必须非阻塞 | 不强制 | 必须,否则循环读的最后一次会阻塞住整个线程 |
| 唤醒次数 | 多 | 少 |
| 出错方式 | 忙唤醒、CPU 偏高 | 数据卡在缓冲区里,连接像”假死” |
选择建议照实说: 先写 LT,正确性容易保证;ET 只在唤醒次数确实成为瓶颈时上,而且要配一套”读到 EAGAIN 才罢手 + 写不完就注册 EPOLLOUT”的完整写法。“用了 ET 就高级”是错的印象。
1.5 流量控制 vs 拥塞控制
最容易混的一对,必须一句话分开:
| 流量控制 | 拥塞控制 | |
|---|---|---|
| 保护谁 | 接收方别被撑爆 | 中间网络别被打满 |
| 靠什么 | 接收方在 ACK 里通告 rwnd | 发送方自己维护 cwnd |
| 谁决定 | 接收方说了算 | 发送方根据丢包/延迟推测 |
| 手段 | 滑动窗口、零窗口探测 | 慢启动、拥塞避免、快重传、快恢复 |
发送方实际能发的未确认数据量 = min(cwnd, rwnd)。这个公式是这一对题的标准收尾。
1.6 close vs shutdown
close(fd) | shutdown(fd, how) | |
|---|---|---|
| 作用对象 | 释放文件描述符 | 关闭连接的某个方向 |
| 引用计数 | fd 引用计数减 1,归零才真正发 FIN | 直接对连接生效,不管有几个 fd 引用 |
| 能否半关闭 | 不能 | 能:SHUT_WR 只关发送、SHUT_RD 只关接收 |
| fork 之后 | 父子各持一份,都 close 才发 FIN | 一次调用双方都受影响 |
用途区分: 想说”我发完了,但还要收你的回复”(半关闭),用 shutdown(fd, SHUT_WR);只是清理资源,用 close。优雅关闭的标准写法是 shutdown(SHUT_WR) → 继续 recv 到返回 0 → close。
1.7 TIME_WAIT vs CLOSE_WAIT
| TIME_WAIT | CLOSE_WAIT | |
|---|---|---|
| 出现在 | 主动关闭方 | 被动关闭方 |
| 含义 | 我已经关完了,在等 2MSL 兜底 | 对端说它发完了,我还没调 close |
| 持续多久 | 固定 2MSL(Linux 60s)后自动消失 | 不会自动消失,直到应用 close 或进程退出 |
| 大量出现 | 通常正常(短连接多);极端情况占满本地端口 | 一定是应用 bug |
| 怎么处理 | 用长连接、SO_REUSEADDR、客户端 tcp_tw_reuse | 去代码里找漏掉的 close |
一句话:TIME_WAIT 是协议让你等,CLOSE_WAIT 是你忘了关。 看到 CLOSE_WAIT 堆积就去查”recv 返回 0 之后走到哪了”,别调内核参数。
1.8 Reactor vs Proactor
| Reactor | Proactor | |
|---|---|---|
| 通知的是 | 就绪:“可以读了” | 完成:“已经读好了,数据在这” |
| 谁做实际 I/O | 应用自己 read | 内核做完再回调 |
| 底层 | select/poll/epoll(同步多路复用) | 真异步 I/O:IOCP、io_uring |
| 平台 | Linux 主流 | Windows 原生;Linux 靠 io_uring 才实用 |
Reactor 的三种形态要能说出演进理由:
- 单 Reactor 单线程——一个线程既等事件又处理业务。简单,无锁。Redis 就是这个模型(所以慢命令会阻塞所有人)。
- 单 Reactor + 线程池——I/O 还是一个线程,业务丢给线程池。解决了慢业务,但单线程的 I/O 可能成瓶颈。
- 主从 Reactor 多线程——主 Reactor 只管
accept,把新连接分给若干从 Reactor,每个从 Reactor 一个线程管自己那批连接的读写。muduo、Netty 是这个模型,也叫 one loop per thread。
1.9 粘包 vs 丢包 vs 乱序
| 现象 | 真正原因 | 归谁管 |
|---|---|---|
| 收到半条 / 两条粘一起 | TCP 是字节流,本来就没有消息边界 | 应用要定义消息边界 |
| 数据丢了 | 网络丢包 | TCP 自己重传;应用看不到 |
| 顺序乱了 | IP 包走不同路径 | TCP 自己重排;应用看到的一定有序 |
| 数据被改坏 | 传输错误 | TCP 校验和能发现大部分;要强保证得应用自己校验 |
必须点明: “粘包”是个坏名字——TCP 没有把你的包粘坏,它从来就不承诺保留边界。所以正确的说法是”应用层消息边界问题”。UDP 保留边界,所以 UDP 不存在这个问题。
1.10 ping 通 ≠ 服务可用
| 结论 | 为什么 |
|---|---|
| ping 通,端口不一定通 | ping 走 ICMP,和你的 TCP 端口是两回事;服务没启动、防火墙只放行 ICMP 都会这样 |
| ping 不通,服务不一定挂 | 很多网络和主机默认丢弃 ICMP |
telnet ip port 通了 | 说明三次握手成功,只证明有程序在监听 |
| 握手成功,业务不一定正常 | 应用可能收了不处理、卡在慢业务、或者消息没发完整 |
| HTTP 200 也不一定成功 | 状态码是传输层面的成功,业务结果要看响应体 |
排查口诀:分层往上问——网络层通不通(ping/traceroute)→ 传输层连不连(telnet/ss)→ 应用层答不答(抓包/日志)。
1.11 HTTP/1.1 vs 2 vs 3
| HTTP/1.1 | HTTP/2 | HTTP/3 | |
|---|---|---|---|
| 传输 | TCP | TCP | QUIC over UDP |
| 格式 | 文本 | 二进制分帧 | 二进制分帧 |
| 并发 | 一条连接串行,浏览器开 6 条 | 一条连接多路复用 | 一条连接多路复用 |
| 头压缩 | 无 | HPACK | QPACK |
| HTTP 层队头阻塞 | 有 | 解决了 | 解决了 |
| TCP 层队头阻塞 | 有 | 仍然有(丢一个包卡住所有 stream) | 解决了(流独立) |
| 握手 RTT | TCP 1 + TLS 1~2 | 同左 | 0~1 RTT |
| 连接迁移 | 不支持 | 不支持 | 支持(换网络不断连) |
这道题的关键得分点是”HTTP/2 只解决了 HTTP 层的队头阻塞,TCP 层的还在” —— 这正是 HTTP/3 换到 UDP 的理由。
第三部分 30 段面试口述稿
2.1 怎么组织答案
网络题不像设计模式那样有统一模板,但好答案都有同样的形状:
1. 这个机制解决什么问题(不解决会怎样)
2. 它怎么做的(说清关键量:谁通告、谁维护、什么触发)
3. 一个边界或反例(什么时候它不管用 / 什么时候要关掉它)
4. 落到代码或运维上,我会怎么做
第 3 句是拉开差距的地方。只会讲机制,听起来像背的;说得出边界,听起来像排查过线上问题。
下面 30 段按被问频率排。出声念一遍,比默读有效得多。
2.2 TCP 建连与断连
1. 讲一下三次握手,为什么是三次?
握手要办成两件事:确认双方收发通路都通,以及交换并确认各自的初始序号。客户端发 SYN 带自己的初始序号 x,服务端回 SYN+ACK 带自己的初始序号 y 并确认 x+1,客户端再发 ACK 确认 y+1。SYN 占一个序号,所以确认值都加 1。
为什么不能两次:如果只有两步,服务端的初始序号 y 得不到确认,它没法确定客户端收到了;更实际的问题是,一个早已失效的旧 SYN 重传到达服务端,服务端就会单方面认为连接建好了并分配资源,而客户端根本没在等这条连接。第三次 ACK 就是让服务端确认”这是客户端当下真的想要的连接”。
为什么不用四次:服务端的 ACK 和自己的 SYN 可以合并在一个包里,没必要拆开。
2. 三次握手期间,连接存在哪里?backlog 是什么?
内核维护两个队列。收到 SYN 回了 SYN+ACK 之后,连接处在
SYN_RCVD,放在半连接队列(SYN queue)。第三次 ACK 到达后连接变成ESTABLISHED,移到全连接队列(accept queue),等应用调accept取走。
listen(fd, backlog)的 backlog 影响的是全连接队列,实际大小是min(backlog, net.core.somaxconn)。所以listen(fd, 8)里的 8 不是”最多 8 个人在线”,而是”最多 8 个已建好但还没被 accept 取走的连接排队”,取走之后位置就腾出来了。两个队列满了的表现不同:半连接队列满,新 SYN 被丢弃,客户端表现为连接超时;全连接队列满,Linux 默认丢掉第三次 ACK,客户端以为连上了但服务端还在
SYN_RCVD,会出现”连上了却没反应”这种很难查的现象。排查看ss -lnt的 Send-Q(队列上限)和 Recv-Q(当前排队数)。
3. SYN flood 是什么,怎么防?
攻击者用伪造的源 IP 大量发 SYN,服务端每个都回 SYN+ACK 并在半连接队列里占一个位置,但第三次 ACK 永远不来,队列被占满,正常客户端就连不上了。成本不对称是它的要害:攻击方发一个包就走,服务端要维护状态并重传 SYN+ACK。
主要防护是 SYN cookie(
net.ipv4.tcp_syncookies):队列满时不再占用队列,而是把连接信息编码进 SYN+ACK 的初始序号里,等第三次 ACK 回来时从序号中还原出来。代价是这条路径上放不了 TCP 选项,比如窗口缩放会受影响,所以它是应急手段而不是常开的最优解。此外还可以调大队列、缩短 SYN+ACK 重试次数,以及在上游做限流。
4. 讲一下四次挥手,为什么是四次?
因为 TCP 是全双工,两个方向要分别关闭。主动关闭方发 FIN,只表示”我不再发数据了”,对端回 ACK 确认;但对端可能还有数据要发,所以它得等自己发完才发自己的 FIN,主动方再回 ACK。ACK 和 FIN 之间隔着”对端还要发多久”,所以合不了。
边界要说两个:如果被动方确实没有数据要发,它的 ACK 和 FIN 可能合并,就只看到三个包;如果双方同时关闭,还有同时关闭的路径。所以”四次挥手”不等于”一定看到四个独立的包”。
5. TIME_WAIT 为什么存在?为什么是 2MSL?
TIME_WAIT 出现在主动关闭方,它有两个作用。
第一,保证最后那个 ACK 能重传。如果我发的最后一个 ACK 丢了,对端会重传它的 FIN;我必须还在,才能再回一次 ACK,否则对端会一直卡在
LAST_ACK并最终收到 RST。第二,让这条连接的迷路报文在网络中彻底消失。MSL 是报文在网络中的最大生存时间,等 2MSL 之后,旧连接的任何延迟报文都已经过期,不会串到之后用同一个五元组建立的新连接上去。
2MSL 是”我发的最后一个 ACK 最长活 MSL + 对端可能重传的 FIN 最长活 MSL”。Linux 上这个时间是写死的 60 秒(
TCP_TIMEWAIT_LEN),不能通过 sysctl 改。什么时候它才成问题:只有当主动关闭方是发起大量短连接的那一侧、把本地端口范围占满时。这时的正确解法是改用长连接或连接池;服务端重启被端口占用是另一回事,用
SO_REUSEADDR解决。客户端可以开tcp_tw_reuse。tcp_tw_recycle不要提建议——它在 NAT 环境下会错误丢弃连接,已经在 Linux 4.12 被移除了。
6. 服务器上大量 CLOSE_WAIT 是什么问题?
这是应用 bug,不是内核参数问题。 CLOSE_WAIT 出现在被动关闭方:对端发了 FIN,我的内核回了 ACK,然后就等应用调
close()来发出我这边的 FIN。只要应用不 close,这个状态就永远不消失,fd 一直占着,最后耗尽文件描述符。所以看到 CLOSE_WAIT 堆积,我会直接去代码里找”
recv返回 0 之后走到哪了”。常见成因是:recv返回 0 被当成错误直接continue了、异常路径上漏了 close、连接对象被放进容器后没人负责回收、或者 epoll 里没处理EPOLLRDHUP/EPOLLHUP。顺带一起答对照的那个:FIN_WAIT_2 堆积是对端的问题——我发了 FIN、收到了 ACK,但对端应用不 close 所以不发它的 FIN。本地只能靠
tcp_fin_timeout兜底。
7. recv 返回 0、返回 -1、收到 RST 分别是什么?
对阻塞或非阻塞的 TCP socket,用正长度缓冲去读:
返回 0 表示对端关闭了发送方向(收到了 FIN),并且之前的数据已经读完了。这是正常结束,不是”暂时没数据”。收到 0 之后我该做的事是把这个连接收尾并
close——漏了这一步就是上一题的 CLOSE_WAIT。返回 -1 要看
errno:EAGAIN/EWOULDBLOCK是非阻塞下”暂时没数据”,正常,回去等下一次事件;EINTR是被信号打断,应该重试;ECONNRESET是收到了 RST,连接已经没了。RST 表示”这条连接不该存在”,典型来源是连到没有程序监听的端口、连接已被内核销毁后又收到数据、或者对端设了
SO_LINGER为 0 后 close。往一个已经收到 RST 的连接写数据会触发 SIGPIPE,默认行为是杀掉进程,所以服务端一般要signal(SIGPIPE, SIG_IGN),然后靠send返回EPIPE来处理。
8. close 和 shutdown 有什么区别?怎样优雅关闭?
close关的是文件描述符,fd 引用计数减一,归零才真正发 FIN——所以 fork 之后父子都持有同一个连接时,只有两边都 close 才会发出 FIN。shutdown关的是连接的方向,不看引用计数,可以只关发送(SHUT_WR)或只关接收。优雅关闭的标准做法是:
shutdown(fd, SHUT_WR)告诉对端”我发完了” → 继续recv直到返回 0,把对端最后的数据收干净 → 再close。直接 close 的风险是,如果接收缓冲区里还有未读数据,内核会发 RST 而不是 FIN,对端已经发出的数据就丢了。
2.3 TCP 可靠性与效率
9. TCP 靠什么做到可靠?
六件事一起做的,答的时候别只说重传:
序号给每个字节编号,接收方据此去重和重排序,所以应用看到的一定是有序无重复的字节流。确认(ACK)告诉发送方”到这个位置之前我都收到了”,TCP 用的是累积确认。超时重传:发送方为未确认数据维护 RTO,超时就重发;RTO 是根据测量的 RTT 动态算的,不是固定值。校验和覆盖首部和数据,能发现大部分传输错误(但不是强校验,要强保证得应用自己做)。流量控制用滑动窗口防止撑爆接收方。拥塞控制用
cwnd防止打满网络。边界:TCP 保证的是”字节流按序到达或者连接报错”,它不保证时延、不保证你的消息边界、也不保证对端应用真的处理了。
10. 讲一下滑动窗口。
如果发一个包等一个 ACK,吞吐会被 RTT 死死限制。滑动窗口让发送方可以连续发出一批未确认的数据,上限就是窗口大小。
接收方在每个 ACK 里通告
rwnd,也就是”我的接收缓冲区还能放多少”。发送方维护一个已发送未确认的区间,收到 ACK 后窗口左边界右移(数据被确认,可以丢弃重传副本),rwnd允许时右边界右移(可以发新数据)。发送方实际能发的未确认数据量是min(cwnd, rwnd)—— 一个来自接收方,一个来自自己对网络的估计。两个边界:零窗口——接收方缓冲满了会通告
rwnd = 0,发送方停发并启动持续计时器定期发窗口探测,等接收方通告非零;不能干等,因为通告窗口的那个 ACK 可能丢。糊涂窗口综合症——接收方每腾出一两个字节就通告,发送方就发一两字节的包,头部开销远超载荷;解法是接收方等窗口够大再通告,发送方用 Nagle 攒一攒。还有一点:16 位窗口字段最大 65535 字节,高带宽长延迟链路不够用,所以有窗口缩放选项,在握手时协商。
11. 讲一下拥塞控制的四个阶段。
流量控制只看接收方,但网络中间也会堵,所以发送方要自己维护一个拥塞窗口
cwnd。慢启动:
cwnd从 1 个 MSS 开始,每收到一个 ACK 就翻倍,指数增长,快速探到可用带宽。“慢”是指起点低,不是增长慢。到达阈值ssthresh后进入下一阶段。拥塞避免:改成线性增长,每个 RTT 只加一个 MSS,小心试探。
快重传:收到 3 个重复 ACK 说明有个包丢了但后面的还在到,网络没有完全堵死,于是立刻重传那个包,不等 RTO 超时。
快恢复:快重传之后不打回慢启动,而是
ssthresh = cwnd/2、cwnd降到ssthresh附近,继续线性增长。对比要说清:如果是 RTO 超时(连重复 ACK 都没有,说明可能真堵死了),处理狠得多——
ssthresh = cwnd/2,cwnd直接打回 1,重新慢启动。边界:上面这套是经典 Reno 的描述。Linux 现在默认是 CUBIC,用三次函数控制增长,在高带宽长延迟下比 Reno 好;还有 BBR,不靠丢包判断拥塞,而是主动测量带宽和最小 RTT——因为在有大缓冲的设备上,丢包发生时延迟已经很高了(缓冲区膨胀)。
12. Nagle 算法和延迟 ACK 是什么?为什么它们一起用会出问题?
Nagle 解决小包太多的问题:如果有未被确认的小包在路上,新的小数据先攒着,等到收到 ACK 或者攒满一个 MSS 再发。这样把很多一字节的包合成一个。
延迟 ACK 解决纯确认包太多:接收方收到数据不立刻回 ACK,而是等一小会儿(Linux 最多约 40ms),看能不能捎带在自己要发的数据上,或者等到第二个包一起确认。
两个都开着就会互相等:我发了一个小包,Nagle 说”下一个小包要等这个的 ACK”;对端延迟 ACK 说”我等等看有没有数据捎带”。结果是每次交互都被拖上几十毫秒,表现成规律的 40ms 延迟阶梯。这在”小请求小响应、一来一回”的协议上特别明显,比如很多 RPC。
解法是在这种场景下
setsockopt(TCP_NODELAY)关掉 Nagle。更根本的做法是应用层不要把一个逻辑消息拆成多次send——攒好一次写出去(或者用writev聚集写),这样 Nagle 本来就不会触发。
13. MTU 和 MSS 是什么关系?为什么要避免 IP 分片?
MTU 是链路层一帧能承载的最大载荷,以太网通常 1500 字节,是链路的属性。MSS 是 TCP 一个段能携带的最大应用数据量,是 TCP 在握手时双方各自通告的,典型值
1500 - 20(IP 头) - 20(TCP 头) = 1460。有 TCP 选项时还要再减。如果 IP 包超过路径上某段的 MTU,就得分片。分片的问题是丢一片,整个 IP 包都要重传——因为 IP 层不会只重传一片,是上层 TCP 重发整个段。于是丢包率被放大。分片还增加中间设备负担,有些防火墙干脆丢弃分片。
所以做法是让 TCP 自己按 MSS 切,天然不会触发 IP 分片。IPv4 靠置 DF 位 + 收到 ICMP “需要分片”来做路径 MTU 发现(PMTUD);IPv6 干脆不允许路由器分片,只能源端分片,完全依赖 PMTUD。这里有个经典坑:中间设备把 ICMP 全丢了,PMTUD 就失效,表现为小包能通、大包卡死(“PMTU 黑洞”),排查时要想到。
UDP 要额外注意:UDP 自己不分段,你
sendto一个 2000 字节的包,就交给 IP 去分片。所以 UDP 应用一般把单包控制在 1472 字节(1500 - 20 - 8)以内。
14. 什么是粘包?怎么解决?
先纠正说法:TCP 没有把包”粘坏”,它从来就不承诺保留消息边界。 TCP 是字节流,你调两次
send发hello和world,对端按顺序一定拿到helloworld,但一次recv拿到多少完全不确定——可能一次拿全、可能拿到he、可能拿到一条半。所谓粘包半包,本质是应用层没有定义消息边界。三种解法:定长,最简单但浪费;分隔符,比如用
\n结束一条文本消息,但正文里能出现分隔符时就要转义;长度头,最通用——固定 4 字节长度加正文,先读满头再按长度读体。长度头有个必须说的安全点:长度字段是对端给的,必须校验上限。照着对方声明的长度直接
resize或malloc,对方发一个 4GB 的长度就能把你打挂。还要注意长度字段的字节序,以及”读到一半”时要把已收字节留在缓冲里等下次。UDP 不存在这个问题,它保留数据报边界,一次
sendto对应一次recvfrom。
15. send 返回值意味着什么?
send返回 n 表示内核接受了 n 个字节放进发送缓冲区,仅此而已。它不表示对端收到了,更不表示对端应用处理了。而且 n 可能小于你传入的长度(短写),尤其在非阻塞模式或发送缓冲区快满的时候。所以正确写法是记住偏移循环发,非阻塞下遇到
EAGAIN要把剩余数据存进应用层发送缓冲、注册EPOLLOUT,等可写事件再继续——这就是为什么每个连接都需要一个应用层写缓冲区。初学最容易犯的两个错:把”返回了正数”当成”整条发完了”;以及把收到的原始字节直接当 C 字符串用——网络数据里没有隐含的
\0结束符。
16. 怎么检测对端已经不在了?keepalive 和心跳的区别?
关键前提:对端拔网线或断电时,它没有机会发 FIN,所以我这边不会收到任何通知,
recv就一直阻塞着,连接看起来还是ESTABLISHED。必须靠主动探测。TCP keepalive(
SO_KEEPALIVE)是内核级的,但 Linux 默认参数很保守:空闲 7200 秒(2 小时)才开始探测,之后每 75 秒探一次、连续 9 次失败才判死。默认配置下等于没用,可以调tcp_keepalive_time等三个参数,但它是全局或按 socket 设置的传输层探测。应用层心跳是自己定义的周期性消息。它的优势是:间隔完全可控、能穿过那些会丢弃 keepalive 探测包的中间设备、而且能证明对端的应用还在处理消息,而不只是内核协议栈还活着。后面这条是关键区别——keepalive 只能说明对端内核有响应,心跳能说明对端业务线程没卡死。
两个边界必须说:心跳超时只说明约定期限内没收到响应,不证明对端一定永久死亡——可能只是网络抖动或它在做慢活。以及重连不等于恢复业务:旧请求可能已经被执行了只是响应丢了,盲目重发会重复执行,涉及副作用时必须有请求 ID 和幂等去重。
2.4 I/O 模型与高并发
17. 讲一下五种 I/O 模型,epoll 属于哪种?
把一次读拆成两个阶段看:阶段一等数据到达内核缓冲区,阶段二把数据从内核拷到用户态。
阻塞 I/O:两个阶段都等着。非阻塞 I/O:阶段一不等,没数据就返回
EAGAIN,靠自己反复试(轮询很浪费 CPU)。I/O 多路复用:把阶段一的等待交给select/poll/epoll,一个线程同时等很多 fd。信号驱动 I/O:数据到了内核发信号通知,实际很少用。异步 I/O:io_uring、Windows IOCP,提交请求就走,内核把数据都拷好了才通知你。前四种都是同步的——因为阶段二那次拷贝都得你自己等着完成。只有第五种是真异步。 所以 epoll 是同步 I/O 多路复用:它只负责告诉你”这个 fd 现在可以读了”,字节还是你自己
read的。这一句是这道题的得分点。
18. select、poll、epoll 有什么区别?epoll 为什么快?
select用fd_set位图,有FD_SETSIZE(通常 1024)的硬上限,每次调用要把整个集合拷进内核,返回后还要 O(n) 遍历才知道哪些就绪,而且集合会被内核改写,下次调用前要重新填一遍。
poll换成pollfd数组,去掉了 1024 限制,用events/revents分开所以不用重填,但每次调用仍然要拷贝整个数组、返回后仍然要 O(n) 遍历。
epoll快在两点,都要说:第一,注册和等待分离。
epoll_ctl把 fd 加进内核的红黑树,只加一次;之后每次epoll_wait都不用再传 fd 集合。而 select/poll 每次都要重新把整个集合拷进内核。第二,就绪链表。fd 上有事件时,内核的回调把它挂到一个就绪链表上;
epoll_wait直接从链表取,不需要遍历所有被监听的 fd。所以开销只和”就绪的数量”相关,和”监听的总数”无关。这就是它在几万连接、少量活跃的场景下压倒性的原因。我还想主动纠正一个流传很广的说法:“epoll 通过 mmap 和内核共享内存所以免拷贝”是错的,现代内核
epoll_wait返回结果走的是copy_to_user。这个说法在很多面经里都有。边界:连接数少而且大部分都活跃时,epoll 未必比 poll 快——红黑树和回调本身有成本,而遍历一个小数组很便宜。另外
epoll是 Linux 专有的,BSD/macOS 对应的是kqueue。
19. LT 和 ET 有什么区别,怎么选?
LT(水平触发,默认):只要缓冲区里还有数据没读完,每次
epoll_wait都会继续告诉你。所以你可以一次只读一部分,剩下的下次还会通知。ET(边缘触发):只在状态发生变化时通知一次——比如有新数据到达。如果你没把数据读完,内核不会再通知你,剩下的数据就卡在缓冲区里,连接看起来”假死”。所以 ET 下必须循环读到返回
EAGAIN才停手,也必须把 fd 设成非阻塞——否则循环的最后一次read会把整个线程阻塞在这个连接上。写也一样:写不完要注册EPOLLOUT,写完要及时取消关注,否则会一直触发可写事件造成忙唤醒。怎么选,我会照实说:先用 LT,正确性容易保证,绝大多数场景够用。ET 的好处是唤醒次数少(比如一次事件里到了很多数据,LT 可能通知多轮),只在唤醒开销确实成为瓶颈时才上。“用了 ET 就更高级”是错的印象——它换来的是一套必须写全的循环和状态管理。
20. 什么是惊群?怎么解决?
多个线程或进程用各自的 epoll 监听同一个 listen fd,一个新连接到来时所有 epoll_wait 都被唤醒,但只有一个能
accept成功,其余全部白醒一次然后拿到EAGAIN。连接量大的时候这是纯粹的 CPU 浪费和缓存污染。两个解法:
EPOLLEXCLUSIVE(Linux 4.5+),注册时加这个标志,内核只唤醒一个等待者;或者SO_REUSEPORT,让每个进程/线程各自bind同一个ip:port建立独立的 listen socket,内核按连接做负载均衡分发——这个方案更彻底,还顺带把 accept 负载均衡了,是现在的主流做法(nginx 就用它)。要区分清楚:
accept系统调用本身的惊群早就在内核里解决了(只唤醒一个阻塞在 accept 上的进程),现在说的惊群特指 epoll 层面的。
21. 讲一下 Reactor 模型和它的演进。
Reactor 就是”多路复用等事件 + 事件分发给对应处理器”这套组织方式:一个循环反复做
epoll_wait→ 取出就绪事件 → 调用对应连接的读/写/错误处理 → 回去等。epoll 是接口,Reactor 是怎么组织代码。三种形态和各自的动机:
单 Reactor 单线程——一个线程既等事件也做业务。全程无锁,实现最简单。Redis 是这个模型,代价是一条慢命令会阻塞所有客户端。
单 Reactor + 线程池——I/O 仍在一个线程,业务丢到线程池。解决了慢业务阻塞,但所有连接的读写都压在一个线程上,高吞吐时那个线程会成为瓶颈,而且跨线程交接引入了同步。
主从 Reactor 多线程——主 Reactor 只负责
accept,把新连接按策略分给若干从 Reactor;每个从 Reactor 一个线程,只管自己那批连接的读写。这样每个连接的状态被单线程独占,不需要锁;多核也用起来了。muduo 和 Netty 都是这个模型,muduo 把它总结成 one loop per thread。对比 Proactor:Reactor 通知的是”就绪”,应用自己读;Proactor 通知的是”完成”,内核已经把数据读好了。Proactor 要真异步 I/O 支撑,Windows 靠 IOCP,Linux 到
io_uring才算实用。
22. 业务处理很慢,epoll 能救吗?
不能。epoll 只优化了”等”,没优化”做”。 I/O 线程取到一条消息后原地算十秒,它管的其他几千个连接就得等十秒——事件都在就绪链表里排着,没人去处理。
做法是把慢任务交给有界线程池,I/O 线程提交任务后立刻回去处理其他连接。但要说清这只是换了地方执行,没有消除成本,而且引入三个必须处理的问题:
一,跨线程交接要同步,工作线程不能直接改 I/O 线程拥有的连接状态,结果要通过队列加唤醒送回 I/O 线程去写。
二,结果回来时连接可能已经断了,而且 fd 是会被复用的——不能拿着旧 fd 号直接写,否则会把数据写给一个完全不相干的新连接。正确做法是用连接对象的
weak_ptr或者带一个单调递增的连接序号来校验。三,必须有背压。任务队列要设上限,满了就拒绝或丢弃;发送积压超过阈值要限制甚至断开那个慢连接。无界队列等于把内存当缓冲,最后是 OOM;固定几个线程也不会自动消化无限的请求。
23. “支持一万并发连接”这个指标怎么看?
我会先反问四个数:同时活跃的比例(一万条连接里同时在收发的是 100 条还是 8000 条,差别是量级)、消息大小、请求速率、单请求处理耗时。这四个不定,“一万连接”没有意义。
C10K 的经典难点是”每连接一线程”撑不住——线程栈、调度和上下文切换成本随连接数线性增长。epoll 解决的是空闲连接不占线程,所以它适合”连接多、活跃少”。但如果一万条全在满速收发,瓶颈就变成 CPU、内存带宽和网卡,epoll 帮不上。
还要看这些硬约束:进程 fd 上限(
ulimit -n)和系统级file-max;每条连接的内核收发缓冲(默认几十到几百 KB,一万条就是几个 GB,这常常是真正的内存瓶颈);以及应用层每连接的读写缓冲。所以我的答法是:连接能建立只是第一步,要在固定负载下同时测成功率、P99 延迟、内存和 CPU,再说支撑多少。
24. 什么是零拷贝?
场景是”把文件内容发到网络上”。传统
read+write要 4 次数据拷贝和 4 次上下文切换:磁盘 DMA 到内核页缓存,页缓存拷到用户缓冲区,用户缓冲区拷到 socket 发送缓冲区,再 DMA 到网卡。中间两次拷贝纯属多余——数据根本没被用户态改过。
mmap+write把文件映射进用户地址空间,省掉第一次拷贝,变成 3 次拷贝、仍是 4 次切换。
sendfile让数据全程留在内核:页缓存直接到 socket 缓冲,2 次拷贝、2 次切换,完全不进用户态。如果网卡支持 SG-DMA(分散/聚集 DMA),连页缓存到 socket 缓冲那次也能省掉,只剩两次 DMA,这才是通常说的”零拷贝”。此外还有splice,通过管道在两个 fd 之间移动数据。“零”指的是不经过用户态、CPU 不参与搬运,不是真的 0 次数据移动。 边界也要说:
sendfile只适合”原样转发”——一旦需要在用户态修改内容,比如加密或压缩,就用不上了(TLS 得靠 kTLS 才能配合)。Kafka、nginx 发静态文件都用它。
2.5 应用层与全链路
25. 在浏览器里输入一个网址,到页面出来,中间发生了什么?
这是全链路题,按层说,每一步都能被追问:
一,DNS 解析。 先查浏览器缓存,再查系统缓存和 hosts,都没有就问本地 DNS 解析器;本地解析器没有缓存就从根域名服务器开始,到顶级域,再到权威域名服务器,拿到 IP。我问本地 DNS 是递归查询(你负责给我最终答案),本地 DNS 向上问是迭代查询(每次给我下一步的线索)。 走 UDP 53 端口。
二,建立 TCP 连接。 有了 IP,按路由表决定下一跳,同网段还要 ARP 查 MAC,跨网段查网关的 MAC;家庭网络还会经过 NAT 做地址端口转换。然后三次握手。
三,TLS 握手(HTTPS)。验证证书,协商出对称密钥。
四,发 HTTP 请求,服务端响应。 可能中间还有 CDN、反向代理、负载均衡。
五,浏览器解析渲染:解析 HTML 构建 DOM,遇到子资源再发请求(可能复用连接),CSS 和 JS 影响渲染时机。
面试想深挖时通常挖三个点:DNS 的递归和迭代、TLS 握手细节、HTTP/1.1 的队头阻塞和浏览器为什么开 6 条连接。
26. HTTPS 握手过程是怎样的?为什么要同时用对称和非对称加密?
先答为什么两种都用:非对称加密(RSA、ECDHE)能在不安全信道上安全地协商出密钥,但运算慢,不适合加密大量数据;对称加密(AES)快,但双方得先有同一个密钥。所以用非对称把对称密钥安全地协商出来,之后所有数据都用对称密钥加密。 这是这道题的核心。
TLS 1.2 大致要 2 个 RTT:ClientHello 带上支持的密码套件和随机数;ServerHello 选定套件、带自己的随机数和证书;客户端验证证书后,用 ECDHE 交换出预主密钥(或用服务端公钥加密预主密钥发过去);双方由两个随机数和预主密钥算出会话密钥;然后 ChangeCipherSpec 和 Finished 互相确认。
TLS 1.3 压到 1 个 RTT:ClientHello 就直接带上 key_share,砍掉了 RSA 密钥传输这类不具备前向保密的套件;配合会话复用(PSK)还能做 0-RTT,但 0-RTT 的数据有重放风险,只适合幂等请求。
证书验证的是四件事:CA 签名链能不能追到本机信任的根证书、域名是否匹配、有效期、以及吊销状态(CRL 或 OCSP)。加密和身份验证是两件事——只加密不验证身份,中间人可以直接冒充服务端。
27. HTTP/1.1、HTTP/2、HTTP/3 各解决了什么?
HTTP/1.1 默认开启 keep-alive 复用连接,但一条连接上的请求必须按发出顺序返回响应,前面一个慢请求会堵住后面所有的——这就是 HTTP 层的队头阻塞。pipelining 本想解决但实际不可用。浏览器的应对办法是对同一域名开 6 条连接,以及把资源拆到多个域名(域名分片)。
HTTP/2 改成二进制分帧,一条连接上可以有多个 stream 并发多路复用,响应可以乱序回来,解决了 HTTP 层的队头阻塞;再加 HPACK 头部压缩、请求优先级、服务端推送。但 TCP 层的队头阻塞还在——所有 stream 跑在一条 TCP 连接上,丢一个包,TCP 必须等它重传才能把后续字节交给上层,于是所有 stream 一起卡住。讽刺的是多路复用让这件事更严重了,因为以前 6 条连接丢包只影响其中一条。
HTTP/3 于是换掉 TCP,用 QUIC over UDP:在 UDP 上自己实现可靠传输,流之间真正独立,一个流丢包不影响其他流;握手把传输层和 TLS 合并,0~1 RTT 就能发数据;还支持连接迁移——用连接 ID 而不是五元组标识连接,手机从 WiFi 切到蜂窝不用重连。
这道题的得分点就是”HTTP/2 只解决了 HTTP 层的队头阻塞,TCP 层的没解决”,这正是 HTTP/3 存在的理由。
28. DNS 解析的完整过程?为什么用 UDP?
查找顺序是逐级缓存:浏览器缓存 → 操作系统缓存和 hosts 文件 → 本地 DNS 解析器(通常是运营商或 8.8.8.8)的缓存。都没有,本地解析器就开始迭代查询:问根域名服务器拿到
.com的 TLD 服务器地址,问 TLD 拿到该域名的权威服务器地址,问权威服务器拿到最终记录,缓存后返回给我。两种查询要分清:我到本地解析器是递归——“你别管过程,把最终答案给我”;本地解析器到根、TLD、权威是迭代——每一步只给下一步的线索。
用 UDP 是因为:DNS 查询和响应通常很小,一问一答就完事,用 UDP 省掉三次握手和连接状态,延迟低、服务端能撑的 QPS 高得多;偶尔丢包重发一次的代价远小于每次都建连接。
什么时候用 TCP:响应超过 512 字节(早期限制,
EDNS0可以扩到 4096,超过还是要退回 TCP)、以及主从服务器之间的区域传送。另外 DoT 和 DoH 是把 DNS 跑在 TLS/HTTPS 上,为了隐私。常见追问:TTL 决定缓存多久,所以改了 DNS 记录不会立刻全网生效;
ping一个域名和浏览器访问它可能解析到不同 IP(CDN 按地理位置返回不同结果)。
29. ARP 和 NAT 在做什么?
ARP 解决”我知道下一跳的 IP,但链路层要 MAC 地址”。它广播问”谁是 192.168.1.1”,对应主机单播回自己的 MAC,结果进 ARP 缓存。关键理解是:跨网段通信时,ARP 查的是网关的 MAC,不是目标主机的 MAC——IP 头里的目的 IP 一路不变,但每一跳的 MAC 都在换。
NAT 解决 IPv4 地址不够:家里一堆设备共用一个公网 IP,路由器把出去的包的源 IP 和源端口改写成公网 IP 加一个新端口,并记下映射表,回来的包按表改回去。
NAT 带来两个要知道的后果:外部无法主动连进来(所以 P2P 需要打洞或中继);映射表项有超时,长连接空闲太久映射被回收,连接就静默失效了——这是应用层心跳的另一个现实理由,心跳同时也在保活 NAT 映射。
30. 网络不通,你怎么排查?
按分层从下往上问,每一层都有对应工具,先不要去抄内核参数改。
网络层通不通:
ping看 ICMP 能不能到,traceroute看在哪一跳断的。但 ping 通不代表端口通,ping 不通也不代表机器挂了——很多网络默认丢弃 ICMP。传输层连不连:
telnet ip port或nc -zv,通了说明三次握手成功、有程序在监听。服务端用ss -lnt确认是否真的在监听、监听在哪个地址(绑到127.0.0.1就只有本机能连,这是很常见的原因),ss -tan看连接状态分布。应用层答不答:看服务端日志有没有收到请求,抓包看请求是否完整发出、响应是否发回。
对着报错定位,而不是猜:
Connection refused是收到了 RST——端口没人监听或被防火墙拒绝,说明网络是通的;连接超时是压根没收到响应,可能是路由、防火墙静默丢包、或者全连接队列满;Connection reset是连接中途被 RST;Broken pipe是往已关闭的连接写。最后强调一句:“连上了但没回复”最容易被误判成网络问题,实际上多半是消息没发完整(缺少边界符或长度头)、服务端读了没处理、或者卡在慢业务上。这时候该看的是应用日志和
ss -tn里的Recv-Q——如果服务端 Recv-Q 一直有值,说明数据到了内核但应用没读。
第四部分 逐个机制详解
按数据流顺序排:从你的字节出发,一路到对端应用,再回来。每节只补细节,成品答案在第三部分(标注了 §2.x 就不重复)。
3.0 到底是谁和谁在说话
说”我的电脑给服务器发消息”,落到代码上,是你电脑里的一个进程向对面电脑里的一个进程发送字节。
客户端通常主动发起联系(浏览器、SSH 客户端);服务端通常等待联系、收到后处理并回复(网页服务、SSH 服务)。它们是通信中的角色,不是两种特殊的机器——同一台电脑可以同时跑客户端和服务端,本机实验就是这么做的。
一件完整的事至少四步,这四步是分开的:
flowchart LR
A[1. 我发出请求] --> B[2. 对方收到]
B --> C[3. 对方处理并发出回复]
C --> D[4. 我收到回复]
看图: “我按了发送但没看到回复”没法直接判断问题在哪一步。后面全部网络知识都是在帮你分清这四步各自怎么发生、各自怎么坏。面试里的排查题考的就是这个分步能力(口述见 §2.5 第 30 题)。
3.1 网络上传的不是一句话,而是字节
人看到 hello,程序处理的是编码后的字节。编码是把字符表示成数字的规则,UTF-8 是最常用的文本编码——一个字符不一定只占一个字节,常见汉字在 UTF-8 里是 3 个字节。
flowchart TB
A[输入 hello] --> B[UTF-8 编码]
B --> C[5 字节:68 65 6c 6c 6f]
C --> D[网络传输]
D --> E[对端 UTF-8 解码]
E --> F[显示 hello]
看图: 操作系统不知道这批字节是聊天还是下单。怎么解释数据完全由两端应用约定。 乱码经常不是丢包,而是两端按不同编码解释了同一批字节。
字节序:htons 到底在干什么
多字节的整数在内存里有两种排列方式:小端(低位字节在前,x86/ARM 常用)和大端(高位在前)。网络协议统一用大端,叫网络字节序。所以填端口、长度这类多字节字段前必须转换。
完整例子 1(本机验证过):
#include <arpa/inet.h>
#include <cstdint>
#include <cstdio>
int main() {
std::uint16_t port = 19090; // 0x4A92
std::uint16_t net = htons(port); // 转成网络字节序
auto dump = [](const char* tag, std::uint16_t v) {
const auto* p = reinterpret_cast<const unsigned char*>(&v);
std::printf("%-10s 值=%u 内存=%02x %02x\n", tag, v, p[0], p[1]);
};
dump("主机序", port);
dump("网络序", net);
std::printf("转回来: %u\n", ntohs(net));
}
在小端机器上输出:
主机序 值=19090 内存=92 4a
网络序 值=37450 内存=4a 92
转回来: 19090
注意”网络序”那行的值变成了 37450 —— 这不是端口变了,是同一批字节按主机序解释出来的另一个数。htons 改变的是内存里字节的排列,不是端口的含义。 四个函数记法:host / network、short(16 位,端口)/ long(32 位,IPv4 地址)。
这也是自定义协议长度头必须处理的地方——两端 CPU 字节序可能不同,长度字段不转换就会读出天文数字(见 §3.8)。
顺带记一下单位
下载器的 MB/s 是每秒多少百万字节,带宽的 Mb/s 是每秒多少百万比特,8 bit = 1 byte,所以 100 Mb/s 的带宽理论上限约 12.5 MB/s。MiB 是 1024×1024 字节,和十进制 MB 不同。实际速度还受协议开销和瓶颈影响。
3.2 地址:IP、端口、五元组
IP 地址用于在网络中寻址和转发,比如 192.168.1.20。它不是机器的永久编号——一台机器可以有多个网络接口、多个 IP,地址还会变。IPv6 用更长的格式。
端口是同一个 IP 上区分”哪个程序”的 16 位编号(1~65535)。服务端要绑定一个约定端口等着别人来;客户端的本地端口通常由内核自动挑(Linux 默认范围 net.ipv4.ip_local_port_range,约 32768~60999,也就是大约两万八千个)。
这是短连接压力测试打不上去的常见原因:客户端本地端口耗尽,而不是服务端撑不住。
一个端口怎么服务上万客户端
因为连接是用五元组区分的:协议 + 源 IP + 源端口 + 目的 IP + 目的端口。
flowchart LR
A["客户端 A:192.168.1.10:51001"] -->|连接 A| S["服务端:192.168.1.20:19090"]
B["客户端 B:192.168.1.11:52002"] -->|连接 B| S
C["客户端 B 的第二条:192.168.1.11:52003"] -->|连接 C| S
看图: 三条连接都用服务端的 19090,因为源 IP 或源端口不同,五元组就不同。服务端的并发连接数不受端口数限制(受 fd 上限和内存限制);受端口数限制的是同一个客户端连同一个服务端的连接数。
面经里常说”四元组”,那是省略了协议字段的说法,答”五元组”更准确,被问时补一句”不含协议时也叫四元组”。
一个特殊地址要知道:127.0.0.1 是回环地址,数据不出网卡,用于本机进程间通信——后面的实验就用它。绑到 0.0.0.0 表示监听本机所有地址,绑到 127.0.0.1 则只有本机能连,这是”服务起了但外面连不上”的头号原因。
3.3 域名和 DNS
example.com 这样的名字要先变成 IP 才能通信。
flowchart TB
A[浏览器缓存] -->|没有| B[系统缓存 / hosts]
B -->|没有| C[本地 DNS 解析器]
C -->|迭代:问一步给一步线索| D[根域名服务器]
D -->|.com 的地址| E[TLD 服务器]
E -->|权威服务器地址| F[权威域名服务器]
F -->|最终 A 记录| C
C -->|返回并缓存| A
看图,两种查询别搞反: 我到本地解析器是递归(“别管过程,给我最终答案”);本地解析器到根、TLD、权威是迭代(每步只给下一步线索)。
走 UDP 53:查询小、一问一答,省掉建连开销。响应超 512 字节(EDNS0 可扩到 4096)或做区域传送时用 TCP。TTL 决定缓存多久,所以改了记录不会立刻全网生效。
要点: 解析成功只说明拿到了地址,和”能连上""业务正常”是三件不同的事。CDN 会按地理位置给不同 IP,所以你 ping 到的 IP 可能和别人不同。口述见 §2.5 第 28 题。
3.4 数据怎么走到对面:路由、ARP、NAT
flowchart TB
A["你的程序<br/>目的 IP = 203.0.113.5"] --> B["查路由表<br/>同网段?还是走网关?"]
B -->|同网段| C["ARP 查目标主机 MAC"]
B -->|跨网段| D["ARP 查网关 MAC"]
C --> E[封成以太网帧发出]
D --> E
E --> F["家用路由器:NAT 改写源 IP 和端口"]
F --> G[运营商网络逐跳转发]
G --> H[目标主机]
看图,抓住一个关键对比:
- IP 头里的目的 IP 一路不变(这是端到端的地址)。
- 每一跳的 MAC 地址都在换(这是”下一跳”的地址)。
ARP 就是”我知道下一跳 IP,要它的 MAC”:广播问,单播答,结果进 ARP 缓存。跨网段时查的是网关的 MAC,不是目标主机的。
NAT 让一堆内网设备共用一个公网 IP:出去的包源 IP 和源端口被改写并记进映射表,回来的包按表改回。两个后果要记住:外部不能主动连进来(P2P 要打洞或中继);映射表项有超时,长连接空闲太久映射被回收,连接就静默失效——这是应用层心跳的另一个现实理由。
路径上的转发设备只看自己该看的那层:交换机看 MAC,路由器看 IP,谁都不理解你的业务。所以业务出错基本不该先怀疑路由器。
3.5 为什么有这么多协议
因为每层只解决一部分问题,各层可以独立替换。
flowchart TB
A["应用层:HTTP —— 请求什么资源,结果什么状态"] --> B["传输层:TCP —— 给哪个程序,保证有序可靠"]
B --> C["网络层:IP —— 跨网段怎么找到目标机器"]
C --> D["链路层:以太网和 ARP —— 同一段内送到下一跳"]
看图: 换 HTTP 为 SSH,下面三层不用改;把 TCP 换成 UDP,IP 层也不用改。这就是分层的价值。 各层职责和典型故障现象见 §0.2 的表。
3.6 TCP 凭什么可靠
TCP 提供的是面向连接的、有序的、可靠的字节流。“建立连接”是双方内核开始为这段关系维护状态(彼此地址端口、序号、窗口、缓冲区),不是拉了根新线,也不是自动给你分配了业务线程。
3.6.1 序号、确认、重传
flowchart TB
A["给每个字节编号"] --> B["接收方按序号:去重 + 重排序"]
B --> C["回 ACK:到这个位置前我都收到了(累积确认)"]
C --> D{"发送方等到 ACK 了吗"}
D -->|等到| E[窗口左移,丢弃重传副本]
D -->|RTO 超时| F[重传]
D -->|收到 3 个重复 ACK| G[快重传,不等超时]
看图,四个要点:
- 编号是按字节的,不是按包。
- 累积确认:ACK = 3001 表示 3000 之前全收到了,不是”只收到第 3000 个”。
- RTO 是动态算的,根据测量的 RTT 及其波动估计,不是固定值。
- 去重和重排序是 TCP 自己做的,所以你的应用永远不会看到乱序或重复的字节——这也是为什么”TCP 会乱序”是错的说法。
所以应用看到的一定是有序无重复的字节流;代价是队头阻塞——中间一段没到,后面的字节即使到了也不能交给应用(这正是 HTTP/2 在 TCP 上仍有队头阻塞的根源,见 §3.17)。
3.6.2 滑动窗口与流量控制
发一个等一个的话,吞吐被 RTT 死死限制。滑动窗口允许连续发出一批未确认数据。
flowchart LR
A["已发送且已确认<br/>(可丢弃)"] --> B["已发送未确认<br/>(要留着可能重传)"]
B --> C["可以立即发送<br/>(窗口内剩余额度)"]
C --> D["还不能发<br/>(超出窗口)"]
看图: 收到 ACK,左边界右移;rwnd 允许,右边界右移。能发的未确认数据量 = min(cwnd, rwnd)。
rwnd由接收方在 ACK 里通告,反映它接收缓冲区的剩余空间 → 流量控制,保护接收方。cwnd由发送方自己维护,反映它对网络容量的估计 → 拥塞控制,保护网络。
两个边界:零窗口时发送方停发,启动持续计时器周期发窗口探测(不能干等,因为通告窗口的 ACK 本身可能丢);糊涂窗口综合症——接收方一腾出几字节就通告、发送方就发几字节,头部开销远超载荷,解法是接收方攒够再通告、发送方用 Nagle。
16 位窗口字段最大 65535 字节,高带宽长延迟链路不够,所以有窗口缩放选项,握手时协商。
3.6.3 拥塞控制
flowchart TB
S["慢启动:cwnd 从 1 MSS 起,每 RTT 翻倍(指数)"] -->|到达 ssthresh| A["拥塞避免:每 RTT 加 1 MSS(线性)"]
A -->|收到 3 个重复 ACK| F["快重传:立刻重发丢的那段"]
F --> R["快恢复:ssthresh = cwnd/2,cwnd 降到 ssthresh,转线性"]
R --> A
A -->|RTO 超时| T["ssthresh = cwnd/2,cwnd 打回 1,重新慢启动"]
T --> S
看图,关键是两种丢包的处理力度完全不同:
| 信号 | 推断 | 处理 |
|---|---|---|
| 3 个重复 ACK | 丢了一个,但后面的还在到 → 网络还通 | 快重传 + 快恢复,cwnd 减半 |
| RTO 超时 | 连重复 ACK 都没有 → 可能真堵死了 | cwnd 打回 1,重新慢启动 |
“慢启动”的”慢”指起点低,不是增长慢——它是指数增长的。
现代实际情况必须说: 上面是经典 Reno 的描述,Linux 默认算法是 CUBIC,用三次函数控制增长,高带宽长延迟下表现更好;还有 BBR,不靠丢包判断拥塞,而是主动测量瓶颈带宽和最小 RTT——因为在带大缓冲的设备上,等到丢包时延迟已经很高了(缓冲区膨胀)。ss -i 能看到每条连接实际用的算法、cwnd 和 RTT。
口述见 §2.3 第 10、11 题。
3.7 建立连接:三次握手与两个队列
sequenceDiagram
participant C as 客户端
participant SQ as 服务端半连接队列
participant AQ as 服务端全连接队列
participant A as 服务端应用
C->>SQ: ① SYN,初始序号 x
Note over SQ: 连接进入 SYN_RCVD
SQ-->>C: ② SYN+ACK,初始序号 y,确认 x+1
C->>AQ: ③ ACK,确认 y+1
Note over AQ: 连接变 ESTABLISHED,排队等 accept
A->>AQ: accept() 取走一条
Note over A: 拿到连接 fd,开始收发
看图,三个必须记住的点:
- SYN 占一个序号,所以确认值是
x+1、y+1。 - 握手由内核完成——你不需要在 C++ 里手写三次
send。客户端调connect,服务端事先listen,然后accept。 accept不是”发握手第二个包的函数”,它是从全连接队列里取走一条已经建好的连接。握手和应用取连接是两件事——这就是为什么服务端应用卡住时,客户端仍然能”连接成功”。
两个队列的关键参数:
| 队列 | 存什么状态 | 大小由什么决定 | 满了会怎样 |
|---|---|---|---|
| 半连接队列(SYN queue) | SYN_RCVD | tcp_max_syn_backlog | 丢新 SYN;客户端表现为连接超时 |
| 全连接队列(accept queue) | ESTABLISHED 待 accept | min(backlog, somaxconn) | 默认丢第三次 ACK;客户端以为连上了却没反应 |
ss -lnt 里监听 socket 那行的 Send-Q 是全连接队列上限、Recv-Q 是当前排队数——Recv-Q 持续接近 Send-Q 说明应用 accept 不够快。
SYN flood 就是打半连接队列:伪造源 IP 大量发 SYN,服务端回 SYN+ACK 并占位,第三次 ACK 永不到来。防护主要靠 SYN cookie(把连接信息编码进初始序号,队列满时不占位),代价是这条路径放不下 TCP 选项。口述见 §2.2 第 1~3 题。
3.8 传输中:消息边界、Nagle、MSS
3.8.1 一次 send 不等于一次 recv
客户端先后发 hello 和 world,接收端一定按顺序拿到 helloworld,但一次读取拿到多少不由发送次数决定:
flowchart TB
A["发送端两次提交:hello,world"] --> B["TCP 字节流:helloworld"]
B --> C["可能一:读到 hello,再读到 world"]
B --> D["可能二:读到 he,再读到 lloworld"]
B --> E["可能三:一次读到 helloworld"]
看图: 三条是不同的可能结果,不是数据被发了三遍。程序必须能应对任意的分段和合并,不能根据一次 read 猜消息刚好结束。
面试里说的”粘包、拆包”讨论的就是这个,但 TCP 没有把内容粘坏——它从来不承诺保留边界。
3.8.2 三种消息边界方案
| 方案 | 做法 | 优点 | 坑 |
|---|---|---|---|
| 定长 | 每条消息固定 N 字节 | 最简单 | 浪费,且长度一变就不兼容 |
| 分隔符 | 用 \n 结束一条消息 | 可读、易调试 | 正文可能含分隔符,要转义 |
| 长度头 | 4 字节长度 + 正文 | 最通用,二进制友好 | 必须校验上限 + 处理字节序 |
完整例子 2——长度头的封包与解包,把三个坑都处理了(本机验证过):
#include <arpa/inet.h>
#include <cstdint>
#include <cstring>
#include <iostream>
#include <optional>
#include <string>
#include <vector>
constexpr std::uint32_t kMaxBody = 1 << 20; // 1 MiB 上限,必须有
// 封包:4 字节网络序长度 + 正文
std::vector<char> encode(const std::string& body) {
std::uint32_t len = htonl(static_cast<std::uint32_t>(body.size()));
std::vector<char> out(4 + body.size());
std::memcpy(out.data(), &len, 4);
std::memcpy(out.data() + 4, body.data(), body.size());
return out;
}
// 解包:从累积缓冲里尽可能取出一条完整消息
// 返回 nullopt 表示还不够,需要继续收
std::optional<std::string> decode(std::string& buf, bool& fatal) {
fatal = false;
if (buf.size() < 4) return std::nullopt; // 连长度头都不全
std::uint32_t net = 0;
std::memcpy(&net, buf.data(), 4);
std::uint32_t len = ntohl(net); // 必须转字节序
if (len > kMaxBody) { // 绝不照着对方的长度分配
fatal = true;
return std::nullopt;
}
if (buf.size() < 4 + len) return std::nullopt; // 半包,等下次
std::string body = buf.substr(4, len);
buf.erase(0, 4 + len); // 只消费这一条
return body;
}
int main() {
// 模拟:两条消息被网络任意切成三段到达
auto a = encode("hello");
auto b = encode("world!!");
std::string wire(a.begin(), a.end());
wire.append(b.begin(), b.end());
std::string buf;
const std::size_t cuts[] = {3, 7, wire.size()}; // 故意切在长度头中间
std::size_t pos = 0;
for (std::size_t cut : cuts) {
buf.append(wire, pos, cut - pos); // 又收到一段
pos = cut;
std::cout << "累计收到 " << cut << " 字节:";
bool fatal = false;
int got = 0;
while (auto msg = decode(buf, fatal)) { // 一段里可能有多条
std::cout << "[" << *msg << "] ";
++got;
}
if (fatal) { std::cout << "长度非法,断开\n"; return 1; }
if (got == 0) std::cout << "还不完整";
std::cout << '\n';
}
}
输出:
累计收到 3 字节:还不完整
累计收到 7 字节:还不完整
累计收到 20 字节:[hello] [world!!]
这段代码体现了四条必须做对的事:
while循环解包——一次读取的缓冲里可能有多条完整消息加最后半条,只解一条会让剩下的一直延迟。- 半包时把已收字节留在缓冲区,不能丢。
- 长度字段过网络必须转字节序(
htonl/ntohl)。 - 必须校验长度上限——
len是对端给的,直接拿它分配内存就是一个远程内存耗尽漏洞。
3.8.3 Nagle、延迟 ACK 与 MSS
MSS 是 TCP 单段能带的最大应用数据量,握手时双方各自通告,以太网典型 1500 - 20 - 20 = 1460。让 TCP 按 MSS 切,就不会触发 IP 分片。
Nagle 攒小包(有未确认的小包在路上时,新小数据先等),延迟 ACK 攒确认(等最多约 40ms 看能否捎带)。两个都开着会互相等,造成规律的 40ms 延迟阶梯,在”小请求小响应”的 RPC 上特别明显。
应对:setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, ...) 关 Nagle;更根本的是应用层别把一个逻辑消息拆成多次 send——攒好一次写出,或用 writev 聚集写,Nagle 本来就不会触发。
口述见 §2.3 第 12、13、14 题。
3.9 关闭连接:四次挥手、状态机、RST
3.9.1 两个方向分别结束
sequenceDiagram
participant C as 客户端(主动关闭方)
participant S as 服务端(被动关闭方)
C->>S: ① FIN —— 我不再发送了
Note over C: FIN_WAIT_1
S-->>C: ② ACK
Note over S: CLOSE_WAIT(等应用调 close)
Note over C: FIN_WAIT_2
Note over S: 这期间它还可以继续发数据给我
S-->>C: ③ FIN —— 我也发完了(应用 close 触发)
Note over S: LAST_ACK
C->>S: ④ ACK
Note over C: TIME_WAIT,等 2MSL
看图,两件事:
- 第②和第③之间隔着”对端还要发多久”,所以 ACK 和 FIN 合不了——这就是四次的原因。被动方确实没数据要发时可能合并成三个包,所以”四次挥手”不等于”一定看到四个独立包”。
- 从
CLOSE_WAIT走到LAST_ACK,靠的是应用调close()。这一步是应用的责任,内核不会替你做。
完整状态机和排查对照表见 §0.4——那张图是这一节的浓缩,建议直接背。
3.9.2 recv 返回值与 RST
| 情况 | 表现 | 该做什么 |
|---|---|---|
对端 shutdown/close | recv 返回 0 | 正常结束,收尾并 close(漏了就是 CLOSE_WAIT 堆积) |
| 非阻塞暂时没数据 | 返回 -1,errno == EAGAIN/EWOULDBLOCK | 正常,回去等下一次事件 |
| 被信号打断 | 返回 -1,errno == EINTR | 重试 |
| 收到 RST | 返回 -1,errno == ECONNRESET | 连接已没了,清理 |
| 往已被 RST 的连接写 | 触发 SIGPIPE,默认杀进程 | signal(SIGPIPE, SIG_IGN),靠 send 的 EPIPE 处理 |
recv 返回 0 不是”暂时没消息”——这是初学最容易搞错的一条。
RST 的三个典型来源: 连到没有程序监听的端口(表现为 Connection refused);连接已被内核销毁后又收到数据;对端设了 SO_LINGER 为 0 后 close(强制丢弃缓冲区并发 RST)。
3.9.3 close、shutdown 与优雅关闭
close 关的是 fd,引用计数归零才发 FIN;shutdown 关的是连接方向,不看引用计数。
优雅关闭的标准三步:
1. shutdown(fd, SHUT_WR) 告诉对端"我发完了"(发 FIN,但还能收)
2. 继续 recv 直到返回 0 把对端最后的数据收干净
3. close(fd) 释放描述符
为什么不能直接 close: 如果接收缓冲区里还有没读的数据,内核会发 RST 而不是 FIN,对端已经发出的数据就丢了。口述见 §2.2 第 4~8 题。
3.9.4 突然断线:为什么必须有超时和心跳
对端拔网线或断电时,它没机会发 FIN。 你这边的连接仍是 ESTABLISHED,recv 一直阻塞,什么也不会发生。
所以必须主动探测。内核 keepalive 默认空闲 7200 秒才开始探测(tcp_keepalive_time),默认配置下几乎等于没有;应用层心跳的间隔可控,还能穿过丢弃探测包的中间设备,更重要的是能证明对端业务线程还在处理消息,而不只是内核协议栈有响应。
两条边界必须记住:心跳超时只说明约定期限内没收到响应,不证明对端已永久死亡;重连只重建通信关系,不恢复业务——旧请求可能已执行只是响应丢了,盲目重发会重复执行,有副作用的操作必须有请求 ID 和幂等去重。口述见 §2.3 第 16 题。
3.10 UDP:什么时候不要 TCP
UDP 是无连接的数据报服务:sendto 一次对应对端 recvfrom 一次,保留消息边界,不排序、不重传、不去重、没有流控和拥塞控制。
flowchart LR
A["sendto 一个 1000 字节数据报"] --> B[IP 层]
B --> C["对端 recvfrom 拿到完整的 1000 字节<br/>或者压根没拿到"]
看图: UDP 不粘包——要么完整拿到一个数据报,要么完全拿不到,不会拿到半个。这是它和 TCP 最实质的区别之一。
用 UDP 时要自己处理的三件事:
- 包大小:UDP 自己不分段,超过 MTU 就交给 IP 分片,丢一片整包丢。所以单包尽量 ≤
1500 - 20 - 8 = 1472字节。 - 可靠性:需要的话得自己做序号、确认、重传——这不是消失了,是搬到你的代码里了,通常做得不如内核。
- 拥塞:没有
cwnd,一个失控的 UDP 程序能把链路打满,影响同网络的所有人。
谁在用: DNS(小查询,一问一答)、实时音视频(迟到的数据不如丢掉)、游戏状态同步、QUIC/HTTP3(在 UDP 上重做了一整套可靠传输,为了绕开 TCP 的队头阻塞和握手成本)。对照表见 §1.2。
3.11 socket 与 fd
socket 是内核提供的通信端点;fd(文件描述符)是当前进程引用这个端点用的非负整数编号。
先把 fd 理解成”在这个进程里操作这项资源的编号”。文件、管道、epoll 实例也都是 fd。fd 不是端口,不是全局 ID,关闭后编号会被复用——最后这条是并发编程里的一个真坑(见 §3.15)。
监听 fd 与连接 fd 是两回事
flowchart TB
L["监听 socket:fd 3,绑定 127.0.0.1:19090"]
L -->|accept 取出一条| A["连接 socket:fd 4"]
L -->|accept 再取一条| B["连接 socket:fd 5"]
A <-->|收发字节| C[客户端甲]
B <-->|收发字节| D[客户端乙]
看图: fd 3 继续接新连接,4 和 5 各自和一个客户端收发。 新连接不会把监听入口变成通信通道。编号只是示例。
从函数名到实际动作
| 调用 | 实际做了什么 | 容易误解成 |
|---|---|---|
socket() | 创建一个端点,拿到 fd | 已经连上了 |
bind() | 把本地地址和端口绑到这个 socket | 已经开始监听 |
listen() | 标记为监听,设置全连接队列大小 | 最多几个人在线 |
accept() | 从全连接队列取走一条已建好的连接,返回新 fd | 参与握手 / 发第二个包 |
connect() | 发起握手(阻塞模式下等到完成) | 已经发了数据 |
send() | 把字节拷进内核发送缓冲区 | 对端已收到 |
recv() | 从内核接收缓冲区取字节 | 收到一条完整消息 |
close() | fd 引用计数减一,归零才发 FIN | 立刻发 FIN |
这张表是很多”以为写对了”的 bug 的根源,尤其是 send 和 accept 那两行。
3.12 三层缓冲区:数据到了哪里
flowchart TB
A["应用发送缓冲区(你自己的 string / vector)"] -->|send| B["内核发送缓冲区(SO_SNDBUF)"]
B -->|TCP 按窗口和拥塞控制发出| N[网络]
N --> C["内核接收缓冲区(SO_RCVBUF)"]
C -->|recv| D["应用接收缓冲区(用于拼半包)"]
看图,四个结论:
send返回 n 只表示”拷进了内核发送缓冲区 n 字节”,真正何时发出由 TCP 的窗口和拥塞控制决定。- 数据可以先到对端内核缓冲区,应用稍后再读——所以”应用没读”和”数据没到”是两回事。
ss -tn里服务端Recv-Q一直有值,就说明数据到了内核但应用没读,这是排查”连上了没回复”的关键线索。 - 非阻塞下
send遇到EAGAIN,剩余数据必须存进应用层发送缓冲区,注册EPOLLOUT等可写再继续——这就是为什么每条连接都需要一个应用层写缓冲区。 - 每条连接的内核缓冲区都要占内存(默认几十到几百 KB,
tcp_rmem/tcp_wmem自动调整)。一万条连接就是几个 GB,这常常是”一万连接”真正的内存瓶颈,比线程栈更值得先算。
3.13 五种 I/O 模型
把一次读拆成两个阶段看,这四个词就不会绕了:
flowchart LR
A["阶段一:等数据到达内核缓冲区"] --> B["阶段二:内核 → 用户态拷贝"]
| 模型 | 阶段一 | 阶段二 | 同步/异步 |
|---|---|---|---|
| 阻塞 I/O | 阻塞 | 阻塞 | 同步 |
| 非阻塞 I/O(轮询) | 不阻塞,反复试 | 阻塞 | 同步 |
| I/O 多路复用 | 阻塞在 epoll_wait | 阻塞 | 同步 |
| 信号驱动 I/O | 不阻塞,信号通知 | 阻塞 | 同步 |
异步 I/O(io_uring、IOCP) | 不阻塞 | 不阻塞 | 异步 |
前四种都是同步的,因为阶段二那次拷贝都得你自己等着完成。epoll 属于同步 I/O 多路复用——它只帮你等和报告就绪,字节还是你自己 read 的。这句是面试的得分点(口述见 §2.4 第 17 题)。
阻塞也要说清:等网络数据时线程通常睡眠,让 CPU 去跑别的可运行线程,不是原地空转,也不是所有线程一起停住。
3.14 一个线程照顾多个连接
3.14.1 为什么”顺序处理”不行
只有一条执行流程时,如果写成”先读甲,再读乙”:
flowchart TB
A[线程开始] --> B[阻塞读取甲的数据]
B --> C[处理甲]
C --> D[读取乙的数据]
D --> E[处理乙]
X[甲一直不发送] -.->|B 不返回,后面走不到| B
看图: 乙的数据可能早就到了内核,但这条线程还没走到读乙的代码。这就是阻塞模型的根本问题——不是数据没到,是没人去取。
一个直观改法是每连接一线程,但线程随连接数线性增长,栈、调度和上下文切换成本都上去了。(不要背”每线程一定 8 MiB”——那是虚拟地址空间的默认栈上限,实际提交的物理内存按需。)
3.14.2 换等待方式:I/O 多路复用
flowchart TB
A[连接 A:暂时无数据] --> W["内核维护的关注集合"]
B[连接 B:有数据到达] --> W
C[连接 C:暂时无数据] --> W
W -->|本次只返回 B 可读| T[一个 I/O 线程]
T --> R[读 B,推进它能推进的部分]
R -->|回去等下一批| W
看图: 一个线程依次处理当前就绪的连接,不是同时执行一万段代码。空闲连接仍然保留,但不用各配一个等待线程——这才是 epoll 解决的问题。
| API | 干什么 |
|---|---|
epoll_create1 | 创建 epoll 实例(本身也是一个 fd) |
epoll_ctl | 增/改/删关注的 fd 和事件(EPOLL_CTL_ADD/MOD/DEL) |
epoll_wait | 等待并取回一批就绪事件,或超时/出错 |
epoll 不替你读字节、不切消息、不处理业务,它只参与等待和报告就绪。三者对比和”为什么快”见 §1.3,口述见 §2.4 第 18 题。接口细节见 epoll(7)。
3.14.3 为什么还必须非阻塞
内核说 B 可读,你读完已有数据后再读一次——如果 fd 还是阻塞模式,这一次就会把整个线程停在 B 上,其他连接全部饿死。
非阻塞表示”没法推进就立刻返回”,socket 上用 EAGAIN/EWOULDBLOCK 表示。epoll 帮你等到合适的时机,非阻塞帮你在没有进展时及时回来,两者是配套的。
反过来也别走极端:不要写”不停对所有连接 recv,失败就立刻重试”的忙轮询去代替等待,那是纯烧 CPU。
3.14.4 LT、ET 与惊群
LT 只要缓冲区还有数据就反复通知;ET 只在状态变化时通知一次,必须循环读到 EAGAIN,且必须配非阻塞。ET 写错的典型症状是数据卡在缓冲区里、连接像假死。详细对照见 §1.4。
写侧同样要注意:写不完就注册 EPOLLOUT,写完要及时取消关注——否则可写事件会一直触发,造成忙唤醒和 CPU 偏高。
惊群:多个线程 epoll_wait 同一个 listen fd,来一个连接全部被唤醒但只有一个能 accept 成功。解法是 EPOLLEXCLUSIVE(Linux 4.5+)或 SO_REUSEPORT(每个线程独立 listen socket,内核做负载均衡,nginx 用的就是这个)。注意区分:accept 系统调用本身的惊群早就在内核解决了,现在说的是 epoll 层面的。口述见 §2.4 第 19、20 题。
3.15 Reactor、线程池与背压
事件循环是反复”等待 → 取事件 → 处理 → 再等待”;Reactor 是把事件等待和分发组织起来的模式。epoll 是接口,Reactor 是组织方式,线程是执行流程——三个层次别混。
三种形态和演进理由见 §1.8,一句话版本:单 Reactor 单线程(Redis,慢命令阻塞所有人)→ 单 Reactor + 线程池(解决慢业务,I/O 线程仍是瓶颈)→ 主从 Reactor 多线程(muduo/Netty,one loop per thread)。
慢业务:epoll 救不了
I/O 线程取到消息后原地算十秒,它管的其他连接就等十秒。把慢任务交给有界线程池,但要清楚这只是换地方执行、没有消除成本,并且引入三个必须处理的问题:
sequenceDiagram
participant C as 客户端
participant I as I/O 线程
participant Q as 有界任务队列(加锁)
participant W as 工作线程
C->>I: 完整请求到达
I->>Q: 提交任务(队列满就拒绝 → 背压)
Note over I: 立刻回去处理其他连接
Q->>W: 取出任务
W->>W: 执行慢计算
W-->>I: 结果入队 + 唤醒 I/O 线程
I-->>C: 若连接仍有效,才发送响应
看图,三个坑:
- 跨线程交接要同步。 工作线程不能直接改 I/O 线程拥有的连接状态,结果要送回 I/O 线程去写。
- 结果回来时连接可能已断,而 fd 会被复用。 拿旧 fd 号直接写,可能把数据写给一个完全不相干的新连接。正确做法是持连接对象的
weak_ptr,或带一个单调递增的连接序号做校验。 - 必须有背压。 任务队列要设上限,满了就拒绝;发送积压超阈值要限制甚至断开慢连接。无界队列等于把内存当缓冲,结局是 OOM;固定几个线程也不会自动消化无限请求。
“支持一万连接”怎么答见 §2.4 第 23 题——先问活跃比例、消息大小、请求速率、处理耗时,再看 fd 上限、内核缓冲内存(见 §3.12)、CPU 和 P99 延迟。
3.16 零拷贝
场景:把一个文件的内容发到网络上。
flowchart LR
subgraph T["传统 read + write:4 次拷贝,4 次上下文切换"]
direction TB
A1[磁盘] -->|DMA| A2[内核页缓存]
A2 -->|CPU 拷贝| A3[用户缓冲区]
A3 -->|CPU 拷贝| A4[socket 发送缓冲]
A4 -->|DMA| A5[网卡]
end
subgraph S["sendfile:2 次拷贝,2 次切换,全程不进用户态"]
direction TB
B1[磁盘] -->|DMA| B2[内核页缓存]
B2 -->|CPU 拷贝<br/>SG-DMA 可省| B3[socket 发送缓冲]
B3 -->|DMA| B4[网卡]
end
T ~~~ S
看图: 中间那两次 CPU 拷贝纯属多余——数据根本没被用户态改过。
| 方式 | 数据拷贝 | 上下文切换 | 说明 |
|---|---|---|---|
read + write | 4 | 4 | 基准 |
mmap + write | 3 | 4 | 省掉页缓存到用户缓冲那次 |
sendfile | 2 | 2 | 全程内核态;配 SG-DMA 可到 1 次 CPU 拷贝都不用 |
splice | 2 | 2 | 通过管道在两个 fd 间移动 |
“零”指的是不经过用户态、CPU 不参与搬运,不是真的 0 次数据移动。
边界必须说:sendfile 只适合”原样转发”——一旦要在用户态修改内容(加密、压缩、改写响应体)就用不上了(TLS 要靠 kTLS 才能配合)。Kafka 传日志段、nginx 发静态文件都用它。口述见 §2.4 第 24 题。
3.17 HTTP 与 HTTPS 怎么接上前面的知识
sequenceDiagram
participant B as 浏览器
participant D as DNS
participant S as 网站服务端
B->>D: 查询域名(UDP 53,逐级缓存)
D-->>B: 返回 IP
B->>S: TCP 三次握手
B->>S: TLS 握手:验证证书,协商对称密钥
B->>S: 加密的 HTTP 请求
S-->>B: 加密的 HTTP 响应
Note over B,S: 连接复用、缓存、CDN 会改变实际过程
看图,分工很清楚: DNS 找地址,TCP 提供可靠字节流,TLS 提供加密和身份验证,HTTP 规定怎么请求和响应。HTTP/3 用 QUIC over UDP,不套用这个顺序(握手合并了)。
HTTP 是应用层的请求响应规则。要能区分两类错误:HTTP 404 说明已经有应用层响应了,和”TCP 连接被拒绝”完全不是一类问题;收到 200 也要看响应体才知道业务是否成功。
TLS/HTTPS 的核心是”非对称加密协商出对称密钥,之后用对称密钥加密数据”——非对称安全但慢,对称快但要先有共享密钥。TLS 1.2 需 2 RTT,TLS 1.3 只需 1 RTT,会话复用可 0-RTT(但 0-RTT 数据有重放风险,只适合幂等请求)。证书验证四件事:签名链、域名匹配、有效期、吊销状态——加密和身份验证是两件事,只加密不验证身份,中间人可以直接冒充。
长连接是建立后复用,不是特殊型号的 socket,也不是永不掉线——服务端可能按策略关闭空闲连接,NAT 映射也可能超时(见 §3.4)。
三个版本的对比见 §1.11,得分点是”HTTP/2 只解决了 HTTP 层的队头阻塞,TCP 层的还在”。口述见 §2.5 第 25~27 题。HTTP 语义参见 RFC 9110,TCP 规范见 RFC 9293。
第五部分 动手与排查
4.1 实验一:让两个程序在你自己的电脑上说话
目标:建立一条 TCP 连接,发一条用换行结束的文本,服务端回 received: 加原文,然后退出。
服务端用 C++,客户端用 Python 标准库——少读一份系统调用代码,先把网络行为看清。网络规则和客户端用什么语言无关。
绑定 127.0.0.1:19090,只用于本机。故意用阻塞 I/O、不做并发和超时:客户端不发换行服务端就会一直等,这正是要观察的行为。卡住按 Ctrl+C。需要 C++17 和 Python 3,Linux 和 macOS 都能跑(后面的 epoll 才是 Linux 专有)。
server.cpp
#include <arpa/inet.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cerrno>
#include <csignal>
#include <cstdio>
#include <iostream>
#include <string>
int main() {
std::signal(SIGPIPE, SIG_IGN); // 否则往断开的连接写会被杀掉
int listener = socket(AF_INET, SOCK_STREAM, 0);
if (listener == -1) {
std::perror("socket");
return 1;
}
int enabled = 1;
if (setsockopt(listener, SOL_SOCKET, SO_REUSEADDR,
&enabled, sizeof(enabled)) == -1) {
std::perror("setsockopt");
close(listener);
return 1;
}
sockaddr_in address{};
address.sin_family = AF_INET;
address.sin_port = htons(19090); // 端口要转网络字节序
inet_pton(AF_INET, "127.0.0.1", &address.sin_addr);
if (bind(listener, reinterpret_cast<sockaddr*>(&address),
sizeof(address)) == -1 || listen(listener, 8) == -1) {
std::perror("bind/listen");
close(listener);
return 1;
}
std::cout << "waiting on 127.0.0.1:19090" << std::endl;
int client = accept(listener, nullptr, nullptr); // 从全连接队列取一条
if (client == -1) {
std::perror("accept");
close(listener);
return 1;
}
close(listener);
std::cout << "connected; waiting for a newline" << std::endl;
std::string message; // 读到换行才算一条完整消息
while (message.size() < 4096) {
char ch = 0;
ssize_t n = recv(client, &ch, 1, 0);
if (n == -1 && errno == EINTR) {
continue; // 被信号打断,重试
}
if (n <= 0) { // 0 = 对端关闭;-1 = 出错
std::cerr << "connection ended before a complete message\n";
close(client);
return 1;
}
message.push_back(ch);
if (ch == '\n') {
break;
}
}
if (message.empty() || message.back() != '\n') {
std::cerr << "message too long\n";
close(client);
return 1;
}
std::string reply = "received: " + message;
std::size_t sent = 0;
while (sent < reply.size()) { // send 可能短写,必须循环
ssize_t n = send(client, reply.data() + sent, reply.size() - sent, 0);
if (n == -1 && errno == EINTR) {
continue;
}
if (n <= 0) {
std::cerr << "send failed\n";
close(client);
return 1;
}
sent += static_cast<std::size_t>(n);
}
close(client);
std::cout << "reply sent; server finished" << std::endl;
return 0;
}
| 代码里的东西 | 意思 |
|---|---|
AF_INET | IPv4 地址族 |
SOCK_STREAM | 流式 socket;与 IPv4 和默认协议组合就是 TCP |
sockaddr_in | 保存 IPv4 地址和端口的结构体 |
address{} | 值初始化,避免遗留字段是垃圾值 |
htons | 端口转网络字节序(见 §3.1) |
inet_pton | 可读 IP 字符串 → 二进制地址 |
reinterpret_cast<sockaddr*> | 按 bind 要求的通用地址类型传入 |
SO_REUSEADDR | 允许 bind 处于 TIME_WAIT 的地址,服务重启不用等 60 秒;不是允许抢别人端口 |
listen(listener, 8) | 设监听并把全连接队列上限设为 8;不是”最多 8 人在线” |
ssize_t | 能同时表达字节数和 -1 错误的有符号类型 |
EINTR | 调用被信号打断,应该重试 |
SIGPIPE | 往已关闭的连接写会收到;忽略它,改看 send 返回的 EPIPE |
两点说明: 一次只读一个字节是为了清楚展示”直到换行才算完整”,不是推荐的高性能读法(正常应该一次读一块进缓冲区再切分,见 §3.8 的例子 2)。4096 是含换行的上限。
client.py
import socket
with socket.create_connection(("127.0.0.1", 19090), timeout=5) as client:
client.sendall(b"hello\n")
reply = bytearray()
while not reply.endswith(b"\n"): # 同样不能只 recv 一次
chunk = client.recv(1024)
if not chunk: # 空字节串 = 对端关闭
raise RuntimeError("connection ended before a complete reply")
reply.extend(chunk)
if len(reply) > 8192:
raise RuntimeError("reply too long")
print(reply.decode("utf-8"), end="")
跑起来
终端 A:
c++ -std=c++17 -Wall -Wextra -Wpedantic server.cpp -o server && ./server
看到 waiting on 127.0.0.1:19090 后停住——这不是卡死,是阻塞在 accept 里等连接。
终端 B:
python3 client.py
应输出 received: hello。终端 A 随后打印完成提示并退出。每次实验前重启服务端。
改三次,观察三件事
| 改动 | 现象 | 说明了什么 |
|---|---|---|
| 不启动服务端,直接跑客户端 | Connection refused | 端口没人监听,内核回了 RST;说明网络是通的(见 §3.9.2) |
客户端改发 b"hello",不带换行 | 服务端显示已连接但一直等,客户端 5 秒超时 | 连接成功 ≠ 消息完整;这是”连上了没回复”的最小复现 |
拆成两次发:b"he" 然后 b"llo\n" | 仍然得到完整的 received: hello | TCP 是字节流;但不能据此断定底层恰好发生了两次 recv(见 §3.8.1) |
边界输入再试三个:b"\n"(空正文,允许)、b"a"*4095 + b"\n"(正好到上限,允许)、b"a"*4096(无换行,应被拒绝)。
4.2 实验二:epoll 版回声服务器
⚠️ 这份代码是 Linux 专有的(
epoll只有 Linux 有,BSD/macOS 对应kqueue)。验证情况要说清:实验一是在本机真跑通的(客户端确实收到了received: hello);这一份只做了语法和类型检查,没有在真的 epoll 上跑过——请在 Linux 上编译运行确认后再依赖它。结构是标准的单 Reactor 单线程 + LT 模式。
#include <arpa/inet.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <unistd.h>
#include <fcntl.h>
#include <cerrno>
#include <csignal>
#include <cstdio>
#include <cstring>
#include <iostream>
#include <string>
#include <unordered_map>
static bool set_nonblocking(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
return flags != -1 && fcntl(fd, F_SETFL, flags | O_NONBLOCK) != -1;
}
int main() {
std::signal(SIGPIPE, SIG_IGN);
int listener = socket(AF_INET, SOCK_STREAM, 0);
int on = 1;
setsockopt(listener, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(19090);
inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);
if (bind(listener, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) == -1
|| listen(listener, 128) == -1 || !set_nonblocking(listener)) {
std::perror("bind/listen");
return 1;
}
int ep = epoll_create1(0);
epoll_event ev{};
ev.events = EPOLLIN; // LT 模式:不加 EPOLLET
ev.data.fd = listener;
epoll_ctl(ep, EPOLL_CTL_ADD, listener, &ev);
std::unordered_map<int, std::string> inbuf; // 每连接一个读缓冲,拼半包
epoll_event events[64];
std::cout << "epoll echo server on 127.0.0.1:19090\n";
while (true) {
int n = epoll_wait(ep, events, 64, -1);
if (n == -1) {
if (errno == EINTR) continue;
std::perror("epoll_wait");
break;
}
for (int i = 0; i < n; ++i) {
int fd = events[i].data.fd;
if (fd == listener) { // 新连接:循环 accept 到 EAGAIN
while (true) {
int c = accept(listener, nullptr, nullptr);
if (c == -1) break; // EAGAIN:这一批接完了
set_nonblocking(c);
epoll_event cev{};
cev.events = EPOLLIN | EPOLLRDHUP; // 关注对端关闭
cev.data.fd = c;
epoll_ctl(ep, EPOLL_CTL_ADD, c, &cev);
inbuf[c]; // 建立该连接的缓冲
}
continue;
}
// 对端关闭或出错:必须 close,否则就是 CLOSE_WAIT 堆积
if (events[i].events & (EPOLLHUP | EPOLLERR | EPOLLRDHUP)) {
epoll_ctl(ep, EPOLL_CTL_DEL, fd, nullptr);
inbuf.erase(fd);
close(fd);
continue;
}
if (events[i].events & EPOLLIN) {
char buf[4096];
ssize_t r = recv(fd, buf, sizeof(buf), 0);
if (r == 0) { // 对端 FIN —— 正常结束
epoll_ctl(ep, EPOLL_CTL_DEL, fd, nullptr);
inbuf.erase(fd);
close(fd); // 漏掉这行就是 CLOSE_WAIT
continue;
}
if (r == -1) {
if (errno == EAGAIN || errno == EWOULDBLOCK
|| errno == EINTR) continue;
epoll_ctl(ep, EPOLL_CTL_DEL, fd, nullptr);
inbuf.erase(fd);
close(fd);
continue;
}
std::string& acc = inbuf[fd];
acc.append(buf, static_cast<std::size_t>(r));
std::size_t pos; // 按 \n 切出完整消息
while ((pos = acc.find('\n')) != std::string::npos) {
std::string line = acc.substr(0, pos + 1);
acc.erase(0, pos + 1);
std::string reply = "echo: " + line;
std::size_t sent = 0; // 简化:不处理写阻塞
while (sent < reply.size()) {
ssize_t w = send(fd, reply.data() + sent,
reply.size() - sent, 0);
if (w <= 0) break;
sent += static_cast<std::size_t>(w);
}
}
if (acc.size() > (1u << 20)) { // 背压:不让半包无限增长
epoll_ctl(ep, EPOLL_CTL_DEL, fd, nullptr);
inbuf.erase(fd);
close(fd);
}
}
}
}
close(listener);
close(ep);
}
这份代码刻意暴露了三个真实工程问题,面试可以直接聊:
recv返回 0 的那一支必须close——注释标出来了。漏掉它就是线上最常见的 CLOSE_WAIT 堆积(见 §0.4)。- 每连接一个读缓冲,因为一次
recv可能拿到半条或多条(见 §3.8)。缓冲还设了上限,否则对端只要一直不发换行就能把你的内存吃光。 - 它简化了写路径——
send遇到EAGAIN时直接break丢数据了。正确做法是把剩余数据存进应用层写缓冲、注册EPOLLOUT、写完再取消关注(见 §3.12)。把这一段补完整,就是从”能跑”到”能用”的距离,也是很好的下一步练习。
用 nc 127.0.0.1 19090 或多开几个终端跑上面的 client.py(把 b"hello\n" 发多次)就能测。
4.3 排查手册
先保留证据:实际报错原文、目标地址和端口、时间点、客户端和服务端两侧日志。不要一上来就照着网上抄内核参数。
按现象定位
| 现象 | 先查什么 | 不能直接断定 |
|---|---|---|
| 域名解析失败 | 域名拼写、DNS 配置、缓存和 TTL | 业务服务端挂了 |
Connection refused | 端口是否在监听、绑的是 127.0.0.1 还是 0.0.0.0、防火墙拒绝规则 | 网线断了——收到 RST 说明网络是通的 |
| 连接超时 | 路由、防火墙静默丢包、全连接队列是否满 | 只有一个原因 |
| 连上但无回复 | 消息是否发完整(边界符/长度头)、服务端是否读了、ss -tn 的 Recv-Q | 建连成功就没网络问题 |
| 收到半条消息 | 应用层消息边界处理 | TCP 把后半条丢了 |
Broken pipe / Connection reset | 对端何时关闭、协议是否对不上、两侧收尾日志 | 重试一定安全 |
| CLOSE_WAIT 堆积 | 代码里 recv 返回 0 之后走到哪了 | 内核参数问题 |
| TIME_WAIT 很多 | 是不是大量短连接;本地端口是否真被占满 | 一定是泄漏 |
| CPU 高但吞吐低 | 忙轮询、一直关注不必要的 EPOLLOUT、慢业务在 I/O 线程里 | 线程不够,多开就好 |
| 小包交互延迟规律地多 40ms | Nagle + 延迟 ACK(见 §3.8.3) | 网络慢 |
| 小请求通、大请求卡死 | PMTU 黑洞:中间设备丢了 ICMP(见 §2.3 第 13 题) | 服务端处理不了大请求 |
常用命令
看监听(-l 监听、-t TCP、-n 数字显示):
ss -ltn
重点看两列:Send-Q 是全连接队列上限,Recv-Q 是当前排队等 accept 的数量。后者持续接近前者,说明应用 accept 跟不上。
看连接状态分布——排查 CLOSE_WAIT / TIME_WAIT 就用这个:
ss -tan state all | awk 'NR>1{print $1}' | sort | uniq -c | sort -rn
看某条连接的拥塞算法、cwnd 和 RTT:
ss -tin
抓本机实验的包(Linux 回环口是 lo,macOS 是 lo0):
sudo tcpdump -i lo -nn 'tcp port 19090'
抓包的三条边界: 它看的是传输的报文,不等于对方业务的内部状态;TCP 包数量不等于你 send 的次数;加密连接抓到的不是明文。只抓自己的实验。
关于 ping:它走 ICMP,不是在连你的 TCP 端口。ping 通不能证明端口有服务,ping 不通也不能单独证明服务不可达(很多网络默认丢 ICMP)。测端口用 telnet ip port 或 nc -zv ip port。完整对照见 §1.10。
4.4 练习路线:一次只加一件事
| 顺序 | 只加这一件 | 验收标准 |
|---|---|---|
| 1 | 跑通 §4.1 的实验 | 拿到 received: hello,能说出两个终端各在干什么 |
| 2 | 不发换行、提前断开、超长消息 | 能解释”连接完成”和”消息完成”的区别;错误路径都释放了资源 |
| 3 | 把分隔符换成 4 字节长度头 | 用 §3.8 的例子 2 那套写法,必须校验长度上限和字节序 |
| 4 | 同一连接连发两条消息 | 每条各得一个响应;一次读取里的多条都被处理(while 解包) |
| 5 | 支持两个客户端(阻塞版) | 一个不发消息时,能说出程序停在哪一行 |
| 6 | 改成非阻塞 + epoll LT | 慢客户端不再妨碍另一个客户端;对照 §4.2 |
| 7 | 补完整写缓冲 + EPOLLOUT | 大响应写不完时不丢数据、不忙唤醒 |
| 8 | 请求超时、读写缓冲上限 | 半包和慢读者不会永久占用无限资源 |
| 9 | 慢任务交给有界线程池 | 结果回来时能识别已断开的连接(fd 会复用) |
| 10 | 固定负载测量 | 同时记录成功率、P99 延迟、内存和 CPU,不只数连接数 |
先能画清 §0.1 那张图:应用 → 内核缓冲 → 网络 → 对端内核 → 对端应用。画不出来就先别跳到”一万连接”。
第六部分 闭卷自测
先遮住答案。每题都按”结论 + 为什么 + 一个边界”回答。
TCP 建连与断连
- 三次握手为什么不能是两次?两次会出什么具体问题?
listen(fd, 8)里的 8 是什么?半连接队列和全连接队列分别满了会怎样?- 四次挥手一定看到四个包吗?
- TIME_WAIT 在哪一方?为什么要等 2MSL?Linux 上是多久?
- 服务器上几千个 CLOSE_WAIT,你先看内核参数还是先看代码?为什么?
- FIN_WAIT_2 堆积说明谁有问题?
recv返回 0 是”暂时没数据”吗?返回 -1 且errno == EAGAIN呢?- 为什么服务端一般要
signal(SIGPIPE, SIG_IGN)? close和shutdown的区别?优雅关闭的三步是什么?- 对端拔网线,你这边
recv会立刻返回 0 吗?
TCP 可靠性与效率
- TCP 靠哪六件事做到可靠?它不保证什么?
- 流量控制和拥塞控制分别保护谁?发送方能发多少未确认数据?
- 慢启动的”慢”指什么?收到 3 个重复 ACK 和 RTO 超时,处理力度有什么不同?
- Linux 默认的拥塞控制算法是哪个?BBR 和它的判断依据有什么不同?
- 为什么 Nagle 和延迟 ACK 一起用会有 40ms 延迟?除了
TCP_NODELAY还能怎么办? - MSS 和 MTU 分别是谁的属性?以太网上 MSS 典型是多少?
- 为什么要避免 IP 分片?什么是 PMTU 黑洞?
- 粘包是 TCP 的 bug 吗?三种消息边界方案各自的坑?
- 用长度头时,为什么必须校验长度上限?
send返回一个正数,说明什么?不说明什么?- TCP keepalive 和应用层心跳的本质区别是什么?
- 心跳超时就能断定对端已经死了吗?重连之后能直接重发旧请求吗?
I/O 模型与高并发
- 五种 I/O 模型里,哪几种是同步的?为什么 epoll 是同步的?
- select 有哪四个问题?poll 解决了哪个、没解决哪个?
- epoll 快在哪两点?“epoll 用 mmap 免拷贝”这个说法对吗?
- 什么情况下 epoll 未必比 poll 快?
- ET 模式为什么必须配非阻塞?ET 写错了会看到什么症状?
- 什么是惊群?两种解法分别是什么?
accept本身还有惊群吗? - Reactor 和 Proactor 通知的分别是什么?主从 Reactor 多线程为什么不需要锁保护连接状态?
- 业务算十秒,丢进线程池就解决了吗?要额外处理哪三件事?
- 为什么”结果回来时不能拿旧 fd 号直接写”?
- 一万条连接,最先撞到的往往是哪个资源上限?
- 零拷贝的”零”是什么意思?
sendfile什么时候用不上?
应用层与排查
- DNS 的递归查询和迭代查询分别发生在哪一段?为什么用 UDP?什么时候用 TCP?
- 跨网段通信时,ARP 查的是谁的 MAC?IP 头里的目的 IP 变不变?
- 为什么 NAT 环境下长连接需要心跳?
- HTTPS 为什么要同时用对称和非对称加密?证书验证哪四件事?
- HTTP/2 解决了什么队头阻塞、没解决什么?HTTP/3 为什么要换到 UDP?
- ping 通了,能说明服务可用吗?
telnet ip port通了呢? - “服务起了但外面连不上”,第一个要查的是什么?
答案
写完再点开
- 服务端的初始序号得不到确认;更实际的是旧的重复 SYN 会让服务端单方面建连、白占资源。第三次 ACK 确认”这是客户端当下真想要的连接”。
- 是全连接队列上限(实际
min(backlog, somaxconn)),不是在线人数。半连接队列满 → 丢 SYN,客户端连接超时;全连接队列满 → 默认丢第三次 ACK,客户端以为连上了却没反应。 - 不一定。被动方无数据要发时 ACK 和 FIN 可能合并成三个包,还有同时关闭的情况。
- 主动关闭方。两个作用:最后的 ACK 丢了能重传;让旧连接的迷路报文过期,不串到相同五元组的新连接。Linux 写死 60 秒,不能用 sysctl 改。
- 先看代码。 CLOSE_WAIT 是”收到 FIN 后应用没
close”,不会自动消失。去查recv返回 0 之后走到哪了。 - 对端——它回了 ACK 但应用不
close,所以不发自己的 FIN。本地靠tcp_fin_timeout兜底。 - 返回 0 是对端关闭了发送方向(收到 FIN),是正常结束,必须收尾
close。EAGAIN才是非阻塞下”暂时没数据”。 - 往已被 RST 的连接写会触发 SIGPIPE,默认行为是杀掉进程。忽略后靠
send返回EPIPE处理。 close关 fd,引用计数归零才发 FIN;shutdown关连接方向,不看引用计数。三步:shutdown(SHUT_WR)→recv到返回 0 →close。直接 close 且接收缓冲有未读数据时,内核发的是 RST。- 不会。 对端没机会发 FIN,连接仍是
ESTABLISHED。必须靠超时和心跳。 - 序号、确认(累积)、超时重传(RTO 动态计算)、去重与重排序、校验和、流量控制、拥塞控制。不保证时延、不保证消息边界、不保证对端应用真的处理了。
- 流量控制保护接收方(
rwnd,接收方通告);拥塞控制保护网络(cwnd,发送方自己维护)。能发min(cwnd, rwnd)。 - “慢”指起点低(1 MSS),增长是指数的。3 个重复 ACK → 快重传 + 快恢复,
cwnd减半;RTO 超时 →cwnd打回 1 重新慢启动。 - CUBIC。BBR 不靠丢包判断,而是主动测量瓶颈带宽和最小 RTT——大缓冲设备上等到丢包时延迟已经很高了(缓冲区膨胀)。
- Nagle 等 ACK 才发下一个小包,延迟 ACK 等捎带(最多约 40ms),两者互等。更根本的办法是应用层攒好一次写出或用
writev,Nagle 就不会触发。 - MTU 是链路属性,MSS 是 TCP 握手时双方各自通告的。以太网上典型
1500-20-20 = 1460。 - 丢一片,整个 IP 包都要重传,丢包率被放大;有些设备还直接丢弃分片。PMTU 黑洞:中间设备把 ICMP 全丢了,路径 MTU 发现失效,表现为小包通、大包卡死。
- 不是 bug——TCP 从不承诺保留消息边界,这是应用层的责任。定长(浪费、不好扩展)、分隔符(正文含分隔符要转义)、长度头(要校验上限和字节序)。
- 长度是对端给的。照着它直接分配内存,对方发一个 4GB 的长度就能远程把你打挂。
- 说明内核接受了这么多字节进发送缓冲区。不说明对端收到、更不说明对端处理了;而且可能小于你传入的长度(短写),必须循环发。
- keepalive 只能说明对端内核协议栈有响应;心跳能说明对端业务线程还在处理消息。而且 keepalive 默认 7200 秒才启动,间隔不好控,还可能被中间设备丢弃。
- 不能——只说明约定期限内没收到响应。不能直接重发:旧请求可能已执行只是响应丢了,有副作用的操作需要请求 ID 和幂等去重。
- 阻塞、非阻塞、I/O 多路复用、信号驱动都是同步——阶段二(内核到用户态的拷贝)都要自己等着。epoll 只报告就绪,字节还是你自己
read的,所以是同步。 FD_SETSIZE1024 上限、每次拷贝整个集合进内核、返回后 O(n) 遍历、集合被改写要重填。poll 解决了上限和重填,没解决拷贝和 O(n) 遍历。- ①注册与等待分离(红黑树注册一次,
epoll_wait不用重传 fd 集合);②就绪链表(内核回调填充,取链表即可,不遍历全部 fd)。“mmap 免拷贝”是错的,现代内核走copy_to_user。 - 连接少且大部分都活跃时——红黑树和回调本身有成本,遍历一个小数组很便宜。
- ET 要循环读到
EAGAIN,阻塞 fd 的最后一次read会把整个线程停住。写错的症状是数据卡在缓冲区、连接像假死(内核不会再通知)。 - 多线程
epoll_wait同一个 listen fd,来一个连接全被唤醒只有一个 accept 成功。解法:EPOLLEXCLUSIVE或SO_REUSEPORT。accept系统调用本身的惊群早已在内核解决。 - Reactor 通知就绪(应用自己读),Proactor 通知完成(内核已读好)。主从 Reactor 里每个连接固定归属一个从 Reactor 线程,状态被单线程独占,所以不需要锁。
- 不解决,只是换地方执行。三件事:跨线程交接要同步且不能直接改 I/O 线程的连接状态;结果回来时连接可能已断而 fd 会复用;必须有背压(有界队列 + 积压阈值)。
- 因为 fd 关闭后编号会被复用,旧 fd 号可能已经指向一条完全不相干的新连接。要用
weak_ptr或单调递增的连接序号校验。 - 常常是内存——每条连接的内核收发缓冲默认几十到几百 KB,一万条就是几个 GB。其次是
ulimit -n的 fd 上限。 - 指不经过用户态、CPU 不参与搬运,不是真的 0 次移动。
sendfile只适合原样转发,一旦要在用户态改内容(加密、压缩)就用不上。 - 我→本地解析器是递归;本地解析器→根/TLD/权威是迭代。用 UDP 因为查询小、一问一答,省掉建连开销。响应超 512 字节(EDNS0 可到 4096)或区域传送时用 TCP。
- 查的是网关的 MAC。IP 头的目的 IP 一路不变,每一跳的 MAC 都在换。
- NAT 映射表项有超时,空闲太久映射被回收,连接就静默失效了。心跳同时在保活映射。
- 非对称安全但慢,对称快但需要共享密钥——所以用非对称协商出对称密钥,之后数据都用对称加密。证书验四件事:CA 签名链、域名匹配、有效期、吊销状态。
- HTTP/2 解决了 HTTP 层的队头阻塞(多路复用),TCP 层的没解决——丢一个包卡住所有 stream。HTTP/3 换到 QUIC over UDP 就是为了让流之间真正独立,顺带拿到 0-RTT 和连接迁移。
- 不能。 ping 走 ICMP,和 TCP 端口无关。
telnet通只说明三次握手成功、有程序在监听,不说明业务正常(可能收了不处理、卡在慢业务)。 - 看服务绑在哪个地址——
ss -ltn确认是0.0.0.0:port还是127.0.0.1:port。绑到回环地址只有本机能连,这是头号原因;其次是防火墙和安全组。
过关标准
某个知识点可以算初步掌握了,当你能做到这五条:
- 说出它解决什么问题,不用它会怎样
- 说清关键量:谁通告、谁维护、什么条件触发
- 给出一个边界或反例——什么时候它不管用、什么时候要关掉它
- 说出它落到代码或运维上是哪个调用、哪个参数、哪条命令
- 能在 §0.1 那张总图上指出它在哪一段
第 3 条最能区分”背过”和”排查过”。
最后留一个习惯:每次看到一个网络现象,先问它发生在 §0.1 图的哪一段,再问这一段谁保证了什么。 定位不到段,就先别调参数。