PERSONAL LAB / interview/从零开始图解
Interview 从零开始图解

网络:面试速记 + 从零学习

这份文档分两层用:

场景读哪里
面试前 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() 取走字节"]

看图,记四句话:

  1. send 返回只表示”拷进了内核发送缓冲区”,不表示对方收到,更不表示对方处理完。
  2. TCP 交付的是字节流,不是你的函数调用边界——发两次可能收一次,这就是”粘包”的来源。
  3. 中间每一跳只看它该看的那一层:路由器看 IP,交换机看 MAC,谁都不理解你的业务。
  4. 出问题时先定位在这张图的哪一段,而不是先去抄内核参数。

0.2 分层:每层解决什么、出问题什么样

干什么关键协议地址设备这层出问题的典型现象
应用层业务怎么请求和响应HTTP、DNS、SSH域名/URL404、业务超时、乱码
传输层端到端:给哪个程序、可靠不可靠TCP、UDP端口Connection refused、RST、粘包
网络层跨网段找路IP、ICMP、路由IP 地址路由器连接超时、ping 不通、Network unreachable
链路层同一网段内送到下一跳以太网、ARPMAC 地址交换机、网卡丢包率高、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/2cwnd 减半后线性增长
零窗口接收方通告 rwnd = 0,发送方启动持续计时器发窗口探测
Nagle + 延迟 ACKNagle 攒小包等 ACK;延迟 ACK 等捎带最多 40ms。两个一起用会出现40ms 延迟阶梯,关掉用 TCP_NODELAY高频追问
MSS / MTUMSS = 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 分片放大丢包影响
谁用 UDPDNS、音视频实时流、游戏同步、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 ETLT:只要还有数据就反复通知,可以一次只读一点。ET:只在状态变化时通知一次,必须循环读到 EAGAIN必须配非阻塞ET 不是”更高级”,是更省唤醒、更难写
惊群多个线程 epoll_wait 同一个 listen fd,来一个连接唤醒全部。解法:EPOLLEXCLUSIVESO_REUSEPORTaccept 本身的惊群早已在内核解决
Reactor vs ProactorReactor:多路复用报告就绪,应用自己读(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/3QUIC 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 floodtcp_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/Oio_uring、IOCP)不阻塞不阻塞异步

必答的一句: epoll 是同步 I/O 多路复用——它只帮你等和报告就绪,字节还是你自己 read,那次拷贝你得等着。只有阶段二也不用你参与,才叫异步 I/O。

1.2 TCP vs UDP

TCPUDP
连接需要握手无连接,直接发
可靠确认+重传+去重+重排序不保证到达、不保证顺序、不去重
边界字节流,无消息边界(要自己切)保留数据报边界(一次发一次收)
流控拥塞rwnd + cwnd没有,会把网络打满
头部20 字节起(有选项)8 字节
一对多只能一对一支持广播、多播
典型HTTP、SSH、数据库DNS、实时音视频、游戏、QUIC

面试别只背优缺点口号,要能答这两个反问:

  • “UDP 会粘包吗?“——不会,它保留数据报边界;但会丢、会乱序、会重复。
  • “既然 UDP 快,为什么不都用?“——可靠性不会消失,只是从内核挪到你的应用里,你得自己实现确认重传,通常做得不如内核。QUIC 就是在 UDP 上重做了一套。

1.3 select / poll / epoll

