TCP协议

周末小课堂又开张了,这次我们来聊一聊 TCP 协议。

握手

多少有点令人意外的是,大多数程序员对 TCP 协议的印象仅限于在创建连接时的三次握手。

严格地说,“三次握手”其实是一个不太准确的翻译,英文原文是 “3-way handshake”,意思是握手有三个步骤。

不过既然教科书都这么翻译,我就只能先忍了。

“三次握手”的步骤相信各位都非常熟悉了:

1
2
3
A: 喂,听得到吗 (SYN)
B: 阔以,你呢 (SYN-ACK)
A: 我也阔以,开始唠吧 (ACK)

(咦,这不是远程面试的开场白吗)

那么问题来了:为什么不是 2 次握手或者 4 次握手呢?

三次握手

针对“为什么不是 4 次”,知乎的段子手是这么回答的:

1
2
3
4
A: 喂,听得到吗 (SYN)
B: 阔以,你呢 (SYN-ACK)
A: 我也阔以,你呢 (SYN-ACK)
B: ...我不想和傻\*说话 (FIN)

实际上,上面省略了真正重要的信息,在握手过程中传输的,不是“你能不能听得到”,而是:

1
2
3
A: 喂,我的数据从 x 开始编号 (SYN)
B: 知道了,我的从 y 开始编号 (SYN-ACK)
A: 行,咱俩开始唠吧 (ACK)

协商一个序号的过程需要一个来回(告知 + 确认),理论上需要 2 个来回( 4 次),互相确认了双方的初始序号( ISN,Initial Sequence Number ),才能真正开始通信。

由于第二个来回的“告知”可以和前一次的“确认”合并在同一个报文里(具体怎么结合后面讲),因此最终只需要 3 次握手,就可以建立起一个 tcp 链接。

这也解释了为什么不能只有 2 次握手:因为只能协商一个序号。

不过话说回来,知乎段子手的回复也不是全在抖机灵:毕竟,发起方怎么才能确认接收方已经知道发起方知道接收方知道了呢?即使发起方再问一遍,接收方又怎么知道发起方知道了接收方知道了呢?

很遗憾,结论是:无论多少个来回都不能保证双方达成一致。

由于实践中丢包率通常不高,因此最合理的做法就是 3 次握手( 2 个来回),少了不够,多了白搭;同时配上相应的容错机制。

例如 SYN+ACK 包丢失,那么发起方在等待超时后重传 SYN 包即可。

想想看,如果最后一个 ACK 丢了会怎样?

然后问题又来了:为什么需要协商初始序号,才能开始通信呢?

可靠

我们都知道,tcp 是一个“可靠”( Reliable )的协议。

这里“可靠”指的不是保证送达,毕竟网络链路中存在太多不可靠因素。

在 IETF 的 RFC 793 ( TCP 协议)中,Reliability 的具体定义是:TCP 协议必须能够应对网络通信系统中损坏、丢失、重复或者乱序发送的数据。

1
2
3
4
5
Reliability:

The TCP must recover from data that is damaged, lost, duplicated, or delivered out of order by the internet communication system.

https://tools.ietf.org/html/rfc793

为了保证这一点,tcp 需要给每一个 [字节] 编号:双方通过三次握手,互相确定了对方的初始序号,后续 [每个包的序号 - 初始序号] 就能标识该包在字节流中所处的位置,这样就可以通过重传来保证数据的连续性。

举个例子:

  • 发送方( ISN=4000 )
    • 发出 4001 、4002 、4003 、4004
    • 假设每个包只有 1 字节的数据
  • 接收方
    • 收到 4001 、4002 、4004
    • 4003 因为某种原因没有抵达
    • 这时上层应用只能读到 4001 、4002 中的信息

由于接收方没有收到 4003,因此给发送方的 ACK 中,序号最大值是 4003 (表示收到了 4003 之前的数据)。

过了一段时间( Linux 下默认是 1s ),发送方发现 4003 一直没被 ACK,就会重传这个包。

当接收方最终收到 4003 以后,上层应用才可以读到 4003 和 4004,从而保证其收到的消息都是可靠的。(以及,接收方需要给发送方 ACK,序号是 4005 )

