其中TCP就属于传输层,并且端口号也是在传输层起作用。
目录
TCP协议报头
可靠性
32位序号
16位窗口大小
六个标记位
三次握手四次挥手
RST
PSH
URG
16位紧急指针
FIN
socksetopt
可靠性机制
确认应答(ACK)机制
超时重传机制
连接管理机制
三大机制
流量控制
滑动窗口
总结
拥塞控制
知识铺垫
概念
总结
延迟应答
知识铺垫
概念
捎带应答
TCP小结
三个问题
面向字节流
粘包问题
TCP异常情况
基于TCP应用层协议
TCP VS UDP
充分理解面向字节流
用UDP实现可靠传输(经典面试题)
TCP 相关实验
理解 listen 的第二个参数
知识铺垫
概念
TCP协议报头
#问:几乎任何协议都要首先解决的两个问题:a、如何分离(封装)? b、如何交付?
- 如何交付?
我们可以发现,其是有一个16位的目的端口。于是就可以直接根据目的端口,直接决定TCP协议在传输层当中,如果底层收到一个数据,通过目的端口向上进行交付。
- 如何分离(封装)?
TCP协议的报头,采取的是标准长度:20字节。于是便可以收到一个TCP报文时,直接先拿出来前20个字节(拿到报头)。
而在报头和有效载荷直接还有一个东西:选项。
TCP的报头是变长的,其可以在TCP中携带一些TCP通讯时相关的选项。
#问:那一个携带相关选项的变长报头,如何确定其大小?
这就与TCP报头里的有一个数据相关了!
4位首部长度,其是在标准长度20字节的长度里的,其包括20字节和选项长度。取值范围:0000 ~ 1111 = 0 ~ 15,但是标准长度报头就是20字节了啊,更何况还会有选项的边长。其实它的单位不是字节,而是4字节 -> 0*4 ~ 15*4,所以一个真实的TCP报头长度是:[20,60]。
即:
- 提取20字节。
- 根据标准报头,提取4位首部长度 * 4 = 20 - done。
- 读取 [ 提取4位首部长度 * 4 - 20 ] 字节数据,选项。
- 读完报头,剩下的都是有效载荷。
在此我们会发现一个问题:TCP是没有整个报文的大小的,或者说是有效载荷的大小!而UDP很明显的是携带报文长度的!
UDP能做到在调用recvfrom的时候,在系统层面上读到的报文就是一个独立的报文,但是TCP是做不到的,因为TCP是面向字节流的。所以原则上它无法判定报文和报文的边界,也不需要判定。因为在TCP看来,其所收到的所有数据,其只要将它的那一部分(报头)拿走,剩下的交付给上层,至于这个数据的二进制字节流当中给如何被进行解释,其都完全不关心,是应用层应该关心的。
底层中,这里所谓的报头实际上叫做:struct tcp_hdr。传说中的tcp报头,以及未来的所有报头,其实就是一个结构体类型(位断)。
融汇贯通的理解:
TCP报头是位断类型,也就是说当上层交付下来一个报文的时候,或底层读上来一个报文的时候,在内核当中对应的报头:struct tcp_hdr对象。是对象也就证明其是可以被拷贝的,所以:所谓的拼接的过程就是将,对应的不同的字段拷贝到一起。提取也就是:利用起始地址指针,对起始地址指针做类型强转,便就直接提取到了报头了。强转之后,便可以直接利用指针,指向给结构体类型(位断)里的不同字段,便可以得到4位首部长度,然后利用起始地址指针 + 4位首部长度 -> 便直接定位到了有效载荷部分。
可靠性
(此处只讲到了最核心的可靠性之一)
#问:是什么原因造成的不可靠?
就如同两个人在进行聊天:一次是近距离面对面聊天,一次是一个人在楼上一个人在楼下进行聊天(喊话)。第一次小声说话也听得见,第二次大声喊也不一定听的清。从此我们可以发现,距离远近的不同,造成的聊天的成本也是不同的,远距离的聊天成本增加了(可靠性是无法保证了,容易丢包)。
当两个进行网络通讯的主机设备相差的距离很遥远,就有可能在中间设备中出现丢失,距离越长参与的中间设备。所以导致的不可靠,单纯的就是距离边长了。
操作系统单机内部,不谈协议,不谈TCP / IP,而一旦涉及到了网络,就需要谈到TCP / IP协议。
可靠性:一方发消息能够保证,另一方一定收到消息。
(此处为了方便,假设有两个距离很远的主机:主机A、主机B)
当主机A发送信息给主机B的时候,主机A是无法知道,主机B是否接收到了消息。而当主机B回应了主机A,并且主机A接收到了主机B对其的应答,那么主机A就能够100%保证,主机B接收到了它的消息。但是,相对的主机B无法确定自己的应答主机A是否接收到,只有当主机A应答了主机B,主机B才能100%保证,主机A接收到了它的应答。
#问:网络中存不存在100%可靠的写协议?
不存在!无论是主机A还是主机B都无法保证自己作为最新发送数据的一方,发送出去的数据是否被对方收到。所以,在宏观上:是不存在100%可靠的协议的。但是,在局部上:我们能够做到100%可靠(当发送方接收到另一方的应答时,是能够100%保证的)。
局部可靠性本质:主机A发送出去的所有的消息,只要有匹配的应答,主机A就能够保证,其所发送出的消息主机B一定接收到了 —— TCP协议的确认应答机制,原则:无法保证最新的报文的可靠性,但是只要一个报文收到了对应的应答,就能保证我们发出的数据对方收到了。
如此,就会出现一个问题: 发送的顺序,不一定是接收顺序 —— 这个问题也是一个不可靠的问题(数据包乱序了)。
#问:client如何确认,哪一个应答,会对应哪一个请求?
所以我们就需要将请求和应答一一对应上,这样客户端才能更好的确认,哪一个报文是对方接收到的。所以便有了TCP报头中的一个概念:32位序号。
32位序号
因为一定能保护每一个报文,一定是携带了完整的报头的TCP报文,则一定有32位序号。
序号代表的就是,报文在发送出去的时候,可以给它携带序号。
当server端进行应答的时候,其一定需要填上确认信号,而确认信号其里面一般都是,他所收到的报文 +1。
我们需要注意此处的确认序号,定义并不是确定一个序号的报文接收到(TCP没有这么定),而是:确认序号对应的数字,之前的所有的报文已经全部收到了!告诉对方,下一次发送,就从确认序号指明的序号发送。
于是,就是说即使客户端没有接收到2001,但是只要接受到了3001,根据定义序号2000的报文也接收到了。
序号和确认序号:
- 将请求和应答进行一一对应
- 确认序号,表示的含义:确认序号之前的数据已经全部接收到了。
- 允许部分确认丢失,或者不给应答。
融汇贯通的理解:
发送数据的时候,如果真的是某一个报文丢失了,是一定接收不到的,比如说是2000丢了,只收到1000和3000。再怎么处理,server的确认序号不能给3001,只能1001。就是因为概念:确认序号对应的数字,之前的所有的报文已经全部收到了!所以,由于2000没有接收到,所以并不能确认序号3001,只能应答确认序号1001。
#问:为什么要有两个字段数字?一个:发方是序号,应答方是确认序号,不行吗?
TCP是全双工的!任何一方,既可以收,又可以发。如果server端既想给对方确认,又想同时给对方进行发送它的消息。在正常的TCP通讯中,往往给对方发送消息,本身就是应答。所以当server端既想确认应答又想发送消息,就同时需要 序号 与 确认序号 。
所以:任何通讯的一方,工作方式都是全双共的,在发送确认的时候,也可能携带新的数据。
- 发序号:1000 2000 3000 -> 发确认序号:1001 3001 2001,可能是乱序的。
这同时也是一个很大的问题,有可能客户端发送的顺序就是其所需要的申请的顺序,而网络有必须要保证数据的按序到达。而有了序号也就不用担心了,可以根据接收到的不同的序号进行排序,以此达到报文按序到达。
总结:
序号和确认序号是为了支持,确认应答和按序到达 —— 序号和确认序号的作用。
TCP是有接收缓冲区和发送缓冲区的。
所以:TCP是传输控制协议!
换句话说,TCP协议有接收和发送,同样的客户端和服务器都有,所以本质上讲:TCP通讯其实是,双方在进行通讯时,是发送方的发送缓冲区和接收方的接收缓冲区,以及接收方的发送缓冲区和发送方的接收缓冲区,两个缓冲区之间进行来回的拷贝(拷贝的介质是网络)。所以读取数据和发送数据,就是在对应的缓冲区里进行读写。
融汇贯通的理解:
因为TCP有发送和接收缓冲区。所以,如果我们有Client和Server,我们便就有了两队接收和发送缓冲区。是两对互相独立的缓冲区对,所以TCP是全双工的。
16位窗口大小
如果客户端发送数据太快而导致客户端来不及接收数据,而作为TCP协议将费尽千辛万苦传送过来的报文,就因为自身来不急接收就抛弃报文。抛弃一堆数据并未出现错误的报文,这是不合理的。而所有的主机都遵守这样的规则,这无疑对网络资源是一种浪费。
而同样的,如果一个客户端发送数据的速度太慢,服务器接收数据的速度太快,一直“嗷嗷待哺”的服务器一直等待,这也是资源的浪费。
所以:Client发送数据,既不能太快,也不能太慢。
#问:如何保证发送方发送数据,不要太快 / 太慢?
给发送方同步自己的接收能力!
#问:接收方的接收能力,由上面决定?
接收缓冲区中剩余空间的大小!(因为数据都是发送到接收缓冲区里面)这种策略称作为:流量控制。而16位窗口大小就正是接收缓冲区中剩余空间的大小。
我们知道网络TCP协议的网络通讯,一定是会有对应的报文,就一定会由对应的报头。
#问:该报头中的16位窗口大小填的是自身的剩余空间的大小还是接收方的剩余空间的大小?
不用迟疑就是自己的剩余空间的大小。
- 对方的大小我们怎么可能知道(就是不知道大小才进行的传递)。
- 这个报文就是发送方自身构建出来的,就是要让对方知道发送方情况的。
六个标记位
其是以1个bit位表示某种含义的。
#问:为什么需要多个标记位?
服务端是会收到大量的不同的报文,不同的报文可能处理的方式是不一样的。所以作为一个面对成百上千客户端的服务器,是需要能够有效的甄别出不同客户端发出的不同的报文类型。多个标记位本质:标记报文类型。
#问:各个标记位都是什么含义?
基础的三个:
(后三个,后面深入再进行讲解)
- SYN:该报文是一个链接请求报文。
- FIN:该报文是一个断开链接请求的报文。
- ACK:确认应答标志位,凡是该报文具有应答特征,该标记位都会被设置为1。
在正常通讯的时候,大部分网络报文ACK都是被设置为1的。(第一个链接请求报文ACK为0)
三次握手四次挥手
#问:如何理解链接?
因为有大量的Client将来可能链接Server,所以Server端一定会存在大量的连接,那么操作系统就需要对这些连接进行管理 —— 先描述,再组织。
所谓的连接:本质其实就是内核的一种数据结构对象,建立连接成功的时候,就是在内存中创建对应的连接对象!再对多个连接对象进行某种数据结构上的组织。
一个数据结构对象进行存储,是需要花费空间的,花费时间的 —— 维护连接是有成本的(内存 + CPU资源的花费)
#问:如何理解三次握手?
双方在进行建立连接的时候,它们自身的状态是会发生特定的变化的。
(图的线之所以会是斜线,是因为时间,接收方的发出的时间一定早于接收方接收的时间)
注意:三次握手对服务端和客户端都要起效。说白了就是:只要认为连接建立成功,那么客户端和服务端都要保护双方都是三次握手。
#问:是不是三次握手一定要保证成功?
是不能保证一定握手成功的,只能保证较大概率握手成功,因为可靠指的是连接成功之后,通讯的时候能够根据ACK保证历史数据的可靠。
而且可以从三次握手的图来看,前面两次握手是有应答的,而最后一次握手确是没有应答的,所以最后一次到底到了没无从知晓。
三次握手不一定能够保证成功!
融汇贯通的理解:
- Client:发一次消息 -> 收一次消息 -> 发一次消息。
- Server:收一次消息 -> 发一次消息 -> 收一次消息。
所以对于无论是客户端还是服务端,都是三次,所以:三次握手对服务端和客户端都要起效。
我们需要认识到,在三次握手期间,我们根本不用担心第一次丢包 / 第二次丢包,因为它们一定会有对应的应答,所以前面两次丢包一定会有对应的反馈,客户端和服务器都能够知道,能够做出后续的策略。
作为客户端,在三次握手期间,其实在第三次的ACK一经发出,一瞬间就会认为自己建立连接好了,但实际上很有可能客户端压根就没有收到这个ACK。
#问:为什么要三次握手?一次?两次?四次?不行吗?
- 如果是一次握手:
即,客户端认为只要简单粗暴的发送一次SYN就建立好了连接,那如果客户端向服务端发送大量的SYN呢?
服务器维护连接是有成本的(内存 + CPU资源的花费),换句话说就是一个黑客,用一台电脑就简单的向服务器发送大量的SYN,就会导致一瞬间就将服务器的可用资源用完了,所以一次握手是不行的。
这种利用发送大量的SYN,来大量消耗服务端资源的情况,叫做:SYN洪水。(这样的连接充满了明显漏洞)
- 如果是两次握手:
即,客户端向服务端发送了一个SYN,服务端再直接向客户端发送SYN+ ACK,然后连接建立好了。服务端认为连接好了,是只要第二个报文发出后就认为了。服务器无法保证自己第二次握手的时候,自己发出的SYN+ ACK被客户端收到。
如果一个客户端大量的发送SYN,并且其知道一定会收到ACK,但是不用就是直接丢弃,然后就无脑发送SYN,于是服务器照样被灌满大量链接。(这样的连接充满了明显漏洞)
- 三次握手:
当客户端发送最后一次ACK的时候,是只要客户端把ACK发出了,客户端就认为连接建立好了。而对于服务器来讲,其必须要收到这个最后的ACK保证三次握手完成,才认为建立连接成功。
换句话说:服务器建立连接成功的时间点,相对于客户端建立连接成功的时间点要延后那么一点点。也就是说让服务器维护连接时,客户端一定也要维护起连接 —— 客户端可以,以发大量请求攻击服务器,但是要是进行了攻击,服务器所承受的连接,客户端一定也要承受等价的连接。
以此达到防止单机的情况下无法对服务器发起攻击,因为服务器的配置比客户端的配置大太多了。
融汇贯通的理解:
最后一次ACK服务端并未接收到,客户端却认为连接建立成功了,这个时候,成本是嫁接到客户端的。
所以,三次握手就是以最小的成本,让连接在建立的时候,不会过度的去消耗服务端的资源。
但是,实际上TCP的三次握手,TCP在需要保证连接安全这个问题,并不是简简单单的靠三次握手就能够解决的(本身也不是为了安全问题而设计的,只是顺带的罢了)。
但是,架不住有黑客有很多的机器,比如说:其利用木马病毒入侵用户端口,然后木马病毒在入侵的笔记本后端,开了一个端口,然后这个端口可以接收来自黑客的指令(以此控制了上万台电脑)。然后拿着上万台机器的ip地址 / 其他信息,然后在一个时间点向所有主机发送请求,请求内容是向一个网站发送请求,这样也可以将服务器搞崩。
所以其会有很多安全得到处理方式,比如说甄别非法请求等,像如一个客户端在短时间内发送大量的请求,于是这个客户端的ip立马加进黑名单。而如公司内网的机器,就可以直接添加进入白名单。
- 为什么三次握手:
- 是双方在建立共识的过程。
- server可以嫁接同等的成本给client —— 在不考虑恶意用户的情况下,确保了服务器的安全。
- 验证全双工 —— 保证任何客户端与服务端都要能收又能发。
- 四次握手?
三次握手已经够了,完全没有必要浪费资源进行继续握手。
RST
((reset)连接重置)
如果客户端发送ACK后服务端没有收到,但是客户端认为连接是建立成功的,于是直接发送数据呢?服务端就发现连接都没建立成功,却直接发送报文,甚至还带上数据,于是便出现:连接认知不一致的情况。于是服务端认识到,客户端连接建立出现了问题,所以服务器立马就回复一个报文(就是TCP报头),并且将RST(reset)标记位:连接重置,标识上,然后让客户端认识到,这个连接建立是由问题的,不能进行通讯。
于是客户端直接将这个连接关闭,然后再重新向服务端发起三次握手。所以RST表征:连接建立的过程之中(之后),双方通讯过程之中有可能连接出现了异常,而导致对方可能不知情而继续发消息时,需要关闭对方的连接,让对方重新建立连接的报文。
这个概率时非常低的,实际上其也并不会存在,因为实际上:其实如果客户端再立马发送的报文内部,ACK是置1的,所以其实ACK也有,连接是会建立成功的。但是在某些情况下,如:Server出现异常连接断了,这个时候RST的意义的凸显出来了(如:我们访问一个网站的时候,如果我们长时间不使用,再使用的时候,就有可能出现提示:连接中断需要重置的提示)。
PSH
((push)督促对方尽快将数据向上交付)
已经将连接建立好了,然而在一次客户端发送报文数据后,服务器回复ACK并且16位窗口大小为0了,然后客户端就只能等待客户端那边有空间。于是一直等,甚至是等 "急了" ,客户端发送了一个不带任何有效数据的报文,让服务端回复,以查看16位窗口大小,但是发现还是0。于是长时间等待客户端就发送了一个PSH(push)标记位:督促对方尽快将数据向上交付。
以此让客户端知道服务端等了很久,于是尽快的将数据进行向上交付。
URG
((urge)紧急标记位,需要配合16位紧急指针进行使用)
之前提到过一个概念:按序到达。所以就是因为TCP是按序到达的机制,这是TCP的优点,而有优点就会有对应的缺点:因为我们发送的报文,被对方上层读到的时候,必须具有先后顺序。
万一有时候TCP想将对应的报文插队交付(尽快将一个报文进行交付)呢?
于是,便有了URG(urge)标记位:紧急标记位,需要配合16位紧急指针进行使用。
16位紧急指针
一般报文的16位紧急标记位都是被清0的,代表其就必须按照常规的排队队列,依次向上进行交付,但是如果设置了16位紧急指针,那么上层可以通过特定的读取选项,来高优先级的将这部分16位紧急指针,所指向的有效载荷部分的数据,尽快向上层交付。
URG标记位代表16位紧急指针是否有效。
#但是还是有一个问题:这里只是知道了,紧急数据的偏移量,也就是起始的位置,但是紧急数据有多少呢?
不用想了,就一个字节。只有16位紧急指针偏移量所指向的一个字节能够被称作为紧急数据。
#问:那这么做有什么目的?
当一个服务器可能由于请求过多或申请过多导致 "卡住" 不动,于是有一个客户端发现,连接时保持着的,但是服务器又总是不给其任何反应,于是客户端想知道服务器到底出现了什么问题,是什么情况。而有一些机器上也是部署的有一些服务(基于TCP),如果机器卡住不动了,客户端可以通过16位紧急数据,向服务器发起询问。
于是由于这个数据不进行排队,然后就会优先级更高的直接向上递交,然后询问服务器主机现在处于的状态。所以16位紧急指针通常是用来作机器管理的命令,其不走正常通讯的数据流程。
FIN
首先对于连接,肯定是需要一方主动的,而断开连接:
- 双方都有可能优先断开连接。
- 断开连接是两边共同的事情,需要挣得两方的同意。
也有可能客户端断开连接的时候,服务端也向断开连接,于是很有可能4次挥手中的2、3次挥手合为一次握手,变为3次握手(本身应答标记位ACK与断开连接标记位FIN就不是一个位置上的,所以完全可以一起执行)
#问:断开连接就一定成功吗?
与连接的三次握手一样的,也不能保证一定断开成功。但是也没有问题,无非就是一个认为断开,一个认为连接,然后连接的那个长时间的不访问,于是就闲置时间久了自动的断开了。
四次挥手对应的状态变化:
- CLOSE_WAIT:
我们知道当一个连接发送FIN的时候,对应的就是上层调用了close。
#问:如果我们发现服务器具有大量的CLOSE_WAIT状态的连接的时候,原因是什么?
说明操作系统的状态没有向后走,就是因为服务器没有发送FIN,就是因为应用层服务器写的有bug,我们忘了关闭对应的连接sockfd。
融汇贯通的理解:
在编码层面上,不关会导致文件描述符泄漏的问题(可用的文件描述符越来越小)。二文件描述符就是一个代表,其底层操作系统为了维护其所匹配的资源,要为其创建大量的数据结构。
所以使用netstat查的时候,出现了大量的CLOSE_WAIT状态,一定是上层忘了关闭对应的连接,所以未来服务器如果运行的越来越卡,服务器周期性的过一段时间就卡死了,就一定要使用netstat查一下,看一下是不是我们的服务器上面是不是挂满了CLOSE_WAIT状态,是不是因为代码的有一些的细节导致文件描述符没有关闭。
- TIME_WAIT:
这里也是解决我们前面日常学习中:我们所写的测试服务端,在使用一个端口号开启一个服务端后,终止服务端再使用该端口启动服务端,却无法启动的问题所在。
文件描述符的生命周期是随进程的,我们将服务端关闭,也就是将进程关闭,于是自动close文件描述符,于是在四次挥手中就是服务端关闭先断开的连接,最后服务端需要进入TIME_WAIT状态等待一段时间后,才会CLOSED。而我们立马使用原端口启动服务器,正好其还处于TIME_WAIT状态,于是无法bind。
核心:主动断开的一方需要维持等待状态TIME_WAIT状态(虽然四次挥手已经完成),在该状态下,连接其实已经解开,但是地址信息ip、port依旧是被占用着。
等待时间受:操作系统TCP协议自身的设置 + 用户配置的设置。
#问:这会带来什么影响?
再日常生活中,比如双11、618等购物节日,像淘宝、拼多多、京东这样的大型网上交易软件,当在这个种消费节日下,如果由于过多的用户访问,出现过多的连接导致服务器撑不住,崩溃了,于是最好的情况肯定是立马重启,但是很尴尬的就是,由于服务器崩溃,于是边成为了主动断开连接的一方,那么最后就需要等待。大量的连接保持TIME_WAIT状态,难道等好几分钟让其进入CLOSED?
很明显这是不可能的,像这种大型app,交易高峰几秒内交易上千万完全是可能的。这样的等待无疑是巨大的损失。于是我们就要保证一个服务器挂掉,要有立马能够重启的能力。
系统就提供了一个接口,这个接口是当我们在创建listen套接字的时候,需要设置一下listen套接字的属性:socksetopt
socksetopt
设置套接字的属性。
#include <sys/types.h>
#include <sys/socket.h>
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
参数:
sockfd:是套接字描述符。
level:是被设置的选项的级别,如果想要在套接字级别上设置选项,就必须把level设置为 SOL_SOCKET。
option_name:指定准备设置的选项,option_name可以有哪些取值,这取决于level。在套接字级别上(SOL_SOCKET):
- SO_REUSEADDR:打开或关闭地址复用功能。当option_value不等于0时,打开,否则,关闭。它实际所做的工作是置sock->sk->sk_reuse为1或0。
optval:指向在其中指定所请求选项值的缓冲区的指针。
optlen:optval 参数指向的缓冲区的大小(以字节为单位)。
这个时候如果我们的服务器挂掉,我们的服务区的可以绕过TIME_WAIT状态的判断,而直接让我们的服务器绑定成功。
opt表示一个标记位要打开。
这个时候因为我们设置了地址复用,让我们对应的端口号和IP地址可以在TIME_WAIT状态期间被我们的服务立马绑定。
#问:为什么要有TIME_WAIT状态?
首先在我们正常的做网路通讯的时候,当双方在协商断开连接的时候,可能历史连接通讯的时候,网络当中可能会存在一些报文滞留在网络当中。还没有送达客户端或者服务器,所以维持一个TIME_WAIT状态,让还在路上的报文到达(TIME_WAIT状态等待的时间,在网络中至少是2倍MSL)。
MSL(Max Segment Life,报文最大生存时间):
两个主机网络通讯,从一个到另一个的单向上数据到达对方时,所花费的最大时间。
依次保证历史数据从网络中进行消散。
消散:要么被双方所接收,要么被对方所丢弃。
#问:为什么要消散?
主要原因是,因为一个客户端和服务器,断开连接之后,还有可能很快的重新建立连接,甚至用的端口号,源端口、目的端口、原IP、目的IP都是可能一样的。这就很尴尬了,这些报文就会影响客户端和服务器之间的正常通讯。所以尽量的需要保证网络中的数据进行消散。
对于此(TIME_WAIT状态等待的时间,在网络中至少是2倍MSL),还有一个更好的理解:毕竟我们要四次挥手才算正式断开,如果是客户端断开连接,而最后一次ACK发送失败了,客户端认为断开了,而服务器却没有收到。如果不等待直接进入CLOSED,于是没有收到的客户端就会重传FIN再继续问,但是客户端已经关闭了,于是发送一点用处也没有。于是导致服务器一直没有办法关闭,导致一些问题。
所以让客户端进入TIME_WAIT状态等待一段时间的好处就是,如果ACK丢了,对方重传的FIN客户端也可以收到,然后补发ACK。如果有出现了其他情况,导致等待时间过了也没有解决,也问题不大服务器在特定时间段内也会自动关闭。
TIME_WAIT状态等待课较大的提高ACK的发送成功的概率。
可靠性机制
确认应答(ACK)机制
确认应答机制就是由TCP报头当中的,32位序号和32位确认序号来保证的。需要再次强调的是,确认应答机制不是保证双方通信的全部消息的可靠性,而是通过收到对方的应答消息,来保证自己曾经发送给对方的某一条消息被对方可靠的收到了。
融汇贯通的理解:
在TCP层,我们一般把所有发出去的数据 / 报文,通常统一叫做数据段,数据段 = 报头 + 有效载荷数据
#: 缓冲区与序号的理解
TCP在发送数据时,是有发送缓冲区的,在接收数据时,也是有接收缓冲区的。同样的对方的主机也具有发送缓冲区和接收缓冲区。
以客户端向服务器发送数据为例:
TCP自带了一个发送缓冲区(可以想象为一个字符类型的数组),于是忽对于TCP,上层需要其发送给数据,就依次的拷贝入 "char类型的数组" ,这样每一个字节天然的就有了一个序号,这个序号就是对应的数组下标。
当发送数据的时候,如一个 0~1000 的数据,则这一个报文的编号就是以最后一个下标 1000 为编号,然后构建一个序号给对方发出,下一次 1001~2023 的序号就编号为2023。然后对方再以序号的方式接收,并且组合,接收方就能够也将其当作字符类型的数组,收的时候就能够按照特定的顺序,将数据拷贝到自己的接收缓冲区里,然后交付给上层。
每一个ACK都带有对应的确认序列号,确认信号是对序号 +1,意思是告诉发送者,我已经收到了哪些数据,下一次你从哪里开始发。
融汇贯通的理解:
这里也能够大概看出来,TCP面向字节流的概念。因为将其当作为char类型的数组,所以char类型数组大小一个一个就是字节,所以将这种双方都用char类型 / 字节流方式保存的放在缓冲区的数据,叫做面向字节流。
超时重传机制
- 主机A发送数据给B之后,可能因为网络拥堵等原因,数据无法到达主机B。
- 如果主机A在一个特定时间间隔内没有收到B发来的确认应答,就会进行重发。
但是,主机A未收到B发来的确认应答,也可能是因为ACK丢失了。
#问: 那么超时的时间如何确定?
#问:时间能设为一定的吗?
- 最理想的情况下,找到一个最小的单元时间,保证 "确认应答一定能在这个时间内返回"。
- 但是这个时间的长短,随着网络环境的不同,是有差异的。
- 如果超时时间设的太长,会影响整体的重传效率。
- 如果超时时间设的太短,有可能会频繁发送重复的包。
- Linux中(BSD Unix和Windows也是如此),超时以500ms为一个单位进行控制,每次判定超时重发的超时时间都是500ms的整数倍。
- 如果重发一次之后,仍然得不到应答,等待 2*500ms 后再进行重传。
- 如果仍然得不到应答,等待 4*500ms 进行重传。依次类推,以指数形式递增。
- 累计到一定的重传次数,TCP认为网络或者对端主机出现异常,TCP强制关闭连接。
连接管理机制
在正常情况下,TCP要经过三次握手建立连接,四次挥手断开连接。
- [CLOSED -> LISTEN] 服务器端调用listen后进入LISTEN状态,等待客户端连接。
- [LISTEN -> SYN_RCVD] 一旦监听到连接请求(同步报文段),就将该连接放入内核等待队列中,并向客户端发送SYN确认报文。
-
[SYN_RCVD -> ESTABLISHED] 服务端一旦收到客户端的确认报文 , 就进入 ESTABLISHED 状态 , 可以进行读写数据了。
-
[ESTABLISHED -> CLOSE_WAIT] 当客户端主动关闭连接 ( 调用 close), 服务器会收到结束报文段 , 服务器返回确认报文段并进入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_SENT -> ESTABLISHED] connect调用成功,则进入ESTABLISHED状态,开始读写数据。
- [ESTABLISHED -> FIN_WAIT_1] 客户端主动调用close时,向服务器发送结束报文段,同时进入 FIN_WAIT_1。
- [FIN_WAIT_1 -> FIN_WAIT_2] 客户端收到服务器对结束报文段的确认,则进入FIN_WAIT_2, 开始等待服务器的结束报文段。
- [FIN_WAIT_2 -> TIME_WAIT] 客户端收到服务器发来的结束报文段,进入TIME_WAIT,并发出LAST_ACK。
- [TIME_WAIT -> CLOSED] 客户端要等待一个2MSL(Max Segment Life, 报文最大生存时间)的时间,才会进入CLOSED状态。
下图是TCP状态转换的一个汇总:
- 较粗的虚线表示服务端的状态变化情况。
- 较粗的实线表示客户端的状态变化情况。
- CLOSED是一个假想的起始点,不是真实状态。
再对TIME_WAIT总结:
- TCP协议规定,主动关闭连接的一方要处于TIME_ WAIT状态,等待两个MSL(maximum segment lifetime)的时间后才能回到CLOSED状态。
- 我们使用Ctrl-C终止了server,所以server是主动关闭连接的一方,在TIME_WAIT期间仍然不能再次监听同样的server端口。
可以通过在listen的时候设置函数socksetopt,使得对应的端口号和IP地址可以在TIME_WAIT状态期间被我们的服务立马绑定。
#问:TIME_WAIT状态的保持会带来的问题?
在server的TCP连接没有完全断开之前不允许重新监听, 某些情况下可能是不合理的:
- 服务器需要处理非常大量的客户端的连接(每个连接的生存时间可能很短,但是每秒都有很大数量的客户端来请求)。
- 这个时候如果由服务器端主动关闭连接(比如某些客户端不活跃,就需要被服务器端主动清理掉),就会产生大量TIME_WAIT连接。
- 由于我们的请求量很大,就可能导致TIME_WAIT的连接数很多,每个连接都会占用一个通信五元组(源ip,源端口,目的ip,目的端口,协议)。其中服务器的ip和端口和协议是固定的,如果新来的客户端连接的ip和端口号和TIME_WAIT占用的链接重复了,就会出现问题。
- MSL在RFC1122(网络相关的标准文档)中规定为两分钟,但是各操作系统的实现不同,在Centos7上默认配置的值是60s。
- 可以通过 cat /proc/sys/net/ipv4/tcp_fin_timeout 查看MSL的值。
#问:想一想,为什么是TIME_WAIT的时间是2*MSL?
- MSL是TCP报文的最大生存时间,因此TIME_WAIT持续存在2MSL的话就能保证在两个传输方向上的尚未被接收或迟到的报文段都已经消失(否则服务器立刻重启,可能会收到来自上一个进程的迟到的数据,但是这种数据很可能是错误的)。
- 同时也是在理论上保证最后一个报文可靠到达(假设最后一个ACK丢失,那么服务器会再重发一个FIN。这时虽然客户端的进程不在了,但是TCP连接还在,仍然可以重发LAST_ACK)。
三大机制
- 流量控制
- 拥塞控制
- 滑动窗口
流量控制
- 接收端将自己可以接收的缓冲区大小放入 TCP 首部中的 "窗口大小" 字段,通过ACK端通知发送端。
- 窗口大小字段越大,说明网络的吞吐量越高。
- 接收端一旦发现自己的缓冲区快满了,就会将窗口大小设置成一个更小的值通知给发送端。
- 发送端接受到这个窗口之后,就会减慢自己的发送速度。
- 如果接收端缓冲区满了,就会将窗口置为0。这时发送方不再发送数据,但是需要定期发送一个窗口探测数据段,使接收端把窗口大小告诉发送端。
融汇贯通的理解:
每一个主机上都有接收和发送缓冲区。客户端和服务端的:
- 客户端发送缓冲区 - 服务端接收缓冲区
- 服务端发送缓冲区 - 客户端接收缓冲区
是一一对应的两队,所以TCP是全双工的(对应的发送缓冲区的数据拷贝到接收缓冲区),而这样相互客户端ACK服务端,服务端ACK客户端,就是通告互相各自剩余接收缓冲区大小 —— 流量控制
#问: 接收端如何把窗口大小告诉发送端呢?
#问:主机A向主机B第一次发消息,主机A怎么知道主机B的接收能力?
我们所需要的是对方的接收能力(接收缓冲区中剩余空间的大小) ,通过交换报文得知。而我们需要认识到,第一次发送数据 != 第一次交换报文,前面是进行三次握手的,在三次握手中的前两次握手时,一定不能携带任何数据(因为三次握手没有完成不能够发数据),但是可以交换双方的接收缓冲区的大小(TCP报头),所以可以在三次握手交换接收能力(接收缓冲区中剩余空间的大小) 。
客户端也会过一段时间就询问服务端是否有空间。并且光有这一种策略还不够完整,还有一种策略是,如果主机B的窗口更新了,其也直接会向主机A发送窗口更新的通知,这两种策略同时进行。进而就是看是探测先来还是更新先来。
#问: 那么问题来了, 16位数字最大表示65535, 那么TCP窗口最大就是65535字节么?
滑动窗口
是一个主要为了提高TCP网络效率的机制。
在之前我们已经讨论了确认应答策略,对每一个发送的数据段,都要给一个ACK确认应答(严格的确认应答机制)。收到ACK后再发送下一个数据段,这样做有一个比较大的缺点,就是性能较差,尤其是数据往返的时间较长的时候。
所有的发送过程都是串行的,既一个一个的发送,没有错,但是效率低下。既然这样一发一收的方式性能较低,那么我们一次发送多条数据(TCP可以一次发送大量的数据),就可以大大的提高性能(其实是将多个段的等待时间重叠在一起了)。
发出方一批次的发送,接收方再一批次的应答,这样发出的一批次的报文,是在网络中并行的同时过去的,确认也是并行的同时过来。这样多个IO的时间是重叠到一起的,那么这就能够提高对应的效率。
#问:并行的发送了一大批的数据段需要每一个都要有应答吗?
理论上是每一个发出的数据段都必须要有对应的应答。
滑动窗口:在自己的发送缓冲区中,属于自己的发送缓冲区的一部分。
- 窗口大小指的是无需等待确认应答而可以继续发送数据的最大值,上图的窗口大小就是4000个字节(四个段)。
- 发送前四个段的时候,不需要等待任何ACK,直接发送。
- 收到第一个ACK后,滑动窗口向后移动,继续发送第五个段的数据,依次类推。
- 操作系统内核为了维护这个滑动窗口,需要开辟 发送缓冲区 来记录当前还有哪些数据没有应答。只有确认应答过的数据,才能从缓冲区删掉。
- 窗口越大,则网络的吞吐率就越高。
#:滑动窗口的本质:
发送方,可以一次性向对方推送的数据的上限,滑动窗口也必须要有上限 == 对方的接收能力决定(目前看来)。
- 想给对方推送更多的数据。
- 又想要保证对方来的及接收。
是一个兼顾效率和安全的策略。
#问:滑动窗口必须向右移吗?
不一定,因为有可能给对方发的数据,对方ACK应答,因为有可能对方并未取数据,导致接收能力下降,从而win_end不移动,win_start向右移动。
(仅仅是基于现在的理解,在阻塞控制完善)
#问:滑动窗口可以为0吗?
相当于win_start == win_end,是可以。因为发送方一直给对方发数据,接收方上层一直不把数据取走,自然而然的接收能力越来越小,当缓冲区剩余空间为0时,自然而然的滑动窗口可以为0。
#问:如果没有收到开始的报文的应答,而是收到中间的?有影响吗?
没有任何影响,因为序号的定义就是:确认序号对应的数字,之前的所有的报文已经全部收到了!告诉对方,下一次发送,就从确认序号指明的序号发送。
#问:如果是报文丢了呢?
应答的ACK中的确认序号就根本不能向后,是只会填丢失报文之前的序号,就是因为序号的定义。即如果滑动窗口中的第一个报文丢失,就算后面的报文全部传输成功,也只会应答第一个报文对应的序号 = 滑动窗口不变,这个时候对应的数据会保存在滑动窗口里,以供发送方进行超时重传。
Note:超时重传背后的含义:就是没有收到应答的时候,数据必须被暂时保存起来!
情况一:数据包已经抵达,ACK被丢了。
这种情况下,部分ACK丢了并不要紧,因为可以通过后续的ACK进行确认。
情况二:数据包就直接丢了。
- 当某一段报文段丢失之后,发送端会一直收到 1001 这样的ACK,就像是在提醒发送端 "我想要的是 1001" 一样。
- 如果发送端主机连续三次收到了同样一个 "1001" 这样的应答,就会将对应的数据 1001 - 2000 重新发送。
- 这个时候接收端收到了 1001 之后,再次返回的ACK就是7001了(因为2001 ~ 7000)接收端其实之前就已经收到了,被放到了接收端操作系统内核的接收缓冲区中。
这种机制被称为: "高速重发控制"(也叫 "快重传" )
快重传 VS 超时重传
#问:为什么快重传这么好还要超时重传?
快重传:是很快,但是快重传是具有条件的,所以其二者不是对立关系的,而是协作关系的。
#问:滑动窗口如果一直向右滑动,会不会出现越界问题?
是不会的,因为其是一个环形结构(TCP的缓冲区是环状的)!核心:根据模运算下标形成的环状结构(线性结构模拟环状结构)。
总结
滑动窗口的更新策略就一个要求,只要其收到应答序号是几,就该成几。其不用担心中间数据段的丢失,还是ACK应答丢失,因为只要根据序号的定义即可!
#问:滑动窗口是解决效率问题?还是可靠性问题?
是解决效率问题的,并不是解决可靠性问题的。对于滑动窗口来讲,其是通过一次限定发送缓冲区的范围,以达到可以向对方一次塞大量的数据,这就是解决效率问题。对于可靠性问题也就是配合超时重传。
拥塞控制
知识铺垫
前面所学策略:
这就是说前述的所有的机制,更多的是主机A如何如何,主机B如何如何。可是在TCP中作为一个数据,不仅仅是在主机上,还要经过网络,所以还要考虑一个总要的问题,网络的健康状态。
- 少量丢包 - 主机的问题 - 重传即可。
- 大量丢包 - 网络出现问题 (网络拥塞了) - 重传吗?
是不是真的拥塞不知道,反正判定为拥塞了,如果不是拥塞是网络瘫痪了,那重传几次不行,还是失败了,最后就直接关闭连接了。如果真的是拥塞了,而软件上只能解决软件上的问题,所以网络拥塞就绝对不能重传了。
- 网络已经拥塞了,再怎么重传没有任何意义,数据照样过不去。
- 因为丢包是大量丢包,所以重传就会是大量重传,从而加重网络的拥塞。
#问:如果出现了网络拥塞,如何解决网路拥塞的问题?
拥塞控制!
概念
虽然TCP有了滑动窗口这个大杀器,能够高效可靠的发送大量的数据,但是如果在刚开始阶段就发送大量的数据,仍然可能引发问题。
因为网络上有很多的计算机,可能当前的网络状态就已经比较拥堵。在不清楚当前网络状态下,贸然发送大量的数据,是很有可能引起雪上加霜的。所以一旦触发了网络拥塞,就会立马触发TCP的拥塞控制算法。
TCP拥塞控制算法:核心思路在于引入 慢启动 机制,先发少量的数据,探探路,摸清当前的网络拥堵状态,再决定按照多大的速度传输数据。
说白了就是,先发送一个数据段,如果没收到确认,那过一会再发一下,如果还是没收到确认,那过会在再发送,依次持续一段,当次序一段时间后还是没有应答,于是就可能不仅仅是网络拥塞了,而是网络瘫痪了,那么主机也只能关闭链接了。如果持续一段收到了,那证明还能用,但是确实阻塞了,然后再发它就会发两个报文如果收到,然后就三个,然后四个以此逐渐增加(探路)。
- 此处引入一个概念程为 拥塞窗口 。
- 发送开始的时候,定义拥塞窗口大小为1。
- 每次收到一个ACK应答,拥塞窗口加1。
- 每次发送数据包的时候,将拥塞窗口和接收端主机反馈的窗口大小做比较,取较小的值作为实际发送的窗口。
拥塞窗口:
一台主机同时向另一台主机发送一个大量数据时,可能触发网络拥塞的一个数据。它衡量的是网络一次能够接收来自于我们这一台主机最多的数据上限。
单机主机一次向网络中发送大量数据时,可能会引发网络阻塞的上限值。
即:滑动窗口的大小 = min(阻塞窗口,对方的窗口大小[接收能力])
像上面这样的拥塞窗口增长速度,是指数级别的。"慢启动" 只是指初使时慢,但是增长速度非常快 。
- 为了不增长的那么快,因此不能使拥塞窗口单纯的加倍。
- 此处引入一个叫做慢启动的阈值。
- 当拥塞窗口超过这个阈值的时候,不再按照指数方式增长,而是按照线性方式增长。
假设第一次拥塞是24,当前第一次的时候拥塞窗口的阈值ssthresh假设为16(指数到线性的阈值),第一次到24了拥塞了,这个时候这个主机立马改变自己,再次进入慢启动,同时改变塞窗口的阈值ssthresh,变为曾经发生拥塞的一半。
拥塞窗口一直在变化,阈值ssthresh也会变化(乘法减小反之乘法增大)。如果一直不拥塞,拥塞窗口会一直线性增大。
#问:为什么阻塞之后,前期是指数增长?
指数前期慢,后期非常快,一旦阻塞:
- 前期要让网络有一个缓一缓的机会 —— 发送:少、慢。
- 中后期,网络回复之后,尽快恢复通讯的过程 —— 慢慢的发:可能会影响通讯效率。
- 当TCP开始启动的时候,慢启动阈值等于窗口最大值。
- 在每次超时重发的时候,慢启动阈值会变成原来的一半, 同时拥塞窗口置回1。
少量的丢包,我们仅仅是触发超时重传。大量的丢包,我们就认为网络拥塞。当TCP通信开始后,网络吞吐量会逐渐上升。随着网络发生拥堵,吞吐量会立刻下降。拥塞控制,归根结底是TCP协议想尽可能快的把数据传输给对方,但是又要避免给网络造成太大压力的折中方案。
TCP拥塞控制这样的过程,就好像 热恋的感觉 (情感从逐渐到猛增,然后趋于平衡,然后冷战,然后重新好……)。
总结
采用指数 + 线性增长。
- 想解决网络阻塞的问题。
- 尽快恢复双方通信的效率。
核心:在于对网络拥塞的判断。
- 少量丢包 - 可以重传。
- 大量丢包 - 立马判定是网络拥塞了
因为大量丢包无非就三个原因:1、网络阻塞;2、发送太快对方无法接收 / 对方连接断了;3、网络瘫痪;
而对于第二个,有对应的流量控制并且对端的连接也是健康链接,所以发送放给接收方发数据接收方根本不会出现来不及接受,甚至不接受的问题。
延迟应答
知识铺垫
#问:如何保证给对方同步的是一个更大的接受能力呢?
首先,网络是不断的向接收方的缓冲区中不断放数据的,所以收数据时,缓冲区的空间只会越来越小,也就是接受能力只会越来越差。只有设置更好的策略,让接收方的上层将数据尽快的取走,以给接收缓冲区中留下更大的剩余空间。
换句话说:发送数据是往接收缓冲区放数据,上层拿数据是从接收缓冲区取数据。只有取走 -> 剩余空间变大,此时才能给发送方同步一个更大的接收能力。
概念
如果接收数据的主机立刻返回ACK应答,这时候返回的窗口可能比较小(没有给上层读取数据的时间)。
- 假设接收端缓冲区为1M,一次收到了500K的数据。如果立刻应答,返回的窗口就是500K。
- 但实际上可能处理端处理的速度很快,10ms之内就把500K数据从缓冲区消费掉了。
- 在这种情况下,接收端处理还远没有达到自己的极限,即使窗口再放大一些,也能处理过来。
- 如果接收端稍微等一会再应答,比如等待200ms再应答, 那么这个时候返回的窗口大小就是1M。
一定要记得,窗口越大,网络吞吐量就越大,传输效率就越高。我们的目标是在保证网络不拥塞的情况下尽量提高传输效率。
所以,接收方在接收到数据可以先现不急着应答,先等一等。如此上层就有可能在等待着的一小段时间内将数据取走了,这个时候应答就有可能给发送方同步一个更大的窗口,进而提高对方一次IO的效率 —— 延迟应答。
#问:那么所有的包都可以延迟应答么?
肯定也不是。 延迟应答是先不着急给对方回复,而是先等一等,而等一等就需要有等一等的策略。
- 数量限制:每隔N个包就应答一次。
- 时间限制:超过最大延迟时间就应答一次。
具体的数量和超时时间,依操作系统不同也有差异。一般N取2,超时时间取200ms。
捎带应答
在延迟应答的基础上,我们发现,很多情况下,客户端服务器在应用层也是 "一发一收" 的。意味着客户端给服务器说了 "How are you",服务器也会给客户端回一个 "Fine, thank you"。
那么这个时候ACK就可以搭顺风车,和服务器回应的 "Fine, thank you" 一起回给客户端。将应答 -> ACK -> 报头,搭回复的数据的顺风车,顺便将应答发送端。
TCP小结
#问:为什么TCP这么复杂?
因为要保证可靠性,同时又尽可能的提高性能。
- 可靠性:
- 校验和 - 可以保证对非法报文进行甄别
- 序列号(按序到达)- 可以保证数据的按序到达
- 确认应答 - 可以保证历史数据的可靠
- 超时重发
- 连接管理
- 流量控制
- 拥塞控制
- 提高性能:
- 滑动窗口 - 可以向对方提供大量的数据
- 快速重传 - 连续三个同序号的ACK,进行重传
- 延迟应答 - 等一等再应答,可以保证给对方同步一个较大的接收缓冲区
- 捎带应答 - 搭回复的数据的顺风车,顺便将应答发送端
- 其他:
- 定时器(超时重传定时器, 保活定时器, TIME_WAIT定时器等)
三个问题
面向字节流
- 创建一个TCP的套接字(socket),操作系统会同时在内核中创建一个 发送缓冲区 和一个 接收缓冲区。
所以,当我们调用 write / send 时,其实本质上这些函数,就是传说中的拷贝函数,数据会先写入(拷贝)发送缓冲区中。如果发送的字节数太长,则会被拆分成多个TCP的数据包发出(TCP做的)。如果发送的字节数太短,就会先在缓冲区里等待,等到缓冲区长度差不多了,或者其他合适的时机发送出去。
接收数据的时候,数据也是从网卡驱动程序到达内核的接收缓冲区。然后应用程序可以调用read / recv从接收缓冲区拿(拷贝)数据。
另一方面,TCP的一个连接,既有发送缓冲区,也有接收缓冲区,那么对于这一个连接,既可以读数据,也可以写数据 —— 这个概念叫做 全双工。
- 由于缓冲区的存在,TCP程序的读和写不需要一一匹配。
写100个字节数据时,可以调用一次 write 写100个字节,也可以调用100次 write ,每次写一个字节。
读100个字节数据时,也完全不需要考虑写的时候是怎么写的,既可以一次 read 100个字节,也可以一次 read 一个字节,重复100次。
融汇贯通的理解
在发送端的用户层将数据拷贝到发送缓冲区,发送端拷贝几次TCP不一定发送几次,有可能我们拷贝了10次,但是TCP有可能识别到网络拥塞了 / 对方来不及接收了,TCP会自动的控制自己的发送速率,甚至是发送的大小。
所以,站在应用层是完完全全只需要将数据给TCP,具体数据什么时候发、发多少、怎么发、出错了怎么办,应用层完全不用管。
而当收到报数据段的时候,将收到的数据段放到接收缓冲区里,对于接收方的上层,它在读取TCP的缓冲区数据的时候,因为其也是拷贝,并且其并不清楚缓冲区里的数据是如何被对方发送过来的,并且也不关心。
这种发送和接收完全不关心,数据的格式、数据的大小,而只要对应的发就收。不用关心过程,读取方想读取的时候,读取:1字节、2字节……100字节,完全由上层自由的决定。这种所对应的数据通讯流程就称作为 —— 面向字节流
粘包问题
如果有一次在TCP进行数据传输完成之后,接收方的上层需要对缓冲区中数据进行读取。
#问:上层如何保证自己读到的数据是一个独立的完整的报文?
如果上层进行单纯的字节流读取时(没有任何的协议的定制),那么在读取的时候,由于TCP根本不关心字节流数据的格式,所以在进行上层任意读取时,就极有可能出现对多个报文出现多读 / 少读的情况 —— 数据包粘包问题
- 首先要明确, 粘包问题中的 "包" ,是指的应用层的数据包。
- 在TCP的协议头中,没有如同UDP一样的 "报文长度" 这样的字段, 但是有一个序号这样的字段.
- 站在传输层的角度,TCP是一个一个报文过来的,按照序号排好序放在缓冲区中。
- 站在应用层的角度,看到的只是一串连续的字节数据。
- 那么应用程序看到了这么一连串的字节数据,就不知道从哪个部分开始到哪个部分,是一个完整的应用层数据包。
#问:那么如何避免粘包问题呢?
归根结底就是一句话,明确两个包之间的边界!想办法在协议中体现报文和报文之间的边界。
- 对于定长的包,保证每次都按固定大小读取即可。例如上面的Request结构,是固定大小的,那么就从缓冲区从头开始按sizeof(Request)依次读取即可。
- 对于变长的包,可以在包头的位置,约定一个包总长度的字段,从而就知道了包的结束位置。
- 对于变长的包,还可以在包和包之间使用明确的分隔符(应用层协议,是程序猿自己来定的,只要保证分隔符不和正文冲突即可)。
#总结:
TCP是面向字节流的,其字节流不管是发送网路还是接收缓冲区,最终数据都是以字节流的方式,在发送和接收缓冲区里进行流动,内部并不识别报文,而需要应用层配合来进行协议的解析来把报文读取出来。
最后我们在进行读取的时候,我们可以一次在缓冲区里一次读一个完整报文,也可以一次将缓冲区里面的数据读上来,然后再上层再做分析。
#思考:对于UDP协议来说,是否也存在 "粘包问题" 呢?
- 对于UDP,如果还没有上层交付数据,UDP的报文长度仍然在。同时,UDP是一个一个把数据交付给应用层,就有很明确的数据边界。
- 站在应用层的站在应用层的角度,使用UDP的时候,要么收到完整的UDP报文,要么不收,不会出现"半个"的情况。
TCP异常情况
- 进程终止:进程终止会释放文件描述符,仍然可以发送FIN,和正常关闭没有什么区别。
TCP在建立连接时,它的连接是在操作系统内部帮我们建立好的,所以其是操作系统内部的一种结构。应用层对应的进程挂掉了会释放文件描述符,并且并不会影响底层的正常释放,因为四次挥手本来就是TCP自行完成的。
- 机器重启 / 关机:和进程终止的情况相同。
在日常生活重启电脑也是可以发现的,在重启的时候电脑不是立马的断电,而是弹出某某某进程正在运行(某某某进程阻止了电脑的关机)是否停掉重启 / 关机,或者是的释放继续重启 / 关机,如果是则该进程被关掉了。
- 机器掉电 / 网线断开:
网线断开:
本主机会立马检测到网络是断开的,于是本地会自动关闭对应的连接,但是没有办法四次挥手了。
对端是无法知道接连接出异常了,于是对端认为连接还在,即使没有写入操作,TCP自己也内置了一个保活定时器,会定期询问对方是否还在,如果多次询问对方一直不在,也会把连接释放。
- 另外:应用层的某些协议,也有一些这样的检测机制,例如HTTP长连接中,也会定期检测对方的状态;例如QQ,在QQ断线之后,也会定期尝试重新连接。
QQ在发现用户长时间不动用电脑,QQ头像就会变为灰色,就是先将我们的QQ和腾讯的服务端先断开,但是照样保证我们的QQ是登录状态,所以一旦检测到用户回来了(鼠标发生移动),QQ就会自动的迅速的将连接重新建立起来。
基于TCP应用层协议
- HTTP
- HTTPS
- SSH
- Telnet
- FTP
- SMTP
当然,也包括你自己写TCP程序时自定义的应用层协议。
TCP VS UDP
#问:我们说了TCP是可靠连接,那么是不是TCP一定就优于UDP呢?
TCP和UDP之间的优点和缺点,不能简单,绝对的进行比较。
- TCP用于可靠传输的情况,应用于文件传输,重要状态更新等场景。
- UDP用于对高速传输和实时性要求较高的通信领域,例如:早期的QQ,视频传输等,另外UDP可以用于广播。
归根结底,TCP和UDP都是程序员的工具,什么时机用,具体怎么用,还是要根据具体的需求场景去判定。
充分理解面向字节流
UDP:非常像我们平时收快递,收一个就能明确的知道一定是对方发送了一个。
TCP:就有一点像家里的自来水管,管子里有很多很多的水(水塔中有很多很多的水),具体这个水是怎么来的(供水站供水供了几次)并不知道,也不关心。我们也就是打开水龙头想放多少放多少,完全看 "上层" 需求。所以,TCP字节流的数据是没有任何边界 / 区分度的。
融汇贯通的理解:
所以在应用层中的代码书写引入了一个协议的概念,就是因为TCP根本就不关心,它发的是什么,它只要保证发送方要发的数据尽快的发送到接收方。对应的数据是什么格式的,由应用层自主决定。这就是为什么使用到TCP协议的程序需要自定义协议,这就是为什么使用HTTP协议时HTTP协议也要自定义协议的根本原因。
所以也就是为什么,TCP报文对于UDP报文少了一个东西 —— TCP报文 / 数据段的长度。也就是整个报头 + 选项 + 有效载荷数据的整体长度。就是因为TCP根本就不需要,因为当其收到数据段时,报头 + 选项一拆直接将数据按照序号,放入到接收缓冲区里面TCP的任务就完成了,其不会对数据进行任何解释,剩下的任务是应用层的。
拓展:
- 文件:
同理Linux下的文件也是被称作文件流。
我们再将数据往文件里面写的时候,操作系统是根本不关心我们向文件里面写的什么,操作系统只需要关心我们拷贝给它的数据拷了多少,然后操作系统会定期的将数据按照特定的文件格式、IO策略等,然后写到对应的文件当中。
对于上层的我们,将数据拷贝到操作系统的内核缓冲区中,应用层就没事了(撤退),而这个数据什么时候刷、刷新多少,这个完全由操作系统自行决定并完成 —— 所以文件也被称作字节流
- 管道:
同理Linux下的管道也是面向字节流服务。
当我们往管道里写一条消息,对方立马读就会读到一条消息,而如果写方连续的写了5回消息,对方再读就会直接读到5条消息。因为管道再传输时根本不知道数据格式是什么,反正读写的时候,数据能拿多少就拿多少。
用UDP实现可靠传输(经典面试题)
参考TCP的可靠性机制,在应用层实现类似的逻辑。
例如:
- 引入序列号, 保证数据顺序。
- 引入确认应答, 确保对端收到了数据。
- 引入超时重传, 如果隔一段时间没有应答, 就重发数据。
- ......
TCP 相关实验
理解 listen 的第二个参数
知识铺垫
#问:accept要不要参与三次握手的过程呢?
其实不需要参与,因为三次握手是由操作系统内部自动完成的。
#问:那accept在做什么?
accept从底层直接获取已经建立好的连接。换句话说,在我们调用accept的时候,如果三次握手没有完成,accept只能阻塞等待,只有当底层将连接建立好之后才能将该连接拿到应用层。
先建立好链接,然后才能accept获取对应的链接。
#问:如果我不调用accept,能建立连接吗?
能的!因为连接能不能建立成功与accept没有关系。
#问: 如果上层来不及调用accept,并且对端还来了大量的连接?难道所有的连接都应该先建立好吗?
这种问题操作系统如何处理就和listen 的第二个参数有关。
概念
可以想的到,连接都来不及处理了,说明服务器已经很忙了,还来了大量的连接,操作系统还要建立连接,每一个连接都要消耗资源,最终带来的结果就是服务器很快的就挂掉了,所以绝对不能这么干。
就和非常受欢迎的店一样,对于店内部客户爆满对新客户的处理方式:
- 客户自愿拿号排队等待 —— 客户还在
- 让客户离去 —— 客户走了
二者在最开始一样的都火爆,但是过了一段时间,走了一些人,第一个处理方式:由于客户等待,所以立马又进客户(一直火爆);第二个处理方式:由于请走了之前的客户,所以在短时间内没有客户(短暂缺失)。
以客户自愿拿号排队等待,以此保证一旦有客人离开,立马可以让外面的人进来吃饭,保证自己的资源是100%利用的。
而抽号的过程本质就是排队(确定优先级的过程),就是一个队列。并且需要是一个合理的队列长度,不也不可能太长,过于的长后续的等待时间注定也长的,所以注定该队列有一个长度范围。
我们的网络服务器就是这个生意火爆的店。服务器上层可能很忙,来不及接收新得连接,所以此时如果有更多的用户到来,那么我们的服务器其实是会为我们在底层维护一个连接队列。并且该队列不能没有且不能太长 —— 其会根据来到的连接的先后算顺序,队列范围之内的先排队,之外的直接拒绝。
换句话说当我们的服务器在进行连接获取的时候,服务器本身要维护一个连接队列,并且不能没有、不能太长,和listen的第二个参数有关(影响队列长度)。
listen的第二个参数,意义:底层全连接队列长度 = listen的第二个参数 + 1
超出的用户:
(listen的第二个参数:2)
客户端状态正常,但是服务器端出现了 SYN_RECV 状态,而不是 ESTABLISHED 状态
这是因为,Linux内核协议栈为一个tcp连接管理使用两个队列:
- 半链接队列(用来保存处于SYN_SENT和SYN_RECV状态的请求)有明显的生命周期,很短暂。
- 全连接队列(accpetd队列)(用来保存处于established状态,但是应用层没有调用accept取走的请求)