赞
踩
学了TCP 和UDP之后,感觉UDP就像是初入职场的年轻人
,两耳不闻 “窗外事”,只管尽力地把自己的事情做好,但收获的却是不可靠
,而TCP更像是涉世极深的"职场老油条"
,给人的感觉就是 “城府极深,深不可测”,不仅事事考虑的周全,懂得人情世故,而且深得"上层" 和 “下层” 的信任,因此大家都说TCP可靠
,TCP 也不禁感慨:“我只需要略微出手,就已知这个分段的极限了
。” “UDP” 的不如意也就有了大家的那句话 “以前的我不屑一顾,现在的我逐帧向 「TCP」学习”,这也就是题目:理解 UDP,成为TCP
的深意。
特点:
形象记忆
在以后的协议讲解时,我们都会先讨论以下两点:
报头与有效载荷是如何封装和分离的?
通过截取定长的8字节长度,将报头和有效载荷进行分离。
数据是如何分用的?
在截取报头之后,可以再通过分析头部信息,进而获取到目的端口号,而目的端口号可以通过一些哈希的方式,比如端口号映射到进程的pid,进而找到上层的进程
,而对应的进程采用N与UDP相关的应用层协议。因此数据完成到应用层的分用。
内核中关于报头的结构体:
struct udphdr {
//typedef unsigned short __u16;
__u16 source; //源端口
__u16 dest; //目的端口
__u16 len; // 报文长度
__u16 check; // 检验和
};
从编码的角度,我们可以看出其实截取报头之后会放在相应的结构体中,通过使用结构体中的变量完成实际的操作。
UDP检验和是如何检验数据的完整性,即在传输路途中没有被损坏呢?
- 说明:如果报文误传给了UDP所在传输层,
伪首部的目的ip地址是报文实际所在主机的ip,而不是目的主机的地址
。通过校验和可检测出报文所在主机是否等于目的主机。
计算方法:
- 说明:所有的数据都是以网络序列进行传输的。那么如果我们想要进行比对,首先要将整个报文里面的数据先转换成主机序列,提取出校验和,然后再将伪首部以及整个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
。
从UDP(U
ser D
atagram P
rotocol)名字上,即用户数据报协议,我们可以先简单的理解数据报是面向用户的,往下再说一层就是将用户发来的数据视为一个完整的报文。这里的"完整"
有两层意思。
"用户数据报"
;进一步,我们可以从UDP,即用户数据报协议中窥探出UDP的本职工作是只负责
——
合理大小
的数据并往下层传。那么就会有两种问题:
- 举个简单的例子:
- 小红和小明开始都有1000块,要去往杭州。小红先走,此时1000块最好只能买一张火车票,于是小红走了,但是当小明隔天再去买票发现,现在飞机票降价了,于是小明买了飞机票也走了。于是最终小红先走,却比小明后到。数据在网络中传输,基本上就是这个道理。
- 假设你玩英雄联盟,如果采取UDP协议,此时你要先按R放大控住,再按Q补伤害。发送给服务端可能就会呈现你要先释放Q,再释放R。于是反应给你的就是Q + R,而不是R + Q,但是Q之后对方就直接闪现了,R都按不出来。UDP这纯纯演员啊,哈哈其实这也不怪UDP,人家UDP不负责管这事。
从现实的角度来看,UDP就像一个只顾自己"一亩三分地"的人,对其它的"人"的事充耳不闻,不通人情世故,这也就怪不得其它层的 “人” 说:“UDP真是干不了大事。” 即「UDP不可靠」。但是UDP把自己的分内事情做的很好,因此也有人说:“UDP已经尽职尽责了!” 即「UDP尽最大可能交付数据」。
下面我们从套接字编程的角度,继续分析。
上述图,只是比较理想的情况,下面我们来谈一谈比较现实的情况:
回过头再来看,像这种事先不知道双方的运行情况,以及网络状态就直接发送数据,我们称之为无连接
,即UDP不可靠的一种。反之如果要确认双方以及网络状况需要消耗一定时间以及资源为代价,侧面上突出了无连接的一种优点 —— 成本低,不太消耗时间与资源
。
最后我们总结一下UDP:
发送来自和接收送往
用户的数据。从以上几个方面,我们列表分析一下UDP的不可靠。
方面 | 不可靠 |
---|---|
面向用户数据报 | 不对网络中的数据进行负责,因此可能存在丢包和乱序的问题。详见上文两种情况 |
无连接 | 双方在发送信息之前,并没有进行协商,因此不知道双方的状态。详见上文的两种情况。 |
全双工 | 接收缓存区满之后,发送方不知道继续发,此后数据会被对方直接丢弃。 |
如下是采用UDP的一些协议:
协议 | 采取UDP的原因 |
---|---|
NFS(网络文件系统)应用层协议 | 注重低开销和速度。自己采取相应机制保证可靠性 |
TFTP(简单文件传输协议) | 要求轻量和低延迟。自己采取相应机制保证可靠性 |
DHCP(Dynamic Host Configuration Protocol)用于自动分配 IP 地址、子网掩码、网关地址等信息的应用层协议 | 注重的是网络配置信息的快速分配和管理 |
BOOTP(Bootstrap Protocol)是一种用于无盘设备启动的网络层协议 | 注重在网络引导阶段快速获取网络设施,对可靠性要求较低。UDP天然具有广播的优势。 |
DNS(Domain Name System)是用于将域名解析为对应 IP 地址的应用层协议 | 注重效率和低延迟,自身对可靠性要求较低。 |
综合来看,采用UDP的大多数场景要求速度,低延时,效率等,但也不缺乏也要求可靠的,因此有对应要求的相应协议可通过再采取某些可靠的机制进而保证可靠。
TCP这个职场老油条,可是是深不可测,因此博主只负责带领读者认识那么"一丢丢" ,进一步的理解还需要各位日后的继续学习~
先来经典两问:
TCP报头与有效载荷是如何封装和分离的?
在数据的前面加上TCP报头,即完成有效载荷和TCP报头的封装。
截取报文前定长的二十字节,然后提取出四位头部长度,可表示的范围为[0 , 15],乘4表示实际报头的大小,又因为报头的最小长度为20,即实际表示的范围为[20,60],如果转化出的实际报头大小大于20,将剩余部分也就是选项部分再进行提取,最终完成报头和有效载荷的分离。
数据是如何完成分用的?
与TCP同理,这里就不过多解释了。
说明:校验和的原理也跟UDP大同小异
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;//紧急指针,即偏移量。 };
协议格式剩余的内容,将在下文进行穿插深入讲解。
先图解字节流~
分析:
全双工,即双方都具有收消息和发消息的能力,TCP比UDP多个发送缓存区。发送缓存区是保证可靠性的基石。
发送缓存区使TCP具有存放信息的能力,那么对于网络中丢包的数据,就可以实现重发,进而保证可靠性。
发送缓存区使TCP具有了管理用户传来数据的能力,因此TCP可以控制什么时候发,一次发多少,以及应对出错的情况,进一步保证了可靠性,即 “议” 如其名——传输控制协议。
UDP相比就显得比较被动,没有发送缓存区,那么就只能一次一次的发送用户传来的完整的数据报,因此没有能力保证可靠性。
发送缓冲区使TCP有底气对用户说" 不 " 的能力,即用户你怎么传是你的事,我TCP怎么发是我的事。两者在某种程度上实现了解耦合。
那么TCP就可根据现实的情况,对用户传来的数据按字节进行划分,每次发送合适大小的数据,因为还要保持有序,所以还要对划分的数据块进行编号,然后进行数据的传输,如果在传输途中如果有丢包就重传,等所有的根据字节划分的数据块到达目的主机之后,再进行排序,从而得到正确的字节序列。最终呈现在接收缓存区,让用户进行读取。
那么就会有图中的问题,即用户在读取时可能只读取到了TCP划分好的字节序列,但是这个字节序列并不保证是一个完整的报文,有可能比一个完整的报文少,也可能比一个完整的报文还要多,即上面所说的粘包问题。
那么如何解决粘包问题呢?
定长字节进行截取,这就要求应用层每次发和收「固定大小的数据」,这样每个报文就有了固定单位。
封装报头,这个报头可以只是简单用户报文的长度,然后使用分割符与用户数据进行分开,方便进行提取长度,之前的文章实现网络版本计算器时,就是采用的这种方式。
自定义协议格式。更加灵活,可以根据实际的需求灵活应对,比如之前学习的HTTP协议的格式。
如何保证数据是按序到达的呢?
我们先假设数据在网络中传输并没有产生丢包,只是网络有一点点的波动,然后导致数据乱序。根据上面字节流的图解,我们可以简单的得知在发送之前,数据是要进行编号的,那么是如何实现编号呢?这就不得不提及协议字段中的序号字段了。
如图:
假设接收方收到的TCP报文是乱序的,比如先收到1001的序号,再收到1的序号,最后收到2001的序号,那么我们就可以根据序号排升序,进而可以让报文按序到达,再分用呈现给上层用户即可。
但是,TCP要保证已经发送的数据是可靠的,即发送方要确保数据接收方已经收到,因此接收方收到数据还要向发送方说一句: " 你放心吧,数据我已经收到了。" 那接收方是如何让发送方放心的呢?这就涉及到了TCP的另一个字段,确认序号字段和ACK标志位。
图解:
分析:
按照上图的发送的方法,是不是感觉接收方并不需要进行排序,直接发一个收一个就行了?确实是这样的,不过这样做有一个缺点,就是每次都得等对方确认数据收到之后,发送方才能接着发,实际上有效率较高的做法。
图解:
分析:
上述操作,其实只需要一回请求与响应即可完成,可谓是提高了效率,节省了时间。更进一步分析,如果在返回确认序号时,有部分丢包现象,也是不要紧的。以上面的Client 与 Server的图举的栗子,在返回确认序号时,如果客户端收到了携带确认序号3001的应答,则说明之前的报文,服务端都收到了,那么携带确认序号1001,2001的报文即使丢了也不要紧。这是因为服务端在进行确认时,是按序进行确认的,如果中间某个报文丢失,之后的携带确认序号的报文是不会进行发送的
。
如果应答不仅仅是应答,而且还带有数据,效率就会进一步提升,像这种我们称之为捎带应答
,即应答的同时,我还捎带了一些数据。
数据在网络中丢包了怎么办?
如图:
一次成功的收发消息,以ACK确认应答为终点,那么如果发送的数据丢包,也就意味着是收不到应答的。那么如果要重传,就要设置等待应答的最长时间,如果超过了这个时间,我们认为数据丢失,然后重传。
与此相关,在计算机中有两个专业术语:
图解:
2. RTO ,全称为 Retransmission TimeOut,即重传超时时间,在 TCP 中,当发送端发送数据后,会启动一个计时器来等待接收端对这些数据的确认。如果在 RTO 时间内未收到确认,发送端会认为数据丢失,并触发重传机制,重新发送这些数据。
这里我们探讨的就是如何设置RTO的问题,一般来说,我们设置RTO的时间,应该略大于RTT的时间,这样能够最大程度上保证效率。
图解:
如果设置的RTO的时间过长,那么等待的时间就会越长,效率就越低;如果设置的RTO时间短于RTT,即使能够收到应答,还可能重发相同的数据,因此效率也会降低。
除此之外,不仅数据会丢包,应答也可能会丢包:
像应答丢了,再进行超时重传,相比于数据丢包多做了一个无用功,无用功指的是接收端已经收到了对应的数据,有用功指的是数据可以提醒接收端发出去的应答丢失了,要重新发送应答。
在实际传数据时,我们肯定不止一次只发送一个请求,为了保证效率,我们会一次发送多个请求,那么中途有报文丢失了,怎么办呢?
可以看出,上述在进行传输数据时,除了第一次正常应答外,之后由于数据丢包是无法正常进行应答的,如果无法应答,那我们就改为缺失报文的应答,即都改为确认序号为101的应答,用于提醒发送方有报文丢失了,补发对应的缺失报文即可。
因此当接收方收到了连续三个重复异常的相同的应答时,就认为是相应报文丢失了,之后及时的补发缺失的报文。
之后补发缺失的报文之后,接收方再发送应答只需发送最后一次报文的应答即可,为啥是应答三次才被认定为是报文缺失呢?别问,问就是科学家通过大量数据测出来的。不过我们可以通过下图与采用超时重传进行对比,来感受一下。
首先超时重传中途要等待较为长的一段时间,而且中间接收方因为数据有缺失也无法发送正常的应答,因此双方是处于空闲状态的,而快重传就利用了这一段时间用于提示发送方补发缺失报文,因此时间效率得到了提高。
- 说明:超时重传只是较为理想的重发情况,这是因为发送方是不知道到底丢了多少数据,因此重发多少是不确定的。
在学习UDP的时候,提过当发送方将接收方的缓存区打满时,发送方再发送数据,此后报文会被直接丢弃,这被视为不可靠的一种,那么TCP是如何解决这种问题呢?这就不得不提及协议字段中的16位窗口大小。
在正式发送数据之前,TCP会经过协商,即三次握手过程中,双方会通知对方自己最多能够接收多少字节,即所谓的16位窗口大小,这个窗口大小会在传输的过程中动态变化,下面我们画图理解。
窗口大小会在TCP报头中动态变化,实时进行更新。且通过上图我们可以简略地看出滑动这个字眼的痕迹。那么具体是如何进行滑动的呢?我们继续以图解的方式进行呈现。
首先发送方窗口的构造是这样的:
其次接收方的窗口的构造是这样的:
因此,发送方和接收方在不断的确认应答之间,移动指针(改变相关变量),更新窗口的大小,进而完成滑动的效果。
关于内核中关于接收窗口与发送窗口的实现的相关结构体:
struct tcp_sock
{
//......
__u32 rcv_nxt;//下一个该收到的报文。
__u32 snd_nxt;//下一个待发送的序列号
__u32 snd_una;//已经发送,但是未被确认的的第一个字节。
__u32 snd_wnd;//发送窗口的大小
__u32 rcv_wnd;//接收窗口的大小。
//.....
};
从这个结构体,我们也可以得出:创建一个套接字,就会生成一对发送缓存区和接收缓存区。
谈及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位窗口表示的最大数据还大时,此时报头中选项中就会有一个窗口扩大因子,当实际计算时,扩大因子会参与和窗口字段的计算,进而计算出实际的窗口大小。当窗口扩大时,也就意味能够一次发送更多的数据,即更大的网络吞吐量。
补充选项:
与之相关:
在上述我们只是考虑了报文到达目的地之后,接收方对报文接收的可靠处理方法,那如果报文就根本没有到达目的地,即网络的情况很差呢?
我们这里说的网络很差,通常来说是网络中流量过多,而超出了网络设备的处理能力,进而导致网络发生阻塞的情况。那么,如果只考虑之前我们所提及的超时重传,隔一段时间就进行重发,但是网络很差,即使重发也是白发,更严重的是你越发,网络中的数据越多,网络越差,越差你越发,网络的数据就更多,进而导致网络更差…… 陷入死循环了。
那么我们在发数据之前我们就要考虑网络的情况如何了,如何实现呢? 我们可以简单的推测既然流量控制有滑动窗口,那么拥塞控制自然也拥塞窗口咯,那么下面我们就来谈一谈拥塞窗口是如何实现的探测网络情况。
简单来说,拥塞窗口是一个变量,在内核中名为snd_cwnd
,用于动态的反映网络阻塞情况。
大致原理:
而在实际发送数据时,我们在考虑加上拥塞窗口的大小,那么实际的发送窗口大小就为:snd_wnd = min(max_window,snd_cwnd);
,即 「发送方实际发送窗口的大小」 ,就为 「阻塞窗口的大小」 和 「对方可以接收的最大窗口的大小」 的 「较小值」 。
那阻塞窗口是如何变大变小的呢?
首先当TCP建立连接时,我们会将snd_cwnd设置为1,此后先按照如下的规则发送数据:
如图:
像这种指数级增长,如果不加以限制,那么很快就变的很大,2的32次方就是一个21亿多的数据,变得很大就会失去防止网路阻塞的作用,因此在设计时,会限制一个最大大小,我们称之为慢启动门限:ssthresh
,一般设置为65535,即unsigned short int的最大值。
因此上面初始状态
一点一点增加发送量的启动过程,即速度逐步加快,但量很少,我们称之为慢启动。为了方便举例,下面我们就假设ssthresh
为 8。
说明:对于快重传来说,最开始的snd_cwnd加三,是因为「还能收到应答报文」,说明网络状况还不是太差,那我就给一个较快的速度把丢失的数据—— 一般不是太多,进行补发。等收到新数据的应答之后,说明丢失的数据都收到了,但是网络由于之前丢包说明网络状况还不是太好,不敢让snd_cwd太大,尤其是最开始加了三,因此还是将snd_cwd 设置为sshthresh让其慢慢增长,确保网络不会发生阻塞。
最终我们总结:第一次是加三为了将丢失的报文快速传过去;第二次是数据已经收到,确保网络不发生阻塞而进行的一种复位行为。
中场休息:我们这里整顿一下 “行李”,进入下面的"重头戏"。
打个比方,三次握手就像是"有情人"在经历了"磨难"之后 “终成眷属”。
为啥说是 “磨难” 呢?因为在每一次握手都可能失败,失败之后就要采取相应的措施进行弥补,不那么顺风顺水,即"磨难"。
在之前我们讨论过无连接的UDP是不可靠的,那么有连接的TCP是如何保证可靠的呢?
从理论上,可靠意味着双方都能正常的收发消息,这里就有一个值得探究的点:如何保证都能双方收发消息——发数据进行试探和确认。
4. 服务端发送 「嗯。」+ 「你好! 」 其实可以合并成一条进行优化,于是就有了最少三次就能够确认双方都能收发消息。
那么就有了一个问题,之后发送和接收数据就一定
能保证可靠吗?答案是很显然的,不一定,因为网络是实时变化的,就像股票一样,谁也不知道,哪一只股票在下一秒是升还是降。于是有了拥塞控制和重传等机制,来保证数据在网络中传输的可靠性。那么我们建立连接的目的就是保证大概率的可靠性,也就是能保证对方和自身有收和发数据的前提。而UDP是连这个前提都没有的。
再联系到TCP,「你好!」 其实就是设置报头中的SYN为1,「 嗯。」 其实就是设置报头中的ACK为1。那么「你好!+ 嗯。」其实就是同时设置报头中的SYN和ACK为1,像这种再携带SYN的同时,也携带着ACK数据,就是一种捎带应答。
画张图替换一下就是:
补充一点:窗口大小也是在这一阶段进行协商确认的。
因此理论上最少三次握手就能保证双方都具有收发数据的前提。但是再进行第三次握手的时候,我们是不知道这个应答是否被服务端收到的,那么如果应答在途中丢失了呢?这就又涉及到TCP报头中的RST字段。
有两种情况:
客户端可以赌一把,即客户端假设这个应答对方收到了,但是服务端没有收到,那么客户端之后就会发送携带数据的报文,那么服务端收到之后就会显的很奇怪:“我并没有收到你的应答,你咋就给我发数据了?还不快重新把你的连接断开!” 于是就会触发RST应答的报文,客户端收到请求之后就会将自己的连接也进行释放。之后若想连接,再进行三次握手。如果客户端赌的再狠一点,在发送应答的时候就携带一些数据,不仅给服务端的应答丢失,而且给服务端的数据也丢失了,真是赔了夫人又折兵啊~。
客户端保险起见,客户端可不想自己建立好的连接白白就被一个RST浪费了,于是就默不作声。于是服务端等到一定限度之后就触发了相应的超时重传机制,认为发出的数据没有被收到,于是再次补发数据,此时客户端只需再补发应答数据即可,应答被服务端收到之后。之后客户端就不用再冒着连接被释放的风险发数据了。
简单理解:
埋坑
:listen,在之前我们提及过第二个参数,不过并不是很清楚,只知道不能设置的太大,也不能设置的太小。这涉及两个连接队列。
int listen(int sockfd, int backlog);
那么这里的backlog就是全连接队列的大小。更准确的来说,Linux 内核 2.2 之前, backlog = 半连接队列长度 + 全连接队列长度. Linux 内核 2.2 之后, backlog = 全连接队列长度,具体还是看内核的实现。
大致图解:
那么全连接队列设置多少合适呢?
因此:在内核中会有一个参数somaxconn
,在实际设置backlog时,会在两者之间取较小值,即min(somaxconn, backlog)
,而这个somaxconn默认为128,在/proc/sys/net/core/somaxconn
可进行查看和修改。
那么抵御SYN攻击,进而让合法用户进行正常访问呢?
- 调大 netdev_max_backlog,即当网卡接收数据大于内核的处理数据的速度时,会有一个队列存放数据,通过参数可以控制这个队列的大小,增大队列的大小,从而增加客户端正常访问的几率;
- 增大 TCP 半连接队列,也是通过增加客户端正常访问的几率进而让合法用户进行正常访问;
- 开启 tcp_syncookies,即当半连接队列满时,服务端会将后来的syn请求进行加密,放在报文中的序号,后续接收ack请求,会进行检验其合法性,如果合法会将其放在全连接队列中。
- 减少 SYN+ACK 重传次数,因为对于超时的半连接,服务端会进行重传,重传次数越少,对服务端的资源消耗就越小,进而服务器的负荷就越小,就能一直处于正常运转,而不会被搞挂掉。
我们接着进行实验,从实践的角度进一步理解三次握手,以及listen的第二个参数。
说明:实验代码使用的是TCP套接字编程,编写了一套客户端与服务端的,这里就不再贴出了,具体可见文章 Socket —— “UDP“ && “TCP“。
netstat是用来查看网络状态的工具,以下是常见的选项。
- n 拒绝显示别名,能显示数字的全部转化成数字
- l 仅列出有在 Listen (监听) 的服務状态
- p 显示建立相关链接的程序名
- t (tcp)仅显示tcp相关选项
- u (udp)仅显示udp相关选项
- a (all)显示所有选项,默认不显示LISTEN相关
首先在Xshell下创建四个会话,进行客户端与服务端连接的实验,需注意在本次实验过程中,listen的第二个参数设置为1,应用层并没有调用accpet,也就意味着,上层并没有拿走全连接,下面我们进行如下步骤,观察现象。
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
#每次执行,休眠一秒,防止打印速度过快。
图解:
结论:
谈到这里,我们已经有了对TCP三次握手有了一定的理解,下面我们用一道面试题——TCP为啥是三次握手,而不是两次,四次?
来进行收尾。
三次握手,客户端可以通过将第三次应答改为发送携带RST复位标记的报文,以此通知服务端关闭历史的连接,进而防止服务器资源的浪费。
下面我们进入第二个重头戏~
打个比方,四次挥手就像「迫切需要断干净关系,好奔赴下一段恋情的一对狗男女」一样~
再拉出UDP鞭一下尸,UDP是无连接的,因此谁双方都不需要看对方的"脸色",自己把建立的套接字一关就啥也不管了~。那么TCP如何保证断干净关系的呢?那就回到最开始的「三次握手」其保证的是双方都能收发消息,那「四次挥手」就保证双方都不能收发消息就好了么~
那么跟三次握手一样,这里我就直接将全过程的图解放出来了。
那么具体的将「分手」换成「SYN」,「知道分手」换成「ACK」再代入理解一下。
至此,我们已经对四次挥手有了简单的认识,下面我们通过问题和实践进一步理解。
为什么是四次挥手,而不是三次挥手呢?
首先,我们使用Wireshark 工具进行抓包,提取出一个TCP的四次挥手的信息。
可见,第一次「客户端FIN请求的应答」和 「服务端FIN请求」合并成了一条报文,因此我们看到的实际上是三次挥手,那就产生疑惑了,既然可以是三次挥手,那么为啥说是四次挥手呢?其实这是一种 “做事留一线,日后好相见” 的说法,服务端没必要立马断开连接,还可能要给客户端发送数据,这种情况下是四次挥手。如果没有服务端之后没有数据要发,立马断开连接,这种情况下是三次挥手。那么退一步,海阔天空,因此我们说成四次挥手也没错~。
说明:在第二次握手之后,由于服务端没有发消息的能力,不能发数据,所以也就没有四次握手的说法,反而四次握手是一种降低效率的一种行为。
下面我们接着做一个实验,进一步理解TCP四次挥手。
说明一点:这里我们只调用一次accept,即上层只拿一个连接,且服务器接收连接之后,会立马将连接进行关闭。
2. 启动客户端,查看网络连接情况。
画个图解,更加清楚:
3. 客户端按下回车,因为我们在后面的执行逻辑中设置了close函数,所以按下回车之后会调用close。再查看网络连接的状态。
画一个图解更加清晰:
说明:TIME_WAIT等待的时间大致是2倍的「MSL」。
假如说京东正处于双十一,结果因为连接过多,而导致服务器挂掉了,这时就会存在大量的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));
最后我们还是以一道面试题,来给四次挥手收下尾:「进程终止和重启」 与 「断电」的区别。
最后,希望这篇文章能够对各位读者有所帮助!如果有误,请及时的进行指出。
我是舜华,期待与你的下一次相遇!
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。