注意:虽然 ISN=4000,但是发送方发送的第一个包,SEQ 是 4001 开始的,TCP 协议规定 SYN 需要占一个序号(虽然 SYN 并不是实际传输的数据),所以前面示意图中 ACK 的 seq 是 x+1 。同样,FIN 也会占用一个序号,这样可以保证 FIN 报文的重传和确认不会有歧义。

但是,为什么序号不能从 0 开始呢?

真实世界的复杂性总是让人头秃。

我们知道,操作系统使用五元组(协议=tcp,源 IP,源端口,目的 IP,目的端口)来标识一个连接,当一个包抵达时,会根据这个包的信息,将它分发到对应的连接去处理。

一般情况下,服务器的端口号通常是固定的(如 http 80 ),而操作系统会为客户端随机分配一个最近没有被使用的端口号,因此包总能被分发到正确的连接里。

但在某些特殊的场景下(例如快速、连续地开启和关闭连接),客户端使用的端口号也可能和上一次一样(或者用了其他刚断开的连接的端口号)。

而 TCP 协议并不对此作出限制:

1
The protocol places no restriction on a particular connection being used over and over again. ... New instances of a connection will be referred to as incarnations of the connection.

那么:

  • 如果前一个连接的包,因为某种原因滞留在网络中,这会儿才送达,客户端可能无法区分(其 sequence number 在本连接中可能是有效的)。

  • 恶意第三方伪造报文的难度很小。注意,在这个场景里,第三方并 [不需要] 处于通信双方的链路之间,只要他发出的报文可以抵达通信的一方即可。

因此我们需要精心挑选一个 ISN,使得上述 case 发生的可能性尽可能低。

注意:不是在 tcp 协议的层面上 100%避免,因为这会导致协议变得更复杂,实现上增加额外的开销,而在绝大多数情况下是不必要的。如果需要“100%可靠”,需要在应用层协议上增加额外的校验机制;或者使用类似 IPSec 这样的网络层协议来保证对包的有效识别。

那么,ISN 应该如何挑选呢?

ISN 生成器

说起来其实很简单:

TCP 协议的要求是,实现一个大约每 4 微秒加 1 的 32bit 计数器(时钟),在每次创建一个新连接时,使用这个计数器的值作为 ISN 。

假设传输速度是 2 Mb/s,连接使用的 sequence number 大约需要 4.55 小时才会溢出并绕回( wrap-around )到 ISN 。即使提高到 100 Mb/s,也需要大约 5.4 分钟。

而一个包在网络中滞留的时间通常是有限的,这个时间我们称之为 MSL ( Maximum Segment Lifetime ),工程实践中一般认为不会超过 2 分钟。

所以我们一般不用担心本次连接的早期 segment ( tcp 协议称之为 old duplicates )导致的混淆。

注:在家用千兆以太网已经逐渐普及、服务器间开始使用万兆以太网卡的今天,wrap-around 的时间已经降低到 32.8s (千兆)、3.28s (万兆),这个假定已经不太站得住脚了,因此 rfc1185 针对这种高带宽环境提出了一种扩展方案,通过在报文中加上时间戳,从而可以识别出这些 old duplicates 。

主要风险在于前面提到的场景:前一个连接可能传输了较多数据,因此其序列号可能大于当前连接的 ISN ;如果该连接的报文因为某种原因滞留、现在又突然冒出来,当前连接将无法分辨。

因此,TCP 协议要求在断开连接时,TIME-WAIT 状态需要保留 2 MSL 的时间才能转成 CLOSED (如下图底部所示)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
                             +---------+ ---------\      active OPEN
