伯阳的网络笔记(四):TCP

669 阅读18分钟

因为疫情期间在外当志愿者,晚上回家无聊翻翻网络知识,权当记录了。
初始动笔:2019-02-11
修改时间:2019-04-01
GitHub Repo:BoyangBlog

1. TCP是什么?

互联网有两个核心协议: IP 和 TCP。IP,即 Internet Protocol(因特网协议)负责联网主机之间的路由选择和寻址;TCP,即 Transmission Control Protocol(传输控制协议)。

TCP 负责在不可靠的传输信道上提供可靠的抽象,向应用层隐藏了大多数网络通信的复杂细节。采用 TCP 数据流可以确保发送的所有字节都能够完整的被接收到,而且到达客户端的顺序也一样。一般来说, HTTP 协议是基于 TCP 的,但也不绝对,实际上已经有人用 UDP 来搞定 HTTP 了。

我们可以这么说,HTTP 协议专注于要传输的信息,TCP 协议专注于确保传输的可靠,而 IP 协议则负责因特网传输。

2. TCP 首部格式

首部格式如图所示:

它的标准长度是 20 字节。TCP 中没有单独的字段表示包长度和数据长度。可由 IP 层获知 TCP 的包长,由 TCP 的包长可知数据的长度。

  • 序列号 (Sequence Number):
    字段长 32 位,序列号是指发送数据的位置,每发送一次数据,就累加一次该数据字节数的大小。序列号不会从0 或者 1 开始,而是在建立连接的时候由计算机生成的随机数作为其初始值,通过 SYN 包传给接收端主机。

  • 确认应答号 (Acknowledgement Number):
    确认应答号字段长度为 32 位。是指下一次应该收到的数据的序列号,实际上,它是指已收到确认应答号减一为止的数据。发送端收到这个确认应答以后可以认为在这个序号以前的数据都已经正常接收了。ACK=1 时有效。

  • 数据偏移 (Data Offset):
    该字段表示 TCP 所传输的数据部分应该从 TCP 包的哪个位开始计算,当然也可以把它看作 TCP 首部的长度。该字段长 4 位,单位为 4 字节 (即 32 位)。不包含选项字段的话,数据偏移字段可以设置为 5 。反之,如果该字段的值为 5,那说明从 TCP 包的最一开始到 20 字节为止都是 TCP 首部,余下的部分为 TCP 数据。

  • 保留 (Reserved):
    该字段主要是为了以后扩展使用,其长度为 4 位,一般设置成 0 ,即使收到的包在该字段不为 0 ,此包也不会被丢弃。

  • 控制位 (Control Flag):
    字段长为 8 位,从左往右分别如下图:

  • 校验和 (Checksum):
    TCP 的校验和和 UDP 的相似,区别在于 TCP 的校验和无法关闭(UDP 可以在校验和字段填 0 ,来关闭校验)与 UDP 数据报一样,TCP 数据报段在计算校验和时也包括一个 12 字节长的伪首部。

  • 紧急指针
    该字段为 16 位,只有在 URG 控制位为 1 的时候有效。该字段的数值表示本报文段中紧急数据的指针。

  • 选项
    选项字段用于提高 TCP 的传输性能,因为根据数据偏移(首部长度)进行控制,所以其长度最大为 40 字节。另外,选项字段尽量调整其为 32 位的整数倍。

控制位

控制位的细节如下。

  1. CWR (Congestion Window Reduced):
    CWR 标志和后面的 ECE 标志都用于 IP 首部的 ECN 字段,ECE 字段为 1 时,则通知对方已将拥塞窗口缩小。

  2. ECE (ECN-Echo):
    ECE 标志表示 ECN-Echo。置为 1 ,代表会通知对方,从对方到这边的网络有拥塞。

  3. URD (Urgent Flag):
    该位为 1,代表包中有需要紧急处理的数据。

  4. ACK (Acknowledgement Flag):
    该位为 1,代表确认应答的字段变有效。

  5. PSH (Push Flag):
    该位为 1,表示需要将受到的数据立即传给上层应用协议。为 0 表示不用立即传,先进行缓存。

  6. RST (Reset Flag):
    该位为 1,表示 TCP 连接中出现异常必须强制断开连接。

  7. SYN (Synchronize Flag):
    为 1 表示希望建立连接,并在其序列号字段进行序列号的随机初始值的设定。

  8. FIN (Fin Flag):
    该位为 1,表示今后再也没有数据发送了,希望断开连接。

3. TCP 三次握手

