【计算机网络】TCP协议

25 阅读17分钟

TCP

Why?

IP 层「不可靠」,不保证网络包的交付、序交付、数据的完整性。

What?

TCP 是面向连接的、可靠的、基于字节流的传输层通信协议。

  • 面向连接:「一对一」连接;

    • 连接:保证可靠性和流量控制维护的某些状态信息,这些信息的组合,包括 Socket、序列号和窗口大小称为连接。
  • 可靠的:保证一个报文一定能够到达接收端;

  • 字节流:消息「分组」,如果接收方的程序如果不知道「消息的边界」,无法读出有效消息。

TCP是工作在传输层的可靠数据传输的服务,能确保接收端接收的网络包是无损坏、无间隔、非冗余和按序的。

TCP 的最大连接数

  • 理论上限=客户端IP*客户端端口=2^48

  • 文件描述符限制:每个 TCP 连接都是一个文件。

    • 系统级:当前系统可打开的最大数量,通过 cat /proc/sys/fs/file-max 查看;
    • 用户级:指定用户可打开的最大数量,通过 cat /etc/security/limits.conf 查看;
    • 进程级:单个进程可打开的最大数量,通过 cat /proc/sys/fs/nr_open 查看;
  • 内存限制

TCP 头格式

  • 序列号:随机数初始值,每发送一次累加一。解决网络包乱序问题。

  • 确认应答号:指下一次「期望」收到的数据的序列号。用来解决丢包的问题。

  • 控制位:

    • ACK:为 1 时,「确认应答」的字段变为有效,除了最初建立连接时的 SYN 包之外该位必须设置为 1 。
    • RST:为 1 时,表示 TCP 连接中出现异常必须强制断开连接。
    • SYN:为 1 时,表示希望建立连接,并在其「序列号」的字段进行序列号初始值的设定。
    • FIN:为 1 时,通信结束希望断开连接时。

  • 为什么没有包长度?

TCP数据长度=IP总长度-IP首部长度-TCP首部长度

TCP 连接建立

Linux查看TCP连接状态:netstat -napt

三次握手:

为什么是三次握手?不是两次、四次?

握手是建立连接(初始化 Socket、序列号和窗口大小并建立 TCP 连接)

  • 避免历史连接

    • 两次握手,客户端连续发送多次 SYN 建立连接的报文,在网络拥堵情况下:服务端会建立连接
  • 避免资源浪费

    • 只要收到客户端SYN就建立连接,历史连接导致资源浪费
  • 同步双方初始序列号

    • 两次发送序列号接收确认应答号,四步并三步

每次建立 TCP 连接时,初始化的序列号都要求不一样呢?

  • 防止历史报文被下一个相同四元组的连接接收(主要方面);
  • 安全性,防止黑客伪造的相同序列号的 TCP 报文被对方接收;

初始序列号 ISN 是如何随机产生的?

起始 ISN 是基于时钟的,每 4 微秒 + 1,转一圈要 4.55 个小时。序列号是一个 32 位的无符号数,因此在到达 4G 之后再循环回到开始值。

  • ISN = M + F(localhost, localport, remotehost, remoteport)。

  • M:计时器,这个计时器每隔 4 微秒加 1。

  • F: Hash 算法,根据源 IP、目的 IP、源端口、目的端口生成一个随机数值。要保证 Hash 算法不能被外部轻易推算得出,用 MD5 算法是一个比较好的选择。

小问题:

  • 服务端先收到旧报文,再收到新报文

    • 回 Challenge Ack 报文给客户端,这个 ack 报文并不是确认收到「新 SYN 报文」的,而是上一次的 ack 确认号,客户端回RST
  • 服务端先收到客户端数据再收到第三次握手

    • 服务端还是在 syn_received 状态,可以建立连接正常收到这个数据包。这是因为数据报文中是有 ack 标识位,也有确认号,这个确认号就是确认收到了第二次握手。
  • 既然 IP 层会分片,为什么 TCP 层还需要 MSS 呢?

    • 那么当如果一个 IP 分片丢失,整个 IP 报文的所有分片都得重传。
  • 第一次握手服务端没收到,会发生什么?

    • 客户端触发「超时重传」机制,重传 SYN 报文,而且重传的 SYN 报文的序列号都是一样的。

      • 重传次数:cat /proc/sys/net/ipv4/tcp_syn_retries
      • 重传时间:第一次1s(RTO),后面每次超时的时间是上一次的 2 倍。
      • 最后一次再收不到就关闭连接
  • 第二次握手客户端没收到,会发生什么?

    • 客户端:重传SYN

    • 服务端:超时重传机制,重传 SYN-ACK 报文。

      • 重传次数:cat /proc/sys/net/ipv4/tcp_synack_retries
  • 第三次握手服务端没收到,会发生什么?

    • 客户端:ESTABLISH 状态。不发送就保活,发生就重传。

    • 服务端:超时重传机制,重传 SYN-ACK 报文。

