frankfan的胡思乱想

学海无涯,回头是岸

TCP及其细节

三次握手 四次挥手 ack 重传 拥塞控制 滑动窗口 接受/发送窗口

这里我们讨论的主题是TCP协议及其细节。

『TCP是面向链接的字节流协议』关于TCP的定性我们已经烂熟于胸了。

对TCP最重要也是最直观的认识是『可靠传输』。意思是对于用户来说只有2种情况:

  • 告诉用户这条链接断了,传输失败(read函数返回0或者负值)
  • 告诉用户数据收到,并且是有序无损完整的收到

其实,TCP除了对『用户负责』外,也需要对『网络负责』。也就是说网络上传输的这些流量可以是无限的,但是网络资源是有限的(网络带宽总是某个有限值),因此某条TCP链接不能无限制贪婪的占用或者抢占网络带宽资源,这就要求TCP需要兼顾『公平性』。当然,数据的传输除了不能出现差错外,也需要关注其效率,不能为了满足可靠性传输而牺牲效率,因此TCP还需要兼顾『高效性』。

而『可靠性』『实时性』『公平性』三者是无法同时全部最优的,只能在这三者之间做均衡。TCP的各种细节都是对这种均衡的实现,当然,我们也可以使用UDP来模拟或者实现TCP所能达到的效果(或者诉求),但是总要在这三者之间作出取舍。我们能实现在某一项或者两项中比TCP更优秀,但是却很难做到全部都比TCP更优秀。同时我们还需要知道,TCP是一个非特定场景的协议,言外之意TCP面对的场景是对所有应用层协议的抽象,与业务逻辑无关。

基于以上讨论,我们来看看TCP是怎么设计自己的包头与相关算法,来对这三者做平衡实践的。

TCP的包头设计

TCP的包头被设计成了固定的20个字节,我们着重关注其中的『部分字段』。

  • 源端口和目的端口(2字节/共4字节)
  • 序号(4字节)
  • 确认号(4字节)
  • 窗口大小(2字节)
  • Flags (确认ACK、PSH(Push)、RST (ReSet)、同步 SYN、终止 FIN)(6Bit)
  • 检验和(2字节)
  • 选择确认(4字节)

以上字段中,有我们熟悉的源和目的端口个号,这是是用来处于与应用程序的关系。

TCP的可靠性保证逻辑非常简单,就是给每一个包都分配一个序号,发送后在接收端协议栈缓存中『排序』后再交由给上次进程即可。

确认号的就是接收端用来告诉发送端,收到了哪些数据、需要哪个序号的包。也就是说这个确认号是双重语义的。既可以表示收到了哪些包,又接下来希望收到的包是哪个。

ack 201

这个表示接收端已经收到了1-200号的包,接下来希望收到201号包。而至于此时202号包有没有收到,不一定,有可能后面的包先到达了。但201号包此时肯定是没有收到的。

窗口大小是表示对端TCP协议栈的滑动窗口大小有多大(表示了对端接受窗口的大小)。这个窗口的大小既限制了此时接收端可以接受多少数据,又限制了发送端能发送多少数据,这种方式就等于变相的控制了网络上的数据包数量,做到了流量控制。

image.png

p3-p1就是滑动窗口的大小,p1左侧的字节表示都已经接受,p1右侧的字节状态(是否已接受)不确定.

p2左侧的字节表示都已接受(ack),右侧的(以p3为边界)表示没接受,但可以接受。

而p3右侧的则表示不准备接受。(如果对端发送过来的数据包序号位于p3的右侧,直接被丢弃)

只有当p3-p1范围内的所有字节都被接受了,p1才会往右侧移动。但凡中间出现一个空洞序号没被接受,p1都不会移动。

p1可以往右侧移动,但TCP协议通常不建议p3往左移动。

p1往右移动而p3保持不动时,则意味着接受窗口在变小,当这个值通过ack包传递到发送端时,发送端也会相应的改变其发送窗口的大小。当p1与p3重合时,接受窗口大小等于0,发送端通过ack带来的接受窗口大小等于0这一信息时会将自己的发送端窗口调整为0,此时发送端不再发送数据。这时会进入一种『死锁』状态,因此当发送端的发送窗口为0时,发送端会启动计时器,到达延迟时间后会发送一个『探测接受窗口大小』的 包,询问此时接收端的窗口大小,接收端回复ack并且更新此时接受窗口的大小,这样,发送窗口大小不再为0,就能继续发送了。

