learn.ochresign
阶段 0 · 补前置

任务 4:亲眼看到三次握手、四次挥手和端口

不背图,直接用 tcpdump 抓包,看一次 TCP 连接从建立、发数据到关闭的每一个包;再用 ss 看 TIME_WAIT 和 CLOSE_WAIT 是怎么出现的。所有输出都是实际抓到的。

前提: 任务 1 里已经装好了 tcpdump 和 nc。

这篇做完你会: 说清楚 IP、端口、连接分别是什么;在抓包结果里认出 SYN、ACK、FIN、RST;知道 TIME_WAIT 和 CLOSE_WAIT 出现在哪一方、为什么出现。

预计用时: 2 到 3 小时。概念部分可以配合 TCP/IP 基础 一起看。

第 1 部分:先把几个词搞清楚

IP 地址:找到哪台机器

IP 地址就像楼的门牌号,比如 192.168.1.10。有一个特殊的地址 127.0.0.1,叫本机回环地址,意思是“我自己”。发往 127.0.0.1 的数据不会经过网卡,直接在本机内部转一圈。我们这篇所有实验都用它,不需要两台电脑。

端口:找到机器上的哪个程序

一台机器上同时跑着很多网络程序:网页服务器、数据库、你的 echo server。数据到了这台机器,要靠端口号决定交给谁。端口号是 0 到 65535 的整数,就像楼里的房间号。

一些常见端口:HTTP 用 80,HTTPS 用 443,SSH 用 22,Redis 用 6379。我们的 echo server 用 8888。

  • 服务器要提前占一个固定端口(bind),等着别人来连,这叫监听(listen)。
  • 客户端一般不指定端口,由系统随机分配一个,叫临时端口,范围通常是 32768 到 60999。

连接:由四个数唯一确定

一条 TCP 连接由四个值唯一确定,叫四元组:

(客户端 IP, 客户端端口, 服务器 IP, 服务器端口)

所以一个监听 8888 端口的服务器可以同时和几万个客户端连接:它们的服务器 IP 和端口都一样,但客户端的 IP 或端口不同,四元组就不同。

TCP 的几个标志位

TCP 的每个包头里有几个标志位(flags),抓包时最常看到的是:

标志 全称 意思 tcpdump 里显示为
SYN synchronize 我要建立连接,这是我的初始序列号 S
ACK acknowledge 我确认收到了你的数据,下一个我期待的序号是 xxx .
FIN finish 我的数据发完了,要关闭我这个方向 F
RST reset 出错了,立刻断开 R
PSH push 这个包带着数据,请尽快交给应用程序 P

组合起来,比如 [S.] 就是 SYN + ACK,[F.] 就是 FIN + ACK。

序列号

TCP 是字节流:你发的每一个字节都有一个编号,叫序列号(seq)。对方回复的 ACK 号表示“这个编号之前的字节我都收到了,下一个我要这个编号”。靠这个机制,TCP 能发现丢包并重传,能把乱序到达的包排好序。

第 2 部分:抓一次完整的连接

需要开三个终端。在 VS Code 里点终端面板右上角的 + 可以开新终端,或者开三个 Ubuntu 窗口。

终端 1:开始抓包

sudo tcpdump -i lo -nn -S 'tcp port 9000'

参数含义:-i lo 只抓回环网卡(127.0.0.1 走的就是它);-nn 不把 IP 和端口翻译成名字,直接显示数字;-S 显示完整的序列号,而不是相对值;'tcp port 9000' 只抓和 9000 端口有关的 TCP 包。

它会显示 listening on lo ...,然后等着。

终端 2:启动一个服务器

nc -l 9000

nc -l 9000 的意思是:在 9000 端口监听,有人连上来就把收到的内容打印出来。它会一直等着。

终端 3:先看看谁在监听,再连上去

ss -tlnp

ss 是查看网络连接的命令:-t 只看 TCP,-l 只看监听中的,-n 显示数字,-p 显示是哪个进程。应该能看到:

State  Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0      1            0.0.0.0:9000      0.0.0.0:*    users:(("nc",pid=3862,fd=3))

意思是:nc 这个进程(pid 3862),用它的 fd 3,在所有网卡(0.0.0.0)的 9000 端口上监听。

然后连上去,发一行字,再关闭:

printf 'hello\n' | nc -q0 127.0.0.1 9000

终端 2 会打印出 hello,然后 nc 退出。

回到终端 1,看抓到了什么

