Linux相关概念和易错知识点(42)(TCP的连接管理、可靠性、面临复杂网络的处理)
目录
- 1.TCP的连接管理机制
- (1)三次握手
- ①握手过程
- ②对握手过程的理解
- (2)四次挥手
- (3)握手和挥手的触发
- (4)状态切换
- ①挥手过程中状态的切换
- ②握手过程中状态的切换
- 2.TCP的可靠性
- (1)重传机制
- ①超时重传
- ②快重传
- (2)流量控制
- ①滑动窗口
- a.滑动窗口的组成
- b.滑动窗口的运行逻辑
- ②拥塞控制
- a.慢启动机制
- b.快恢复机制
- ③窗口探测
- 3.TCP针对复杂网络环境的一些处理
- (1)随机序号
- (2)延迟应答
- (3)紧急指针
- (4)粘包问题
- (5)意外断连
- ①进程异常终止
- ②机器断电、断网
1.TCP的连接管理机制
(1)三次握手
三次握手和四次挥手都需要建立在标识位的基础上,不同的标识位可以告诉对方自己的目的。 用一个故事来描述三次握手:A向B表白,B同意了并且问什么时候开始谈,A告诉B就现在并递出了一束花。
①握手过程
我们认为发出“表白”请求的就是C端,就是客户端,接收的就是服务端,即S端。三次握手都是用标志位来表达的。第一次C端发送SYN,这是个空报文,只有报头,这相当于A向B表白;第二次S端发送SYN + ACK,ACK是对第一次握手的回应,SYN相当于B向A表白,这也是个空报文,只有报头;第三次C端发送ACK,并且可以不再是空报文,可以捎带应答了,这里的ACK也是对B向A发送SYN的回应。
②对握手过程的理解
过程其实可以拆分成两部分,C端向S端申请连接,S端向C端申请连接。申请连接的一方都会发出SYN,接收到SYN的都会进行ACK。那么更清晰地说,三次握手的本质是四次握手:C发送SYN,S回应ACK;S发送SYN,C回应ACK。只不过我们将中间S端回应ACK和发送SYN合并了而已,所以才叫三次握手,这体现出全双工的特性。
本质上,三次握手、四次挥手是以最少次数验证双方全双工信道的通畅性。
三次握手的最后一次,C端可以边ACK边发消息,就是使用了确认序号和序号个部分,本质就是利用了捎带应答机制。但注意三次握手合并的两次握手(S端的ACK和SYN)并不能说是捎带应答,因为捎带应答针对的是报文的捎带,要用到序号和确认序号,而这里只是将ACK和SYN两个标志位置为1。
(2)四次挥手
为什么是四次前面已经提及,本质就是从左到右建立共识,再从右向左建立共识。C端发起FIN,S端接收回应后再发起FIN,断开连接和建立连接一样,都要征得双方的同意。 根据建立连接,我们可以推断出,在有的情况下四次挥手可以合并成三次,即双方都有很强的断开连接的意愿下。
(3)握手和挥手的触发
TCP的服务端一直处于listen状态(一直阻塞在accept函数中),客户端建立连接需要connect,服务端需要accpet,那么握手和挥手在宏观上是在哪一步被触发的呢?
C端调用connect之后,会触发OS去握手,connect只会阻塞等待结果,这个函数并不会主动去参与握手的流程。所以C端发送的SYN、ACK这些都是调用connect之后系统自动进行的,和这个函数没有直接关系。同理,accept也不参与三次握手,它只负责等待结果。总结起来就是:connect发起请求后,由OS自主三次握手,握手成功C端的connect和S端的accept都会收到消息,但这两个函数不会参与握手过程。
挥手过程和握手相似,C端调用close后OS会自主发起一个FIN,S端收到后再自主发起FIN,因此close也是不会直接参与挥手过程的。但挥手有一个问题:C端close会触发OS的挥手流程,但S端收到C端的挥手后,S端发起的FIN怎么能被C端接收到呢?C端不是已经close了吗?其实关闭网络通信还有个更底层接口,int shutdown(int fd, int how), 其中how是宏,有读、写、读写选项,因此调用这个函数可以实现更细粒度的通信关闭,也可实现半通信。close之后OS并不会马上断开读、写通信,而会步步关闭,这样就解决了刚才的疑问。
(4)状态切换
握手和挥手会伴随一系列的状态切换,因为建立和断开连接不仅由双方意愿决定,还要受网络状态影响,所以每一次挥手和握手都对应状态切换,这样在出现意外时OS能根据状态及时判断出错的阶段并给出异常处理方案。 下面是一些比较重要的状态切换,涉及一些网络环境的处理:
①挥手过程中状态的切换
C端发起close后,会进入TIME_WAIT等待S端的FIN,收到S端的FIN后等待两个MSL时间就会切换到CLOSED状态。
TIME_WAIT的存在也能保证S端发起的FIN确认收到,有可能C端收到S端的FIN后发起的ACK没有送到S端,导致S端一直FIN,TIME_WAIT下的C端在收到FIN后可以马上再ACK,尽可能保证正常4次挥手完成。
MSL是一个报文在网络中的最长时间(大部分在60s - 120s),保持在TIME_WAIT是为了等待网络的游离报文消散,在这个状态下一般bind会失败,以防快速建立两次连接,第二次连接后却收到第一次连接时的报文。不过,我们可以设置套接字setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt))(其中需定义int opt = 1),SO_REUSEADDR允许进行地址复用,这个时候服务器TIME_WAIT状态也能再次bind。 S端在收到C端的FIN并ACK之后,会进入CLOSE_WAIT,S端发起FIN后会进入LAST_ACK,收到C端的ACK就直接进入CLOSED状态。前两次挥手是由C端调用触发的,后两次挥手需要由S端调用close触发。 所以如果服务器一直不调用close,一直处于CLOSE_WAIT,那么第三次挥手就迟迟不发,这种情况下C端等待一段时间后会直接退出,而S端就一直积累,导致服务器的文件描述符、内存泄漏。所以一定要及时close。
②握手过程中状态的切换
connect触发三次握手后,C端就切换为SYN_SENT,S端收到SYN之后就变成SYN_RCVD。S端再发起SYN + ACK,被C端收到,C端就会变成ESTABLISHED,对C端来说连接就已经建立了,之后就会开始发消息。而S端就会在第三次握手后变成ESTABLISHED,稍晚一些。
这里会出现一个问题,S端和C端切换为ESTABLISHED的时间不一样。如果C端变成ESTABLISHED之后S端出现意外,被快速重启了,而C端不知道。这个时候C端照常发消息,S端就不认识,这时S端就会回应RST(重置连接),重新进行三次握手。
或者说S端发送SYN后C端的ACK没有被S端收到,S端仍未置于ESTABLISHED,之后C端发送消息,S端也会RST重置连接。
2.TCP的可靠性
报文在网络上传输要花时间,确认收到了消息需要做应答,告诉发送端已经收到了。就像上课老师问同学听没听懂,同学要应答。
但服务端应答要发回一些消息,怎么保证应答收到了呢?应答再应答?这就无限递归了。 可以得出,长距离通信,没有100%的可靠性,因为总有最新的一条消息是没有应答的。 客户端给服务器发消息,服务器要应答,只要客户端收到了应答,就说明上一条消息的可靠性,我们关注的其实是前面发送的消息是否可靠,而不是最新的应答,应答只是个标志,而不是重要的信息。 TCP就是以这种方式为基础,实现了可靠性。
(1)重传机制
①超时重传
信息丢失分为数据丢了,应答丢了。数据丢了不会应答,C端会设定一个时间间隔,超时会重传。但应答丢了,C端确认不了是应答丢了还是数据丢了,所以C端都会超时重传。
这里我们可以结合一下系统的知识来理解,如何实现超时功能呢?闹钟信号(定时器,通过软中断实现,本质就是count --)。
超时等待时间不是固定的,会动态计算,一般以500ms为单位控制,第一次500ms,下一次2 * 500,下一次4 * 500… 指数级增加等待时间间隔,多次不行的话发送端就会认为是网络不行,直接执行中断连接等操作。
其实有的时候信息根本没丢,只是被卡在某个路由器了,但网络太复杂了,我们只能按照最坏的打算来处理。这个时候就可能遇到两个一模一样的信息被接受端收到(原信息 + 重传信息),怎么办呢?很简单,用序号去重即可。
②快重传
我们已经知道,滑动窗口里面的数据随时可以发送,如果分成四块发送1 - 4000,第2块先到,ACK为1,第3、4块其次,它们回应的ACK都是1,这个时候OS连续接收到三个相同的ACK,就会马上重传第1块,不论第1块是真丢了还是传的比较慢。如果是传的慢,没丢失,接受端收到两个报文也会去重
但如果收到的顺序是2、3,但1和4都还没传过来,这个时候就不会触发快重传。如果1先到,则ACK立即变为3001,之后等待4;如果4先到,那就立刻触发快重传;如果都没到,那就等待超时重传。
可以发现,快重传只是在一方面降低时间延迟,本质上还是个概率问题,面对网络的复杂性,很多时候的处理并不像系统那样“优雅”,简单来说就是“赌”。
(2)流量控制
流量控制 != 传输数据速率变慢,它是通过一系列考量,告诉OS当前最合适的发送数据速率是多少,在拥堵时减少发送,在空闲时能增加发送量,整体提高数据传输效率。
①滑动窗口
a.滑动窗口的组成
我们前面说过,TCP有自己的发送缓冲区和接受缓冲区,两个缓冲区本质就是sk_buff的链表,对报文的管理本质就是对sk_buff的管理,发送和接收都是修改链表(生产者 - 消费者模型)。滑动窗口本质上就是建立在缓冲区之上的,它本质上是一种算法,通过类似首尾指针或下标维护窗口范围。我们前面说过发送数据就是向缓冲区写数据,至于数据怎么发,什么时候发是OS自主决定的。滑动窗口就属于OS自主决定的这部分。
发送缓冲区中,滑动窗口的左侧是已经确认收到的数据,中间是随时可以发送的数据(可以不按顺序发),右侧是现在不能发送的数据。缓冲区逻辑结构上是环形的,因此滑动窗口不会越界,本质上就是环形队列的生产者 - 消费者模型。
TCP报文交换过程中,双方互相交换的报头中就有16位窗口大小,这个滑动窗口大小不能超过16位窗口大小对应值,这样发送的数据永远不会超出对方的接受能力导致数据丢弃。注意这个窗口大小控制是双方都在做的,这是全双工通信。
其实这里还有个更底层的问题,为什么滑动窗口要划分成一块一块的数据发送,直接一下子把滑动窗口里面的数据全发出去不是更省事吗?数据链路层不允许发送大的报文,有大小限制,所以越往上走,就越要控制报文的大小,要保证从传输层向下传输的数据到链路层不会超过它的规定最大值。
b.滑动窗口的运行逻辑
窗口滑动的本质是维护窗口首尾的start和end在做修改,滑动窗口的start = 确认序号,end = start + win(接收的报文的当前窗口大小)。滑动窗口只能向右移动,毕竟确认序号是不断增加的。
已知滑动窗口是分段发送的,假设为四等分段,丢包问题无非三种情况的组合,最左侧、中间、最右侧丢了。最左侧丢了:后面收到的3个应答的确认序号应该都是滑动窗口原起始值,滑动窗口不移动,这个时候也会触发快重传。补发接收到后确认序号直接跳过第2、3、4段。中间段丢失,应答的确认序号都是第1(或2)段后面的值,窗口移动一段,这个时候转为最左侧丢失情况,补发后窗口再跳过已接收到段。其余同理,所有的丢失都会转为最左侧丢失问题,滑动窗口逐一滑动,逐一解决丢包。
为什么收到补发的数据后,窗口会直接跳过前面接收的数据序号呢?在滑动窗口发送的数据没有被完整接收完前,先前到达的sk_buff会被暂时维护起来,直到完全接收完毕后统一上交到缓冲区,链入缓冲区的sk_buff链表中。
上述的丢包重传,除了第一种只有最左侧丢弃,其余三个都收到并ACK同一值触发了快重传以外,其余的都是靠超时重传实现的。
②拥塞控制
和建立连接断开连接一样,双方想干什么不仅仅是看双方的意愿,还要看网络的状态。要进行流量控制,滑动窗口的大小不能仅仅是由对方的窗口大小决定,还受网络限制,不然虽然对方能接受大量数据,但网络极差,发出去的基本都丢了,有什么用?
a.慢启动机制
少量报文丢失是正常情况,但大部分报文丢失就只能是网络的问题(16位窗口大小排除了对端缓冲区的影响)。
网络出现拥塞问题,要么重传,要么进行其他处理。可以肯定的是,如果大量数据超时,重传肯定不现实,这只会加重网络拥堵。因为用全局视角来看,网络上有很多用户,如果每个用户都采用这种策略,这一定会导致大量数据重复发出,造成更严重的网络拥塞。
所以一旦发生拥塞,就必须使用慢启动机制,先发少量数据,摸清网络状态,每收到一个ACK就再多发点,一点一点试探,发送的数据量受到拥塞窗口值的影响。一般来说,大于拥塞窗口的值就可能拥塞,小于这个值就很可能不会引发拥塞。
这又来了个窗口,怎么和前面的知识结合起来呢?仅需一个公式就能彻底理解。滑动窗口大小 = min(16位窗口大小,拥塞窗口大小),我们需要认识到,对流量的控制已经转为了对滑动窗口大小的控制,这里取最小值就是最好的佐证。
我们还需要了解下慢启动的算法,这个拥塞窗口的大小怎么调整的?拥塞窗口大小由每个主机单独维护(双方都要有,且不在报文中体现),是主机自己对网络环境的评估。因此可以说,网络的好坏转为了对拥塞窗口大小的比较,这也是后续理解拥塞窗口大小调整算法的核心思想。
刚开始通信时,主机对网络状况一无所知,因此拥塞窗口是1字节,每收到一次ACK,窗口大小 + 1、+2、+4、+8,指数级增加。 为什么采用指数增长呢?这是为了让主机尽快探明网络,尽快达到正常通信速度。
但由于指数爆炸的特性,窗口大小增长速度还受慢启动阈值限制,一旦拥塞窗口到达阈值,后面拥塞窗口就按+1、+1、+1增大。这里有一个需要注意的点,达到阈值后窗口值线性增加,如果网络一直畅通,每收到一个ACK,那么窗口值会不断增大,甚至到1000,10000都可以。我们要理解,拥塞窗口的值是对网络状态的描述,值越大,说明网络越好,上不封顶。
如果发生拥塞,拥塞窗口大小直接变为1,慢启动阈值 = 触发拥塞前上一次拥塞窗口大小(拥塞前一瞬间拥塞窗口的大小) / 2 。 这里我们也能理解为什么拥塞窗口要一直变化,上不封顶。因为网络的拥堵、健康等状况一直是变化的,因此拥塞窗口也一定一直在变化。如果很长一段时间拥塞窗口都很大,这说明网络状况很好,就算发生一次拥塞后,由于慢启动阈值高,能很快回到原来状态。
b.快恢复机制
快恢复指的是当触发快重传之后,慢启动阈值 = 触发拥塞前上一次拥塞窗口大小(拥塞前一瞬间拥塞窗口的大小) / 2 ,这些和慢启动一致。但区别在于拥塞窗口大小不会置为1,而是会置为变化后的慢启动阈值。 启动快重传,毕竟是连续收到3个ACK的(大概率能收到消息,只不过某一块数据出现了意外),网络状况还是稍好一点的,不需要极端地置为1,可以说快恢复是一种相对而言效率更优的做法,和慢启动结合控制拥塞窗口大小的修改。
③窗口探测
滑动窗口大小可以为0,即对方剩余窗口大小为0时,就会触发窗口探测。 这个时候每隔一段时间,C端就会试探性地发送消息,并且PSH标志位设为1,告知对方请尽快将数据交给上层。事实上,当滑动窗口大小较小时(不一定要窗口为0),C端就会开始发送PSH了。
3.TCP针对复杂网络环境的一些处理
(1)随机序号
在快速建立两次连接时,挥手时要避免上一次连接的游离报文传到下一次连接中,因此第2次连接的开始序号要处理。由于前两次握手没有任何报文内容交换,所以可以在两次握手中的序号处设置一个随机序号,双方交换随机序号。在后续的传输中,收到序号后应当减去对方握手时发给自己的随机序号,恢复出原始序号,如果异常就丢弃。这就减少了游离报文的影响。
(2)延迟应答
S端收到了数据,一般会等一小会再应答,等待期间上层会读取数据,这会使得S端的接收缓冲区变大,之后ACK的窗口大小也会更大一些,提高传输效率提高。但是我们要如何理解等一小会?S端可以每隔2个包应答一次(也可为其它值,TCP允许少量ACK丢失或者隔包应答,收到ACK表示之前的数据都收到了),或者根据时间限制(如200ms)应答,延迟的时间要控制在超时重传内。
(3)紧急指针
TCP具有按序到达的特性,但有的时候我们想要插队,让后面的数据先被收到。例如,使用云盘上传数据时,我们突然想要终止上传,这个终止的指令就相对比较紧急,因为就算按序收到前面的大量数据,也终归是无效的。
我们可以将URG标记设为1,这样到达对方主机后,OS就会优先读取并交给上层处理。 URG的紧急数据由16位紧急指针标识,其值标记有效载荷中紧急数据的偏移量。但注意紧急数据只占1个字节,可以约定设置状态码来进行快捷的紧急处理。
紧急指针的运用也能体现在代码中,在应用层recv可以读取紧急数据,其中recv的第4个参数标志位设置为MSG_OOB,这个时候recv就会去读取紧急数据(也称为带外数据)。发送紧急数据同理,在send标记位设置MSG_OOB。
(4)粘包问题
UDP没有粘包问题,因为其报头中含有有效载荷长度,要么拿上一个完整报文,要么直接将整个报文丢弃。而TCP没有这个字段,只有起始序号,因此在面向字节流中,不能保证读到应用层的是完整的报文,有可能读了两个报文,交到应用层的数据中第2个报文只有一半,这就是粘包问题。 解决方案就是在应用层设置明确的报文边界,例如用特殊符号隔开报文,使用序列化和反序列化,增加自描述长度字段等,这需要在应用层人为解决。
(5)意外断连
①进程异常终止
维护连接本质上就是维护创建的文件及其缓冲区,由于文件生命周期随进程,所以当进程异常终止时,杀掉进程的就是OS内核,相应地,OS内核也会负责地正常进行四次挥手断开连接。 机器重启也是如此,OS都会正常四次挥手。
②机器断电、断网
如果C端的机器断电或断网,对于S端来说,连接就无效了。但S端连接信息一直都在,它怎么知道C端的连接怎么样呢?
如果C端长时间没通信,S端会询问它,如果没回应,S端就会关闭,这叫连接保活,保活时间一般是几分钟。 TCP自己有连接保活机制,但一般连接的保持是应用层来做,因为TCP不能根据不同情况满足实际需求,只是个兜底的。