程序员

注册

 

发新话题 回复该主题

近两万字TCP硬核知识,教你吊打面试官 [复制链接]

1#

作者

小林coding

TCP性能的提升不仅考察TCP的理论知识,还考察了对于操作系统提供的内核参数的理解与应用。

TCP协议是由操作系统实现,所以操作系统提供了不少调节TCP的参数。

LinuxTCP参数

如何正确有效的使用这些参数,来提高TCP性能是一个不那么简单事情。我们需要针对TCP每个阶段的问题来对症下药,而不是病急乱投医。

接下来,将以三个角度来阐述提升TCP的策略,分别是:

TCP三次握手的性能提升;TCP四次挥手的性能提升;TCP数据传输的性能提升;

TCP三次握手的性能提升

TCP是面向连接的、可靠的、双向传输的传输层通信协议,所以在传输数据之前需要经过三次握手才能建立连接。

三次握手与数据传输

那么,三次握手的过程在一个HTTP请求的平均时间占比10%以上,在网络状态不佳、高并发或者遭遇SYN攻击等场景中,如果不能有效正确的调节三次握手中的参数,就会对性能产生很多的影响。

如何正确有效的使用这些参数,来提高TCP三次握手的性能,这就需要理解「三次握手的状态变迁」,这样当出现问题时,先用netstat命令查看是哪个握手阶段出现了问题,再来对症下药,而不是病急乱投医。

TCP三次握手的状态变迁

客户端和服务端都可以针对三次握手优化性能。主动发起连接的客户端优化相对简单些,而服务端需要监听端口,属于被动连接方,其间保持许多的中间状态,优化方法相对复杂一些。

所以,客户端(主动发起连接方)和服务端(被动连接方)优化的方式是不同的,接下来分别针对客户端和服务端优化。

客户端优化

三次握手建立连接的首要目的是「同步序列号」。

只有同步了序列号才有可靠传输,TCP许多特性都依赖于序列号实现,比如流量控制、丢包重传等,这也是三次握手中的报文称为SYN的原因,SYN的全称就叫SynchronizeSequenceNumbers(同步序列号)。

TCP头部

SYN_SENT状态的优化

客户端作为主动发起连接方,首先它将发送SYN包,于是客户端的连接就会处于SYN_SENT状态。

客户端在等待服务端回复的ACK报文,正常情况下,服务器会在几毫秒内返回SYN+ACK,但如果客户端长时间没有收到SYN+ACK报文,则会重发SYN包,重发的次数由tcp_syn_retries参数控制,默认是5次:

通常,第一次超时重传是在1秒后,第二次超时重传是在2秒,第三次超时重传是在4秒后,第四次超时重传是在8秒后,第五次是在超时重传16秒后。没错,每次超时的时间是上一次的2倍。

当第五次超时重传后,会继续等待32秒,如果仍然服务端没有回应ACK,客户端就会终止三次握手。

所以,总耗时是1+2+4+8+16+32=63秒,大约1分钟左右。

SYN超时重传

你可以根据网络的稳定性和目标服务器的繁忙程度修改SYN的重传次数,调整客户端的三次握手时间上限。比如内网中通讯时,就可以适当调低重试次数,尽快把错误暴露给应用程序。

服务端优化

当服务端收到SYN包后,服务端会立马回复SYN+ACK包,表明确认收到了客户端的序列号,同时也把自己的序列号发给对方。

此时,服务端出现了新连接,状态是SYN_RCV。在这个状态下,Linux内核就会建立一个「半连接队列」来维护「未完成」的握手信息,当半连接队列溢出后,服务端就无法再建立新的连接。

半连接队列与全连接队列

SYN攻击,攻击的是就是这个半连接队列。

如何查看由于SYN半连接队列已满,而被丢弃连接的情况?

我们可以通过该netstat-s命令给出的统计结果中,可以得到由于半连接队列已满,引发的失败次数:

上面输出的数值是累计值,表示共有多少个TCP连接因为半连接队列溢出而被丢弃。隔几秒执行几次,如果有上升的趋势,说明当前存在半连接队列溢出的现象。

如何调整SYN半连接队列大小?

要想增大半连接队列,不能只单纯增大tcp_max_syn_backlog的值,还需一同增大somaxconn和backlog,也就是增大accept队列。否则,只单纯增大tcp_max_syn_backlog是无效的。

增大tcp_max_syn_backlog和somaxconn的方法是修改Linux内核参数:

增大backlog的方式,每个Web服务都不同,比如Nginx增大backlog的方法如下:

最后,改变了如上这些参数后,要重启Nginx服务,因为SYN半连接队列和accept队列都是在listen()初始化的。

如果SYN半连接队列已满,只能丢弃连接吗?

并不是这样,开启syncookies功能就可以在不使用SYN半连接队列的情况下成功建立连接。

syncookies的工作原理:服务器根据当前状态计算出一个值,放在己方发出的SYN+ACK报文中发出,当客户端返回ACK报文时,取出该值验证,如果合法,就认为连接建立成功,如下图所示。