按 Ctrl + C 停止 tcpdump。下面是实际抓到的 8 个包(每行很长,我在每行前面加了编号):

① 12:36:09.497354 IP 127.0.0.1.41844 > 127.0.0.1.9000: Flags [S], seq 603288137, win 65495, options [...], length 0
② 12:36:09.497379 IP 127.0.0.1.9000 > 127.0.0.1.41844: Flags [S.], seq 934707998, ack 603288138, win 65483, options [...], length 0
③ 12:36:09.497424 IP 127.0.0.1.41844 > 127.0.0.1.9000: Flags [.], ack 934707999, win 64, options [...], length 0
④ 12:36:09.497521 IP 127.0.0.1.41844 > 127.0.0.1.9000: Flags [P.], seq 603288138:603288144, ack 934707999, win 64, options [...], length 6
⑤ 12:36:09.497526 IP 127.0.0.1.9000 > 127.0.0.1.41844: Flags [.], ack 603288144, win 64, options [...], length 0
⑥ 12:36:09.996945 IP 127.0.0.1.41844 > 127.0.0.1.9000: Flags [F.], seq 603288144, ack 934707999, win 64, options [...], length 0
⑦ 12:36:09.997033 IP 127.0.0.1.9000 > 127.0.0.1.41844: Flags [F.], seq 934707999, ack 603288145, win 64, options [...], length 0
⑧ 12:36:09.997062 IP 127.0.0.1.41844 > 127.0.0.1.9000: Flags [.], ack 934708000, win 64, options [...], length 0

怎么读一行: 时间;IP;源地址.源端口 > 目的地址.目的端口;Flags [标志];序列号和确认号;length 是这个包带了多少字节的数据。

先找到两边是谁:127.0.0.1.41844 是客户端(系统随机分配的临时端口 41844),127.0.0.1.9000 是服务器。

三次握手:① ② ③

包 方向 标志 关键数字 含义
① 客户端 → 服务器 S seq 603288137 “我要连接,我的字节从 603288137 开始编号”
② 服务器 → 客户端 S. seq 934707998,ack 603288138 “收到了,我也要连接,我的字节从 934707998 开始编号;你的下一个序号我期待 603288138”
③ 客户端 → 服务器 . ack 934707999 “收到你的了,你的下一个序号我期待 934707999”

注意 ack 都是对方的 seq 加 1。 SYN 本身虽然不带数据,但要占用一个序号。

三个包之后,双方都知道了:对方能收也能发,以及对方的起始编号。连接建立完成。

这三个包是内核自己发的,你的程序完全不参与。 服务器程序调用的 accept(),只是从内核里取出一个“已经握手完成的连接”。这一点面试常考。

发送数据:④ ⑤

包 方向 标志 关键数字 含义
④ 客户端 → 服务器 P. seq 603288138:603288144,length 6 发了 6 个字节,编号 603288138 到 603288143
⑤ 服务器 → 客户端 . ack 603288144 “603288144 之前的都收到了”

6 个字节就是 hello\n(5 个字母加一个换行)。

关闭连接:⑥ ⑦ ⑧

包 方向 标志 含义
⑥ 客户端 → 服务器 F. “我发完了”(客户端 close)
⑦ 服务器 → 客户端 F. “收到你的 FIN,我也发完了”
⑧ 客户端 → 服务器 . “收到你的 FIN”

咦,不是“四次挥手”吗,怎么只有三个包?

标准的四次挥手是:

  1. A 发 FIN(我不发了)
  2. B 回 ACK(知道了)
  3. B 发 FIN(我也不发了)
  4. A 回 ACK(知道了)

第 2 和第 3 步是 B 分开发的,因为 B 收到 FIN 时可能还有数据没发完,要等发完了才发自己的 FIN。但在这个实验里,nc 收到 FIN 后没有别的数据要发,马上就关闭了,内核就把 ACK 和 FIN 合并成了一个包(⑦ 的 F. 同时带着 FIN 和 ACK)。所以抓到的是三个包。

面试时回答“四次挥手”,然后补一句“如果被动关闭方没有数据要发,第二次和第三次可以合并,抓包时会看到三个包”,会显得你真的抓过包。

关闭之后:TIME_WAIT

马上在终端 3 执行:

ss -tan | grep 9000

会看到:

TIME-WAIT 0      0          127.0.0.1:41844    127.0.0.1:9000