所有的 TCP 连接一开始都要经过三次握手,如下图所示。客户端在于服务器在交换应用数据之前,必须就起始分组序列号,以及其他一些连接相关的细节达成一致。处于安全考虑,序列号由两端随机生成。

  1. 第一次握手(SYN=1, seq=x):
    客户端发送一个 TCP 的 SYN 标志位置1的包,指明客户端打算连接的服务器的端口,以及初始序号 X,保存在包头的序列号(Sequence Number)字段里,即 ISN。序列号的实现目前会随着时间的变化而变化,所以每次建立连接时的序列号都不同。
    发送完毕后,客户端进入 SYN_SEND 状态。

  2. 第二次握手(SYN=1, ACK=1, seq=y, ACKnum=x+1):
    服务器发回确认包(ACK)应答。即 SYN 标志位和 ACK 标志位均为1。服务器端选择自己 ISN(init SEQ num) 序列号,放到 Seq 域里,同时将确认序号(Acknowledgement Number)设置为客户的 ISN 加 1,即 X+1。
    发送完毕后,服务器端进入 SYN_RCVD 状态。

  3. 第三次握手(ACK=1,ACKnum=y+1):
    客户端再次发送确认包( ACK ),SYN 标志位为0,ACK 标志位为1,并且把服务器发来 ACK 的序号字段 +1,放在确定字段中发送给对方,并且在数据段将服务器的 ISN 的 +1。
    发送完毕后,客户端进入 ESTABLISHED 状态。当服务器端接收到这个包时,也进入 ESTABLISHED 状态,TCP 握手结束。

三次握手完成之后,客户端与服务器之间就可以通信了。客户端在发送 ACK 分组之后立即发送数据,而服务器必须等到受到 ACK 分组之后才能发送数据。这个启动通信的过程适用于所有 TCP 连接。

三次握手带来的延迟是的每创建一个新的 TCP 连接都要三次握手,而这也决定了,提高 TCP 应用性能的关键在于想办法重用连接

为什么是“三次”

我们可以很明显的发现,只要两次其实就可以开始连接了,为什么要多出来第三次呢(你别问为什么不四次五次,要考虑性能的好伐)?

事实上我们可以这么说,只有两次握手,可以保证连接成功,而第三次的握手是用来声明双方的连接是正常的。我们要明白,网络环境是不稳定的,尤其是在几十年前。我们可以想象这样一个画面:客户端发出一个连接请求,但是很久没有收到消息,所以又发送了一个连接请求,但是第一个请求因为网络拥塞延迟到达,甚至在服务器接收第二个请求然后发送消息了之后才到达。

我们知道请求的序列号其实是随机的,那么服务器实际上是没法知道这两个的先后顺序。

所以,将第二次握手的数据发送过去的序列号 +1,放在确定字段中发送给对方是非常重要的。这样保证了不会浪费资源多次请求。

RST

RST (Reset Flag): 该位为 1,表示 TCP 连接中出现异常必须强制断开连接。

TCP 选择使用三次握手来建立连接并在连接引入了 RST 这一控制消息,接收方当收到请求时会将发送方发来的 SEQ+1 发送给对方,这时由发送方来判断当前连接是否是历史连接:

  • 如果当前连接是历史连接,即 SEQ 过期或者超时,那么发送方就会直接发送 RST 控制消息中止这一次连接;
  • 如果当前连接不是历史连接,那么发送方就会发送 ACK 控制消息,通信双方就会成功建立连接;

序列号

再说一次,网络是不稳定的,即使建立了连接,发送的消息也有可能在接收的时候顺序颠倒。为了重新排序以及去重,这里引入了序列号这个概念。

序列号的初始化需要在三次握手的时候进行初始化。由于 TCP 连接通信的双方都需要获得初始序列号,所以它们其实需要向对方发送 SYN 控制消息并携带自己期望的初始化序列号 SEQ,对方在收到 SYN 消息之后会通过 ACK 控制消息以及 SEQ+1 来进行确认。

握手的时候可以带数据

曾经抓包的时候,发现 TCP 的第三次握手是可以带数据的,然后查阅资料后发现,这个其实非常正常,是个常见情况。不过随着我继续深入的查阅,很惊讶的发现,其实第一次握手也是可以带数据的!!!

原始 TCP 标准RFC 793确实允许与第一个 SYN 数据包一起发送数据,作为握手的一部分,但是在握手完成之前,此类数据无法释放。大多数传统的 TCP 编程接口都不支持它。

4. TCP 拥塞控制 & 流量控制

第三次说:网络是不稳定的!

如果网络出现拥塞(比较常见的是我们称之为弱网的情形),分组将会丢失,此时发送方会继续重传,从而导致网络拥塞程度更高。因此当出现拥塞时,应当控制发送方的速率,这是拥塞控制

而网络情况突然好的不得了,一堆数据一拥而上,也会有问题。事实上,接收方也许正在忙于其他任务,甚至要过很长时间才去读取该数据。如果某应用程序读取数据时相对缓慢,而发送方发送的太多、太快,发送的数据就会很容易地使该连接的接收缓存溢出。这个也需要控制,这个控制就是流量控制