selectpollepoll
数据结构fd_set 位图pollfd 数组内核红黑树 + 就绪链表
fd 上限FD_SETSIZE = 1024无硬上限无硬上限
每次调用的拷贝整个集合拷进内核整个数组拷进内核只在 epoll_ctl 注册时拷一次
找就绪的开销O(n) 遍历O(n) 遍历O(1) 拿就绪链表
集合是否被改写,每次要重填不会(用 revents不会
触发模式只有 LT只有 LTLT 和 ET
跨平台广泛广泛Linux 专有(BSD 是 kqueue)

epoll 快在两点,答的时候都要说:

  1. 注册和等待分离——epoll_ctl 把 fd 加到红黑树里一次,之后每次 epoll_wait 不用重传 fd 集合。select/poll 每次都要把整个集合拷进内核。
  2. 就绪链表——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_WAITCLOSE_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

ReactorProactor
通知的是就绪:“可以读了”完成:“已经读好了,数据在这”
谁做实际 I/O应用自己 read内核做完再回调
底层select/poll/epoll(同步多路复用)真异步 I/O:IOCP、io_uring
平台Linux 主流Windows 原生;Linux 靠 io_uring 才实用

Reactor 的三种形态要能说出演进理由:

  1. 单 Reactor 单线程——一个线程既等事件又处理业务。简单,无锁。Redis 就是这个模型(所以慢命令会阻塞所有人)。
  2. 单 Reactor + 线程池——I/O 还是一个线程,业务丢给线程池。解决了慢业务,但单线程的 I/O 可能成瓶颈。
  3. 主从 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 通,端口不一定通pingICMP,和你的 TCP 端口是两回事;服务没启动、防火墙只放行 ICMP 都会这样
ping 不通,服务不一定挂很多网络和主机默认丢弃 ICMP
telnet ip port 通了说明三次握手成功,只证明有程序在监听
握手成功,业务不一定正常应用可能收了不处理、卡在慢业务、或者消息没发完整
HTTP 200 也不一定成功状态码是传输层面的成功,业务结果要看响应体

排查口诀:分层往上问——网络层通不通(ping/traceroute)→ 传输层连不连(telnet/ss)→ 应用层答不答(抓包/日志)。

1.11 HTTP/1.1 vs 2 vs 3

HTTP/1.1HTTP/2HTTP/3
传输TCPTCPQUIC over UDP
格式文本二进制分帧二进制分帧
并发一条连接串行,浏览器开 6 条一条连接多路复用一条连接多路复用
头压缩HPACKQPACK
HTTP 层队头阻塞解决了解决了
TCP 层队头阻塞仍然有(丢一个包卡住所有 stream)解决了(流独立)
握手 RTTTCP 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 cookienet.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_reusetcp_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 要看 errnoEAGAIN/EWOULDBLOCK 是非阻塞下”暂时没数据”,正常,回去等下一次事件;EINTR 是被信号打断,应该重试;ECONNRESET 是收到了 RST,连接已经没了。

RST 表示”这条连接不该存在”,典型来源是连到没有程序监听的端口、连接已被内核销毁后又收到数据、或者对端设了 SO_LINGER 为 0 后 close。往一个已经收到 RST 的连接写数据会触发 SIGPIPE,默认行为是杀掉进程,所以服务端一般要 signal(SIGPIPE, SIG_IGN),然后靠 send 返回 EPIPE 来处理。

8. closeshutdown 有什么区别?怎样优雅关闭?

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/2cwnd 降到 ssthresh 附近,继续线性增长。

对比要说清:如果是 RTO 超时(连重复 ACK 都没有,说明可能真堵死了),处理狠得多——ssthresh = cwnd/2cwnd 直接打回 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 是字节流,你调两次 sendhelloworld,对端按顺序一定拿到 helloworld,但一次 recv 拿到多少完全不确定——可能一次拿全、可能拿到 he、可能拿到一条半。所谓粘包半包,本质是应用层没有定义消息边界

三种解法:定长,最简单但浪费;分隔符,比如用 \n 结束一条文本消息,但正文里能出现分隔符时就要转义;长度头,最通用——固定 4 字节长度加正文,先读满头再按长度读体。

长度头有个必须说的安全点:长度字段是对端给的,必须校验上限。照着对方声明的长度直接 resizemalloc,对方发一个 4GB 的长度就能把你打挂。还要注意长度字段的字节序,以及”读到一半”时要把已收字节留在缓冲里等下次。

UDP 不存在这个问题,它保留数据报边界,一次 sendto 对应一次 recvfrom

15. send 返回值意味着什么?

send 返回 n 表示内核接受了 n 个字节放进发送缓冲区,仅此而已。它不表示对端收到了,更不表示对端应用处理了

而且 n 可能小于你传入的长度(短写),尤其在非阻塞模式或发送缓冲区快满的时候。所以正确写法是记住偏移循环发,非阻塞下遇到 EAGAIN 要把剩余数据存进应用层发送缓冲、注册 EPOLLOUT,等可写事件再继续——这就是为什么每个连接都需要一个应用层写缓冲区

初学最容易犯的两个错:把”返回了正数”当成”整条发完了”;以及把收到的原始字节直接当 C 字符串用——网络数据里没有隐含的 \0 结束符

16. 怎么检测对端已经不在了?keepalive 和心跳的区别?

关键前提:对端拔网线或断电时,它没有机会发 FIN,所以我这边不会收到任何通知,recv 就一直阻塞着,连接看起来还是 ESTABLISHED。必须靠主动探测。

TCP keepaliveSO_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/Oio_uring、Windows IOCP,提交请求就走,内核把数据都拷好了才通知你

前四种都是同步的——因为阶段二那次拷贝都得你自己等着完成。只有第五种是真异步。 所以 epoll 是同步 I/O 多路复用:它只负责告诉你”这个 fd 现在可以读了”,字节还是你自己 read。这一句是这道题的得分点。

18. select、poll、epoll 有什么区别?epoll 为什么快?

selectfd_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 + write4 次数据拷贝和 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 portnc -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[快重传,不等超时]

看图,四个要点:

  1. 编号是按字节的,不是按包。
  2. 累积确认:ACK = 3001 表示 3000 之前全收到了,不是”只收到第 3000 个”。
  3. RTO 是动态算的,根据测量的 RTT 及其波动估计,不是固定值。
  4. 去重和重排序是 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,开始收发

看图,三个必须记住的点:

  1. SYN 占一个序号,所以确认值是 x+1y+1
  2. 握手由内核完成——你不需要在 C++ 里手写三次 send。客户端调 connect,服务端事先 listen,然后 accept
  3. accept 不是”发握手第二个包的函数”,它是从全连接队列里取走一条已经建好的连接。握手和应用取连接是两件事——这就是为什么服务端应用卡住时,客户端仍然能”连接成功”。

两个队列的关键参数:

队列存什么状态大小由什么决定满了会怎样
半连接队列(SYN queue)SYN_RCVDtcp_max_syn_backlog丢新 SYN;客户端表现为连接超时
全连接队列(accept queue)ESTABLISHED 待 acceptmin(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

客户端先后发 helloworld,接收端一定按顺序拿到 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!!] 

这段代码体现了四条必须做对的事:

  1. while 循环解包——一次读取的缓冲里可能有多条完整消息加最后半条,只解一条会让剩下的一直延迟。
  2. 半包时把已收字节留在缓冲区,不能丢。
  3. 长度字段过网络必须转字节序htonl/ntohl)。
  4. 必须校验长度上限——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