SYN 攻击

Linux 内核的 SYN 队列(半连接队列)与 Accpet 队列(全连接队列)

  • 正常流程:

    • 服务端接收 SYN 报文,创建半连接对象加入到内核的「 SYN 队列」;
    • 发送 SYN + ACK 给客户端,等待回应;
    • 服务端接收到 ACK 报文后,从「 SYN 队列」取出一个半连接对象,然后创建一个新的连接对象放入到「 Accept 队列」;
    • 应用通过调用 accpet() socket 接口,从「 Accept 队列」取出连接对象。

  • 伪造不同 IP 地址的 SYN 报文,把 TCP 半连接队列打满,后续再在收到 SYN 报文就会丢弃,导致客户端无法和服务端建立连接。

避免 SYN 攻击

  • 调大 netdev_max_backlog

    • 当网卡接收数据包的速度大于内核处理的速度时,会有一个队列保存这些数据包。控制该队列的最大值如下参数,默认值是 1000。net.core.netdev_max_backlog = 10000
  • 增大 TCP 半连接队列

    • 同时增大 net.ipv4.tcp_max_syn_backlog、 listen() 函数中的 backlog、net.core.somaxconn
  • 开启 tcp_syncookies;

    • SYN 队列满后计算出一个 cookie 放到第二次握手报文的「序列号」里,客户端正确回复再放到Accept队列
    • 0 关闭该功能;1仅当 SYN 半连接队列放不下时,再启用它;2无条件开启功能;
    • echo 1 > /proc/sys/net/ipv4/tcp_syncookies
  • 减少 SYN+ACK 重传次数

    • echo 2 > /proc/sys/net/ipv4/tcp_synack_retries

TCP 连接断开

  • close 函数:完全断开连接,无法传输数据,也不能发送数据。

  • shutdown 函数:可以控制只关闭一个方向的连接:int shutdown(int sock, int howto);第二个参数

    • SHUT_RD(0):关闭连接的「读」这个方向,如果接收缓冲区有已接收的数据,则将会被丢弃,并且后续再收到新的数据,会对数据进行 ACK,然后悄悄地丢弃。也就是说,对端还是会接收到 ACK,在这种情况下根本不知道数据已经被丢弃了。
    • SHUT_WR(1):关闭连接的「写」这个方向,这就是常被称为「半关闭」的连接。如果发送缓冲区还有未发送的数据,将被立即发送出去,并发送一个 FIN 报文给对端。
    • SHUT_RDWR(2):相当于 SHUT_RD 和 SHUT_WR 操作各一次,关闭套接字的读和写两个方向。

