目录
- TCP协议格式
- 理解可靠性
- 序号与确认序号
- 16位窗口大小
- 六个标志位
- 连接管理机制
- 三次握手
- 四次挥手
- 确认应答机制(ACK)
- 超时空重传机制
- 流量控制
- 滑动窗口
- 拥塞控制
- 延迟应答
- 捎带应答
- 面向字节流
- 粘包问题
- TCP异常情况
- TCP小结
- 基于TCP应用层协议
- TCP/UDP对比
- 用UDP实现可靠传输
- 理解listen的第二个参数
TCP协议格式
TCP协议格式格式如下:
TCP报头当中各个字段的含义如下:
- 源/目的端口号:表示数据是从哪个进程来,到发送到对端主机上的哪个进程。
- 32位序号/32位确认序号:分别代表TCP报文当中每个字节数据的编号以及对对方的确认,是TCP保证可靠性的重要字段。
- 4位首部长度:表示该TCP报头的长度,以4字节为单位。
- 6位保留字段:TCP报头中暂时未使用的6个比特位。
- 16位窗口大小:保证TCP可靠性机制和效率提升机制的重要字段。
- 16位检验和:由发送端填充,采用CRC校验。接收端校验不通过,则认为接收到的数据有问题。(检验和包含TCP首部+TCP数据部分)
- 16位紧急指针:标识紧急数据在报文中的偏移量,需要配合标志字段当中的URG字段统一使用。
- 选项字段:TCP报头当中允许携带额外的选项字段,最多40字节。
TCP报头当中的6位标志位:
- URG:紧急指针是否有效。
- ACK:确认序号是否有效。
- PSH:提示接收端应用程序立刻将TCP接收缓冲区当中的数据读走。
- RST:表示要求对方重新建立连接。我们把携带RST标识的报文称为复位报文段。
- SYN:表示请求与对方建立连接。我们把携带SYN标识的报文称为同步报文段。
- FIN:通知对方,本端要关闭了。我们把携带FIN标识的报文称为结束报文段。
如何分离?
当TCP从下次获取到报文时,尽管此时TCP并不知道报头的长度,但是前20字节是报文的基本报头是确定的,这20字节中包含了4位首部长度,因此就可以使用如下方式分离报头与有效荷载:
- 先提取20字节;
- 根据标准报头,提取4位首部长度,此时就获得了TCP报头大小size;
- 根据标准报头,如果size < 20,继续提取20 - size字节数据;如果size > 20,读取size - 20字节数据,这就是报文中的选项数据。
- 读完基本报头和选项数据后,剩下的就是有效荷载。
TCP报头当中的4位首部长度描述的基本单位是4字节,这也恰好是报文的宽度。4为首部长度的取值范围是0000 ~ 1111,因此TCP报头最大长度为15 × 4 = 60字节,因为基本报头的长度是20字节,所以报头中选项字段的长度最多是40字节。
如果TCP报头当中不携带选项字段,那么TCP报头的长度就是20字节,此时报头当中的4位首部长度的值就为20 ÷ 4 = 5,也就是0101。
如何交付?
其实交付的方式跟UDP一样的,因为每一个进程都会绑定一个端口号,客户端由操作系统动态进行绑定,服务端需要显示进行绑定,TCP协议中报头涵盖了目的端口号,我们可以读取目的端口号,进而找到相应的进程。
理解可靠性
比如此时有两个人小花和小红,两个人在面对面进行交流,对方说话两个人都能听见,但是两个人相隔50米以后,此时再说话,对方也就无法听见了,这也就造成消息的不可靠性,其实本质上就是距离变长了,因此,要进行设备间的通信,就必须引入可靠性,如果要进行通信的各个设备相隔千里,,传输数据时出现错误的概率也会大大增高,此时要保证传输到对端的数据无误。
在网络中,并不存在100%可靠的协议,我们并不能保证发出去的消息被对方接收到了,但是在局部上我们却可以保证,只要对方对我们所发送到消息做出应答,我就能确保对方一定是收到了,而这就是TCP的确认应答机制,只要一个报文收到了对方的,我们就那保证发出的数据对方已经收到了,因此,TCP协议是一种可靠的协议。
TCP协议已经是可靠的了,为什么还会存在UDP协议呢?
因为网络通信具体采用TCP还是UDP取决于上层的场景,我们会发现不可靠和可靠是两个中性词,它所描述的是协议的特点。
- TCP协议作为一种可靠的协议,也就意味着需要更多的工作来保证传输数据的可靠性,就需要耗费更多的(时间+空间)资源;
- TCP协议会处理数据传输过程中出现的丢包,乱序,检验和失败等数据不可靠问题,所以它使用起来一定会比UDP复杂,维护成本也一定会比UDP高;
- UDP作为不可靠协议,也就意味着他会忽略数据传输过程中出现的不可靠性的问题,他的维护也就足够简单;
- 需要注意的是,虽然TCP复杂,但TCP的效率不一定比UDP低,TCP当中不仅有保证可靠性的机制,还有保证传输效率的各种机制。
我们最终选择何种协议,往往取决于上层的应用场景,如果场景严格要求数据传输的可靠性,就需要使用TCP协议,如果允许出现少量丢包等问题,就使用UDP协议。
序号与确认序号
32位序号
如果双方在进行通信的过程中,一方只有接收到了上一次发送数据的响应后才会在进行下一次数据的发送,就表明双方此时是串行的,效率也就非常低了。
因此双方在进行网络通信时,允许一方向另一方连续发送多个报文数据,只要保证发送的每个报文都有对应的响应消息就行了,此时也就能保证这些报文被对方收到了。
但在连续发送多个报文时,由于各个报文在进行网络传输时选择的路径可能是不一样的,因此这些报文到达对端主机的先后顺序也就可能和发送报文的顺序是不同的。但报文有序也是可靠性的一种,因此TCP报头中的32位序号的作用之一实际就是用来保证报文的有序性的。
TCP将发送出去的每个字节数据都进行了编号,这个编号叫做序列号。
- 比如现在发送端要发送3000字节的数据,如果发送端每次发送1000字节,那么就需要用三个TCP报文来发送这3000字节的数据。
- 此时这三个TCP报文当中的32位序号填的就是发送数据中首个字节的序列号,因此分别填的是1、1001和2001。
此时接收端收到了这三个TCP报文后,就可以根据TCP报头当中的32位序列号对这三个报文进行顺序重排(该动作在传输层进行),重排后将其放到TCP的接收缓冲区当中,此时接收端这里报文的顺序就和发送端发送报文的顺序是一样的了。
注意:接收端在进行报文重排时,可以根据当前报文的32位序号与其有效载荷的字节数,进而确定下一个报文对应的序号。
32位确认序号
TCP报头当中的32位确认序号是告诉对端,我当前已经收到了哪些数据,你的数据下一次应该从哪里开始发。
当服务端收到客户端发送过来的32位序号为1的报文时,由于该报文当中包含1000字节的数据,因此主机B已经收到序列号为1-1000的字节数据,于是服务端发给客户端的响应数据的报头当中的32位确认序号的值就会填成1001。
- 一方面是告诉客户端,序列号在1001之前的字节数据我已经收到了。
- 另一方面是告诉客户端,下次向我发送数据时应该从序列号为1001的字节数据开始进行发送。
注意:
- 响应数据与其他数据一样,也是一个完整的TCP报文,尽管该报文可能不携带有效载荷,但至少是一个TCP报头。
如果过程中出现报文丢失怎么办?
我们假设客户端向服务端发送了三个报文数据,32位报文序号分别是1,1001,2001,如果这三个报文在网络传输过程中出现了丢包,最终只有序号为1和2001的报文被客户端收到了,此时服务端在对客户端进行响应时,其响应报头当中的32位确认序号填的就是1001,告诉主机A下次向我发送数据时应该从序列号为1001的字节数据开始进行发送。
所以此时服务端向客户端发送的确认序号不可以是3001,因为如果是3001,就表明序号为1,1001,2001的报文都被收到了,但是此时序号为1001的报文并没有被收到,所以此时服务端只能给客户端响应1001,当客户端收到该确认序号后就会判定序号为1001的报文丢包了,此时客户端就可以选择进行数据重传。
为什么会存在序号和确认序号两套序号机制?
如果通信双方是半双工,一方发送数据,一方接收数据,一套序号机制是足够的:
- 发送端在发送数据时,将该序号看作是32位序号;
- 接收端在接收数据时,将该序号看作是32位确认序号;
TCP作为全双工通信,双方就可能会同时给对方发消息:
- 在双方发出的报文中,不仅需要32位序号来填充表明自己发送数据的信号,还需要32位确认序号来填充对对方上一次发出的信号进行确认,告诉对方下一次从哪一个字节序号开始进行发送数据。
- 因此在进行TCP通信时,双方都需要有确认应答机制,此时一套序号就无法满足需求了,所以TCP报头当中出现了两套序号。
我们就可以总结出来序号与确认序号的作用是:
- 32位序号的作用是,保证数据的按序到达,同时这个序号也是作为对端发送报文时填充32位确认序号的根据。
- 32位确认序号的作用是,告诉对端当前已经收到的字节数据有哪些,对端下一次发送数据时应该从哪一字节序号开始进行发送。
- 序号和确认序号是确认应答机制的数据化表示,确认应答机制就是由序号和确认序号来保证的。
- 此外,通过序号和确认序号还可以判断某个报文是否丢失。
16位窗口大小
发送缓冲区和接收缓冲区
我们在学习UDP过程中接触到了接收缓冲区,UDP并不存在真正的发送缓冲区,但是对于TCP来说,是存在发送缓冲区和接收缓冲区的。
- 发送缓冲区:用来暂时保存还未发送的数据。
- 接收缓冲区:用来暂时保存接收到的数据。
服务端与客户端之间的通信并不是直接发送至网络中的,而是通过对应发送和接收缓冲区保存起来,然后在进行数据的交互的。
- 上层在调用write/send接口过程中并不是将数据直接发送至网络中,而是先将数据拷贝到发送缓冲区中,先暂时保存起来。
- 上层在调用read/recv接口过程中也并不是直接从网络中读取的,而是从接收缓冲区中将对应的数据进行读取。
- 当数据写入到TCP的发送缓冲区后,对应的write/send函数就可以返回了,至于发送缓冲区当中的数据具体什么时候发,怎么发等问题实际都是由TCP决定的。
- 我们之所以称TCP为传输层控制协议,就是因为最终数据的发送和接收方式,以及传输数据时遇到的各种问题应该如何解决,都是由TCP自己决定的,用户只需要将数据拷贝到TCP的发送缓冲区,以及从TCP的接收缓冲区当中读取数据即可。
TCP的发送缓冲区和接收缓冲区存在的意义
- 数据在网络中传输时可能会出现某些错误,此时就可能要求发送端进行数据重传,因此TCP必须提供一个发送缓冲区来暂时保存发送出去的数据,以免需要进行数据重传。只有当发出去的数据被对端可靠的收到后,发送缓冲区中的这部分数据才可以被覆盖掉。
- 接收端处理数据的速度是有限的,为了保证没来得及处理的数据不会被迫丢弃,因此TCP必须提供一个接收缓冲区来暂时保存未被处理的数据,因为数据传输是需要耗费资源的,我们不能随意丢弃正确的报文。此外,TCP的数据重排也是在接收缓冲区当中进行的。
窗口大小
当发送端要将数据发送给对端时,本质是把自己发送缓冲区当中的数据发送到对端的接收缓冲区当中。但缓冲区是有大小的,如果接收端处理数据的速度小于发送端发送数据的速度,那么总有一个时刻接收端的接收缓冲区会被打满,这时发送端再发送数据过来就会造成数据丢包,进而引起丢包重传等一系列的连锁反应。
因此TCP报头当中就有了16位的窗口大小,这个16位窗口大小当中填的是自身接收缓冲区中剩余空间的大小,也就是当前主机接收数据的能力。
接收端在对发送端发来的数据进行响应时,就可以通过16位窗口大小告知发送端自己当前接收缓冲区剩余空间的大小,此时发送端就可以根据这个窗口大小字段来调整自己发送数据的速度。
- 窗口大小字段越大,说明接收端接收数据的能力越强,此时发送端可以提高发送数据的速度。
- 窗口大小字段越小,说明接收端接收数据的能力越弱,此时发送端可以减小发送数据的速度。
- 如果窗口大小的值为0,说明接收端接收缓冲区已经被打满了,此时发送端就不应该再发送数据了。
此时我们也就可以理解,调用write/send/read/recv会阻塞的原因了:
- 在编写TCP套接字时,我们调用read/recv函数从套接字当中读取数据时,可能会因为套接字当中没有数据而被阻塞住,本质是因为TCP的接收缓冲区当中没有数据了,我们实际是阻塞在接收缓冲区当中了。
- 而我们调用write/send函数往套接字中写入数据时,可能会因为套接字已经写满而被阻塞住,本质是因为TCP的发送缓冲区已经被写满了,我们实际是阻塞在发送缓冲区当中了
六个标志位
标记为存在的原因
TCP的报文种类是多种多样的,存在常规报文,建立连接报文,断开连接报文,确认报文,其他类型报文。在接收到不同的报文就需要执行相应的动作,不同种类的报文对应的是不同的处理逻辑,所以我们要能够区分报文的种类。而TCP就是使用报头当中的六个标志字段来进行区分的,这六个标志位都只占用一个比特位,为0表示假,为1表示真。
SYN
该报文是一个连接请求报文,只有在连接建立阶段,SYN才被设置,正常通信时SYN不会被设置。
FIN
该报文是一个断开连接的请求报文,只有在断开连接阶段,FIN才被设置,正常通信时FIN不会被设置。
ACK
确认应答标志位,凡是该报文具有应答特征,该标志位会被设置为1。
RST
对连接进行重置,通信双方在连接未建立好的情况下,一方向另一方发数据,此时另一方发送的响应报文当中的RST标志位就会被置1,表示要求对方重新建立连接。如果通信中途发现之前建立好的连接出现了异常也会要求重新建立连接。
PSH
报文当中的PSH被设置为1,是在告诉对方尽快将你的接收缓冲区当中的数据交付给上层。
我们的接收缓冲区和发送缓冲区是存在一个水准线的概念的,当从接收缓冲区中recv/read数据时,只有当接收缓冲区中的数据到达这个水准线时才会read/recv数据,而当PSH被设置为1时,就是告诉操作系统,尽快将接收缓冲区内的数据交付给上层,尽管此时接收缓冲区中的数据并没达到我们的水准线,这也就是为什么我们平时recv/read期望的数据值与实际读取数据值不匹配的原因。
URG
URG是紧急标志位,由于双方进行网络通信过程中,数据被分成若干个TCP报文进行传输,由于32位序号的存在,保证了发送端发送的数据时有序的,接收端接收到的数据依然是有序的,但是有时候发送端发送了一些“紧急数据”,这些数据需要被上层应用马上读取,我们又该怎么办呢?
此时就需要我们的紧急标志位URG和16位紧急指针了:
- 当URG标志位被设置为1时,需要通过TCP报头当中的16位紧急指针来找到紧急数据,16位紧急指针代表的就是紧急数据在报文中的偏移量。
- 因为紧急指针只有一个,它只能标识数据段中的一个位置,因此紧急数据只能发送一个字节。
连接管理机制
双方通信的过程中,必定会存在大量的客户端去连接服务端,而操作系统对这些连接的管理方法就是“先描述在组织”,我们所说的连接,其实本质就是内核中的一种数据结构类型,建立连接成功的时候,就是在内存中创建对应的连接对象,最终再将这些连接以某种数据结构组织起来,此时操作系统对连接的管理就变成了对该数据结构的增删查改。
三次握手
三次握手的过程:
双方在进行TCP通信之前需要先建立连接,建立连接的这个过程我们称之为三次握手。
客户端想要与服务端之间进行通信时,需要与服务端先建立连接,此时客户端作为主动方会先向服务器发送连接建立请求,然后双方TCP在底层会自动进行三次握手。
- 第一次握手:客户端向服务端发送连接建立请求,此时SYN被设置为1;
- 第二次握手:此时服务端收到客户端发来的连接建立请求以后,服务端会紧接着向客户端发送建立连接的请求并且对刚才客户端发来的连接请求进行响应,此时SYN和ACK均被设置为1;
- 第三次握手:客户端收到服务器发来的报文以后,得到自己的连接请求已经被服务端收到并且服务端再向自己发送建立连接的请求,最后客户端在对服务端发送来的连接请求进行响应。
注意:TCP作为全双工通信,三次握手对客户端服务端均会起效。
为什么是三次握手?不是一次,两次,四次呢?
建立连接的过程不一定是百分百成功的,通信双方前两次的握手是可以保证对方收到的,因为前两次握手都有对应的下一次握手对其进行响应,但第三次握手是没有对应的响应报文的,如果第三次握手时客户端发送的ACK报文丢失了,那么连接建立就会失败。
客户端再发起第三次握手就会认为自己已经完成了握手,但是如果此时服务器并没有收到客户端发来的第三次握手,此时服务端就不会建立对应的连接,所以建立连接时不管采用几次握手,最后一次握手的可靠性都是不能保证的。
既然连接的建立都不是百分之百成功的,因此建立连接时具体采用几次握手的依据,实际是看几次握手时的优点更多。
首先我们来看一次握手,以客户端向服务端发起连接请求为例,一次握手就表明客户端只向服务端发起建立连接的请求,服务端并不会进行响应,也就说明客户端一旦发出建立连接的请求就已经认为自己连接成功了,就会继续进行下一次连接,最终就会导致服务端被连接请求挤满,这种现象就叫做“SYN洪流”。显然,一次握手并不可取。
同样,两次握手也是,尽管第一次握手会发生响应,但是第二次握手并没有响应,而且此时的异常连接是挂在服务端的,服务端就需要进行维护,因为我们可能是多个服务器之间进行通信,所以服务端的维护就需要大量的成本。这也是不行的。
而我们的三次握手正好解决了上面的问题:
- 进行第二次握手时,就已经证明了双方的通信通道是联通的,当客户端发出第三次握手以后,此时这个连接已经在客户端建立了;
- 但是只有当服务器收到客户端发来的响应以后才会去建立对应的连接;
- 所以三次握手中尽管最后一次握手丢包了,连接的异常是客户端去进行维护的,并不是服务端进行维护的,客户端的异常连接并不会很多,所需要的维护成本是远小于服务端的。
同时,三次握手也验证了TCP是全双工通信:
- 从客户端来看,当他收到服务器的第二次握手时,就说明自己发出的第一次握手已经被服务器收到了,证明了自己能发,服务器能收;同时,自己接收到的服务器的第二次握手,也就证明了自己能收,服务器能发,也就证明自己和客户端是能发能收的。
- 从服务端来看,当自己收到客户端发出第一次握手时,就证明了自己可以收到并且服务器可以发送,当自己收到客户端的第三次握手时,就证明了自己能发,客户端能收,也证明了自己和客户端是能发能收的。
三次握手就保证连接建立时的异常连接挂在客户端,同时也验证了TCP是全双工通信的,是验证双方通信信道的最小次数,所以我们可以用三次握手来解决的事情,也就没必要进行对此验证了。
三次握手时的状态变化
- 最开始服务端和客户端都处于CLOSED状态;
- 服务端为了接收到客户端发出的连接请求,需要由CLOSED状态转为LISTEN状态;
- 此时客户端与服务端就可以三次握手了,第一次握手时客户端就会变为SYN_SENT状态;
- 此时处于LISTEN状态的服务器在接受到客户端发来的连接请求后,将该连接放入等待队列中,此时向客户端发起第二次握手,服务端的状态就变为SYN_RCVD状态;
- 当客户端收到服务端发来的第二次握手以后,接着就会向服务端发起第三次握手,此时客户端连接已经建立,状态变为ESTABLISHED。
- 而服务器收到客户端发来的最后一次握手后,连接也建立成功,此时服务器的状态也变成ESTABLISHED。
套接字和三次握手之间的关系
- 在客户端对服务端发起请求之前,服务端会进入LISTEN状态,此时调用的就是listen函数;
- 当客户端向服务端发起连接时,就会三次握手,此时客户端调用的就是connect函数;
- 注意,connect并不会参与三次握手,它的作用只是发起三次握手,当connect函数返回时,要么是底层已经成功完成了三次握手连接建立成功,要么是底层三次握手失败。
- 如果服务器端与客户端成功完成了三次握手,此时在服务器端就会建立一个连接,但这个连接在内核的等待队列当中,服务器端需要通过调用accept函数将这个建立好的连接获取上来。
- 当服务器端将建立好的连接获取上来后,双方就可以通过调用read/recv函数和write/send函数进行数据交互了。
四次挥手
四次挥手的过程
当服务端与客户端完成通信以后,就会结束通信,结束通信就会四次挥手。
- 第一次挥手:客户端请求与服务端断开连接,此时客户端向服务端发送报文中的FIN就被设置为1;
- 第二次挥手:服务端收到客户端发来的断开连接请求后,对客户端进行响应;
- 第三次挥手:服务端收到客户端发来的断开连接请求以后,并且没有数据要发送给客户端,服务端就会向客户端发起断开连接的请求;
- 第四次挥手:客户端收到服务端发来的断开连接请求以后,客户端会对其进行响应。
四次挥手结束后连接方可断开成功。
为什么是四次挥手?
- 因为TCP是全双工通信的,所以建立连接的过程是双方都建立连接,断开连接的过程也是双方都断开连接。因此断开连接不仅要断开从客户端到服务端的通信通道,还要断开从服务端到客户端之间的通信通道,其中每两次挥手对应就是关闭一个方向的通信信道,因此断开连接时需要进行四次挥手。
- 需要注意的是第二次挥手和第三次挥手并不能合并,第三次挥手是服务端想要与客户端断开连接而发起的请求,在此之前,当服务端收到客户端断开连接的请求并响应以后,并不会立马第三次挥手,因为服务端可能还有某些数据要发送给客户端,只有当服务器端将这些数据发送完后才会向客户端发起第三次挥手。
四次挥手时的状态变化
- 客户端与服务端主动断开连接,向服务器发送断开连接的请求,此时客户端的状态变为FIN_WAIT_1;
- 服务端接收到客户端的断开连接请求并对其进行响应,此时服务端的状态变为CLOSED_WAIT;
- 当服务器没有数据发送给客户端时,服务器会向客户端发起断开连接的请求,等待最后一个ACK的到来,此时服务器的状态变为LASE_ACK;
- 客户端收到服务端的断开连接请求以后,会对其进行响应,此时客户端的状态为TIME_WAIT;
- 当服务器收到最后一个响应报文以后,就会彻底关闭,状态变为CLOSED;
- 而客户端则会等待一个2MSL(Maximum Segment Lifetime,报文最大生存时间)才会进入CLOSED状态。
套接字和四次挥手之间的关系
- 客户端发起断开连接的请求,客户端就会调用close函数;
- 服务端发起断开连接的请求,服务端也会调用close函数;
- 调用一次close函数对应两次挥手,调用两次对应四次挥手;
CLOSE_WAIT
- 双方在进行四次挥手过程中,如果只有客户端调用了close函数,而服务端并没有调用close函数,此时客户端就会进入FIN_WAIT_2状态,服务端进入CLOSED状态;
- 只有完成了四次挥手以后双方才算真正断开连接,如果服务器此时并没有调用close函数,就会导致出现大量处于CLOSE_WAIT状态的连接,此时就会导致服务器可用的资源越来越少;
- 因此如果不及时关闭不用的文件描述符,除了会造成文件描述符泄漏以外,可能也会导致连接资源没有完全释放,这其实也是一种内存泄漏的问题。
- 因此在编写网络套接字代码时,如果发现服务器端存在大量处于CLOSE_WAIT状态的连接,此时就可以检查一下是不是服务器没有及时调用close函数关闭对应的文件描述符。
TIME_WAIT
虽然四次挥手已经完成,但是主动断开连接的一方会维持一段时间的TIME_WAIT状态,在该状态下,其实连接已经释放,但是ip,port依然被占用。
TIME_WAIT状态存在的必要性:
- 客户端在第四次挥手后进入TIME_WAIT状态,如果此时第四次挥手过程中报文丢包了,客户端在一段时间内依然可以接收到服务器发来的FIN并对其进行响应,而且大概率保证最后一个ACK被服务器收到;
- 客户端发出最后一次挥手时,双方历史通信的数据可能还没有发送到对方。因此客户端四次挥手后进入TIME_WAIT状态,还可以保证双方通信信道上的数据在网络中尽可能的消散。
TIME_WAIT的等待时长是多少?
TIME_WAIT的等待时长既不能太长也不能太短。
- 太长会让等待方维持一个较长的时间的TIME_WAIT状态,在这个时间内等待方也需要花费成本来维护这个连接,这也是一种浪费资源的现象。
- 太短可能没有达到我们最初目的,没有保证ACK被对方较大概率收到,也没有保证数据在网络中消散,此时TIME_WAIT的意义也就没有了。
- TCP协议规定,主动关闭连接的一方在四次挥手后要处于TIME_WAIT状态,等待两个MSL(Maximum Segment Lifetime,报文最大生存时间)的时间才能进入CLOSED状态。
TIME_WAIT的等待时长设置为两个MSL的原因:
- MSL是TCP报文的最大生存时间,因此TIME_WAIT状态持续存在2MSL的话,就能保证在两个传输方向上的尚未被接收或迟到的报文段都已经消失。
- 同时也是在理论上保证最后一个报文可靠到达的时间。
解决TIME_WAIT状态引起的bind失败的方法
- 服务器需要处理非常大量的客户端的连接(每个连接的生存时间可能很短,但是每秒都有很大数量的客户端来请求)。
- 这个时候如果由服务器端主动关闭连接(比如某些客户端不活跃,就需要被服务器端主动清理掉),就会产生大量TIME_WAIT连接。
- 由于我们的请求量很大, 就可能导致TIME_WAIT的连接数很多, 每个连接都会占用一个通信五元组(源ip,源端口,目的ip,目的端口,协议)。其中服务器的ip和端口和协议是固定的。如果新来的客户端连接的ip和 端口号和TIME_WAIT占用的链接重复了,就会出现问题。
使用setsockopt()
设置socket
描述符的选项SO_REUSEADDR
为1, 表示允许创建端口号相同但IP地址不同的多个socket描述符;
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
确认应答机制(ACK)
确认应答机制就是由TCP报头当中的,32位序号和32位确认序号来保证的。确认应答机制不是保证双方通信的全部消息的可靠性,而是通过收到对方的应答消息,来保证自己曾经发送给对方的某一条消息被对方可靠的收到了。
如何理解缓冲区?
TCP是面向字节流的,我们可以将TCP的发送缓冲区和接收缓冲区都想象成一个字符数组。
- 此时上层应用拷贝到TCP发送缓冲区当中的每一个字节数据天然有了一个序号,这个序号就是字符数组的下标,只不过这个下标不是从0开始的,而是从1开始往后递增的。
- 而双方在通信时,本质就是将自己发送缓冲区当中的数据拷贝到对方的接收缓冲区当中。
- 发送方发送数据时报头当中所填的序号,实际就是发送的若干字节数据当中,首个字节数据在发送缓冲区当中对应的下标。
- 接收方接收到数据进行响应时,响应报头当中的确认序号实际就是,接收缓冲区中接收到的最后一个有效数据的下一个位置所对应的下标。
- 当发送方收到接收方的响应后,就可以从下标为确认序号的位置继续进行发送了。
超时空重传机制
双方在进行网络通信时,发送方发出去的数据在一个特定的事件间隔内如果得不到对方的应答,此时发送方就会进行数据重发,这就是TCP的超时重传机制。
需要注意的是,TCP保证双方通信的可靠性,一部分是通过TCP的协议报头体现出来的,还有一部分是通过实现TCP的代码逻辑体现出来的。
比如超时重传机制实际就是发送方在发送数据后开启了一个定时器,若是在这个时间内没有收到刚才发送数据的确认应答报文,则会对该报文进行重传,这就是通过TCP的代码逻辑实现的,而在TCP报头当中是体现不出来的。
丢包两种情况
丢包的一种情况就是发送端的数据报文丢失了,发送端在一定时间内收不到对应的响应报文,会进行超时空重传。
丢包的另一种情况是对方发过来的响应报文丢失了,此时发送端也会因为收不到对应的响应报文,而进行超时重传。
- 当出现丢包时,发送端无法判断是发送的数据报文丢包了还是对方发来的响应报文丢包了,这两种情况下发送方都收不到对方发来的响应报文,此时发送方就只能进行超时重传。
- 如果是对方的响应报文丢包了,此时发送方就会在发送一个重复的报文数据,接收方会在接收一个重复的报文数据,但是并不用担心,因为接收方会根据32位序号进行去重;
- 当发送缓冲区当中的数据被发送出去后,操作系统不会立即将该数据从发送缓冲区当中删除或覆盖,而会让其保留在发送缓冲区当中,以免需要进行超时重传,直到收到该数据的响应报文后,发送缓冲区中的这部分数据才可以被删除或覆盖。
超时空重传时间如何设置?
超时空重传时间时间不能设置的太长也不能设置的太短:
- 超时重传的时间设置的太长,会导致丢包后对方长时间收不到对应的数据,进而影响整体重传的效率。
- 超时重传的时间设置的太短,会导致对方收到大量的重复报文,可能对方发送的响应报文还在网络中传输而并没有丢包,但此时发送方就开始进行数据重传了,并且发送大量重复报文会也是对网络资源的浪费。
因此超时重传的时间一定要是合理的,最理想的情况就是找到一个最小的时间,保证“确认应答一定能在这个时间内返回”。但这个时间的长短,是与网络环境有关的。网好的时候重传的时间可以设置的短一点,网卡的时候重传的时间可以设置的长一点,也就是说超时重传设置的等待时间一定是上下浮动的,因此这个时间不可能是固定的某个值。
TCP为了保证无论在任何环境下都能有比较高性能的通信,因此会动态计算这个最大超时时间。
- Linux中(BSD Unix和Windows也是如此),超时以500ms为一个单位进行控制,每次判定超时重发的超时时间都是500ms的整数倍。
- 如果重发一次之后,仍然得不到应答,下一次重传的等待时间就是2 × 500 ms。
如果仍然得不到应答,那么下一次重传的等待时间就是4 × 500 ms。以此类推,以指数的形式递增。 - 当累计到一定的重传次数后,TCP就会认为是网络或对端主机出现了异常,进而强转关闭连接。
流量控制
TCP支持根据接收端的接收数据的能力来决定发送端发送数据的速度,这个机制叫做流量控制(Flow Control)。
接收端处理数据的速度是有限的,如果发送端发的太快,导致接收端的缓冲区被打满,此时发送端继续发送数据,就会造成丢包,进而引起丢包重传等一系列连锁反应。
因此接收端可以将自己接收数据的能力告知发送端,让发送端控制自己发送数据的速度。
- 接收端将自己可以接收的缓冲区大小放入TCP首部中的“窗口大小”字段,通过ACK通知发送端。
- 窗口大小字段越大,说明网络的吞吐量越高。
- 接收端一旦发现自己的缓冲区快满了,就会将窗口大小设置成一个更小的值通知给发送端。
- 发送端接收到这个窗口之后,就会减慢自己发送的速度。
- 如果接收端缓冲区满了,就会将窗口值设置为0,这时发送方不再发送数据,但需要定期发送一个窗口探测数据段,使接收端把窗口大小告诉发送端。
当发送端得知接收端接收数据的能力为0时会停止发送数据,此时发送端会通过以下两种方式来得知何时可以继续发送数据。
- 等待告知。接收端上层将接收缓冲区当中的数据读走后,接收端向发送端发送一个TCP报文,主动将自己的窗口大小告知发送端,发送端得知接收端的接收缓冲区有空间后就可以继续发送数据了。
- 主动询问。发送端每隔一段时间向接收端发送报文,该报文不携带有效数据,只是为了询问发送端的窗口大小,直到接收端的接收缓冲区有空间后发送端就可以继续发送数据了。
16为数字最大表示65535,那TCP窗口最大就是65535吗?
理论上确实是这样的,但实际上TCP报头当中40字节的选项字段中包含了一个窗口扩大因子M,实际窗口大小是窗口字段的值左移M位得到的。
第一次向对方发送数据时如何得知对方的窗口大小?
双方在进行TCP通信之前需要先进行三次握手建立连接,而双方在握手时除了验证双方通信信道是否通畅以外,还进行了其他信息的交互,其中就包括告知对方自己的接收能力,因此在双方还没有正式开始通信之前就已经知道了对方接收数据能力,所以双方在发送数据时是不会出现缓冲区溢出的问题的。
滑动窗口
连续发送多个数据
双方在进行TCP通信时可以一次向对方发送多条数据,这样可以将等待多个响应的时间重叠起来,进而提高数据通信的效率。
虽然双方在进行TCP通信时可以一次向对方发送大量的报文,但不能将自己发送缓冲区当中的数据全部打包发送给对端,在发送数据时还要考虑对方的接收能力。
发送方可以一次发送多个报文给对方,此时也就意味着发送出去的这部分报文当中有相当一部分数据是暂时没有收到应答的。
发送缓冲区当中的数据分为三部分:
- 已经发送并且已经收到ACK的数据。
- 已经发送还但没有收到ACK的数据。
- 还没有发送的数据。
这里发送缓冲区的第二部分就叫做滑动窗口。
滑动窗口描述的就是,发送方不用等待ACK一次所能发送的数据最大量。
滑动窗口存在的最大意义就是可以提高发送数据的效率:
- 滑动窗口的大小等于对方窗口大小与自身拥塞窗口大小的较小值,因为发送数据时不仅要考虑对方的接收能力,还要考虑当前网络的状况。
- 我们假设对方的窗口大小一直是4000,表明不需要等待ACK可以发送的数据大小为4000字节,因此滑动窗口大小也就为4000字节;
- 现在连续发送1001-2000、2001-3000、3001-4000、4001-5000这四个段的时候,不需要等待任何ACK,可以直接进行发送。
- 当收到对方的相应序号2001后,说明1001-2001这个数据段已经被对方收到了,此时该数据段就被纳入发送缓冲区的第一部分(已经发送并且已经收到ACK的数据),此时滑动窗口向右移动,继续发送5001-6000字段,以此内推;
- 滑动窗口越大,则网络的吞吐率越高,同时也说明对方的接收能力很强。
当发送方发送出去的数据段陆陆续续收到对应的ACK时,就可以将收到ACK的数据段归置到滑动窗口的左侧,并根据当前滑动窗口的大小来决定,是否需要将滑动窗口右侧的数据归置到滑动窗口当中。
TCP的重传机制要求暂时保存发出但未收到确认的数据,而这部分数据实际就位于滑动窗口当中,只有滑动窗口左侧的数据才是可以被覆盖或删除的,因为这部分数据才是发送并被对方可靠的收到了,所以滑动窗口除了限定不收到ACK而可以直接发送的数据之外,滑动窗口也可以支持TCP的重传机制。
滑动窗口一定会整体右移吗?
滑动窗口不一定是整体右移的,比如收到2001这个ACK以后,就表明1001-2001这段数据已经被对方收到,但是对方上层一直不从接收缓冲区中读取数据,此时滑动窗口的大小就由4000变为了3000。
当发送端收到响应ACK2001以后,就会将1001-2000这个数据段置为滑动窗口左侧,但是由于此时对方的接收能力已经变为了3000,1001-2000这个数据段置为滑动窗口左侧以后窗口大小刚好为3000,此时滑动窗口就不能继续右移了。
如何实现滑动窗口
TCP接收和发送缓冲区都看作一个字符数组,而滑动窗口实际就可以看作是两个指针限定的一个范围,比如我们用start指向滑动窗口的左侧,end指向的是滑动窗口的右侧,此时在start和end区间范围内的就可以叫做滑动窗口。
当发送方收到对方响应时,我们假设确认序号为x,窗口大小为win,此时start就变为x,end就变为了start+win。
丢包问题
发送端一次发送多个报文数据时,此时的丢包情况也可以分为两种。
- 数据包已经抵达,ACK丢包。
上面这种部分确认ACK丢失并没多大问题,我们可以通过后序的ACK来进行确认,比如1-1001,2001-3000这两个数据段对应ACK丢失了,但是发送方最终接收到3001-4001这个数据段的响应,此时发送端就会认为4001之前的数据段都收到了,确认序号4001就表示1-4000的字节数据都收到了,下一次应该从序号4001开始发送数据。
- 数据包丢包。
- 当1001-2000的数据包丢失后,发送端会一直收到确认序号为1001的响应报文,就是在提醒发送端“下一次应该从序号为1001的字节数据开始发送”。
- 如果发送端连续收到三次确认序号为1001的响应报文,此时就会将1001-2000的数据包重新进行发送。
- 此时当接收端收到1001-2000的数据包后,就会直接发送确认序号为4001的响应报文,因为2001-4000的数据接收端其实在之前就已经收到了。
这种机制被称为“高速重发控制”,也叫做“快重传”。
需要注意的是,快重传需要在大量的数据重传和个别的数据重传之间做平衡,实际这个例子当中发送端并不知道是1001-2000这个数据包丢了,当发送端重复收到确认序号为1001的响应报文时,理论上发送端应该将1001-4000的数据全部进行重传,但这样可能会导致大量数据被重复传送,所以发送端可以尝试先把1001-2000的数据包进行重发,然后根据重发后的得到的确认序号继续决定是否需要重发其它数据包。
滑动窗口中的数据一定都没有被对方收到吗?
滑动窗口的数据并都是对方没有收到的数据,因为可能某一个数据段在传输的过程中出现丢包了,但是仅仅只有这个数据段的数据对端并没有收到,其他数据段的数据已经收到了,比如滑动窗口最左端出现了丢包,后面的数据都接收到了,但是并没有响应而已。
例如1001-2000数据段在传输过程中丢包了,此时2001-5000的数据虽然对方收到了,但是并没有响应,发送方接收到的确认序号依然是1001,当发送端对1001-2000数据段进行重发以后,确认序号就变为了5001,此时缓冲区当中1001-5000的数据就会被立马移动至滑动窗口左侧。
快重传 VS 超时重传
- 当发送端连续三次接收到相同的应答就会触发快重传,而超时空重传需要通过设置重传定时器,在固定的时间后才会进行重传。
- 虽然快重传能快速判断数据报丢失,但是有可能数据报丢失以后发送端并没有收到三次应答,此时就不会触发快重传机制,还是需要超时空重传机制来实现。
- 因此快重传虽然是一个效率上的提升,但超时重传却是所有重传机制的保底策略,也是必不可少的。
拥塞控制
为什么会有拥塞控制?
双方进行通信的过程中,出现少量丢包的情况是允许的,此时触发快重传机制和超时空重传机制即可,但是如果出现大量丢包的情况,这种情况就不正常了。
TCP进行通信的过程,不仅仅考虑了双端主机的问题,还考虑了网络的问题。
- 流量控制,考虑的是对端接收缓冲区的接收能力,进而在控制发送缓冲区的发送速度,避免对端缓冲区溢出;
- 滑动窗口,考虑的是发送端不用等待ACK一次所能发送的数据最大量,进而提高发送端发送数据的效率。
- 拥塞窗口:考虑的是双方通信时网络的问题,如果发送的数据超过了拥塞窗口的大小就可能会引起网络拥塞。
如果出现大量的丢包情况,此时TCP就不会再推测是否是双方发送或者是接收数据出了问题,而是判断双方通信网络出现了拥塞问题。
如何解决网络拥塞问题?
当网络出现大面积瘫痪时,影响到的并不是一两台主机,几乎大部分主机都会被影响到,同样,造成网络大面积瘫痪,也不是一两台主机可以做到的,是大部分主机共同作用的结果。
- 如果在某一时刻,存在大量主机向网络中发送数据,由于网络的接收能力也是有限的,此时就会造成大量的报文堵塞住,就造成了无法在规定时间内到达对端主机,也就造成了丢包问题。
- 当网络出现拥塞问题时,通信双方虽然不能提出特别有效的解决方案,但双方主机可以做到不加重网络的负担。
- 双方通信时如果出现大量丢包,不应该立即将这些报文进行重传,而应该少发数据甚至不发数据,等待网络状况恢复后双方再慢慢恢复数据的传输速率。
拥塞控制
虽然滑动窗口能够高效可靠的发送大量的数据,但如果在刚开始阶段就发送大量的数据,就可能会引发某些问题。因为网络上有很多的计算机,有可能当前的网络状态就已经比较拥塞了,因此在不清楚当前网络状态的情况下,贸然发送大量的数据,就可能会引起网络拥塞问题。
因此TCP引入了慢启动机制,在刚开始通信时先发少量的数据探探路,摸清当前的网络拥堵状态,再决定按照多大的速度传输数据。
- TCP除了有窗口大小和滑动窗口的概念以外,还有一个窗口叫做拥塞窗口。拥塞窗口是可能引起网络拥塞的阈值,如果一次发送的数据超过了拥塞窗口的大小就可能会引起网络拥塞。
- 刚开始发送数据的时候拥塞窗口大小定义以为1,每收到一个ACK应答拥塞窗口的值就加一。
- 每次发送数据包的时候,将拥塞窗口和接收端主机反馈的窗口大小做比较,取较小的值作为实际发送数据的窗口大小,即滑动窗口的大小。
我们会发现,拥塞窗口是以指数级方式增长的,原因就是慢启动实际上只是初始的时候比较慢,我们是为了探测网络拥塞的阈值,当网络拥塞恢复以后,我们就需要尽快的恢复通信的过程,所以就需要加快速度。
但是我们又不能一直以指数方式进行增长,可能又会导致网络拥塞:
- 为了避免短时间内再次导致网络拥塞,因此不能一直让拥塞窗口按指数级的方式进行增长。
- 此时就引入了慢启动的阈值,当拥塞窗口的大小超过这个阈值时,就不再按指数的方式增长,而按线性的方式增长。
- 当TCP刚开始启动的时候,慢启动阈值设置为对方窗口大小的最大值。
- 在每次超时重发的时候,慢启动阈值会变成当前拥塞窗口的一半,同时拥塞窗口的值被重新置为1,如此循环下去。
延迟应答
如果接收数据的主机收到数据后立即进行ACK应答,此时返回的窗口可能比较小。
- 假设对方接收端缓冲区剩余空间大小为1M,对方一次收到500K的数据后,如果立即进行ACK应答,此时返回的窗口就是500K。
- 但实际接收端处理数据的速度很快,10ms之内就将接收缓冲区中500K的数据消费掉了。
- 在这种情况下,接收端处理还远没有达到自己的极限,即使窗口再放大一些,也能处理过来。
- 如果接收端稍微等一会再进行ACK应答,比如等待200ms再应答,那么这时返回的窗口大小就是1M。
窗口越大,网络吞吐量就越大,传输效率就越高。我们的目标是在保证网络不拥塞的情况下尽量提高传输效率,延迟应答的目的不是为了保证可靠性,而是留出一点时间让接收缓冲区中的数据尽可能被上层应用层消费掉,此时在进行ACK响应的时候报告的窗口大小就可以更大,从而增大网络吞吐量,进而提高数据的传输效率。
此外,不是所有的数据包都可以延迟应答。
- 数量限制:每个N个包就应答一次。
- 时间限制:超过最大延迟时间就应答一次(这个时间不会导致误超时重传)。
延迟应答具体的数量和超时时间,依操作系统不同也有差异,一般N取2,超时时间取200ms。
捎带应答
捎带应答其实是TCP通信时最常规的一种方式,就好比主机A给主机B发送了一条消息,当主机B收到这条消息后需要对其进行ACK应答,但如果主机B此时正好也要给主机A发生消息,此时这个ACK就可以搭顺风车,而不用单独发送一个ACK应答,此时主机B发送的这个报文既发送了数据,又完成了对收到数据的响应,这就叫做捎带应答。
捎带应答最直观的角度实际也是发送数据的效率,此时双方通信时就可以不用再发送单纯的确认报文了。
此外,由于捎带应答的报文携带了有效数据,因此对方收到该报文后会对其进行响应,当收到这个响应报文时不仅能够确保发送的数据被对方可靠的收到了,同时也能确保捎带的ACK应答也被对方可靠的收到了。
面向字节流
当创建一个TCP的socket时,同时在内核中会创建一个发送缓冲区和一个接收缓冲区。
- 调用write函数就可以将数据写入发送缓冲区中,此时write函数就可以进行返回了,接下来发送缓冲区当中的数据就是由TCP自行进行发送的。
- 如果发送的字节数太长,TCP会将其拆分成多个数据包发出。如果发送的字节数太短,TCP可能会先将其留在发送缓冲区当中,等到合适的时机再进行发送。
- 接收数据的时候,数据也是从网卡驱动程序到达内核的接收缓冲区,可以通过调用read函数来读取接收缓冲区当中的数据。
- 而调用read函数读取接收缓冲区中的数据时,也可以按任意字节数进行读取。
由于缓冲区的存在,TCP程序的读和写不需要一一匹配,例如:
- 写100个字节数据时,可以调用一次write写100字节,也可以调用100次write,每次写一个字节。
- 读100个字节数据时,也完全不需要考虑写的时候是怎么写的,既可以一次read100个字节,也可以一次read一个字节,重复100次。
实际对于TCP来说,它并不关心发送缓冲区当中的是什么数据,在TCP看来这些只是一个个的字节数据,它的任务就是将这些数据准确无误的发送到对方的接收缓冲区当中就行了,而至于如何解释这些数据完全由上层应用来决定,这就叫做面向字节流。
粘包问题
什么是粘包?
- 首先要明确,粘包问题中的“包”,是指的应用层的数据包。
- 在TCP的协议头中,没有如同UDP一样的“报文长度”这样的字段。
- 站在传输层的角度,TCP是一个一个报文过来的,按照序号排好序放在缓冲区中。
- 但站在应用层的角度,看到的只是一串连续的字节数据。
- 那么应用程序看到了这么一连串的字节数据,就不知道从哪个部分开始到哪个部分,是一个完整的应用层数据包。
如何解决粘包问题
要解决粘包问题,本质就是要明确报文和报文之间的边界。
- 对于定长的包,保证每次都按固定大小读取即可。
- 对于变长的包,可以在报头的位置,约定一个包总长度的字段,从而就知道了包的结束位置。比如HTTP报头当中就包含Content-Length属性,表示正文的长度。
- 对于变长的包,还可以在包和包之间使用明确的分隔符。因为应用层协议是程序员自己来定的,只要保证分隔符不和正文冲突即可。
UDP是否存在粘包问题?
UDP是不存在粘包问题的,根本原因就是UDP报头当中的16位UDP长度记录的UDP报文的长度,因此UDP在底层的时候就把报文和报文之间的边界明确了,而TCP存在粘包问题就是因为TCP是面向字节流的,TCP报文之间没有明确的边界。
TCP异常情况
进程终止
当一个进程退出,该进程打开的文件描述符会自动关闭,也就是说,客户端进程退出以后,就会调用close函数关闭对应的文件描述符,双方在底层也就会完成四次挥手,断开双方链接,也就是说,进程终止和进程正常退出没什么区别。
机器重启
当我们选择重启主机时,操作系统会先杀掉所有进程然后再进行关机重启,因此机器重启和进程终止的情况是一样的,此时双方操作系统也会正常完成四次挥手,然后释放对应的连接资源。
机器掉电/网线断开
当客户端掉线后,服务器端在短时间内无法知道客户端掉线了,因此在服务器端会维持与客户端建立的连接,但这个连接也不会一直维持,因为TCP是有保活策略的。
- 服务器会定期客户端客户端的存在状况,检查对方是否在线,如果连续多次都没有收到ACK应答,此时服务器就会关闭这条连接。
- 此外,客户端也可能会定期向服务器发消息,如果服务器长时间没有收到客户端的消息,此时服务器也会将对应的连接关闭。
其中服务器定期询问客户端的存在状态的做法,叫做基于保活定时器的一种心跳机制,是由TCP实现的。此外,应用层的某些协议,也有一些类似的检测机制,例如基于长连接的HTTP,也会定期检测对方的存在状态。
TCP小结
TCP协议这么复杂就是因为TCP既要保证可靠性,同时又尽可能的提高性能。
可靠性:
- 检验和。
- 序列号。
- 确认应答。
- 超时重传。
- 连接管理。
- 流量控制。
- 拥塞控制。
提高性能:
- 滑动窗口。
- 快速重传。
- 延迟应答。
- 捎带应答。
需要注意的是,TCP的这些机制有些能够通过TCP报头体现出来的,但还有一些是通过代码逻辑体现出来的。
基于TCP应用层协议
- HTTP
- HTTPS
- SSH
- Telnet
- FTP
- SMTP
当然, 也包括你自己写TCP程序时自定义的应用层协议
TCP/UDP对比
我们说了TCP是可靠连接, 那么是不是TCP一定就优于UDP呢? TCP和UDP之间的优点和缺点, 不能简单,绝对的进行比较
- TCP用于可靠传输的情况,应用于文件传输,重要状态更新等场景;
- UDP用于对高速传输和实时性要求较高的通信领域,例如,早期的QQ,视频传输等。另外UDP可以用于广播;
归根结底,TCP和UDP都是程序员的工具,什么时机用,具体怎么用,还是要根据具体的需求场景去判定。
用UDP实现可靠传输
参考TCP的可靠性机制, 在应用层实现类似的逻辑。
例如:
- 引入序列号,保证数据顺序;
- 引入确认应答,确保对端收到了数据;
- 引入超时重传,如果隔一段时间没有应答,就重发数据;
- …
理解listen的第二个参数
我们在前面学习套接字的过程中,对于listen的第二个参数并没有详细的讲解,今天我们就来重新认识一下它,我们以一段代码进行测试:
Sock.hpp
#pragma once
#include <iostream>
#include <string>
#include <cstring>
#include <cerrno>
#include <cassert>
#include <unistd.h>
#include <memory>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <ctype.h>
#include "log.hpp"
class Sock
{
private:
const static int gbacklog = 1;
public:
Sock()
{
}
int Socket()
{
int listensock = socket(AF_INET, SOCK_STREAM, 0);
if (listensock < 0)
{
logMessage(ERROR, "create socket error:%d:%s", errno, strerror(errno));
exit(0);
}
logMessage(NORMAL, "create socket success, listensock:%d", listensock);
int opt = 1;
setsockopt(listensock, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));
return listensock;
}
void Bind(int sock, uint16_t port, std::string ip = "0.0.0.0")
{
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(port);
inet_pton(AF_INET, ip.c_str(), &local.sin_addr);
if (bind(sock, (struct sockaddr *)&local, sizeof(local)) < 0)
{
logMessage(ERROR, "bind error:%d:%s", errno, strerror(errno));
exit(1);
}
}
void Listen(int sock)
{
if (listen(sock, gbacklog) < 0)
{
logMessage(ERROR, "listen error:%d:%s", errno, strerror(errno));
exit(2);
}
logMessage(NORMAL, "init server success...");
}
int Accept(int listensock, uint16_t *port, std::string *ip)
{
struct sockaddr_in src;
socklen_t len = sizeof(src);
int servicesock = accept(listensock, (struct sockaddr *)&src, &len);
if (servicesock < 0)
{
logMessage(ERROR, "accept error:%d:%s", errno, strerror);
exit(3);
}
if (port)
*port = htons(src.sin_port);
if (ip)
*ip = inet_ntoa(src.sin_addr);
return servicesock;
}
bool Connect(int sock, const uint16_t &server_port, const std::string &server_ip)
{
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
socklen_t len = sizeof(server);
server.sin_family = AF_INET;
server.sin_port = htons(server_port);
server.sin_addr.s_addr = inet_addr(server_ip.c_str());
if (connect(sock, (struct sockaddr *)&server, len) == 0)
return true;
else
return false;
}
~Sock()
{
}
};
main.cc
#include <iostream>
#include "Sock.hpp"
int main()
{
Sock sock;
int listensock = sock.Socket();
sock.Bind(listensock, 8080);
sock.Listen(listensock);
while (true)
{
sleep(1);
}
return 0;
}
我们将listen第二个参数设置为1,运行程序,调用netstat命令,我们可以发现,启动 2个客户端同时连接服务器,查看服务器状态,一切正常。但是启动第3个客户端时, 发现服务器对于第3个连接的状态存在问题了,此时变为了SYN_RECV状态。
客户端状态正常,但是服务器端出现了 SYN_RECV 状态,而不是 ESTABLISHED 状态。
这是因为,Linux内核协议栈为一个tcp连接管理使用两个队列:
- 半链接队列(用来保存处于SYN_SENT和SYN_RECV状态的请求);
- 全连接队列(accpetd队列)(用来保存处于established状态,但是应用层没有调用accept取走的请求)。
而全连接队列的长度会受到 listen 第二个参数的影响,全连接队列满了的时候, 就无法继续让当前连接的状态进入 established 状态了。这个队列的长度通过上述实验可知,是 listen 的第二个参数 + 1。
服务器本身就会维护连接队列,这个连接队列不能没有也不能太长,他就和listen的第二个函数存在关系。