文章目录
- [1. TCP协议](#1. TCP协议)
-
- [1.1 TCP协议段格式](#1.1 TCP协议段格式)
- [1.2 确认应答(ACK)机制](#1.2 确认应答(ACK)机制)
- [1.3 16位窗口大小](#1.3 16位窗口大小)
- [1.4 6位标志位](#1.4 6位标志位)
-
- [1.4.1 TCP三次握手](#1.4.1 TCP三次握手)
- [1.5 确认应答(ACK)机制](#1.5 确认应答(ACK)机制)
- [1.6 超时重传机制](#1.6 超时重传机制)
- [1.7 连接管理机制](#1.7 连接管理机制)
-
- [1.7.1 理解TIME_WAIT状态](#1.7.1 理解TIME_WAIT状态)
- [1.7.2 理解 CLOSE_WAIT 状态](#1.7.2 理解 CLOSE_WAIT 状态)
1. TCP协议
TCP全称为传输控制协议,意思是要对数据的传输进行一个详细的控制。
1.1 TCP协议段格式
TCP的报头是20个字节,一共5行,每行4个字节。这是标准长度,如果报头中含有选项,那么报头的长度就是变长的。
那么我们该如何去解包呢?
因为TCP的报头是变长的,所以我们首先要看的是4位首部长度。它的意思是除数据外其它的总长度。因为是4个bit位,所以最大是1111,并且它的单位是4字节,所以最大长度是4*15=60字节,也就是说TCP报头最大60字节。而我们得知标准长度是20个字节,那么4位首部长度是5,也就是0101,它的取值范围就是0101~1111。
当我们要解包时,先提取20字节,在这20字节找到4位首部长度,假如是30,那么就30-20=10得出选项的大小,再把选项提取出来。
1.2 确认应答(ACK)机制
首先,我们要谈一个问题:可靠性。
那么什么是不可靠的呢?
丢包、乱序、校验失败等等。
怎么确定一个报文是丢了还是没丢?
当我们给对方发送消息的时候,怎么确定对方有没有收到呢?如果对方给我们应答吃了,吃的是炸鸡。就说明对方收到了我发送的消息。
那么对方又怎么确定我有没有收到呢?我们再给对方回应。
可能大家此时就会发现一个结论:在长距离交互的时候,永远有一条最新的数据是没有应答的。
如果发送的消息有对应的应答,就一定没有丢,否则是不确定的。
还有一个问题:当我们传多个报文时,怎么确定哪个回应对应哪个报头呢?
这里我们就需要使用32位序号和32位确认序号。
当我们发送数据的时候,在32位序号中添加数字,在响应数据时,在32位确认序号中添加。
32位确认序号代表的是:对方前面已经发送完整报文的数据量。
举个例子:比如客户端给服务器发送了1,2,3,5,6这几个报文,那么我们的确认序号就应该是4,说明4前面的序号报文都接受成功了,应该再从4开始重新发送。
那么为什么需要两种序号呢?
因为TCP协议是全双工的,我在给你发消息的同时,我也可以收消息。如果客户端想给你发消息的同时再接受消息呢?反之服务端也是如此。
所以,客户端根据它的序号和对方的确认序号保证客户端发送数据的可靠性,服务端根据它的序号和客户端的确认序号保证服务器发送的数据可靠性。
总而言之,序号是让对方确认的,确认序号是对方让我确认的。
1.3 16位窗口大小
首先,我们要知道:TCP协议是具有发送缓冲区和接受缓冲区的。
我们在调用write,read等系统接口时,其实就是把数据拷贝到缓冲区,或者从缓冲区拷贝数据到应用层。
从进程地址空间看,就是从内核空间拷贝到用户空间的栈或者堆上。
当互相通信的时候,TCP的过程如下图所示:
每个发送缓冲区和接受缓冲区都是一对,所以TCP通信的时候是全双工的。
我们在应用层用系统调用接口,把数据拷贝到内核缓冲区里,所以,数据什么时候发,发多少,出错了怎么办,要不要添加提高效率的策略,都是由OS内的TCP自主决定的。所以叫做传输控制协议。
既然是缓冲区,就说明缓冲区是有大小的。那么如果发送数据太快,缓冲区来不及接受,或者发送的太慢,效率太低。这些问题该怎么办呢?
为了解决这个问题,我们需要让客户端知道服务器的接受能力,它的接受能力就是接受缓冲区剩余空间的大小。
那么客户端怎么知道服务器接受能力的大小?
发送都会有应答,应答里包含TCP报头,报头里有16位窗口大小来表示接受能力的属性字段。我们就可以根据对方的窗口大小来设置发送速度,这种策略叫做流量控制。
如果我们是第一次发送消息,我们怎么确定对方的接受能力呢?
因为是16位,最大是64K,所以我们不能超过这个大小。
1.4 6位标志位
在前面,初步讲解了3次握手和4次挥手:初步了解三次握手
在服务端,收到的报文有的是为了建立链接的,有的是正常传输数据,有的是断开链接。这说明报文是有区别的。
SYN: 请求建立连接,SYN需要被设置成1,我们把携带SYN标识的称为同步报文段。
客户端需要给服务端发送一个报头,即使没有有效载荷,我们也要发一个SYN为1的TCP报头过去。
FIN: 通知对方,本端要关闭了。我们称携带FIN标识的为结束报文段。
SYN和FIN不应该同时设置。
ACK: 确认标记位,表示该报文是对历史报文的确认,一般在大部分正式通信的信号下,ACK都是1。
比如:客户端给服务器发送一个报文,服务端给客户端响应返回,这个响应返回中ACK就会设置成1,说明上一条报文发送成功,然后可以提取确认序列号。
PSH: 提示接收端应用程序立刻从TCP缓冲区把数据读走。
当客户端给服务端发送数据时,服务器是用read来从接受缓冲区里读取的。如果读取条件不满足,read会被阻塞。这是进程主动地去轮询检测的。
当数据准备好的时候,我们不想使用系统默认的读取情况,而是想主动通知服务端立即去读取,我们可以使用PSH标记位。
URG: 紧急指针标记位。
我们知道:报文在发送的时候,是可能乱序到达的。
那么如何做到让我们的报文进行按序到达呢?
主机每次发送数据时,TCP就给每个数据包分配一个序列号并且在一个特定的时间内等待接收主机对分配的这个序列号进行确认,如果发送主机在一个特定时间内没有收到接收主机的确认,则发送主机会重传此数据包。接收主机利用序列号对接收的数据进行确认,以便检测对方发送的数据是否有丢失或者乱序等,接收主机一旦收到已经顺序化的数据,它就将这些数据按正确的顺序重组成数据流并传递到高层进行处理。
数据在TCP中是有序到达的话,但是如果有一些数据优先级更高,但序号较后,我们想紧急处理此数据。我们是按照优先级还是序号呢?
我们可以设置TCP中的URG来让优先级更高的数据先处理。
不是所有的数据都是紧急数据,我们该如何找到紧急数据呢?
这里就需要根据16位紧急指针的偏移量了。但是只能读取一个字节。
1.4.1 TCP三次握手
这里的线画的是斜线,原因是考虑到时间的关系。
并且我们要知道:大量的连接,OS就要管理这些连接,就需要先描述,后组织。
下面我们要讲解一下为什么是3次握手:
三次握手,前两次都有应答,第三次没有应答。所以说3次握手不一定成功。
为什么不是一次?
如果一次握手成功,那么它非常容易受到攻击。如果一台主机不断的给我们服务器发送SYN链接,服务器需要创建相关数据结构来管理,那么它可能创建假的SYN来把服务器的资源占满,让正常的SYN不能链接。
为什么不是二次?
如果客户端将服务器的回复丢弃,那么效果就和一次是一样的了。
为什么是三次?
三次握手最后一次是由服务端来确定的,所以最后握手如果没有成功,影响的是客户端不是服务端。最后一次握手,服务器不一定能收到,但是客户端是发送出去了,它需要维护相关数据结构。以奇数次握手来将花费的成本嫁接到客户端。并且客户端发送一次,接受一次,服务器发送一次,接受一次,以最小成本验证全双工。
如果客户端发出最后一次ACK,但是报文丢了,服务端没有收到,那么客户端认为建立成功,服务端认为建立没有成功,下面会是什么情况呢?
那么客户端就会给服务端发送数据,如何服务端就会给客户端发出报头,报头里的RST就会设置成1。
RST: 对方要求重新建立连接,我们把携带RST标识的称为复位报文段。
1.5 确认应答(ACK)机制
TCP将每个字节的数据都进行了编号,即为序列号。
每一个ACK都带有对应的确认序列号,意思是告诉发送者,我已经收到了哪些数据,下一次你从哪里开始发。
1.6 超时重传机制
主机A发送数据给B之后,可能因为网络拥堵等原因,数据无法到达主机B,如果主机A在一个特定时间间隔内没有收到B发来的确认应答,就会进行重发。
但是,主机A未收到B发来的确认应答,也可能是因为ACK丢失了。因此主机B会收到很多重复数据,那么TCP协议需要能够识别出那些包是重复的包,并且把重复的丢弃掉。这时候我们可以利用前面提到的序列号,就可以很容易做到去重的效果。
那么丢包要在特定的时间内重传,如果超时的时间如何确定?
最理想的情况下,找到一个最小的时间,保证"确认应答一定能在这个时间内返回"。但是这个时间的长短,随着网络环境的不同,是有差异的。如果超时时间设的太长,会影响整体的重传效率。如果超时时间设的太短, 有可能会频繁发送重复的包。
TCP为了保证无论在任何环境下都能比较高性能的通信,因此会动态计算这个最大超时时间:
Linux中(BSD Unix和Windows也是如此),超时以500ms为一个单位进行控制,每次判定超时重发的超时时间都是500ms的整数倍。如果重发一次之后,仍然得不到应答,等待 2 *500ms 后再进行重传。如果仍然得不到应答,等待 4 *500ms 进行重传, 依次类推,以指数形式递增。累计到一定的重传次数,TCP认为网络或者对端主机出现异常,强制关闭连接。
1.7 连接管理机制
在正常情况下,TCP要经过三次握手建立连接,四次挥手断开连接:
在某些特殊情况,如果客户端和服务端想同时断开连接,那么在第一次ACK时,可以把FIN加上,这样就是3次挥手。
如果我们服务器不accept,3次握手也能成功,accept只是把已经成功建立的连接拿上来。
服务端状态转化:
[CLOSED -> LISTEN] 服务器端调用listen后进入LISTEN状态,等待客户端连接。
[LISTEN -> SYN_RCVD] 一旦监听到连接请求(同步报文段),就将该连接放入内核等待队列中,并向客户端发送SYN确认报文。
[SYN_RCVD -> ESTABLISHED] 服务端一旦收到客户端的确认报文,就进入ESTABLISHED状态,可以进行读写数据了。
[ESTABLISHED -> CLOSE_WAIT] 当客户端主动关闭连接(调用close),服务器会收到结束报文段,服务器返回确认报文段并进入CLOSE_WAIT。
[CLOSE_WAIT -> LAST_ACK] 进入CLOSE_WAIT后说明服务器准备关闭连接(需要处理完之前的数据),当服务器真正调用close关闭连接时,会向客户端发送FIN,此时服务器进入LAST_ACK状态,等待最后一个ACK到来(这个ACK是客户端确认收到了FIN)。
[LAST_ACK -> CLOSED] 服务器收到了对FIN的ACK,彻底关闭连接。
客户端状态转化:
[CLOSED -> SYN_SENT] 客户端调用connect,发送同步报文段。
[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状态。
1.7.1 理解TIME_WAIT状态
这里是以客户端为例,如果是服务端也是一样的。谁先断开连接,谁先进入TIME_WAIT状态,当客户端发送最后一次ACK之后,客户端理论上可以关闭连接了,但是需要等一段时间,才会进入CLOSED状态。
为什么TIME_WAIT状态需要等一段时间才会结束?
原因是:在第四次挥手时,发送的ACK可能会丢失,那么对方可能会进行FIN重传机制。如果在一段时间内,没有收到对方的FIN,就认为对方收到了ACK。并且,如果当正常数据发送的时候,同时发送FIN,那么等一段时间是为了让历史数据尽可能的被双方收到。
那么TIME_WAIT状态需要等待的时间是多长呢?
TCP协议规定:主动关闭连接的一方要处于TIME_ WAIT状态,等待两个MSL的时间后才能回到CLOSED状态。
MSL在RFC1122中规定为两分钟,但是各操作系统的实现不同, 在Centos7上默认配置的值是60s。可以通过 cat /proc/sys/net/ipv4/tcp_fin_timeout 查看msl的值。
为什么是TIME_WAIT的时间是2MSL?
MSL是TCP报文的最大生存时间,因此TIME_WAIT持续存在2MSL的话就能保证在两个传输方向上的尚未被接收或迟到的报文段都已经消失(否则服务器立刻重启,可能会收到来自上一个进程的迟到的数据,但是这种数据很可能是错误的)。
同时也是在理论上保证最后一个报文可靠到达(假设最后一个ACK丢失,那么服务器会再重发一个FIN。这时虽然客户端的进程不在了,但是TCP连接还在,仍然可以重发LAST_ACK)。
如果是服务端先断开连接,进入TIME_WAIT状态后,我们就不能启动服务端了(./server 8080),它的错误码是2(绑定失败) 。
这是因为虽然server的应用程序终止了,但TCP协议层的连接并没有完全断开,因此不能再次监听同样的server端口。
在server的TCP连接没有完全断开之前不允许重新监听,某些情况下可能是不合理的:
服务器需要处理非常大量的客户端的连接(每个连接的生存时间可能很短,但是每秒都有很大数量的客户端来请求)。这个时候如果由服务器端主动关闭连接(比如某些客户端不活跃,就需要被服务器端主动清理掉),就会产
生大量TIME_WAIT连接。
由于我们的请求量很大,就可能导致TIME_WAIT的连接数很多,每个连接都会占用一个通信五元组(源ip、源端口、目的ip、目的端口、协议)。其中服务器的ip和端口和协议是固定的,如果新来的客户端连接的ip和端口号和TIME_WAIT占用的链接重复了,就会出现问题。
解决TIME_WAIT状态引起的bind失败的方法:使用setsockopt()设置socket描述符的选项SO_REUSEADDR为1,表示允许创建端口号相同但IP地址不同的多个socket描述符。
函数功能:设置套接字描述符的属性。
sockfd:要设置的套接字描述符。
level:是被设置的选项的级别,如果想要在套接字级别上设置选项,就必须把level设置为 SOL_SOCKET。
optname:指定准备设置的选项,optname可以有哪些取值,这取决于level。
optval:指向某个变量的指针,该变量是要设置新值的缓冲区。可以是一个结构体,也可以是普通变量。
optlen:optval缓冲区的长度。
代码如下:
bash
// 1. 创建socket
listenSock_ = socket(PF_INET, SOCK_STREAM, 0);
if (listenSock_ < 0)
{
exit(1);
}
int opt = 1;
setsockopt(listenSock_, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// 2. bind绑定
// 2.1 填充服务器信息
1.7.2 理解 CLOSE_WAIT 状态
如果客户端断开连接,服务端没有close,那么服务端就会进入CLOSE_WAIT 状态。
小结: 对于服务器上出现大量的 CLOSE_WAIT 状态,原因就是服务器没有正确的关闭 socket,导致四次挥手没有正确完成。这是一个BUG,只需要加上对应的 close 即可解决问题。