小问题

  • 为什么挥手需要四次?

    • 客户端调用 close 函数申请关闭,服务端通常需要等待完成数据的发送和处理,ACK 和 FIN 一般都会分开发送,因此是需要四次挥手。
  • 第一次挥手服务端没收到,会发生什么?

    • 客户端进入 FIN_WAIT_1 状态。收不到ACK,触发超时重传机制,重传 FIN 报文,重发次数由 tcp_orphan_retries 参数控制。重传次数用完直接进入close状态。
  • 第二次挥手客户端没收到,会发生什么?

    • 客户端重传
    • 服务端进入 CLOSE_WAIT 状态,ACK 报文是不会重传的。
  • 第三次挥手服务端没收到,会发生什么?

    • 客户端处于 FIN_WAIT:

      • close关闭:持续 tcp_fin_timeout 默认值是 60 秒。进入close状态。
      • shutdown关闭:一直处于 FIN_WAIT2 状态。
    • 服务端:CLOSE_WAIT 状态,调用 close 函数,内核就会发出 FIN 报文进入 LAST_ACK 状态

      • 收不到ACK重发,次数由 tcp_orphan_retrie决定。再没有就直接close
  • 第四次挥手服务端没收到,会发生什么?

    • 服务端重传
    • 客户端进入 TIME_WAIT 状态,每收到一次重传就回复ACK,重置2MSL定时器,到时close。
    • 2MSL 默认是 60 秒
  • 为什么需要 TIME_WAIT 状态?

    • 防止历史连接中的数据,被后面相同四元组的连接错误的接收

      • 序列号和初始化序列号会发生回绕为初始值的情况,这意味着无法根据序列号来判断新老数据。
    • 保证「被动关闭连接」的一方,能被正确的关闭

      • 如果服务端收不到会重复发FIN
  • 为什么 TIME_WAIT 等待的时间是 2MSL?

    • MSL (Maximum Segment Lifetime)报文最大生存时间,超过这个时间报文将被丢弃。
    • 2MSL 时长,足以让两个方向上的数据包都被丢弃,使得原来连接的数据包在网络中都自然消失,再出现的数据包一定都是新建立连接所产生的。
  • 为什么不是 4 或者 8 MSL 的时长呢?

    • 连续两次丢包的概率只有万分之一,这个概率实在是太小了
  • TIME_WAIT 过多有什么危害?

    • 客户端:无法对相同addr再次连接
    • 服务端:占用系统资源,比如文件描述符、内存资源、CPU 资源、线程资源等;
  • 如何优化 TIME_WAIT?

    • 打开 net.ipv4.tcp_tw_reuse 和 net.ipv4.tcp_timestamps 选项;

      • 复用处于 TIME_WAIT 的 socket 为新的连接所用。
    • net.ipv4.tcp_max_tw_buckets:值默认为 18000

      • 当系统中处于 TIME_WAIT 的连接一旦超过这个值时,系统就会将后面的 TIME_WAIT 连接状态重置
    • 程序中使用 SO_LINGER ,应用强制使用 RST 关闭。

      • 调用close后,会立该发送一个RST标志给对端,该 TCP 连接将跳过四次挥手,也就跳过了TIME_WAIT状态,直接关闭。
  • 服务器出现大量 TIME_WAIT 状态的原因有哪些?

    • 服务器TIME_WAIT 就是客户端主动关闭连接导致

    • 什么场景下服务端会主动断开连接呢?

      • HTTP 没有使用长连接:Connection:close
      • HTTP 长连接超时
      • HTTP 长连接的请求数量达到上限:QPS高的场景
  • 速回收处于 TIME_WAIT 状态的连接

    • net.ipv4.tcp_tw_reuse,如果开启该选项的话,客户端(连接发起方) 在调用 connect() 函数时,如果内核选择到的端口,已经被相同四元组的连接占用的时候,就会判断该连接是否处于 TIME_WAIT 状态,如果该连接处于 TIME_WAIT 状态并且 TIME_WAIT 状态持续的时间超过了 1 秒,那么就会重用这个连接,然后就可以正常使用该端口了。 所以该选项只适用于连接发起方。
    • net.ipv4.tcp_tw_recycle,如果开启该选项的话,允许处于 TIME_WAIT 状态的连接被快速回收;
  • 服务器出现大量 CLOSE_WAIT 状态的原因有哪些?

    • 被动关闭方没有调用 close 函数关闭连接,那么就无法发出 FIN 报文
  • 如果已经建立了连接,但是客户端突然出现故障了怎么办?

    • 服务端不能一直等,TCP 搞了个保活机制:每隔一个时间间隔,发送一个探测报文

      • tcp_keepalive_time=7200:保活时间是 7200 秒(2小时),也就 2 小时内如果没有任何连接相关的活动,则会启动保活机制
      • tcp_keepalive_intvl=75:每次检测间隔 75 秒;
      • tcp_keepalive_probes=9:检测 9 次无响应,认为对方是不可达的,从而中断本次的连接。
    • 对方响应:

      • 正常响应,TCP 保活时间会被重置
      • 可以响应的,没有该连接信息,返回RST 报文
      • 对端主机宕机,TCP 连接已经死亡。
  • 如果已经建立了连接,但是服务端的进程崩溃会发生什么?

    • 内核回收该进程 TCP 连接资源,发送第一次挥手 FIN 报文

TCP 重传

发送端的数据到达接收主机时,接收端主机会返回一个确认应答消息,表示已收到消息。