| CLOSED | \ -----------
+---------+<---------\ \ create TCB
| ^ \ \ snd SYN
passive OPEN | | CLOSE \ \
------------ | | ---------- \ \
create TCB | | delete TCB \ \
V | \ \
+---------+ CLOSE | \
| LISTEN | ---------- | |
+---------+ delete TCB | |
rcv SYN | | SEND | |
----------- | | ------- | V
+---------+ snd SYN,ACK / \ snd SYN +---------+
| |<----------------- ------------------>| |
| SYN | rcv SYN | SYN |
| RCVD |<-----------------------------------------------| SENT |
| | snd ACK | |
| |------------------ -------------------| |
+---------+ rcv ACK of SYN \ / rcv SYN,ACK +---------+
| -------------- | | -----------
| x | | snd ACK
| V V
| CLOSE +---------+
| ------- | ESTAB |
| snd FIN +---------+
| CLOSE | | rcv FIN
V ------- | | -------
+---------+ snd FIN / \ snd ACK +---------+
| FIN |<----------------- ------------------>| CLOSE |
| WAIT-1 |------------------ | WAIT |
+---------+ rcv FIN \ +---------+
| rcv ACK of FIN ------- | CLOSE |
| -------------- snd ACK | ------- |
V x V snd FIN V
+---------+ +---------+ +---------+
|FINWAIT-2| | CLOSING | | LAST-ACK|
+---------+ +---------+ +---------+
| rcv ACK of FIN | rcv ACK of FIN |
| rcv FIN -------------- | Timeout=2MSL -------------- |
| ------- x V ------------ x V
\ snd ACK +---------+delete TCB +---------+
------------------------>|TIME WAIT|------------------>| CLOSED |
+---------+ +---------+

TCP Connection State Diagram
Figure 6.

tcp 连接状态图,截取自 rfc 793

那么问题又来了:为什么只有 TIME-WAIT 需要等待 2MSL,而 LAST-ACK 不需要呢?

报文

针对 TCP 协议可以提的问题太多了,写得有点累,所以这里不打算继续自问自答了。

但写了这么多,还没有看一下 TCP 报文是什么结构的,实在不应该,这里还是祭出 rfc 793 里的 ascii art (并顺便佩服 rfc 大佬的画图功力)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
 0                   1                   2                   3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Source Port | Destination Port |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Sequence Number |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Acknowledgment Number |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Data | |U|A|P|R|S|F| |
| Offset| Reserved |R|C|S|S|Y|I| Window |
| | |G|K|H|T|N|N| |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Checksum | Urgent Pointer |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Options | Padding |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| data |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

TCP Header Format

简单介绍下:

  • 一行是 4 个字节( 32 bits ),header 一般共 5 行( options 和 padding 是可选的)
  • 第一行包含了源端口和目的端口
    • 每个端口 16bits,所以端口最大是 65535
    • 源 IP 和目的 IP 在 IP 报文头里
  • 第二行是本次报文的 Sequence Number
  • 第三行是 ACK 序列号
  • 第四行包含了较多信息:
    • 数据偏移量:4 字节的倍数,最小是 0101 ( 5 ),表示数据从第 20 个字节开始(大部分情况)
    • 控制位( CTL ):一共 6 个,其中的 ACK 、SYN 、FIN 就不介绍了
    • RST 是 Reset,遇到异常情况时通知对方重置连接(我们敬爱的防火墙很爱用它)
    • URG 表示这个报文很重要,应该优先传送、接收方应该及时给上层应用。URG 的数据不影响 seq,实际很少被用到,感兴趣的话可以参考下 RFC 854 ( Telnet 协议)
    • PSH 表示这个报文不应该被缓存、应当立即被发送出去。在交互式应用中比较常用,如 ssh,用户每按下一个键都应该及时发出去。注意和 Nagle 算法可能会有一些冲突。
    • 窗口大小:表示这个包的发送方当前可以接受的数据量(字节数),从这个包里的 ack 序号开始算起。用于控制滑动窗口大小的关键字段就是它了。

举个例子,三次握手的第二步,SYN 和 ACK 合并的报文就是这么生成的:

  • Sequence Number 填入从 ISN 生成器中获取的值
  • Acknowledgement Number 填入 [发送方的序号 + 1]
  • 将控制位中的 ACK 位、SYN 位都置 1

总结

  • TCP“三次握手”翻译不准确
  • 握手的目的是双方协商初始序列号 ISN
  • 序列号是用于保证通信的可靠性
  • 不使用 0 作为 ISN 可以避免一些坑
  • TCP 报文里包含了端口号、2 个序列号、一些控制位、滑动窗口大小