在学习计算机网络的过程中,我们知道OSI七层协议模型,但是在实际开发应
用中我们发现OSI七层协议模型并不适合实施,因为OSI上三层通常都是由开
发人员统一完成的,这三层之间在实现过程中没有一个明确的界限,所以我
们更多的是将七层模型认为是TCP/IP四层协议(除去硬件层),而TCP、IP
分别是两个网络层中非常具有代表性的网络协议,其中TCP处于传输层。一
个网络协议栈的名字其中由两个协议名构成,足以可见这两个协议的地位。
所以我今天就来介绍传输层的TCP协议。
1. TCP的结构化字段
这就是TCP协议的结构化字段,其中两个端口号字段不再多说,校验和也不介绍(和UDP协议(我的一篇博客)的意思一样)。接下来我们直入主题,来认识TCP协议中的各个结构化字段。
a. 4位首部长度
在学习一个协议时,我们避免不了的两个问题就是:
如何将报文中的报头和有效载荷分离?
如何将有效载荷向上交付?
对于后者我们很容易就能知道,那就是TCP报头中的目的端口号字段。
而对于前者就需要介绍一下了,如果你了解过UDP协议的话,你就知道UDP协议中报头与有效载荷的分离原因是因为UDP报头的长度是固定的八字节,而固定报头长度在TCP协议这里是不适用的,因为我们可以发现在有效载荷的前面的报头部分中除了我标明的几个具有具体字节数的字段外,还有一个字段,那就是选项字段。在选项前面TCP报头的长度是固定的,但是因为选项字段长度是不确定的(选项可能有多个),所以这就导致了TCP的报头分离不再像UDP那么简单,那么怎么办呢?这就需要我们的TCP标准报头(选项字段前的内容)中的一个字段了,那就是4位首部长度:
首部长度在TCP报头中占四位,它用来表示TCP整个报头的长度。
那么此时就有人有疑问了,四位最大能表示的数大小也才是15,怎么来表示TCP整个报头的大小呢?
这里直接给出结论,这个首部长度的基本单位不是1,而是4,所以这个首部长度的表示范围应该是[0, 60],这样就可以记录报头的长度了。或许有的人也意识到了,如果首部长度的基本单位是4的话,那么后面的选项的基本单位不就也是4了啊?你的想法是对的,很明显报头长度为23的话首部长度不能对其进行表示。
那么现在,我们就可以知道TCP报文如何进行报头与有效载荷的分离了,那就是看报头中的4位首部长度就可以得知报头的长度了,然后也是指针偏移的方法找到有效载荷。
b. 16位窗口大小
我在介绍UDP协议(仍然是我上面的那一篇博客)时说过,TCP协议有接收缓冲区和发送缓冲区,而UDP协议只有接收缓冲区。而且我们也知道,TCP协议叫做传输控制协议(Transmission Control Protocol)。因为面向字节流的特性作为一部分原因导致它可以使用文件的IO接口(write/read)。
现在我来介绍一下上层是如何将一个应用层报文以TCP协议的方式传输给对端主机的(由于TCP协议是全双工的,建立连接的双方地位同等,所以我们只需要研究一段到另一端,那么另一端到一段也就明白了),在这里我们描述的是逻辑上的传输(不穿过网络协议栈,实际上是穿过的):
在这里由于接收缓冲区的存在,上层用户发送报文的时候不是直接发送给对方,而是这个内容会先被拷贝到TCP的发送缓冲区中,至于什么时候发?发多少?发的过程中出错了怎么办?这些都不需要上层担心,TCP会处理好一切,这就是TCP协议叫做传输控制协议的原因。
这种现象就跟文件系统中的文件IO一样,我们对文件进行写入时,其实不是直接写入到磁盘中,而是写到对应文件描述符对应的文件描述符表的所指向的文件结构体的指向的文件缓冲区中,至于什么时候将缓冲区的内容写入到磁盘,由操作系统决定,这一点两者有一些类似。
我们现在想象这样一种场景:
假如主机A不断地大量的向主机B发送信息,消息也确实都发过去了,但是到了对方的接收缓冲区中,对方的上层不从接收缓冲区拿走数据,这样的话就会导致主机B的TCP的接收缓冲区中数据一直堆积,缓冲区大小是有限的,持续下去缓冲区就满了。满了怎么办?像UDP协议一样丢掉?这样的方式其实是可以的,因为TCP为了保证可靠性有着各种机制,其中有一点就是重发数据,但是这样也太浪费资源了吧,假如对端主机就是不拿数据,那么缓冲区一直是满的,这样我们的主机A一直重发,但是重发过去的都被丢弃了,有点浪费资源。
所以TCP协议就设定了更为合理的处理方式,那就是流量控制。
在上面的场景中假如对端主机的接收缓冲区满了,那么对端主机就会向发送端主机发送一个"消息",意思就是 “我的缓冲区满了,你不要再发了”。而这种对发送端的表示自己接收状态的"消息"并不只是在自己的接收缓冲区满了之后会发送,而是持续存在于两端主机的交流中,在这里我们需要穿插一个知识:
知识穿插
我们知道,TCP协议是可靠的,它会为了保证可靠性做出各种策略,其中有一中策略就是,一端主机给另一端主机发送消息,当对端主机收到消息之后,对端主机也会给一端主机发送一个确认消息,而这个确认消息就是我们TCP结构化字段中的一个标记位ACK:
这就是TCP协议的确认应答机制。
需要非常注意的是,我们上面的消息的发送和应答,就只是发送一个字符串和一个标记位吗?大错特错,我们发送的是一个完整的报文:
继续刚才的话题,由于确认应答机制的存在,接收方将自己的接收状态交付给对端主机不是只发生在自己的接收缓冲区满了的时候,而是在每次交流时都会向对方反应自己的接收能力大小,而能够直观地反应自己的接收能力的大小的就是接收缓冲区的剩余空间的大小,所以TCP的应答报文中就会携带描述这个信息的字段,那就是16位窗口大小。
所以发送方就会因为确认应答机制得知对端窗口大小从而时刻知道对端的接受能力的大小,一旦对端主机的接收缓冲区满了之后,发送端TCP协议立马得知,并会停止发送。这一切在应用层的用户是没有感知的。这一切TCP协议已经帮我们做了,说得更具体一点那就是操作系统给我们做了。
而我们还要理解一个现象,既然你说上面的一系列行为上层用户无感知,那么上层用仍会继续向下层拷贝应用层报文,那么发送端的发送缓冲区满了怎么办呢?
那就对上层用户的写入函数进行阻塞。
我们还需要明确一个事情,那就是发送端的报文中的窗口大小描述的是谁的窗口大小?
是自己的,因为这个数据是需要对端识别的一个字段。
这一下我们的逻辑思路就顺起来了。
c. 确认应答
在上面的知识穿插中,我们了解到了,在使用TCP协议进行网络通信时,当发送端发送的报文对端收到之后,接收端会发送一个ACK标记位置为1的报文发给发送端,以表示自己收到了发送端发送来的报文。
但是网络通信就跟我们现实生活中两个人说话一样,总有一个人会说最后一话,那么这个最后一句话是没有应答的,也就是说就算有确认应答机制,我们也无法保证我们发送的消息百分百被对方收到,但是反过来想,只要我们收到了对方的应答,我们就能保证我们上一条发送的消息是一定被对方收到了。
d. TCP报文发送模式
经过了解上面的确认应答机制之后我们就可以理解一个比较简单的TCP报文发送模式:
我们通过确认应答机制,一定程度上能够反映对报文的发送成功的确定性,并且能够通过一发一确认的方式来进行通信,但是这样的发送效率有点低。
如果对方的窗口大小非常大,以及网络情况非常好,这样的传送方式就会显得效率低下,所以我们就有另一种传输模式:
那就是一次性发送多个报文,然后收到多个ACK,这样的发送方式效率是较高的(重叠了发送时间),但是这样的发送方式存在很大的问题:
首先,一次性发过去多个报文,由于网络情况错综复杂,无法确保先发的报文就会先到(UDP协议也有所体现),所以我们发送过去的多个报文就有可能乱序到达的,但是TCP协议是面向字节流的,这就意味着TCP协议必须确保上层拿到的消息是有序的,所以为了保证这一点,TCP协议在报头中有一个字段那就是32位序号,假如我每个发送的报文大小都是1000字节那么就会有这种发送方式:
这样的话,通过序号的大小比对就可以确保上层拿到的数据是有序的,不会出现数据不一致的情况。
但是这样的发送方式仍然有问题,在上面的第一种发送方式中,如果在发送过程中出现报文丢失的情况,发送方可以知道自己丢到的是哪个报文,但是对于第二种发送方式,假如其中一个或者几个报文丢了,发送方很明显就不知道自己丢了哪个报文,哪个报文需要重发,那就把刚才发的报文全部重发一遍?当然不是,所以就有了TCP协议的另一个字段,那就是32位确认应答序号,当接收方收到一个报文之后,会给发送方发送ACK,但是注意发送ACK并不只是发送一个标记位,而是一个完整报文,所以这个完整报文中还会携带一个信息,那就是确认应答序号,告诉对方我收到了哪条报文:
而为什么确认应答序号会对序号进行+1呢?这个就是一个顺带的事情,在发送端也能做,这个的意思就是你下一条报文该从这个序号开始了。
确认序号的存在不仅能让发送方明确自己发送的某一条报文是否发送成功,确认序号还有一个规定:发送确认序号的时候,接收方保证确认序号之前的所有消息全部收到。
这个规定意味着,在接收方发给发送方多个应答报文时,就算1001,2001,3001报文丢失,只有4001报文到达,发送方就知道4001以前的报文对方全部收到了。这么做再一次提高了双方通信的效率及可靠性,允许少量应答报文的丢失。
有人此时就有有问题了,为什么序号和确认序号需要分开表示呢?使用同一个32位空间不可以吗?
答案是不可以的,我所介绍的上面的各种例子中都是只有一端发送消息,一段接收消息,但是在实际网络通信中,基本上不是这样的情况,而是双方互相发送消息,双方互相确认消息(ACK)。
这个时候就需要再穿插一个知识了:
知识穿插
我们上面讨论的是只有一端向另一端发送消息,而另一端只负责接收和应答消息,这次我们来看看双方都在通信会是什么情况:
上面我们看到,双方对互相发送了消息,然后也收到了对方的应答,但是对于服务端来说,我应答之后,再发送我要发送的消息好像效率有点低,应答报文中只需要一个ACK,确认序号和窗口大小,而我要发的消息里面不需要标记位和确认序号,我只要序号和窗口大小还有有效载荷就可以了,两者可以共存在一个报文中,不影响,所以就会有这样的通信模式:
在给对方进行应答的时候,同时如果自己也需要发送给对方消息的话,就会将两个报文合并为一个报文发送,这就是捎带应答,正是有着这样的机制存在,才不会允许序号和确认序号使用同一个字段。
说到现在,我们可以发现TCP不仅保证双方通信的可靠性,而且还提高双方通信的效率。
e. 16位紧急指针 + 标记位
上面我们提到了ACK,它是用来确认应答的一个标记位,在我们的报头中只占一位:
我们发现,在ACK的旁边,还有许多字段,我现在就来介绍这些字段:
1). SYN和FIN
我们都知道在使用TCP是面向连接的,它进行网络通信前双方要先建立连接,所以TCP协议有三次握手和四次挥手,而三次握手和四次挥手就会使用这两个标记位:
关于三次握手和四次挥手我后面会再次详细介绍。
2). URG
URG是urgency,是紧急的意思,在我们的双方网络通信时,难免会出现一些突发情况,我们就需要对这些突发情况做出预防和相应的处理措施。
这个时候就需要向服务端主机发送一个紧急报文,紧急报文也就是将URG标记位置为一,并且会在有效载荷中附带紧急信息,这个时候16位紧急指针的作用就体现出来了,我们知道TCP协议是面向字节流的,它的报文与报文之间没有明显的界限,这就导致我们的紧急信息在这次报文中的位置是不确定的:
它并不是想当然的这个报文中就只携带紧急信息,这个认识是错误的因为面向字节流的特性,报文中的有效载荷中应用层的报文也不是单个的或者是完整的多个的,TCP协议同意将上层的数据都认为是字节流。所以16位紧急指针的作用就是标识该报文中的有效载荷中的紧急信息相对于整体有效载荷的偏移量,这样还不够假如对方收到这个报文之后,知道这个报文是一个紧急报文,要提取其中的有效信息,怎么提取呢?光知道开头,却不知道结尾,我哪知道这个紧急信息占多少个字节啊?所以规定紧急信息的大小是固定的一个字节。一个字节的状态有255种,也足够用来标识所有的突发情况了。
而突发情况一般来说都是很紧急的,需要立即处理的,但是我们也知道,TCP在向上层提供数据时是有序的,这就让TCP协议对报文进行一定的顺序性设置,这一点我们知道是使用序号来实现的,那此时假如在报文的接收中,接收到了一个紧急报文,那么,这个报文就不还是得乖乖排到比它序号低的报文后面吗?实际上,当一端主机收到了紧急报文之后,会对这个紧急报文做提前处理也就是插队的行为。
而目前这种紧急报文的使用一般是用在检测服务器的健康状态,或者是终止上次报文的上传行为等等。
在应用层我们使用recv接口将TCP报文中的有效载荷读上来,而这个recv接口中有一个参数flag,他其中有一个标记位字段:
这里的紧急信息就是out-of-band data ,带外数据(不属于普通信息)。当recv的flag字段种设置了MSG_OOB字段之后,在TCP缓冲区中如果存在紧急信息,那么该接口就会优先将该紧急信息读取上来。
3). PSH
我们上面提到过当接收方的接收缓冲满了之后,此时回复给发送方的报文中的窗口大小就是0,也意味着接收方告诉发送方不要再发送数据给他了,我只说了对于接收方来说当自己的接受能力不足时,就会告诉发送方不要再发送消息了,但是我们的接收方总归还是要接收消息的,那么发送方怎么知道接收方的窗口大小不再是0了呢?这里就会有两种处理方式:
首先发送方会间隔一段时间式的给接收方发送携带有PSH标记位的报文,这个报的意思就是询问接收方准备好了没,我能不能发送消息?如果不能发的话,你需要尽快处理你接收缓冲区的消息。
还有一种就是当接收方的窗口大小不再是0之后,它会自动给发送方发送报文,表明自己的窗口大小不再是0了,允许让发送方发消息。
这两种方式哪个先生效,就算哪个。
我在上面介绍PSH标记位报文时,提到让接收方尽快处理数据,这个标记位的使用不仅仅在这种场景下,在一些需要消息被尽快处理的场景下也会用到,就比如我们所使用的远端的shell一样,我们所输入的命令行的指令就会被设置成PSH报文,表示尽快处理。
4). RST
我们知道TCP协议是可靠的,并且为了可靠性采取了很多的策略。我们也知道TCP协议在在双方通信之前需要进行三次握手,那么此时我想问一个问题:这里的三次握手可以失败吗?
我在这里给出答案,TCP协议的三次握手是可以失败的,这里我们就需要再重新理解一下TCP协议的可靠性了。
我在上面介绍了确认应答机制,我还会在后面介绍当一个报文再发送的过程中丢失了,发送方会重新发送报文的重传机制,不管是确认应答还是重传机制都表明TCP协议不保证百分百的将报文送到对方,而是我会确认我发送的报文是否发送给了对方,然后会根据这个判断结果来采取后续的重发措施。也就是说TCP协议的可靠性并不是保证百分百的将数据交给对方,而是我能够确认我这个报文是否到达以及失败后续的补救措施,可靠性是体现在我发送一个报文这个报文到没到的确定性,TCP协议的可靠性是体现在确定性上。
所以我们的TCP协议的三次握手是有可能失败的。
而我们还需要明确三次握手的连接建立成功的时机:
对于客户端而言,客户端开始向服务端发送SYN建立连接请求,然后服务端向客户端发送SYN+ACK,当客户端收到ACK和SYN的时候客户端知道,服务器同意了我的连接请求,同时也向我请求连接,而当我给服务端发送ACK的时候,客户端就认为连接已经建立好了。
而服务器呢?不难想到的是服务器收到最后一次ACK时,才会认为双方的连接建立好了。
我们知道双方通信的时候不管你怎么保证可靠性,最新的一条报文你无法得知它是否到达,所以对于客户端来说,最后发送的ACK,客户端是不确定能都到达服务端的,客户端的这种行为是在 “赌” 服务端会收到。
那么此时我们现在来想象这样的一种场景:
假如就是客户端最后发送的ACK丢失了,或者在服务端发送完SYN + ACK之后,服务端的网线被拔了。
那么此时客户端认为连接已经建立好了,而服务端还认为没有建立好这个连接,或者是就不知道这个连接, 双方出现建立连接意识不一致的问题。然后服务端的网线被插上了,网络恢复正常。客户端此时向服务端直接发送报文,当服务端接收到这个报文之后,就会问:你是谁啊?咱俩通信前不是得先三次握手吗?你怎么直接把数据发送过来了?
所以此时服务端认为连接没有建立,向客户端发送携带RST标记位的报文,表示重新进行三次握手建立连接。
而这个RST也就是reset重置的意思。这样双方三次握手的可靠性才得以被保证。
2. 补充知识
a. 前置准备
在我介绍之后的知识之前,我们首先要建立一些共识以方便后续知识的理解。
TCP是面向字节流的,而且关于TCP报文的缓冲区他也是被一个叫struct sk_buff(这篇博客中也有介绍:UDP协议)的一个结构体描述的,但是由于TCP协议本身就很复杂,并且面向字节流的特性导致我们对于TCP缓冲区不好理解,所以我们可以在应用层和TCP的缓冲区之间再加一层:char类型的数组:
char outbuffer[N];
这样的话我们对于序号的理解就是char数组的下标了:
这里我为了方便说明只说明了从一端到另一端的场景(因为TCP协议下双方地位是对等的,研究好一方,另一方也是相同的状况),并且省略了发送方的接收缓冲区和接收方的发送缓冲区。当有了上面的认识之后,对于序号的理解我们就更加清晰了。
上面总说TCP协议是面向字节流的,其中关于"字节"来说我们已经理解了,TCP协议的有效载荷的基本单位是字节。那么我们如何理解 “流” 呢?
我们逐字节地将数据拷贝到发送缓冲区中,然后这个数据经过封装穿过网络协议栈到达对方,然后再经过解包和分用到达对方的TCP的接收缓冲区中,而当对方的应用层读取数据时,又会逐字节的读取缓冲区中的数据,此时我们可以感官上感觉到数据的流动性。这就是面向字节流,如果你了解过UDP的话,它的报文是由一个sk_buff结构体的数据结构来管理每一个报文,其实TCP的报文也是由sk_buff结构体的数据结构来被管理报文。
而我们还要明确的一点就是:每一对TCP连接操作系统都会为其创建单独的接收和发送缓冲区。
b. 超时重传机制
在介绍标记位RST时,我说TCP的可靠性体现在确定性上,而且也说了TCP会根据这个确定性采取出现错误后的补救措施,这其中就有超时重传机制,超时重传机制是在发送方发送出去报文之后,经过一定时间仍然没有收到该报文的应答,就会触发超时重传。
要认识这个超时重传机制,我们主要认识两点:超时重传面对的情况有哪些?,超时重传中经过一定时间的 “时间” 是多久?
关于前两点问题,我们不难想到出发超时重传的情况无非就两种:报文丢失或者应答丢失。
对于报文丢失,接收方并没有接受到该报文,也就不会有应答,而对于发送方由于长时间接收不到应答,经过一定时间之后就会触发超时重传机制,再次给接收方发送该报文,重复此过程,直到收到应答。
这里我们发现TCP协议发出报文之后,它不会将该报文立即丢失,而是会存储起来,直到收到关于该报文的应答之后再丢失,至于该报文被发出之后被存储在哪里,我在后面也会说到。
对于应答丢失,接收方已经收到了发送方发来的报文,也给发送方发送了相关的ACK,但是ACK丢失了,这样的话接收方由于收不到该报文的应答过一会儿也会触发超时重传机制,根报文丢失的情况一样,但是有不同的是,这里接收方已经接收到了该报文,面对发送方再次发送过来的报文该作何处理呢?这就体现出我们序号的作用了,接收方一看这个报文的序号是一个历史已经应答过的序号,对它的处理方式有两种,直接丢弃或者更新相同序号的报文。
还有一点就是关于超时重传的时间是多少?
关于这一点,由于网络世界错综复杂,我们无法确切的得出在某一时刻我们的报文经过多长时间就可以被判断为超时了,但是网络协议仍然给出了一个确切的数字,那就是500ms,并且这个超时的时间会随着超时的次数进行指数增长即500 * 2 ^ n,其中n与重传次数有关,当重传到达一定次数之后,发送方就会认为对方已经掉线,从而断开该连接。
c. 三次握手
我在上面画了一幅三次握手的草图:
现在我们已经清楚了,三次握手的具体内容,那就是通过设置标记位来进行连接的申请,现在我们将三次握手与socket编程中用到的接口及逆行一个贯通:
这就是三次握手与套接字编程的一个整体理解,三次握手以及后续四次挥手的过程中都会导致连接状态的变化,为了更好的理解其中的细节,我们需要再次将视角放的更大一些,我们从系统的角度来认识连接究竟是什么?
我们要清楚在一台机器上你可能同时听着歌,看着视频,打着游戏等等一系列的网上的行为,这就意味着,我们的系统中是会同时存在多个TCP连接的,那么既然存在多个连接,我们就需要对这多个连接进行管理,怎么管理?先描述再组织,我们首先要将连接描述成一个计算机能够认识的结构化字段,比如:
struct link
{int sockfd;int seq; // 序号uint16_t port;uint16_t ip;int status; // 连接状态struct link* next;struct link* prev;//.....
};
这样我们就把连接给具象化了,然后再用链表管理起来,从此之后对连接的管理转化为对链表的增删查改。
对连接有了一定认识之后,现在我们还要认识一个问题,那就是为什么要进行三次连接?为什么要进行连接?为什么不是一次、两次、或者是四次五次,偏偏是三次?
经过上面的认识之后我们知道维护连接是需要成本的,而这个成本体现在计算机上就是时间和空间。有一个比较权威的说法是:三次握手以最小的成本验证全双工,这句话的理解就是三次握手以最小的成本验证了连接双方的通信信道的畅通。这样的理解显然是不够的,我们还需要更加深刻的认识,其实我们的三次握手本质上是四次握手:
我们也可以看到,三次握手实质上是四次握手加一次捎带应答,我们来分析上图,首先客户端发送了一个SYN,然后接收到了一个ACK,这样客户端就可以确定自己的接收和发送是正常的,而服务器也是一样,一发一收,也确定了自己的收发是正常的。中间但凡少一次,都无法保证两端各自通信信道的正常通信,这就是为什么是三次握手,而不是一次或二次。三次握手已经明确了双方的收发能力的情况,过多的握手是无意义的消耗,所以不是更多握手次数。
那么此时就有人有问题了,那为什么我们所学到的TCP都是说三次握手,而不是这个呢?这是因为TCP连接绝大多数的场景都是客户端与服务器的连接,而服务器对于到来的连接一般都是同意,所以对于服务端而言发送ACK和SYN是同时的也就使用了捎带应答。而对于四次握手基本上是不存在,这么说更多的是便于理解。
我在上面说过,在双方进行三次握手的时候,双方认为的建立连接成功的时间是不一致的,对于发起方,收到对方的SYN+ACK认为连接已经建立好,而对端机器则认为发起方对自己的SYN+ACK做出应答才认为连接已经建立成功,这样的设定,导致假如发起方在三次握手的过程中出现问题,只要不发送最后一个ACK,那么这个失败的主要责任也是由发起方承担,而不是接收方,这样就会导致服务器的压力进一步变小。所以对于三次握手的原因还有就是奇数次握手可以降低服务端的压力。
对于一个大型服务来说,有可能会面临这样的一个情况,那就是会遇到一种攻击行为,这种攻击行为就是一直向服务端发送连接请求,从而使服务端的压力增加,导致服务器崩溃,这种行为叫做SYN洪水问题,那么此时假如是较少次数的握手,无法更好的面临这种问题,因为来一个连接请求就同意来一个连接请求就同意,服务器很快就撑不住了。所以对于三次握手还有一个原因可以是较为有效的优化SYN洪水问题(尽管还是无法彻底解决)。
以上就是我对三次握手的进一步理解。
d. 四次挥手
这是四次挥手的大致流程:
经过三次握手的进一步认识,我们也可以知道为什么是四次挥手的原因就是双方地位是对等的,你向我断开连接,我也要向你断开连接,并且是为了断开连接的确定性,也体现了TCP协议的全双工特性。
我在上面也说过,在TCP建立连接和断开连接的时候,都会伴随着连接状态的改变,那么我也会将socket套接字编程接口与四次挥手进行一次贯通,并且展现其中的状态变化:
经过三次握手的理解,对于四次挥手我们也是的理解方式。
对于断开连接的理解我们也可以是,A端的数据发送工作已经完成了,A端进程也不需要再进行工作了,所以A端向B端申请断开连接,自此A端到B端的连接关闭,当B端的工作完成之后也会断开B端向A端的连接。
所以我们会有一个接口:
我们不仅可以关闭一端连接,我们也可以关闭一端连接的读端或写端,这样的话,当A端结束了数据的传输之后,仍然可以接收B端的信息,并进行处理。
还有一个问题,四次挥手有没有可能是三次挥手呢?
这里给出的回答是有可能,但是不常见,因为在实际场景中,很少会出现,当一端要断开连接时,另一端正好也要断开连接,更多的情况是,当一端断开连接时,另一端大概率还没有将它要发送的数据发送完。
我们现在来看看四次挥手过程中双方连接的状态变化。(但是其中我们只能观察到其中的部分状态)
首先是CLOSE_WAIT和FIN_WAIT_2两个状态,我们发现当客户端发送FIN服务端对该报文进行应答之后,服务端如果不断开连接的话就会处于CLOSE_WAIT状态,而客户端收到ACK之后就会进入FIN_WAIT_2状态。这一点我们可以通过代码验证:
我怕所使用的就是一个简单的TCP协议的socket接口编写好的代码,代码在这。
所以我们为了做实验,我就把TcpServer中的close注释掉,不让服务端断开连接:
然后我使用telnet来对这个服务端进行连接,然后断开telnet,期间一直观测两端网络连接的变化:
而LAST_ACK这个状态我们也能顺带观测到,因为当客户端telnet断开连接之后,我们的服务端ACK之后,我们会直接关闭服务端这个进程,而直接关闭这个进程就会让操作系统意识到这个进程所对应的连接应该也要断开,所以操作系统就会向这个连接对端发送FIN,但是客户端的进程早就关闭了,也就不会给服务端的FIN进行ACK所以,服务器端这个连接就会处于LAST_ACK状态,过一段时间之后由于超时重传机制的原因会自动断开连接。
所以我们在我们编写的代码中一定要在对端关闭连接之后及时的关闭自己对应的连接,如果不关闭的话,不仅会出现文件描述符泄露,而且现在我们也知道,会有大量的连接没有关闭,而处于CLOSE_WAIT状态,从而对操作系统造成不必要的压力。这也与我们平时使用电脑的感受也有关系。假如我们打开电脑之后直接关机,这时候我们会发现电脑关闭得特别快,但是当我们打开电脑使用一段时间之后,就会发现电脑关机变得比较慢,这一部分的原因是要关闭系统中运行的进程,另一部分就是关闭计算机中的网络连接。
接下来就是TIME_WAIT状态,对于这个状态我们要进行一种新的实验,那就是将服务端连接直接关闭(让服务器正常关闭),然后检测连端的网络连接状态:
我要说的就是在双方断开连接的时候,主动断开连接的一方不会直接关闭连接,而是会进入TIME_WAIT状态,这就出现疑问了?为什么要进入TIME_WAIT状态呢?既然有原因的话为什么另一端不进入TIME_WAIT状态呢?
我先说为什么只有主动断开连接的一方会进入TIME_WAIT状态,这是因为主动断开连接的一方意味着它会发送此次会话中最后一个报文ACK,那么就说明这个报文是被对方收到还是丢失,自己完全不知道的,所以为了防止最后一个报文丢失,主动断开连接的一方会进入TIME_WAIT状态在ACK报文丢失之后,被动断开连接的一方仍然可以补发FIN报文,来确保双方连接可靠的关闭。这也是对四次挥手的可靠性的 保证。
其实TIME_WAIT还有另一个重要的意义,那就是让网络中的历史数据彻底消散,我们知道,TCP协议发送的报文是有可能丢失的,但是这个丢失有两种情况一种是真的丢了,还有一种那就是在网络中 “迷路” 了,那么这个报文极有可能会在双方连接断开之后到达目的地,这样的话假如没有TIME_WAIT状态并且恰好主动断开连接的一方再次跑起的进程又使用了这个套接字,那么就会对这个连接造成影响。所以TIME_WAIT的另一个意义就是让双方交流时产生的报文在网络中尽可能的彻底消散。一方面TIME_WAIT状态避免了历史报文重复到达产生影响,另一方面也避免了主动断开连接的一方中新旧连接产生混淆。
但其实尽管存在TIME_WAIT状态,仍然无法绝对的避免上述中历史报文的到达的影响,所以我们还需要知道一个知识,那就是关于序号的认识,在双方建立连接之后,发送报文的序号不是从0起始而是随机起始,那么就有人有疑问了,如果双方的序号是随机起始的话,双方如何保证可靠性呢?我哪知道对端发过来的序号是有效的还是无效的。这里我们就要意识到三次握手的重要性了,我们要知道三次握手不仅仅是简单的进行SYN、SYN+ACK、ACK,三次握手的过程中,双方还会交换自己的序号,而这个起始序号是随机的,这样的方式也一定程度上避免了,旧连接的历史报文对本次连接的影响。
所以现在我们知道TIME_WAIT的意义,那么TIME_WAIT持续的时间是多长呢?
这个时长一般是二倍的MSL,而这个MSL的意思是(Maximum Segment Lifetime,最大分节生命期)意思就是,官方认为一个报文在网络中的存活周期不会超过这个时间,所以关于这个MSL时间的限制依然是不同的系统有着不同设置,在Linux系统下我们可以通过输出这个路径下文件的内容,然后就可以得知当前系统中MSL的大小了:
cat /proc/sys/net/ipv4/tcp_fin_timeout
正因为TIME_WAIT的存在,导致我们使用TCP的socket编程时,对服务器端进程高频的终止启动,会导致在bind时出现下面的问题:
这是因为服务器端一般使用的都是固定端口(因为服务器端的套接字信息是众所周知的),所以当我们断开连接关闭进程之后,这个连接会进入TIME_WAIT状态,也就意味着这个套接字信息仍然被使用,只不过是不属于任何一个进程,而当再次启动进程的时候就会出现这样的问题,表示端口号已被使用。
所以这也是我为什么选择服务端做实验的原因,因为客户端bind的是随机端口,所以客户端就不会出现这样的问题。
而为了避免端口被重复使用的问题,所以就会有这么一个接口:
这个接口的使用方式一般是如下:
其中SO_REUSEADDR这个宏就是允许套接字重新绑定到一个仍在TIME_WAIT状态的本地地址。
或者就是直接修改刚才说过的那个路径的文件,但是这个方法不推荐。
e. 滑动窗口
接下来要介绍的是比较重要的两个大话题,一个是滑动窗口一个是拥塞控制。那么现在我就来介绍滑动窗口。
我在前面说TCP报文的发送有两种一种是一发送一应答,一种是并行发送然后并行应答,后者可以直观地感受到是比前者的效率高很多的,但是后者的实现肯定要比前者麻烦很多,所以就有了滑动窗口,滑动窗口就是上述后者发送方式的实现方案。
我也在前面说过为了更好的理解TCP缓冲区,我们需要在TCP缓冲区和用户层之间再加一层,那就是char类型的数组,那么做过一些算法题的应该都知道滑动窗口在数组中是怎么体现的,无非就是使用两个变量来指向窗口的开始和窗口的结尾,那么在这里的滑动窗口也是如此,只不过滑动窗口将这个char类型的数组分为了三个区域,三个区域代表不同的特点:
那么通过那两个变量就可以维护这个滑动窗口,窗口的更新就是两个变量数值的更新。
与此同时一系列的问题就来了,滑动窗口如何更新?窗口大小是否会改变?
我在这里直接给出答案,滑动窗口的更新方式如下:
win_start = 确认序号
win_end = 确认序号 + 窗口大小
举个例子,假如现在我们发送的报文大小都是1000个字节,那么发送数据的流程如下(这里图中一个格子代表一千个字节大小):
维护滑动窗口的两个变量目前是这么变化的(实际上还缺少一个条件)。
所以我们就清楚了滑动窗口的大小是由对方的窗口大小所决定的,此时有人有疑问了,那么刚开始的滑动窗口的大小是如何确定的?这个也是依靠三次握手,在三次握手期间不仅仅是单纯的连接,还有序号的协商,以及窗口大小的协商。所以我们也看到正因为对方窗口大小决定着自己的滑动窗口的大小,所以滑动窗口也是流量控制的实现方案,对方的接受能力是会体现到自己的滑动窗口中的,对方接受能力强,滑动窗口就大,对方接受能力小,滑动窗口就缩小。所以滑动窗口的大小是会变化的:
一旦对端上层此时一下把大量数据取走,那么对端的窗口大小变大,意味着自己的滑动窗口的大小也会变大。
这里可以很明确的得到,滑动窗口不会向左滑动,那么此时就有有问题了滑动窗口滑出去了怎么办?这个你不用担心,因为真正的TCP缓冲区非常复杂,所以我们使用一个char数组来理解它,所以你把这个数组想象成一个环形数组即可,真实的TCP缓冲区的实现是非常复杂的(因为它要符合面向字节流的特定等等)。
接下来我们探讨在并发发送报文的过程中假如报文出现丢失,那么滑动窗口会做出什么样的反应:
我们要知道假如报文丢失,这个报文在滑动窗口中的什么位置?如果理解了上面的东西的话,一下就会知道,丢失的报文意味着这个报文已经被发送,但是没有收到应答,也就是待应答,所以应该在窗口的这个区域:
再思考一下我们就会发现丢失的报文只会在窗口的最左侧,中间和最右侧。我们现在先研究假如在下图中的2000号报文丢失会发生什么:
我们不要忘了,报文做出应答还有一个重要的字段就是确认序号,而确认序号的规定就是,当前序号之前的报文已经全部收到,那么此时2000号报文丢失,接收方只收到了3000、4000、5000号报文而没有收到2000号报文,所以根据确认序号的定义这三个收到的报文的确认序号全部是1001,因为只有1001之前的报文全部被收到了:
需要注意的是,像图中发送方连续收到三个同样确认序号的报文时,会触发快重传机制,也就是会立即重发该序号之后的报文。
而假如是应答丢失的话,发送方会触发超时重传机制,来重新发送报文。
而当发送方重新发送2000号报文,并且对方也收到之后,接收方就会直接发送5001号报文来更新滑动窗口的起始位置。这就是最左侧报文丢失的情况。
假如是中间的报文丢失的话(3000号丢失),那么接收方会给发送方应答三个2001号确认序号,滑动窗口起始位置更新到2001的位置,并且重发3000号报文,这就是中间报文丢失的处理方式最终会转化为最左侧报文丢失的情况,而最右侧报文跟中间报文一样最终也会被处理成最右侧报文丢失的情况。
那么现在我们就知道了,发送出去的报文不会立即被丢失,而是保存起来,那么就是保存在滑动窗口中,当超过超时重传时间的时候就会重发报文。
所以滑动窗口还是超时重传机制的解决方案,而在报文丢失的处理情况中,快重传和超时重传是相辅相成的关系,两者并不冲突。
在上面了解了滑动窗口之后,我提出一个问题,那就是为什么知道对方的窗口大小之后我们还是要一块一块的发送,而不是直接将整个滑动窗口中的数据作为整体发送出去?这个问题的解答与更为下层的网络协议有关,本质上是跟硬件有关。
f. 拥塞控制
在上面关于TCP为了保证它的可靠性做出了各种各样的策略,比如确认应答,超时重传,并且在保证可靠性的基础上也提供了一系列的提高效率的策略,比如一次性发送大量报文,捎带应答,滑动窗口等等。
但是上面的介绍的一系列策略都只是针对于连接的两端主机的本身收发的可靠性保证,但是我们要知道在网络传输数据中可不仅仅只有两台主机,还有最重要的一个事物,那就是网络。所以TCP不仅保证了两端主机收发的可靠性的保证,也对网络传输做了一定的可靠性保证,当然这里的网络传输的可靠性依然不是解决网络问题,而是当出现网络问题之后,我们怎么采取下一步行动,而网络出现问题,其中最常见的就是网络拥塞,所以TCP协议有着拥塞控制,来应对这一问题。
网络拥塞是指在网络中出现大量发送的报文,导致网络转发能力下降,此时的情况已经超过网络运输报文的上限能力,所以就会使网络拥塞。
可能此时就有人有问题了,对于一个用户来说,他的主机一下最多能发送多少数据啊,怎么就会导致网络出现拥塞呢?此时就需要我们建立一个宏观的概念,在现实生活中一般是有许多个客户端连接一个服务器的情况,上面说到的是一台主机发送数据,如果客户端有一百万个呢?所以说网络拥塞不是一个单一的主机就会导致的问题,而是网络中所有客户端共同造成的结果。
所以当出现网络拥塞之后,对于TCP的处理方式就不应该是继续像以往一样发送报文了,因为继续这样的发送方式只会加重网络的压力,而拥塞控制的处理方式就是当网络中出现拥塞的时候,TCP协议会让发送端发送的数据减小,然后如果发送的数据得到应答,TCP协议就会让发送端逐渐增大每次发送的数据量,这就是拥塞控制。对于控制数据量发送的方式就是拥塞窗口。
所以此时就会出现一个拥塞窗口,当网络出现拥塞之后,这个拥塞窗口就会限制TCP的单次发送的数据量,拥塞窗口本质其实就是一个数字,上面也说到当开始拥塞控制发送报文的时候如果加下来的报文得到应答,则会扩大这个拥塞窗口,其实就是扩大这个数字,这个数字的增长方式是以指数的形式增长(2 ^ n)的,总体单次发送数据的数据量的多少的趋势是先小后大。这样的增长处理方式也叫做慢启动算法。
到了现在我们可以发现这个拥塞控制好像跟流量控制的效果一样也是限制单次发送报文的大小,只不过流量控制受限于对方的窗口大小,拥塞控制受限于网络状态,就是这样的,所以我在前面介绍滑动窗口时,滑动窗口应该多大我当时说滑动窗口的大小的更新方式就是确认序号 + 对方的窗口大小,这里我们了解了拥塞控制之后,就要更正滑动窗口大小的更新方式,他应该是:
win_end = 确认序号 + min(对端窗口大小, 拥塞窗口大小)
上面说到,网络状态良好发送方发送的数据都可以收到应答之后,拥塞窗口就会按指数型方式扩大,这里就有问题了,这个窗口是一直扩大吗?
我在这里直接给出答案:当网络情况良好的时候,这个拥塞窗口是会一直增长的,但是这里关于拥塞控制还有一个量,那就是拥塞窗口的阈值,当拥塞窗口的大小超过这个阈值之后,这个窗口大小就不再按照指数型增长而是线性增长。
此时如果在发送过程中又出现了网络拥塞,拥塞控制的阈值降为进行拥塞窗口数值的一半,然后拥塞窗口大小直接变为1,下面是一个假设对端主机接受能力无限的情况下,拥塞窗口控制数据发送的流程:
少量的丢包, 我们仅仅是触发超时重传; 大量的丢包, 我们就认为网络拥塞。
g. 延迟应答
如果接收数据的主机立刻返回ACK应答, 这时候返回的窗口可能比较小.
假设接收端缓冲区为1M,一次收到了500K的数据,如果立刻应答,返回的窗口就是500K,但实际上可能处理端处理的速度很快,10ms之内就把500K数据从缓冲区消费掉了,在这种情况下, 接收端处理还远没有达到自己的极限, 即使窗口再放大一些,,也能处理过来,如果接收端稍微等一会再应答,比如等待200ms再应答,那么这个时候返回的窗口大小就是1M。
延迟应答也提高了TCP协议发送报文的效率。
而对于延迟应答的实现方式有两种:
一种是按报文数量即接收方接收到N个报文之后再做应答。
另一种是过一段时间之后在整体对收到的报文进行应答,这个时间叫做最大延迟时间,
N(数据包数量)的值可能设为2,而最大延迟时间可能设置为200ms。当然不同的操作系统可能有不同的设置。
3. 总结以及TCP的特点
经过上面对TCP协议的全面认识我们已经知道了TCP协议是采取了一些什么策略来保证它的可靠性的,除此之外,在保证可靠性的基础上也提供了提高发送报文效率的解决方案:
保证可靠性:
校验和
按序到达(序号)
确认应答
超时重传
连接管理
流量控制
拥塞控制
提高效率:
滑动窗口
快重传
延迟应答
捎带应答
使用TCP协议的应用层协议较为出名的有:HTTP、HTTPS、SSH、Telnet、FTP、SMTP等。
a. 面向字节流
现在我们对于TCP协议的面向字节流的特点或许会有更加清晰的认识:
创建一个TCP的socket, 同时在内核中创建一个 发送缓冲区 和一个 接收缓冲区;
调用write时, 数据会先写入发送缓冲区中;
如果发送的字节数太长, 会被拆分成多个TCP的数据包发出;
如果发送的字节数太短, 就会先在缓冲区里等待, 等到缓冲区长度差不多了, 或者其他合适的时机发送出去;
接收数据的时候, 数据也是从网卡驱动程序到达内核的接收缓冲区;
然后应用程序可以调用read从接收缓冲区拿数据;
另一方面, TCP的一个连接, 既有发送缓冲区, 也有接收缓冲区, 那么对于这一个连接, 既可以读数据, 也可以写数据. 这个概念叫做 全双工由于缓冲区的存在, TCP程序的读和写不需要一一匹配, 例如:
写100个字节数据时, 可以调用一次write写100个字节, 也可以调用100次write, 每次写一个字节;
读100个字节数据时, 也完全不需要考虑写的时候是怎么写的, 既可以一次read 100个字节, 也可以一次read一个字节, 重复100次;
b. 粘包问题
由于TCP面向字节流的特性导致上层用户拿到数据时,数据与数据之间没有明显的边界感,这好像看起来就是自己家做好包子出锅之后,当我们想要夹一个包子,我们会发现有时候会带起其他包子,或者夹起来的是半个包子,也可能是运气好就是一个完整且独立的包子,用户层拿到的数据就是这样,这就是粘包问题。
而解决粘包问题那自然就是应用层要制定应用层协议,以便于每一次可以取到完整且独立的报文。
一般来说应用层协议的制定一般是一下几种方法:
1. 采用固定长度
2. 使用特殊字符进行分隔
3. 在报头中添加自描述字段
c. TCP异常情况
在两端传输数据的时候可能会发生一些不可抗拒因素,导致一端的连接突然断开,从而导致TCP连接出现异常,而下面就是比较常见的异常问题,以及处理方式。
进程终止: 进程终止会释放文件描述符, 仍然可以发送FIN. 和正常关闭没有什么区别.
机器重启: 和进程终止的情况相同.
机器掉电/网线断开: 接收端认为连接还在, 一旦接收端有写入操作, 接收端发现连接已经不在了, 就会进行reset.
4. listen接口的第二个参数
在很多人使用TCP协议的socket接口进行网络编程的时候,对于使用listen接口时,总是会将第二个参数设置为一个固定值,有些人可能也不知道这个参数的意义,只知道就是这么设置就行了,而这个数一般不要设置的太大,也不要不设置,接下来我就来介绍这个参数的意义。
话不多说,我们直接使用代码做实验来观测这个参数的意义,这里的代码仍然是上面观察四次挥手状态变化时的代码,但是不同的是,我们会对代码做出不同的修改:
首先我们将这里的defaultbacklog的值设为1,
然后我们只让这个进程bind套接字,并且在_listenfd文件描述符上进行监听到来的连接请求,但是对于建立好的的连接我们并不提取到上层。
这里需要注意的是,accept不参与双方建立连接的过程,他只是负责将建立好的连接所生成的文件描述符返回来,而具体双方连接的过程前面说过,与上层无关,上层用户对此是无感的。所以,就算不使用accept函数,对于到来的连接,操作系统也会建立。
此时我们使用另一台机器来连接这台主机,然后监测连接时网络连接的变化:
我们可以发现,当前两个连接到来时,操作系统能够成功的与其建立连接,但是面对到来的第三条连接,我们发现它卡在了SYN_RECV状态,也就是说对于到来的连接请求,接收端发出SYN+ACK等待客户端的ACK,卡在了SYN_RECV状态,这就是listen的第二个参数的意义。
为了更好理解上述内容,这就需要我给大家讲一个故事:
假如现在有一个饭馆,店内的顾客已经满了,此时又来了一个顾客要吃饭,服务员就说,人已经满了你去别家去吃吧。而就当这个顾客走了的时候刚好店里面有顾客吃完饭了,为此老板把服务员大骂一顿,说你就不能在店的外面准备一些桌椅板凳小瓜子,当店里满了又有顾客到来的时候你就让他们在外面等一等,等里面有空位了之后就可以把他们招呼进来了,于是服务员就在外面摆了超级多的桌椅板凳但是实际上桌椅板凳有很多剩余,为此老板又把他臭骂了一顿,说你买了这么多的桌椅板凳放在外面,还不如我把这些钱拿来扩张店面呢。
在上面的故事中店里的顾客就是TCP中的提取到上层的连接,外面的桌椅板凳就是backlog的大小,而外面等待的顾客就是已经建立好连接但未被上层取走的连接,当然座椅板凳的数量这个也是一个经验性数字,并没有一个特别准确的规定,所以可能还会有一些顾客,他们就算店外面的顾客都坐满了,他们仍然愿意站在这个店旁边等一会儿,而这里店外面站着的等待的顾客就是那些处于SYN_RECV状态的连接。当我们再次检测网络连接状态的时候,我们发现:
操作系统仍然为我们保存着这两个已经建立好的连接,但是处于SYN_RECV状态的连接却消失了这里我们也可以发现对于未被上层用户取走且已经建立好的连接,操作系统会维护他较长时间,而另一种处于SYN_RECV状态的连接却不会维持很长时间。
在上述中backlog + 1就是该套接字所能维持的未被上层用户取走且已经建立好连接的数目,我们也知道管理链接无非就是存储连接结构体的数据结构进行增删查改,而存储这种连接的数据结构我们也叫做全连接队列。
而维护时间很短的那个处于SYN_RECV状态的连接叫做半连接队列。
所以现在我们就知道了backlog的意义,它是用来设置全连接队列的大小,全连接队列的大小与backlog有关。而我们也知道了为什么这个参数不宜设置过大,也不应该没有的原因。
需要注意的是,这里backlog所表示的意义就是全连接队列即已经建立好连接的结构,但在有些操作系统中它也可能涵盖半连接队列。并且SYN_RCVD和SYN_RECV是同一种状态(不同操作系统有不同的体现)。
以上就是关于TCP协议的全部内容,如果有不对的地方,还请指正。