image.png

发送缓存的主要作用是:

  • 进程传递给协议栈的数据存放的地方(准备发送)
  • 协议栈已经发送却还没有收到确认的数据存放的地方
image.png

接受缓存的主要作用是:

  • 已按序到达但进程尚未读取的数据
  • 提前到达的数据(未按序)

Flags字段抓包可以发现有10bit,我们通常认为是6bit,每个bit位表示一种状态,允许多个比特位状态叠加。

这个字段表示这个包的『性质』如syn表示这是一个主动握手包,ack则表示这个包具有确认性质(并不是说这个包只当做确认包用,可以是一个数据包,不过附带做确认包使用而已),而push则表示协议栈缓存不要保留这个包到缓存中不往上传递,收到这样的包表示协议栈应该马上将数据包传递给应用进程。fin则表示这是一个主动断开连接的包。

校验和则用来检查这个包的完整性(有没有在网络上被别的网络设备无意改变)。

选择确认的作用则是为了避免发送端的反复发送已收字节。

image.png

接收端期望收到第n号包,而实际上有两个提前包n+1和n+3已经收到,为了让发送端不再重复发送已经收到的n+1、n+3号包,接收端需要告诉发送端,但是不能直接发送ack n+1或者ack n+3,上文说过,如果发送ack x则表示x-1号包以及之前的所有包都已经收到,当ack n+1则表示1-n号包都已经收到,这显然是不对的。此时选择确认字段就派上用场了。顾名思义,就是带有选择的确认,选择n+1n+3号包,那么发送端就不会再发送这两个包了,这样就减少了网络带宽的浪费。

至此,我们大概捋清楚了TCP包头中的一些必备字段,当我们需要使用UDP协议模拟TCP的种种机制时,这些字段都是必不可少的,可是,如果我们只是为了让UDP协议能够有序完整的到达,那么我们需要实现的字段可以只有:

  • 序号
  • 确认号
  • Flags
  • 校验和

也就是说我们只处理包的完整性有序性这两个问题。

要做好完整性与有序性这两件事,则必须处理好丢包重传这件事。

TCP每发送一个报文段就起一个定时器,在T时间内,若还没有收到对这个报文的确认,则认为报文段丢失(可能报文段还在网络中传输,但发送端判断其已经丢失),于是重新传输这个报文段(因此发送出去的报文段在没有被ack之前是不能丢弃的)。这就叫超时重传,通常,这个超时时间T被称为RTO(Retransmission Timeout/重传超时)。

这个RTO的具体值是怎么来的呢?这是一个动态变动的值,抽象了网络的传输质量。这个值的设定是个非常考究的事,甚至是影响TCP性能的核心原因之一,当RTO设置得过小,那么极易造成丢包误判,也就是实际没丢包,判断为丢包了,然后重传,这样加重了网络中不必要的重复包,设置得过大,灵敏度降低,对丢包不能及时响应,造成已经丢包了但迟迟得不到重传。怎么科学合理的设置RTO的值TCP有一套他的方式与算法,这里我们不做深究(需要采样RTT)。

那么在模拟TCP时,简单起见,我们可以人为的设置一个RTO。(毕竟,在这个模拟中我们并不太在意模拟性能)

TCP协议为了使得大家都能好好的利用网络资源(公平性)在数据的发送方式上采取了限制措施。

也就是说,TCP关于发包速率有一条自己的规则。(若发得太快,则当网络拥堵时会加剧拥堵,若发得太慢,则不能好好的利用网络带宽,怎么平衡二者呢?)

送发端维护了一个字段『拥塞窗口』,通常,发送端将发送窗口的值等于这个拥塞窗口的值。当然,发送窗口的值也可能比拥塞窗口的值要小,因为还取决于接受端的接受窗口。那么,这个拥塞窗口具体是干什么用的呢?

拥塞窗口的本质是『我怀疑网络带宽不够用此时很拥堵,因此一开始我不会开足马力的去发包,而是试探性的发包,逐步放开』。

image.png

以上就是拥塞控制的具体实现。