开启syncookies功能

syncookies参数主要有以下三个值:

0值,表示关闭该功能;1值,表示仅当SYN半连接队列放不下时,再启用它;2值,表示无条件开启功能;那么在应对SYN攻击时,只需要设置为1即可:

SYN_RCV状态的优化

当客户端接收到服务器发来的SYN+ACK报文后,就会回复ACK给服务器,同时客户端连接状态从SYN_SENT转换为ESTABLISHED,表示连接建立成功。

服务器端连接成功建立的时间还要再往后,等到服务端收到客户端的ACK后,服务端的连接状态才变为ESTABLISHED。

如果服务器没有收到ACK,就会重发SYN+ACK报文,同时一直处于SYN_RCV状态。

当网络繁忙、不稳定时,报文丢失就会变严重,此时应该调大重发次数。反之则可以调小重发次数。修改重发次数的方法是,调整tcp_synack_retries参数:

tcp_synack_retries的默认重试次数是5次,与客户端重传SYN类似,它的重传会经历1、2、4、8、16秒,最后一次重传后会继续等待32秒,如果服务端仍然没有收到ACK,才会关闭连接,故共需要等待63秒。

服务器收到ACK后连接建立成功,此时,内核会把连接从半连接队列移除,然后创建新的完全的连接,并将其添加到accept队列,等待进程调用accept函数时把连接取出来。

如果进程不能及时地调用accept函数,就会造成accept队列(也称全连接队列)溢出,最终导致建立好的TCP连接被丢弃。

accept队列溢出

accept队列已满,只能丢弃连接吗?

丢弃连接只是Linux的默认行为,我们还可以选择向客户端发送RST复位报文,告诉客户端连接已经建立失败。打开这一功能需要将tcp_abort_on_overflow参数设置为1。

tcp_abort_on_overflow共有两个值分别是0和1,其分别表示:

0:如果accept队列满了,那么server扔掉client发过来的ack;1:如果accept队列满了,server发送一个RST包给client,表示废掉这个握手过程和这个连接;如果要想知道客户端连接不上服务端,是不是服务端TCP全连接队列满的原因,那么可以把tcp_abort_on_overflow设置为1,这时如果在客户端异常中可以看到很多connectionresetbypeer的错误,那么就可以证明是由于服务端TCP全连接队列溢出的问题。

通常情况下,应当把tcp_abort_on_overflow设置为0,因为这样更有利于应对突发流量。

举个例子,当accept队列满导致服务器丢掉了ACK,与此同时,客户端的连接状态却是ESTABLISHED,客户端进程就在建立好的连接上发送请求。只要服务器没有为请求回复ACK,客户端的请求就会被多次「重发」。如果服务器上的进程只是短暂的繁忙造成accept队列满,那么当accept队列有空位时,再次接收到的请求报文由于含有ACK,仍然会触发服务器端成功建立连接。

tcp_abort_on_overflow为0可以应对突发流量

所以,tcp_abort_on_overflow设为0可以提高连接建立的成功率,只有你非常肯定TCP全连接队列会长期溢出时,才能设置为1以尽快通知客户端。

如何调整accept队列的长度呢?

accept队列的长度取决于somaxconn和backlog之间的最小值,也就是min(somaxconn,backlog),其中:

somaxconn是Linux内核的参数,默认值是,可以通过net.core.somaxconn来设置其值;backlog是listen(intsockfd,intbacklog)函数中的backlog大小;Tomcat、Nginx、Apache常见的Web服务的backlog默认值都是。

如何查看服务端进程accept队列的长度?

可以通过ss-ltn命令查看:

Recv-Q:当前accept队列的大小,也就是当前已完成三次握手并等待服务端accept()的TCP连接;Send-Q:accept队列最大长度,上面的输出结果说明监听端口的TCP服务,accept队列的最大长度为;如何查看由于accept连接队列已满,而被丢弃的连接?

当超过了accept连接队列,服务端则会丢掉后续进来的TCP连接,丢掉的TCP连接的个数会被统计起来,我们可以使用netstat-s命令来查看:

上面看到的times,表示accept队列溢出的次数,注意这个是累计值。可以隔几秒钟执行下,如果这个数字一直在增加的话,说明accept连接队列偶尔满了。

如果持续不断地有连接因为accept队列溢出被丢弃,就应该调大backlog以及somaxconn参数。

如何绕过三次握手?

以上我们只是在对三次握手的过程进行优化,接下来我们看看如何绕过三次握手发送数据。

三次握手建立连接造成的后果就是,HTTP请求必须在一个RTT(从客户端到服务器一个往返的时间)后才能发送。

常规HTTP请求

在Linux3.7内核版本之后,提供了TCPFastOpen功能,这个功能可以减少TCP连接建立的时延。

接下来说说,TCPFastOpen功能的工作方式。

开启TCPFastOpen功能

在客户端首次建立连接时的过程:

