[计算机网络 1] 我所知道的关于 TCP 的一切

用户头像
海神名
关注
发布于: 2020 年 05 月 04 日

在这里记录一下我目前(2020年04月25日16:02:00)所知道的关于TCP协议的一切东西。



内容大部分来自《计算机网路:自上而下方法》这本书。



众所周知,TCP是传输层协议,运行在IP协议之上,是一个可靠传输协议,因此首先必须清楚,可靠在哪里,先来看看可靠数据传输包含哪些方面。



可靠数据传输



数据内容可靠——校验和



首先要保证的是传输的数据是可靠的,即客户端发送的数据“hello”到了服务端接收到的也必须是“hello”而不能是“good beye”或者其他什么。这就需要一个机制来让服务端验证收到的数据是否正确。这就是校验和。



简单来说,就是客户端在发送数据之前,对数据内容做一个运算,获得一个校验码,并将校验码附带在数据报文首部,服务端拿到这和校验码,和收到的数据在通过一个运算,检验是否正确。



数据按时送达——定时器与确认



我们还需要数据在可观测的时间内送达,很容易想到需要一个超时机制,相对于的就需要一个确认机制。



获悉网络状况——窗口与流水线



最后还需要知道当前网络环境的状况,以控制传输的速率,来确保绝大部分的数据分组都能够不超时的正确的送达。



TCP和UDP



要讲TCP,不可避免就要谈到UDP。



相对于TCP,UDP则要简单得多得多,只提供了传输层协议需要做到的最少的事情,当然UDP也是有校验和的,除此之外一无所有。



  • TCP有连接,UDP没有

  • TCP是有状态的,UDP没有

  • TCP是保证可靠的,UDP没有

  • TCP有个拥塞机制,UDP没有



但是UDP肯定也不是一无是处的,由于TCP提供了一堆功能来保证传输的可靠性,可想而知其传输速率就会受到不小的影响,因此,在对消息可靠性没什么要求,对传输速度有要求的场景下,使用UDP会是更好的选择。



另外,由Google发布的QUIC协议,被用于HTTP3中,就是一个基于UDP的可靠传输协议。



TCP连接



如何构成一个连接



显然,计算机世界中的连接不同于物理世界,并不是具体的网线插口接插座,而是一个逻辑的概念。通常认为,当通讯双方互相缓存了对方的通讯信息,一般是IP地址和端口,则可以认为它们建立了一个连接。



理论上讲,双方需要建立一个连接,需要双方个交换一次信息,即两次通讯。然而TCP协议作为可靠传输协议,建立连接时必须保证双发都具有正常收发消息的能力,因此有着自己的连接方式,这就是著名的三次握手。



{% plantuml %}



autonumber

client -> service: SYN seq=client_isn

note left: 客户端请求建立连接



service -> client: SYN ACK seq=serviceisn ack=clientisn+1

note right: 服务端返回同意建立连接,此时服务端确认自己有接收消息的能力



client -> service: SYN seq=clientsin+1 ack=serviceisn+1

note left: 客户端收到服务端ACK,确认自己有发送消息和接收消息的能力

note right: 服务端再次收到客户端的SYN,确认自己有发送消息的能力



{% endplantuml %}



上图中的clientisn和serviceisn,就是TCP报文首部中的消息序号,下面会讲到。



同样的,TCP为了保证在断开连接时,最后的数据依然是正常收发的,因此需要四次挥手。



{% plantuml %}



autonumber

client -> service: FIN

note left: 客户端发起断开连接请求,注意不一定是客户端,服务端也可以主动发起断开



service -> client: ACK

note right: 服务端回复可以断开,此时服务端进入CLOSE_WAIT状态,等待处理最后的数据



service -> client: FIN

note right: 服务端处理完成数据后,告诉客户端可以断开连接



client -> service: ACK

note left: 客户端回复,并进入到TIME_WAIT状态,等待2MSL后断开