TCP一开始发包的时候,拥塞窗口值非常小,每次能够发的包都很少,当随着时间的流逝,发送端收到接收端发来的ack,这时候拥塞窗口值开始改变,变大,可以看到变化不是线性的,也就是呈指数级增长,这里被称为慢开始。虽然称为『慢』开始,但是其实增长速率并不慢(相反还很快),只是初始大小比较小而已。每次收到一个ack这个拥塞窗口就指数增大,增大到某一个值时停止指数增长,这个值被称为『拥塞门限值』,越过这个值后拥塞窗口继续增大,不过是线下的增大了,每收到一个ack包就增加1,这个阶段 被称为『拥塞避免』这时随着拥塞窗口的增大,发送的数据越来越多,开始出现丢包,这时ack不能及时收到,此时的拥塞窗口开始掉落。

  • 拥塞门限值开始调整,调整为出现拥塞时的拥塞窗口大小的一半
  • 发送方再次从慢启动开始发送数据

可见,网络一旦出现丢包,影响是多方面的,不仅因为丢包而使得接收端很难再接收到数据,而且发送端本身也会控制数据的发送,所以丢包是『网速慢』的第一元凶。虽然TCP本身不能左右网络带宽本身的情况,但是却能利用自身的拥塞控制,减少网络带宽的进一步拥堵,从而避免雪上加霜。

可见,TCP真正做的事情远不止表面的数据完整性和有序性,可靠性的保证也是非常重要的一个环节。

而当我们使用UDP模拟TCP的相关特性时,可以考虑拥塞控制的模拟,当然,如果只是想使用UDP来模拟基本的文件传输,那么似乎简单的模拟包的有序与完整性就可以了。

以上就是我认为值得讨论的一些TCP的细节点。

最后,聊聊熟悉又陌生的2点:

  • 3次握手
  • 4次挥手

无论是初学TCP还是使用UDP模拟TCP,这两个话题都老生常谈。这里稍微再来聊聊牵手分手这码事。

image.png

客户端发起一个握手包syn,这是在做序列号的同步,除此外这个包还携带了本端的接受窗口大小。若syn发送迟迟得不到服务器的ack包,客户端协议栈会再次重试发送syn包,会反复重试几次(可以控制,通常默认是6次),如果最后都没有收到ack包,则握手失败。

3次握手最后一个包围客户端的ack,这个包可以携带负载数据。

image.png

4次挥手的4个包很简单,但需要注意挥手时两个端的状态是不一样的。

客户端主动发送Fin分手后,服务端会马上发送一个ack来确认,从客户端发出fin到收到ack这个时间被称为wait_1.非常短的一个值,大概只有几毫秒(取决网速),TCP协议栈在处理分手时这个ack是非常决断的不会拖泥带水。

此时客户端处于wait_1状态。这个状态的客户端是不能写的,只能读。

这是服务器处于close_wait状态,在这个状态中协议栈会通知应用进程(应用进程读数据时会返回0)

此时服务器会发送一个Fin包给客户端,马上,客户端就回个ack。至此似乎4次挥手已经完成,全双工通信已经完成。但是,客户端此时还没有完成工作,当它发送完分手的最后一个ack包后,就处于『time_wait』状态,这个状态下socket链接不会被释放,端口依然会被占用,必须要等到2倍MSL时长才会彻底释放链接,所谓MSL就是一个最大TCP分段在网络中的存活时间,2倍就等于是一个来回的时间,这个时间非常长,大概有2分钟(系统不同值可能不同)。

这么做的目的是什么呢?

因为客户端发出的最后一个ack包可能会丢包,丢包后要留足够时间给服务端再次发送fin包。

假如没有time_wait阶段,当客户端的最后一个ack丢包后,服务端发来一个fin包,此时客户端只能简单粗暴的rst,造成服务端关闭逻辑出错。

其二是,当客户端立马关闭链接释放socket后,此时存在一种巧合,就是当再次创建一条链接时恰好四元组(源IP、目的IP,源端口、目的端口)与之前的完全相同,刚好创建完就受到服务端重发来的Fin包...这刚创建好就立马被迫分手...

所以这就是time_wait存在的价值。

当然,并非只有价值,没做缺陷。

最大的缺陷就是占用了端口资源,使得端口不能快速的得到重复使用。


至此,TCP的一些细节以及讨论完毕。

显然,『使用UDP模拟TCP』与『使用UDP完成文件的可靠传输』是2个不同的命题。第二个命题我们可以通过实现第一个来实现,也可以直接针对性的实现。

那么我们在模拟TCP中始终关注的核心是什么呢?

  • 包的有序接受
  • 需要一定的效率

我们甚至可以抽象出一种机制,一种只关注数据包或者字节流的收发机制,而与具体的网络协议无关。

posted on 2021-12-28 09:35  shadow_fan  阅读(81)  评论(0编辑  收藏  举报

导航