客户端发送SYN报文,该报文包含FastOpen选项,且该选项的Cookie为空,这表明客户端请求FastOpenCookie;支持TCPFastOpen的服务器生成Cookie,并将其置于SYN-ACK数据包中的FastOpen选项以发回客户端;客户端收到SYN-ACK后,本地缓存FastOpen选项中的Cookie。所以,第一次发起HTTPGET请求的时候,还是需要正常的三次握手流程。

之后,如果客户端再次向服务器建立连接时的过程:

客户端发送SYN报文,该报文包含「数据」(对于非TFO的普通TCP握手过程,SYN报文中不包含「数据」)以及此前记录的Cookie;支持TCPFastOpen的服务器会对收到Cookie进行校验:如果Cookie有效,服务器将在SYN-ACK报文中对SYN和「数据」进行确认,服务器随后将「数据」递送至相应的应用程序;如果Cookie无效,服务器将丢弃SYN报文中包含的「数据」,且其随后发出的SYN-ACK报文将只确认SYN的对应序列号;如果服务器接受了SYN报文中的「数据」,服务器可在握手完成之前发送「数据」,这就减少了握手带来的1个RTT的时间消耗;客户端将发送ACK确认服务器发回的SYN以及「数据」,但如果客户端在初始的SYN报文中发送的「数据」没有被确认,则客户端将重新发送「数据」;此后的TCP连接的数据传输过程和非TFO的正常情况一致。所以,之后发起HTTPGET请求的时候,可以绕过三次握手,这就减少了握手带来的1个RTT的时间消耗。

注:客户端在请求并存储了FastOpenCookie之后,可以不断重复TCPFastOpen直至服务器认为Cookie无效(通常为过期)。

Linux下怎么打开TCPFastOpen功能呢?

在Linux系统中,可以通过设置tcp_fastopn内核参数,来打开FastOpen功能:

tcp_fastopn各个值的意义/p>

0关闭1作为客户端使用FastOpen功能2作为服务端使用FastOpen功能3无论作为客户端还是服务器,都可以使用FastOpen功能TCPFastOpen功能需要客户端和服务端同时支持,才有效果。

小结

本小结主要介绍了关于优化TCP三次握手的几个TCP参数。

三次握手优化策略

客户端的优化

当客户端发起SYN包时,可以通过tcp_syn_retries控制其重传的次数。

服务端的优化

当服务端SYN半连接队列溢出后,会导致后续连接被丢弃,可以通过netstat-s观察半连接队列溢出的情况,如果SYN半连接队列溢出情况比较严重,可以通过tcp_max_syn_backlog、somaxconn、backlog参数来调整SYN半连接队列的大小。

服务端回复SYN+ACK的重传次数由tcp_synack_retries参数控制。如果遭受SYN攻击,应把tcp_syncookies参数设置为1,表示仅在SYN队列满后开启syncookie功能,可以保证正常的连接成功建立。

服务端收到客户端返回的ACK,会把连接移入accpet队列,等待进行调用accpet()函数取出连接。

可以通过ss-lnt查看服务端进程的accept队列长度,如果accept队列溢出,系统默认丢弃ACK,如果可以把tcp_abort_on_overflow设置为1,表示用RST通知客户端连接建立失败。

如果accpet队列溢出严重,可以通过listen函数的backlog参数和somaxconn系统参数提高队列大小,accept队列长度取决于min(backlog,somaxconn)。

绕过三次握手

TCPFastOpen功能可以绕过三次握手,使得HTTP请求减少了1个RTT的时间,Linux下可以通过tcp_fastopen开启该功能,同时必须保证服务端和客户端同时支持。

TCP四次挥手的性能提升

接下来,我们一起看看针对TCP四次挥手关不连接时,如何优化性能。

在开始之前,我们得先了解四次挥手状态变迁的过程。

客户端和服务端双方都可以主动断开连接,通常先关闭连接的一方称为主动方,后关闭连接的一方称为被动方。

客户端主动关闭

可以看到,四次挥手过程只涉及了两种报文,分别是FIN和ACK:

FIN就是结束连接的意思,谁发出FIN报文,就表示它将不会再发送任何数据,关闭这一方向上的传输通道;ACK就是确认的意思,用来通知对方:你方的发送通道已经关闭;四次挥手的过程/p>

当主动方关闭连接时,会发送FIN报文,此时发送方的TCP连接将从ESTABLISHED变成FIN_WAIT1。当被动方收到FIN报文后,内核会自动回复ACK报文,连接状态将从ESTABLISHED变成CLOSE_WAIT,表示被动方在等待进程调用close函数关闭连接。当主动方收到这个ACK后,连接状态由FIN_WAIT1变为FIN_WAIT2,也就是表示主动方的发送通道就关闭了。当被动方进入CLOSE_WAIT时,被动方还会继续处理数据,等到进程的read函数返回0后,应用程序就会调用close函数,进而触发内核发送FIN报文,此时被动方的连接状态变为LAST_ACK。当主动方收到这个FIN报文后,内核会回复ACK报文给被动方,同时主动方的连接状态由FIN_WAIT2变为TIME_WAIT,在Linux系统下大约等待1分钟后,TIME_WAIT状态的连接才会彻底关闭。当被动方收到最后的ACK报文后,被动方的连接就会关闭。你可以看到,每个方向都需要一个FIN和一个ACK,因此通常被称为四次挥手。