Why?

网络环境复杂,可能发送的数据没收到,有可能返回的响应没收到,也就是丢包。

How?

常见的重传机制

  • 超时重传:发送数据时,设定一个定时器,超时未收到回复就重传

    • RTT(Round-Trip Time 往返时延)
    • 超时重传时间:RTO (Retransmission Timeout 超时重传时间)略大于RTT
    • 在 Linux 下,α = 0.125,β = 0.25, μ = 1,∂ = 4。
    • 每当遇到一次超时重传的时候,都会将下一次超时时间间隔设为先前值的两倍。
  • 快速重传:不以时间为驱动,而是以数据驱动重传。

    • 收到了三个重复ACK的确认,重传丢失的ACK。
    • 重传1个,丢多个时效率低
    • 重传多个,丢1个时浪费资源
  • SACK( Selective Acknowledgment), 选择性确认

    • 在 TCP 头部「选项」字段加SACK,将已收到的数据的信息发送给「发送方」,发送方可知哪些数据收到了,哪些数据没收到,可以只重传丢失的数据。
    • 通过 net.ipv4.tcp_sack 参数打开这个功能(Linux 2.4 后默认打开)。
  • Duplicate SACK,D-SACK:使用 SACK 来告诉「发送方」有哪些数据被重复接收了。

    • 可以让「发送方」知道,是发出去的包丢了,还是接收方回应的 ACK 包丢了;
    • 可以知道是不是「发送方」的数据包被网络延迟了;
    • 可以知道网络中是不是把「发送方」的数据包给复制了;

滑动窗口

Why?

一发一收,数据包的往返时间越长,通信的效率就越低。

How?

  • 窗口:一个缓存空间,发送方主机在等到确认应答返回之前,必须在缓冲区中保留已发送的数据。如果按期收到确认应答,此时数据就可以从缓存区清除。

窗口大小由哪一方决定?

  • TCP 头字段Window:窗口大小。

    • 接收端告诉发送端自己还有多少缓冲区可以接收数据。于是发送端就可以根据这个接收端的处理能力来发送数据,而不会导致接收端处理不过来。

发送方的滑动窗口

  • SND.WND:发送窗口的大小(大小是由接收方指定的);
  • SND.UNASend Unacknoleged):指向的是已发送但未收到确认的第一个字节的序列号。
  • SND.NXT:指向未发送但可发送范围的第一个字节的序列号。
  • 可用窗口大小 = SND.WND -(SND.NXT - SND.UNA)

接收方的滑动窗口

  • RCV.WND:表示接收窗口的大小,它会通告给发送方。

  • RCV.NXT:指向期望从发送方发送来的下一个数据字节的序列号,也就是 #3 的第一个字节。

流量控制

Why?

发送方不能无脑发送,要考虑接收方的能力。

How?

  • 流量控制:「发送方」根据「接收方」的实际接收能力控制发送的数据量

不允许同时减少缓存又收缩窗口的,而是采用先收缩窗口,过段时间再减少缓存,这样就可以避免了丢包情况。

  • 窗口关闭:窗口大小为 0 时,就会阻止发送方给接收方传递数据,直到窗口变为非 0 为止。

    • 接收方处理完数据后,会向发送方通告一个窗口非 0 的 ACK 报文,如果就是就会死锁。
    • 发送方定期发送窗口探测 ( Window probe ) 报文, 3 次,每次大约 30-60 秒
  • 糊涂窗口综合症:接收方太忙了,来不及取走接收窗口里的数据,那么就会导致发送方的发送窗口越来越小。接收方腾出几个字节并告诉发送方现在有几个字节的窗口,而发送方会义无反顾地发送这几个字节。

    • 接收方不通告小窗口给发送方:小于 MSS 与 1/2 缓存大小中的最小值时,就会向发送方通告窗口为 0

    • 让发送方避免发送小数据: Nagle 算法,只有满足下面两个条件中的任意一个条件:

      • 要等到窗口大小 >= MSS 并且 数据大小 >= MSS;

      • 收到之前发送数据的 ack 回包;

拥塞控制

Why?

在网络出现拥堵时,如果继续发送大量数据包,可能会导致数据包时延、丢失等,这时 TCP 就会重传数据,但是一重传就会导致网络的负担更重,于是会导致更大的延迟以及更多的丢包,这个情况就会进入恶性循环被不断地放大....