{% endplantuml %}



最后再来看看客户端和服务端的连接状态



{% plantuml %}



title 客户端状态转换



CLOSED --> SYN_SENT: 发送SYN,请求建立连接

SYN_SENT --> ESTABLISHED: 收到服务端ACK,成功建立连接



ESTABLISHED -up-> FINWAIT1: 发送FIN,请求断开连接

FINWAIT1 -up-> FINWAIT2: 收到ACK,等待对方FIN

FINWAIT2 -up-> TIME_WAIT: 收到对方FIN,等待最后的消息

TIME_WAIT -up-> CLOSED: 断开连接



CLOSED: 连接关闭

SYN_SENT: 已发送SYN

ESTABLISHED: 客户端已建立连接

FINWAIT1: 已发送FIN,等待对方ACK

FINWAIT2: 已收到ACK,等待对方FIN

TIME_WAIT: 已收到FIN,等待2MSL后断开



{% endplantuml %}



{% plantuml %}



title 服务端状态转换



CLOSED -up-> LISTEN: 服务端进入监听状态

LISTEN -up-> SYN_RCVD: 回复SYN的ACK

SYN_RCVD -up-> ESTABLISHED: 收到客户端的回复,连接建立



ESTABLISHED --> CLOSE_WAIT: 收到FIN,等待处理最后的数据

CLOSEWAIT --> LASTACK: 处理完数据发送FIN后,等待回应

LAST_ACK --> CLOSED: 收到回应,断开连接



CLOSED: 连接关闭

LISTEN: 服务端进入监听状态

SYN_RCVD: 服务端已回复SYN

ESTABLISHED: 客户端已建立连接



CLOSE_WAIT: 服务端等待完成消息的处理

LAST_ACK: 服务端等待最后的ACK



{% endplantuml %}



TCP报文



数据肯定不可能是在网络中裸传的,传输层会将数据分组打包后传输。



TCP协议的包结构如下