这里一点需要注意是:主动关闭连接的,才有TIME_WAIT状态。

主动关闭方和被动关闭方优化的思路也不同,接下来分别说说如何优化他们。

主动方的优化

关闭的连接的方式通常有两种,分别是RST报文关闭和FIN报文关闭。

如果进程异常退出了,内核就会发送RST报文来关闭,它可以不走四次挥手流程,是一个暴力关闭连接的方式。

安全关闭连接的方式必须通过四次挥手,它由进程调用close和shutdown函数发起FIN报文(shutdown参数须传入SHUT_WR或者SHUT_RDWR才会发送FIN)。

调用close函数和shutdown函数有什么区别?

调用了close函数意味着完全断开连接,完全断开不仅指无法传输数据,而且也不能发送数据。此时,调用了close函数的一方的连接叫做「孤儿连接」,如果你用netstat-p命令,会发现连接对应的进程名为空。

使用close函数关闭连接是不优雅的。于是,就出现了一种优雅关闭连接的shutdown函数,它可以控制只关闭一个方向的连接:

第二个参数决定断开连接的方式,主要有以下三种方式:

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

FIN_WAIT1状态的优化

主动方发送FIN报文后,连接就处于FIN_WAIT1状态,正常情况下,如果能及时收到被动方的ACK,则会很快变为FIN_WAIT2状态。

但是当迟迟收不到对方返回的ACK时,连接就会一直处于FIN_WAIT1状态。此时,内核会定时重发FIN报文,其中重发次数由tcp_orphan_retries参数控制(注意,orphan虽然是孤儿的意思,该参数却不只对孤儿连接有效,事实上,它对所有FIN_WAIT1状态下的连接都有效),默认值是0。

你可能会好奇,这0表示几次?实际上当为0时,特指8次,从下面的内核源码可知:

如果FIN_WAIT1状态连接很多,我们就需要考虑降低tcp_orphan_retries的值,当重传次数超过tcp_orphan_retries时,连接就会直接关闭掉。

对于普遍正常情况时,调低tcp_orphan_retries就已经可以了。如果遇到恶意攻击,FIN报文根本无法发送出去,这由TCP两个特性导致的:

首先,TCP必须保证报文是有序发送的,FIN报文也不例外,当发送缓冲区还有数据没有发送时,FIN报文也不能提前发送。其次,TCP有流量控制功能,当接收方接收窗口为0时,发送方就不能再发送数据。所以,当攻击者下载大文件时,就可以通过接收窗口设为0,这就会使得FIN报文都无法发送出去,那么连接会一直处于FIN_WAIT1状态。解决这种问题的方法,是调整tcp_max_orphans参数,它定义了「孤儿连接」的最大数量:

当进程调用了close函数关闭连接,此时连接就会是「孤儿连接」,因为它无法在发送和接收数据。Linux系统为了防止孤儿连接过多,导致系统资源长时间被占用,就提供了tcp_max_orphans参数。如果孤儿连接数量大于它,新增的孤儿连接将不再走四次挥手,而是直接发送RST复位报文强制关闭。

FIN_WAIT2状态的优化

当主动方收到ACK报文后,会处于FIN_WAIT2状态,就表示主动方的发送通道已经关闭,接下来将等待对方发送FIN报文,关闭对方的发送通道。

这时,如果连接是用shutdown函数关闭的,连接可以一直处于FIN_WAIT2状态,因为它可能还可以发送或接收数据。但对于close函数关闭的孤儿连接,由于无法在发送和接收数据,所以这个状态不可以持续太久,而tcp_fin_timeout控制了这个状态下连接的持续时长,默认值是60秒:

它意味着对于孤儿连接(调用close关闭的连接),如果在60秒后还没有收到FIN报文,连接就会直接关闭。

这个60秒不是随便决定的,它与TIME_WAIT状态持续的时间是相同的,后面我们在来说说为什么是60秒。

TIME_WAIT状态的优化

TIME_WAIT是主动方四次挥手的最后一个状态,也是最常遇见的状态。

当收到被动方发来的FIN报文后,主动方会立刻回复ACK,表示确认对方的发送通道已经关闭,接着就处于TIME_WAIT状态。在Linux系统,TIME_WAIT状态会持续60秒后才会进入关闭状态。

TIME_WAIT状态的连接,在主动方看来确实快已经关闭了。然后,被动方没有收到ACK报文前,还是处于LAST_ACK状态。如果这个ACK报文没有到达被动方,被动方就会重发FIN报文。重发次数仍然由前面介绍过的tcp_orphan_retries参数控制。

TIME-WAIT的状态尤其重要,主要是两个原因:

防止具有相同「四元组」的「旧」数据包被收到;保证「被动关闭连接」的一方能被正确的关闭,即保证最后的ACK能让被动关闭方接收,从而帮助其正常关闭;原因一:防止旧连接的数据包

TIME-WAIT的一个作用是防止收到历史数据,从而导致数据错乱的问题。

假设TIME-WAIT没有等待时间或时间过短,被延迟的数据包抵达后会发生什么呢?

接收到历史数据的异常

如上图*色框框服务端在关闭连接之前发送的SEQ=报文,被网络延迟了。这时有相同端口的TCP连接被复用后,被延迟的SEQ=抵达了客户端,那么客户端是有可能正常接收这个过期的报文,这就会产生数据错乱等严重的问题。所以,TCP就设计出了这么一个机制,经过2MSL这个时间,足以让两个方向上的数据包都被丢弃,使得原来连接的数据包在网络中都自然消失,再出现的数据包一定都是新建立连接所产生的。

原因二:保证连接正确关闭

TIME-WAIT的另外一个作用是等待足够的时间以确保最后的ACK能让被动关闭方接收,从而帮助其正常关闭。

假设TIME-WAIT没有等待时间或时间过短,断开连接会造成什么问题呢?

没有确保正常断开的异常

如上图红色框框客户端四次挥手的最后一个ACK报文如果在网络中被丢失了,此时如果客户端TIME-WAIT过短或没有,则就直接进入了CLOSE状态了,那么服务端则会一直处在LASE-ACK状态。当客户端发起建立连接的SYN请求报文后,服务端会发送RST报文给客户端,连接建立的过程就会被终止。我们再回过头来看看,为什么TIME_WAIT状态要保持60秒呢?这与孤儿连接FIN_WAIT2状态默认保留60秒的原理是一样的,因为这两个状态都需要保持2MSL时长。MSL全称是MaximumSegmentLifetime,它定义了一个报文在网络中的最长生存时间(报文每经过一次路由器的转发,IP头部的TTL字段就会减1,减到0时报文就被丢弃,这就限制了报文的最长存活时间)。

为什么是2MSL的时长呢?这其实是相当于至少允许报文丢失一次。比如,若ACK在一个MSL内丢失,这样被动方重发的FIN会在第2个MSL内到达,TIME_WAIT状态的连接可以应对。

为什么不是4或者8MSL的时长呢?你可以想象一个丢包率达到百分之一的糟糕网络,连续两次丢包的概率只有万分之一,这个概率实在是太小了,忽略它比解决它更具性价比。

因此,TIME_WAIT和FIN_WAIT2状态的最大时长都是2MSL,由于在Linux系统中,MSL的值固定为30秒,所以它们都是60秒。

虽然TIME_WAIT状态有存在的必要,但它毕竟会消耗系统资源。如果发起连接一方的TIME_WAIT状态过多,占满了所有端口资源,则会导致无法创建新连接。

客户端受端口资源限制:如果客户端TIME_WAIT过多,就会导致端口资源被占用,因为端口就个,被占满就会导致无法创建新的连接;服务端受系统资源限制:由于一个四元组表示TCP连接,理论上服务端可以建立很多连接,服务端确实只监听一个端口但是会把连接扔给处理线程,所以理论上监听的端口可以继续监听。但是线程池处理不了那么多一直不断的连接了。所以当服务端出现大量TIME_WAIT时,系统资源被占满时,会导致处理不过来新的连接;另外,Linux提供了tcp_max_tw_buckets参数,当TIME_WAIT的连接数量超过该参数时,新关闭的连接就不再经历TIME_WAIT而直接关闭:

当服务器的并发连接增多时,相应地,同时处于TIME_WAIT状态的连接数量也会变多,此时就应当调大tcp_max_tw_buckets参数,减少不同连接间数据错乱的概率。

tcp_max_tw_buckets也不是越大越好,毕竟内存和端口都是有限的。

有一种方式可以在建立新连接时,复用处于TIME_WAIT状态的连接,那就是打开tcp_tw_reuse参数。但是需要注意,该参数是只用于客户端(建立连接的发起方),因为是在调用connect()时起作用的,而对于服务端(被动连接方)是没有用的。

tcp_tw_reuse从协议角度理解是安全可控的,可以复用处于TIME_WAIT的端口为新的连接所用。

什么是协议角度理解的安全可控呢?主要有两点:

只适用于连接发起方,也就是C/S模型中的客户端;对应的TIME_WAIT状态的连接创建时间超过1秒才可以被复用。使用这个选项,还有一个前提,需要打开对TCP时间戳的支持(对方也要打开):

由于引入了时间戳,它能带来了些好处:

我们在前面提到的2MSL问题就不复存在了,因为重复的数据包会因为时间戳过期被自然丢弃;同时,它还可以防止序列号绕回,也是因为重复的数据包会由于时间戳过期被自然丢弃;老版本的Linux还提供了tcp_tw_recycle参数,但是当开启了它,就有两个坑:

Linux会加快客户端和服务端TIME_WAIT状态的时间,也就是它会使得TIME_WAIT状态会小于60秒,很容易导致数据错乱;另外,Linux会丢弃所有来自远端时间戳小于上次记录的时间戳(由同一个远端发送的)的任何数据包。就是说要使用该选项,则必须保证数据包的时间戳是单调递增的。那么,问题在于,此处的时间戳并不是我们通常意义上面的绝对时间,而是一个相对时间。很多情况下,我们是没法保证时间戳单调递增的,比如使用了NAT,LVS等情况;所以,不建议设置为1,建议关闭它:

在Linux4.12版本后,Linux内核直接取消了这一参数。

另外,我们可以在程序中设置socket选项,来设置调用close关闭连接行为。

如果l_onoff为非0,且l_linger值为0,那么调用close后,会立该发送一个RST标志给对端,该TCP连接将跳过四次挥手,也就跳过了TIME_WAIT状态,直接关闭。

但这为跨越TIME_WAIT状态提供了一个可能,不过是一个非常危险的行为,不值得提倡。

被动方的优化

当被动方收到FIN报文时,内核会自动回复ACK,同时连接处于CLOSE_WAIT状态,顾名思义,它表示等待应用进程调用close函数关闭连接。

内核没有权利替代进程去关闭连接,因为如果主动方是通过shutdown关闭连接,那么它就是想在半关闭连接上接收数据或发送数据。因此,Linux并没有限制CLOSE_WAIT状态的持续时间。

当然,大多数应用程序并不使用shutdown函数关闭连接。所以,当你用netstat命令发现大量CLOSE_WAIT状态。就需要排查你的应用程序,因为可能因为应用程序出现了Bug,read函数返回0时,没有调用close函数。

处于CLOSE_WAIT状态时,调用了close函数,内核就会发出FIN报文关闭发送通道,同时连接进入LAST_ACK状态,等待主动方返回ACK来确认连接关闭。

如果迟迟收不到这个ACK,内核就会重发FIN报文,重发次数仍然由tcp_orphan_retries参数控制,这与主动方重发FIN报文的优化策略一致。

还有一点我们需要注意的,如果被动方迅速调用close函数,那么被动方的ACK和FIN有可能在一个报文中发送,这样看起来,四次挥手会变成三次挥手,这只是一种特殊情况,不用在意。

如果连接双方同时关闭连接,会怎么样?

由于TCP是双全工的协议,所以是会出现两方同时关闭连接的现象,也就是同时发送了FIN报文。

此时,上面介绍的优化策略仍然适用。两方发送FIN报文时,都认为自己是主动方,所以都进入了FIN_WAIT1状态,FIN报文的重发次数仍由tcp_orphan_retries参数控制。

同时关闭

接下来,双方在等待ACK报文的过程中,都等来了FIN报文。这是一种新情况,所以连接会进入一种叫做CLOSING的新状态,它替代了FIN_WAIT2状态。接着,双方内核回复ACK确认对方发送通道的关闭后,进入TIME_WAIT状态,等待2MSL的时间后,连接自动关闭。

小结

针对TCP四次挥手的优化,我们需要根据主动方和被动方四次挥手状态变化来调整系统TCP内核参数。

四次挥手的优化策略

主动方的优化

主动发起FIN报文断开连接的一方,如果迟迟没收到对方的ACK回复,则会重传FIN报文,重传的次数由tcp_orphan_retries参数决定。

当主动方收到ACK报文后,连接就进入FIN_WAIT2状态,根据关闭的方式不同,优化的方式也不同:

如果这是close函数关闭的连接,那么它就是孤儿连接。如果tcp_fin_timeout秒内没有收到对方的FIN报文,连接就直接关闭。同时,为了应对孤儿连接占用太多的资源,tcp_max_orphans定义了最大孤儿连接的数量,超过时连接就会直接释放。反之是shutdown函数关闭的连接,则不受此参数限制;当主动方接收到FIN报文,并返回ACK后,主动方的连接进入TIME_WAIT状态。这一状态会持续1分钟,为了防止TIME_WAIT状态占用太多的资源,tcp_max_tw_buckets定义了最大数量,超过时连接也会直接释放。

当TIME_WAIT状态过多时,还可以通过设置tcp_tw_reuse和tcp_timestamps为1,将TIME_WAIT状态的端口复用于作为客户端的新连接,注意该参数只适用于客户端。

被动方的优化

被动关闭的连接方应对非常简单,它在回复ACK后就进入了CLOSE_WAIT状态,等待进程调用close函数关闭连接。因此,出现大量CLOSE_WAIT状态的连接时,应当从应用程序中找问题。

当被动方发送FIN报文后,连接就进入LAST_ACK状态,在未等到ACK时,会在tcp_orphan_retries参数的控制下重发FIN报文。

TCP传输数据的性能提升

在前面介绍的是三次握手和四次挥手的优化策略,接下来主要介绍的是TCP传输数据时的优化策略。

TCP连接是由内核维护的,内核会为每个连接建立内存缓冲区:

如果连接的内存配置过小,就无法充分使用网络带宽,TCP传输效率就会降低;如果连接的内存配置过大,很容易把服务器资源耗尽,这样就会导致新连接无法建立;因此,我们必须理解Linux下TCP内存的用途,才能正确地配置内存大小。

滑动窗口是如何影响传输速度的?

TCP会保证每一个报文都能够抵达对方,它的机制是这样:报文发出去后,必须接收到对方返回的确认报文ACK,如果迟迟未收到,就会超时重发该报文,直到收到对方的ACK为止。

所以,TCP报文发出去后,并不会立马从内存中删除,因为重传时还需要用到它。

由于TCP是内核维护的,所以报文存放在内核缓冲区。如果连接非常多,我们可以通过free命令观察到buff/cache内存是会增大。

如果TCP是每发送一个数据,都要进行一次确认应答。当上一个数据包收到了应答了,再发送下一个。这个模式就有点像我和你面对面聊天,你一句我一句,但这种方式的缺点是效率比较低的。

按数据包进行确认应答

所以,这样的传输方式有一个缺点:数据包的往返时间越长,通信的效率就越低。

要解决这一问题不难,并行批量发送报文,再批量确认报文即刻。

并行处理

然而,这引出了另一个问题,发送方可以随心所欲的发送报文吗?当然这不现实,我们还得考虑接收方的处理能力。

当接收方硬件不如发送方,或者系统繁忙、资源紧张时,是无法瞬间处理这么多报文的。于是,这些报文只能被丢掉,使得网络效率非常低。

为了解决这种现象发生,TCP提供一种机制可以让「发送方」根据「接收方」的实际接收能力控制发送的数据量,这就是滑动窗口的由来。

接收方根据它的缓冲区,可以计算出后续能够接收多少字节的报文,这个数字叫做接收窗口。当内核接收到报文时,必须用缓冲区存放它们,这样剩余缓冲区空间变小,接收窗口也就变小了;当进程调用read函数后,数据被读入了用户空间,内核缓冲区就被清空,这意味着主机可以接收更多的报文,接收窗口就会变大。

因此,接收窗口并不是恒定不变的,接收方会把当前可接收的大小放在TCP报文头部中的窗口字段,这样就可以起到窗口大小通知的作用。

发送方的窗口等价于接收方的窗口吗?如果不考虑拥塞控制,发送方的窗口大小「约等于」接收方的窗口大小,因为窗口通知报文在网络传输是存在时延的,所以是约等于的关系。

TCP头部

从上图中可以看到,窗口字段只有2个字节,因此它最多能表达字节大小的窗口,也就是64KB大小。

这个窗口大小最大值,在当今高速网络下,很明显是不够用的。所以后续有了扩充窗口的方法:在TCP选项字段定义了窗口扩大因子,用于扩大TCP通告窗口,使TCP的窗口大小从2个字节(16位)扩大为30位,所以此时窗口的最大值可以达到1GB(2^30)。

Linux中打开这一功能,需要把tcp_window_scaling配置设为1(默认打开):

要使用窗口扩大选项,通讯双方必须在各自的SYN报文中发送这个选项:

主动建立连接的一方在SYN报文中发送这个选项;而被动建立连接的一方只有在收到带窗口扩大选项的SYN报文之后才能发送这个选项。这样看来,只要进程能及时地调用read函数读取数据,并且接收缓冲区配置得足够大,那么接收窗口就可以无限地放大,发送方也就无限地提升发送速度。

这是不可能的,因为网络的传输能力是有限的,当发送方依据发送窗口,发送超过网络处理能力的报文时,路由器会直接丢弃这些报文。因此,缓冲区的内存并不是越大越好。

如果确定最大传输速度?

在前面我们知道了TCP的传输速度,受制于发送窗口与接收窗口,以及网络设备传输能力。其中,窗口大小由内核缓冲区大小决定。如果缓冲区与网络传输能力匹配,那么缓冲区的利用率就达到了最大化。

问题来了,如何计算网络的传输能力呢?

相信大家都知道网络是有「带宽」限制的,带宽描述的是网络传输能力,它与内核缓冲区的计量单位不同/p>

带宽是单位时间内的流量,表达是「速度」,比如常见的带宽MB/s;缓冲区单位是字节,当网络速度乘以时间才能得到字节数;这里需要说一个概念,就是带宽时延积,它决定网络中飞行报文的大小,它的计算方式:

比如最大带宽是MB/s,网络时延(RTT)是10ms时,意味着客户端到服务端的网络一共可以存放MB/s*0.01s=1MB的字节。

这个1MB是带宽和时延的乘积,所以它就叫「带宽时延积」(缩写为BDP,BandwidthDelayProduct)。同时,这1MB也表示「飞行中」的TCP报文大小,它们就在网络线路、路由器等网络设备上。如果飞行报文超过了1MB,就会导致网络过载,容易丢包。

由于发送缓冲区大小决定了发送窗口的上限,而发送窗口又决定了「已发送未确认」的飞行报文的上限。因此,发送缓冲区不能超过「带宽时延积」。

