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协议栈的滑动窗口大小有多大(表示了对端接受窗口的大小)。这个窗口的大小既限制了此时接收端可以接受多少数据,又限制了发送端能发送多少数据,这种方式就等于变相的控制了网络上的数据包数量,做到了流量控制。
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,就能继续发送了。
发送缓存的主要作用是:
- 进程传递给协议栈的数据存放的地方(准备发送)
- 协议栈已经发送却还没有收到确认的数据存放的地方
接受缓存的主要作用是:
- 已按序到达但进程尚未读取的数据
- 提前到达的数据(未按序)
Flags字段抓包可以发现有10bit,我们通常认为是6bit,每个bit位表示一种状态,允许多个比特位状态叠加。
这个字段表示这个包的『性质』如syn
表示这是一个主动握手包,ack
则表示这个包具有确认性质(并不是说这个包只当做确认包用,可以是一个数据包,不过附带做确认包使用而已),而push
则表示协议栈缓存不要保留这个包到缓存中不往上传递,收到这样的包表示协议栈应该马上将数据包传递给应用进程。fin
则表示这是一个主动断开连接的包。
校验和则用来检查这个包的完整性(有没有在网络上被别的网络设备无意改变)。
而选择确认的作用则是为了避免发送端的反复发送已收字节。
接收端期望收到第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+1
和n+3
号包,那么发送端就不会再发送这两个包了,这样就减少了网络带宽的浪费。
至此,我们大概捋清楚了TCP包头中的一些必备字段,当我们需要使用UDP协议模拟TCP的种种机制时,这些字段都是必不可少的,可是,如果我们只是为了让UDP协议能够有序完整的到达,那么我们需要实现的字段可以只有:
- 序号
- 确认号
- Flags
- 校验和
也就是说我们只处理包的完整性、有序性这两个问题。
要做好完整性与有序性这两件事,则必须处理好丢包重传这件事。
TCP每发送一个报文段就起一个定时器,在T时间内,若还没有收到对这个报文的确认,则认为报文段丢失(可能报文段还在网络中传输,但发送端判断其已经丢失),于是重新传输这个报文段(因此发送出去的报文段在没有被ack之前是不能丢弃的)。这就叫超时重传,通常,这个超时时间T被称为RTO(Retransmission Timeout/重传超时)。
这个RTO的具体值是怎么来的呢?这是一个动态变动的值,抽象了网络的传输质量。这个值的设定是个非常考究的事,甚至是影响TCP性能的核心原因之一,当RTO设置得过小,那么极易造成丢包误判,也就是实际没丢包,判断为丢包了,然后重传,这样加重了网络中不必要的重复包,设置得过大,灵敏度降低,对丢包不能及时响应,造成已经丢包了但迟迟得不到重传。怎么科学合理的设置RTO的值TCP有一套他的方式与算法,这里我们不做深究(需要采样RTT)。
那么在模拟TCP时,简单起见,我们可以人为的设置一个RTO。(毕竟,在这个模拟中我们并不太在意模拟性能)
TCP协议为了使得大家都能好好的利用网络资源(公平性)在数据的发送方式上采取了限制措施。
也就是说,TCP关于发包速率有一条自己的规则。(若发得太快,则当网络拥堵时会加剧拥堵,若发得太慢,则不能好好的利用网络带宽,怎么平衡二者呢?)
送发端维护了一个字段『拥塞窗口』,通常,发送端将发送窗口的值等于这个拥塞窗口的值。当然,发送窗口的值也可能比拥塞窗口的值要小,因为还取决于接受端的接受窗口。那么,这个拥塞窗口具体是干什么用的呢?
拥塞窗口的本质是『我怀疑网络带宽不够用此时很拥堵,因此一开始我不会开足马力的去发包,而是试探性的发包,逐步放开』。
以上就是拥塞控制的具体实现。
TCP一开始发包的时候,拥塞窗口值非常小,每次能够发的包都很少,当随着时间的流逝,发送端收到接收端发来的ack,这时候拥塞窗口值开始改变,变大,可以看到变化不是线性的,也就是呈指数级增长,这里被称为慢开始。虽然称为『慢』开始,但是其实增长速率并不慢(相反还很快),只是初始大小比较小而已。每次收到一个ack这个拥塞窗口就指数增大,增大到某一个值时停止指数增长,这个值被称为『拥塞门限值』,越过这个值后拥塞窗口继续增大,不过是线下的增大了,每收到一个ack包就增加1,这个阶段 被称为『拥塞避免』这时随着拥塞窗口的增大,发送的数据越来越多,开始出现丢包,这时ack不能及时收到,此时的拥塞窗口开始掉落。
- 拥塞门限值开始调整,调整为出现拥塞时的拥塞窗口大小的一半
- 发送方再次从慢启动开始发送数据
可见,网络一旦出现丢包,影响是多方面的,不仅因为丢包而使得接收端很难再接收到数据,而且发送端本身也会控制数据的发送,所以丢包是『网速慢』的第一元凶。虽然TCP本身不能左右网络带宽本身的情况,但是却能利用自身的拥塞控制,减少网络带宽的进一步拥堵,从而避免雪上加霜。
可见,TCP真正做的事情远不止表面的数据完整性和有序性,可靠性的保证也是非常重要的一个环节。
而当我们使用UDP模拟TCP的相关特性时,可以考虑拥塞控制的模拟,当然,如果只是想使用UDP来模拟基本的文件传输,那么似乎简单的模拟包的有序与完整性就可以了。
以上就是我认为值得讨论的一些TCP的细节点。
最后,聊聊熟悉又陌生的2点:
- 3次握手
- 4次挥手
无论是初学TCP还是使用UDP模拟TCP,这两个话题都老生常谈。这里稍微再来聊聊牵手分手这码事。
客户端发起一个握手包syn
,这是在做序列号的同步,除此外这个包还携带了本端的接受窗口大小。若syn
发送迟迟得不到服务器的ack
包,客户端协议栈会再次重试发送syn
包,会反复重试几次(可以控制,通常默认是6次),如果最后都没有收到ack
包,则握手失败。
3次握手最后一个包围客户端的ack
,这个包可以携带负载数据。
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) 编辑 收藏 举报