看图,两件事:

  1. 第②和第③之间隔着”对端还要发多久”,所以 ACK 和 FIN 合不了——这就是四次的原因。被动方确实没数据要发时可能合并成三个包,所以”四次挥手”不等于”一定看到四个独立包”。
  2. CLOSE_WAIT 走到 LAST_ACK,靠的是应用调 close()。这一步是应用的责任,内核不会替你做。

完整状态机和排查对照表见 §0.4——那张图是这一节的浓缩,建议直接背。

3.9.2 recv 返回值与 RST

情况表现该做什么
对端 shutdown/closerecv 返回 0正常结束,收尾并 close(漏了就是 CLOSE_WAIT 堆积)
非阻塞暂时没数据返回 -1,errno == EAGAIN/EWOULDBLOCK正常,回去等下一次事件
被信号打断返回 -1,errno == EINTR重试
收到 RST返回 -1,errno == ECONNRESET连接已没了,清理
往已被 RST 的连接写触发 SIGPIPE,默认杀进程signal(SIGPIPE, SIG_IGN),靠 sendEPIPE 处理

recv 返回 0 不是”暂时没消息”——这是初学最容易搞错的一条。

RST 的三个典型来源: 连到没有程序监听的端口(表现为 Connection refused);连接已被内核销毁后又收到数据;对端设了 SO_LINGER 为 0 后 close(强制丢弃缓冲区并发 RST)。