发送缓冲区与带宽时延积的关系:

如果发送缓冲区「超过」带宽时延积,超出的部分就没办法有效的网络传输,同时导致网络过载,容易丢包;如果发送缓冲区「小于」带宽时延积,就不能很好的发挥出网络的传输效率。所以,发送缓冲区的大小最好是往带宽时延积靠近。

怎样调整缓冲区大小?

在Linux中发送缓冲区和接收缓冲都是可以用参数调节的。设置完后,Linux会根据你设置的缓冲区进行动态调节。

调节发送缓冲区范围

先来看看发送缓冲区,它的范围通过tcp_wmem参数配置;

上面三个数字单位都是字节,它们分别表示:

第一个数值是动态范围的最小值,byte=4K;第二个数值是初始默认值,byte≈86K;第三个数值是动态范围的最大值,byte=K(4M);发送缓冲区是自行调节的,当发送方发送的数据被确认后,并且没有新的数据要发送,就会把发送缓冲区的内存释放掉。

调节接收缓冲区范围

而接收缓冲区的调整就比较复杂一些,先来看看设置接收缓冲区范围的tcp_rmem参数:

上面三个数字单位都是字节,它们分别表示:

第一个数值是动态范围的最小值,表示即使在内存压力下也可以保证的最小接收缓冲区大小,byte=4K;第二个数值是初始默认值,byte≈86K;第三个数值是动态范围的最大值,byte=K(6M);接收缓冲区可以根据系统空闲内存的大小来调节接收窗口:

如果系统的空闲内存很多,就可以自动把缓冲区增大一些,这样传给对方的接收窗口也会变大,因而提升发送方发送的传输数据数量;反正,如果系统的内存很紧张,就会减少缓冲区,这虽然会降低传输效率,可以保证更多的并发连接正常工作;发送缓冲区的调节功能是自动开启的,而接收缓冲区则需要配置tcp_moderate_rcvbuf为1来开启调节功能:

调节TCP内存范围

接收缓冲区调节时,怎么知道当前内存是否紧张或充分呢?这是通过tcp_mem配置完成的:

上面三个数字单位不是字节,而是「页面大小」,1页表示4KB,它们分别表示:

当TCP内存小于第1个值时,不需要进行自动调节;在第1和第2个值之间时,内核开始调节接收缓冲区的大小;大于第3个值时,内核不再为TCP分配新内存,此时新连接是无法建立的;一般情况下这些值是在系统启动时根据系统内存数量计算得到的。根据当前tcp_mem最大内存页面数是,当内存为(*4)/K≈M时,系统将无法为新的TCP连接分配内存,即TCP连接将被拒绝。

根据实际场景调节的策略

在高并发服务器中,为了兼顾网速与大量的并发连接,我们应当保证缓冲区的动态调整的最大值达到带宽时延积,而最小值保持默认的4K不变即可。而对于内存紧张的服务而言,调低默认值是提高并发的有效手段。

同时,如果这是网络IO型服务器,那么,调大tcp_mem的上限可以让TCP连接使用更多的系统内存,这有利于提升并发能力。需要注意的是,tcp_wmem和tcp_rmem的单位是字节,而tcp_mem的单位是页面大小。而且,千万不要在socket上直接设置SO_SNDBUF或者SO_RCVBUF,这样会关闭缓冲区的动态调整功能。

小结

本节针对TCP优化数据传输的方式,做了一些介绍。

数据传输的优化策略

TCP可靠性是通过ACK确认报文实现的,又依赖滑动窗口提升了发送速度也兼顾了接收方的处理能力。

可是,默认的滑动窗口最大值只有64KB,不满足当今的高速网络的要求,要想要想提升发送速度必须提升滑动窗口的上限,在Linux下是通过设置tcp_window_scaling为1做到的,此时最大值可高达1GB。

滑动窗口定义了网络中飞行报文的最大字节数,当它超过带宽时延积时,网络过载,就会发生丢包。而当它小于带宽时延积时,就无法充分利用网络带宽。因此,滑动窗口的设置,必须参考带宽时延积。

内核缓冲区决定了滑动窗口的上限,缓冲区可分为:发送缓冲区tcp_wmem和接收缓冲区tcp_rmem。

Linux会对缓冲区动态调节,我们应该把缓冲区的上限设置为带宽时延积。发送缓冲区的调节功能是自动打开的,而接收缓冲区需要把tcp_moderate_rcvbuf设置为1来开启。其中,调节的依据是TCP内存范围tcp_mem。

但需要注意的是,如果程序中的socket设置SO_SNDBUF和SO_RCVBUF,则会关闭缓冲区的动态整功能,所以不建议在程序设置它俩,而是交给内核自动调整比较好。

有效配置这些参数后,既能够最大程度地保持并发性,也能让资源充裕时连接传输速度达到最大值。

巨人的肩膀:

[1]系统性能调优必知必会.陶辉.极客时间.

[2]网络编程实战专栏.盛延敏.极客时间.

[3]

分享 转发
TOP
发新话题 回复该主题