很多人会将这两者混淆,但是要明白,虽然这两者的动作非常相似(对发送方的遏制),但是它们显然是针对完全不同的场景而采取的措施。

因为这两者的动作非常类似,可以统一的来说。TCP 主要使用四种方法:慢启动拥塞避免快速恢复。发送方需要维护一个叫做拥塞窗口(Congestion Window-cwnd)的状态变量。

慢启动 和 拥塞避免

TCP 协议使用慢启动阈值(Slow start threshold, ssthresh)来决定使用慢启动或者拥塞避免算法:

  • 当拥塞窗口大小小于慢启动阈值时,使用慢启动;
  • 当拥塞窗口大小大于慢启动阈值时,使用拥塞避免算法;
  • 当拥塞窗口大小等于慢启动阈值时,使用慢启动或者拥塞避免算法;

慢启动实际上指的是一个启动的过程:

  1. 最开始 cwnd=1,发送方只发送一个 MSS(最大报文段长度)大小的数据包,在一个 RTT 后,会收到一个 ACK ,cwnd 加一,cwnd=22.
  2. 此时 cwnd=2,则发送方要发送两个 MSS 大小的数据包,发送方会收到两个 ACK,则 cwnd 会进行两次加一的操作,则也就是 cwnd+2,则 cwnd=4,也就是 cwnd = cwnd * 2
  3. 此时 cwnd=4,则发送方要发送四个 MSS 大小的数据包,发送方会收到四个 ACK ,则 cwnd 会加4,则 cwnd = 8,也就是 cwnd = cwnd * 2.

这个阶段,也被称之为“指数增长”阶段。

一旦发现当发送方发送的数据包丢包时,就会开始拥塞避免算法:

  • 慢启动阈值会设置为拥塞窗口大小的一半;
  • 不再使用“指数增长”的方式,而是“加法增长”。

整个流程如图所示:

快速恢复

在接收方,要求每次接收到报文段都应该发送对已收到有序报文段的确认,例如已经接收到 M1 和 M2,此时收到 M4,应当发送对 M2 的确认。

在发送方,如果收到三个重复确认,那么可以确认下一个报文段丢失,例如收到三个 M2 ,则 M3 丢失。此时执行快重传,立即重传下一个报文段。

在这种情况下,只是丢失个别报文段,而不是网络拥塞,因此执行快恢复,令 ssthresh = cwnd/2 ,cwnd = ssthresh ,注意到此时直接进入拥塞避免。

5. SYN 洪泛攻击

在三次握手过程中,服务器发送 SYN-ACK 之后,收到客户端的 ACK 之前的 TCP 连接称为半连接(half-open connect)。此时服务器处于 SYN_RCVD 状态。当收到 ACK 后,服务器才能转入 ESTABLISHED 状态.

SYN 攻击指的是,攻击客户端在短时间内伪造大量不存在的 IP 地址,向服务器不断地发送 SYN 包,服务器回复确认包,并等待客户的确认。由于源地址是不存在的,服务器需要不断的重发直至超时,这些伪造的 SYN 包将长时间占用未连接队列,正常的 SYN 请求被丢弃,导致目标系统运行缓慢,严重者会引起网络堵塞甚至系统瘫痪。我猜这肯定也是 TCP 第一次绝对不可以带数据发送的原因。SYN 攻击是一种典型的 DoS/DDoS 攻击。

面对这种情况,可以使用 SYN cookie 方法来解决:

  • SYN cookie
    是对 TCP 服务器端的三次握手做一些修改,专门用来防范 SYN 洪泛攻击的一种手段。它的原理是,在 TCP服务器接收到 TCP SYN 包并返回 TCP SYN + ACK 包时,不分配一个专门的数据区,而是根据这个 SYN 包计算出一个 cookie 值。这个 cookie 作为将要返回的 SYN ACK 包的初始序列号。当客户端返回一个 ACK 包时,根据包头信息计算 cookie,与返回的确认序列号(初始序列号 + 1)进行对比,如果相同,则是一个正常连接,然后,分配资源,建立连接。

6. 四次挥手

