🌈前言
这篇文章给大家带来传输层中TCP协议学习!!!
🌸1、基本概念
-
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字节,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;
}
测试
键盘按上Ctrl C意味着关闭前台进程,给进程发送信号退出,意味着自动close了套接字
- Server:./test 8080
- telnet:telnet 127.0.0.1 8080
总结
-
对于服务器上出现大量的 CLOSE_WAIT 状态, 原因就是服务器没有正确的关闭 socket套接字, 导致四次挥手没有正确完成
-
这是一个 BUG. 只需要加上对应的 close函数 即可解决问题
✨3.3.4、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;
}
🍫3.4、滑动窗口
前言:
-
使用TCP进行网络数据传输时,每个数据段都要有一个ACK确认应答(确认应答机制),收到对端ACK应答后发送端再发送下一个数据段
-
这样做有一个比较大的缺点,就是性能较差,尤其是数据往返的时间较长的时候
解决方案
-
我们可以一次连续发送多条数据段,就可以大大的提高性能(其实是将多个段的等待时间重叠在一起了)
-
这样一来对方收到数据后就会连续发送ACK应答报文,这样大大的缩短了等待ACK应答报文的时间
-
如果还是原来的方法,那么发一个数据又要等待应答,这样太费时间了。连续发连续应答直接提高了效率
滑动窗口概念
-
滑动窗口的原理就是维护一个窗口,“窗口的大小”就是无需等待确认应答而可以继续发送数据的最大值
-
滑动窗口有二个指针一个指向窗口起始位置,另外一个指向尾部。如果ACK了一个报文,那么窗口的起始位置移动到应答报文中确认序号字段中的值的下标位置
-
尾部指针也要进行移动,需要移动原位置的值加上应答报文中部确认序号的值
-
上图的窗口大小就是4000个字节,每段只能发送1000个字节,分为四段
-
发送前四个数据段的时候,不需要等待任何ACK,直接发送
-
收到第一个ACK后,滑动窗口向后移动。继续发送第五个段的数据;依次类推
-
操作系统内核为了维护这个滑动窗口,需要开辟发送缓冲区来记录当前还有哪些数据没有应答;
-
只有确认应答过的数据,才能从缓冲区删掉,删除数据其实是移动起始窗口就行了,滑动窗口是一个环形队列,后续会覆盖原来的应答的数据段
-
窗口越大,则网络的吞吐率(传输数据的大小)就越高
那么如果出现了丢包,如何进行重传? 这里分两种情况讨论:
情况一:数据包已送达,ACK丢包
-
这种情况下,部分ACK丢了并不要紧,因为可以通过后续的ACK进行确认
-
ACK丢了不要紧,数据包丢了才是大问题!
情况二:数据包丢包
-
当某一段报文段丢失之后,发送端会一直收到 1001 这样的ACK,就像是在提醒发送端"我想要的是 1001"一样
-
如果发送端主机连续三次收到了同样一个 “1001” 这样的应答ACK,就会将对应的数据 1001 - 2000 重新发送
-
这个时候接收端收到了 1001 之后,再次返回的ACK就是7001了(因为2001 - 7000)接收端其实之前就已经收到了,被放到了接收端操作系统内核的接收缓冲区中
-
这种机制被称为 “高速重发控制”(也叫 “快重传”)
🍬3.5、流量控制
前言
-
接收缓冲区的接收能力是有限的,如果发送的太快,导致接收缓冲区被打满,这个时候继续发送的数据就会被丢弃,造成丢包
-
如果发送的数据太少,会导致效率低下,导致来回发送和确认的次数变多
概念
-
TCP支持根据接收端的处理能力,来决定发送端的发送速度. 这个机制就叫做流量控制(Flow Control)
-
接收端将自己可以接收的缓冲区大小放入 TCP 首部中的 “窗口大小” 字段,通过ACK应答通知发送端
-
窗口大小字段越大,说明网络的吞吐量越高(传送数据的数量)
-
接收端一旦发现自己的缓冲区快满了,就会将窗口大小设置成一个更小的值通知给发送端
-
发送端接收到这个窗口大小之后,就会根据窗口大小减慢自己的发送速度
-
如果接收端缓冲区满了,就会将窗口置为0;这时发送方不再发送数据,但是需要定期发送一个窗口探测数据段,使接收端把窗口大小告诉发送端
TCP首部中窗口大小只有16位,意味着缓冲区的最大值为65535字节吗???
- 实际上,TCP首部40字节选项字段中还包含了一个窗口扩大因子M,实际窗口大小是窗口字段的值左移 M 位
🍫3.6、拥塞控制
前言
-
前面的部分策略全都是用来解决一端到另一端数据的可靠性、效率的问题
-
但忽略了数据传输过程中要经过网络的问题,因为网络上有很多计算机(包括主机和路由器等)在发送数据或转发数据
-
如果当前的网络状态比较拥堵,但是主机在不清楚网络的情况下,贸然的发送大量的数据,那么网络的压力会越来越大,导致数据转发困难,丢包率变高
概念
-
TCP引入“慢启动机制”,先发少量的数据,探探路,摸清当前的网络拥堵状态,再决定按照多大的速度传输数据
-
下图是一个“拥塞窗口”,它规定了TCP一次只能发多少数据,分为多少段进行发送
-
发送开始的时候,定义拥塞窗口大小为1,每次收到一个ACK应答,拥塞窗口加1
-
每次发送数据包的时候,将拥塞窗口和接收端主机反馈的窗口大小做比较,取较小的值作为实际发送的窗口
慢启动
-
上面这样的拥塞窗口增长速度,是指数级别的 “慢启动” 只是指初始时慢,但是增长速度非常快
-
为了不增长的那么快,因此不能使拥塞窗口单纯的加倍
-
此处引入一个叫做慢启动的阈值
-
当拥塞窗口超过这个阈值的时候,不再按照指数方式增长,而是按照线性方式增长
-
下图中拥塞窗口一开始是指数的方式增长到16(阈值)随后线性增长到24,发现还是数据大量丢包没有应答,说明网络还是很拥塞,随即重置拥塞窗口为1,设置新的阈值为12
总结
-
少量的丢包,我们仅仅是触发超时重传;大量的丢包,我们就认为网络拥塞
-
当TCP通信开始后,网络吞吐量会逐渐上升;随着网络发生拥堵,吞吐量会立刻下降
-
拥塞控制,归根结底是TCP协议想尽可能快的把数据传输给对方,但是又要避免给网络造成太大压力的折中方案
🍭3.7、延迟应答
前言
-
TCP通过滑动窗口可以连续给对端发送数据,对端收到后就会立即应答
-
如果接收数据的主机立刻返回ACK应答,这时候返回的窗口可能比较小
-
返回的窗口比较小,意味着滑动窗口一次性发的数据变少了,导致效率低下
概念
- 延迟应答就是对端收到连续的报文后,等待特定的时间(不超过超时重传的时间)再进行ACK应答,这样上层尽可能读取多个报文,让应答的窗口大小变大
- 假设接收端缓冲区为1M. 一次收到了500K的数据; 如果立刻应答, 返回的窗口就是500K
- 但实际上可能处理端处理的速度很快, 10ms之内就把500K数据从缓冲区消费掉了
- 在这种情况下, 接收端处理还远没有达到自己的极限, 即使窗口再放大一些, 也能处理过来
- 如果接收端稍微等一会再应答, 比如等待200ms再应答, 那么这个时候返回的窗口就是1M
- 窗口越大,网络吞吐量就越大,传输效率就越高,我们的目标是在保证网络不拥塞的情况下尽量提高传输效率
限制和取值大小
那么所有的包都可以延迟应答么? 肯定也不是
-
数量限制:每隔N个包就应答一次
-
时间限制:超过最大延迟时间就应答一次
-
具体的数量和延时时间,依操作系统不同也有差异;一般N取2,超时时间取200ms
🍮3.8、捎带应答
概念
-
捎带应答:发送端发送数据给对端,对端应答时携带回复的数据(有效载荷)
-
在延迟应答的基础上,我们发现, 很多情况下,客户端服务器在应用层也是 “一发一收” 的
-
意味着客户端给服务器说了 “Hi”,服务器也会给客户端回一个 “Hello”
-
那么这个时候ACK就可以搭顺风车,和服务器回应的 “Hello” 一起回给客户端
-
TCP是全双工的,既可以收消息也可以携带数据回复消息
🍀4、面向字节流
概念
首先我们有客户端和服务器,创建套接字,建立好连接后,进行数据交互
-
调用write时,数据会先写入内核中的发送缓冲区中
-
如果发送的字节数太长,会被拆分成多个TCP的数据包发出(由拥塞窗口和窗口大小决定)
-
如果发送的字节数太短,就会先在缓冲区里等待,等到缓冲区长度差不多了,或者其他合适的时机发送出去
-
接收数据的时候,数据也是从网卡驱动程序到达内核的接收缓冲区
-
然后应用程序可以调用read从接收缓冲区拿数据
-
另一方面,TCP的一个连接,既有发送缓冲区,也有接收缓冲区,那么对于这一个连接,既可以读数据,也可以写数据,这个概念叫做“全双工”
由于缓冲区的存在,TCP程序的读和写不需要一一匹配
-
写100个字节数据时,可以调用一次write写100个字节,也可以调用100次write,每次写一个字节
-
读100个字节数据时,也完全不需要考虑写的时候是怎么写的,既可以一次read 100个字节,也可以一次read一个字节,重复100次
-
TCP面向字节流,虽然不用关心任何的数据格式,但是要正确的使用数据,必须在应用层规定特定的格式
-
这个格式的意思就是序列化和定制协议,为了保证应用层能正确的读取到有效载荷,必须进行序列化和定义各种协议
-
TCP的报头中之所以没有表示整个报文大小的字段,是因为它是面向字节流的,但是UDP面向数据报,里面会包含这个字段,数据报是一次性发送和一次性读取的
🍁5、粘包问题
前言
-
粘包问题中的包,指的是应用层的数据包
-
在TCP的协议报头中,没有如同UDP一样的 “报文长度” 这样的字段,但是TCP有序号可以对报文进行排序
-
站在传输层的角度,TCP是一个一个报文过来的,按照序号排好序放在缓冲区中
-
站在应用层的角度,看到的只是一串连续的字节数据
-
因为TCP没有“报文长度”字段,所以无法计算出数据的大小,应用程序看到了一连串的字节数据,就不知道从哪个部分开始到哪个部分是一个完整的数据包
-
粘包问题就是:应用层读取缓冲区的数据时,可能读多,也可能读少,TCP没有报文长度字段,上层读取数据的大小是自定义的
那么如何避免粘包问题呢?
-
明确两个包之间的边界
-
对于定长的包,保证每次都按固定大小读取即可;比如直接传输结构化数据,它是固定大小的,那么只要从缓冲区从头开始按sizeof(结构体)依次读取大小即可
-
对于变长的包,可以在包头的位置,约定一个包总长度的字段, 从而就知道了包的结束位置
-
对于变长的包,还可以在包和包之间使用明确的分隔符(应用层协议,是程序猿自己来定的,只要保证分隔符不和正文冲突即可)
-
总结:避免粘包问题很简单,只要明确各个包之间的边界,进行序列化和定制协议即可
对于UDP协议来说, 是否也存在 “粘包问题” 呢?
-
对于UDP,如果还没有上层交付数据,UDP的报文长度仍然在,同时, UDP是一个个把数据交付给应用层,就有很明确的数据边界
-
站在应用层的角度,使用UDP的时候,要么收到完整的UDP报文,要么不收,不会出现"半个"的情况
🍂6、TCP总结
为什么TCP这么复杂? 因为要保证可靠性,同时又尽可能的提高性能
可靠性
- 校验和
- 序列号(按序到达)
- 确认应答
- 超时重发
- 连接管理
- 流量控制
- 拥塞控制
提高性能
- 滑动窗口
- 快速重传
- 延迟应答
- 捎带应答
其他
- 定时器(超时重传定时器, 保活定时器, TIME_WAIT定时器等)
基于TCP的应用层协议
- HTTP
- HTTPS
- SSH
- Telnet
- FTP
- SMTP
- 当然,也包括你自己写TCP程序时自定义的应用层协议
TCP/UDP对比
我们说了TCP是可靠连接,那么是不是TCP一定就优于UDP呢? 答:看需求和场景
-
TCP用于可靠传输的情况,应用于文件传输、支付转账,重要状态更新等场景
-
UDP用于对高速传输和实时性要求较高的通信领域,例如直播、视频传输等,另外UDP可以用于广播
-
归根结底,TCP和UDP都是程序员的工具。什么时机用。具体怎么用。还是要根据具体的需求场景去判定
用UDP实现可靠传输(经典面试题)
参考TCP的可靠性机制, 在应用层实现类似的逻辑
-
引入序列号,保证数据顺序
-
引入确认应答,确保对端收到了数据
-
引入超时重传,如果隔一段时间没有应答,就重发数据
-
引入流量控制,确保发送的数据不会过多和过少
-
…等等