꧁ 大家好,我是 兔7 ,一位努力学习C++的博主~ ꧂
☙ 如果文章知识点有错误的地方,请指正!和大家一起学习,一起进步❧
🚀 如有不懂,可以随时向我提问,我会全力讲解~💬
🔥 如果感觉博主的文章还不错的话,希望大家关注、点赞、收藏三连支持一下博主哦~!👀
🔥 你们的支持是我创作的动力!⛅
🧸 我相信现在的努力的艰辛,都是为以后的美好最好的见证!⭐
🧸 人的心态决定姿态!⭐
🚀 本文章CSDN首发!✍
目录
1.2 认识知名端口号(Well-Know Port Number)
当client发送一批数据的时候,server端是否有能力识别中间有一部分丢失了?
为何主动断开连接的一方在四次挥手之后不会立马进入CLOSED状态?
0. 前言
此博客为博主以后复习的资料,所以大家放心学习,总结的很全面,每段代码都给大家发了出来,大家如果有疑问可以尝试去调试。
大家一定要认真看图,图里的文字都是精华,好多的细节都在图中展示、写出来了,所以大家一定要仔细哦~
感谢大家对我的支持,感谢大家的喜欢, 兔7 祝大家在学习的路上一路顺利,生活的路上顺心顺意~!
传输层
负责数据能够从发送端传输接收端。
1. 再谈端口号
端口号(Port)标识了一个主机上进行通信的不同的应用程序。
在TCP/IP协议中,用"源IP","源端口号","目的IP","目的端口号","协议号" 这样一个五元组来标识一个通信(可以通过 netstat -n查看)。
1.1 端口号范围划分
- 0 - 1023: 知名端口号,HTTP,FTP,SSH等这些广为使用的应用层协议,他们的端口号都是固定的。
- 1024 - 65535: 操作系统动态分配的端口号。客户端程序的端口号,就是由操作系统从这个范围分配的。
1.2 认识知名端口号(Well-Know Port Number)
有些服务器是非常常用的,为了使用方便,人们约定一些常用的服务器,都是用以下这些固定的端口号:
- ssh服务器,使用22端口
- ftp服务器,使用21端口
- telnet服务器,使用23端口
- http服务器,使用80端口
- https服务器,使用443
执行下面的命令,可以看到知名端口号:
cat /etc/services
我们自己写一个程序使用端口号时,要避开这些知名端口号。
1.3 两个问题
一个进程是否可以bind多个端口号?
答:可以, 这个和第二个不冲突,端口号向进程方向是受限制的,反过来不受限制。
一个端口号是否可以被多个进程bind?
答:不可以,一个端口号只能被一个进程bind,这也就是我们在运行程序的时候会出现bind error的问题。
1.4 netstat
netstat是一个用来查看网络状态的重要工具。
语法:netstat [选项]
功能:查看网络状态
常用选项:
- n 拒绝显示别名,能显示数字的全部转化成数字。
- l 仅列出有在 Listen (监听) 的服務状态。
- p 显示建立相关链接的程序名 t (tcp)仅显示tcp相关选项。
- u (udp)仅显示udp相关选项。
- a (all)显示所有选项,默认不显示LISTEN相关。
1.5 pidof
在查看服务器的进程id时非常方便。
语法:pidof [进程名]
功能:通过进程名,查看进程id。
2. UDP协议
2.1 UDP协议端格式
- 16位UDP长度, 表示整个数据报(UDP首部+UDP数据)的最大长度。
- 如果校验和出错, 就会直接丢弃。
在讲网络架构的时候说过:
向上交付的时候怎么将报头和有效载荷分离?
1. 答:其实将报头和有效载荷分离是通过这个报头是占8字节的,也就是定长报头,比方说总共读了15个字节,那么前8个字节是报头,剩下7个字节就是有效载荷。
自己的有效载荷给上层的哪个协议?
2. 答:将有效载荷给哪个协议是通过目的端口号来实现的。
2.2 UDP的特点
UDP传输的过程类似于寄信。
- 无连接: 知道对端的IP和端口号就直接进行传输,不需要建立连接。
- 不可靠: 没有确认机制,没有重传机制。如果因为网络故障该段无法发到对方,UDP协议层也不会给应用层返回任何错误信息。
- 面向数据报: 不能够灵活的控制读写数据的次数和数量。
2.3 面向数据报
应用层交给UDP多长的报文,UDP原样发送,既不会拆分,也不会合并。
用UDP传输100个字节的数据:
- 如果发送端调用一次sendto,发送100个字节,那么接收端也必须调用对应的一次recvfrom,接收100个字节。而不能循环调用10次recvfrom,每次接收10个字节。
2.4 UDP的缓冲区
- UDP没有真正意义上的发送缓冲区。调用sendto会直接交给内核,由内核将数据传给网络层协议进行后续的传输动作。
- UDP具有接收缓冲区。但是这个接收缓冲区不能保证收到的UDP报的顺序和发送UDP报的顺序一致。如果缓冲区满了,再到达的UDP数据就会被丢弃。
UDP的socket既能读,也能写,这个概念叫做 全双工
2.5 UDP使用注意事项
我们注意到,UDP协议首部中有一个16位的最大长度。也就是说一个UDP能传输的数据最大长度是64K(包含UDP首部)。
然而64K在当今的互联网环境下,是一个非常小的数字。
如果我们需要传输的数据超过64K,就需要在应用层手动的分包,多次发送,并在接收端手动拼装。
2.6 基于UDP的应用层协议
- NFS: 网络文件系统
- TFTP: 简单文件传输协议
- DHCP: 动态主机配置协议
- BOOTP: 启动协议(用于无盘设备启动)
- DNS: 域名解析协议
当然,也包括你自己写UDP程序时自定义的应用层协议。
3. TCP协议
TCP全称为"传输控制协议(Transmission Control Protocol")。人如其名,要对数据的传输进行一个详细的控制。
3.1 TCP协议段格式
- 源/目的端口号: 表示数据是从哪个进程来,到哪个进程去。
- 32位序号/32位确认号: 后面详细讲。
- 4位TCP报头长度: 表示该TCP头部有多少个32位bit(有多少个4字节); 所以TCP头部最大长度是15 * 4 = 60。
- 6位标志位:
1. URG: 紧急指针是否有效。
2. ACK: 确认号是否有效
3. PSH(push): 提示接收端应用程序立刻从TCP缓冲区把数据读走。
4. RST(reset): 对方要求重新建立连接; 我们把携带RST标识的称为复位报文段。
5. SYN: 请求建立连接; 我们把携带SYN标识的称为同步报文段。
6. FIN(final): 通知对方, 本端要关闭了, 我们称携带FIN标识的为结束报文段。- 16位窗口大小: 后面再说。
- 16位校验和: 发送端填充, CRC校验。接收端校验不通过,则认为数据有问题。此处的检验和不光包含TCP首部,也包含TCP数据部分。
- 16位紧急指针: 标识哪部分数据是紧急数据。
- 40字节头部选项: 暂时忽略。
报头和有效载荷如何分离?
1. 答:通过4位首部长度,通过先读取前20个字节,读到之后提取4位首部长度 * 4就是报头大小,一般情况下求出来的是20,那么就分离出来报头和有效载荷了,这是一般没有选项的情况,如果携带了选项,比如说最后乘出来等于60,说明报头是60,已经读了20,再读40把选项读出来,此时剩下的就是有效载荷了。
tcp如何决定将自己的有效载荷交付给上层的哪个协议?
2. 答:通过目的端口号!(当然可以通过端口号通过哈希找到对应的进程)
什么是可靠?
在通信中,一般而言,我们认为只有我发的消息受到了回应,才算我发的消息可靠的被对方收到。
但是总有最新的一条消息是得不到响应的,所以也就是说在互联网中是没有100%的可靠的。
3.2 确认应答(ACK)机制
一般而言,发送的数据都要有响应,这是保证可靠性的底层策略!这种策略在TCP中叫做确认应答机制!
确认应答机制不是保证双方可靠性的机制,是保证我给你发的消息,你给我应答,你给我应答的消息你自己不确定,但是因为你给我应答了,所以是保证了我给你发的消息你给我应答了,这就是确认应答。
3.3 32位序号与32位确认序号
我们要像上面那种一个发起请求,一个响应,响应完之后再请求,效率是很慢的,所以这里其实是可以直接一次发大量的请求的。
但是我们知道,一次发送大量的请求,比方说发的是1、2、3,接收的可能是2、3、1或1、3、2等等,这样可能在对应的时候就会出现问题。
那么我们怎么保证客户端发送的数据在服务端是有序的呢?
TCP将每个字节的数据都进行了编号,即为序列号。
而报文的有序,也是可靠性的一种!
这样就有了 32位序号,如果发1号报文,32位序号就填1,发2号报文,32位序号就填2。所以当我们发送大量数据后,虽然它可能受到的顺序不同,但是根据序号可以进行重排(在传输层进行重排),排完之后按照有序的顺序放到TCP的接收缓冲区里,此时就是发的什么数据,接收的就是什么数据。
所以报文的有序是通过32位序号保证的。
但是还有一种情况,就是发了1、2、3号报文,但是2号报文在网络传送的时候丢失了,所以当对方在排序的时候接收到的是1、3,那么此时的server就要对client进行响应,server一方面要告诉client你发的报文我收到了,另一方面也要告诉client,2号报文丢失了,让它把2号报文重传一下。
当client发送一批数据的时候,server端是否有能力识别中间有一部分丢失了?
答:当然是可以的。比如说上面的1号序号和2001号序号,可是一号序号里面只携带了1000个字节,怎么编号直接编到2001了呢,其中的1000个字节没了,所以接收方就可以通过这样的起始序号+报文的有效载荷的长度去辨别一下个序号是多少,如果下一个序号和我收到的信号不匹配,就证明这个报文丢失。
确认信号(n)是对应的32位序号+1,代表告诉对方,你给我发送的n个字节之前的所有的字节我已经全部收到了,下次请从n+1开始进行发送。
像我上面说的那种情况,因为2号报头丢失了,就应该返回1001序列号,也就印证了确认信号返回的n+1表示n个字节之前的已经全部收到了。
所以序号+确认序号目前的功能有两个,一个是确认应答机制数据化的表述,第二个可以保证按需到达。
为什么要用两套序号机制呢?
答:TCP是全双工的!
也就是可能同时在进行双方通信!确认应答机制双方都需要。所以这样一个序号就不满足server既要给你响应,又要给你发送数据。而且如果是这样也不能确定server给你的响应是确认还是要发送的数据,所以必须要有两套序号。
3.4 16位窗口大小
我们知道,TCP是有接收缓冲区和接收缓冲区的,发送缓冲区上面已经说过了,而数据因为在传输过程中可能会丢失,所以发送的数据不会立马删掉,要暂时保留一会,等到对方接收到了再删掉(或者说是覆盖),如果没接收到那么则会进行超时重传,所以也会有发送缓冲区。这里是接收缓冲区和UDP的接收缓冲区存在的意义是相同的,都是因为在来到大量的数据的时候避免资源浪费。
所以我们现在就能得知,我们调用的系统调用接口:write/send是用户数据拷贝到发送缓冲区,read/recv是从内核TCP接收缓冲区拷贝数据到用户层。而不是直接从网络里写入和读取。
所以其实用户将数据放到发送缓冲区就没事了,剩下的数据如何发,怎么发,何时发,都是由TCP决定的,所以TCP叫做传输控制协议,它的传输控制体现在上层应用把数据给我,然后如何发、何时发、丢包了怎么办、要不要重传,这种机制完全由TCP决定,所以如果上层通过write/send、read/recv已经把数据传出去了,那么要传输控制协议干嘛呢。
所以其实客户端和服务器都是有一对接收缓冲区和发送缓冲区的,它们可以给对方同时发送数据和进行接收数据,这也就是上面说过的,TCP是全双工的。
后面我就通过单向通信来讲解,因为通信中他们俩个之间的地位的对等的,所以只要将单向通信明白了,双向通信只不过的角色互换一下而已。
这就是客户端和服务器之间的传输,它们的发送缓冲区和接收缓冲区都是有大小的。
但是当发送缓冲区很快的时候,就会很快的将server的接收缓冲区占满,等占满后再传数据的时候因为没有能力接收了,那么就将这个数据丢了,但是这是不合理的,因为我发过来的数据如果没有丢包,就说明我明明是正确的,但是你却把正确的丢了,这明显就是浪费资源。
所以,这时就如要让发送缓冲区发送的慢一点,但是这时大家可能会有疑问,难道快慢不是又用户层决定write/send的速度么,TCP怎么搞呢,这不是为难TCP么?其实不然,我们上面说过,用户层只管将数据放到发送缓冲区中,剩下的工作就交给TCP去完成了,也就是说,用户层和传输层通过缓冲区完成了解耦,让效率变得更高(生产者消费者模型)。上面说过,TCP如何传,怎么传,传什么等等都是由TCP自己决定,所以TCP是有这个能力的,大家一定要清楚!!!
那么这里是怎么做的呢?
比方说client给server发了7KB的数据,那么server在确认或者再传给client数据的时候,也会携带上我的接收缓冲区还有3KB这样的数据(上面说过,在响应的时候,也是回应完整的TCP报文),然后client就根据server返回的这个消息进行操作,比方说可以发的慢点,等server中的接收缓冲区的空间到达一定的限度,我再发的快点,或者我之间发3KB,然后就不发了,等server有一定的空间我再继续发。
这种功能是通过16位的窗口大小传输的,16位的窗口填充的是自身的接收缓冲区中剩余空间的大小。上面说的对应的方法就是通过16位的窗口完成的流量控制。
所以我们有的时候会因为再read/recv的时候卡住了,是不是底层没数据而卡住呢?是不是因为对方没有给我在网络里发数据呢?都不是!!笨猪卡住的原因是因为缓冲区里没有数据。当然当我们write/send的时候也可能被卡住,此时就是发送缓冲区满了。
这就是接收和发送的本质!
3.5 16位校验和
接下来看一下16位校验和,它就是为整个报文做校验,如果报文当中出错了,那么它就讲整个报文丢弃,也就是接收方丢弃,那么丢弃之后怕不怕没了呢?一点也不怕,因为此时会重传,那么此时就不考虑浪费系统资源了么?当然不考虑,因为此时数据出问题了,这是不可避免的,所以只能丢包,等对面重传。
3.6 6个标志位
SYN 与 ACK
接下来就看保留后面的那6个标志位:
我们知道client会发给server这么多种类的报文,那么tcp如何区分呢?为什么要区分呢?这么多种类,也就是这么多种报文,每一种报文是执行不同的动作,比方说建立链接本来就不是用户要发送给服务器的数据,它需要由操作系统在tcp层去建立连接,去执行三次握手动作,然后完成过连接建立的过程。
所以怎么区分,就是要使用这6个标志字段了。当然这里是为0表示没有,为1表示设置。
在通信中就是进行上面的操作,一般而言,ACK字段是被经常设置的,连接建立阶段,SYN被设置。
所以上面的通信是通过三步完成:
- SYN
- SYN+ACK
- ACK
三次握手属于通信细节,上层用户不需要关心,双方OS(TCP)自动完成。
其中接口中:
- connect():发起三次握手
- accept():获取已经建立好的连接,已经完成三次握手的连接
所以更验证了在三次握手的时候,上层用户不需要关心,也没有参与。
FIN
当我们在断开的时候发送FIN,需要进行FIN->ACK->FIN->ACK。
因为进行一次FIN->ACK只是断开了一方的数据传输,另一方还是可以传输的,所以只有双方都进行FIN,并且都经过对方的ACK才断开了。
我们现在大致懂了建立和断开,但是:
如何理解连接?
server:client = 1:n;
存在很多的客户端,连接server时,如果客户端同时向server发送报文,多个不同的客户端发送的报文都在服务器的接收缓冲区中,那不就冲突了么。其实可以简单的理解成,这里的重传什么的机制都是基于连接的,要不然通信之前要先建立连接的目的是什么呢,因为连接可以保存所有的TCP相关的可靠性机制、缓冲区这样的概念。
- 面向连接是tcp可靠性的一种
- 有大量的连接的时候,OS就要管理连接,既然要管理连接,就要"先描述,再组织",而且创建连接和销毁连接都是花费成本的->时间+空间。所以维护链接是有成本的!
如何理解建立和断开
释放曾经建立好的连接结构体字段。
3.7 16位紧急指针 与 前面没说完的标志位
URG
因为tcp是按需到达的,所以正常情况下,数据被对方的应用层在读取的时候,也必须是按序读取的,如果一个数据需要被提前读取,那么就需要用到URG + 16位紧急指针完成!
也就是如果URG这个位上如果是0,就不关心16位紧急指针,如果是1就关心16位紧急指针。
而且要说明的是:16位紧急指针是紧急数据在报文中的偏移量!
我们现在看来,只知道是从那个偏移量开始,那么有几个紧急数据呢?在tcp中只有一个紧急指针字段,当然在选项中也有一些16位紧急指针的字段。
PSH
如果上层数据取的非常慢,最后通过窗口返回给client没有空间了,这时client只能等待了,那么client又怎么知道recv buffer有数据了呢?
- server有数就给client发消息(Win:新大小),说是有数据了,你可以继续传输了。
- client每隔一段时间去问server,有没有空间啦~
那么如果client问server的时候,server老是没空间,那么client就生气了,把PSH标志位标成1,发送给server,这次就不单单是问server有没有空间,更是告诉server:尽快把你的缓冲区的数据交付给上层!
那么这时有的小伙伴就认为server很委屈,可能说又不是server的问题,是上层的问题,上层它不读啊,它读的慢啊,催server也没用啊。其中这里有一个细节。
当我们调用read();的时候,如果缓冲区有数据,那么read就会读到数据然后返回,如果没有数据,那么read就阻塞住了,用系统的角度就是调用read的进程被阻塞了。
我们一直理解的可能都是数据的有无!但其实缓冲区里是否有数据,不是严格意义上的有或者无,其实在缓冲区中有一个叫做水位线这样的概念。也就是有低水位(recv_low_water)这样的标志的。比方说低水位的100字节,那么如果缓冲区里的数据高于100字节才会被read读取,因为如果只有一个字节,那么read就会频繁读缓冲区里的数据然后返回,进而一定会影响我们读取的效率,因为从内核到用户状态的切换也要花时间。
所以这里的PSH就是告诉系统,你赶紧把数据交付给对方,让它读取,所以虽然没有到水位线,但是因为client已经用PSH催它了,所以它也就直接让上层读取了。
所以我们调用recv的时候,你期待读取的数据和你真正读到的数据(返回值)是不一定吻合的。
RST
我们在上面说建立连接是需要进行三次挥手的,然后我们也说过,只有收到ACK才能保证上次发送的报文被接收了,所以其实最后那一次ACK是不一定会被接收到的,也就是最后一次ACK丢了比较麻烦,因为前两次丢了会进行重传。也就是说,在建立连接的时候进行的三次握手也不是100%建立成功的。
首先我们得知道,报文在网络上传送需不需要时间?肯定是需要的!而且观察仔细的可能会发现,我每次在它们进行通信的时候画的都是斜线,这又是为什么呢?
其实这就是时间流逝。
client认为自己建立连接成功的时候是什么时候呢?
因为client不可能或者说是很难知道server收没收到最后的ACK,因为server没有给client应答。
所以,client只能认为,只要最后一个ACK发出,都认为字节建立好了连接!
那么server认为自己连接成功是什么时候呢?
server肯定不是在自己发出SYN+ACK的时候认为,因为自己连接成功不成功还没有收到对方的ACK,所以server肯定是通过自己收到ACK的时候,才完成三次握手,才认为自己连接建立成功。
所以client和server认为连接建立成功是有一点时间差的!
时间差不可怕,可怕的是最后一次ACK这个报文丢了,因为如果ACK丢失,client认为连接已经建立好了,server连接建立没有完成。
那么怎么办呢?
因为client认为连接建立好了,然后client给server发送数据,但是因为server不知道连接建立好了,所以client发送过来数据以后server很诧异,难道不是没有建立好么?为什么它发送过来数据了呢?为什么它认为建立好了呢?所以server这时就知道了,肯定是因为最后的ACK报文丢失了,所以此时server就会给client发送tcp->RST,给客户端,那么客户端立马就知道自己最后发送的ACK丢了,这时client就会重新建立连接,也就是释放现在的,重新进行三次握手。
当然这里因为方便只说了一方,其实双方连接有问题的时候都可以使用,RST就会进行连接重置。
3.8 超时重传机制
我们从开始到现在,没有说过丢包,因为丢包在tcp报头里体现不出来,而且我们前面讲的所有报头里面的字段里,没有一个可以得到哪个报头丢包了。
TCP保证可靠性
- 一方面通过协议报头体现的。
- 一方面是通过tcp的代码逻辑实现的。
所以重传就是通过代码设置某种定时器,也就是我发送完数据,一定时间内没有收到应答,然后我再重新发送,所以是通过编码实现的。
这里的丢包是分为两种的。
第一种是真的丢包了,就像上面第一个,A给B数据,然后丢包了,那么B是感受不到有数据的,所以,这时主机A没有收到应答,那么A就会进行超时重传。
还有一种可能是主机A发送的数据根本就没丢,但是主机B的ACK丢了,也就是应答丢了,这是有可能的!那么此时B会不会超时重传呢?不会的,因为主机B的ACK本来就不需要应答,虽然ACK丢了,但是主机B就不会管了,但是这时A就操心了,发过去了数据,但是主机B没有给应答,主机A可不知道是数据丢了,还是确认应答丢了,所以这时主机A还是会进行超时重传,还是会重新发送一遍数据。
那么这时又重新发了一次数据,会不会导致接收方数据重复的问题呢?用不用担心呢?其实是不会的,而且不用担心,我们上面讲了序号,所以这一个数据的序号是相同的,这里序号不但可以保证按序到达,而且还有去重功能!
那么这里的超时重传的时间如何确定呢?
因为如果传数据之后,在一定时间后没有收到确认应答,但是此时的数据还在路上,那么就还会重发,虽然不会对数据本身有影响,但是本来数据就没有问题的话,又重发一次,占用了系统资源,还是不好的,所以:
- 最理想的情况下,找到一个最小的时间,保证 "确认应答一定能在这个时间内返回"。
- 但是这个时间的长短,随着网络环境的不同,是有差异的。
- 如果超时时间设的太长,会影响整体的重传效率。
- 如果超时时间设的太短,有可能会频繁发送重复的包。
TCP为了保证无论在任何环境下都能比较高性能的通信,因此会动态计算这个最大超时时间。
- Linux中(BSD Unix和Windows也是如此),超时以500ms为一个单位进行控制,每次判定超时重发的超时时间都是500ms的整数倍。
- 如果重发一次之后,仍然得不到应答,等待 2*500ms 后再进行重传。
- 如果仍然得不到应答,等待 4*500ms 进行重传。依次类推,以指数形式递增。
- 累计到一定的重传次数,TCP认为网络或者对端主机出现异常,强制关闭连接。
随着网络环境的不同,是有差异的,是因为网卡的时候周期就长一点,网好的时候周期就短一点,所以也就决定了这个超时不能太长也不能太短,而且这个超时时间一定是浮动的,不能用编码定死。
所以太长影响影响整体的重传效率,太短频繁发送重复的包。
下面这个会有动态计算时间,但是不会一直重传,如果传了几次对面就是收不到,那么这时系统就可以判定连接出异常,或者对方的网络出异常,此时我们的连接就会被强制关闭。
3.9 连接管理机制
在正常情况下,TCP要经过三次握手建立连接,四次挥手断开连接。
这里为什么是三次握手,而不是一、二、四、五呢?
首先我们要知道,建立连接不是100%能成功的,换句话说不管是两次四次五次还是一百次,总有最后一次是不可靠的,那么既然这样,几次握手的依据是哪个优点更多。
那么下面给出解释:
- 全双工协议,连接建立的核心要务:首先要验证双方的通信信道是连通的。所以三次握手是验证双方通信信道的最小次数。
- 连接建立异常的情况下,一定已经已经建立的连接是在client端,也就是最后维护连接的要是client端。所以三次握手是需要奇数次。
第一条:是最小次数是因为,client要验证能收,能发。server也要验证能收,能发,所以三次握手是验证双方通信信道的最小次数。其中前两次是验证client能收,能发,后两次是验证server能收,能发。
第二条:建立的连接一定会消耗时间和空间,而且谁最后发ACK,谁就先建连接,所以如果最后的ACK丢了,client认为已经建立好了连接,而server不这么认为。换句话说,谁最后发送ACK,谁就先维护连接。所以如果最后是偶数次,也就是最后发ACK是server端,换句话说也就是如果最后的ACK丢了,那么这条连接就维护在server端,可是服务器是一对多的,所以如果很多是client连接server,而且很多的client最后的ACK都连接失败了,那么server就会有大量的临时连接被建立起来,而且还不做事情,还占了操作系统大量资源,那么就会导致操作系统短期之内无法建立或者接收新的内容。虽然最后还是有异常连接,但是不影响,因为最后的异常连接维护在client,是无影响的,不像在server端是一对多的,如果异常连接太多就会导致服务器出问题,有风险。这样维护在client端也是没风险的!因为client端不会维护太多的资源,所以通过风险转移,保证了连接建立。
这里为什么是四次握手呢?
- 信道都没有问题
- tcp是全双工,可以双向通信,所以在断开连接的时候要经过双方认可,至此才能关闭连接。
当我们在连接断开的时候,我们已经不需要验证双方通信信道是否通常了,因为前面我们一直在通信,所以我们在连接断开连接这个事情上,我们更重要的是在功能上通知对方,换句话说,如果client给server发送给FIN,server收到了,那么server就确认了已经把连接断开的信息给了server,如果server给client发送了ACK,client收到了,那么client就确认了,自身可以释放对应的缓冲区资源,而且server也保证的知道了这条消息,换言之这两次挥手在功能上就已经可以确认了client已经单向的向server断开连接,所以再两次挥手就可以实现server单向的向client断开。所以我们就可以通过四次挥手在功能上完成双方信道的关闭。
然后后面就说说在连接、断开的时候的状态。
连接时的状态:
这个的状态就很明确,当发送过去之后client就是SYN_SENT状态,当server确认之后就是SYN_RCVD状态,当client知道自己能收、能发后就是ESTABUSHED状态,当server收到client的确认,也就知道自己能收、能发了,那么此时server的状态就是ESTABUSHED状态了。
断开时的状态:
从FIN_WAIT_1 到 FIN_WAIT_2,表示的是client到server的连接断开了,CLOSE_WAIT表示server知道了对方想跟我断开了,所以其实CLOSE_WAIT是一种半关闭状态。
LAST_ACK表示server向client的"最后确认",其实也就是最后两次挥手时给server的FIN后起的名字,然后client端因为已经向server关闭连接了,然后收到server的FIN后给server确认后就会是TIME_WAIT状态,当然client不是立马进入CLOSED状态,然后当server收到ACK后,server会直接进入CLOSED状态,也就意味着server所申请的空间可以释放了。
但是最后一般是client必须得经过TIME_WAIT时间,然后才进入CLOSED状态。
所以我们下面要注重的状态是TIME_WAIT和CLOSE_WAIT。
CLOSE_WAIT:
我们知道,在四次挥手的时候是通过用户层的close(client_fd);close(server_fd);实现的。
那么如果只有client去close(client_fd)会是怎样的现象呢?
server端会存在处于CLOSE_WAIT状态的连接,因为server没有进行第三、四次挥手,这样的问题很大,因为server只有处于CLOSED状态才会释放连接资源,也就意味着有很多处于CLOSE_WAIT状态的话,就表示有大量的连接占着资源,系统的资源就会很少,所以如果后期我们发现server端存在大量的CLOSE_WAIT状态的连接,那么就需要排查一下自己的代码是否有bug,没有即时close(fd);
所以现在我们知道没有及时close。
- 造成fd泄漏(前面套接字讲过)。
- 连接资源没有释放完全。
为何主动断开连接的一方在四次挥手之后不会立马进入CLOSED状态?
在这四次挥手的时候,前三次的:FIN->ACK、FIN丢失了都没事,都会进行超时重传,但是最后一次挥手的ACK丢了就会有很大的问题。
因为我们前面说了,当client发送完ACK后,client的状态不会立马从TIME_WAIT状态到CLOSED状态,因为如果当client发送完ACK后立马处于CLOSED状态的话,也就意味着client的连接立马关闭,那么万一最后一次挥手的ACK丢了呢?
server给client发的FIN丢了没事,因为client没有收到,所以会进行超时重传,client对server的响应丢了,而且立马进入了CLOSED就会完蛋。因为对应server来说,不管哪个丢了,都会进行重传,但是因为client进入了CLOSED状态,那么server再怎么重传,client都不会有响应,一旦没有响应,server经过无数次尝试之后,最后也一定会断开连接。但是!在这个server重复的同时,也会短暂的维护一条废弃的连接,这样对于server来讲,也是不友好的,因为我们争取让断开连接的一方,也就是client方来维护这个成本。
所以只要双方没有断开连接,一切都还有余地。
为什么要进行等待TIME_WAIT?
当client收到一次FIN,然后对server进行响应,但是ACK在途中丢了,但是client还是处于TIME_WAIT状态,虽然server不会对client进行响应,但是server因为给client发送了FIN,超时重传的时间没有收到响应,那么就会进行超时重传,也就是说client第二次收到FIN就说明了client对server第一次FIN的响应是丢了的。
这时可能有人会问:那么如果第二次的FIN也丢了呢?因为client此时处于TIME_WAIT状态,如果第二次的FIN丢了,也就意味着client到server的信道出问题了,server到client的信道也出问题了,那么也就说明了client和server之间的网络出问题了,那么客户端就等TIME_WAIT时间退出就行了,因为此时client没有收到FIN,那么ACK也就不需要重传了。server经过多次尝试也就断开了。
这时可能又会有人说:这样不还是server经过多次尝试,然后尝试无果最后再断开么?这样就和上面的为什么不会立马进入CLOSED状态相同了么?因为这样做是最好的,而且这种情况虽然可能会发生,但是发生的概率很少。
所以:
- 第一个理由是:等待TIME_WAIT可以通过等待,较大概率的保证给最后一个ACK被对面收到。
- 第二个是:保证双方通信信道上面的正常数据在网络中尽可能的消散。
解释一下第二个理由:有没有可能你在断开连接的时候,历史上还有一些数据没有发送给对方呢?完全有可能,虽然tcp是按序到达的,但实际上网络中被对方先收到的可能就会出现断开连接的报文,比正常的报文先到,这是完全有可能出现的。所以我们等上TIME_WAIT的时间会保证双方通信信道上面的正常数据在网络中尽可能的消散。也就是我们在等的期间,双方的连接依旧维持着,那么曾经发送的历史数据可能在网络中还存在,所以给它一段时间,让它把历史数据消散掉。至于为什么要将历史数据消散,可以理解成为了防止历史上的数据对下次新通信产生影响。
通过上面所说,我们可以知道,对于我们来说,不是建立连接和断开连接就一定保证可靠性。所以现在再强调一下,我们前面说过,tcp是保证数据通信的可靠性,它是尽量的保证在连接建立之后,连接断开之前,在我们正常通信期间,通过确认应答机制,保证我们能够100%可靠,但是建立连接和断开连接,我们不能做到100%!
所以我们不要相信可靠万能论!!!
TIME_WAIT等待的时长是多少?
我们知道,TIME_WAIT太短的话就会没有达到它应达到的目的,没有较大概率的保证给最后一个ACK被对面收到,也没有达到历史数据的消散。太长的话会让对面维持一个较长的时间段、时间点,处于TIME_WAIT状态,其实也就是浪费资源的现象。所以这个时间段必须合适。
接下来带大家看到上面所讲的那几种状态:
CLOSE_WAIT和FIN_WAIT2:
1 #include <iostream>
2 #include <cstring>
3 #include <sys/socket.h>
4 #include <sys/types.h>
5 #include <arpa/inet.h>
6 #include <netinet/in.h>
7 #include <pthread.h>
8 #include <unistd.h>
9
10 #define NUM 5
11
12 void* Routinue(void* arg)
13 {
14 pthread_detach(pthread_self());
15 int fd = *(int*)arg;
16 delete (int*)arg;
17
18 while(true){
19 std::cout << "thread is running: " << pthread_self() << std::endl;
20 sleep(1);
21 }
22 return nullptr;
23 }
24
25 int main()
26 {
27 int lsock = socket(AF_INET, SOCK_STREAM, 0);
28 if(lsock < 0){
29 std::cerr << "lsocket error" << std::endl;
30 return 1;
31 }
32
33 struct sockaddr_in local;
34 memset(&local, 0, sizeof(local));
35
36 local.sin_family = AF_INET;
37 local.sin_port = htons(8081);
38 local.sin_addr.s_addr = INADDR_ANY;
39
40 if(bind(lsock, (struct sockaddr*)&local, sizeof(local)) < 0){
41 std::cerr << "bind error" << std::endl;
42 return 2;
43 }
44
45 if(listen(lsock, NUM) < 0){
46 std::cerr << "listen error" << std::endl;
47 return 3;
48 }
49
50 struct sockaddr_in peer;
51 for(;;)
52 {
53 socklen_t len = sizeof(peer);
54 int sock = accept(lsock, (struct sockaddr*)&peer, &len);
55 if(sock < 0){
56 continue;
57 }
58
59 std::cout << "get a new link: " << sock << std::endl;
60 int* p = new int(sock);
61 pthread_t tid;
62 pthread_create(&tid, nullptr, Routinue, p);
63
64 }
65
66 return 0;
67 }
我们这里写的是创建套接字,绑定之后进行监听,进入循环,进入循环之后如果收到客户端发起连接,那么就accept进行三次握手,成功后创建线程完成某种任务,当然这里的任务就是循环发 thread is running... ,然后线程没有退出,所以我们这里再使客户端退出之后,会进行前两次挥手,由于服务器一直在循环的再进行打印数据,没有close(fd),所以服务器不会退出,所以此时我们可以看到下面的图。看到client处于FIN_WAIT2状态,server处于CLOSE_WAIT状态。
TIME_WAIT
我们知道文件描述符是随进程的,我们进程关了,文件描述符也就关了,所以这里为了方便,我就不再动代码了,我们为了看到TIME_WAIT,我们就先关闭client,然后关闭server。
从上图可以看到,第一次我们先关闭client,效果就如我们上面所讲的一样,这里就不说了,然后关闭server,可以看到client进入了TIME_WAIT状态,server进入了CLOSED状态,因为server进入了CLOSED状态我们这里就查不到了,所以我们就这么看就可以。然后经过一段时间,在Linux中一般为一分钟,我们可以看到client的TIME_WAIT状态就消失了,其实是变成了最后的CLOSED状态。
那么接下来我们再看一个现象:
当我们连接上之后,我们直接让server断开,我们可以看到client直接断开了,而server直接变成了TIME_WAIT状态。
而且我们此时再次启动server进程的时候,发现 bind error ,然后又经过TIME_WAIT时间,server也退出了,此时我们也可以进行启动server进程了。
也就是在服务器TIME_WAIT状态的时候,服务器是无法立即启动的,根本原因在于,server在TIME_WAIT期间,资源是没有被完全释放的,没有被释放也就意味着这里的端口依旧是被占用的,所以你想再次绑定是不允许的。
TIME_WAIT解决方案
如果此时有一万个客户端在连接着服务器,当第一万零一个连接的时候,服务器挂了,退出了,进入了TIME_WAIT状态,因为服务器处于TIME_WAIT状态了,不能给客户提供服务了,此时服务器最重要的就是要重新启动,但是又因为是TIME_WAIT状态,我们此时不能立即启动,那不就有大问题了么,比方说在双十一的时候,别说等TIME_WAIT时间了,等一秒钟都是上亿的钱。所以我们要调整一下,即便是处于TIME_WAIT状态,也可以让服务器可以立马进行绑定。
我们可以使用下面的接口:
- listenfd:对监听套接字设置什么属性
- SOL_SOCKET:在哪一层
- SO_REUSEADDR:设置选项
- &opt:设置为1,也就是为真。
我们这要这样设置好就没问题了,即便是我是主动断开连接的一方,我也可以立马启动。
我们可以看到,当server主动断开,server的状态变成了TIME_WAIT,但是当我们再启动server的时候会发现可以直接启动了,不用等TIME_WAIT时间,也就是让那个TIME_WAIT等吧,我们再绑定一个端口。
当我们断开了客户端,断开了服务器,我们会发现我们还是可以查到我们之前连接的信息,之前连接的信息还是在系统中,所以也就是说这里的连接管理并不属于进程管理,而是属于tcp网络连接管理,所以进程管理和网络tcp是相对独立的单元。
所以这就更加证明了,tcp本身的连接管理是由它自己管理的,和进程运行状态并不是特别强相关。
3.10 滑动窗口
首先先明确,前面讲的是在连接和断开的两方进行讨论的,此时我们要在连接建立好了,在通信的基础上进行讨论。
我们说了确认应答策略,对每一个发送的数据段,都要给一个ACK确认应答。收到ACK后再发送下一个数据段。这样做有一个比较大的缺点,就是性能较差,尤其是数据往返的时间较长的时候。
既然这样一发一收的方式性能较低,那么我们一次发送多条数据,就可以大大的提高性能(其实是将多个段的等待时间重叠在一起了)。
那么我们就可以讲发送的时间放在一起,那么有人可能会问,既然可以多次发送,那么为什么不一次发送1~4000个数据呢?其实tcp发送的数据段也是有要求的,不是说可以发送无线大的。比方说每次发送的最大字节数是1500,这个后面再说。所以结论就是不可以!我们暂时可以理解为:也要考虑对方的接收能力。
什么是滑动窗口呢?
描述的是,发送方不用等待ACK一次所能发送的数据最大量。这里滑动窗口的大小是与TCP的窗口大小(对方的接收能力)是相关的。
- 窗口大小指的是无需等待确认应答而可以继续发送数据的最大值。上图的窗口大小就是4000个字节(四个段)。
- 发送前四个段的时候,不需要等待任何ACK,直接发送。
- 收到第一个ACK后,滑动窗口向后移动,继续发送第五个段的数据。依次类推。
- 操作系统内核为了维护这个滑动窗口,需要开辟发送缓冲区来记录当前还有哪些数据没有应答,只有确认应答过的数据,才能从缓冲区删掉。
- 窗口越大,则网络的吞吐率就越高。
滑动窗口:
- 提高效率
- 流量控制
- 超时重传
滑动窗口和窗口大小对比
- 窗口大小表示的是对方的接收能力,衡量的是对方接收缓冲区剩余空间的大小
- 滑动窗口是在自己的发送缓冲区中限定一块区域可以直接发送,暂时不用ACK。
此时看来窗口大小和滑动窗口的大小近似是相等的。
滑动窗口一定会整体右移么?
先说结论,不一定会!因为我们的发送方的滑动窗口大小是表示可以直接给对方发送的大小,当我们发送过去之后,如果对方的应用层拿了数据,而且是发多少拿多少,那么则会整体右移,但是如果我们发过去了,接收方响应了ACK,但是没有来得及处理,那么也就意味着对方的窗口大小为0了,也就是对方的接受能力为0,那么发送方的滑动窗口大小就滑不动了,此时的滑动窗口就不会在整体右移。
所以滑动窗口变宽变窄,是与接收方接受能力有关。所以是不一定!!右方还有可能一直不变。
其实发送缓冲区里就像是数组,数组的下标就是上图的第几字节,然后通过struct send_win可以来划分滑动缓冲区的范围,如果滑动缓冲区整体向右移动,也就是start和end都 +10。
而且这里的左边不会向又移动,也就是strart不会减少,虽然发送缓冲区是有大小的,但是不用担心它会越界,这里的发送缓冲区可以想象成我之前的博客里讲过的环形队列,所以是不会越界的。
如果出现了丢包,如何进行重传? 这里分两种情况讨论
情况一:数据包已经抵达,ACK被丢了。
这种情况是中间部分的ACK丢失了,但是是没关系的,因为当主机A最后收到ACK:6001就表示(ACK的含义)主机B 6001之前的已经全部收到了,请接着从6001开始发。
也就是允许丢失ACK响应而不需要重传!
情况二:数据包就直接丢了。
当主机A发送了7个数据包时,发现主机B响应回来的6个都是下一个是1001,如果主机B全部都收到,应该最后应该响应回来下一个是7001,所以大概率是可能在从主机A到主机B的传输中有数据包丢了,而且丢的包就是返回过来的下一个是1001,所以主机A就会重传1001-2000的数据包,当主机B收到后,然后给主机A响应的就是下一个是7001,这时主机A机会继续从7001继续开始传送数据包。
如果中间的2001-3000也丢了,那么就会是:先重传1001-2000,然后主机B给主机A响应的如果是2001~3000,那么主机A就重传2001~3000,直到响应是7001就不需要重传了。
这样就可以保证可靠性。
下面总结一下:
- 当某一段报文段丢失之后,发送端会一直收到 1001 这样的ACK,就像是在提醒发送端 "我想要的是 1001" 一样。
- 如果发送端主机连续三次收到了同样一个 "1001" 这样的应答,就会将对应的数据 1001 - 2000 重新发送。
- 这个时候接收端收到了 1001 之后,再次返回的ACK就是7001了(因为2001 - 7000)接收端其实之前就已经收到了,被放到了接收端操作系统内核的接收缓冲区中。
这种机制被称为 "高速重发控制"(也叫 "快重传")
快重传 VS 超时重传
这里说一下为什么有快重传还要有超时重传,这是因为快重传的条件是发送端主机连续三次收到了同样一个应答,才会触发快重传,那么如果没有触发条件,快重传就是不可以的,所以这里其实超时重传是保底策略,而快重传是提高效率的。
3.11 流量控制
接收端处理数据的速度是有限的。如果发送端发的太快,导致接收端的缓冲区被打满,这个时候如果发送端继续发送,就会造成丢包,继而引起丢包重传等等一系列连锁反应。因此TCP支持根据接收端的处理能力,来决定发送端的发送速度。这个机制就叫做流量控制(Flow Control)。
- 接收端将自己可以接收的缓冲区大小放入 TCP 首部中的 "窗口大小" 字段,通过ACK端通知发送端。
- 窗口大小字段越大,说明网络的吞吐量越高。
- 接收端一旦发现自己的缓冲区快满了,就会将窗口大小设置成一个更小的值通知给发送端。
- 发送端接受到这个窗口之后,就会减慢自己的发送速度。
- 如果接收端缓冲区满了,就会将窗口置为0。这时发送方不再发送数据,但是需要定期发送一个窗口探测数据段,使接收端把窗口大小告诉发送端。
这里减少自己的发送速度是通过控制滑动窗口来实现的,
这里是有两个策略的,不很需要担心丢包了,因为如果一个策略丢包了,那么另一个策略也会执行(两个策略都会执行),但是如果两个策略都丢了,这就证明双方之间的网络出了问题。
第一次发送数据的时候,A如何得知对方接收缓冲区大小?
因为这里主机B开始没有向主机A发送数据,所以主机A开始的时候是不知道对方的接收缓冲区的大小的,但是主机A要想给主机B发送数据,就应该要直到主机B的缓冲区大小。其实是有很多策略的,但是我们在应用的时候要选择最合适的,有的策略比方说是开始先发1字节,然后等主机B给主机A发送了它的接收缓冲区大小,我们再进行操作,这是没问题的,但是不是很合适。
其实在这里,主机A得知对方接收缓冲区大小的信息是在三次握手的时候协商了窗口的大小。
3.12 拥塞控制
在网络发送数据的时候,如果发送一万个数据,丢了三四个包,是很正常的,但是如果发送一万个数据,只被响应了三四个包,那么肯定就是有问题的。
所以当网络中发送数据的时候,出现了大量丢包,此时不是正常现象。
在我们上面说的流量控制、滑动窗口和现在要说的拥塞控制中:
流量控制控制的是发送方向接收方发送数据的效率,考虑的是接收方的问题。
滑动窗口为了提供效率,也是考虑发送方和接收方的数据交互,考虑的是发送方发送数据的效率。
一个是考虑发送方,一个是考虑接收方,可是为什么没有考虑到网络中发送时呢?
所以上面出现大量丢包的情况,是网络出现了拥塞问题。那么既然出现了拥塞问题,那么我们就要想办法解决它,当然网络出了问题,接收方和发送方貌似不能解决。但是雪崩发生的时候,没有一片雪花是无辜的,所以当网络出问题的时候,一定是网络中的大部分主机共同作用的结果,比如说一定时间内所有的主机大量的向网络里塞数据,最后导致网络的负担太大,在路上的关键节点排的报文太多。在路上就出现了大量丢包的情况,这些都是有可能的。
当网络拥塞时,正确的做法
发送方和接收方解决的方法就是不加重!
当然,有人可能跟会说,少量丢包重传就好了,大量的丢包为了保证数据正确的传输,还是要重传啊,虽然话是这么说,但是是不是当网络拥塞的时候,影响的就是你一个人?当然不是,这是影响的全片网,或者全片主机。
那么现在夸张一点说,就是全网都大量丢包,正常来说要说都重传,因为这时网络已经不堪重负了,在那个时刻全网还都在进行重传,这样只会加重网络的负担,所以重传是不可取的。如果少量丢包影响的只是我一个人,那么此时不会对网络有影响。
所以正确是做法是尽量不发,或者少发数据,等网络恢复再正常发送。
虽然TCP有了滑动窗口这个大杀器,能够高效可靠的发送大量的数据。但是如果在刚开始阶段就发送大量的数据,仍然可能引发问题。因为网络上有很多的计算机,可能当前的网络状态就已经比较拥堵。在不清楚当前网络状态下,贸然发送大量的数据,是很有可能引起雪上加霜的。
TCP引入 慢启动 机制,先发少量的数据,探探路,摸清当前的网络拥堵状态,再决定按照多大的速度传输数据。
我们在前面将的是,双方通信时会互相交互自己的窗口大小,所以彼此就相互直到了对方的接受能力,然后根据对方的接受能力再设置一下滑动窗口大小,然后保证一次向对方发送多少数据。但是其实这种是不太准确的,因为我们还要考虑一个窗口就是拥塞窗口。
拥塞窗口跟接收缓冲区和发送缓冲区没有关系,它其实是一个简单是数值,可以简单的理解成,拥塞窗口是:如果超过了这个窗口就有可能引起网络拥塞,如果不超过就不会引起网络拥塞,也就是可能引起网络拥塞的阈值。
拥塞窗口:一次发送的数据大于拥塞窗口,可能引发网络拥塞问题。
打比方说,虽然发送方和接收方的通信能力很强,发送方一次可以发送大量的数据,接收方也有接收能力,但是网络很差,一次发送少量的数据就会引起拥塞问题,所以它们的滑动窗口就不能单单的看对方的窗口大小(对方的接受能力),也要考虑网络的情况。
换言之,其实两台主机都有一个拥塞窗口,而且这个值也不是确定的,双方都会认为超过某一个值就会引发网络拥塞问题,所以实际上:
滑动窗口大小 = min(对方接收能力,自己拥塞窗口大小)
- 此处引入一个概念程为拥塞窗口。
- 发送开始的时候,定义拥塞窗口大小为1。
- 每次收到一个ACK应答,拥塞窗口加1。
- 每次发送数据包的时候,将拥塞窗口和接收端主机反馈的窗口大小做比较,取较小的值作为实际发送的窗口。
其实我们可以看到上图,拥塞窗口是按照指数级增长的。
但是我们不是因为发生了拥塞,使用慢启动算法么?但是拥塞窗口竟然是指数增长的。
- 先要确认网络是否通畅,会不会在少量数据的情况下依旧会丢包。
- 确认没问题的情况下,考虑尽快恢复我们的通信速度。
所以因为这个算法是基于这两个特点,所以前期的时候要慢一点,后期一定要快一点。
所以用指数增长的方式是很合适的 。
那么这时可能就又有人说,照这么增加下去,那么是不是可以发送的数据量越来越大,那么会不会一定又导致出现网络拥塞呢?答案是不一定的。其一是滑动窗口的大小不仅受拥塞窗口的大小,还受对方接受能力,也就是窗口大小的影响。
而且也不只是一直按照指数级增长的,就像上图我画的一样,我们尽快恢复通信速度,不是一直按照指数增长的那个区间去用,而是只是用一部分。所以当拥塞窗口超过一定的大小就不再按照指数方式增长,而是按照线性方式增长。
总结:
- 为了不增长的那么快,因此不能使拥塞窗口单纯的加倍。
- 此处引入一个叫做慢启动的阈值。
- 当拥塞窗口超过这个阈值的时候,不再按照指数方式增长,而是按照线性方式增长。
我们上面所讲的都是拥塞窗口的调整方式。
当在加法增大的时候,如果一不小心又进行了网络拥塞,所以立马就会进行乘法减小,乘法减小的意思是:下一次从指数增长变成线性增长的阈值不再是最开始的16了,而是上一次拥塞时拥塞窗口的一半。
拥塞控制这里,是有两个核心值,一个叫做拥塞窗口的大小,用来限定发送数据的量,还有一个是发送拥塞窗口的阈值,这个阈值是上一次发生拥塞窗口的一半,核心内容就是由指数增长变成线性增长的临界值。就是这两个数字,共同构成了拥塞窗口的控制。
- 拥塞窗口的大小
- 发生拥塞窗口的阈值(慢启动的阈值)
所以拥塞控制大部分来讲它是一种策略,这个策略一定是由算法实现的,而与这个策略相关的数据就只有两个,也就是上面的那两个。
是不是网络的情况是一直发发发,然后网络拥塞,再调整,慢启动,再发发发,再拥塞,再慢启动再发,整个互联网以这种方式去工作呢?肯定是这样的。
但是!不要想象成互联网上所有的主机跟流水线上的工作一样,每台主机都是发发发,同时进行减少,同时进行慢启动,指数级增长,不是这样。因为我们前面过,每台主机认为,拥塞窗口的大小并不一样,既然都不一样,那么每台主机的指数增长到线性增长的阈值都不一样,所以实际上真实的工作情况是互联网上的所有主机,你可能正在正常通信,他可能就检测到要进行拥塞避免了,经过不断的动态调整,大家可能以接近步调一致的方式运作。
当然除了服务器本身出了故障,出了问题,一般不是所有的主机都会一起进行拥塞避免。
3.13 延迟应答
如果接收数据的主机立刻返回ACK应答,这时候返回的窗口可能比较小。
- 假设接收端缓冲区为1M,一次收到了500K的数据,如果立刻应答,返回的窗口就是500K。
- 但实际上可能处理端处理的速度很快,10ms之内就把500K数据从缓冲区消费掉了。
- 在这种情况下,接收端处理还远没有达到自己的极限,即使窗口再放大一些,也能处理过来。
- 如果接收端稍微等一会再应答,比如等待200ms再应答,那么这个时候返回的窗口大小就是1M。
其实也就是不着急应答,但是不能超过超时重传的那个时间,那么就会有更高的概率使接收缓冲区中的数据被上层的应用层读走,那么接收缓冲区剩余的空间就变大了。
一定要记得,窗口越大,网络吞吐量就越大,传输效率就越高。我们的目标是在保证网络不拥塞的情况下尽量提高传输效率。
那么所有的包都可以延迟应答么?肯定也不是。
- 数量限制:每隔N个包就应答一次。
- 时间限制:超过最大延迟时间就应答一次。
具体的数量和超时时间,依操作系统不同也有差异,一般N取2,超时时间取200ms。
当然延时应答测试也可能会使接收方少发起一些ACK应答。
3.14 捎带应答
捎带应答也是大部分的工作方式。
在延迟应答的基础上,我们发现,很多情况下,客户端服务器在应用层也是 "一发一收" 的。意味着客户端给服务器说了 "How are you",服务器也会给客户端回一个 "Fine,thank you",那么这个时候ACK就可以搭顺风车,和服务器回应的 "Fine, thank you" 一起回给客户端。
3.15 面向字节流
创建一个TCP的socket,同时在内核中创建一个 发送缓冲区和一个 接收缓冲区。
- 调用write时,数据会先写入发送缓冲区中。
- 如果发送的字节数太长,会被拆分成多个TCP的数据包发出。
- 如果发送的字节数太短,就会先在缓冲区里等待,等到缓冲区长度差不多了,或者其他合适的时机发送出去。
- 接收数据的时候,数据也是从网卡驱动程序到达内核的接收缓冲区。然后应用程序可以调用read从接收缓冲区拿数据。
- 另一方面, TCP的一个连接,既有发送缓冲区,也有接收缓冲区,那么对于这一个连接,既可以读数据,也可以写数据。这个概念叫做全双工。
由于缓冲区的存在, TCP程序的读和写不需要一一匹配,例如:
- 写100个字节数据时,可以调用一次write写100个字节,也可以调用100次write,每次写一个字节。
- 读100个字节数据时,也完全不需要考虑写的时候是怎么写的,既可以一次read 100个字节,也可以一次 read一个字节,重复100次。
什么叫面向字节流?
发送方想怎么写就怎么写,接收方想怎么读就怎么读,也就是写入缓冲区里的数据没有格式上的要求的,
上层协议可以根据字节流来解释自身协议。
3.16 粘包问题
先说一下什么是粘包。
当接收方在接收缓冲区读取数据的时候,如果上层根据自己的协议识别的读,那么会是对一整个一整的报头读取,这时没有问题的,但是如果上层随便的读,那么就会出现一整个报头就读了一部分,或者读了一个多的报头,这样这里就会有很多读取上来的报头是没有意义的。这样的问题就叫做粘包问题。
解决粘包的常规做法:
我们通过上面知道了粘包问题的原因,所以我们现在只要明确报文和报文之间的边界便可以解决这个问题。
- 定长报文
- 特殊字符
- 定长+自描述
- 特殊字符+自描述(http)
思考:对于UDP协议来说, 是否也存在 "粘包问题" 呢?
- UDP使用的是 定长+自描述 -> 先是读取8字节,然后找到16为UDP长度,再决定后面还有没有数据。
- 对于UDP,如果还没有上层交付数据,UDP的报文长度仍然在。同时,UDP是一个一个把数据交付给应用层。就有很明确的数据边界。
- 站在应用层的站在应用层的角度,使用UDP的时候,要么收到完整的UDP报文,要么不收。不会出现"半个"的情况。
所以基于TCP的应用层可能存在粘包问题的原因就是:TCP是面向字节流的。但是面向字节流不是不好,因为这样就可以让tcp的工作只聚焦于数据发送上。
3.17 TCP异常情况
1、进程终止:因为文件描述符是随进程的,所以进程终止会释放文件描述符,仍然可以发送FIN。和正常关闭没有什么区别。
2、、机器重启:当我们关机的或者重启的时候,会有关机失败的时候,就是在我们关机的时候,就会有提示:有文档尚未保存,是否进行保存。这样的字样,所以其实在关机和重启之前,进程先是会被杀死,然后再关机,这部分和进程终止的情况相同。
3、机器掉电/网线断开:接收端认为连接还在,一旦接收端有写入操作,接收端发现连接已经不在了,就会进行reset。即使没有写入操作,TCP自己也内置了一个保活定时器,会定期询问对方是否还在。如果对方不在,也会把连接释放。或者client长时间不访问server,也会把连接释放。
这种方式就是基于保活定时器的一种心跳检测机制,一般应用层可能会自己实现。
TCP小结
为什么TCP这么复杂?因为要保证可靠性,同时又尽可能的提高性能。
可靠性:
- 校验和
- 序列号(按序到达、去重)
- 确认应答
- 超时重发
- 连接管理
- 流量控制
- 拥塞控制
提高性能:
- 滑动窗口
- 快速重传
- 延迟应答
- 捎带应答
这里还可以从 数据 和 策略 两个方面进行探讨。
- 数据:序号、确认序号、紧急指针、六个标志位、窗口大小、tcp的连接管理、tcp的流量控制、tcp的确认应答... 这样的策略是能够用数据体现出来的,而且还可以在报头里看出来的。
- 策略:拥塞控制、滑动窗口、快重传、超时重传、延迟应答、捎带应答... 这些我们是在报头里看不出来的,它本身是一种策略,其实也就是通过代码、逻辑实现的。
还有就是定时器的概念:在超时重传或者前面博客说过的信号中都有定时器的概念,也就是经过一段时间,会做出相应的策略,比方说重发、重传。
其实上面讲的这些内容都是策略,就是给你一个目的,达成某种效果,但是具体怎么做,它是不关心的。
确定是的做什么和如何做,比方说确认应答,做什么?做应答。如何做?我发个数据,对方要给我确认。
所以这些其实是提供决策支持的,提供理论支持的,比方说数据在传输过程中丢包了,那么这时就该重传,因为这是tcp告诉我要重传的,然后超时重传比方说是10ms,这也是tcp说的。
一方面有人做决策,那么就一定有人做执行 -> IP+MAC,这个我会在后面的博客中单独拿出IP和MAC去讲解。
基于TCP应用层协议
- HTTP
- HTTPS
- SSH
- Telnet
- FTP
- SMTP
在我们上面的学习中用过telnet,而且我们一直用的Xshell底层其实就是SSH协议,比方说创建SSH渠道......
4. TCP/UDP对比
我们前面说了TCP是可靠连接,那么是不是TCP一定就优于UDP呢? TCP和UDP之间的优点和缺点,不能简单,绝对的进行比较。
- TCP用于可靠传输的情况,应用于文件传输,重要状态更新等场景。
- UDP用于对高速传输和实时性要求较高的通信领域,例如,早期的QQ,视频传输等。另外UDP可以用于广播。
归根结底,TCP和UDP都是程序员的工具,什么时机用,具体怎么用,还是要根据具体的需求场景去判定。
用UDP实现可靠传输(经典面试题)
参考TCP的可靠性机制,在应用层实现类似的逻辑。
当然还可以给面试官进行深度沟通,你可以细的问一下,这个要使用可靠的UDP协议,它的应用场景是什么,因为TCP是要保证所有的情况都要可靠,可是有的时候不同的应用场景并不需要TCP中那么重的可靠。比方说我们在通信的时候只需要按序到达和确认应答就够了。所以我们在模拟实现的时候,侧重点就不一样。
- 引入序列号, 保证数据顺序
- 引入确认应答, 确保对端收到了数据;
- 引入超时重传, 如果隔一段时间没有应答, 就重发数据;
- ......
5. TCP 相关实验
5.1 理解 listen 的第二个参数
接下来我们还是通过代码带大家去了解。
1 #include <iostream>
2 #include <cstring>
3 #include <sys/socket.h>
4 #include <sys/types.h>
5 #include <arpa/inet.h>
6 #include <netinet/in.h>
7 #include <pthread.h>
8 #include <unistd.h>
9
10 #define NUM 2
11
12 void* Routinue(void* arg)
13 {
14 pthread_detach(pthread_self());
W> 15 int fd = *(int*)arg;
16 delete (int*)arg;
17
18 while(true){
19 std::cout << "thread is running: " << pthread_self() << std::endl;
20 sleep(1);
21 }
22 return nullptr;
23 }
24
25 int main()
26 {
27 int lsock = socket(AF_INET, SOCK_STREAM, 0);
28 if(lsock < 0){
29 std::cerr << "lsocket error" << std::endl;
30 return 1;
31 }
32
33 int opt = 1;
34 setsockopt(lsock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
35
36 struct sockaddr_in local;
37 memset(&local, 0, sizeof(local));
38
39 local.sin_family = AF_INET;
40 local.sin_port = htons(8081);
41 local.sin_addr.s_addr = INADDR_ANY;
42
43 if(bind(lsock, (struct sockaddr*)&local, sizeof(local)) < 0){
44 std::cerr << "bind error" << std::endl;
45 return 2;
46 }
47
48 if(listen(lsock, NUM) < 0){
49 std::cerr << "listen error" << std::endl;
50 return 3;
51 }
52
W> 53 struct sockaddr_in peer;
54 for(;;)
55 {
56 sleep(1);//这个没有意义,只是为了不让这个循环太快造成卡顿
57 }
58
59 return 0;
60 }
也就是服务器允许客户去连,但是服务器并不accept客户。
而且我们可以看到,我在设置listen的第二个参数时,为了方便操作,所以设置成了2。
我们启动服务器后先检查下状态。
当我们连接上一个的时候连接成功了,而且可以通过netstat查到。
第二个也成功连接了。
第三个也成功连接了,然后我们接着向下看。
我们可以看到,当第四个连接过来的时候不是ESTABLISHEND状态,也就是不是连接成功的状态,而是SYN_RECV状态,也就是连接一直没有收到ACK包。
甚至到第五个连接的时候,我们还发现我们只能查到一个客户端处于SYN_RECV状态,而且它们的端口绑定的也不是同一个。然后经过一段时间,处于SYN_RECV状态的进程也退出了,其实是没有收到ACK,长时间没有应答。
所以刚才我们最多是连接了五个,但是只有三个是处于ESTABLISHEND状态,也就是剩下的就不接收进的连接了,最多也就是只收到SYN,但是不去响应,所以也就有了ACK_RECV状态。
也就是tcp现在最多就连三个,再来连接就不给予ACK,所以tcp是有自己的连接拒绝策略的。
最开始的时候我说过,这次的实验我讲listen的第二个参数设置成了2。
所以现在可以说了,listen的第二个参数叫做tcp所能接受的底层ESTABLISHEND的个数,也叫全连接的个数。
如果我作为tcp来讲,我把已经建立好的连接来不及接收,我们刚刚的实验现象是刚才我根本没有accept,但是底层连接是建立好的,说明accept根本不参与三次握手,因为底层已经建立好了,accept的核心工作是只要把建立好的连接拿上去,让我们拿到文件描述符进行访问就好了,所以这里和我们前面讲的读取recv是一个概念,所以listen第二个参数代表我们底层已经建立好连接的连接队列的长度,一般我们有效建立好的连接的长度是我设置的listen第二个参数的值+1,比方说我们设置的值是2,那么底层允许你能成功建立连接的个数就是3。
Linux内核协议栈为一个tcp连接管理使用两个队列:
- 半链接队列(用来保存处于SYN_SENT和SYN_RECV状态的请求)
- 全连接队列(accpetd队列)(用来保存处于established状态,但是应用层没有调用accept取走的请求)
当然这个队列不能太长,也不能没有。
下面我再次验证一下这个参数,此时我设置成3:
我们可以看到,现在连接成功的已经是4个了,接下来我们继续连接。
我这里直接又多连接了五个,这里可以看到半连接的个数变多了,从开始半连接只允许有一个,到我将第二个参数改为3后,半连接数变成了更多(这里我也不知道最多多少,我也没试,这个也没有准确值,基于服务器的设置)。
最后处于SYN_RECV状态的也都退出了。
为什么底层要维护连接队列呢?不能没有也不能太长?
首先,一般服务器的压力不大,那么连接队列上基本是没有连接的,因为服务器压力要说不大,一单有连接,服务器就会直接读走,上层就处理了,一般需要使用连接队列的时候实际上表示服务器的压力有点大了。
可以当内部有客户离开的时候,立马可以从连接队列选区一个连接进行处理(保证服务器几乎100%工作),这就是为什么底层要维护连接队列。
至于为什么不能太长,这是因为:如果队列太长也就意味着队列尾部的客户一定要等很长时间,那么这匹用户一定会流失、体验不好,而且时间太长,客户端就可能放弃等待,在服务器那里要维护这个连接请求也是需要成本的。
所以:维护队列是有成本的,与其维护长连接造成client等待过久,并且占用暂时用不到的资源,不如把这些资源的部分资源节省出来给server使用。
那么我如果是这么个用户:只给server发送SYN,然后自己不会对你的SYN+ACK进行响应,可以当成,我给server发送了SYN然后我就退出。因为全连接都是从半连接来的,在server的半连接中会维护一个半连接节点,但是对方又不给它响应,所以这个半连接节点就一直会处于维持的一种状态,可是半连接的长度是有限的,那么要是有大量的客户端都是只给你发SYN,但是不给你响应,也就是半连接中是没有连接建立成功的节点,那么也就不会将连接放到全连接队列中,并且这些半连接节点会占用半连接队列,等占满后导致新来的客户刚想建立连接,直接就被拒绝了,此时半连接队列里存在着大量废弃连接。
- 半连接队列里存在着大量废弃连接
- 新来想建立连接,直接就被拒绝了
这种攻击方式就是SYN洪水攻击。
当然也是有应对措施的,当服务器发现一个IP地址一直发送连接请求,但是从来不应答,那么服务器就会将你的IP地址填入服务器的黑名单中,也就是当你再发来请求,服务器一概拒绝。
接下来就说一下TCP的解决方案:首先当然不能将半连接队列搞的很长,这个我们想想就知道。所以我们的TCP提供了一套syncookie的机制,也就是现在服务器再维护一个连接,如果你发送过来一个SYN请求,那么此时我给你响应,响应的时候是有初始序号的 -> seq+syncookie(随机值)。然后当你发送的响应的时候也会带上这个syncookie,然后服务器就会在那个维护的连接(保护队列)中通过syncookie匹配到对应的连接,如果匹配成功了,再将这个连接放到全连接队列里,此时这个连接就建立成功了。
它最大的意义就是可以将半连接队列节省出来,也就是以后所有发送的非法连接都不会出现在半连接上,而是出现在保护队列上,然后就可以定期去扫描保护队列,凡是长时间没有得到相应的就给它释放掉,然后半连接中只要你给我发送SYN请求,那么我就会给你发送SYN+ACK响应,然后将这个连接挪走,也就是这个半连接一直可以有客户来连,也就不会存在因为大量肉鸡上面的非法SYN请求占用半连接队列的情况,这样就可以解决这个问题,这就是syncookie的意义。
5.2 观察三次握手过程
启动好服务器。
使用 telnet 作为客户端连接上服务器:
telnet [ip] [port]
抓包结果如下:
观察三个报文各自的序列号和确认序号的规律。在中间部分可以看到 TCP 报文详细信息。
5.3 观察确认应答
在 telnet 中输入一个字符:
可以看到客户端发送一个长度为 1 字节的数据,此时服务器返回了一个 ACK 以及一个 9 个字节的响应(捎带应答)。然后客户端再反馈一个 ACK(注意观察序列号和确认序号)。
5.4 观察四次挥手
在 telnet 中输入 ctrl + ],回到 telnet 控制界面,输入 quit 退出:
实际上是 "三次挥手",由于捎带应答,导致其中的两次重合在了一起 。
如上就是 HTTP协议----对于传输层的详细讲解 的所有知识,如果大家喜欢看此文章并且有收获,可以支持下 兔7 ,给 兔7 三连加关注,你的关注是对我最大的鼓励,也是我的创作动力~!
再次感谢大家观看,感谢大家支持!