【计网】从零开始理解TCP协议 --- TIME_WAIT状态 , CLOSE_WAIT状态,流量控制机制,滑动窗口机制
本文详细讲解了TCP协议中通信过程中的两个状态:TIME_WAIT状态 , CLOSE_WAIT状态。同时讲解了两个十分重要的机制流量控制机制和滑动窗口机制!
1 TCP通信状态
在四次挥手问题中,客户端向服务端发送FIN请求断开连接,服务端返回应答,并也发送一个FIN请求进行断开连接,客户端收到后就返回应答。这里服务端发送ACK和FIN可以合并为一次,所以也能成为三次挥手!所以建立连接和断开连接本质上是没有区别的,那为什么还要强调四次挥手呢?因为一方端口连接,另一方可能还有需要发送的数据,所以有可能是进行四次挥手。
在这里出现了这样几个状态:TIME_WAIT , CLOSE_WAIT,FIN_WAIT。
- TIME_WAIT(时间等待)状态: TIME_WAIT是主动关闭方在完成最后一次ACK发送后的状态。这个状态存在的目的是为了确保连接被动关闭方能够收到最后一个ACK确认包,同时防止在网络中延迟的数据包影响新连接的建立。
- CLOSE_WAIT(关闭等待)状态: CLOSE_WAIT是连接被动关闭方在收到对方发送的FIN请求后,发送ACK确认进入的状态。在这个状态下,应用程序可能还有未处理的数据需要发送,因此需要等待应用程序处理完这些数据后,才能发送FIN请求来关闭连接。如果应用程序没有及时关闭连接,可能会导致大量的CLOSE_WAIT状态,从而消耗系统资源。CLOSE_WAIT状态表示被动关闭方正在等待关闭。
我们来验证两个状态:TIME_WAIT , CLOSE_WAIT。
1.1 验证CLOSE_WAIT状态
我们先来看一看服务端的CLOSE_WAIT状态:在网络套接字代码中,只要服务器不关闭文件描述符其状态就会处于CLOSE_WAIT状态!
在Tcpserver的回调函数中,当服务端套接字接受到了新的的连接就会创建新线程执行Execute
函数。在完成service函数之后,按理来说完成一次通信之后要关闭对应的文件描述符,这里我们不关闭文件描述符:
static void *Execute(void *args)
{
pthread_detach(pthread_self()); // 线程分离!!!
// 执行Service函数
TcpServer::ThreadData *td = static_cast<TcpServer::ThreadData *>(args);
// 直接进行IO
std::string reqstr;
// 这里默认读取到的是完整的请求
ssize_t n = td->_sockfd->Recv(&reqstr);
if (n > 0)
{
std::string resstr = td->_this->_service(reqstr);
td->_sockfd->Send(resstr);
}
//验证CLOSE_WAIT状态
//td->_sockfd->Close();
delete td;
return nullptr;
}
我们通过netstat -natp
指令可以查看连接状态!我通过浏览器与服务端进行连接通信,此时完成一次连接之后,完成一次连接之后并不会关闭套接字文件,所以就会产生CLOSE_WAIT状态!
我们可以看到,我们的服务端确实有两个连接处在CLOSE_WAIT状态,并且服务端处在LISTEN监听状态!客户端和服务端断开连接了,服务端并没有与客户端断开连接,这就模拟了服务端还有数据要发送的情况!
当服务器关闭连接发送FIN请求就会处于LAST_ACK状态,等待对方发送的最后一次ACK!
PS:如果服务器卡顿,可以检查一下是不是存在大量的CLOSE_WAIT状态的连接
1.2 验证TIME_WAIT状态
主动断开连接时,自己是要处在TIME_WAIT状态的。
当浏览器退出连接之后,服务端这里的连接并不会立刻退出,而是处于TIME_WAIT状态。这个状态存在的目的是为了确保连接被动关闭方能够收到最后一个ACK确认包,同时防止在网络中延迟的数据包影响新连接的建立。所以持续一段时间才关闭连接!
所以我们在服务端启动8888接口的连接,完成任务后关闭服务端,立刻再次启动8888接口连接就会出现bind error错误,这就是由于TIME_WAIT状态的原因!TIME_WAIT状态通常持续2MSL(最大报文生存时间),时间过
后,连接才会真正关闭。
MSL 在 RFC1122 中规定为两分钟,但是各操作系统的实现不同, 在 Centos7 和Ubuntu 上默认配置的值是 60s
之所以是2MSL(最大报文生存时间) , 是因为有些数据需要应答,所以这个报文完全结束时需要2MSL时间的!就是为了保证两个来回的时间!同时也为了避免四次挥手出现丢包的情况!
这个时间是等待历史报文消散,防止影响后续连接的通信过程!
解决这个问题是通过setsockopt
接口解决:
GETSOCKOPT(2) Linux Programmer's Manual GETSOCKOPT(2)
NAME
getsockopt, setsockopt - get and set options on sockets
SYNOPSIS
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int getsockopt(int sockfd, int level, int optname,
void *optval, socklen_t *optlen);
int setsockopt(int sockfd, int level, int optname,
const void *optval, socklen_t optlen);
这个函数的参数如下:
- socket:需要设置选项的套接字描述符。
- level:指定选项所在的协议层次。比如,SOL_SOCKET表示通用套接字选项,IPPROTO_TCP表示TCP协议的选项。
- option_name:需要设置的选项名。不同的level会有不同的选项名。
- option_value:指向一个变量的指针,这个变量包含要设置的选项的值。
- option_len:option_value指向的变量的长度。
这个接口可以设置套接字的属性,我们在TcpSocket类中加入方法 ReUseAddr()
可以通过SO_REUSEADDR
选项使其复用地址:
void ReUseAddr() override
{
int opt = 1;
::setsockopt(_sockfd , SOL_SOCKET , SO_REUSEADDR , &opt , sizeof(opt));
}
我们在建立Socket套接字时设置一下属性:
void BuildListenSocket(uint16_t port)
{
CreateSocketOrDie();
ReUseAddr();
CreateBindOrDie(port);
CreateListenOrDie();
}
这样就不会再出现bind error 的问题了!
2 流量控制
接收端处理数据的速度是有限的。
如果发送端发的太快, 导致接收端的缓冲区被打满, 这个时候如果发送端继续发送,就会造成丢包,继而引起丢包重传等等一系列连锁反应。因此 TCP 支持根据接收端的处理能力, 来决定发送端的发送速度。这个机制就叫做流量控制(Flow Control)。
当客户端服务端双方进行通信时,是通过两个缓冲区(发送缓冲区和接收缓冲区)进行的,当对方的接收缓冲区空间不够了,发送方发送的数据就会接收失败,造成丢包。此时如果采用重发的策略就会显得十分的不负责任,因为报文千里迢迢通过网络,耗费了很多资源,结果最后因为接收方没有足够空间就丢包,这样多亏啊!并且下一次也不能保证会不会丢包!所以发送方在面对这种情况时就要减少发送速度,进行流量控制!
- 发送的快慢取决于对方的接收能力,也就是接受缓冲区中的剩余空间大小。
- 发送方通过ACK报头中的字段:16位窗口大小,通过这个窗口大小来知道对方的接收能力,可以根据这个大小动态调整发送速度!
- 16位窗口大小填写的都是自己的窗口大小!单位是字节!通过这个大小可以加快速度也能减慢速度!
在最开始进行三次握手时,就已经确认一次对方的窗口大小了,已经调整好了发送速度!这样在后续第一次发送报文时就不需要考虑了这个问题了!
当对方发送的ACK报头中的窗口大小是0了,发送方就不会再发送数据。此时有两种策略:
- 当接收方的窗口大小变大了,就会主动发送一个窗口更新通知
- 发送方在等待的过程中会每隔一段时间发送窗口探测!
那如果对方的窗口大小一直不更新呢?那么发送方有没有策略可以让对方抓紧向上交付接收缓冲区的数据呢?
- 标志位中的PSH标志位: 提示接收端的应用程序应立即从TCP缓冲区中读取数据。
如果对方返回的窗口大小一直是0,发送方可以使用PSH标志位催促接收方赶紧处理!就算对方的窗口大小没有为0,发送方也可以使用PSH,让对方抓紧进行数据交付!
这里提一下最后一个标志位URG,当这个标志位为0时,16位紧急指针是无效的,当标志位为1时,16位紧急指针有效。16位紧急指针标志了数据中的紧急数据是什么!!!可是只有一个指针,没有数据长度,如何确定紧急数据?因为紧急数据只有一字节!
紧急指针很少使用,其是用来对报文进行管理的。下面举个例子:
小明收集了70G的学习资料,想要上传到网盘中,方便大家分享使用。当上传到40G时,小明突然发现资料的问题,赶紧取消上传,这时就是紧急指针起作用了!
但是,我们知道报文是按序到达的,如果正常的发送报文,会等待前面的数据上传完毕。而紧急指针可以表示一些特殊操作,可以快速从缓冲区中读取!然后立刻就取消了上传!
紧急指针是很少使用的,一般可以通过两个端口建立两个连接,一个进行正常通信,另外一个进行特殊处理。这样更加优雅!
3 滑动窗口
3.1 简单理解滑动窗口
现在有几个问题需要解决:
- 上面讲的流量控制可以根据通过对方的接收能力调节发送速度,那么具体是如何调节发送速度的呢?
- 超时重传:超时时间以内,已经发送的报文不能被丢弃,而是保存起来,这是保存在哪里的?
滑动窗口机制就是解决这两个问题的!
在实际的传输中,如果一次报文就要立刻返回ACK,那么就会是串行的传输报文,效率较慢。实际上传输采取的是并行发送一波报文,对方需要一起返回ACK,这样效率更高!一次性发送大量数据,需要保证是在对方的接收能力之内!对方需要有能力进行接收!
发送方规定了一个概念:滑动窗口。
滑动窗口以内的数据,可以直接发送,暂时不需要收到应答!窗口大小指的是无需等待确认应答而可以继续发送数据的最大值。上图的窗口大小就是 4000 个字节(四个段)。
这个窗口会将缓冲区划分为三个部分:
- 窗口左边:已经发送,已经确认的数据。
- 窗口:暂时不需要应答,可以直接发送的数据。
- 窗口右边:等待发送的数据。
在上图中,主机A的滑动窗口是1000-4000,当发送的1000得到应答之后,滑动窗口就向右移动了!通过这个可以大概了解滑动窗口的工作原理。
- 滑动窗口滑动的方向只能向右滑吗?
按照我们上图是不能向左的,但是概念定义中是可以向左滑动的! - 滑动窗口的大小是一成不变的吗?
不是,滑动窗口可以根据对方的接收能力动态改变,也就是流量控制! - 滑动窗口的大小可以为0吗?
当然可以,当对方的接受能力为0时,那么滑动窗口就为0了!
3.2 深入理解滑动窗口
之前我们讲过一个序号确认机制,我们提到了可以把缓冲区理解为一个数组。每个数据都有对应的序号!那么滑动窗口也就是数组的一个区域,也就是两个指针:win_start , win_end
。窗口滑动就是两个指针同时向右移动,窗口大小的改变就可以通过对指针的操作进行!
但是有个问题,按这样来说,那么滑动窗口肯定会走到数组边缘啊?其实这个发送缓冲区是一个环形队列,并不是一个简单的大数组。环形队列就不会出现越界!
在发送的过程中,发送端发送了四组数据,服务端收到的顺序不确定,那么发送ACK应答的属性就不确定。
当收到了应答时,可以保证应答之前的数据都已经被接收到了,这时就可以移动两个指针win_start , win_end
:
win_start = ack_seq;
win_end = win_start + win;
这个win
可以根据应答的窗口大小,动态改变,这样不就进行了流量控制了!
以上图为例,如果丢包了怎么办?
- 最左侧数据丢包
- 中间数据丢包
- 最右侧数据丢包
当最左侧数据丢包时,其他的数据发送成功会有对应的ACK应答,这个ACK应答序号是多少呢?根据确认序号的定义(确认序号之前的数据已经被成功接收)所以这些ACK应答的确认序号是 1001!所以滑动窗口左侧不会移动!注意不会影响滑动窗口的正常滑动!右侧是可以移动的!
也就是说,后续每次的ACK应答的确认序号都是1001,收到了3次同样的确认应答时则进行重发!这个机制叫做快重传机制。
那么怎么还有一个超时重传机制呢?这两种重传机制并不冲突,快重传的前提是丢包后还有多个数据进行发送,如果没有了,那么就要靠超时重传进制进行兜底了!如果是应答丢包了怎么办?
只有最新的应答收到了,那么也可以确认之前的数据对方收到了。这时确认序号的定义!如果全丢了也不要紧,我们有超时传机制兜底!
当中间的数据丢失时,那么ACK应答中的确认序号一定是已经被成功接收的数据,所以这时可以理解为窗口的左端已经已经可以移动到这个位置了,那么此时也就可以转换成最左侧数据丢包了!
当最右侧数据丢包时,同样也可以转换成最左侧数据丢包!
所以滑动窗口并不怕出现丢包问题!
开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!
更多推荐
所有评论(0)