前言
学了TCP 和UDP之后,感觉UDP就像是初入职场的年轻人
,两耳不闻 “窗外事”,只管尽力地把自己的事情做好,但收获的却是不可靠
,而TCP更像是涉世极深的"职场老油条"
,给人的感觉就是 “城府极深,深不可测”,不仅事事考虑的周全,懂得人情世故,而且深得"上层" 和 “下层” 的信任,因此大家都说TCP可靠
,TCP 也不禁感慨:“我只需要略微出手,就已知这个分段的极限了
。” “UDP” 的不如意也就有了大家的那句话 “以前的我不屑一顾,现在的我逐帧向 「TCP」学习”,这也就是题目:理解 UDP,成为TCP
的深意。
一、回顾
1.UDP
- 特点:
- 面向数据报,不可靠,无连接。适用于对实时性要求高的应用。
- 可以发数据,也可以接收数据,即全双工。有接收缓存区,没有发送缓存区。
2.TCP
-
特点:
- 面向字节流,可靠,有连接。适合文件和视频等信息的传输。
- 可以发数据,也可以接收数据,即全双工。有接收缓冲区,有发送缓存区。
-
形象记忆
- 三次握手
- 四次挥手
- 三次握手
在以后的协议讲解时,我们都会先讨论以下两点:
- 报头和有效载荷是如何封装和分离的。
- 数据是如何往上进行分用的。
一、UDP
1.协议格式
报头与有效载荷是如何封装和分离的?
通过截取定长的8字节长度,将报头和有效载荷进行分离。
数据是如何分用的?
在截取报头之后,可以再通过分析头部信息,进而获取到目的端口号,而目的端口号可以通过一些哈希的方式,比如端口号映射到进程的pid,进而找到上层的进程
,而对应的进程采用N与UDP相关的应用层协议。因此数据完成到应用层的分用。
内核中关于报头的结构体:
struct udphdr {
//typedef unsigned short __u16;
__u16 source; //源端口
__u16 dest; //目的端口
__u16 len; // 报文长度
__u16 check; // 检验和
};
- 博主采用的是linux-2.6.11.1版本的内核源码。
从编码的角度,我们可以看出其实截取报头之后会放在相应的结构体中,通过使用结构体中的变量完成实际的操作。
UDP检验和是如何检验数据的完整性,即在传输路途中没有被损坏呢?
- 不为人知的伪头部,也叫伪首部。
- 说明:如果报文误传给了UDP所在传输层,
伪首部的目的ip地址是报文实际所在主机的ip,而不是目的主机的地址
。通过校验和可检测出报文所在主机是否等于目的主机。
计算方法:
- 按每16位求和得出一个32位的数
- 如果这个32位的数,高16位不为0,则高16位加低16位再得到一个32位的数;
- 重复第2步直到高16位为0,将低16位取反,得到校验和(32位数取低16位)。
- 说明:所有的数据都是以网络序列进行传输的。那么如果我们想要进行比对,首先要将整个报文里面的数据先转换成主机序列,提取出校验和,然后再将伪首部以及整个UDP报文的其它数据,分成一个一个的unsigned short int,用unsigned int 变量,将这些变量加起来,然后按照上述的步骤进行计算,得出校验和进行比对,进而确保数据的准确与完整性。
- 注意:
- 既然数据肯定能分成一个一个的unsigned short int且为了保证报文完整,方便传输,即一个UDP报文看起来是一个完整的矩形,即协议格式的形状,那么UDP报文的长度必然是4字节的整数倍。
- 这样我们可以提出三点并在之后验证:
- 报文可能是含有填充字段的,这个填充字段一般设置为0;
- 数据校验和计算是包含填充字段,但是伪首部中的UDP报文长度是报头 与 有效载荷的总长度;
- 为了截取出有效载荷,报头中UDP的长度是不含填充字段,即只含UDP报头的大小和有效载荷的大小。
下面我们实验进行验证:
- 下载与简单使用抓包工具:WireShark ,下载可能需要有点科技。
随便截取一个UDP报文:
进行数据分析:
提取数据编写代码:
#include<functional>
#include<iostream>
using namespace std;
int main()
{
//源IP: 339F C766 —— 51.159.199.102(字符串转int,再转16位)方便进行截取16位的2进制数,相当于截取16进制数的4位。
//目的IP:C0A8 1D0F ——192.168.29.15(字符串转int,再转16位)
//填充字段 + 协议号:0x0011 —— 17
//报文长度 :0x001D
//源端口:82D3 —— 33,491
// 目的端口:DFB5 —— 57269
//检验和:0xebd8
//数据 + 填充字段:5f55 ae84 de67 b6a6 fb71 63af b868 4bdf ba13 4d2e cb 00 0000
//按照:伪首部 + UDP头部 + 数据(包括有效数据 和 填充字段)
unsigned short check_arr[22] = { /*源IP*/0x339F,0xC766,/*目的IP*/ 0xC0A8,0x1D0F,/*协议号*/0x0011,/*报文长度*/0x1D,\
/*源端口*/0x82D3,/*目的端口*/0xDFB5,/*报文长度*/0x1D,/*检验和置为0*/0x0000,\
/*数据 == 有效载荷 + 填充字段*/0x5f55, 0xae84, 0xde67, 0xb6a6, 0xfb71, 0x63af, 0xb868,\
0x4bdf, 0xba13, 0x4d2e, 0xcb00,0x0000};
/*说明:实际数据字节后面还要补0,凑成一个unsigned short,后面的只是为了表示完整的UDP报文,实际计算并没有意义。*/
int checksum = 0xebd8;
function<unsigned short int(unsigned short*, int)> check = [&](unsigned short int* arr, int size)
{
int sum = 0;
for (int i = 0; i < size; i++)
{
sum += arr[i];
}
while (sum >> 16)
{
//sum 为低16位 加上 高16位的
sum = (sum >> 16) + (sum & 0xffff);
}
//对sum取反。
return (unsigned short int)(~sum);
};
unsigned short int check_sum = check(check_arr, 22);
if (check_sum == checksum)
{
cout << "验证成功!" << endl;
}
return 0;
}
- 结果:
谈到这里,想必我们已经对UDP的协议格式有了基本的了解。下面我们从缓存区和数据的发送方式来进一步理解UDP
。
2.数据报
从UDP(U
ser D
atagram P
rotocol)名字上,即用户数据报协议,我们可以先简单的理解数据报是面向用户的,往下再说一层就是将用户发来的数据视为一个完整的报文。这里的"完整"
有两层意思。
- 第一层是可以直接将数据原封不动的封装后传给下层,因此就不需要发送缓存区,不过从协议的格式来看,一个UDP报文最多有65535个字节,如果超过了这个最大值而且又没有发送缓存区,这也就导致了要把切分数据的任务交给了应用层,即
"用户数据报"
; - 第二层是报文具有明确的界限,那么发送方一次发多少数据,接收方就一定能收多少数据,因此接收方没必要分次读取一个完整的报文,但是接收方可能没必要立马读取报文,这也就有了接收缓存区,进一步缓存区是有一定的大小限制的,如果发送方的数据,占满了接收方的数据,那么之后发送方再发数据,接收方是收不了的,因此接收方只能丢弃;
进一步,我们可以从UDP,即用户数据报协议中窥探出UDP的本职工作是只负责
——
- 简单的接收下层传来的UDP报文,然后通过校验和,进而检验数据是否完整和准确,完整并且准确传给用户层,不完整或者不准确则丢弃;
- 直接封装用户传来的
合理大小
的数据并往下层传。
那么就会有两种问题:
- 网络的情况不太好,此时数据在网络中可能会丢失,即丢包现象,而且UDP不负责传丢失的数据,而且也因为没有发送缓冲区而重传不了,那么接收方有的数据就会收不到。
- 接收方收到的报文是完整的,但是由于数据在网络中传输,网络的状况是千变万化的,这就可能存在先发的数据报后收到,后发的数据先收到。因此数据报在接收缓冲区会存在乱序的现象,虽然数据报之间是独立的,但是呈现给上层的处理顺序就会发生变化。
- 举个简单的例子:
- 小红和小明开始都有1000块,要去往杭州。小红先走,此时1000块最好只能买一张火车票,于是小红走了,但是当小明隔天再去买票发现,现在飞机票降价了,于是小明买了飞机票也走了。于是最终小红先走,却比小明后到。数据在网络中传输,基本上就是这个道理。
- 假设你玩英雄联盟,如果采取UDP协议,此时你要先按R放大控住,再按Q补伤害。发送给服务端可能就会呈现你要先释放Q,再释放R。于是反应给你的就是Q + R,而不是R + Q,但是Q之后对方就直接闪现了,R都按不出来。UDP这纯纯演员啊,哈哈其实这也不怪UDP,人家UDP不负责管这事。
从现实的角度来看,UDP就像一个只顾自己"一亩三分地"的人,对其它的"人"的事充耳不闻,不通人情世故,这也就怪不得其它层的 “人” 说:“UDP真是干不了大事。” 即「UDP不可靠」。但是UDP把自己的分内事情做的很好,因此也有人说:“UDP已经尽职尽责了!” 即「UDP尽最大可能交付数据」。
3.无连接
下面我们从套接字编程的角度,继续分析。
上述图,只是比较理想的情况,下面我们来谈一谈比较现实的情况:
- Server端都还没有启动,或者在Server端还未运行到recvform。这时客户端发送数据那不是白发了么,即不可靠。对客户端同理。
- 网络很不好,Client端发送数据,此时Server端大概率是收不到数据的。客户端梅开二度,即不可靠。对服务端同理。
回过头再来看,像这种事先不知道双方的运行情况,以及网络状态就直接发送数据,我们称之为无连接
,即UDP不可靠的一种。反之如果要确认双方以及网络状况需要消耗一定时间以及资源为代价,侧面上突出了无连接的一种优点 —— 成本低,不太消耗时间与资源
。
最后我们总结一下UDP:
- 面向用户数据报。协议职责面向用户,只负责
发送来自和接收送往
用户的数据。 - 无连接。请读者从socket编程角度进行理解。
- 半双工。收发消息,有接收缓存区,无发送缓存区。
从以上几个方面,我们列表分析一下UDP的不可靠。
方面 | 不可靠 |
---|---|
面向用户数据报 | 不对网络中的数据进行负责,因此可能存在丢包和乱序的问题。详见上文两种情况 |
无连接 | 双方在发送信息之前,并没有进行协商,因此不知道双方的状态。详见上文的两种情况。 |
半双工 | 接收缓存区满之后,发送方不知道继续发,此后数据会被对方直接丢弃。 |
- 优点:不可靠,意味着更低的成本,更快的传输,更低的延迟。也就被应用于实际的场景中。
如下是采用UDP的一些协议:
协议 | 采取UDP的原因 |
---|---|
NFS(网络文件系统)应用层协议 | 注重低开销和速度。自己采取相应机制保证可靠性 |
TFTP(简单文件传输协议) | 要求轻量和低延迟。自己采取相应机制保证可靠性 |
DHCP(Dynamic Host Configuration Protocol)用于自动分配 IP 地址、子网掩码、网关地址等信息的应用层协议 | 注重的是网络配置信息的快速分配和管理 |
BOOTP(Bootstrap Protocol)是一种用于无盘设备启动的网络层协议 | 注重在网络引导阶段快速获取网络设施,对可靠性要求较低。UDP天然具有广播的优势。 |
DNS(Domain Name System)是用于将域名解析为对应 IP 地址的应用层协议 | 注重效率和低延迟,自身对可靠性要求较低。 |
综合来看,采用UDP的大多数场景要求速度,低延时,效率等,但也不缺乏也要求可靠的,因此有对应要求的相应协议可通过再采取某些可靠的机制进而保证可靠。
TCP这个职场老油条,可是是深不可测,因此博主只负责带领读者认识那么"一丢丢" ,进一步的理解还需要各位日后的继续学习~
二、TCP
1.协议格式
先来经典两问:
TCP报头与有效载荷是如何封装和分离的?
在数据的前面加上TCP报头,即完成有效载荷和TCP报头的封装。
截取报文前定长的二十字节,然后提取出四位头部长度,可表示的范围为[0 , 15],乘4表示实际报头的大小,又因为报头的最小长度为20,即实际表示的范围为[20,60],如果转化出的实际报头大小大于20,将剩余部分也就是选项部分再进行提取,最终完成报头和有效载荷的分离。
数据是如何完成分用的?
与TCP同理,这里就不过多解释了。
说明:校验和的原理也跟UDP大同小异
- 内核中TCP协议字段的结构体:
struct tcphdr
{
__u16 source;//源端口号
__u16 dest;//目的端口号
__u32 seq;//序号
__u32 ack_seq;//发送序号
//根据不同的主机序列,进行条件编译。
#if defined(__LITTLE_ENDIAN_BITFIELD) //小端机
__u16 res1:4, //保留长度,可以看见这里的保留长度可以用于进行扩展。
doff:4,//头部的长度
fin:1,//释放连接
syn:1,//建立连接
rst:1,//重置连接。
psh:1,//推送数据
ack:1,//确认序号是否有效。
urg:1,//紧急数据。
/*相比较上面的保留长度,这是扩展的功能*/
ece:1,//提示网络拥塞。
cwr:1;//表示网络拥塞时,对方已经做出了调整。
#elif defined(__BIG_ENDIAN_BITFIELD) //大端机
__u16 doff:4,
res1:4,
cwr:1,
ece:1,
urg:1,
ack:1,
psh:1,
rst:1,
syn:1,
fin:1;
#else
#error "Adjust your <asm/byteorder.h> defines"
#endif
__u16 window;//窗口大小
__u16 check;//校验和
__u16 urg_ptr;//紧急指针,即偏移量。
};
协议格式剩余的内容,将在下文进行穿插深入讲解。
2.面向字节流
先图解字节流~
分析:
-
全双工,即双方都具有收消息和发消息的能力,TCP比UDP多个发送缓存区。发送缓存区是保证可靠性的基石。
-
发送缓存区使TCP具有存放信息的能力,那么对于网络中丢包的数据,就可以实现重发,进而保证可靠性。
-
发送缓存区使TCP具有了管理用户传来数据的能力,因此TCP可以控制什么时候发,一次发多少,以及应对出错的情况,进一步保证了可靠性,即 “议” 如其名——传输控制协议。
-
UDP相比就显得比较被动,没有发送缓存区,那么就只能一次一次的发送用户传来的完整的数据报,因此没有能力保证可靠性。
-
发送缓冲区使TCP有底气对用户说" 不 " 的能力,即用户你怎么传是你的事,我TCP怎么发是我的事。两者在某种程度上实现了解耦合。
-
那么TCP就可根据现实的情况,对用户传来的数据按字节进行划分,每次发送合适大小的数据,因为还要保持有序,所以还要对划分的数据块进行编号,然后进行数据的传输,如果在传输途中如果有丢包就重传,等所有的根据字节划分的数据块到达目的主机之后,再进行排序,从而得到正确的字节序列。最终呈现在接收缓存区,让用户进行读取。
-
那么就会有图中的问题,即用户在读取时可能只读取到了TCP划分好的字节序列,但是这个字节序列并不保证是一个完整的报文,有可能比一个完整的报文少,也可能比一个完整的报文还要多,即上面所说的粘包问题。
那么如何解决粘包问题呢?
-
定长字节进行截取,这就要求应用层每次发和收「固定大小的数据」,这样每个报文就有了固定单位。
-
封装报头,这个报头可以只是简单用户报文的长度,然后使用分割符与用户数据进行分开,方便进行提取长度,之前的文章实现网络版本计算器时,就是采用的这种方式。
-
自定义协议格式。更加灵活,可以根据实际的需求灵活应对,比如之前学习的HTTP协议的格式。
- HTTP请求报头:
如何保证数据是按序到达的呢?
我们先假设数据在网络中传输并没有产生丢包,只是网络有一点点的波动,然后导致数据乱序。根据上面字节流的图解,我们可以简单的得知在发送之前,数据是要进行编号的,那么是如何实现编号呢?这就不得不提及协议字段中的序号字段了。
如图:
假设接收方收到的TCP报文是乱序的,比如先收到1001的序号,再收到1的序号,最后收到2001的序号,那么我们就可以根据序号排升序,进而可以让报文按序到达,再分用呈现给上层用户即可。
3.确认应答机制
但是,TCP要保证已经发送的数据是可靠的,即发送方要确保数据接收方已经收到,因此接收方收到数据还要向发送方说一句: " 你放心吧,数据我已经收到了。" 那接收方是如何让发送方放心的呢?这就涉及到了TCP的另一个字段,确认序号字段和ACK标志位。
图解:
分析:
- 首先客户端向服务端发送序列号为1,大小为1000字节的数据,服务端收到之后,计算出数据的长度为1000字节,因此将确认序号设置为1 + 1000,即下一个应发送的序号为1001,并将ack标记位设置为1,对客户端进行响应。
- 然后客户端收到了服务端发送的ack应答,确认对方收到前面1000字节,于是接着往下发序号为1001,大小为1000字节的数据。
- 此后重复1,2类似的步骤,直到数据发送完毕为止。
按照上图的发送的方法,是不是感觉接收方并不需要进行排序,直接发一个收一个就行了?确实是这样的,不过这样做有一个缺点,就是每次都得等对方确认数据收到之后,发送方才能接着发,实际上有效率较高的做法。
图解:
分析:
- 客户端一次可以按顺序发送批量的请求。
- 服务端收到一批数据之后对每个数据进行批量的返回确认序号。
上述操作,其实只需要一回请求与响应即可完成,可谓是提高了效率,节省了时间。更进一步分析,如果在返回确认序号时,有部分丢包现象,也是不要紧的。以上面的Client 与 Server的图举的栗子,在返回确认序号时,如果客户端收到了携带确认序号3001的应答,则说明之前的报文,服务端都收到了,那么携带确认序号1001,2001的报文即使丢了也不要紧。这是因为服务端在进行确认时,是按序进行确认的,如果中间某个报文丢失,之后的携带确认序号的报文是不会进行发送的
。
如果应答不仅仅是应答,而且还带有数据,效率就会进一步提升,像这种我们称之为捎带应答
,即应答的同时,我还捎带了一些数据。
4.超时重传
数据在网络中丢包了怎么办?
如图:
一次成功的收发消息,以ACK确认应答为终点,那么如果发送的数据丢包,也就意味着是收不到应答的。那么如果要重传,就要设置等待应答的最长时间,如果超过了这个时间,我们认为数据丢失,然后重传。
与此相关,在计算机中有两个专业术语:
- RTT,全称为Round Trip Time,即往返时延,是指从发送数据包到接收到对应确认的时间。
图解:
2. RTO ,全称为 Retransmission TimeOut,即重传超时时间,在 TCP 中,当发送端发送数据后,会启动一个计时器来等待接收端对这些数据的确认。如果在 RTO 时间内未收到确认,发送端会认为数据丢失,并触发重传机制,重新发送这些数据。
这里我们探讨的就是如何设置RTO的问题,一般来说,我们设置RTO的时间,应该略大于RTT的时间,这样能够最大程度上保证效率。
图解:
如果设置的RTO的时间过长,那么等待的时间就会越长,效率就越低;如果设置的RTO时间短于RTT,即使能够收到应答,还可能重发相同的数据,因此效率也会降低。
除此之外,不仅数据会丢包,应答也可能会丢包:
像应答丢了,再进行超时重传,相比于数据丢包多做了一个无用功,无用功指的是接收端已经收到了对应的数据,有用功指的是数据可以提醒接收端发出去的应答丢失了,要重新发送应答。
5.快重传
在实际传数据时,我们肯定不止一次只发送一个请求,为了保证效率,我们会一次发送多个请求,那么中途有报文丢失了,怎么办呢?
可以看出,上述在进行传输数据时,除了第一次正常应答外,之后由于数据丢包是无法正常进行应答的,如果无法应答,那我们就改为缺失报文的应答,即都改为确认序号为101的应答,用于提醒发送方有报文丢失了,补发对应的缺失报文即可。
因此当接收方收到了连续三个重复异常的相同的应答时,就认为是相应报文丢失了,之后及时的补发缺失的报文。
之后补发缺失的报文之后,接收方再发送应答只需发送最后一次报文的应答即可,为啥是应答三次才被认定为是报文缺失呢?别问,问就是科学家通过大量数据测出来的。不过我们可以通过下图与采用超时重传进行对比,来感受一下。
首先超时重传中途要等待较为长的一段时间,而且中间接收方因为数据有缺失也无法发送正常的应答,因此双方是处于空闲状态的,而快重传就利用了这一段时间用于提示发送方补发缺失报文,因此时间效率得到了提高。
- 说明:这里的超时重传只是较为理想的重发情况,这是因为发送方是不知道到底丢了多少数据,因此重发多少是不确定的。
6.滑动窗口
- 滑动窗口,顾名思义可以移动的窗口,我们就会产生疑问。滑动是什么意思?窗口又是啥意思?
在学习UDP的时候,提过当发送方将接收方的缓存区打满时,发送方再发送数据,此后报文会被直接丢弃,这被视为不可靠的一种,那么TCP是如何解决这种问题呢?这就不得不提及协议字段中的16位窗口大小。
在正式发送数据之前,TCP会经过协商,即三次握手过程中,双方会通知对方自己最多能够接收多少字节,即所谓的16位窗口大小,这个窗口大小会在传输的过程中动态变化,下面我们画图理解。
- 下面采用服务端与客户端进行互相通信。
- 事先协商好,客户端最多能收200字节的数据,服务端最多能收100字节的数据。
- 双方互发一次数据。
窗口大小会在TCP报头中动态变化,实时进行更新。且通过上图我们可以简略地看出滑动这个字眼的痕迹。那么具体是如何进行滑动的呢?我们继续以图解的方式进行呈现。
首先发送方窗口的构造是这样的:
- 首先我们已知三个变量:WND——发送窗口的大小,UNA——已发送但未被确认的第一个序号,NXT——下一个可发送的开头序号。
- 当发送的数据返回应答时,即UNA左移,移动过的路程就变为了[已发送已确认的数据]。
- 当我们继续发数据时,需要计算出可发数据的大小,即可用窗口大小SIZE = WND - (NXT - UNA)。然后NXT在[NXT,NXT + SIZE)范围之间移动即可。
- 当接收方的滑动窗口变大时,[等待发送的数据]就会有一部分变为[可继续发的数据]。
其次接收方的窗口的构造是这样的:
- 首先我们已知两个变量:WND——接收窗口的大小,NXT——即下一个接收报文的起始位置。
- 我们可以通过计算获取填充数据的范围为:[NXT, NXT + WND)。
- 当新的报文来临时,NXT向右进行移动,移动的部分变为[已经收到的数据]。
- 当接收窗口扩大时,[等待填充的数据]其中一部分就会变为[可进行填充的数据]
因此,发送方和接收方在不断的确认应答之间,移动指针(改变相关变量),更新窗口的大小,进而完成滑动的效果。
关于内核中关于接收窗口与发送窗口的实现的相关结构体:
struct tcp_sock
{
//......
__u32 rcv_nxt;//下一个该收到的报文。
__u32 snd_nxt;//下一个待发送的序列号
__u32 snd_una;//已经发送,但是未被确认的的第一个字节。
__u32 snd_wnd;//发送窗口的大小
__u32 rcv_wnd;//接收窗口的大小。
//.....
};
从这个结构体,我们也可以得出:对于TCP来说创建一个套接字,就会生成一对发送缓存区和接收缓存区。
7.流量控制
谈及UDP的不可靠,即双方无法并不知道双方收发信息的情况,这也就导致了在一方发数据时,并不考虑也无法考虑对方是否具有接收数据的能力,也就是说当发送方发送的数据已经打满了接收方的缓存区,此时发送方由于不知道接收方不能再接收数据,之后继续再发数据,那么接收方就会因为无法处理,而导致报文丢弃,进而导致不可靠以及效率的降低。
反观TCP,可以通过协议字段中的滑动窗口的大小,间接的知道双方能收多少数据,那么当接收方不能再收数据时,那么发送方就不会再发送数据了,而是等接收方能够再接收数据时,再进行发送。这样做保证数据能够可靠到达,避免了重发的现象,因此也在一定程度上提高数据传输的效率,即有用功变多了。这样通过窗口大小能够控制收发消息的手段,我们一般称之为流量控制。
下面我们来进一步深入:
-
当接收方不能收消息时,发送方并不一定是干等着接收方的窗口消息更新的应答的,而是在「每隔一会儿」就发送发送窗口大小探测报文,当然报文只含报头不含数据,因此接收方解析出报头之后会返回一个实时的窗口大小,这样发送方就能及时的获取接收方窗口的更新情况。
-
当接收方的缓存区满了,而应用层迟迟的不去收消息,这不是让发送方干着急么,于是发送方可以发送带
PSH标记
的报文用于提醒用户 : “你赶紧把数据拿走,我要发数据!”。催促赶紧将数据移到应用层,此后发送方继续发送消息。这在降低延迟,以及某些对实时性要求的领域有重要的作用。 -
当正常发送消息时,接收方也能进行正常的接收,突然发送方有一些「紧急的数据」要处理,就如抗日战争中的「鸡毛信」,里面有着「重要的情报」,这时就会把「URG标记位设置为1」,然后设置「十六位紧急指针的大小」,指向「紧急数据的最后一个字节的下一个位置」。一般来说紧急数据只包含一个数据,且是夹杂在正常数据进行传输的。到达接收方时会被优先进行处理。
- 如图:
- 相关接口:
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
- 说明:当设置
flags
为MSG_OOB时,即为设置带外数据,即out-of-data,也就是我们所说的紧急数据。
-
当接收方收到数据时,并不着急给对方发送确认应答,而是等大概200ms左右,期间等待上层处理数据,「等待之后」 窗口大小就会比「不等」的情况 大一些,发送方确认应答之后之后就能够发送更多的数据。这样来给 “双方留有回旋的余地” 的方式,我们称为延迟应答。
-
当接收方能够接收比16位窗口表示的最大数据还大时,此时报头中选项中就会有一个窗口扩大因子,当实际计算时,扩大因子会参与和窗口字段的计算,进而计算出实际的窗口大小。当窗口扩大时,也就意味能够一次发送更多的数据,即更大的网络吞吐量。
补充选项:
- MSS,即最大报文长度( Maximum Segment Size ),是TCP协议定义的一个选项,MSS选项用于在TCP连接建立时,收发双方协商通信时每一个报文段所能承载的最大数据长度。这里挖一个坑,后面谈IP协议时填上~。
与之相关:
- MTU 最大传输单元(Maximum Transmission Unit,MTU)用来通知对方所能接受数据服务单元的最大尺寸,说明发送方能够接收的最大有效载荷大小。
8.拥塞控制
在上述我们只是考虑了报文到达目的地之后,接收方对报文接收的可靠处理方法,那如果报文就根本没有到达目的地,即网络的情况很差呢?
我们这里说的网络很差,通常来说是网络中流量过多,而超出了网络设备的处理能力,进而导致网络发生阻塞的情况。那么,如果只考虑之前我们所提及的超时重传,隔一段时间就进行重发,但是网络很差,即使重发也是白发,更严重的是你越发,网络中的数据越多,网络越差,越差你越发,网络的数据就更多,进而导致网络更差…… 陷入死循环了。
那么我们在发数据之前我们就要考虑网络的情况如何了,如何实现呢? 我们可以简单的推测既然流量控制有滑动窗口,那么拥塞控制自然也拥塞窗口咯,那么下面我们就来谈一谈拥塞窗口是如何实现的探测网络情况。
简单来说,拥塞窗口是一个变量,在内核中名为snd_cwnd
,用于动态的反映网络阻塞情况。
大致原理:
- 当网络情况变好时,snd_cwnd会变大。
- 当网络情况变差时,snd_cwnd会变小。
而在实际发送数据时,我们在考虑加上拥塞窗口的大小,那么实际的发送窗口大小就为:snd_wnd = min(max_window,snd_cwnd);
,即 「发送方实际发送窗口的大小」 ,就为 「阻塞窗口的大小」 和 「对方可以接收的最大窗口的大小」 的 「较小值」 。
那阻塞窗口是如何变大变小的呢?
-
首先当TCP建立连接时,我们会将snd_cwnd设置为1,此后先按照如下的规则发送数据:
- 收到一轮ACK,意味着网络可以发送一个MSS,认为之后可以发送两个MSS大小的数据,将snd_cwnd设置为2。
- 收到两轮ACK,意味着网络可以发送两个MSS,认为之后可以发送四个MSS大小的数据,将snd_cwnd设置为4。
- 收到四轮ACK,意味着网络可以发送四个MSS,认为之后可以发送八个MSS大小的数据,将snd_cwnd设置为8。
- 以此类推……以2n 进行指数级增长。
如图:
像这种指数级增长,如果不加以限制,那么很快就变的很大,2的32次方就是一个21亿多的数据,变得很大就会失去防止网路阻塞的作用,因此在设计时,会限制一个最大大小,我们称之为慢启动门限:ssthresh
,一般设置为65535,即unsigned short int的最大值。
因此上面初始状态
一点一点增加发送量的启动过程,即速度逐步加快,但量很少,我们称之为慢启动。为了方便举例,下面我们就假设ssthresh
为 8。
- 当超过ssthresh时,我们就会进入一个线性增长的阶段,以免增长过快。
- 在之后再进行增长时,每当收到snd_cwnd个MSS数据,即一次传输轮次,snd_cwnd就加1。
- 直到增长到触发重传为止,因为大量重传就在一定程度上反应了网络的状况是不好的,即网络可能发生阻塞。
- 实际上当超过max_wnd时,snd_cwnd变量就失效了,而当网络阻塞时,会快速重新生效。
- 重传有两种方式:
- 对于超时重传:将ssthresh设置为snd_cwnd / 2,snd_cwd设置为1。
- 对于快重传:
- 将ssthresh设置为snd_cwnd / 2, snd_cwd设置为 snd_cwnd / 2。
- 之后将snd_cwd = snd_cwd + 3。
- 然后将数据进行补发,并等待新数据的应答。
- 如果等待新数据的应答之后,就将snd_cwd = ssthresh,继续陷入线性增长。
说明:对于快重传来说,最开始的snd_cwnd加三,是因为「还能收到应答报文」,说明网络状况还不是太差,那我就给一个较快的速度把丢失的数据—— 一般不是太多,进行补发。等收到新数据的应答之后,说明丢失的数据都收到了,但是网络由于之前丢包说明网络状况还不是太好,不敢让snd_cwd太大,尤其是最开始加了三,因此还是将snd_cwd 设置为ssthresh让其慢慢增长,确保网络不会发生阻塞。
最终我们总结:第一次是加三为了将丢失的报文快速传过去;第二次是数据已经收到,确保网络不发生阻塞而进行的一种复位行为。
中场休息:我们这里整顿一下 “行李”,进入下面的"重头戏"。
- 在最开始简单的认识了一下TCP协议,通过之前的UDP协议我们可以理解源端口和目的端口,以及校验和字段。
- 在学习字节流的过程中,提及了TCP报头中的序号,并学习了数据报粘包的三种解决办法。
- 在确认应答机制中,提及了TCP报头的确认序号,并逐步优化确认应答的方式,部分应答可以缺失,提出了捎带应答。
- 在重传部分,超时重传中RTO应该略大于RTT。在中间报文丢失部分丢失的情况下,提出了快重传对效率进行了优化。
- 在滑动窗口和流量控制部分,我们理解了流量控制主要是通过滑动窗口进行实现的,并补充了PSH,URG两个标记位,选项中的窗口扩大因子,延迟应答,并拓展认识了MSS , MTU,对数据是如何处理的有了进一步的了解。
- 在拥塞控制部分,进一步补充了实际发送的窗口的影响因素,即拥塞窗口。并画图理解慢启动,以及重传数据时进行的两种碰撞避免算法。
9.三次握手
打个比方,三次握手就像是"有情人"在经历了"磨难"之后的 “终成眷属”。
- 为啥说是 “磨难” 呢?因为在每一次握手都可能失败,失败之后就要采取相应的措施进行弥补,不那么顺风顺水,即"磨难"。
- “终成眷属”,即三次握手之后的成功建立连接。
在之前我们讨论过无连接的UDP是不可靠的,那么有连接的TCP是如何保证可靠的呢?
从理论上,可靠意味着双方都能正常的收发消息,这里就有一个值得探究的点:如何保证都能双方收发消息——发数据进行试探和确认。
- 客户端给服务端发送消息,收到服务端应答。说明 [客户端发送] 的消息能够被 [服务端收到]。那么就保证了从客户端到服务端的可靠性。
- 服务端给客户端发送消息,收到服务端应答。说明[服务端发送] 的消息能够被 [服务端收到]。那么就保证了从服务端到客户端的可靠性。
- 前两步进行合并,双方互发消息。即能保证双方能收发消息。
4. 服务端发送 「嗯。」+ 「你好! 」 其实可以合并成一条进行优化,于是就有了最少三次就能够确认双方都能收发消息。
那么就有了一个问题,之后发送和接收数据就一定
能保证可靠吗?答案是很显然的,不一定,因为网络是实时变化的,就像股票一样,谁也不知道,哪一只股票在下一秒是升还是降。于是才有了拥塞控制和重传等机制,来保证数据在网络中传输的可靠性。那么我们建立连接的目的就是保证大概率的可靠性,也就是能保证对方和自身有收和发数据的前提。而UDP是连这个前提都没有的。
再联系到TCP,「你好!」 其实就是设置报头中的SYN为1,「 嗯。」 其实就是设置报头中的ACK为1。那么「你好!+ 嗯。」其实就是同时设置报头中的SYN和ACK为1,像这种再携带SYN的同时,也携带着ACK数据,就是一种捎带应答。
画张图替换一下就是:
补充一点:窗口大小也是在这一阶段进行协商确认的。
因此理论上最少三次握手就能保证双方都具有收发数据的前提。但是再进行第三次握手的时候,我们是不知道这个应答是否被服务端收到的,那么如果应答在途中丢失了呢?这就又涉及到TCP报头中的RST字段。
有两种情况:
-
客户端可以赌一把,即客户端假设这个应答对方收到了,但是服务端没有收到,那么客户端之后就会发送携带数据的报文,那么服务端收到之后就会显的很奇怪:“我并没有收到你的应答,你咋就给我发数据了?还不感觉把你的连接断开!” 于是就会触发RST应答的报文,客户端收到请求之后就会将自己的连接也进行释放。之后若想连接,就要再进行三次握手。如果客户端赌的再狠一点,在发送应答的时候就携带一些数据,不仅给服务端的应答丢失,而且给服务端的数据也没有被接收成功,真是赔了夫人又折兵~。
-
客户端保险起见,客户端可不想自己建立好的连接白白就被一个RST浪费了,于是就默不作声。于是服务端忍到到一定限度之后就触发了相应的超时重传机制,认为发出的数据没有被收到,于是再次补发数据,此时客户端只需再补发应答数据即可,应答被服务端收到之后。之后客户端就不用再冒着连接被释放的风险发数据了。
- 联系socket编程:
简单理解:
- 首先客户端发起连接,即调用connect函数,即发送SYN请求的报文,不携带数据,之后陷入SYN_SEND状态。
- 然后服务端收到之后,即调用listen函数,保存客户端发来的连接信息,服务端陷入SYN_RECV状态。
- 其次服务端向客户端发送SYN + ACK的报文进行响应,等待客户端的ACK。
- 然后客户端收到之后,即connect函数返回,客户端认为三次握手成功,陷入ESTABLISHED状态。
- 接着客户端向服务端发送ACK应答,最后服务端接收之后,也认为三次握手成功,申请资源建立与客户端的连接。
埋坑
:listen,在之前我们提及过第二个参数,不过并不是很清楚,只知道不能设置的太大,也不能设置的太小。这涉及两个连接队列。
int listen(int sockfd, int backlog);
- 连接还没有"完全"建立,即三次握手没有完成,收到了客户端的syn请求,然后建立的连接队列,我们称之为半连接队列,也叫SYN队列。
- 连接建立成功,即三次握手完成,然后从半连接队列中拿出对应客户端的连接,放到一个连接队列,称之为全连接队列,也叫accept队列。
- 区别:半连接「建立成功」到「被释放」之间,时间间隔比较短,
那么这里的backlog就是全连接队列的大小。更准确的来说,Linux 内核 2.2 之前, backlog = 半连接队列长度 + 全连接队列长度. Linux 内核 2.2 之后, backlog = 全连接队列长度,具体还是看内核的实现。
大致图解:
那么全连接队列设置多少合适呢?
- 太大,就会导致总有一些闲置连接无法被及时的被处理,因此会导致系统资源浪费的情况。
- 太小,就会导致还要从半连接队列中再拿,中间有一定的时间消耗,因此会导致效率的降低。
因此:在内核中会有一个参数somaxconn
,在实际设置backlog时,会在两者之间取较小值,即min(somaxconn, backlog)
,而这个somaxconn默认为128,在/proc/sys/net/core/somaxconn
可进行查看和修改。
- SYN泛洪攻击:是一种利用TCP半连接队列的缺陷,由攻击者向服务端发送大量的伪造的SYN请求但是不进行ACK应答,就会导致服务端有大量的半连接,其次由于不进行ACK,服务端还要进行维护,甚至说对维护的半连接进行超时重传 SYN + ACK,这样就会损耗服务器资源,从而导致系统资源不足,甚至系统瘫痪。其次对于正常的用户来说,也可能因为半连接的队列被打满,进行正常SYN连接时,因为服务端由于半连接队列已满,只能将新来的连接释放,而导致用户无法正常进行访问,像这种泛洪攻击,我们称之为DOS攻击。
那么抵御SYN攻击,进而让合法用户进行正常访问呢?
- 调大 netdev_max_backlog,即当网卡接收数据大于内核的处理数据的速度时,会有一个队列存放数据,通过参数可以控制这个队列的大小,增大队列的大小,从而增加客户端正常访问的几率;
- 增大 TCP 半连接队列,也是通过增加客户端正常访问的几率进而让合法用户进行正常访问;
- 开启 tcp_syncookies,即当半连接队列满时,服务端会将后来的syn请求进行加密,放在报文中的序号,后续接收ack请求,会进行检验其合法性,如果合法会将其放在全连接队列中。
- 减少 SYN+ACK 重传次数,因为对于超时的半连接,服务端会进行重传,重传次数越少,对服务端的资源消耗就越小,进而服务器的负荷就越小,就能一直处于正常运转,而不会被搞挂掉。
我们接着进行实验,从实践的角度进一步理解三次握手,以及listen的第二个参数。
说明:实验代码使用的是TCP套接字编程,编写了一套客户端与服务端的,这里就不再贴出了,具体可见文章 Socket —— “UDP“ && “TCP“。
- 知识储备工作——熟悉 netstat 工具 的基本使用。
netstat是用来查看网络状态的工具,以下是常见的选项。
- n(numeric) 拒绝显示别名,能显示数字的全部转化成数字
- l(listening)仅列出有在 Listen (监听) 的服務状态
- p(program) 显示建立相关链接的程序名
- t (tcp)仅显示tcp相关选项
- u (udp)仅显示udp相关选项
- a (all)显示所有选项,默认不显示LISTEN相关
首先在Xshell下创建四个会话,进行客户端与服务端连接的实验,需注意在本次实验过程中,listen的第二个参数设置为1,应用层并没有调用accpet,也就意味着,上层并没有拿走全连接,下面我们进行如下步骤,观察现象。
- 启动服务端,并准备启动客户端1,查看此时的网络状态。
- 命令解释
sudo netstat -nltp | head -2 && sudo netstat -altp | grep -E "8888"
#netstat通常查看需要使用root权限,所以用sudo进行提权;
#sudo netstat -nltp | head -2 是将 netstat 的结果过滤出前两行,即表头的描述信息。
#&& 是在执行前面语句的同时,执行后面的语句。
#sudo netstat -altp | grep -E "8888" 是将 netstat结果过滤出与服务端口号8888相关的。
while :; do done
#此可看做一个while死循环,一直进行。
sleep 1
#每次执行,休眠一秒,防止打印速度过快。
- 启动客户端1,查看客户端与服务端的连接状态。
- 启动客户端2,查看客户端与服务端的连接状态。
- 启动客户端3,查看客户端与服务端的连接状态。
图解:
结论:
- 至少在Centos7.6版本下,「全连接队列大小」等于「listen第二个参数」加「1」。
- 当全连接队列满时,即使收到客户端的ACK,服务端也因拿不上去,而处于SYN_RECV,这个半连接的存活时间通常很短。
谈到这里,我们已经有了对TCP三次握手有了一定的理解,下面我们用一道面试题——TCP为啥是三次握手,而不是两次,四次?
来进行收尾。
- 首先在最开始,我们就已经阐明:三次握手是理论上能够保证双方具有收发信息能力的最小次数。四次握手通过捎带应答优化为了三次握手,减少了建立连接的次数,进而提高了效率。也就是为啥不是四次的原因。
- 其次不采用两次握手,主要有以下两点无法保证~
- 第一点为:无法阻止历史连接,进而会导致造成服务器资源的浪费。
三次握手,客户端可以通过将第三次应答改为发送携带RST复位标记的报文,以此通知服务端关闭历史的连接,进而防止服务器资源的浪费。
- 第二点:无法同步双方的初始序列号,以及窗口大小等信息,进而导致收发消息不可靠。
下面我们进入第二个重头戏~
10.四次挥手
- 打个比方,四次挥手就像「迫切需要断干净关系,好奔赴下一段恋情的一对狗男女」一样~
拉出UDP鞭一下尸,UDP是无连接的,因此谁双方都不需要看对方的"脸色",自己把建立的套接字一关就啥也不管了~。那么TCP如何保证断干净关系的呢?那就回到最开始的「三次握手」其保证的是双方都能收发消息,那「四次挥手」就保证双方都不能收发消息就好了么~
那么跟三次握手一样,这里我就直接将全过程的图解放出来了。
- 首先客户端发送我要和你分手,然后服务端收到之后发送我知道了。
- 其次客户端收到了服务端的应答,但是由于「不确定服务端是否要分手」,所以还要再等等。
- 接着等「服务端决定好想分手」了向客户端发送我也要和你分手,然后客户端收到之后向服务端发送我知道了。
- 最后「等客户端确认服务端收到」之后,双方分手完成,奔赴下一段恋情~
那么具体的将「分手」换成「SYN」,「知道分手」换成「ACK」再代入理解一下。
- FIN_WAIT1,即代表客户端通知服务端要分手。
- CLOSE_WAIT,即代表服务端知道服务端要分手。
- FIN_WAIT2,即代表客户端知道服务端收到分手的消息,但还要等服务端分手。
- LAST_ACK,即代表服务端决定要和客户端分手。
- TIME_WAIT,即代表客户端收到服务端确认分手的消息,但「不确定服务端」是否知道「客户端收到服务端确认分手的消息」。
- CLOSED,表示双方都收到「彼此」确认分手的消息。
至此,我们已经对四次挥手有了简单的认识,下面我们通过问题和实践进一步理解。
为什么是四次挥手,而不是三次挥手呢?
首先,我们使用Wireshark 工具进行抓包,提取出一个TCP的四次挥手的信息。
可见,第一次「客户端FIN请求的应答」和 「服务端FIN请求」合并成了一条报文,因此我们看到的实际上是三次挥手,那就产生疑惑了,既然可以是三次挥手,那么为啥说是四次挥手呢?其实这是一种 “做事留一线,日后好相见” 的说法,服务端没必要立马断开连接,还可能要给客户端发送数据,这种情况下是四次挥手。如果没有服务端之后没有数据要发,立马断开连接,这种情况下是三次挥手。那么退一步,海阔天空,因此我们说成四次挥手是一种比较宽的说法。
说明:在第二次握手之后,由于服务端没有发消息的能力,不能发数据,所以也就没有四次握手的说法,反而四次握手是一种降低效率的一种行为。
下面我们接着做一个实验,进一步理解TCP四次挥手。
说明一点:这里我们只调用一次accept,即上层只拿一个连接,且服务器接收连接之后,会立马将连接进行关闭。
- 启动服务端,查看服务端的连接情况。
2. 启动客户端,查看网络连接情况。
画个图解,更加清楚:
3. 客户端按下回车,因为我们在后面的执行逻辑中设置了close函数,所以按下回车之后会调用close。再查看网络连接的状态。
画一个图解更加清晰:
- 此时我们终止掉服务端,并重新启动。
说明:TIME_WAIT等待的时间大致是2倍的「MSL」。
- MSL(Maximum Segment Lifetime) 通常是指 TCP 连接在正常关闭后,等待所有可能存在于网络中的数据包消失所需的时间。2 倍的最大报文段生存时间。
假如说京东正处于双十一,结果因为连接过多,而导致服务器挂掉了,这时就会存在大量的TIME_WAIT状态等待进行处理,而且由于服务器是要固定端口号的,因此会导致服务器无法立即重新启动,那么一秒就可能成交成千上百万单,那损失。。。。
不过好在有相应的接口,可以避免这种情况:
//接口:
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
//使用方式:
int opt = 1;
setsockopt(_sockfd,SOL_SOCKET,SO_REUSEADDR|SO_REUSEPORT,&opt,sizeof(opt));
最后我们还是以一道面试题,来给四次挥手收下尾:「进程终止和重启」 与 「断电」的区别。
- 进制终止和重启,操作系统还是可以给你整「私活」的,即在背后默默的帮你把四次挥手的工作完成,这也就是我们在关机时,如果开的应用程序多了,往往还要等待一段时间才能关机的一部分原因。
- 断电,操作系统就无力回天了,这时就要看服务端是否正在向客户端发消息
- 如果发消息一段时间收不到之后,会触发超时重传,重传到一定次数之后,服务端会自动将连接关闭。
- 如果没发消息,则要看服务端是否开启了「保活机制」~
- 如果开启,则服务端会定时发送一些探测报文,检测客户端是否存活,等到一定次数确定客户端消亡之后,会将连接关闭。
- 如果关闭,则服务端的连接会一直处于ESTABLISHED状态。
最后,希望这篇文章能够对各位读者有所帮助!如果有误,请及时的进行指出。
尾序
我是舜华,期待与你的下一次相遇!