TCP协议总结

  • TCP是面向连接
    所谓的建立连接,是为了在客户端和服务端维护连接,而建立一定的数据结构来维护双方交互的状态,用这样的数据结构来保证所谓的面向连接的特性
  • TCP 提供可靠交付
    通过 TCP 连接传输的数据,无差错、不丢失、不重复、并且按序到达。
  • TCP 是面向字节流
    发送的时候发的是一个流,没头没尾。
  • 拥塞控制
    TCP意识到包丢弃了或者网络的环境不好了,就会根据情况调整自己的行为,看看是不是发快了,要不要发慢点。

TCP 优点

  • 可靠,稳定
    TCP 的可靠体现在 TCP 在传递数据之前,会有三次握手来建立连接,而且在数据传递时,有确认、窗口、重传、拥塞控制机制,在数据传完后,还会断开连接用来节约系统资源。

TCP 缺点

慢,效率低,占用系统资源高,易被攻击
TCP 在传递数据之前,要先建连接,这会消耗时间,而且在数据传递时,确认机制、重传机制、拥塞控制机制等都会消耗大量的时间,而且要在每台设备上维护所有的传输连接,事实上,每个连接都会占用系统的 CPU、内存等硬件资源。 而且,因为 TCP 有确认机制、三次握手机制,这些也导致 TCP 容易被人利用,实现 DOS、DDOS、CC 等攻击。

TCP的数据包格式

TCP数据包格式

  1. 源端口号和目标端口号,用于确定数据应该发给哪个应用;
  2. 包序号
    用于解决乱序问题;
  3. 确认序号
    解决丢包问题,如果没有收到就重新发送,直到送达。
  4. 状态位
    例如SYN是发起一个连接;
    ACK是回复;
    RST是重新连接;
    FIN是结束连接。
  5. 窗口大小
    TCP要做到流量控制,通信双方各声明一个窗口,标识自己当前的处理能力。

连接的建立 - 三次握手

TCP三次握手
一开始,客户端和服务端都处于 CLOSED 状态。先是服务端主动监听某个端口,处于 LISTEN 状态。然后客户端主动发起连接 SYN,之后处于 SYN-SENT 状态。服务端收到发起的连接,返回 SYN,并且 ACK 客户端的 SYN,之后处于 SYN-RCVD 状态。客户端收到服务端发送的 SYN 和 ACK 之后,发送 ACK 的 ACK,之后处于 ESTABLISHED 状态,因为它一发一收成功了。服务端收到 ACK 的 ACK 之后,处于 ESTABLISHED 状态,因为它也一发一收了。
从连接建立的流程中可见,三次握手除了双方建立连接外,还能确定TCP包的序号问题。

总而言之:

  1. 首先 Client 端发送连接请求报文,Server 段接受连接后回复 ACK 报文,并为这次连接分配资源。
  2. Client 端接收到 ACK 报文后也向 Server 段发生 ACK 报文,并分配资源,这样 TCP 连接就建立了。
    TCP三次握手
    最初两端的 TCP 进程都处于 CLOSED 关闭状态,A(Client)主动打开连接,而 B(Server)被动打开连接。(A、B 关闭状态 CLOSED——B 创建 TCB,进入 LISTEN 状态,等待 A 请求——A 同步已发送状态 SYN-SENT——B 同步收到状态 SYN-RCVD——A、B 连接已建立状态 ESTABLISHED)
  3. 第一次握手:起初两端都处于 CLOSED 关闭状态,A(Client)将标志位 SYN 置为 1,随机产生一个值 seq=x,并将该数据包发送给 B(Server),A(Client)进入 SYN-SENT 状态,等待 B(Server)确认;
  4. 第二次握手:B(Server)收到连接请求报文段后,如同意建立连接,则向 A(Client)发送确认,在确认报文段中(SYN=1,ACK=1,确认号 ack=x+1,初始序号 seq=y),B(Server)TCP 服务器进程进入 SYN-RCVD(同步收到)状态;
  5. 第三次握手:TCP 客户进程收到 B(Server)的确认后,要向 B(Server)给出确认报文段(ACK=1,确认号 ack=y+1,序号 seq=x+1)(初始为 seq=x,第二个报文段所以要+1),ACK 报文段可以携带数据,不携带数据则不消耗序号。TCP 连接已经建立,A 进入 ESTABLISHED(已建立连接)。
    当 B 收到 A 的确认后,也进入 ESTABLISHED 状态。

    TCB 传输控制块 Transmission Control Block,存储每一个连接中的重要信息,如 TCP 连接表,到发送和接收缓存的指针,到重传队列的指针,当前的发送和接收序号。

一些问题:

  1. 为什么 A 还要发送一次确认呢?可以二次握手吗?
    主要为了防止已失效的连接请求报文段突然又传送到了 B,因而产生错误。如 A 发出连接请求,但因连接请求报文丢失而未收到确认,于是 A 再重传一次连接请求。后来收到了确认,建立了连接。数据传输完毕后,就释放了连接,A 发出了两个连接请求报文段,其中第一个丢失,第二个到达了 B,但是第一个丢失的报文段只是在某些网络结点长时间滞留了,延误到连接释放以后的某个时间才到达 B,此时 B 误认为 A 又发出一次新的连接请求,于是就向 A 发出确认报文段,同意建立连接,不采用三次握手,只要 B 发出确认,就建立新的连接了,此时 A 不理睬 B 的确认且不发送数据,则 B 一致等待 A 发送数据,浪费资源。
  2. Server 端易受到 SYN 攻击?
    服务器端的资源分配是在二次握手时分配的,而客户端的资源是在完成三次握手时分配的,所以服务器容易受到 SYN 洪泛攻击,SYN 攻击就是 Client 在短时间内伪造大量不存在的 IP 地址,并向 Server 不断地发送 SYN 包,Server 则回复确认包,并等待 Client 确认,由于源地址不存在,因此 Server 需要不断重发直至超时,这些伪造的 SYN 包将长时间占用未连接队列,导致正常的 SYN 请求因为队列满而被丢弃,从而引起网络拥塞甚至系统瘫痪。
    防范 SYN 攻击措施:降低主机的等待时间使主机尽快的释放半连接的占用,短时间受到某 IP 的重复 SYN 则丢弃后续请求。

连接的断开 - 四次挥手

TCP四次挥手
假设 Client 端发起中断连接请求,也就是发送 FIN 报文。Server 端接到 FIN 报文后,意思是说”我 Client 端没有数据要发给你了”,但是如果你还有数据没有发送完成,则不必急着关闭 Socket,可以继续发送数据。所以你先发送 ACK,”告诉 Client 端,你的请求我收到了,但是我还没准备好,请继续你等我的消息”。这个时候 Client 端就进入 FIN_WAIT 状态,继续等待 Server 端的 FIN 报文。当 Server 端确定数据已发送完成,则向 Client 端发送 FIN 报文,”告诉 Client 端,好了,我这边数据发完了,准备好关闭连接了”。Client 端收到 FIN 报文后,”就知道可以关闭连接了,但是他还是不相信网络,怕 Server 端不知道要关闭,所以发送 ACK 后进入 TIME_WAIT 状态,如果 Server 端没有收到 ACK 则可以重传。“,Server 端收到 ACK 后,”就知道可以断开连接了”。Client 端等待了 2MSL 后依然没有收到回复,则证明 Server 端已正常关闭,那好,我 Client 端也可以关闭连接了。Ok,TCP 连接就这样关闭了!

数据传输结束后,通信的双方都可释放连接,A 和 B 都处于 ESTABLISHED 状态。(A、B 连接建立状态 ESTABLISHED——A 进入等待 1 状态 FIN-WAIT-1——B 关闭等待状态 CLOSE-WAIT——A 进入等待 2 状态 FIN-WAIT-2——B 最后确认状态 LAST-ACK——A 时间等待状态 TIME-WAIT——B、A 关闭状态 CLOSED)

  1. A 的应用进程先向其 TCP 发出连接释放报文段(FIN=1,序号 seq=u),并停止再发送数据,主动关闭 TCP 连接,进入 FIN-WAIT-1(终止等待 1)状态,等待 B 的确认。
  2. B 收到连接释放报文段后即发出确认报文段,(ACK=1,确认号 ack=u+1,序号 seq=v),B 进入 CLOSE-WAIT(关闭等待)状态,此时的 TCP 处于半关闭状态,A 到 B 的连接释放。
  3. A 收到 B 的确认后,进入 FIN-WAIT-2(终止等待 2)状态,等待 B 发出的连接释放报文段。
  4. B 没有要向 A 发出的数据,B 发出连接释放报文段(FIN=1,ACK=1,序号 seq=w,确认号 ack=u+1),B 进入 LAST-ACK(最后确认)状态,等待 A 的确认。
  5. A 收到 B 的连接释放报文段后,对此发出确认报文段(ACK=1,seq=u+1,ack=w+1),A 进入 TIME-WAIT(时间等待)状态。此时 TCP 未释放掉,需要经过时间等待计时器设置的时间 2MSL 后,A 才进入 CLOSED 状态。

总而言之:

  1. 刚开始A发送断开连接请求,进入FIN_WAIT_1状态;
  2. B接收请求进入CLOSED_WAIT状态,并发回响应;
  3. A接收B响应,进入FIN_WAIT_2状态;
  4. B发送断开连接请求,并进入LAST_ACK状态;
  5. A接收请求,进入TIME_WAIT状态,并发回响应;
    A会在发出响应2MSL后自动进入CLOSED状态。
    MSL 是 Maximum Segment Lifetime,报文最大生存时间,它是任何报文在网络上存在的最长时间,超过这个时间报文将被丢弃。因为 TCP 报文基于是 IP 协议的,而 IP 头中有一个 TTL 域,是 IP 数据报可以经过的最大路由数,每经过一个处理他的路由器此值就减 1,当此值为 0 则数据报将被丢弃,同时发送 ICMP 报文通知源主机。协议规定 MSL 为 2 分钟,实际应用中常用的是 30 秒,1 分钟和 2 分钟等。
  6. B接收响应后进入CLOSED状态。

该流程中让人比较困惑的问题是:

  1. 为什么A断开连接后,第4步还要B在反向发起一次断开连接?
    不能直接断开,因为A单方面断开连接时,A不知道B是不是还有事情要处理。
  2. 如果第4步B没有重新发起断开连接(只有2次挥手),连接怎么断开?
    如果这期间B宕机了没有重新发起断开连接,A将永远停留在FIN_WAIT_2的状态,TCP 协议里面并没有对这个状态的处理,但是 Linux 有,可以调整 tcp_fin_timeout 这个参数,设置一个超时时间。
  3. 第5步如果A响应没有返回给B,B怎么断开?
    此时B会重新发起一次断开请求,因而TCP要求A最后等待一段时间TIME_WAIT,这个时间要足够长,长到如果B没有收到ACK,B可以重试且时间足够到达。
    不过,还有一个异常情况就是,B 超过了 2MSL 的时间,依然没有收到它发的 FIN 的 ACK,怎么办呢?按照 TCP 的原理,B 当然还会重发 FIN,这时A再收到这个包后,会直接返回RST,B就知道早已断开连接了。
  4. TIME_WAIT
    TIME_WAIT 状态容易被人误解,比如当使用三方系统的服务时,看到系统 TIME_WAIT 数量特别高,于是赖对方没有及时把连接释放掉,实际上TIME_WAIT 产生在主动断开连接的一方
  5. 为什么 A 在 TIME-WAIT 状态必须等待 2MSL 的时间?(MSL 最长报文段寿命 Maximum Segment Lifetime,MSL=2)
    原因有 2:
    保证 A 发送的最后一个 ACK 报文段能够到达 B。这个 ACK 报文段有可能丢失,使得处于 LAST-ACK 状态的 B 收不到对已发送的 FIN+ACK 报文段的确认,B 超时重传 FIN+ACK 报文段,而 A 能在 2MSL 时间内收到这个重传的 FIN+ACK 报文段,接着 A 重传一次确认,重新启动 2MSL 计时器,最后 A 和 B 都进入到 CLOSED 状态,若 A 在 TIME-WAIT 状态不等待一段时间,而是发送完 ACK 报文段后立即释放连接,则无法收到 B 重传的 FIN+ACK 报文段,所以不会再发送一次确认报文段,则 B 无法正常进入到 CLOSED 状态。
    防止“已失效的连接请求报文段”出现在本连接中。A 在发送完最后一个 ACK 报文段后,再经过 2MSL,就可以使本连接持续的时间内所产生的所有报文段都从网络中消失,使下一个新的连接中不会出现这种旧的连接请求报文段。
  6. 为什么连接的时候是三次握手,关闭的时候却是四次握手?
    因为当 Server 端收到 Client 端的 SYN 连接请求报文后,可以直接发送 SYN+ACK 报文。其中 ACK 报文是用来应答的,SYN 报文是用来同步的。但是关闭连接时,当 Server 端收到 FIN 报文时,很可能并不会立即关闭 SOCKET,所以只能先回复一个 ACK 报文,告诉 Client 端,”你发的 FIN 报文我收到了”。只有等到我 Server 端所有的报文都发送完了,我才能发送 FIN 报文,因此不能一起发送。故需要四步握手。
  7. 为什么 TIME_WAIT 状态需要经过 2MSL(最大报文段生存时间)才能返回到 CLOSE 状态?
    虽然按道理,四个报文都发送完毕,我们可以直接进入 CLOSE 状态了,但是我们必须假象网络是不可靠的,有可能最后一个 ACK 丢失。所以 TIME_WAIT 状态就是用来重发可能丢失的 ACK 报文。
  8. 如何优化
    我们可以通过修改系统参数来优化服务器
    tcp_tw_reuse: 是否重用处于 TIME_WAIT 状态的 TCP 链接 (设为 true)
    tcp_max_tw_buckets: 处于 TIME_WAIT 状态的 SOCKET 最大数目 (调大,这个参数千万不要调小了)
    tcp_fin_timeout: 处于 FIN_WAIT_2 的时间 (调小)

TCP状态机

上面的握手和挥手流程汇总为状态机如下图所示:
TCP状态机

累计确认(cumulative acknowledgment,或称为累计应答)

TCP中并不是发1收1,而是使用一个缓冲区保存数据包,这个缓冲区称为窗口,发送端的发送窗口如下图所示:
TCP发送窗口

  1. 发送了并且已经确认的。
  2. 发送了并且尚未确认的。
  3. 没有发送,但是已经等待发送的。
  4. 没有发送,并且暂时还不会发送的。
  • LastByteAcked:第一部分和第二部分的分界线
  • LastByteSent:第二部分和第三部分的分界线
  • LastByteAcked + AdvertisedWindow:第三部分和第四部分的分界线

为什么会有第3和第4部分?难道这些不都是等待发送的吗?其实区分第3和第4部分的主要目的是流量控制,在 TCP 里,接收端会给发送端报一个窗口的大小,叫 Advertised window。这个窗口的大小应该等于上面的第2部分加上第3部分,超过了这个窗口的接收端处理不过来,就不发送了,作为第4部分。

接收端同样也有一个接收窗口:
TCP接收窗口

  1. 接受并且确认过的。
  2. 还没接收,但是马上就能接收的。
  3. 还没接收,也没法接收的。
  • MaxRcvBuffer:最大缓存的量;
  • LastByteRead 之后是已经接收了,但是还没被应用层读取的;
  • NextByteExpected 是第一部分和第二部分的分界线。

顺序问题与丢包问题

TCP中的重发有两种:

  1. 超时重试
    对每一个发送了,但是没有 ACK 的包,都有设一个定时器,超过了一定的时间,就重新尝试。
    这个时间不宜过短,时间必须大于往返时间 RTT,否则会引起不必要的重传。也不宜过长,这样超时时间变长,访问就变慢了。估计往返时间,需要 TCP 通过采样 RTT 的时间,然后进行加权平均,算出一个值,而且这个值还是要不断变化的,因为网络状况不断地变化。除了采样 RTT,还要采样 RTT 的波动范围,计算出一个估计的超时时间。由于重传时间是不断变化的,我们称为自适应重传算法(Adaptive Retransmission Algorithm)
    超时间隔加倍:每当遇到一次超时重传的时候,都会将下一次超时时间间隔设为先前值的两倍。两次超时,就说明网络环境差,不宜频繁反复发送。
  2. 快速重传
    超时重传存在的问题是超时周期可能会比较长,为了加快重传,TCP采用一种快速重传机制:
    当接收方收到一个序号大于下一个所期望的报文段时,就会检测到数据流中的一个间隔,于是它就会发送冗余的 ACK,仍然 ACK 的是期望接收的报文段。而当客户端收到三个冗余的 ACK 后,就会在定时器过期之前,重传丢失的报文段。
    例如,接收方发现 6 收到了,8 也收到了,但是 7 还没来,那肯定是丢了,于是发送 6 的 ACK,要求下一个是 7。接下来,收到后续的包,仍然发送 6 的 ACK,要求下一个是 7。当客户端收到 3 个重复 ACK,就会发现 7 的确丢了,不等超时,马上重发。
  3. Selective Acknowledgment (SACK)
    接收方可以将缓存的地图发送给发送方,例如发送ACK6、SACK8,SACK8表示已接收但未处理的,有了地图,发送方一下子就能看出来是 7 丢了。

流量控制

当发送未确认窗口中最早的一个包接收到了确认,则窗口将前移一格:
TCP接收窗口
上图中,5接收到了确认,此时窗口前移一格,第14个包可以发送了。
TCP接收窗口移动
如果接收方处理太慢,导致缓存中没有空间了,可以通过确认信息修改窗口的大小,甚至可以设置为 0,则发送方将暂时停止发送。
假设一个极端情况,接收端的应用一直不读取缓存中的数据,当数据包 6 确认后,窗口大小就不能再是 9 了,就要缩小一个变为 8。
那么缩小后什么时候恢复呢?发送方会定时发送窗口探测数据包,看是否有机会调整窗口的大小。

拥塞控制

拥塞控制也是通过控制窗口大小来实现的,前面的滑动窗口 rwnd 是怕发送方把接收方缓存塞满,而拥塞窗口 cwnd,是怕把网络塞满。

TCP的拥塞控制主要是为了避免丢包和超时重传问题:

  • 如果窗口不经控制——像UDP那样——很有可能发送的数据量超过中间设备的承载能力,多出来的包就会被丢弃,即发生了丢包,这是我们不希望看到的。

  • 如果在这些设备上加缓存,处理不过来的先保存在缓存队列里,这样虽然不会丢失,但是会增加时延,如果时延达到一定程度,就会导致超时重传

  • 慢启动(指数增长)
    刚开始不清楚网络情况,因此发送数据包时一次只能发1个,后来按2、4、8的指数性增长速度来增长;

  • 线性增长
    当超过一个阈值ssthresh=65535时,可能速度达到了网络性能,这时会慢下来,变成线程增长,每收到一个确认后,cwnd才会增加1/cwnd

  • 指数递减
    当发生了丢包,需要超时重传时,会设置ssthresh=cwnd/2,并将cwnd设置为1,重新开始慢启动,这种减速方式的问题是太过激进,从原来的高速马上减到1,会造成明显的网络卡顿,因为这个问题,一般会采用快速重传算法

  • 快速重传算法
    当接收端发现丢了一个中间包的时候,发送三次前一个包的 ACK,于是发送端就会快速地重传,不必等待超时再重传。
    TCP 认为这种情况不严重,因为大部分没丢,只丢了一小部分,cwnd 减半为 cwnd/2,然后 sshthresh = cwnd,当三个包返回的时候,cwnd = sshthresh + 3,也就是说没有立刻减到1。