How?

拥塞控制:避免「发送方」的数据填满整个网络。

  • 拥塞窗口 cwnd:发送方维护的一个的状态变量,它会根据网络的拥塞程度动态变化的。
  • 发送窗口 swnd= min(cwnd拥塞窗口, rwnd接收窗口)
  • 拥塞判断:发生超时重传

四个算法:

  • 慢启动:

    • 当发送方每收到一个 ACK,拥塞窗口 cwnd 的大小就会加 1。
    • 慢启动门限 ssthresh (slow start threshold)65535 字节。
    • cwnd < ssthresh 时,使用慢启动算法。
    • cwnd >= ssthresh 时,就会使用「拥塞避免算法」。
  • 拥塞避免:抑制指数级速度增长,线性增长

    • 每当收到一个 ACK 时,cwnd 增加 1/cwnd。
  • 拥塞发生

    • 超时重传:问题严重,急刹车

      • ssthresh 设为 cwnd/2
      • cwnd 重置为 1 (是恢复为 cwnd 初始化值,我这里假定 cwnd 初始化值 1)
    • 快速重传:问题不大,速度减半

      • cwnd = cwnd/2 ,也就是设置为原来的一半;
      • ssthresh = cwnd;
      • 进入快速恢复算法
  • 快速恢复

    • 拥塞窗口 cwnd = ssthresh + 3 ( 3 的意思是确认有 3 个数据包被收到了);

    • 重传丢失的数据包;

    • 如果再收到重复的 ACK,那么 cwnd 增加 1;

    • 如果收到新数据的 ACK 后,把 cwnd 设置为第一步中的 ssthresh 的值,原因是该 ACK 确认了新的数据,说明从 duplicated ACK 时的数据都已收到,该恢复过程已经结束,可以回到恢复之前的状态了,也即再次进入拥塞避免状态;

优化 TCP 🤔

TCP 三次握手的性能提升

  • 调整SYN报文重传次数

    • 内网中通讯时,就可以适当调低重试次数,尽快把错误暴露给应用程序。
  • 调整SYN半连接队列长度

    • 增大 tcp_max_syn_backlog 的值,还需一同增大 somaxconn 和 backlog,也就是增大 accept 队列。
  • 调整SYN+ACK报文重传次数

  • 调整accpet队列长度

  • 绕过三次握手:TCP Fast Open

Echo 3 > /proc/sys/net/ipv4/tcp_fastopen

TCP 四次挥手的性能提升

  • 调整FIN报文重传次数,默认8

  • 调整FIN_WAIT2状态的时间

  • 调整孤儿连接的上限个数

  • 调整time_wait状态的上限个数

  • 复用time_wait状态的连接

TCP 数据传输的性能提升

  • 扩大窗口大小

  • 调整发送缓冲区范围

  • 调整接收缓冲区范围

  • 接收缓冲区动态调节

  • 调整内存范围

其他问题

如何理解是 TCP 面向字节流协议

Why?

发送方的机制不同

  • UDP:每个 UDP 报文就是一个用户消息的边界,这样接收方在接收到 UDP 报文后,读一个 UDP 报文就能读取到完整的用户消息。
  • TCP:消息可能会被操作系统分组成多个的 TCP 报文,不能认为一个用户消息对应一个 TCP 报文,正因为这样,所以 TCP 是面向字节流的协议。

如何解决粘包?

  • 固定长度的消息:方式灵活性不高,实际中很少用

  • 特殊字符作为边界:

    • 消息内容里有特殊字符,要对这个字符转义,避免被接收方当作消息的边界点而解析到无效的数据。
  • 自定义消息结构:自定义一个消息结构,由包头和数据组成,其中包头包是固定大小的,而且包头里有一个字段来说明紧随其后的数据有多大。

    • 当接收方接收到包头的大小(比如 4 个字节)后,就解析包头的内容,于是就可以知道数据的长度,然后接下来就继续读取数据,直到读满数据的长度,就可以组装成一个完整到用户消息来处理了。

SYN 报文什么时候情况下会被丢弃?

  • 开启 tcp_tw_recycle 参数,并且在 NAT 环境下,造成 SYN 报文被丢弃
  • TCP 两个队列满了(半连接队列和全连接队列),造成 SYN 报文被丢弃