连接已经关闭了,但客户端这一侧(41844 端口)还留着一个 TIME-WAIT 状态的记录。大约 60 秒后再执行,它就消失了。

TIME_WAIT 出现在主动关闭的一方。 这次是客户端先 close 的,所以在客户端。如果是服务器先关,就在服务器这边。

它为什么要等 60 秒(Linux 上是 60 秒,理论上叫 2MSL)?两个原因:

  1. 如果最后那个 ACK(⑧)丢了,对方会重发 FIN,这边还在 TIME_WAIT 里,能再回一次 ACK。如果直接走了,对方收不到确认,会一直重发。
  2. 让这条连接在网络里残留的旧包全部过期,避免被之后用同样四元组的新连接收到。

这和我们写服务器有什么关系? 服务器主动关掉连接后,再重启服务器,bind(8888) 可能会报错 Address already in use,因为还有 TIME_WAIT 状态的连接占着这个端口。解决办法是在 bind 之前设置 SO_REUSEADDR。这就是阶段 0 的 echo server 里有 setsockopt(... SO_REUSEADDR ...) 那一行的原因。

第 3 部分:连一个没人监听的端口

终端 1 换一个端口抓包:

sudo tcpdump -i lo -nn 'tcp port 9001'

终端 3 连接 9001(没有任何程序在监听它):

nc -v 127.0.0.1 9001

立刻报错:

nc: connect to 127.0.0.1 port 9001 (tcp) failed: Connection refused

抓到两个包:

IP 127.0.0.1.47478 > 127.0.0.1.9001: Flags [S], seq 2121009491, ...
IP 127.0.0.1.9001 > 127.0.0.1.47478: Flags [R.], seq 0, ack 2121009492, win 0, length 0

客户端发了 SYN,内核发现 9001 端口上没有人监听,直接回了一个 RST(R.)。客户端收到 RST,connect 就返回失败,错误是 Connection refused(连接被拒绝)。

以后看到 Connection refused,第一反应是:服务器没启动,或者端口写错了。 用 ss -tlnp 看一下那个端口有没有人在监听。

第 4 部分:制造一个 CLOSE_WAIT

TIME_WAIT 是正常的。但如果服务器上出现大量 CLOSE_WAIT,一般就是代码有 bug。我们来制造一个。

下载这两个脚本到同一个目录:

lazy_server.py 下载

# 一个“偷懒”的服务器:接受连接后既不读也不关,用来观察 CLOSE_WAIT
import socket
import time

s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", 9003))
s.listen()
print("listening on 9003")
conn, addr = s.accept()
print("accepted", addr, "- now sleeping 60s without closing")
time.sleep(60)

quick_client.py 下载

# 连上 9003,发 3 个字节,马上关闭
import socket
import time

c = socket.create_connection(("127.0.0.1", 9003))
c.sendall(b"bye")
c.close()
print("client closed")
time.sleep(30)  # 先别退出,方便用 ss 观察

终端 2 运行偷懒的服务器:

python3 lazy_server.py

终端 3 运行客户端:

python3 quick_client.py

再开一个终端(或者在客户端打印 client closed 后按 Ctrl + C 结束它),马上查看:

ss -tan | grep 9003

实际输出:

LISTEN     0      128        127.0.0.1:9003        0.0.0.0:*
FIN-WAIT-2 0      0          127.0.0.1:50586     127.0.0.1:9003
CLOSE-WAIT 4      0          127.0.0.1:9003      127.0.0.1:50586

读一下:

  • 客户端(50586)先 close,发出了 FIN,服务器的内核自动回了 ACK。客户端收到 ACK,进入 FIN-WAIT-2:“我已经关了我这边,在等对方也关”。
  • 服务器(9003 那条连接)收到了 FIN,进入 CLOSE-WAIT:“对方已经关了,在等我的程序调用 close”。
  • 但我们的服务器程序在 sleep,根本不 close,所以它会一直卡在 CLOSE-WAIT,直到进程退出。
  • Recv-Q 是 4:客户端发的 3 个字节 bye 还没被程序读走,FIN 也占一个序号,一共 4。

CLOSE_WAIT 卡住,说明程序没有处理“对方关闭了连接”这件事。 在 echo server 里,对方关闭时 read 会返回 0,你必须在这时 close(fd)。忘了这一步,每断开一个客户端就留下一个 CLOSE_WAIT 和一个 fd,时间长了 fd 耗尽,服务器再也接不了新连接。

