TCP:学得越多越不懂

2020-04-06 11:35:43 +08:00
 felix021

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

== 握手 ==

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

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

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

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

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

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

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

== 3 次 ==

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

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

<s>由此可见知乎质量的下降。</s>

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

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 协议必须能够应对网络通信系统中损坏、丢失、重复或者乱序发送的数据。

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

举个例子:

由于接收方没有收到 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 协议并不对此作出限制:

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.

那么:

因此我们需要精心挑选一个 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 (如下图底部所示)。

                              +---------+ ---------\      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 大佬的画图功力)

    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

简单介绍下:

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

写不动了,真是没完没了(相信看到这里的同学已经不多了),但是 TCP 协议中还有很多有意思的设计本文完全没有涉及,文末我给出一些推荐阅读的链接,供感兴趣的同学参考。

== 总结 ==

~ 投递链接 ~

后端开发(上海) https://job.toutiao.com/s/sBAvKe

后端开发(北京) https://job.toutiao.com/s/sBMyxk

广告策略研发(上海) https://job.toutiao.com/s/sBDMAK

其他地区、职能线 https://job.toutiao.com/s/sB9Jqk

== 推荐阅读 ==

[1] RFC 793:TRANSMISSION CONTROL PROTOCOL

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

[2] Coolshell - TCP 的那些事儿 (上 & 下)

https://coolshell.cn/articles/11564.html

https://coolshell.cn/articles/11609.html

[3] 知乎 - TCP 为什么是三次握手,而不是两次或四?

https://www.zhihu.com/question/24853633

10123 次点击
所在节点    程序员
34 条回复
shino996
2020-04-06 21:32:52 +08:00
我终于学会了直接拉到底🌚
felix021
2020-04-06 21:49:46 +08:00
@shino996 都拉到底了,不投个简历吗
churchmice
2020-04-06 22:40:34 +08:00
不错
但说实话,这都是通信系统的常规操作
psirnull
2020-04-06 22:48:05 +08:00
疫情期间 不要握手
Keyes
2020-04-06 23:13:12 +08:00
@fishioon http://www.network-science.de/ascii
另推荐 Ascii Art Generator,我拿来做各种彩蛋 logo 用( 233
GeekBao
2020-04-07 08:28:16 +08:00
嗯,从听到看完所有评论。加深印象一次。
sakura1
2020-04-07 15:12:57 +08:00
理论上完全的停等协议应该是不需要报文序列号的,不过吞吐量必然极地,tcp 会一次发送多个报文,我觉得这也需要 ISN 的一个原因
felix021
2020-04-07 15:48:07 +08:00
@sakura1 对,为了实现滑动窗口,所以需要给字节编号
felix021
2020-04-08 21:55:33 +08:00
@sakura1 想了下,如果没有序列号的话,停等协议也会有坑,如果网络中滞留的包出现了,还是需要某种机制去识别出来。
hawken
2020-04-13 07:05:02 +08:00
文章写得很好,以后再复习一遍(希望近期面试用得上🤨)。不过三次握手的主要目的应该还有 阻止重复历史连接的初始化。这篇文章分析了一下: https://draveness.me/whys-the-design-tcp-three-way-handshake/

通过三次握手才能阻止重复历史连接的初始化;

通过三次握手才能对通信双方的初始序列号进行初始化;
Wirbelwind
2020-04-13 19:00:43 +08:00
标准和实现上还是有一点区别的

TIME_WAIT 在实现上有一个定时器,2MSL(不同系统不一样时间.)
FIN_WAIT2 好像也是有一个定时器,Linux 大概是 MSL 时间。但是标准应该没有规定这里需要,按照标准 FIN_WAIT2 应该一直等待。
GSNote
2020-05-12 15:25:05 +08:00
@also24 用词精准
NGPONG
2020-11-27 11:22:01 +08:00
@felix021

好文挖坟,楼主您好,我是初学者,在预览文章中对以下段落感觉到困惑,不吝赐教

> TCP 协议的要求是,实现一个大约每 4 微秒加 1 的 32bit 计数器(时钟),在每次创建一个新连接时,使用这个计数器的值作为 ISN 。
>
> 假设传输速度是 2 Mb/s,连接使用的 sequence number 大约需要 4.55 小时才会溢出并绕回( wrap-around )到 ISN 。即使提高到 100 Mb/s,也需要大约 5.4 分钟

这个 sequence number 到底要使用多少,不是依照其内部实现的计数器当前时间的值来决定的嘛,为啥这里会和传输速率扯上关系(越快那么这个 wrap-around 就会缩短)
felix021
2020-11-27 18:24:26 +08:00
@NGPONG seq 被用来标识数据流中每一个字节的位置,传输速度越快,int32 就会越快被用到溢出。

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/659779

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX