深入理解TCP协议

1,929 阅读32分钟

一、TCP协议报文

1. TCP协议的概念

TCP是TCP/协议簇中最重要也是最复杂协议,它位于OSI七层协议模型的网络层,它提供了一种全双工的、面向连接的、可靠的字节流服务。 TCP协议是两台主机进程进行通信的基石,TCP使用连接(connection)作为最基本的抽象,同时将TCP连接的端点称为插口或者套接字(socket)。

  • 全双工:通信两端在任意时刻可以互相发送数据,既可以是客户端也可以是服务端。
  • 面向连接:通信前需要先三次握手建立连接,通信后四次挥手释放连接。
  • 可靠的:通过序号解决报文乱序/丢失、超时重传、拥塞控制、滑动窗口、检验和。
  • 字节流:没有固定的报文边界。

1.1 Socket

如上图所示,客户端ip+port和服务端ip+port的四元组,组成一个socket可以唯一标识一个连接,所以理论上来说服务器的一个端口就能连接成千上万的连接。

2. TCP首部格式

TCP的首部包含了20字节的固定部分和长度可变的选项部分,如下图所示:

  • 端口号:每个TCP段都包含各占两个字节的源端和目的端的端口号,用于寻找发端和收端应用进程。这两个值加上IP首部中的源端IP地址和目的端IP地址唯一确定一个TCP连接。

  • 序号:TCP协议在数据传输的过程中是基于字节流的并且在传输给传输层的时候会将字节流进行分段,序号用来表示这个报文段的第一个字节(报文段的第一个字节号就是序号)。这个序号是由特定的算法生成的随着时间逐渐递增的数字,最大值是2^32-1。这样设计序号主要有三个原因:

    1. 标识两个主机间前后传输报文段的关系。
    2. 防止由于网络延迟有的分组被重发后,而导致某一方连接做出错误的解释。
    3. 防止黑客TCP劫持。
  • 确认号:是期望收到下个报文段数据的第一个字节号。

  • 首部长度: 共有多少4个字节,TCP首部长度可以在20-60个字节之间。

  • 标志位:

    • URG:紧急bit,当URG=1时,代表该报文段有紧急数据应该尽快传输(优先级高)。
    • ACK:当ACK=1时代表确认号有效,否则无效。
    • PUS:当PUS=1时代表该报文段应当尽快传输给接收应用进程,而不用等待缓存填满。
    • RST:当RST=1时代表TCP连接出现了严重差错,必须释放连接然后重新建立。
    • SYN:当SYN=1时代表这是一个请求连接或者连接接受报文。
    • FIN:当FIN=1时代表报文段的发送端已经发送完数据,并要求释放连接。
  • 窗口:窗口字段用来控制发送方的的发送数据量,接收方根据设置的缓存确定自己接收窗口的大小,然后通知对方以确定对方发送窗口的上限。

  • 校验和:检验部分包括TCP首部和报文段部分,要加上12字节的伪首部。

  • 紧急指针:指出本报文段中紧急数据的最后一个字节的序号,并且URG标志位置为1。

  • 选项:TCP只规定了一种选项,即最大报文段长度MSS(max segment size),MSS会通知发送方我所能接收的报文段的数据字段的最大长度是MSS个字节。

二、TCP的三次握手

TCP是面向连接的,那么TCP是如何建立连接的那?答案就是三次握手,如果你已经了解了TCP的三次握手,在面试的时候一定被问过为什么是三次握手?两次或者四次可以吗?带着这些问题,接下来我们使用wireshak抓包工具,看看TCP是如何进行三次握手的。

首先我们使用Java的Socket或者使用nc -l port 监听一个端口号。例如:

然后我们再使用nc ip port建立连接并发送一些数据。lsof查看下连接状态已经是ESTABLISHED。

接下来我们用wireshark看下抓的包数据。

上图中1、2、3号包就是三次握手的数据包,[xxx]表示TCP数据段标志位,例如1号包是[SYN],3号包是[ACK]。关于标志位在上篇文章里已经讨论过了,这里就不详细展开了。

三次握手的过程

不知道大家是否看明白了三次握手的过程,大家可以自己参照我画的三次握手的时序图仔细理解下,里面的TCP状态机,Win,Mss我会在后面给大家分析请暂时不要捉急,先搞清楚三次握手的流程就可以了。

介绍几个概念

  • Seq: Seq表示该报文段的序列号,TCP的每个报文段的序列号在正常传输下都是递增的,在三次握手中的客户端和服务端的第一个Sep称为ISN(初始序列号),因为TCP是全双工的,所以双方都需要一个ISN。我们不需要知道序列号是如何生成的,但是我们需要知道它是怎么增加的,因为在TCP的数据传输中收到乱序的数据包是根据序列号重新排序的。

    为什么这里的ISN(初始序列号)为0,因为我们要频繁的对Seq进行计算,在wireshark中提供了Relative sequence number的相对序列号方式,简化我们的计算。

  • Len:表示数据包的长度,但是不包含TCP头的长度,三次握手中的数据。
  • ACK:表示确认收到了ACK号之前的数据包,如果对方没有发送数据包那么确认号保持不变,例如,A发送给B的数据包的Seq=x,Len=y那么B的回复的Ack号就是x+y。
  • 序列号的增长方式: 下个数据包的Seq=上个数据包的Seq + 上个数据包的Len。也就是说收端的ACK号正是发送端下次的Seq号。

    三次握手中的ACK和Seq增长方式有所不同。

三次握手可以是两次吗?

答案:可以,但是不够可靠。因为网络上有多条传输路径,当客户端用于建立连接的第一个包跑到了一个延迟比较高的路径上,服务端迟迟没有响应,那么客户端会认为这个包丢而进行重发。然后第二个包很快建立了连接,并发送数据后关闭。这时候第一个包姗姗来迟,由于服务端不能识别这是一个旧的并且无效的连接,就会响应这个请求。如果是三次握手,客户端收到这个ACK,就会发送一个RST包,释放这个连接,如果是两次就会建立一个无效的连接。

三次握手可以是四次吗?

答案:可以,但是没必要。因为服务端的ACK不携带任何数据,一般会立即响应,多加一次ACK浪费流量。

三、TCP四次挥手

TCP作为可靠的连接协议,不仅体现在连接的建立上,也体现在其释放连接上,但是世界上是不存在百分之百可靠的通信机制的,我们来看下TCP释放连接,也就是四次挥手是如何尽可能保证TCP的可靠性的。 使用Wireshark抓包,如图:

四次挥手的过程

  1. 当客户端已经发送完数据后,调用close方法向服务端发送FIN包,请求关闭连接,此时客户端进入FIN_WAIT1状态,代表客户端已经不再发送数据,但是还能接收服务端数据,这个状态也叫半关闭状态。

    因为TCP是全双工协议,客户端和服务端能互相发送数据,都有可能先传输完数据请求关闭连接,所以先请求关闭的一方称为主动关闭方,而另一方称为被动关闭方。

  2. 服务端收到客户端的FIN包后进入CLOSE_WAIT状态,并返回一个ACK给客户端,客户端端收到后,进入FIN_WAIT2状态。

  3. 当服务端没有数据向客户端发送时,向客户端发送FIN包,然后服务端进入LAST_ACK状态,客户端收到FIN包,会进入TIME_WAIT状态,这是一个比较特殊的状态,后面会单独讲解。

  4. 服务端收到ACK后进入CLOSE状态,客户端在TIME_WAIT等待2MSL后会进入CLOSE状态。

    MSL是Maximum Segment Lifetime英文的缩写,即报文的最大生存时间,超过这个时间的报文将会被丢弃。

在TCP中还存在一种特殊的情况,就是同时关闭。就是客户端和服务端同时发送FIN包,但是这种情况并不常见,知道这么个概念就行了,这里就不过多阐述了。

TCP四次挥手改为三次可以吗

因为TCP连接是全双工的,数据在两个方向上能够同时传递,因此每个方向的数据传输都必须单独关闭。当一端收到FIN并响应给对端的时候,这一方向的数据流便停止了,也就是半关闭状态。但是由于TCP有延迟确认的功能或者服务端收到FIN包后没有数据发送了,就能同时发送FIN+ACK包,所以其实三次挥手是可以的。

但是延迟确认可能会带来一个问题,就是如果被动关闭方没有及时ACK主动关闭方的FIN包,主动关闭方可能认为FIN包丢失了,导致不必要的重传发生。所以四次挥手是相对最合理的关闭连接的方式。

四、TCP状态机

下图来自RFC 793

  • CLOSED

    CLOSED状态是TCP连接彻底释放或者没有建立时的状态,通过netstat或者lsof命令没有办法查看到。CLOSED状态执行主动打开(active OPEN)将会进入SYN_SENT状态。 CLOSED状态执行被动打开(passives OPEN)将会进入LISTEN状态。

  • LISTEN

    通常来说服务端调用bind或者listen方法绑定一个端口,然后进入LITEN状态,等待客户端发送SYN包建立三次连接。例如Java:

    ServerSocket socket = new ServerSocket(8081);
    

    或者

    ServerSocket socket = new ServerSocket();
    socket.bind(new InetSocketAddress(8081));
    
  • SYN_SENT

    一般来说处于CLOSED状态的客户端发送SYN包,等待SYN+ACK就会进入SYN_SENT状态。但是LISTEN下也能发送SYN包进入SYN_SENT状态。

  • SYN_RCVD

    服务端收到客户端的SYN包,并回复SYN+ACK包后,进入SYN_RCVD状态。还有一种特殊情况就是SYN_SENT收到ACK也可以进入SYN_RCVD状态,这种情况就是同时打开。

  • ESTABLISHED

    经过三次握手后,客户端和服务端连接后进入ESTABLISHED状态。

  • FIN_WAIT1

    主动关闭方发送FIN包,等待对方ACK时进入FIN_WAIT1状态。SYN_RCVD下也可能调用CLOSE方法,发送FIN包,进入FIN_WAIT1状态。

  • CLOSE_WAIT

    被动关闭方收到FIN包,回复ACK后,进入CLOSE_WAIT状态。

  • FIN_WAIT2

    主动关闭方在FIN_WAIT1状态下,收到被动关闭方的ACK包后,将会进入FIN_WAIT2。

  • LAST_ACK

    被动关闭方,发送完数据数据,向主动关闭方发送FIN包后,由CLOSE_WAIT状态进入LAST_ACK状态。最后在收到主动关闭方的ACK后进入CLOSED状态。

  • TIME_WAIT

    主动关闭方收到被动关闭方的FIN包,返回ACK后,会进入TIME_WAIT状态。在TIME_WAIT状态等待2MSL后进入CLOSED状态。

  • CLOSING

    CLOSING状态比较特殊,出现在同时关闭的时候。主动关闭方在FIN_WAIT1状态下,收到被动关闭方的FIN包,将会进入CLOSING状态,然后收到ACK后,再进入TIME_WAIT状态,随后释放连接。

五、TCP的全连接和半连接队列

当服务端调用listen函数监听端口的时候,内核会为每个监听的socket创建两个队列:

  • 半连接队列(syn queue): 客户端发送SYN包,服务端收到后回复SYN+ACK后,服务端进入SYN_RCVD状态,这个时候的socket会放到半连接队列。
  • 全连接队列(accept queue): 当服务端收到客户端的ACK后,socket会从半连接队列移出到全连接队列。当调用accpet函数的时候,会从全连接队列的头部返回可用socket给用户进程。

半连接队列

半连接队列的大小由/proc/sys/net/ipv4/tcp_max_syn_backlog控制,Linux的默认是1024。 当服务端发送SYN_ACK后将会开启一个定时器,如果超时没有收到客户端的ACK,将会重发SYN+ACK包。重传的次数由/proc/sys/net/ipv4/tcp_synack_retries控制,默认是5次。

全连接队列

全连接队列的大小通过/proc/sys/net/core/somaxconn指定,在使用listen函数时,内核会根据传入的backlog参数系统参数somaxconn,取二者的较小值。

listen函数:

int listen(int sockfd, int backlog)

Nginx和Redis默认的backlog值等于511,Linux默认的backlog 为 128,Java默认的backlog等于50。

默认情况下,全连接队列满以后,服务端会忽略客户端的ACK,随后会重传SYN+ACK,也可以修改这种行为,这个值由/proc/sys/net/ipv4/tcp_abort_on_overflow决定。

  • tcp_abort_on_overflow=0:表示三次握手最后一步全连接队列满以后服务端会丢掉客户端发过来的ACK,服务端随后会进行重传SYN+ACK。
  • tcp_abort_on_overflow=1:表示全连接队列满以后服务端发送RST给客户端,直接释放资源。

SYN Flood攻击

客户端大量伪造IP发送SYN包,服务端回复的ACK+SYN去到了一个「未知」的IP地址,造成服务端大量的连接处于SYN_RCVD状态,导致服务器半连接队列满,出现无法处理正常请求的情况。

如何应对

  1. 增加半连接队列大小:调整/proc/sys/net/ipv4/tcp_max_syn_backlog
  2. 减小服务器重发SYN+ACK次数:调整 /proc/sys/net/ipv4/tcp_synack_retries
  3. SYN Cooikes算法

SYN Cooikes机制

SYN Cookie的原理是基于「无状态」的机制,服务端收SYN包以后不马上分配为Inbound SYN分配内存资源,而是根据这个SYN包计算出一个Cookie值,作为握手第二步的序列号回复SYN+ACK,等对方回应ACK包时校验回复的ACK值是否合法,如果合法才三次握手成功,分配连接资源。

SYN Cooikes不保存客户端的连接信息,而是将其保存在服务端的SYN+ACK的时间戳选项和初始序列号中,客户端连接信息会随着ACK报文带回来,所以要使用SYN Cooikes要求开启时间戳选项。

SYN Cookies由/proc/sys/net/ipv4/tcp_syncookies控制。默认等于 1,表示半连接队列满时启用,等于 0 表示禁用,等于 2 表示始终启用。

服务器如何计算SYN以及SYN Cookikes的缺点

深入浅出TCP中的SYN-Cookies

六、TCP首部时间戳选项

时间戳可选项主要包含4个部分:

  • kind:类型
  • length:长度
  • TimeStamp value:发送方时间戳
  • TimeStamp echo reply:回显时间戳

时间戳可选项可以处理序号回绕判断乱序更加准确的计算RTT,在linux中可以通过proc/net/ipv4/tcp_timestamps设置,一般默认是1(打开)。

时间戳的原理是:发送方在发送数据时将发送数据的时间X放到发送方时间戳TSval中。接收方在接收到数据的时候将收到的时间X原封不动的放到回显时间戳TSecr中,同时将自己发送数据的时间Y放到发送方时间戳TSval中,以此类推。如下图所示

七、TIME_WAIT状态

主动关闭方在收到被动关闭方的FIN包后并返回ACK后,会进入TIME_WAIT状态,TIME_WAIT状态又称2MSL状态,每个TCP连接都必须有一个最大报文段生存时间MSL,在网络传输中超过这个时间的报文段将被丢弃。当TCP连接发起一个主动关闭,并发出最后一个ACK时,必须在TIME_WAIT状态停留两倍MSL时间,在2MSL等待期间,定义这个连接的插口(客户端IP地址和端口号,服务器IP地址和端口号的四元组)将不能再被使用。2MSL状态存在有两个理由:

  1. 允许老的重复报文分组在网络中消逝。

  2. 保证TCP全双工连接的正确关闭。

第一个理由是假如我们在192.168.1.1:5000和39.106.170.184:6000建立一个TCP连接,一段时间后我们关闭这个连接,再基于相同插口建立一个新的TCP连接,这个新的连接称为前一个连接的化身。老的报文很有可能由于某些原因迟到了,那么新的TCP连接很有可能会将这个迟到的报文认为是新的连接的报文,而导致数据错乱。为了防止这种情况的发生TCP连接必须让TIME_WAIT状态持续2MSL,在此期间将不能基于这个插口建立新的化身,让它有足够的时间使迟到的报文段被丢弃。

第二个理由是因为如果主动关闭方最终的ACK丢失,那么服务器将会重新发送那个FIN,以允许主动关闭方重新发送那个ACK。要是主动关闭方不维护2MSL状态,那么主动关闭将会不得不响应一个RST报文段,而服务器将会把它解释为一个错误,导致TCP连接没有办法完成全双工的关闭,而进入半关闭状态。

为什么要维持2MSL

  • 一个MSL是确保主动关闭方最后的ACK能够到达对端。

  • 一个MSL是确保被动关闭方重发的FIN能够被主动关闭方收到。

TIME_WAIT会造成什么问题

一个web服务器最大的端口数量是65535个,如果客户端不停的和服务端不停的创建短连接,就会导致有大量的TCP进入TIME_WAIT状态,导致端口耗尽。当服务端是主动关闭方,因为有TIME_WAIT状态的存在,在重启程序的时候可能会出现java.net.BindException: Address already in use的错误,这是因为这个端口TIME_WAIT状态需要等待2MSL。在RFC793中规定MSL的时间为2min,在实际使用中一般是30s或者1min,在高并发的情况下毫无疑问,这将造成大量连接无法建立的问题,那么有什么方法可以处理这些问题那?

服务端TIME_WAIT状态重用

当服务端主动断开连接的时候,bind端口号对应的socket将会陷入TIME_WAIT状态,这时再重启服务端应用将会出现Address already in use错误。在这个时候可以设置SO_REUSEADDR为1,允许服务端TIME_WAIT状态重用,这个参数默认是0,即不启用。SO_REUSEADDR参数同样可以作用于FIN_TIMEWAIT2状态,当服务端没有收到客户端的FIN包也可以重用这个端口号。这个参数一般由应用程序设置,如下

ServerSocket serverSocket = new ServerSocket();
// setReuseAddress 必须在 bind 函数调用之前执行
serverSocket.setReuseAddress(true);

但是,SO_REUSEADDR并不是在所有情况下都可以使用

SO_REUSEADDR可以用在以下四种情况下。(摘自《Unix网络编程》卷一,即UNPv1)

  1. 当有一个有相同本地地址和端口的socket1处于TIME_WAIT状态时,而你启动的程序的 socket2要占用该地址和端口,你的程序就要 用到该选项。
  2. SO_REUSEADDR允许同一port上启动同一服务器的多个实例(多个进程)。但每个实例绑定的IP地址是不能相同的。在有多块网卡或用IP Alias技术的机器可 以测试这种情况。
  3. SO_REUSEADDR允许单个进程绑定相同的端口到多个socket上,但每个socket绑定的ip地址不同。这和2很相似,区别请看UNPv1。
  4. SO_REUSEADDR允许完全相同的地址和端口的重复绑定。但这只用于UDP的多播,不用于TCP。

客户端TIME_WAIT状态重用

net.ipv4.tcp_tw_reuse

在linux中proc/net/ipv4/tcp_tw_reuse默认为0,设置为1后表示客户端允许TIME_WAIT状态重用。下面把主动关闭方记为A, 被动关闭方记为B,它的原理是:

  • 如果主动关闭方收到的包时间戳比当前存储的时间戳小,说明是一个的旧连接的包,直接丢弃掉
  • 如果因为ACK包丢失导致被动关闭方还处于LAST-ACK状态,并且会持续重传FIN+ACK。这时A发送SYN包想三次握手建立连接,此时A处于SYN-SENT阶段。当收到B的FIN包时会回以一个RS 包给 B,B 这端的连接会进入CLOSED状态,A因为没有收到SYN包的ACK,会重传 YN,后面就一切顺利了。

所以tcp_tw_reuse需要依赖客户端和服务端开启tcp_timestamps时间戳选项

net.ipv4.tcp_tw_recycel

在linux中proc/net/ipv4/tcp_tw_recycel默认为0,设置为1后开启(tcp_timestamps必须同时打开),开启tcp_tw_recycel后服务器将会缓存每个套接字的最新时间戳。对于新的连接,如果SYN包中的时间戳小于之前缓存的套接字的时间戳,则直接丢弃,否则允许复用TIME_WAIT。

这种机制在经过NAT或者负载均衡后,会发生严重的问题。因为不同请求经过NAT或者负载均衡后IP可能是一样的,尤其是在使用了容器后的云上,会导致正常的连接无法使用。所以不推荐打开这个参数。

TCP_REUSEPORT选项

解决惊群效应

八、IP数据分片之MTU和TCP的MSS

最大传输单元(Maximum Transmission Unit,MTU)

数据链路层传输的帧大小是有限制的,以太网和IEEE 802.3对数据帧的长度都有一个限制,不能把一个太大的包直接塞给链路层,这个限制被称为最大传输单元(Maximum Transmission Unit,MTU)

以太网的贞最小为64字节,最大为1518字节。除去14字节头部和4字节 CRC字段,最小的有效载荷为46字节,最大的有效载荷为1500字节这个值就是MTU。就是说传输100KB的数据,至少需要发送69次以太网的贞。

不同的数据链路层的MTU也是不同的,IP协议的数据报最大为65535个字节,如果开启了巨型贞(Jumbo Frame)能达到9000个字节。这远超出了MTU的值,所以当IP数据报大于MTU的时候就需要对数据进行分片,这也是IP协议的主要功能之一。

IP首部中有个字段片偏移,在IP数据报长度大于MTU的时候对数据报进行分片片偏移用来表示数据报在原来分组的相对位置,以8个字节为偏移单位,如下图:

我们使用ping请求发送3000个字节给www.baidu.com,网卡eto的MTU为1500。使用wireshark抓包如下:

  1. 第一个包

ip.id = 0x00005ce1

ip.flags.mf(More fragments : Set) = 1,代表这个id的ip分组还有更多分片。

ip.frag_offset(Fragment offset:0),代表分片偏移量是0。

ip首部20个字节,1500-20=1480字节。

  1. 第二个包

ip.id = 0x00005ce1

ip.flags.mf(More fragments : Set) = 1,代表这个id的ip分组还有更多分片。

ip.frag_offset(Fragment offset:1480),代表分片偏移量是0。

ip首部20个字节,1500-20=1480字节。

  1. 第三个包

ip.id = 0x00005ce1

ip.flags.mf(More fragments : Set) = 2960,代表这是这个ip分组的最后的一个分片,可以进行重组了。

ip首部20个字节,icmp首部8字节,68-20-8=40字节。

ip.id=0x00005ce1的三个分片的载荷加起来正好是3000个字节。

TCP最大报文段长度(Max Segment Size,MSS)

TCP为了避免被发送方分片,它主动把数据分成小段再交给网络层。最大的分段大小称为MSS(Maximum Segment Size),它相当于把MTU刨去IP头和TCP头之后的大小,所以一个MSS恰好能装进一个MTU中。

有时候TCP的头不止有20个字节,还有可选项会占用一定的MSS空间。这些信息我们都能通过Wireshar观察到。

以太网贞是1500字节+14字节的MAC头,TCP头一共是32个字节,可选项占了12字节。所以TCP的MSS=1500-20-20-12=1448字节。

UDP协议没有MSS的概念,传输层的数据可能一股脑的交给网络层,所以数据可能会被分片而影响性能。

在三次握手的时候接收方和发送方都会声明自己MSS,因为接收方和发送方的MTU可能是不同的,最终会以较小的MSS为准。

九、延迟确认(捎带ACK)和Nagle

Nagle算法

Nagle算法是针对交互式数据流的一种拥塞控制算法,它主要解决的是网络传输中小包的问题,在交互式数据流中假如从键盘输入的一个字符,占用一个字节,可能在传输上造成41字节的包,其中包括1字节的有用信息和40字节的首部数据。这种情况对于轻负载的网络来说(局域网)还是可以接受的。但是对于广域网来说有可能在经过路由器的时候丢包,就不得不重新传输这个数据,会严重影响TCP的传输性能。

为了尽可能的利用网络带宽,TCP总是希望尽可能的发送足够大的数据。(TCP/IP希望每次都能够以MSS尺寸的数据块来发送数据)。Nagle算法就是为了尽可能发送大块数据,避免网络中充斥着许多小数据块。 Nagle算法的基本定义是任意时刻,最多只能有一个未被确认的小段。 所谓"小段",指的是小于MSS尺寸的数据块,所谓"未被确认",是指一个数据块发送出去后,没有收到对方发送的ACK确认该数据已收到。很明显该算法也是为了交互式数据流而设计的,因为该算法依赖的是TCP的ACK机制。对于成块数据流,假设每收到一次确认才干发送下一个报文段,那么传输速率就会非常低。

延迟确认

延迟确认和Nagle算法一样,都是为了避免浪费带宽。延迟确认的意思是在TCP发送数据后接收端并不会立即发送ACK,而是等待一段时间后(Linux上是40ms)将ACK和服务端要发送的数据一起发送给客户端。如果在此期间客户端又有数据传输,那么可以把多次ACK合并为一次,但是接收端也不是要一直等待数据才会发送ACK,大多数操作系统都采用了一个不大于200ms的定时器,也就是说,TCP将以最大200ms的延时等待是否有数据一起发送。

在生产环境中请关闭Nagle算法,因为Nagle算法会攒包,只有等到发送的数据被确认了才会发送下一个包,而延迟确认又不会很快发送ACK,导致应用吞吐量下降。

十、TCP的滑动窗口机制

如果每次传输数据都只能发送一个MSS,就需要等待接收方的ACK,这显然会极大的影响传输的速率。在发送数据的时候,最好的方式是一下将所有的数据全部发送出去,然后一起确认。但是现实中会存在一些限制:

接收方的缓存(接收窗口)可能已经满了,无法接收数据。网络的带宽也不一定足够大,一口发太多会导致丢包事故。 发送方要知道接收方的接收窗口和网络这两个限制因素中哪一个更严格,然后在其限制范围内尽可能多发包。这个一口气能发送的数据量就是传说中的TCP发送窗口。首先TCP在进行数据传输的时候都是先将数据放在数据缓冲区中的,TCP维护了两个缓冲区,发送方缓冲区和接收方缓冲区。

  • 发送方缓冲区: 发送方缓冲区用于存储已经准备就绪数据和发送了但是没有被确认的数据。
  • 接收方缓冲区:接收方缓冲区用于存储已经被接收但是还没有被用户进程消费的数据。 滑动窗口机制是TCP的一种流量控制方法,该机制允许发送方在停止并等待确认前连续发送多个分组,而不必每发送一个分组就停下来等待确认,从而增加数据传输的速率提高应用的吞吐量。

TCP的包可以分为四种状态

  • 已发送并且已经确认的包。
  • 已发送但是没有确认的包。
  • 未发送但是可以发送的包。
  • 不允许被发送的包。

滑动窗口协议的基本工作流程就是由接收方通告窗口的大小,这个窗口称为提出窗口,也就是接收方窗口。接收方提出的窗口则是被接收缓冲区所影响的,如果数据没有被用户进程使用那么接收方通告的窗口就会相应得到减小,发送窗口取决于接收方窗口的大小。可用窗口的大小等于接收方窗口减去发送但是没有被确认的数据包大小。 滑动窗口机制示意流量图

滑动窗口的动态性

零窗口(TCP Zero Window)

在接收方窗口大小变为0的时候,发送方就不能再发送数据了。但是当接收方窗口恢复的时候发送方要怎么知道那?在这个时候TCP会启动一个零窗口(TCP Zero Window)定时探测器,向接收方询问窗口大小,当接收方窗口恢复的时候,就可以再次发送数据。

十一、TCP的拥塞避免

在TCP传输数据的时候,发送方的窗口受到网络接收方窗口的影响,实际的网络环境是非常复杂的,每一跳路由的带宽可能都不一样,如果一下接收到太多的数据网络就会发送拥塞,拥塞的结果就是丢包。导致网络拥塞的数据量称为拥塞点。 拥塞点是一个在不断变化的动态值,TCP需要在尽可能大的发送窗口下,避免触碰到拥塞点。TCP主要使用以下几种算法来避免拥塞的发生:

1. 慢启动算法

慢启动算法的思想是为发送方增加了一个拥塞窗口(Congestion Window),记为cwnd。 拥塞窗口指的是在收到对端的ACK时还能发送的最大MSS数。拥塞窗口是发送端维护的一个值,不会像接收方窗口(rwnd)那样通告给对端,发送方窗口的大小是min(cwnd,rwnd)。目前的linux的拥塞窗口初始值为10个MSS

慢启动算法,每经过一个RTT,cwnd变为之前的两倍。发送方开始时发送initcwnd个报文段(假设接收方窗口没有限制然后等待ACK。当收到该ACK时,拥塞窗口扩大为initcwnd*2,即可以发送initcwnd2个报文段。当收到这发出报文段的ACK时,拥塞窗口继续扩大为initcwnd4,这是一种指数增加的关系。

慢启动算法示意图: 计算公式: 指数n为RTT次数。

2.拥塞避免算法

拥塞避免算法和慢启动算法是两个不同的算法,但是他们都是为了解决拥塞,在实际中这两个算法通常是在一起实现的。相比于慢启动算法拥塞避免算法多维护了一个慢启动阈值ssthresh

  • cwnd<ssthresh时,拥塞窗口使用慢启动算法,按指数级增长
  • cwnd>ssthresh时,拥塞窗口使用拥塞避免算法,按线性增长

拥塞避免算法每经过一个RTT,拥塞窗口增加initcwnd。当发生拥塞的时候就需要调整拥塞窗口和慢启动阈值。RFC2001建议,它把慢启动阈值定义为发生丢包时拥塞窗口的一半大小,拥塞窗口缩小到initcwnd。但是在真实的网络环境中这种算太过粗暴,在不同的Linux内核版本种,使用了不同的TCP的拥塞算法来调整临界窗口和拥塞窗口。

超时重传对传输性能有严重影响。原因之一是在RTO阶段不能传数据,相当于浪费了一段时间;原因之二是拥塞窗口的急剧减小,相当于接下来传得慢多了

3.快速重传算法

有时候拥塞比较轻微,只有少量包丢失,后续的包能够正常到达。当后续的包到达接收方时,接收方会发现其Seq号比期望的大,所以它每收到一个包就Ack一次期望的Seq号,以此提醒发送方重传。当发送方收到3个或以上重复确认(Dup Ack)时,就意识到相应的包已经丢了,从而立即重传它。这个过程称为快速重传。

为什么要规定凑满3个呢?这是因为网络包有时会乱序,乱序的包一样会触发重复的Ack,但是为了乱序而重传没有必要。由于一般乱序的距离不会相差太大,比如2号包也许会跑到4号包后面,但不太可能跑到6号包后面,所以限定成3个或以上可以在很大程度上避免因乱序而触发快速重传。

选择性确认SACK

还有一个问题, 如果2号和3号包都丢失了,但是后面4,5,6,7号都正常收到了,并触发了三次Ack2。在重传了2号包之后该传哪个包那,是全部需要重传还是只传2号包? 为了解决这种问题,TCP在发送重复的Ack包的时候,会告诉接收方收到的已经收到包的序号,如下图:

这样发送方就知道该重传哪个包了,这种方式被称为选择性确认(Selective Acknowledgement)

4.快速恢复

如果在拥塞阶段发生了快速重传就没有必要像超时重传那样处理拥塞窗口了,因为此时的拥塞并不是很严重。RFC5681建议此时的慢启动阈值ssthreh设置为没有被确认包的1/2,但是不小于2个MSS。拥塞窗口设置为慢启动阈值加3个MSS,然后继续拥塞避免过程。这个过程被称为快速恢复。

5.总结

超时重传对性能的影响最大,因为在RTO期间不能传输任何数据,而且拥塞窗口会急剧减小。所以应该尽量避免超时重传。丢包对极小文件的影响比大文件严重,因为小文件可能不能触发三次重复的Ack,导致无法快速重传。

TCP协议优化

优化三次握手

参数缺省值建议值作用
tcp_syn_retries62当客户端发送SYN建立连接后,如果没有收到服务端ACK,将会重传SYN包。时间间隔是1s,2s,4s,8s,16s...
tcp_syncooikes11值为1表示,在半连接队列溢出后开启SYN Cooikes,0表表示不开启,2表示始终开启
tcp_syn_max_backlog128适当调大适当的调整半连接队列的值,避免瞬间有大量客户端建立连接,导致客户端SYN被丢弃
tcp_synack_retries52服务端在回复了SYN+ACK后没有收到客户端ACK,将会重传SYN+ACK,时间间隔是1s,2s,4s,8s,16s...
backlog和somaxcoon128适当调大backlog不是系统参数,它由应用程序控制。全连接队列的大小为min(backlog,somaxconn)
tcp_abort_on_overflow00全连接队列溢出往往是由于瞬间大量客户端请求建立连接,所以给客户端一个重试的机会往往连接就可以建立

优化四次挥手

参数缺省值建议值作用
tcp_fin_timeout60s5s避免被动关闭方迟迟不发送FIN包导致主动关闭方停留在FIN_TIMEWAIT2状态,缺省值60s,建议适当调小
tcp_tw_max_buckets81928192控制TIMEWAIT状态的最大个数
tcp_tw_reuse01是否允许TIMEWAIT状态重用,1表示允许
tcp_tw_recycle00此选项也是用来控制是否允许TIMEWAIT状态重用,不建议开启

参考

[1]. 深入理解 TCP 协议:从原理到实战(掘金小册)

[2]. Wireshark网络分析就这么简单

[3]. Wireshark网络分析的艺术

[4]. TCP/IP详解 卷1