3.9.3 closeshutdown 与优雅关闭

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。 你这边的连接仍是 ESTABLISHEDrecv 一直阻塞,什么也不会发生。

所以必须主动探测。内核 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 时要自己处理的三件事:

  1. 包大小:UDP 自己不分段,超过 MTU 就交给 IP 分片,丢一片整包丢。所以单包尽量 ≤ 1500 - 20 - 8 = 1472 字节。
  2. 可靠性:需要的话得自己做序号、确认、重传——这不是消失了,是搬到你的代码里了,通常做得不如内核。
  3. 拥塞:没有 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 的根源,尤其是 sendaccept 那两行。

3.12 三层缓冲区:数据到了哪里

flowchart TB
    A["应用发送缓冲区(你自己的 string / vector)"] -->|send| B["内核发送缓冲区(SO_SNDBUF)"]
    B -->|TCP 按窗口和拥塞控制发出| N[网络]
    N --> C["内核接收缓冲区(SO_RCVBUF)"]
    C -->|recv| D["应用接收缓冲区(用于拼半包)"]

看图,四个结论:

  1. send 返回 n 只表示”拷进了内核发送缓冲区 n 字节”,真正何时发出由 TCP 的窗口和拥塞控制决定。
  2. 数据可以先到对端内核缓冲区,应用稍后再读——所以”应用没读”和”数据没到”是两回事。ss -tn服务端 Recv-Q 一直有值,就说明数据到了内核但应用没读,这是排查”连上了没回复”的关键线索。
  3. 非阻塞下 send 遇到 EAGAIN,剩余数据必须存进应用层发送缓冲区,注册 EPOLLOUT 等可写再继续——这就是为什么每条连接都需要一个应用层写缓冲区
  4. 每条连接的内核缓冲区都要占内存(默认几十到几百 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/Oio_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: 若连接仍有效,才发送响应

看图,三个坑:

  1. 跨线程交接要同步。 工作线程不能直接改 I/O 线程拥有的连接状态,结果要送回 I/O 线程去写。
  2. 结果回来时连接可能已断,而 fd 会被复用。 拿旧 fd 号直接写,可能把数据写给一个完全不相干的新连接。正确做法是持连接对象的 weak_ptr,或带一个单调递增的连接序号做校验。
  3. 必须有背压。 任务队列要设上限,满了就拒绝;发送积压超阈值要限制甚至断开慢连接。无界队列等于把内存当缓冲,结局是 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 + write44基准
mmap + write34省掉页缓存到用户缓冲那次
sendfile22全程内核态;配 SG-DMA 可到 1 次 CPU 拷贝都不用
splice22通过管道在两个 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_INETIPv4 地址族
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: helloTCP 是字节流;但不能据此断定底层恰好发生了两次 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);
}

这份代码刻意暴露了三个真实工程问题,面试可以直接聊:

  1. recv 返回 0 的那一支必须 close——注释标出来了。漏掉它就是线上最常见的 CLOSE_WAIT 堆积(见 §0.4)。
  2. 每连接一个读缓冲,因为一次 recv 可能拿到半条或多条(见 §3.8)。缓冲还设了上限,否则对端只要一直不发换行就能把你的内存吃光。
  3. 它简化了写路径——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 -tnRecv-Q建连成功就没网络问题