四次挥手指的是断开连接的过程,如下图所示。

  1. 第一次挥手(FIN=1,seq=x)
    假设客户端想要关闭连接,客户端发送一个 FIN 标志位置为1的包,表示自己已经没有数据可以发送了,但是仍然可以接受数据。
    发送完毕后,客户端进入 FIN_WAIT_1 状态。

  2. 第二次挥手(ACK=1,ACKnum=x+1)
    服务器端确认客户端的 FIN 包,发送一个确认包,表明自己接受到了客户端关闭连接的请求,但还没有准备好关闭连接。
    发送完毕后,服务器端进入 CLOSE_WAIT 状态,客户端接收到这个确认包之后,进入 FIN_WAIT_2 状态,等待服务器端关闭连接。

  3. 第三次挥手(FIN=1,seq=y)
    服务器端准备好关闭连接时,向客户端发送结束连接请求,FIN 置为1。
    发送完毕后,服务器端进入 LAST_ACK 状态,等待来自客户端的最后一个 ACK。

  4. 第四次挥手(ACK=1,ACKnum=y+1)
    客户端接收到来自服务器端的关闭请求,发送一个确认包,并进入 TIME_WAIT 状态,等待可能出现的要求重传的 ACK 包。
    服务器端接收到这个确认包之后,关闭连接,进入 CLOSED 状态。
    客户端等待了某个固定时间(两个最大段生命周期,2MSL,2 Maximum Segment Lifetime)之后,没有收到服务器端的 ACK ,认为服务器端已经正常关闭连接,于是自己也关闭连接,进入 CLOSED 状态。

为什么要四次

因为 TCP 是全双工模式,客户端请求关闭连接后,客户端向服务端的连接关闭(一二次挥手),服务端继续传输之前没传完的数据给客户端(数据传输),服务端向客户端的连接关闭(三四次挥手)。所以 TCP 释放连接时服务器的 ACK 和 FIN 是分开发送的(中间隔着数据传输),而 TCP 建立连接时服务器的 ACK 和 SYN 是一起发送的(第二次握手),所以 TCP 建立连接需要三次,而释放连接则需要四次。

而之所以释放的时候 ACK 和 FIN 要分开发送,因为客户端请求释放时,服务器可能还有数据需要传输给客户端,因此服务端要先响应客户端 FIN 请求(服务端发送 ACK),然后数据传输,传输完成后,服务端再提出 FIN 请求(服务端发送 FIN);而连接时则没有中间的数据传输,因此连接时可以 ACK 和 SYN 一起发送。

TIME-WAIT

TIME-WAIT 是一个很有趣的设置,它仅在主动断开连接的一方出现,被动断开连接的一方会直接进入 CLOSED 状态,进入 TIME_WAIT 的客户端需要等待 2 MSL 才可以真正关闭连接。TCP 协议需要 TIME_WAIT 状态的原因和客户端需要等待两个 MSL 不能直接进入 CLOSED 状态的原因是一样的:

  • 防止延迟的数据段被其他使用相同源地址、源端口、目的地址以及目的端口的 TCP 连接收到;
  • 保证 TCP 连接的远程被正确关闭,即等待被动关闭连接的一方收到 FIN 对应的 ACK 消息。

防止延迟的数据段

每一个 TCP 数据段都包含唯一的序列号,这个序列号能够保证 TCP 协议的可靠性和顺序性,在不考虑序列号溢出归零的情况下,序列号唯一是 TCP 协议中的重要约定,一旦违反了这条规则,就可能造成令人困惑的现象和结果。为了保证新 TCP 连接的数据段不会与还在网络中传输的历史连接的数据段重复,TCP 连接在分配新的序列号之前需要至少静默数据段在网络中能够存活的最长时间,即 MSL。

而之所以设置 2 倍 MSL,可能是因为网络中可能存在来自发起方的数据段,当这些发起方的数据段被服务端处理后又会向客户端发送响应,所以一来一回需要等待 2 倍的时间。

保证连接关闭

如果客户端等待的时间不够长,当服务端还没有收到 ACK 消息时,客户端就重新与服务端建立 TCP 连接就会造成以下问题 — 服务端因为没有收到 ACK 消息,所以仍然认为当前连接是合法的,客户端重新发送 SYN 消息请求握手时会收到服务端的 RST 消息,连接建立的过程就会被终止。

总结

通过上面的阅读,可以发现,TCP 是一个设计的非常精妙的保障连接的机制。但是因为目标集中在保障连接上,会因为种种保障机制而产生性能问题。

  1. TCP 的拥塞控制在发生丢包时会进行退让,减少能够发送的数据段数量,但是丢包并不一定意味着网络拥塞,更多的可能是网络状况较差;
  2. TCP 的三次握手带来了额外开销,这些开销不只包括需要传输更多的数据,还增加了首次传输数据的网络延迟;
  3. TCP 的重传机制在数据包丢失时可能会重新传输已经成功接收的数据段,造成带宽的浪费;

虽然对于一个移动开发者还说,这些已经够用了,但是不要忘了我们另外一个身份,工程师。有机会应该继续把剩下的东西补上,比如说 TCP 里的很多细节设计。有时间一定要把 《TCP/IP详解》好好读一读。

引用

RFC791

RFC793

为什么 TCP 建立连接需要三次握手

为什么 TCP 协议有 TIME_WAIT 状态

《An Introduction to Computer Networks:12.TCP Transport》

《Web权威性能指南》

《计算机网络》

《计算机网络:自顶向下方法》

《TCP/IP详解:卷一:协议》