目录
- 🌈前言
- 🌸1、基本概念
- 🌺2、TCP协议报文结构
- 🍨2.1、源端口号和目的端口号
- 🍩2.2、4位首部长度
- 🍪2.3、32位序号和确认序号(重点)
- 🍫2.4、16位窗口大小
- 🍬2.5、常见的6位标记位
- ✨2.5.1、SYN和FIN标记位(三次握手和四次挥手)
- ✱2.5.2、ACK标记位(确认标记位)
- ✴2.5.3、PSH标记位(数据推送标记位)
- ✵2.5.4、URG标记位(紧急指针标记位)和16位紧急指针
- 🍺2.6、三次握手和RST标记位
- ✶2.5.5、RST标记位(复位标记位)
- 🌺3、TCP机制
- 🍨3.1、确认应答机制
- 🍩3.2、超时重传机制
- 🍪3.3、连接管理机制
- ✨3.3.1、四次挥手
- ✨3.3.2、连接管理机制状态的变化
- ✨3.3.3、CLOSE_WAIT状态
- ✨3.3.3、TIME_WAIT状态
🌈前言
这篇文章给大家带来传输层中TCP协议学习!!!
🌸1、基本概念
[TCP协议 – 百度百科]
-
TCP是隶属于传输层的协议,它的主要功能是实现是让应用程序之间可以相互通信
-
TCP全称为 “传输控制协议(Transmission Control Protocol”)主要对数据的传输进行一个详细的控制
-
TCP协议是一个可靠的(确认应答机制、超时重传等等)、面向连接的(通信前先建立连接)、基于字节流(流式IO)进行网络数据传输的网络通信协议
-
TCP在传输过程中可以正确的处理丢包、数据包乱序的异常状况;还能有效的利用宽带,缓解网络拥堵
🌺2、TCP协议报文结构
TCP协议是如何封包的呢?
-
封包的本质是:将TCP报头对象拷贝到应用层协议报文的前面即可,这样就完成了封包了
-
后续添加新的报头时,只要将缓冲区的指针移动到TCP头部然后加上新报头长度的大小,最后填充就行了
TCP是如何解包的呢?
-
解包的本质是:将TCP报头和TCP选项在数据包中去除即可
-
TCP报头中有一个"4位首部长度"字段,它标识了这个报头的长度(报头 + 选项),只要拿到首部长度然后根据首部长度去掉选项,剩下的就是有效载荷了
-
有效载荷包含了上层应用所需传输的数据,比如HTTP请求或响应内容
TCP协议是如何进行分用的呢?
-
TCP报头的前32位属性字段名是跟UDP一样,都是源端口号和目的端口号
-
分用的本质是:通过TCP报头里面的目的端口号字段找到应用层的具体协议,并且传递给上层应用程序进行处理
-
注意:这里没有说IP地址,因为IP地址是用来找网络中唯一主机的,现在已经找到了,通过目的端口号就能标定主机中唯一的进程了
下图为TCP报文的组成结构,里面包含了不同的属性字段:
🍨2.1、源端口号和目的端口号
-
源/目的端口号: 表示数据是从哪个进程来,到哪个进程去
-
16位源端口号:标识发送端主机上进行网络通信的某个进程(具有唯一性)
-
16位目的端口号:标识接收端主机上进行网络通信的某个进程(具有唯一性)
🍩2.2、4位首部长度
概念
-
TCP报头的标准长度是20个字节(不包括选项字段长度)
-
那么我们如何确定选项的大小呢? 答案就是:4位首部长度
-
4位首部长度:表示该TCP头部有多少个32位bit(4字节);4位的的取值范围是[0, 15](0000 - 1111)可以得出TCP报头最多有15 * 4 = 60个字节的数据
-
TCP报头标准长度为20字节,所以4位首部长度的值至少为5(0101)
-
我们解包时,就是通过四位首部长度来提取选项字段的!!!
🍪2.3、32位序号和确认序号(重点)
TCP可靠性问题
-
什么是不可靠:网络数据传输过程中导致丢包、数据包乱序、校验失败等问题
-
什么是可靠性:网络传输过程中不会出现丢包、乱序、校验失败等异常问题,确保数据包完整的到达对端
-
那么TCP是如何解决丢包问题的呢? 怎么确定一个报文是丢了还是没丢呢?
确认应答机制
-
TCP中的32位序号和32位确认序号就是为了防止丢包准备的
-
确认应答机制:数据在网络传输过程中没有发生丢包,完整的被对端收到,并且得到对端的应答
-
确认应答机制不能保证双方的应答,因为这是不可能的,永远只有一条消息是没有应答的,只能保证单向的应答
32位序号和确认序号
首先建立一个共识:TCP进行通信时,发出去的报文一定携带TCP报头,哪怕不携带数据
-
TCP是如何实现确认应答机制的呢? 答案是通过序号和确认序号实现的
-
实现原理:发送端给对方发送消息时会携带序号字段,接收端收到后回复消息会更新确认序号,回复给发送端后,发送端根据自己的序号和对方的确认序号可以判断数据是否应答(发送端到接收端的数据)
-
通俗的话说就是:网络传输时,发送端发送的数据,根据自身的序号和对方回复的确认序号可以确保单端的数据是否应答
-
比如:发送端要发送100个字节数据,携带的序号是1,接收端收到数据后,会更新自己的确认序号为101,回复给发送端后,发送端看到确认序号比自己的序号大于100,表示没有丢包
为什么TCP报文中需要二个不同的序号来完成确认应答机制呢?⭐⭐⭐⭐⭐
-
按照上面的说法,一个也能实现确认应答机制,为什么出现了二个不同的序号
-
我们都知道TCP在通信时是全双工的,意味着双方都能够进行发消息和收消息
假设如下图:
- 如果发送端发送消息,接收端应答消息并且发送新的消息呢???
总结:
-
只有单个序号只能保证一端到另外一端的数据应答
-
如果对端应答并且携带了新的消息,那么就不能保证对端到发送端的应答了
-
可以得出:发送端序号和对端确认序号可以保证发送端到对端数据的应答;对端序号和发送端确认序号可以保证对端到发送端的应答
-
对端给发送端发送消息,也要更新响应报文的序号,发送端收到报文更新确认序号然后回复给对端,对端比较自己的序号和发送端序号是否符合就能判断是否应答
🍫2.4、16位窗口大小
发送缓冲区和接收缓冲区
首先建立一个共识:这里的TCP协议发送缓冲区和接收缓冲区都是在内核中定义的
-
TCP需要保证报文的可靠性,需要发送缓冲区做各种可靠性的策略,而UDP不需要保证数据完整到达,所以没有发送缓冲区
-
缓冲区本质是一段连续的内存,它可以集中的处理数据刷新,减少I/O次数,从而达到提高整机的效率~!
-
发送缓冲区:当我们在应用层调用write、send系统函数向套接字写入数据时,进程会从用户态变为内核态,并且会将数据拷贝到内核的发送缓冲区中(用户的数据拷贝到内核中)
-
接收缓冲区:当我们在应用层调用read、recv系统函数向套接字接收数据时,进程会从用户态变为内核态,并且将内核中接收缓冲区的数据拷贝到用户所设置的buffer中存储着(内核数据拷贝到用户中)
-
数据被拷贝到内核的缓冲区中,就归OS管了(OS实现了传输层和网络层),用户不会再过问,什么时候传输是根据指定传输层协议来确定的
-
注意:write、send、read、recv都是面向字节流的,他们本身是不携带缓冲区的函数,但是它们向指定的套接字写入或接收数据,函数会根据传输层不同的协议放到缓冲区或从缓冲区中读取数据
16位窗口大小
-
缓冲区是有大小的,如果发送的数据太快或数据太大超过缓冲区大小,都会导致数据被丢弃(丢包),那么我们就要有个字段来获取对端的缓冲区接收能力(剩余空间大小)
-
16位窗口大小:用于填充缓冲区剩余空间大小的属性字段;可以让发送端智能的根据对端的接收能力来动态的调整发送的速度或数据的大小
-
如果服务器给客户端发报文,那么只能由服务器填充自己的窗口大小,对方就知道自己的缓冲区接收能力了
-
流量控制:不管是服务器给客户端发信息还是客户端给服务器发信息,只要有窗口大小存在,就能解决发送数据太快或太大,导致对端缓冲区已经接收不了数据还一直发的问题!!!
🍬2.5、常见的6位标记位
概念
-
我们都知道TCP协议在通信前要建立连接(三次握手)后才能正常通信,通信完后要进行断开连接(四次挥手)
-
服务器每次要处理那么多报文(连接报文、通信报文、断开连接报文等),需要对报文进行类别的(根据不同类型的报文用不同的逻辑处理它)
-
6位标记位:就是来标记报文类型的!!!
✨2.5.1、SYN和FIN标记位(三次握手和四次挥手)
概念
-
SYN:只要是建立连接请求的报文,SYN就要被设置为1;携带SYN标记位的报文称为同步报文段(连接请求报文)
-
只要是断开连接请求的报文,FIN就要被设置为1;携带FIN标记位的报文称为结束报文段(断开连接请求报文)
-
SYN和FIN标记位不可能被同时设置为1,因为不可能同时进行连接和断开连接
✱2.5.2、ACK标记位(确认标记位)
概念
-
ACK:确认标记位,表示该报文是对历史报文的确认(根据确认序号来进行确认),表示发送端发送的报文已经被对端收到,应答报文携带ACK标记位
-
历史报文:发送端发送数据给对端,对端应答发送端的报文就是”历史发送的报文“
-
一般在大部分正式通信情况下,ACK都是1
✴2.5.3、PSH标记位(数据推送标记位)
概念
-
PSH:提示接收端应用程序立刻从缓冲区把数据读走
-
应用层中的write、recv系统函数,在读取内核缓冲区时会自动判断是否存在数据,如果没有数据会一直阻塞,反之读取数据到用户设置的缓冲区中
-
TCP缓冲区中有一个接收数据的低水位线(比如有100字节,低水位线为20字节),只要传输的数据超过20字节,就会被上层给读取
假设
假设应用层一直非常的忙,没有时间读取TCP接收缓冲区里面的数据
-
如果TCP接收缓冲区满了,并且应答了一个窗口大小为0的报文,那么发送端只能等待对端读取完数据才能发送
-
如果等待了很久对端还是没有读取数据,那么发送端可以发送一个带有PSH标记位的报文给对端,催促对端应用层赶紧把缓冲区数据读取完
✵2.5.4、URG标记位(紧急指针标记位)和16位紧急指针
前言
-
报文在传输的过程中,是可能乱序到达的,它是不可靠其中的一种行为,TCP必须让我们发的报文按序到达
-
如果数据必须在TCP中进行按序到达,那么如果有一部分TCP报文优先级更高(PSH报文),但是序号比较晚,就无法做到报文被优先紧急处理
-
TCP是根据16位序号来实现按序达到的,因为序号可以确定每个报文中数据发送了多少字节(比如第一个报文发送了100字节,那么第二个报文序号就是101)
概念
-
URG:只要是发送紧急数据,就要把URG标记位置1;URG标志设置为1时,TCP首部紧急指针字段才有效,默认为0时,紧急指针无效
-
紧急指针:该字段中保存着一个正的偏移量,通过这个偏移量和序号相加,可以找到数据(有效载荷)中的紧急数据
-
紧急报文发送给对端是不会经过缓冲区保存按序等待读取的,会直接交付上层读取(紧急数据只有一个字节,其余数据要进入接收缓冲区)
🍺2.6、三次握手和RST标记位
概念
Server是服务器,Client是客户端
-
TCP是面向连接的协议,通信前需要建立连接(三次握手)
-
原理:Client向Server发送连接请求报文(携带SYN标记位),Server收到报文后应答连接请求报文(携带SYN+ACK标记位),Client收到报文后创建连接对象并且发送应答报文(携带ACK),Server收到应答报文也创建连接对象,到此,就完成了三次握手
-
只要最后一次握手Server端收到Client的应答报文,Server就会创建连接对象,表明建立连接成功了!!!
注意
-
TCP是保证数据完整的被对端收到,但是三次握手不一定会成功
-
第三次握手时Client发送的应答报文可能会丢包,没有被Server收到,Client已经处于连接成功状态,但是Server还未完成连接
原理
-
Client与Server建立好连接后,Server需要对连接进行管理,因为如果来了成千上万个连接,Server就会分不清谁是谁了
-
管理的本质就是先描述(连接的属性结构体),再组织(高效数据结构进行增删查改)
-
可以得出双方建立连接,需要花费时间和内存的,特别是Server还要管理连接对象,而Client只需创建好连接对象就行了
为什么要进行三次握手呢?一次二次不行吗?
一次握手
-
一次握手:一次握手是完全不行的,因为极其容易受到服务器攻击(SYN泛洪攻击),因为一次握手只要Client给我Server发送一个连接请求报文就能完成连接的建立了
-
但是如果Client发送完连接请求报文后就不管了,也不创建连接对象,但是Server会创建连接对象并且管理起来
-
如果Client循环式的发送SYN报文,那么Server的内存就会一下子被填满了,最后发生崩溃(创建连接对象要消耗内存)
二次握手
-
二次握手:跟一次握手一样不行,因为二次握手是由Server端最后应答连接报文给Client(意味着要先创建连接对象)
-
如果Client无视Server发送的应答连接报文或者直接丢弃,那么Server端维护的连接对象也就没有意义了,白白浪费资源
-
如果Client还是循环的发送SYN报文,那么服务器内存也会被一下子填满,导致崩溃
三次握手
-
前面的一、二次握手都不行,是因为Server端先认为自己建立连接成功(创建连接对象并且管理),只要Client不建立连接或忽略应答连接报文,那么Server就浪费资源了
-
三次握手:第三次握手由Server来结束握手,意味只要Server收到Client的应答报文,Server才会认为自己连接成功,但是Client在之前就认为自己建立连接成功了
-
好处就是Client如果想对Server进行攻击,循环发送连接报文,那么Client会先建立连接,而Server最后建立连接,双方都消耗了OS的内存资源
-
三次握手在Server收到ACK报文之前,都维持着一个半连接的方式(只有Client认为自己连接成功),只要Server收到ACK报文,那么就完成了一个完整的连接
✶2.5.5、RST标记位(复位标记位)
概念
-
RST:对方要求重新建立连接;携带RST标识的称为复位报文段
-
作用:发送带有RST标记位的报文,表示叫对方关闭连接,并且进行重新建立连接
-
用途:双方在建立连接失败或出现严重丢包等等时,就会给对端发送带有RST标记位的报文,表明要求重新建立连接或连接复位
假设
-
如果Client在第三次握手中发送的ACK报文没有被Server收到,但是Client认为自己建立连接成功了,但是Server认为还未建立连接
-
Client认为自己建立连接成功,向Server发送数据报文,Server收到报文后,想着自己还没有建立连接成功,说明最后一次ACK报文丢包了
-
Server会给Client发送的数据报文进行应答,并且把应答报文中的RST标记位也置为1,表示要求Client断开当前连接,并且重新建立连接
🌺3、TCP机制
🍨3.1、确认应答机制
概念
-
确认应答机制:主机A向主机B发送数据,主机B给主机A应答并且设置ACK标记位为1
-
ACK标记位设置为1的意思是对历史接收的报文的应答
序号问题
-
缓冲区是一段线性的内存,发送端将发送的数据拷贝到缓冲区中,也就意味着可以像数组一样用下标来进行访问
-
TCP每次发送报文都会设置序号(数据从哪里开始发送,也就是从哪个下标开始发送),每一个ACK应答报文都有对应的确认序号(已经接收到了哪些数据,[序号, 确认序号]),发送端会根据对端应答报文中确认序号来判断下一次从哪里开始发送
-
如下图所示:一开始序号为1(从第一个字节开始发送),对端收到了1k字节,应答报文中确认序号更新1001(收到了1k字节,1000+1就是后续发送端从1001下标继续发送的位置)。发送端看到应答报文中确认序号为1001,就知道从下标为1001的数据开始再次发送
🍩3.2、超时重传机制
概念
-
主机A发生数据给主机B,在网络传输过程中可能因为网络拥堵等原因,数据无法到达主机B,说明数据丢包了
-
如果主机A在特定的时间间隔内没有收到主机B的应答ACK报文,就会进行超时重发报文
如果主机B收到了报文,但是发送的应答ACK报文丢包了怎么办?
-
因此得出主机B会收到很多重复数据,那么TCP协议需要能够识别出哪些包是重复的包,并且把重复的丢弃掉
-
这时候我们可以利用前面提到的序列号(判断序号下标是否出现冗余),就可以很容易做到去重的效果
超时重传时间如何确定?
-
最理想的情况下,找到一个最小的时间,保证 “确认应答报文一定能在这个时间内返回”
-
但是这个时间的长短,随着网络环境和状态的不同,是有差异的
-
如果超时时间设的太长,会影响整体的重传效率
-
如果超时时间设的太短,有可能会频繁发送重复的包
解决方案
-
TCP为了保证无论在任何环境下都能比较高性能的通信,将动态计算这个最大超时时间
-
Linux中(BSD Unix和Windows也是如此),超时以500ms为一个单位进行控制,每次判定超时重发的超时时间都是500ms的整数倍
-
如果重发一次之后,仍然得不到应答,等待 2*500ms 后再进行重传
-
如果仍然得不到应答,等待 4*500ms 进行重传. 依次类推,以指数形式递增(2N * 500ms)
-
累计到一定的重传次数,TCP认为网络或者对端主机出现异常,强制关闭连接
🍪3.3、连接管理机制
在正常情况下,TCP要经过三次握手建立连接,四次挥手断开连接
✨3.3.1、四次挥手
概念
注意图中的时间轴走向,图中Client先提出断开连接,却是在Server断开之后才断开的,反之Server先提出的也是一样的
-
TCP首先要进行三次握手建立连接,随后正常数据通信,双方断开连接时要进行四次挥手
-
四次挥手:不管是Client还是Server,都要与对方断开连接(双方都要调用close),也就是Client要跟Server断开连接,Server也要跟Client断开连接
-
比如:我和女朋友分手了,是女方先提出的,我同意了. 但是我还没提出跟她分手,我还可以一直骚扰她(发消息),直到我给她提出分手,女方也同意后,双方才真正的分手了!!!
原理
CLOSED状态就是断开连接状态
-
Client向Server发送断开连接报文并且将状态设置为FIN_WAIT_1. Server收到报文,向Client发送应答报文并且将状态设置为CLOSE_WAIT. Client收到报文将状态由FIN_WAIT_1设置为FIN_WAIT_2
-
Server向Client发送FIN报文,状态从CLOSE_WAIT变成LAST_ACK. Client收到报文并且应答,从FIN_WAIT_2状态变成TIME_WAIT(等待一段时间变成CLOSED状态),Server收到应答后,由LAST_ACK状态变成CLOSED
✨3.3.2、连接管理机制状态的变化
服务端状态的变化(三次握手)
-
[CLOSED, LISTEN]:服务器端调用listen系统函数后进入LISTEN状态(监听Client连接请求状态),等待客户端连接
-
[LISTEN, SYN_RCVD]:一旦监听到Client连接请求(同步报文段 – SYN),就将该连接放入内核等待队列中,并向客户端发送SYN确认报文
-
[SYN_RCVD, ESTABUSHED]:服务端收到客户端的确认报文,就进入ESTABLISHED状态,可以进行读写数据了
服务端状态的变化(四次挥手)
-
[ESTABLISHED, CLOSE_WAIT]:当客户端主动关闭连接(调用close),服务器会收到结束报文段(FIN),服务器返回确认报文段并进入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)
-
[SYN_SENT, ESTABLISHED]:connect调用成功,则进入ESTABLISHED状态, 开始正常读写数据
客户端状态的变化(四次挥手)
-
[ESTABLISHEDFIN, WAIT_1]:客户端主动调用close时,向服务器发送结束报文段,同时进入FIN_WAIT_1状态
-
[FIN_WAIT_1, FIN_WAIT_2]:客户端收到服务器对结束报文段的确认ACK报文,则进入FIN_WAIT_2,开始等待服务器的结束报文段(FIN)
-
[FIN_WAIT_2, TIME_WAIT]:客户端收到服务器发来的结束报文段,进入TIME_WAIT状态,并发出LAST_ACK(最后的确认应答报文)
-
[TIME_WAIT, CLOSED]:客户端要等待一个2MSL(Max Segment Life,报文最大生存时间)的时间,才会进入CLOSED状态
✨3.3.3、CLOSE_WAIT状态
概念
-
CLOSE_WAIT状态:先发出断开连接报文(FIN)的一端先调用了close,对端收到了FIN报文,但是自己没有真正的调用close,所以会一直处于CLOSE_WAIT状态,但会给先断开的一端进行ACK应答,让他进入FIN_WAIT_2状态
-
只要调用了close函数,关闭套接字,就会进入下一个LAST_ACK状态
使用Server基本通信程序 和 telnet指令测试
#include <iostream>
#include <string>
#include <cerrno>
#include <cstdlib>
#include <cstring>
#include <cassert>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
using std::cout;
using std::endl;
class TcpServer
{
public:
TcpServer(uint16_t port, std::string ip = "")
: _listensockfd(-1), _ip(ip), _port(port)
{
}
~TcpServer()
{
if (_listensockfd > 2)
close(_listensockfd);
}
void InitInetData()
{
assert(_port > 1025);
_listensockfd = socket(AF_INET, SOCK_STREAM, 0);
if (_listensockfd < 0)
exit(1);
sockaddr_in serverData;
socklen_t len = sizeof(serverData);
memset(&serverData, 0, len);
serverData.sin_family = PF_INET;
serverData.sin_port = htons(_port);
serverData.sin_addr.s_addr = _ip.empty() ? (INADDR_ANY) : inet_addr(_ip.c_str());
if (bind(_listensockfd, (const sockaddr *)&serverData, len) < 0)
exit(3);
if (listen(_listensockfd, 2) < 0)
exit(4);
}
void StartServer()
{
while (true)
{
int ServerSockfd = accept(_listensockfd, nullptr, nullptr);
if (ServerSockfd < 0)
exit(5);
}
}
private:
int _listensockfd;
uint16_t _port;
std::string _ip;
};
int main(int argc, char *argv[])
{
if (argc < 2)
{
std::cout << "Format: ./可执行程序 [ip] port" << std::endl;
exit(8);
}
std::string ip;
uint16_t port;
if (argc == 3)
{
ip = argv[1];
port = std::stoi(argv[2]);
}
else
{
port = std::stoi(argv[1]);
}
TcpServer tps(port, ip);
tps.InitInetData();
tps.StartServer();
return 0;
}
测试
- Server:./test 8080
- telnet:telnet 127.0.0.1 8080
总结
-
对于服务器上出现大量的 CLOSE_WAIT 状态, 原因就是服务器没有正确的关闭 socket套接字, 导致四次挥手没有正确完成
-
这是一个 BUG. 只需要加上对应的 close函数 即可解决问题
✨3.3.3、TIME_WAIT状态
模拟TIME_WAIT状态,Client先关闭,随后关闭Server,代码不变还是上面的
模拟TIME_WAIT状态,Server先关闭,随后关闭Client,代码不变还是上面的
大家应该都遇到过先关闭服务器,然后关闭客户端,服务器再重启启动就会bind出错,这是为什么呢???
-
现在做一个测试,首先启动server,然后启动client,然后用Ctrl-C使server终止,然后用Ctrl-C使Client终止,这时马上重新运行server
-
运行结果是:bind函数出错
#include <iostream>
#include <string>
#include <cerrno>
#include <cstdlib>
#include <cstring>
#include <cassert>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
using std::cout;
using std::endl;
class TcpServer
{
public:
TcpServer(uint16_t port, std::string ip = "")
: _listensockfd(-1), _ip(ip), _port(port)
{
}
~TcpServer()
{
if (_listensockfd > 2)
close(_listensockfd);
}
void InitInetData()
{
assert(_port > 1025);
_listensockfd = socket(AF_INET, SOCK_STREAM, 0);
if (_listensockfd < 0)
exit(1);
sockaddr_in serverData;
socklen_t len = sizeof(serverData);
memset(&serverData, 0, len);
serverData.sin_family = PF_INET;
serverData.sin_port = htons(_port);
serverData.sin_addr.s_addr = _ip.empty() ? (INADDR_ANY) : inet_addr(_ip.c_str());
if (bind(_listensockfd, (const sockaddr *)&serverData, len) < 0)
{
std::cout << "bind error: " << strerror(errno) << std::endl;
exit(3);
}
if (listen(_listensockfd, 2) < 0)
exit(4);
}
void StartServer()
{
while (true)
{
int ServerSockfd = accept(_listensockfd, nullptr, nullptr);
if (ServerSockfd < 0)
exit(5);
}
}
private:
int _listensockfd;
uint16_t _port;
std::string _ip;
};
int main(int argc, char *argv[])
{
if (argc < 2)
{
std::cout << "Format: ./可执行程序 [ip] port" << std::endl;
exit(8);
}
std::string ip;
uint16_t port;
if (argc == 3)
{
ip = argv[1];
port = std::stoi(argv[2]);
}
else
{
port = std::stoi(argv[1]);
}
TcpServer tps(port, ip);
tps.InitInetData();
tps.StartServer();
return 0;
}
原理解析
-
虽然server的应用程序终止了,但TCP协议层的连接并没有完全断开,因此不能再次监听同样的server端口
-
上图运行结果中使用netstat指令可以看到Server还处于TIME_WAIT未关闭连接的状态
-
TCP协议规定,主动关闭连接的一方要处于TIME_ WAIT状态,等待两个MSL(maximum segment lifetime)的时间后才能回到CLOSED状态
-
MSL在RFC1122中规定为两分钟,但是各操作系统的实现不同,在Centos7上默认配置的值是60s
-
可以通过 cat /proc/sys/net/ipv4/tcp_fin_timeout 查看MSL的值
[root@Linux_Study]$ cat /proc/sys/net/ipv4/tcp_fin_timeout
60
为什么是TIME_WAIT的时间是2MSL?
-
服务器需要处理非常大量的客户端的连接,每个连接的生存时间可能很短,但是每秒都有很大数量的客户端来请求
-
这个时候如果由服务器端主动关闭连接(比如某些客户端不活跃, 就需要被服务器端主动清理掉),就会产生大量TIME_WAIT连接
-
由于我们的请求量很大,就可能导致TIME_WAIT的连接数很多,每个连接都会占用一个通信五元组(源ip、源端口、目的ip、目的端口、协议)
-
其中服务器的ip和端口和协议是固定的,如果新来的客户端连接的ip和端口号和TIME_WAIT占用的链接重复了,就会出现问题
解决方案
- 使用setsockopt()设置socket描述符的选项SO_REUSEADDR为1,表示允许创建端口号相同但IP地址不同的多个socket描述符
#include <sys/types.h>
#include <sys/socket.h>
int setsockopt(int sockfd, int level, int optname,const void *optval, socklen_t optlen);
-
sockfd:套接字的文件描述符(socket的返回值)
-
level:选项定义的层次;支持SOL_SOCKET、IPPROTO_TCP、IPPROTO_IP和IPPROTO_IPV6
-
optname:需要设置的选项,选项有SO_REUSEADDR 和 SO_REUSEPORT,一般设置第一个即可
-
optval:指针,指向存放选项待设置的新值的缓冲区
-
optlen:optval缓冲区长度
-
作用:主要用来禁止OS的判断和算法,比如服务器处于TIME_WAIT状态,重新启动就要判断状态
#include <iostream>
#include <string>
#include <cerrno>
#include <cstdlib>
#include <cstring>
#include <cassert>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
using std::cout;
using std::endl;
class TcpServer
{
public:
TcpServer(uint16_t port, std::string ip = "")
: _listensockfd(-1), _ip(ip), _port(port)
{
}
~TcpServer()
{
if (_listensockfd > 2)
close(_listensockfd);
}
void InitInetData()
{
assert(_port > 1025);
_listensockfd = socket(AF_INET, SOCK_STREAM, 0);
if (_listensockfd < 0)
exit(1);
sockaddr_in serverData;
socklen_t len = sizeof(serverData);
memset(&serverData, 0, len);
serverData.sin_family = PF_INET;
serverData.sin_port = htons(_port);
serverData.sin_addr.s_addr = _ip.empty() ? (INADDR_ANY) : inet_addr(_ip.c_str());
int opt = 1;
setsockopt(_listensockfd, SOL_SOCKET, SO_REUSEADDR, (void*)&opt, sizeof(opt));
if (bind(_listensockfd, (const sockaddr *)&serverData, len) < 0)
{
std::cout << "bind error: " << strerror(errno) << std::endl;
exit(3);
}
if (listen(_listensockfd, 2) < 0)
exit(4);
}
void StartServer()
{
while (true)
{
int ServerSockfd = accept(_listensockfd, nullptr, nullptr);
if (ServerSockfd < 0)
exit(5);
}
}
private:
int _listensockfd;
uint16_t _port;
std::string _ip;
};
int main(int argc, char *argv[])
{
if (argc < 2)
{
std::cout << "Format: ./可执行程序 [ip] port" << std::endl;
exit(8);
}
std::string ip;
uint16_t port;
if (argc == 3)
{
ip = argv[1];
port = std::stoi(argv[2]);
}
else
{
port = std::stoi(argv[1]);
}
TcpServer tps(port, ip);
tps.InitInetData();
tps.StartServer();
return 0;
}