收到半条消息应用层消息边界处理TCP 把后半条丢了
Broken pipe / Connection reset对端何时关闭、协议是否对不上、两侧收尾日志重试一定安全
CLOSE_WAIT 堆积代码里 recv 返回 0 之后走到哪了内核参数问题
TIME_WAIT 很多是不是大量短连接;本地端口是否真被占满一定是泄漏
CPU 高但吞吐低忙轮询、一直关注不必要的 EPOLLOUT、慢业务在 I/O 线程里线程不够,多开就好
小包交互延迟规律地多 40msNagle + 延迟 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 portnc -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 建连与断连

  1. 三次握手为什么不能是两次?两次会出什么具体问题?
  2. listen(fd, 8) 里的 8 是什么?半连接队列和全连接队列分别满了会怎样?
  3. 四次挥手一定看到四个包吗?
  4. TIME_WAIT 在哪一方?为什么要等 2MSL?Linux 上是多久?
  5. 服务器上几千个 CLOSE_WAIT,你先看内核参数还是先看代码?为什么?
  6. FIN_WAIT_2 堆积说明谁有问题?
  7. recv 返回 0 是”暂时没数据”吗?返回 -1 且 errno == EAGAIN 呢?
  8. 为什么服务端一般要 signal(SIGPIPE, SIG_IGN)
  9. closeshutdown 的区别?优雅关闭的三步是什么?
  10. 对端拔网线,你这边 recv 会立刻返回 0 吗?

TCP 可靠性与效率

  1. TCP 靠哪六件事做到可靠?它保证什么?
  2. 流量控制和拥塞控制分别保护谁?发送方能发多少未确认数据?
  3. 慢启动的”慢”指什么?收到 3 个重复 ACK 和 RTO 超时,处理力度有什么不同?
  4. Linux 默认的拥塞控制算法是哪个?BBR 和它的判断依据有什么不同?
  5. 为什么 Nagle 和延迟 ACK 一起用会有 40ms 延迟?除了 TCP_NODELAY 还能怎么办?
  6. MSS 和 MTU 分别是谁的属性?以太网上 MSS 典型是多少?
  7. 为什么要避免 IP 分片?什么是 PMTU 黑洞?
  8. 粘包是 TCP 的 bug 吗?三种消息边界方案各自的坑?
  9. 用长度头时,为什么必须校验长度上限?
  10. send 返回一个正数,说明什么?说明什么?
  11. TCP keepalive 和应用层心跳的本质区别是什么?
  12. 心跳超时就能断定对端已经死了吗?重连之后能直接重发旧请求吗?

I/O 模型与高并发

  1. 五种 I/O 模型里,哪几种是同步的?为什么 epoll 是同步的?
  2. select 有哪四个问题?poll 解决了哪个、没解决哪个?
  3. epoll 快在哪两点?“epoll 用 mmap 免拷贝”这个说法对吗?
  4. 什么情况下 epoll 未必比 poll 快?
  5. ET 模式为什么必须配非阻塞?ET 写错了会看到什么症状?
  6. 什么是惊群?两种解法分别是什么?accept 本身还有惊群吗?
  7. Reactor 和 Proactor 通知的分别是什么?主从 Reactor 多线程为什么不需要锁保护连接状态?
  8. 业务算十秒,丢进线程池就解决了吗?要额外处理哪三件事?
  9. 为什么”结果回来时不能拿旧 fd 号直接写”?
  10. 一万条连接,最先撞到的往往是哪个资源上限?
  11. 零拷贝的”零”是什么意思?sendfile 什么时候用不上?

应用层与排查

  1. DNS 的递归查询和迭代查询分别发生在哪一段?为什么用 UDP?什么时候用 TCP?
  2. 跨网段通信时,ARP 查的是谁的 MAC?IP 头里的目的 IP 变不变?
  3. 为什么 NAT 环境下长连接需要心跳?
  4. HTTPS 为什么要同时用对称和非对称加密?证书验证哪四件事?
  5. HTTP/2 解决了什么队头阻塞、没解决什么?HTTP/3 为什么要换到 UDP?
  6. ping 通了,能说明服务可用吗?telnet ip port 通了呢?
  7. “服务起了但外面连不上”,第一个要查的是什么?

答案

写完再点开
  1. 服务端的初始序号得不到确认;更实际的是旧的重复 SYN 会让服务端单方面建连、白占资源。第三次 ACK 确认”这是客户端当下真想要的连接”。
  2. 全连接队列上限(实际 min(backlog, somaxconn)),不是在线人数。半连接队列满 → 丢 SYN,客户端连接超时;全连接队列满 → 默认丢第三次 ACK,客户端以为连上了却没反应
  3. 不一定。被动方无数据要发时 ACK 和 FIN 可能合并成三个包,还有同时关闭的情况。
  4. 主动关闭方。两个作用:最后的 ACK 丢了能重传;让旧连接的迷路报文过期,不串到相同五元组的新连接。Linux 写死 60 秒,不能用 sysctl 改。
  5. 先看代码。 CLOSE_WAIT 是”收到 FIN 后应用没 close”,不会自动消失。去查 recv 返回 0 之后走到哪了。
  6. 对端——它回了 ACK 但应用不 close,所以不发自己的 FIN。本地靠 tcp_fin_timeout 兜底。
  7. 返回 0 是对端关闭了发送方向(收到 FIN),是正常结束,必须收尾 closeEAGAIN 才是非阻塞下”暂时没数据”。
  8. 往已被 RST 的连接写会触发 SIGPIPE,默认行为是杀掉进程。忽略后靠 send 返回 EPIPE 处理。
  9. closefd,引用计数归零才发 FIN;shutdown连接方向,不看引用计数。三步:shutdown(SHUT_WR)recv 到返回 0 → close。直接 close 且接收缓冲有未读数据时,内核发的是 RST
  10. 不会。 对端没机会发 FIN,连接仍是 ESTABLISHED。必须靠超时和心跳。
  11. 序号、确认(累积)、超时重传(RTO 动态计算)、去重与重排序、校验和、流量控制、拥塞控制。不保证时延、不保证消息边界、不保证对端应用真的处理了。
  12. 流量控制保护接收方rwnd,接收方通告);拥塞控制保护网络cwnd,发送方自己维护)。能发 min(cwnd, rwnd)
  13. “慢”指起点低(1 MSS),增长是指数的。3 个重复 ACK → 快重传 + 快恢复,cwnd 减半;RTO 超时 → cwnd 打回 1 重新慢启动。
  14. CUBIC。BBR 不靠丢包判断,而是主动测量瓶颈带宽和最小 RTT——大缓冲设备上等到丢包时延迟已经很高了(缓冲区膨胀)。
  15. Nagle 等 ACK 才发下一个小包,延迟 ACK 等捎带(最多约 40ms),两者互等。更根本的办法是应用层攒好一次写出或用 writev,Nagle 就不会触发。
  16. MTU 是链路属性MSS 是 TCP 握手时双方各自通告的。以太网上典型 1500-20-20 = 1460
  17. 丢一片,整个 IP 包都要重传,丢包率被放大;有些设备还直接丢弃分片。PMTU 黑洞:中间设备把 ICMP 全丢了,路径 MTU 发现失效,表现为小包通、大包卡死
  18. 不是 bug——TCP 从不承诺保留消息边界,这是应用层的责任。定长(浪费、不好扩展)、分隔符(正文含分隔符要转义)、长度头(要校验上限和字节序)。
  19. 长度是对端给的。照着它直接分配内存,对方发一个 4GB 的长度就能远程把你打挂。
  20. 说明内核接受了这么多字节进发送缓冲区说明对端收到、更不说明对端处理了;而且可能小于你传入的长度(短写),必须循环发。
  21. keepalive 只能说明对端内核协议栈有响应;心跳能说明对端业务线程还在处理消息。而且 keepalive 默认 7200 秒才启动,间隔不好控,还可能被中间设备丢弃。
  22. 不能——只说明约定期限内没收到响应。不能直接重发:旧请求可能已执行只是响应丢了,有副作用的操作需要请求 ID 和幂等去重。
  23. 阻塞、非阻塞、I/O 多路复用、信号驱动都是同步——阶段二(内核到用户态的拷贝)都要自己等着。epoll 只报告就绪,字节还是你自己 read,所以是同步。
  24. FD_SETSIZE 1024 上限、每次拷贝整个集合进内核、返回后 O(n) 遍历、集合被改写要重填。poll 解决了上限和重填没解决拷贝和 O(n) 遍历
  25. 注册与等待分离(红黑树注册一次,epoll_wait 不用重传 fd 集合);②就绪链表(内核回调填充,取链表即可,不遍历全部 fd)。“mmap 免拷贝”是错的,现代内核走 copy_to_user
  26. 连接少且大部分都活跃时——红黑树和回调本身有成本,遍历一个小数组很便宜。
  27. ET 要循环读到 EAGAIN阻塞 fd 的最后一次 read 会把整个线程停住。写错的症状是数据卡在缓冲区、连接像假死(内核不会再通知)。
  28. 多线程 epoll_wait 同一个 listen fd,来一个连接全被唤醒只有一个 accept 成功。解法:EPOLLEXCLUSIVESO_REUSEPORTaccept 系统调用本身的惊群早已在内核解决
  29. Reactor 通知就绪(应用自己读),Proactor 通知完成(内核已读好)。主从 Reactor 里每个连接固定归属一个从 Reactor 线程,状态被单线程独占,所以不需要锁。
  30. 不解决,只是换地方执行。三件事:跨线程交接要同步且不能直接改 I/O 线程的连接状态;结果回来时连接可能已断而 fd 会复用必须有背压(有界队列 + 积压阈值)。
  31. 因为 fd 关闭后编号会被复用,旧 fd 号可能已经指向一条完全不相干的新连接。要用 weak_ptr 或单调递增的连接序号校验。
  32. 常常是内存——每条连接的内核收发缓冲默认几十到几百 KB,一万条就是几个 GB。其次是 ulimit -n 的 fd 上限。
  33. 不经过用户态、CPU 不参与搬运,不是真的 0 次移动。sendfile 只适合原样转发,一旦要在用户态改内容(加密、压缩)就用不上。
  34. 我→本地解析器是递归;本地解析器→根/TLD/权威是迭代。用 UDP 因为查询小、一问一答,省掉建连开销。响应超 512 字节(EDNS0 可到 4096)或区域传送时用 TCP。
  35. 查的是网关的 MACIP 头的目的 IP 一路不变,每一跳的 MAC 都在换
  36. NAT 映射表项有超时,空闲太久映射被回收,连接就静默失效了。心跳同时在保活映射。
  37. 非对称安全但慢,对称快但需要共享密钥——所以用非对称协商出对称密钥,之后数据都用对称加密。证书验四件事:CA 签名链、域名匹配、有效期、吊销状态
  38. HTTP/2 解决了 HTTP 层的队头阻塞(多路复用),TCP 层的没解决——丢一个包卡住所有 stream。HTTP/3 换到 QUIC over UDP 就是为了让流之间真正独立,顺带拿到 0-RTT 和连接迁移。
  39. 不能。 ping 走 ICMP,和 TCP 端口无关。telnet 通只说明三次握手成功、有程序在监听,不说明业务正常(可能收了不处理、卡在慢业务)。
  40. 看服务绑在哪个地址——ss -ltn 确认是 0.0.0.0:port 还是 127.0.0.1:port。绑到回环地址只有本机能连,这是头号原因;其次是防火墙和安全组。

过关标准

某个知识点可以算初步掌握了,当你能做到这五条:

  1. 说出它解决什么问题,不用它会怎样
  2. 说清关键量:谁通告、谁维护、什么条件触发
  3. 给出一个边界或反例——什么时候它不管用、什么时候要关掉它
  4. 说出它落到代码或运维上是哪个调用、哪个参数、哪条命令
  5. 能在 §0.1 那张总图上指出它在哪一段

第 3 条最能区分”背过”和”排查过”。

最后留一个习惯:每次看到一个网络现象,先问它发生在 §0.1 图的哪一段,再问这一段谁保证了什么。 定位不到段,就先别调参数。

Related · 从零开始图解
⎇ main interview/从零开始图解 79 节 253 notes UTF-8