当前位置: 首页 > 科技观察

TCP那些事之一:TCP协议、算法和原理

时间:2023-03-12 07:42:33 科技观察

TCP是一个非常复杂的协议,因为它要解决很多问题,而这些问题又会引出很多子问题和阴暗面。所以学习TCP本身就是一个痛苦的过程,但是学习的过程却能让人收获很多。关于TCP协议的细节,我还是推荐大家阅读W.RichardStevens的《TCP/IP 详解 卷1:协议》(当然你也可以阅读RFC793和后面的RFC)。另外,我会在本文中使用英文术语,方便大家通过这些英文关键词轻松找到相关的技术文档。之所以要写这篇文章,有以下三个目的:一是锻炼自己在简单的篇幅里把如此复杂的TCP协议描述清楚的能力。另一个是现在很多程序员基本不认真看这本书,喜欢快餐文化。因此,希望这篇快餐文章能够让大家了解TCP的经典技术,体验软件设计。各种困难。您可以从中获得一些软件设计收益。最重要的是希望这些基础知识能够让你明白很多以前说得通的东西,能够体会到基础知识的重要性。因此,本文不会面面俱到,只是对TCP协议、算法和原理做一个科普。话不多说,首先我们要知道TCP在七层网络OSI模型的第四层——传输层,IP在第三层——网络层,ARP在第二层——数据链路layer,在第二层的数据叫做Frame,第三层的数据叫做Packet,第四层的数据叫做Segment。首先我们要知道我们程序的数据首先会发送到TCP的Segment,然后TCP的Segment会发送到IP的Packet,再发送到Ethernet的Frame。解析自己的协议,然后将数据交给上层协议处理。TCP报头格式接下来我们看一下TCP报头的格式:TCP报头格式(图片来源)需要注意以下几点:TCP数据包没有IP地址,这是IP层的事情.但是有源端口和目的端口。一个TCP连接需要四个元组来表示同一个连接(src_ip、src_port、dst_ip、dst_port),正好是一个五元组,一个是协议。但是因为这只是TCP协议,所以这里只讲四元组。注意上图中四个很重要的东西:SequenceNumber是数据包的序号,用来解决网络数据包重排序的问题。AcknowledgementNumber是ACK——用来确认收到,解决不丢包的问题。Window也叫Advertised-Window,也就是大名鼎鼎的滑动窗口(SlidingWindow),用来解决流量控制。TCPFlag,也就是数据包的类型,主要用来操作TCP的状态机。其他的可以参考下图:(图片来源)TCP状态机其实就是网络上的传输是没有连接的,包括TCP。TCP所谓的“连接”,其实无非就是在通信的双方之间保持一种“连接状态”,使之看起来像是连接上了。因此,TCP的状态转换非常重要。以下是:“TCP协议状态机”(图片来源)与“TCP连接建立”、“TCP断开”、“数据传输”对比图,我把两张图并排放置,方便大家比较手表。另外,下面两张图非常非常重要,大家一定要牢记在心。(吐槽一下:看到这么复杂的状态机就知道这个协议有多复杂了,复杂的东西里面总有很多坑爹的东西,所以TCP协议其实坑爹得很)。很多人会问为什么建立链接需要3次握手,断开链接需要4次握手?对于建立链路的3次握手,主要目的是初始化SequenceNumber的初始值。通信的双方需要告知对方自己初始化的序列号(简称ISN:InitalSequenceNumber)——所以称为SYN,全称SynchronizeSequenceNumbers。即上图中的x和y。这个编号应该作为以后数据通信的序号,以保证应用层接收到的数据不会因为网络上的传输问题而乱序(TCP会使用这个序号来拼接数据)。对于4次挥手,其实仔细看是2次,因为TCP是全双工的,所以发送端和接收端都需要Fin和Ack。只是一方是被动的,所以看起来就是所谓的4次挥手。如果双方同时断开,则进入CLOSING状态,然后到达TIME_WAIT状态。下图是双方同时断开的示意图(也可以参考TCP状态机看):连接两端同时断开(图片来源)另外还有需要注意的几点:关于建立连接时的SYN超时。试想一下,如果server端收到client发送的SYN,然后返回SYN-ACK,然后client下线,而server端没有收到client返回的ACK,那么连接就处于中间状态,即既不成功也不失败。因此,如果服务器在一定时间内没有收到TCP,就会重发SYN-ACK。Linux下默认重试次数为5次,重试间隔从1s开始。5次的重试间隔为1s、2s、4s、8s、16s,一共31s,第五次需要32s才知道,第5次发送后也会超时,所以一共需要在1s+2s+4s+8s+16s+32s=2^6-1=63s之前TCP将断开连接。关于SYN泛洪。一些恶意的人为此创建了一个SYNFlood——向服务器发送一个SYN后,它就下线了,所以服务器需要默认等待63s再断开连接,这样黑客就可以将服务器的syn连接排到Exhausted,从而无法处理正常的连接请求。所以在linux下,给出了一个叫做tcp_syncookies的参数来处理这件事——当SYN队列满时,TCP会创建一个特殊的SequenceNumber,通过源地址端口、目的地址端口和时间戳(也叫cookie)发回),如果是黑客,则没有响应,如果是正常连接,会发回SYNCookie,然后服务器可以通过cookie建立连接(即使你不在SYN队列中).请注意,请不要使用tcp_syncookies来处理正常的重负载连接。因为synccookies是TCP协议的折衷版本,并不严谨。对于正常的请求,你应该调整三个TCP参数供你选择,一个是:tcp_synack_retries可以用来减少重试次数;二是:tcp_max_syn_backlog,可以增加SYN连接数;第三种是:tcp_abort_on_overflow如果你无法处理,你干脆拒绝连接。关于ISN的初始化。ISN不能硬编码,否则会出问题——例如:如果建立连接,则一直使用1作为ISN,如果客户端发送了30个报文段,但是网络断开了,客户端重新连接并使用1作为ISNISN,但是之前连接的数据包到达,所以认为是新连接的数据包。这时候client的SequenceNumber可能是3,server端以为client端的sequenceNumber是30,全乱了。根据RFC793,ISN会绑定一个假时钟,这个时钟会每4微秒给ISN加1,直到超过2^32,然后从0开始。因此,一个ISN的周期约为4.55小时.因为我们假设我们的TCPSegment在网络上的存活时间不会超过MaximumSegmentLifetime(简称MSL——维基百科词条),所以只要MSL的值小于4.55小时,那么我们就不会重用国际标准号。关于MSL和TIME_WAIT。通过上面对ISN的描述,相信大家也知道MSL是怎么来的了。我们注意到在TCP状态图中,从TIME_WAIT状态到CLOSED状态,有一个超时设置。这个超时设置为2*MSL(RFC793定义MSL为2分钟,Linux设置为30s)。为什么我们需要TIME_WAIT??为什么不直接切换到CLOSED状态呢?主要有两个原因:1)TIME_WAIT保证了有足够的时间让另一端收到ACK。如果被动关闭方没有收到Ack,就会触发被动端重新发送Fin,刚好有2个MSL,2)有足够的时间让这个连接不和后面的连接混在一起(你要知道有些自称路由器会缓存IP数据包,如果连接被重用,那么这些延迟的数据包可能会与新连接混在一起)。你可以看看这篇文章《TIME_WAIT and its design implications for protocols and scalable client server systems》。关于TIME_WAIT的数量太多了。从上面的描述我们可以知道TIME_WAIT是一个非常重要的状态,但是如果并发量很大的短链接,TIME_WAIT就会过多,也会消耗大量的系统资源。你只要搜索一下,就会发现处理方法十有八九是教你设置两个参数,一个叫tcp_tw_reuse,一个叫tcp_tw_recycle。这两个参数的默认值都是关闭的,后者的Recyle比前者的resue更激进,resue更温和。另外,如果使用tcp_tw_reuse,必须设置tcp_timestamps=1,否则无效。这里,一定要注意,开启这两个参数会有一个比较大的坑——它可能会导致TCP连接出现一些奇怪的问题(因为上面说了,如果不等待超时重用连接,一个新的连接可能会成立没有。正如官方文件所说“如果没有技术专家的建议/请求,不应更改”)。关于tcp_tw_reuse。官方文档说tcp_tw_reuse加上tcp_timestamps(也叫PAWS,ProtectionAgainstWrappedSequenceNumbers)可以保证协议的安全,但是需要tcp_timestamps两边都打开(可以看tcp_twsk_unique的源码)。我个人估计还是有一些场景会出问题。关于tcp_tw_recycle。如果启用了tcp_tw_recycle,它会假设对端启用了tcp_timestamps,然后比较时间戳。如果时间戳变大,可以重复使用。但是,如果对端是NAT网络(比如:某公司只用一个IP出公网)或者对端IP被另外一个复用,这个事情就复杂了。建立链接的SYN可能会直接丢失(可能会看到connectiontimeout错误)(如果想观察linux内核代码,可以参考源码tcp_timewait_state_process)。关于tcp_max_tw_buckets。这是为了控制并发TIME_WAIT的数量。默认值为180000,如果超过限制,系统会将超出部分销毁,然后在日志中发出警告(如:timewaitbuckettableoverflow)。官网文档说这个参数是用来对抗DDoS的。据说180000这个默认值也不小了。这个还是要根据实际情况来考虑的。同样,使用tcp_tw_reuse和tcp_tw_recycle来解决TIME_WAIT的问题是非常非常危险的,因为这两个参数违反了TCP协议(RFC1122)。其实TIME_WAIT就是你主动断开连接,所以这就是所谓的“不死不死”。试想一下,如果另一端断开了,那么这个断线的问题就属于另一端了,呵呵。另外,如果你的服务器是基于HTTP服务器的,设置一个HTTPKeepAlive(浏览器会复用一个TCP连接来处理多个HTTP请求),然后让客户端断开连接(你必须小心,浏览器可能非常贪婪,除非万不得已,否则它们不会主动断开连接)。SequenceNumberindatatransmission下图是我在访问coolshell.cn时从Wireshark中用datatransmission截下来的图,给大家看SeqNum是怎么变化的。(使用Wireshark菜单中的Statistics->FlowGraph...)您可以看到SeqNum随着传输的字节数的增加而增加。上图中,三次握手后,来了两个Len:1440的数据包,第二个数据包的SeqNum变成了1441。然后前面的ACK返回了1441,说明收到了一个1440。注意:如果你用Wireshark抓包程序看3次握手,你会发现SeqNum一直是0,其实不是这样的。为了显示更友好,Wireshark使用了RelativeSeqNum——相对序号。只需要在右键菜单中点击协议取消优先,就可以看到“AbsoluteSeqNum”。TCP重传机制TCP必须保证所有的数据包都能到达,所以需要重传机制。注意,接收端到发送端的Ack确认只会确认最后一个连续的包。比如发送端一共发送了1、2、3、4、5五个数据,接收端收到了1、2,所以返回ack3,然后又收到了4(注意没有收到3此时),此时TCP会做什么呢?我们要知道,因为前面说了SeqNum和Ack都是以字节为单位的,所以在acking的时候,不能跳转确认,只能确认连续收到的比较大的包,否则发送方会认为前面的都是已收到。超时重传机制之一是等待3不回复ack。当发送方发现没有收到3的ack超时,就会重传3。一旦接收方收到3,就会ack回4——也就是说3和4都收到了。但是这个方法有一个比较严重的问题,就是因为要等3,所以会导致4和5都收到了,而发送端不知道发生了什么,因为没有收到Ack,所以,发送端可能会悲观的认为自己也丢了,所以也可能会造成4和5的重传。这个有两种选择:一种是只重传超时包。那是第三个数据。另一种是重传超时后的所有数据,即第3、4、5个数据。两种方法各有利弊。一会节省带宽,但速度慢,二会快一些,但会浪费带宽,而且还可能有无用功。但总体来说不是很好。因为都是在等待超时,所以超时时间可能会很长(我会在下一篇文章中讲到TCP是如何动态计算超时时间的)。快速重传机制因此,TCP引入了一种称为FastRetransmit的算法,这种算法不是时间驱动的,而是数据驱动的重传。也就是说,如果数据包没有连续到达,如果发送方连续3次收到相同的ack,可能会在ack结束时丢失的数据包将被重传。FastRetransmit的好处是不用等超时再重传。例如:如果发送方发送了1,2,3,4,5份数据,先发送了一份,所以ack返回2,但是由于某种原因没有收到2,3到了,所以ack返回了2,下面的4而5已经到了,但是还是ack返回了2,因为2还是没有收到,所以发送方收到了三个ack=2的确认,知道2还没有到,所以马上重传2。然后,接收端收到了2,因为此时3、4、5都收到了,所以ack返回了6。原理图如下:FastRetransmit只解决了一个问题,就是超时问题。它仍然面临一个艰难的选择,就是重传之前的还是重传所有的问题。对于上面的例子,它应该重传#2还是重传#2、#3、#4、#5?因为发送方不知道连续3个ack(2)是谁发回来的?也许发送方发送了20条数据,分别来自#6、#10和#20。这样,发送方很可能会重传2到20的那堆数据(这是一些TCP的实际实现)。可见这是一把双刃剑。SACK方法的另一种更好的方法称为:选择性确认(SACK)(请参阅RFC2018)。该方法需要在TCP头中加入一个SACK。ACK是FastRetransmit的ACK,SACK是上报接收到的数据。破版。见下图:这样发送方就可以根据返回的SACK知道哪些数据已经到达,哪些还没有到达。所以对FastRetransmit的算法进行了优化。当然,这个协议需要双方的支持。Linux下可以通过tcp_sack参数开启该功能(Linux2.4后默认开启)。这里还有一个问题需要注意——receiverReneging,所谓Reneging是指接收方有权丢失SACK中已经上报给发送方的数据。不鼓励这样做,因为这会使问题复杂化,但可能会有一些极端情况让接收者这样做,例如将内存分配给其他更重要的事情。所以发送方不能完全依赖SACK,还是依赖ACK,维持Time-Out。如果后面的ACK没有增加,那么这个SACK就必须重传。另外,接收方永远不能发送标记为Ack的SACK包。注意:SACK会消耗发送方的资源。试想一下,如果黑客向数据发送方发送了一堆SACK选项,这将导致发送方开始重传甚至遍历发送的数据,这将消耗发送方大量的资源。详情请参考《TCP SACK的性能权衡》。DuplicateSACK——DuplicateSACK,也称为D-SACK,主要是通过SACK告诉发送方哪些数据被重复接收了。详细说明和示例在RFC-2883中。下面是几个例子(源自RFC-2883)D-SACK使用SACK的第一段作为标志。如果SACK的第一段范围被ACK覆盖,那么就是D-SACK。如果第一段SACK的范围被第二段SACK覆盖,那么就是D-SACK例子1:ACK丢包下面的例子中有两个ACK丢失,所以发送方重传第一个数据包(3000-3499),所以receiver端发现重复收到了,于是返回一个SACK=3000-3500,因为ACK到了4000,也就是说4000之前的数据都收到了,所以这个SACK是D-SACK——旨在告诉发送方我收到了一个重复的Data,而我们的发送方也知道数据包没有丢,只是ACK包丢了。TransmittedReceivedACKSentSegmentSegment(包括SACK块)3000-34993000-34993500(ACKdropped)3500-39993500-39994000(ACKdropped)3000-34993000-34994000,SACK35000---------例子2,网络延迟下面的例子中,网络包(1000-1499)被网络延迟,导致发送端没有收到ACK,后面到达的三个包触发了“FastRetransmit算法”,所以重传,但是重传的时候,延迟的包又到了,所以返回一个SACK=1000-1500,因为ACK已经到了3000,所以这个SACK是D-SACK——表示收到了一个重复的包。在这种情况下,发送方知道“快速重传算法”触发的重传不是因为发送包丢失或响应ACK包丢失,而是因为网络延迟。TransmittedReceivedACKSentSegmentSegment(包括SACK块)500-999500-99910001000-1499(delayed)1500-19991500-19991000,SACK=1500-20002000-24952000-2409SACK20-209250-29991000,SACK=1500-30001000-14991000-149930001000-14993000,SACK=1000-1500----------可以看出D-SACK的引入,有这样多一个好处:1)可以让发送方知道是传出的包丢失了,还是返回的ACK包丢失了。2)是不是因为你自己的超时时间太小,导致重传。3)第一个数据包在网络上到达较晚(也称为重新排序)4)我的数据包是否在网络上被复制?知道这些可以帮助TCP很好的了解网络情况,从而更好的对网络做流量控制。Linux下的tcp_dsack参数用于开启该功能(Linux2.4后默认开启)。