TCP:学得越多越不懂
2020-04-06 21:51:32 Author: wiki.ioin.in(查看原文) 阅读量:448 收藏

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

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

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

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

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

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

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

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

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

举个例子:

  • 发送方( 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 协议并不对此作出限制:

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 应该如何挑选呢?

说起来其实很简单:

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

简单介绍下:

  • 一行是 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 协议中还有很多有意思的设计本文完全没有涉及,文末我给出一些推荐阅读的链接,供感兴趣的同学参考。

  • TCP“三次握手”翻译不准确
  • 握手的目的是双方协商初始序列号 ISN
  • 序列号是用于保证通信的可靠性
  • 不使用 0 作为 ISN 可以避免一些坑
  • TCP 报文里包含了端口号、2 个序列号、一些控制位、滑动窗口大小
  • 我在字节跳动网盟广告业务线(穿山甲),由于业务持续高速发展,长期缺人。关于字节跳动面试的详情,可参考我之前写的

~ 投递链接 ~

后端开发(上海) 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

felix021

1

felix021   9 小时 2 分钟前

回答文中的问题:三次握手中的最后一个 ACK 丢了会怎样?

A:如果接着有数据要发(例如 http 请求),由于在 A 看来连接已经建立,可以立即发出下一个数据包,这个报文中也会有 ack seq,B 收到后就能成功建立连接。

B:如果一直没收到 ACK,SYN+ACK 这个包的计时器会超时,然后主动重传,直到收到 A 的 ACK ;如果重传多次仍然没有收到 ACK,则停止尝试,关闭该 TCP 连接。

felix021

2

felix021   5 小时 22 分钟前

827 次点击,627 个会员阅读,21 个收藏,1 个感谢,0 个回复

_(:-] 」∠)_

Liampor

3

Liampor   5 小时 6 分钟前

赞,就是那种字符画是用什么画的呢

LosLord

4

LosLord   4 小时 45 分钟前

现在招人都这么花式的吗

hcocoa

6

hcocoa   4 小时 11 分钟前

看题目以为要讨论拥塞控制

lhx2008

7

lhx2008   3 小时 49 分钟前 via Android

确实的干货,知乎上面的那个回答完全就是瞎搞

fishioon

9

fishioon   3 小时 20 分钟前

优秀的文章;其实某种意义上 TCP 是支持 2 次握手的,TCP 握手第三步是客户端发 ACK 给服务端,假如这时候不发送 ACK,直接发 DATA+ACK,服务也是能够正常转换成 established 并且接受数据的

dexter

10

dexter   1 小时 59 分钟前

收藏收藏,浅显易懂,像我这样的小白都看的懂了

ujued

11

ujued   1 小时 56 分钟前 via iPhone   ❤️ 1

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

不是太理解。TCP 是可以 100%准确传递上层要求传递的数据分组的,收到 ACK 分组,才算分组传递成功,这是有硬性要求分组准确有序传达。

2. TCP“三次握手”翻译不准确

三次握手这个还算准确,毕竟礼仪之邦!
3 次数据分组的发送,可以理解为 3 种方式握手,即双方相互握手。
说是 一次握手分 3 步 问题也不大。

3. 序列号是用于保证通信的可靠性

更准确的讲,序列号是为了解决 ACK 分组受损或丢失而超时重发哪些分组,以及分组排序(选择重传差错恢复策略)。通信的可靠性不仅仅是由序列号保证的,是和检验和、ACK 分组、重传机制一起协作完成的。

4. 不使用 0 作为 ISN 可以避免一些坑

可以用 0 作为 ISN,每个 TCP 连接 ISN 起始号是随时间递增取模再加一个 hash 的值,可以为 0 。不过不会固定使用 0 或任何可以猜到的数字作为起始号是为了安全考虑。RFC1948 有详细介绍,以及为什么在 TCP 协议解决,如何解决的。大致问题是这样的:
如果在 3 次握手中,攻击者在发起连接的一方未收到 ACK 时而率先发送自己的 ISN,而抢先和对方建立连接,这时 3 次握手成了非法入侵者的机会。

also24

12

also24   1 小时 33 分钟前   ❤️ 1

@ujued #11
1 、联系上下文来看,楼主说的 『 100%避免』指的应该是:
精心挑选的 ISN 无法 100% 的避免『误收旧包』和『恶意伪造』这两种情况。

2 、这个全看个人喜好,我赞同『三次』更容易带来误解

3 、其实我感觉楼主这里应该没有将它描述为充分条件的意思,如果描述为这样可能会更严谨:
TCP 设计了若干基于序列号的机制,用于保证通信的可靠性。

4 、联系上下文来看,楼主所说的『不使用 0 作为 ISN 』应该指的是:
『不固定使用 0 』 作为 ISN 。而不是说 ISN 『始终不应为 0 』。

另外,『不固定使用 0 』最初应该只是为了避免 『误收旧包』。
后续 rfc1948 提出的『不使用容易被猜到的数字』才是为了安全原因。

这一点可以在 rfc793 中查证:
To avoid confusion we must prevent segments from one incarnation of a connection from being used while the same sequence numbers may still be present in the network from an earlier incarnation. We want to assure this, even if a TCP crashes and loses all knowledge of the sequence numbers it has been using. When new connections are created, an initial sequence number (ISN) generator is employed which selects a new 32 bit ISN. The generator is bound to a (possibly fictitious) 32 bit clock whose low order bit is incremented roughly every 4 microseconds. Thus, the ISN cycles approximately every 4.55 hours. Since we assume that segments will stay in the network no more than the Maximum Segment Lifetime (MSL) and that the MSL is less than 4.55 hours we can reasonably assume that ISN's will be unique.

AllenHua

14

AllenHua   1 小时 11 分钟前 via iPhone

你收藏有了

felix021

17

felix021   1 小时 0 分钟前 via Android

@hcocoa 本来想写一点的,但开了个头就写了 3000 多字,实在写不动了

felix021

18

felix021   56 分钟前 via Android

@fishioon 对的,这点我在第一个回复里提到了。实际上对于 A 来说,多发一个 ack 没啥区别,因为不用等 B 的回复,数据可以立即发出; 但对于 B 来说,没有 ack 就没法继续,这头看起来还是 3 次。


文章来源: https://wiki.ioin.in/url/nYN2
如有侵权请联系:admin#unsafe.sh