![tcp报文段结构](http://images.haishenming.xyz/blog/20200426104610.png)



端口号



源端口和目标端口。因为TCP是传输层协议,所以只需要负责到端口号就可以了,IP地址是网络层负责的。



序号与确认序号



TCP使用序号要确保消息顺序,使用确认序号来确保消息正确送达。



序号是建立在字节流上的,而不是具体的包1,包2这样的数据报,例如某一个分组是0 ~ 500字节,那么这其中的每一个字节都会有一个序号,从0 ~ 500,而确认序号则是接收端希望收到的下一个字节的编号。



例如发送端已经发送了0 ~ 500,那么接收端返回的确认序号就是501,以此类推。



另外一个例子,如果接收端收到了0 ~ 499,下一个收到的是1000~1500,很显然500 ~ 999这一段丢了,那么此时接收端只需要在确认序号中填入500即可。



标志字段



表示这个包是要干什么,比如我们常见了SYN请求连接,ACK确认,FIN断开连接,RST异常等。



接收窗口



用于向发送端表名自己接收消息的能力,以达到流量控制的作用。



首部长度



字面意思,就是包首部有多长,这个值通常是20字节,但是是可变的。



校验和



用于校验收到的数据和发送的数据是否是一样的。



数据



存放应用层数据的地方,不同的网路环境中这个值通常不一样,在以太网中,通常是1500-40=1460字节。



TCP可靠传输与超时估算



前面有提到可靠传输协议需要包含的内容,TCP也大致如此。



TCP协议通过重传确认,超时检验,流量与阻塞控制等保证传输的可靠。



超时时间计算与RTT



先说超时。



这就涉及到一个很基础但是很重要的问题,怎么确定一次传输的超时时间?如果设置过长,丢失的包迟迟无法获得重传,必然影响整个整体数据的传输速率;如果设置过短,则会发生很多不必要的重传,占用网络资源。



TCP通过RTT值来评估超时时间。



RTT是指一个报文从发出到收到确认的时间。TCP通过收集已确认的报文的RTT时间,通过计算能够获得大致的网络拥塞状况,以估算出一个合理的超时间。是这样算的:



我们设一个RTT样本为SampleRTT,TCP会记录每一个收到确认的包的RTT作为SampleRTT,但是由于网络状况的复杂性,可能出现短时间的剧烈抖动,因此需要通过以下加权的手段估计出一个典型的RTT,我们称之为EstimetedRTT,公式如下:



$EstimetedRTT=(1-\alpha) EstimetedRTT + \alpha SampleRTT$



在TCP标准中,推荐的$\alpha$值是0.125,因此这个公式通常以这种形式出现:



$EstimetedRTT=0.875 EstimetedRTT + 0.125 SampleRTT$



EstimetedRTT这个公式的作用是,加大新样本在计算中的权重。



另外,RTT的变化值也会被参考进来,我们称之为DevRTT。



$DevRTT = (1-\beta) DevRTT | EstimetedRTT - SampleRTT|$



$\beta$的值通常是0.25。



最后,记住我们的目的是计算超时时间,我们称为TimeoutInterval:



$TimeoutInterval = EstimetedRTT + 4*DevRTT$



超时加倍



字面意思,当一个包遇到超时的时候,在重发的基础上,它的超时时间将加倍。这实际上并不影响整体的超时时间的估算,上面提到过,对超时时间的估算值考虑一个包从发出到收到确认的时间,其中不包括超时的包。



快速重传



加倍超时机制认为发生超时时,代表当前网络环境不好,于是将超时时间加倍以减少重试次数,减少网络环境中的传输数量。但是这种机制效率太低,如果发生一个报文的频繁超时,将会影响整个数据的正常传输,因此加入了快速重传机制。



快速重传基于这样一种情况:



发送端在发送了报文1之后,接着发送报文2,报文3,理论上讲,此时他应该收到全部报文1,报文2,报文3的确认消息,然而并没有,当发送端收到报文1的确认消息后,紧接着有收到了报文1的确认消息,并没有报文2,报文3的确认,那么显然,报文2或者报文3有可能丢了,但是此时并没有超时,于是发送端继续发送报文4之后,有收到报文1的确认消息,此时还是没有超时,于是发送端又发送了报文5,然而有收到了报文1的确认消息... 如果发送端是人类的话,此时应该疯掉了。



我们来总结一下这个时候发生了什么。发送端发送了报文1,报文2,报文3,报文4,报文5。接收端返回了四次报文1的确认消息,即三个冗余的确认消息。



这个时候,发送端便可判断出报文1的下一个包,即报文2丢了,接着重传了报文2。然后发送端收到了报文5的确认消息,这就说明不仅重传的报文2收到了,报文5及其之前的所有报文都收到了。



这就是快速重传。



简而言之就是,一旦受到三个冗余的ACK,就执行重传,而不管是否超时。



GBN和SR



GBN和SR都是遇到丢包重传或报文顺序出错等情况,所采用的差错恢复机制。



GBN指退回N步,即退回到最近的正确的报文那一步,往后的报文重新接收。



SR只选择重传,即只重传出错的报文。



TCP大概是这两者的结合。TCP协议会维持未被确认的最后报文的字节序号和下一个要发送的报文的字节序号,同时缓存已经正确接收的报文,在套接字一层按顺序将缓存的报文上报。



TCP流量控制与拥塞控制



TCP需要知道接收方接收消息的能力,和当前网络环境状况,来控制报文发送的速率和同时发送量。



流量控制



流量控制和拥塞控制,看起来感觉差不多,实际上还是有差别的,先说流量控制。



还记得TCP报文段中的接收窗口,这是一个16位的数值,用来表示接收端当前能够接收的数据量。这个数据量实际上等于接收端还剩余的缓存的大小,即:



$LastByteRcvd - LastByteRead \leq RcvBuffer$

$rwnd = RcvBuffer - (LastByteRcvd - LastByteRead)$



rwnd 表示窗口大小,RcvBuffer表示接收方总共的缓存大小。



运作机制也很简单,发送端只需要确保自己已发送但是没确认的包的大小小于rwnd即可。



但是这里就出会出现一个问题,当rwnd=0,即接收方缓存被完全占用之后怎么办?按理来说,此时发送方将会受到rwnd=0,那么发送方将永远不会在发送数据了。于是TCP规定,当rwnd=0的时候,发送方可以继续发送只有一个字节的数据,以确保能够继续获取到最新的rwnd值。



拥塞的原因和代价



拥塞控制则是发送方主动获取当前网络拥塞状况,以此为依据控制自己的发送速率。



很容易想到,当网络环境中由于各种原因导致环境十分拥塞,此时如果发送端只以rwnd为依据,继续正常发送数据,势必倒是出现大量报文无法顺利到达,出现大量超时重发,又进一步挤占网络资源,致使拥堵更加严重,因此需要在对拥塞的网络做流量控制。



按照《计算机网络:自上而下方法》中所描述的,拥塞的代价包括



  • 当分组的到达速率接近链路容量的时候,分组会经历巨大的排队延时。

  • 分组必须重传以补偿因为链路缓存溢出而导致的丢包。

  • 由于重传而导致大量网络流量被浪费,而这在本来就拥堵的网络中是巨大的损失。



拥塞控制



通常拥塞控制有两种方法,一是通过下层网络(网络层,链路层)告知传输层当前网络拥堵状况,二十传输层自己通过某种方式估算网络拥堵状况。TCP采用第二种。所用到的机制如下:



我们设拥塞控制的值为拥塞窗口cwnd,这个值有发送端自己维护,其单位是MSS,即TCP当前环境中一个的大小,维护的逻辑也很简单,即发出去的报文收到确认,就认为当前网络良好,可以增大cwnd,如果发送超时重传,则认为当前网络有点拥堵,就减小cwnd值,具体如下:



慢启动



连接刚刚创建的时候,TCP完全不知道当前网络状况,就像我们不知道杯子里的水有多烫手一样,我们可以一点一点的试,这就是慢启动的由来。



TCP刚刚连接的时候,cwnd的值为1,之后每收到一个确认,cwnd值加倍,即1,2,4,8......, 知道发送重传或者触及sshresh(慢启动阈值)。



当发生超时重传时: sshresh = cwnd/2, cwnd = 1, 即先将sshresh设为发送超时时cwnd的值得一半,再重新开始慢启动。



当触及sshresh是,cwnd值不在翻倍,而是每次加一,进入到拥塞避免状态。



最后还有一种情况,还记得前面提到的快速重传吗?当发生快速重传时,TCP认为此时虽然发生了丢包,但是还是能够正确收到三个冗余的确认,因此当前网络状况并不是很糟糕,所以不必重新开始慢启动,而是进入到快速恢复状态。



拥塞避免和快速恢复下面讲到。



拥塞避免



拥塞避免很简单,就是因为慢启动的指数增长太过暴力,很容易出现拥塞丢包的情况,因此就不再指数增长,而是每次加一。当发送慢启动中描述的超时重传等事件之后,依然采取和慢启动一样的操作。



快速恢复 TCP Remo



快速恢复并非TCP的必要组件,当然现代TCP的实现中大多都包含这个功能。



此时: sshresh = cwnd = cwnd / 2 + 3, 然后进入到拥塞避免状态。



最后



我目前所知道的关于TCP的就这么多了,以后再补充吧。

发布于: 2020 年 05 月 04 日 阅读数: 50
用户头像

海神名

关注

Keep Learning Yes 2017.11.30 加入

golang后端

评论

发布
暂无评论
[计算机网络1]我所知道的关于TCP的一切