TCP/IP 基础
分层模型、三次握手、四次挥手、TIME_WAIT 和字节序。面试必考,也是看懂 socket 代码的前提。
分层:每层只管一件事
| 层 | 管什么 | 例子 |
|---|---|---|
| 应用层 | 数据是什么意思 | HTTP、RESP(Redis 协议) |
| 传输层 | 把数据可靠、按顺序地送到对方的某个程序 | TCP、UDP |
| 网络层 | 把数据包从一台机器送到另一台机器 | IP |
| 链路层 | 同一个局域网内怎么传 | 以太网、Wi-Fi |
你写服务器时,主要和 TCP 打交道。IP 地址定位到哪台机器,端口定位到这台机器上的哪个程序。一条 TCP 连接由四元组唯一确定:(源 IP, 源端口, 目的 IP, 目的端口)。
TCP 是“字节流”
这是写网络程序最重要的一句话:TCP 只保证字节按顺序到达,不保证你一次 write 的内容会被对方一次 read 读到。 你连发两条消息,对方可能一次读到两条,也可能一条拆成两次读。所以应用层必须自己定义消息边界,这就是阶段 2 要处理的粘包、半包问题。
三次握手:建立连接
客户端 服务器 (LISTEN)
| ---- SYN seq=x ----------> |
| <--- SYN+ACK seq=y ack=x+1 | (SYN_RCVD)
| ---- ACK ack=y+1 --------> | (ESTABLISHED)
(ESTABLISHED)
为什么是三次而不是两次?双方都要确认“对方能收到我的包”,并交换各自的初始序号。只握两次的话,服务器无法确认客户端收到了自己的 SYN。另外,一个早已失效的旧 SYN 如果延迟到达,服务器也会误以为是新连接,白白分配资源。
握手由内核完成。listen() 之后,内核会把完成握手的连接放进队列,accept() 只是从队列里取出一个。
四次挥手:关闭连接
主动关闭方 被动关闭方
| ---- FIN --------------> | (CLOSE_WAIT:read 返回 0)
| <--- ACK --------------- |
| ……对方可能还在发剩下的数据……
| <--- FIN --------------- | (对方调用 close)
| ---- ACK --------------> |
(TIME_WAIT,等待 2MSL)
因为 TCP 是全双工的,两个方向要分别关闭,所以需要四次。在你的 echo server 里,read 返回 0 就表示对方发来了 FIN。
TIME_WAIT 和 SO_REUSEADDR
主动关闭的一方会在 TIME_WAIT 状态停留 2MSL(通常 60 秒左右),确保最后一个 ACK 丢失时还能重发,也让网络里残留的旧包过期。
副作用是:服务器刚关掉马上重启,bind 会报 Address already in use,因为旧连接还处在 TIME_WAIT。所以服务器在 bind 之前都会设置:
int opt = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
字节序
不同 CPU 存储多字节整数的顺序不同。x86 是小端序(低位字节在前),网络协议统一使用大端序。端口号和 IP 地址填进结构体之前,都要转换成网络字节序:
addr.sin_port = htons(8888); // host to network short
addr.sin_addr.s_addr = htonl(INADDR_ANY); // host to network long
忘记转换的话,程序会监听到一个奇怪的端口上:8888 会变成 47138。
自测
对方连发了 “abc” 和 “def” 两条消息,你的一次 read 可能读到什么?
都有可能:“abc”、“abcdef”、“ab”,或者 “abcd”。TCP 是字节流,没有消息边界,边界得由应用层协议自己定义。
服务器上出现大量 CLOSE_WAIT,说明什么?
对方已经关闭了连接,但你的程序没有调用 close。通常是 read 返回 0 之后忘了关 fd,属于程序 bug,会导致 fd 泄漏。
TIME_WAIT 出现在主动关闭方还是被动关闭方?
主动关闭方,也就是先发 FIN 的那一方。
面试常问
TCP 为什么是三次握手,两次不行吗?
参考回答: 主要有两个原因。第一,双方都要确认对方能收也能发,并且交换各自的初始序列号(ISN),三次刚好够:客户端发 SYN 带上自己的序列号,服务器回 SYN+ACK 确认它并带上自己的序列号,客户端再 ACK 确认服务器的。第二,防止历史连接:如果一个很早以前发出、在网络里滞留的旧 SYN 到了服务器,两次握手的话服务器会直接建立连接、白白占用资源;三次握手时客户端发现这个 SYN+ACK 对不上自己当前的请求,会回 RST 拒绝掉。
为什么挥手要四次?TIME_WAIT 为什么要等 2MSL?
参考回答: 因为 TCP 是全双工的,两个方向要分别关闭。一方发 FIN 只表示“我不再发了”,对方先回 ACK,但可能还有数据没发完,等发完了才发自己的 FIN,所以 ACK 和 FIN 一般分两次发,总共四次。
TIME_WAIT 出现在主动关闭的一方,等 2MSL(报文最大生存时间的两倍)有两个目的:一是如果最后那个 ACK 丢了,对方会重发 FIN,这边还能再回一次 ACK;二是让这个连接在网络里残留的旧报文全部过期,避免被之后用同一个四元组建立的新连接误收。
服务器上有大量 TIME_WAIT,怎么处理?
参考回答: 先说明 TIME_WAIT 本身是正常的保护机制,只说明这台机器在主动关闭大量连接。它的危害主要是占用端口和内存,在作为客户端频繁发起短连接时会把本地端口耗尽。处理思路:能用长连接或连接池就用,从根本上减少关闭次数;服务端设置 SO_REUSEADDR 保证重启时能马上 bind;作为客户端时可以打开 net.ipv4.tcp_tw_reuse。不要用 tcp_tw_recycle,它在 NAT 环境下会丢连接,Linux 4.12 已经删掉了。
可能的追问: 大量 CLOSE_WAIT 呢?那是被动关闭方收到 FIN 后程序没有 close,基本是代码 bug,要去查哪里漏了关闭。
什么是 TCP 粘包?怎么解决?
参考回答: 严格说 TCP 没有“包”的概念,它是字节流,不保留消息边界。发送方连发两条消息,接收方一次 read 可能读到一条半,也可能两条粘在一起。所以要在应用层自己分帧,常见三种做法:定长消息;用分隔符,比如 HTTP 头部和 Redis 内联命令用 \r\n;在消息前加长度字段,比如 RESP 的 $5\r\nhello\r\n。服务器为每个连接维护一个输入缓冲区,读到的数据先追加进去,凑够一条完整消息再处理,剩下的留到下次。
TCP 是怎么保证可靠传输的?
参考回答: 几个机制配合:序列号和确认应答,保证不丢、不重、有序;超时重传和快速重传(收到三个重复 ACK 就立即重传);滑动窗口做流量控制,接收方通过窗口大小告诉发送方自己还能收多少;拥塞控制防止把网络打满,包括慢启动、拥塞避免、快重传和快恢复;另外还有校验和检查数据是否损坏。
TCP 和 UDP 有什么区别?各自适合什么场景?
参考回答: TCP 面向连接、可靠、有序、有流量和拥塞控制,是字节流;UDP 无连接、不保证送达和顺序,保留消息边界,头部只有 8 字节,开销小、延迟低。需要数据完整的场景用 TCP,比如 HTTP、数据库、Redis;能容忍少量丢包但对延迟敏感的用 UDP,比如音视频通话、游戏、DNS 查询。QUIC(HTTP/3)是在 UDP 上自己实现了可靠传输。
在浏览器输入一个网址到看到页面,中间发生了什么?
参考回答: 按顺序说:浏览器解析 URL;DNS 解析拿到 IP(先查浏览器缓存、系统缓存、hosts,再问递归 DNS 服务器);和服务器 TCP 三次握手;如果是 HTTPS 再做 TLS 握手,协商密钥并校验证书;发送 HTTP 请求;服务器(可能先经过 CDN、反向代理如 Nginx / Caddy)处理后返回响应;浏览器解析 HTML,加载 CSS、JS 和图片,构建 DOM 和渲染树,排版绘制。答题时可以挑自己熟的一段讲深,比如结合自己的项目讲 Caddy 怎么自动申请证书、怎么做反向代理。
原文与延伸
- RFC 9293:TCP 的现行规范,状态机部分值得看一遍
- 谢希仁《计算机网络》:国内教材,适合配合课程使用
- W. Richard Stevens《TCP/IP 详解 卷 1》:经典参考书