第 5 部分:TCP 状态一览

把今天看到的状态串起来:

状态 在哪一方 什么时候出现
LISTEN 服务器 调用了 listen,等待连接
SYN-SENT 客户端 发出 SYN,等对方回 SYN+ACK
SYN-RECV 服务器 收到 SYN 并回了 SYN+ACK,等最后一个 ACK
ESTABLISHED 双方 三次握手完成,可以收发数据
FIN-WAIT-1 主动关闭方 发出 FIN,等对方 ACK
FIN-WAIT-2 主动关闭方 收到 ACK,等对方的 FIN
CLOSE-WAIT 被动关闭方 收到对方 FIN,等自己的程序调用 close
LAST-ACK 被动关闭方 发出了自己的 FIN,等最后一个 ACK
TIME-WAIT 主动关闭方 收到对方 FIN 并回了 ACK,等 2MSL

检查清单

  • 能解释四元组,以及为什么一个端口能同时有几万个连接
  • 自己抓到了三次握手,能对着 seq 和 ack 说出每个包的含义
  • 能解释为什么挥手抓到的是三个包而不是四个
  • 看到过 TIME-WAIT,知道它在主动关闭方
  • 自己制造出了 CLOSE-WAIT,知道它意味着程序没有 close
  • 知道 Connection refused 对应的是 RST

自测

accept() 参与三次握手吗?

不参与。三次握手完全由内核完成,握手成功的连接放进一个队列(全连接队列),accept() 只是从队列里取出一个。所以服务器就算一直不调用 accept,客户端的 connect 也能成功。

抓包看到 ② 的 ack 是 603288138,为什么比 ① 的 seq 大 1?

SYN 包虽然不带数据,但要占用一个序号。ack 的含义是“我期待的下一个序号”,所以是对方的初始序号加 1。FIN 也一样占一个序号,所以 ⑧ 的 ack 是 ⑦ 的 seq 加 1。

服务器上有几千个 CLOSE_WAIT,你会怎么排查?

CLOSE_WAIT 表示对方已经关闭,但我们的程序没有调用 close。用 ss -tanp 找到是哪个进程,然后去查代码里处理“对方关闭”的地方:read 返回 0 时有没有 close,出错的分支有没有漏掉 close,有没有在某个地方提前 return 了。

面试常问

TCP 为什么是三次握手,两次不行吗?

参考回答: 两个原因。第一,双方都要确认对方能收能发,并且交换各自的初始序列号:第一次客户端发 SYN 带上自己的序列号,第二次服务器回 SYN+ACK 确认它并带上自己的序列号,第三次客户端 ACK 确认服务器的序列号,三次刚好够。第二,防止历史连接:一个很久以前发出、在网络里滞留的旧 SYN 到达服务器时,如果两次握手就建立连接,服务器会白白分配资源;三次握手时客户端发现这个 SYN+ACK 对不上,会回 RST 拒绝。

为什么挥手要四次?TIME_WAIT 为什么要等 2MSL?

参考回答: TCP 是全双工的,两个方向要分别关闭。一方发 FIN 只表示自己不再发数据,对方先回 ACK,但可能还有数据要发,发完了才发自己的 FIN,所以一般是四次。如果对方没有数据要发,ACK 和 FIN 可以合并,我抓包时就看到过只有三个包的情况。

TIME_WAIT 在主动关闭方,等 2MSL 是为了:一是最后一个 ACK 如果丢了,对方重发 FIN 时还能再回一次 ACK;二是让这条连接在网络里的旧包全部过期,不会干扰之后相同四元组的新连接。

大量 TIME_WAIT 和大量 CLOSE_WAIT 分别说明什么?

参考回答: 大量 TIME_WAIT 说明这台机器在主动关闭大量连接,通常是短连接太多,本身是正常的,问题主要是占用端口;可以改用长连接、连接池,服务器用 SO_REUSEADDR 保证能立刻重启。大量 CLOSE_WAIT 说明对方关了连接,但我们的程序没有调用 close,基本就是代码 bug,要查哪里漏了关闭。

Connection refused 和连接超时有什么区别?

参考回答: Connection refused 是对方机器收到了 SYN,但那个端口上没有程序监听,内核直接回了 RST,所以会立刻失败。连接超时是 SYN 发出去一直没有回应,可能是对方机器不存在、防火墙把包丢了、或者网络不通,客户端会重试几次 SYN,最后超时失败,通常要等几十秒。