让小白也能理解tcp协议(2)
一:引言
上次,我给大家讲解了tcp协议格式中的各种报头,只是把浅显的道理讲给了大家,今天让我给大家带来更加深入的理解,比如说之前数据包丢失,超过了一定时间就会超时重传,这些我都是把名字说出来,并没有讲解其工作真正的机制,这次让我们继续深探。
二:超时重传机制
主机A发送数据给B之后,可能因为网络拥堵等原因,数据无法到达主机B;
如果主机A在⼀个特定时间间隔内没有收到B发来的确认应答,就会进行重发;

提问:那么我们如何设置一个合理的超时零界点呢?
最理想的情况下,找到⼀个最小的时间,保证"确认应答⼀定能在这个时间内返回",但是这个时间的长短,随着网络环境的不同,是有差异的。
我们考虑到以下两点:
1. 如果超时时间设的太长,会影响整体的重传效率;
2.如果超时时间设的太短,明明是消息还没送到,却以为丢失了,有可能会频繁发送重复的包;
答:TCP为了保证无论在任何环境下都能比较高性能的通信,因此会动态计算这个最大超时时间。
- Linux中,超时以500ms为⼀个单位进行控制,每次判定超时重发的超时时间都是500ms的整数倍;
- 也就是说如果重发⼀次之后,仍然得不到应答,等待2*500ms后再进行重传;
- 如果仍然得不到应答,等待4*500ms进行重传。依次类推,以指数形式递增;
- 累计到⼀定的重传次数,TCP认为网络或者对端主机出现异常,强制关闭连接。
三:连接管理机制
上篇博客我们讲了握手和挥手的过程,这次我们要来讲讲与我们调用的接口有什么关系以及状态的变化。

看到这张图,大家应该是很熟悉的,这是tcp从建立连接到结束连接的固定流程。
这里我只给大家讲几个易混淆的接口:
1. connect
我们很容易想当然connect就是进行了三次握手进行了连接,但是事实上,connect只是发起了建立请求,发出了SYN包,至于接下来的连接过程都是tcp协议底层异步完成,connect会堵塞至底层完成连接,最后收到连接是否成功的结果。
2. write和read
这个问题不大,可能与大家的想法略有误差
write:确实是把数据写入tcp的写缓冲区,但是是通过我们传入参数buf拷贝进入tcp写缓冲区。
read:实质tcp读缓冲区接收到数据后,tcp读缓冲区拷贝一份到你传入的参数buf。
3. close
close只是关闭了套接字,事实上tcp连接并没有断开,底层还连接着;套接字的关闭代表着用户层无法通过套接字调用read和write,那么客户端将不会再向服务端发送数据,底层可以接收到服务端的数据,但是无法交付到应用层,所以数据会立马丢弃,底层依旧可以发送不带数据的纯报头进行ACK应答。
4. listen
这里我想给大家讲讲这个接口的第二个参数---------连接队列长度(tcp缓存连接的最大个数);
这个连接队列其实就相当于三次握手成功的缓冲结构,为什么这么说呢,因为它会存储已经完成三次握手的连接,但是暂时不会交付给应用层,这不就相当于缓冲了,暂时的存放。
5.accept
我们很容易认为accept就是同意连接,所以accept就是建立三次握手的过程,其实并不是,上面我们提到的listen已经将连接建立好了放在队列中,调用accept只是将连接取到程序中,也就是获取了连接,得到相对应的套接字。
1.服务端状态的转换:
- [CLOSED -> LISTEN] 服务器端调用listen后进入LISTEN状态,等待客户端连接;
- [LISTEN -> SYN_RCVD] ⼀旦监听到连接请求,就将该连接放⼊内核等待队列中,并向客户端发送SYN+ACK应答报文。
- [SYN_RCVD -> ESTABLISHED] 服务端⼀旦收到客户端的确认报文,就进入ESTABLISHED状态,可以进行读写数据了。
- [ESTABLISHED -> CLOSE_WAIT] 当客户端主动关闭连接(调⽤close),服务器会收到结束报文段, 服务器返回ACK确认报文段并进入CLOSE_WAIT;
- [CLOSE_WAIT -> LAST_ACK] 进⼊CLOSE_WAIT后说明服务器准备关闭连接(需要处理完之前的数据);当服务器真正调⽤close关闭连接时,会向客户端发送FIN,此时服务器进⼊LAST_ACK状态,等待最后⼀个ACK到来(这个ACK是客⼾端确认收到了FIN);
- [LAST_ACK -> CLOSED] 服务器收到了对FIN的ACK, 彻底关闭连接。
2.客户端的转换:
- [CLOSED -> SYN_SENT] 客户端调用connect,发送SYN请求;
- [SYN_SENT -> ESTABLISHED] connect调⽤成功,说明底层连接已经完成,则进⼊ESTABLISHED状态,开始读写数据;
- [ESTABLISHED -> FIN_WAIT_1] 客户端主动调用close时,向服务器发送结束报⽂段,同时进入FIN_WAIT_1;
- [FIN_WAIT_1 -> FIN_WAIT_2] 客户端收到服务器对结束报文段的确认,则进⼊FIN_WAIT_2,开始等待服务器的结束报文段;
- [FIN_WAIT_2 -> TIME_WAIT] 客户端收到服务器发来的结束报⽂段,进⼊TIME_WAIT,并发出 LAST_ACK;
- [TIME_WAIT -> CLOSED] 客户端要等待⼀个2MSL(Max Segment Life,报文最大生存时间)的时间,才会进入CLOSED状态。
注意:TIME_WAIT状态是谁先调用了close接口,谁就有这个状态,因为上面那张图片是客户端调用close发起FIN请求。
3.TIME_WAIT
提问:这里有个重点也就是TIME_WAIT,我们如何理解TIME_WAIT呢?
现在做⼀个测试,我们可以启动以前自己写的一个客户端,如果没有看看我的操作就行,首先启动server,然后启动client,然后用Ctrl-C使server终止,这时马上再运行server,结果是:

原因如下:
- TCP协议规定,主动关闭连接的一方要处于TIME_WAIT状态,等待两个MSL(maximum segment lifetime)的时间后才能回到CLOSED状态;
- 我们使用Ctrl-C终止了server,所以server是主动关闭连接的一方,在TIME_WAIT期间仍然占着之前绑定的端口,所以当我们再次以该端口进行启动,就会显示端口正在使用的报错。
提问:想一想为什么TIME_WAIT是2MSL呢?
- TIME_WAIT状态是主动close方接收到对方的FIN请求进入的状态,那么需要主动结束方进行应答后才会真正结束连接,但是如果ACK应答没有被对方收到,而主动结束方已经退出,那么对方就一直无法退出连接,所以主动结束方需要有一个TIME_WAIT的等待时间,给对方重发FIN请求的时间。
- MSL 是 TCP 报文的最大生存时间,因此 TIME_WAIT 持续存在 2MSL 的话,就能保证在两个传输方向上的尚未被接收或迟到的报⽂段都已经消失(否则服务器立刻重启,可能会收到来自上⼀个进程的迟到的数据,但是这种数据很可能是错误的。
4.解决TIME_WAIT的bind错误
提问:为什么要解决一个为了安全而设置的状态?
在 server 的 TCP 连接没有完全断开之前不允许重新监听,某些情况下可能是不合理的:
- 服务器需要处理非常⼤量的客户端的连接(每个连接的⽣存时间可能很短,但是每秒都有很⼤数量的客户端来请求。
- 这个时候如果由服务器端主动关闭连接(比如某些客户端不活跃,就需要被服务器端主动清理掉),就会产生大量TIME_WAIT连接。
- 由于我们的请求量很大,就可能导致TIME_WAIT的连接数很多,每个连接都会占⽤⼀个通信五元组 (源ip,源端⼝,⽬的ip,目的端⼝,协议)其中服务器的ip和端⼝和协议是固定的,如果新来的客户端连接的ip和客户端和TIME_WAIT占用的链接重复了,就会出现问题.。
使⽤ setsockopt ()设置 socket 描述符的选项 SO_REUSEADDR 为 1 ,表示允许创建端⼝号相同但IP地址不同的多个 socket 描述符。

将上述代码写在bind函数的前面就可以复用端口了。
四:流量控制和滑动窗口
1.预备知识

上篇博客我只是提了窗口大小报头的作用是为了告诉缓存区剩余大小来流量控制,所以现在我们来深入细节。
流量控制其实就是依靠了滑动窗口来实现的。
上篇博客讨论了确认应答策略,对每⼀个发送的数据段,都要给⼀个ACK确认应答,收到ACK后再发送下⼀个数据段,这样做有⼀个比较大的缺点,就是性能较差,尤其是数据往返的时间较长的时候。

既然这样⼀发⼀收的方式性能较低,那么我们⼀次发送多条数据,就可以大的提高性能(其实是将多个段的等待时间重叠在⼀起了)。

- 窗口大小指的是⽆需等待确认应答而可以继续发送数据的最大值,上图的窗口大小就是4000个字 节(四个段)。
- 发送前四个段的时候,不需要等待任何ACK,直接发送;
- 收到第⼀个ACK后,滑动窗⼝向后移动,继续发送第五个段的数据,依次类推;
- 操作系统内核为了维护这个滑动窗口,需要开辟发送缓冲区来记录当前还有哪些数据没有应答;只 有确认应答过的数据,才能从缓冲区删掉;
- 窗口越大,则网络的吞吐率就越高;

系统将缓冲区中的每一个字节都编了一个号,所谓的序号报头就是对应着缓冲区中的某个字节,这就和上篇博客联系起来了。
通过这些可以引出我们的滑动窗口。
2.滑动窗口

滑动窗口其实是发送缓冲区的一部分,它的大小是动态的,接下来我们来好好解释解释一下。
滑动窗口的边界是由start_win和end_win决定,这是我随意编的变量。
提问:为什么要划分成几个区域来呢?
当然是为了能够暂时保护住没有确定真的发送给了对方的数据,因为如果数据发出去之后就将数据从缓冲区抛弃掉,那么对方没有收到,我们需要重发,那么就没有数据可以重发了,所以已确定数据区的数据可以随意抛弃,这样子就归类了。

主机B发送的ACK应答报头中的确定序号其实就是start_win接下来要移动到的位置,因为确定序号的含义是该序号之前的数据全部收到了,所以将窗口往后移动之后,确定收到的数据会被归为已确定数据区,就会被丢失。
start_win = ACK报头中的确定序号大小
end_win = start_win + ACK报头中的窗口大小
ACK报头中的窗口大小告诉你它的缓冲区有多少空闲大小,就说明可以发多少数据过去,所以滑动窗口的右边界就是 "左边界+需要发送数据的大小" 。
那么如果出现了丢包,如何进⾏重传?这里分两种情况讨论
情况一:数据包已经到了,ACK丢了

这种情况下,部分ACK丢了并不要紧,因为可以通过后续的ACK进行确认;
如图所示,通过滑动窗口发送了多个数据包,最后通过收到的确定序号和窗口大小来动态移动滑动窗口,此时,start_win应该向右移动至6001;
情况二:数据包就直接丢了

- 如图所示,如果1001~2000的数据包丢了,那么接下来所有的ACK应答中的确定序列都是1001,因为1001~2000的数据包丢了,那么只能说收到了1001之前的数据。
- 如果发送端主机连续三次收到了同样⼀个"1001"这样的应答,就会将对应的数据1001-2000重新发送;
- 这个时候接收端收到了1001之后,再次返回的ACK就是7001了(因为2001-7000)接收端其实之前就已经收到了,被放到了接收端操作系统内核的接收缓冲区中,所以现在是7001之前的数据都受到了,自然ACK确认序号是7001。
注意:这种机制被称为"⾼速重发控制"(也叫"快重传"),这并不是超时重传,因为这是因为收到了多个相同的应答才重传,并不是因为超过了时间没收到ACK。
3.连环四联问
提问:滑动窗口会变大吗?会变小吗?会不变吗?会左移吗?
答:滑动窗口可能变大,变小,也可能不变。滑动窗口的大小就是对方缓冲区空闲部分的大小,那么当对方上层拿取数据快,缓冲区空闲部分多,那滑动窗口自然变大;反之,滑动窗口变小;那用户拿取数据速率和这边发送速率一致,那么空闲部分不变,自然滑动窗口大小就不变。
但是滑动窗口绝对不会左移,因为start_win左边界等于ACK应答报头中确定序号,而确定序号是收到了该序号之前的所有数据,而滑动窗口的左边是已确定数据,也就是一定确认了被对方接收的数据,所以绝对不会向左移动。
4.流量控制
通过上诉对滑动窗口的讲解能更好的理解流量控制,现在让我们重新定义一下流量控制:
接收端处理数据的速度是有限的,如果发送端发的太快,导致接收端的缓冲区被打满,这个时候如果发送端继续发送,就会造成丢包,继而引起丢包重传等等⼀系列连锁反应,因此TCP支持根据接收端的处理能力,来决定发送端的发送速度,这个机制就叫做流量控制。
- 接收端将自己可以接收的缓冲区剩余空间大小放入TCP首部中的"窗口大小"字段,通过ACK端通知发送端;
- 窗口大小字段越大,说明⽹络的吞吐量越高;
- 接收端⼀旦发现自己的缓冲区快满了,就会将窗口大小设置成⼀个更小的值通知给发送端;
- 发送端接受到这个窗口之后,就会减慢自己的发送速度;
- 如果接收端缓冲区满了,就会将窗⼝置为0;这时发送方不再发送数据,但是需要定期发送⼀个窗⼝探测数据段,使接收端把窗口大小告诉发送端。

那我们设想一个场景,如果接收端ACK应答了一个窗口大小为0,那么发送端又不知道什么时候接收端又有缓冲区空闲位置,难道要一直不发消息吗?
接收端会定期窗口更新通知,发送端也会定期发送窗口探测包,发送一个不含任何东西0字节的数据包,让接收端应答ACK,查看ACK中窗口大小就能直到对方接收能力了。
最后一个问题了,在tcp协议格式中,窗口大小报头只有16位,所以最大也就65535大小,显然这肯定不是缓冲区应有的大小,所以这就体现出来了我们一直没讲的报头就是这个选项,选项中是一个窗⼝扩⼤因子M,实际窗⼝大小是窗口字段的值左移M位,左移一位乘以2。

五:总结
今天把大部分TCP的内容都要讲完了,剩下来的拥塞控制,还有粘包问题等等,下一篇博客再讲,希望我的讲解能够让大家真的学到东西。
谢谢大家,记得三连激励一下博主!!!我们下次再见

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)