源/目的端口号:表示数据是从哪个进程来,到发送到对端主机上的哪个进程。
32位序号/32位确认序号:分别代表TCP报文当中每个字节数据的编号以及对对方的确认,是TCP保证可靠性的重要字段。
4位TCP报头长度:表示该TCP报头的长度,以4字节为单位。
6位保留字段:TCP报头中暂时未使用的6个比特位。
16位窗口大小:保证TCP可靠性机制和效率提升机制的重要字段。
16位检验和:由发送端填充,采用CRC校验。接收端校验不通过,则认为接收到的数据有问题。(检验和包含TCP首部+TCP数据部分)
16位紧急指针:标识紧急数据在报文中的偏移量,需要配合标志字段当中的URG字段统一使用。
选项字段:TCP报头当中允许携带额外的选项字段,最多40字节。
URG:紧急指针是否有效。
ACK:确认序号是否有效。
PSH:提示接收端应用程序立刻将TCP接收缓冲区当中的数据读走。
RST:表示要求对方重新建立连接。我们把携带RST标识的报文称为复位报文段。
SYN:表示请求与对方建立连接。我们把携带SYN标识的报文称为同步报文段。
FIN:通知对方,本端要关闭了。我们把携带FIN标识的报文称为结束报文段。
观察TCP协议格式,报文部分把选项除去一共是20字节。所以我们可以先取20字节。而其中的四位头部长度就表示的是报头的大小,根据这个就可以计算出选项的大小。
读取完TCP的基本报头和选项字段后,剩下的就是有效载荷了。
这里有四个比特位,如果按照正常的计算取值范围就是【0 ~ 15】,但明显不对,因为报头最少要为20字节。
所以规定TCP报头当中的4位首部长度描述的基本单位是4字节,这样取值范围就是【0 ~ 60】,所以整个报头的大小范围是【20 ~ 60】,报头中选项字段的长度最多是40字节。
因为应用层的每个进程都会绑定一个端口号:
上面把报头提取了出来,而报头里含有目的端口,就可以向上找到对应的协议了。
补充:内核中用哈希的方式维护了端口号与进程ID之间的映射关系,因此传输层可以通过端口号快速找到其对应的进程ID,进而找到对应的应用层进程。绑定映射关系的时机:bind端口的时候。
跟上一章讲的UDP报头一样,TCP报头就是一个结构化对象:
【网络编程】传输层协议——UDP协议
也是内核创建一块内存,后边就拷贝有效载荷,前面就强转成结构化数据然后填写每个字段。
在讲需要与确认序号之前先引入网络可靠性的概念:
现在计算机基本都是基于冯诺依曼体系结构:
上图的这些设备虽然都在一台机器上,但它们都是独立的硬件设备,它们之想要进行数据交互,就必须要进行通信。因此这几个设备实际是用“线”连接起来的,其中连接内存和外设之间的“线”叫做IO总线,而连接内存和CPU之间的“线”叫做系统总线。
而在一台机器内,这些"线"的长度很短,所以传输数据发生错误的概率很小,但是如果要通信的两个机器相隔很远(网络),那么传输数据出错的概率也会大大增加。
所以网络传输的不可靠问题本质就是距离变长了。
丢包、乱序(网络阻塞)、校验错误(比特位翻转)、重复
怎么保证自己说的话对方听到了呢?答案是得到对方的回复(应答),只有收到了应答,才能保证历史消息被对方收到了。只有确认了应答,才算可靠。
而双方通信一定会存在最新消息,最新消息一般是无法保证可靠的。
由上面描述可知没有绝对的可靠性,只有相对的可靠性。
TCP保证可靠性的机制之一就是确认应答机制。
所以双方进行通信的时候可能除了正常的数据段,还会包含确认数据段。
如图是双方通信使用串行方式,即只有收到了确认应答才会继续发送数据。这样的效率可想而知是非常低的。
实际工作中不会这样,而是一方同时发送多条数据段,只要保证所有数据段都有应答即可。
但此时就会有一个问题,那就是这么些数据段到达对面的顺序不一定就是发送的顺序。
比方说发送了四个数据段,结果只收到了三个确认应答,那么怎么知道是哪个数据段发送失败了呢?
解决上面的问题就是TCP报头中的32位序号字段。
TCP将发送出去的每个数据段都进行了编号,这个编号叫做序列号。
这样就保证了传递数据段的有序性。
举个例子:
假设要发送4000字节的数据,分四次发送,就需要发送四个TCP报文,此时这四个TCP报文当中的32位序号填的就是发送数据中首个字节的序列号,因此分别填的是1、1001、2001和3001。
当主机B接收到这四个TCP报文的时候,就可以利用这四个报头中的序号字段进行排序。
TCP报头当中的32位确认序号是告诉对端,我当前已经收到了哪些数据,你的数据下一次应该从哪里开始发。
比方说客户端发送的数据段的序号是1,报文中含有1000字节的数据,如果服务端收到了,那么就会把返回给客户端的响应报头中的32位确认序号填写成1001,那么这个1001就有两层含义:
1️⃣ 告诉主机A,序列号在1001之前的字节数据我已经收到了。
2️⃣ 告诉主机A,下次向我发送数据时应该从序列号为1001的字节数据开始进行发送。
通过序号和确认序号就可以表示:
接收方已经收到ACK序号(确认序号)之前的所有(连续)报文。
举个例子:
发送的数据都是1000字节大小。
如果序号1001的数据段没有传递到主机B,其他的传递到了主机B,那么1001之后的数据段的确认序号都只能填写1001。这表明的就是序列号在1001之前的数据段都被收到了。
为什么不能把32位序号和32位确认序号压缩为一个字段,发送的时候就填序号,返回的时候就填确认序号?
如果是一端发送数据一段接收数据当然可以使用这种方式,但是TCP是全双工的,双方可能同时要给对方发送消息。
双方发出的报文当中,不仅需要填充32位序号来表明自己当前发送数据的序号。还需要填充32位确认序号,对对方上一次发送的数据进行确认,告诉对方下一次应该从哪一字节序号开始进行发送。
首先要知道TCP是有自己的发送缓冲区和接收缓冲区。
当上层调用write/send,实际上是把数据拷贝到发送缓冲区。
当上层调用read/recv,实际上是把数据拷贝到接收缓冲区。
这样就会导致两种种情况:
发送数据过快,导致接收缓冲区被打满,剩下的报文都会被丢弃掉。
发送数据过慢,影响到上层的业务处理。
既然如此,TCP就要控制传输速度。所以必须要知道对方缓冲区的接受能力。也就是接收缓冲区剩余空间的大小。
通过16位窗口的字段填写发送方的剩余缓冲区的大小。也就是当前主机接收数据的能力。那么接收方知道了以后就会调整发送速度。
窗口大小字段越大,说明接收端接收数据的能力越强,此时发送端可以提高发送数据的速度。
窗口大小字段越小,说明接收端接收数据的能力越弱,此时发送端可以减小发送数据的速度。
如果窗口大小的值为0,说明接收端接收缓冲区已经被打满了,此时发送端就不应该再发送数据了。
补充一点
因为窗口有16位,所以窗口最大的内存位64k。如果数据量太大了就可以用选项字段的一些选项把窗口扩大。
TCP的报文也是有类型的,比方说正常通信的普通报文,建立连接时发送的报文,断开连接发送的报文。
针对这些不同类型的报文需要有对应的动作,比方说如果收到的是正常通信的报文,就需要把数据放到缓冲区中,如果收到的是建立连接的报文,就要进行三次握手。
六个标志位就是为了区分不同的类型。
报文当中的SYN被设置为1,表明该报文是一个连接建立的请求报文。
只有在连接建立阶段,SYN才被设置,正常通信时SYN不会被设置。
报文当中的FIN被设置为1,表明该报文是一个连接断开的请求报文。
只有在断开连接阶段,FIN才被设置,正常通信时FIN不会被设置。
报文当中的ACK被设置为1,表明该报文可以对收到的报文进行确认。
一般除了第一个请求报文没有设置ACK以外,其余报文基本都会设置ACK,因为发送出去的数据本身就对对方发送过来的数据具有一定的确认能力,因此双方在进行数据通信时,可以顺便对对方上一次发送的数据进行响应。
报文当中的PSH被设置为1,是在告诉对方上层尽快去取走数据。
因为可能接收方的窗口值比较小,而发送发就需要阻塞等待接收方取走缓冲区的数据后才能发送,那么此时就可以用PSH标志位来催促。
报文当中URG被设置为1,是告诉对方这个数据是要特殊尽快处理。
因为TCP是可靠传输,所以数据段一定是有序的被接收方收到,但是如果有数据段想要插队就可以设置URG。
这里要注意并不是说这个数据段的有效载荷的所有部分都要被紧急处理,可能只是一小部分,那么怎么找到位置呢?
TCP报头有一个字段是紧急指针。它填写的是偏移量,就可以找到紧急数据。因为紧急指针只有一个,它只能标识数据段中的一个位置,因此紧急数据只能发送一个字节。
URG一般用来发送带外数据,它不用走TCP流,因为接收方直接处理。比方说我们现在发了很多数据,对方正在处理,但是我们突然发现不需要这些数据了,此时就可以发送紧急带外数据,把套接字关了。
报文当中的RST被设置为1,表示需要让对方重新建立连接。
在通信双方在连接未建立好的情况下,一方向另一方发数据,此时另一方发送的响应报文当中的RST标志位就会被置1,表示要求对方重新建立连接。
还有一种情况是服务端网线被把了,连接被断开了,但是客户端不知道,还会发消息,这时服务端就会把RST设置为1,让客户端建立一个新连接。
TCP保证可靠性的机制之一就是确认应答机制。
确认应答机制是靠TCP报头中的32位序号和32位确认序号实现的,收到的确认应答说明该序号之前的数据全部被收到了。
我们可以把传输层的发送缓冲区看成一个数组,当我们把应用层的数据拷贝到发送缓冲区的时候,每个字节的数据就天然的有了一个编号(下标),只不过这个下标不是从0开始的,而是从1开始往后递增的。。
发送方发送数据时报头当中所填的序号,实际就是发送的若干字节数据当中,首个字节数据在发送缓冲区当中对应的下标。
接收方接收到数据进行响应时,响应报头当中的确认序号实际就是,接收缓冲区中接收到的最后一个有效数据的下一个位置所对应的下标。
当发送方收到接收方的响应后,就可以从下标为确认序号的位置继续进行发送了。
发送的数据报文丢失了,此时发送端在一定时间内收不到对应的响应报文,就会进行超时重传。
对方发来的响应报文丢包了,此时发送端也会因为收不到对应的响应报文,而进行超时重传。
当出现丢包情况的时候,发送方是不会知道究竟是数据段发送的时候丢包了还是确认应答的时候丢包。所以发送方只能进行超时重传。
那么如果是第二种丢包情况,接收方就可能会收到份同样的数据。因为重复的报文也是不可靠的一种,所以主机B需要进行去重(通过序号)。
因为需要超时重传,所以数据发送出去后不会立即清除,而是保留一段时间。直到收到该数据的响应报文后,发送缓冲区中的这部分数据才可以被删除或覆盖。
我们通过超时来判断是否丢包,那么这个时间到底是多久呢?
我们知道数据发送的时间是由网络状况决定的,而网络会因为环境的变化不断变化。所以超时重传的时间一定不是固定的。
TCP为了保证无论在任何环境下都能有比较高性能的通信,因此会动态计算这个最大超时时间:
Linux中(BSD Unix和Windows也是如此),超时以500ms为一个单位进行控制,每次判定超时重发的超时时间都是500ms的整数倍。
如果重发一次之后,仍然得不到应答,下一次重传的等待时间就是2 × 500 2\times5002×500ms。
如果仍然得不到应答,那么下一次重传的等待时间就是4 × 500 4\times5004×500ms。以此类推,以指数的形式递增。
当累计到一定的重传次数后,TCP就会认为是网络或对端主机出现了异常,进而强转关闭连接。
面向连接是通过要连接的两台主机分别在自己的主机上开辟一块区域,然后通过TCP协议来共同维护这两块区域,来实现网络传输的可靠性。所以,面向连接就是为了保证数据的可靠性。
这样说可能还不是很好理解,那么在对比一下面向无连接理解一下。UDP协议就是典型的无连接协议,在两台主机之间网络通信时,不需要知道目标主机ip和目标端口是否存在,可以按照定义的ip和端口直接发送到网络中,而面向连接则是先根据给定的目标ip和端口号发送到网络中一些消息来确认目标主机是否存在,如果不存在则不能完成接下来的网络通信。
所以,面向连接是需要先建立连接才能进行网络通信的,建立连接就是确定对方存在并协商好一些控制量来确保接下来的通信是可靠的。
无连接协议中的分组被称为数据报,每个分组都是独立寻址,并由应用程序发送的。从协议的角度来看,每个数据报都是一个独立的实体,与在两个相同的对等实体之间传送的任何其他数据报都没有关系,这就意味着协议很可能是不可靠的。也就是说,网络会尽最大努力传送每一个数据报,但并不保证数据报不丢失、不延迟或者不错序传输。
另一方面,面向连接的协议则维护了分组之间的状态,使用这种协议的应用程序通常都会进行长期的对话。记住这些状态,协议就可以提供可靠的传输。比如,发送端可以记住哪些数据已经发送出去了但还未被确认,以及数据是什么时候发送的。如果在某段时间间隔内没有收到确认,发送端可以重传数据。接收端可以记住已经收到了哪些数据,并将重复的数据丢弃。如果分组不是按序到达的,接收端可以将其保存下来,直到逻辑上先于它的分组到达为止。
典型的面向连接协议有三个阶段。第一阶段,在对等实体间建立连接。接下来是数据传输阶段,在这个阶段中,数据在对等实体间传输。最后,当对等实体完成数据传输时,连接被拆除。
一种标准的类比是:使用无连接协议就像寄信,而使用面向连接的协议就像打电话。
因为要保证可靠性,连接不能直接保证可靠性。
只要建立了连接,就会有连接结构体,里面包含了超时重传、按序到达、流量控制、拥塞控制等等策略以及通信状态和报文属性等等。连接结构体就是保证数据可靠性的基础。而三次握手是建立连接结构体的基础,所以三次握手间接的保证了可靠性。
而UDP不需要通信状态以及报文属性等等,所以不需要建立连接。
双方在进行TCP通信之前需要先建立连接,建立连接的这个过程我们称之为三次握手。
第一次握手:客户端向服务器发送的报文当中的SYN位被设置为1,表示请求与服务器建立连接。
第二次握手:服务器收到客户端发来的连接请求报文后,紧接着向客户端发起连接建立请求并对客户端发来的连接请求进行响应,此时服务器向客户端发送的报文当中的SYN位和ACK位均被设置为1。
第三次握手:客户端收到服务器发来的报文后,得知服务器收到了自己发送的连接建立请求,并请求和自己建立连接,最后客户端再向服务器发来的报文进行响应。
建立连接时不是百分之百成功的,三次握手的任何一次都有可能丢包,前两次握手是能够保证被对方收到的,因为它们都有应答,如果没有,大不了超时重传,但是如果是第三次ACK应答丢了呢?
当客户端发送ACK应答的一瞬间,它就会认为三次握手已经建立成功了,此时如果ACK应答丢了,此时就会连接建立失败,但是根本不用担心,有解决方案:
例如服务端没有收到应答,它就会重传第二次握手,客户端就会意识到链接没有被建立成功。
另外算客户端已经发送了数据,因为只有三次握手成功了才能发送消息,所以服务端就会返回RST报文要求客户端重新建立连接。
先说说一次,一次就是客户端发送连接请求后就认为连接建立好了,服务端就会维护这个连接。那么如果客户端写一个多线程不断向服务端发送连接请求,服务端就会认为这些连接都建立好了,服务端就会维护这些链接,如果链接过多了就会导致资源被占满,这个情况就叫做SYN洪水。其次也无法验证全双工通信信道是通畅的(客户端无法保证自己发送了消息被服务端收到),所以一次握手是不可能完成连接的。
那么两次握手呢?在第二次握手发出报文的瞬间服务端就认为连接建立好了,可能这个报文客户端压根就没收到,所以就会产生一次握手同样的问题(SYN洪水)。其次也无法验证全双工通信信道是通畅的(服务端不能证明自己能发送消息被对方收到)。
上面会导致单机攻击服务器的本质原因是客户端还没有建立连接时服务端已经建立好连接了。所以必须让客户端先建立连接,服务端再建立连接。
现在就可以说明为什么要三次握手了:
三次握手是用最小的成本验证全双工通信信道是通畅的。
要想服务端建立连接,客户端必须先建立连接,所以可以有效的规避单主机对服务器攻击问题。
可以是可以,但是没必要,会降低效率。
服务端是把第二次握手的SYN和ACK分开发送,这两个既然可以合并发就没必要分开分两次发送。出于优化目的,四次握手中的二、三可以合并。
最开始时客户端和服务器都处于CLOSED状态。
1️⃣ 服务器为了能够接收客户端发来的连接请求,需要由CLOSED状态变为LISTEN状态。
2️⃣ 此时客户端就可以向服务器发起三次握手了,当客户端发起第一次握手后,状态变为SYN_SENT状态。
3️⃣ 处于LISTEN状态的服务器收到客户端的连接请求后,将该连接放入内核等待队列中,并向客户端发起第二次握手,此时服务器的状态变为SYN_RCVD。
4️⃣ 当客户端收到服务器发来的第二次握手后,紧接着向服务器发送最后一次握手,此时客户端的连接已经建立,状态变为ESTABLISHED。
5️⃣ 而服务器收到客户端发来的最后一次握手后,连接也建立成功,此时服务器的状态也变成ESTABLISHED。
在客户端发起连接建立请求之前,服务器需要先进入LISTEN状态,此时就需要服务器调用对应listen函数设置套接字属性。
当服务器进入LISTEN状态后,客户端就可以向服务器发起三次握手了,此时客户端对应调用的就是connect函数。
需要注意的是,connect函数不参与底层的三次握手,connect函数的作用只是发起三次握手。当connect函数返回时,要么是底层已经成功完成了三次握手连接建立成功,要么是底层三次握手失败。
如果服务器端与客户端成功完成了三次握手,此时在服务器端就会建立一个连接,但这个连接在内核的等待队列当中,服务器端需要通过调用accept函数将这个建立好的连接获取上来。
当服务器端将建立好的连接获取上来后,双方就可以通过调用read/recv函数和write/send函数进行数据交互了。
由于双方维护连接都是需要成本的,因此当双方TCP通信结束之后就需要断开连接,断开连接的这个过程我们称之为四次挥手。
哪边不想给对方发消息了,就要发送断开连接请求,比如说客户端要断开连接:
客户端发送断开连接请求,服务端返回ACK应答,这就已经两次挥手了。
服务端也要断开连接,发送请求,客户端返回ACK应答,这就是四次挥手。
这里有一个问题,既然前面客户端都已经说明不给服务端发送数据了,为什么后边还会发送确认应答呢?
注意这里的不发送数据的数据指的是用户数据(应用层不发数据了)。并不代表底层没有报文交互。
注意这里的二三次挥手是有可能合并为一次的。
在挥手前客户端和服务器都处于连接建立后的ESTABLISHED状态。
1️⃣ 客户端为了与服务器断开连接主动向服务器发起连接断开请求,此时客户端的状态变为FIN_WAIT_1。
2️⃣ 服务器收到客户端发来的连接断开请求后对其进行响应,此时服务器的状态变为CLOSE_WAIT。
3️⃣ 当服务器没有数据需要发送给客户端的时,服务器会向客户端发起断开连接请求,等待最后一个ACK到来,此时服务器的状态变为LASE_ACK。
4️⃣ 客户端收到服务器发来的第三次挥手后,会向服务器发送最后一个响应报文,此时客户端进入TIME_WAIT状态。
5️⃣ 当服务器收到客户端发来的最后一个响应报文时,服务器会彻底关闭连接,变为CLOSED状态。
6️⃣ 而客户端则会等待一个2MSL(Maximum Segment Lifetime,报文最大生存时间)才会进入CLOSED状态。
客户端发起断开连接请求,对应就是客户端主动调用close函数关闭套接字。
服务器发起断开连接请求,对应就是服务器主动调用close函数关闭套接字。
一个close对应的就是两次挥手,双方都要调用close,因此就是四次挥手。
主动断开连接的一方最终状态是TIME_WAIT
被动断开连接的一方两次挥手完成后的状态是CLOSE_WAIT
我们主要研究的就是这两个状态:
双方在进行四次挥手时,如果只有客户端调用了close函数,而服务器不调用close函数(不会发送FIN),此时服务器就会进入CLOSE_WAIT状态,而客户端则会进入到FIN_WAIT_2状态。
如果服务器没有主动关闭不需要的文件描述符,此时在服务器端就会存在大量处于CLOSE_WAIT状态的连接,而每个连接都会占用服务器的资源,最终就会导致服务器可用资源越来越少。
因此在编写网络套接字代码时,如果发现服务器端存在大量处于CLOSE_WAIT状态的连接,此时就可以检查一下是不是服务器没有及时调用close函数关闭对应的文件描述符。
次挥手前三次如果发生了丢包情况,我们都可以利用超时重传机制,最担心的自然是第四次ACK应答时丢包
如果客户端在发出第四次挥手后立即进入CLOSED状态,此时服务器虽然进行了超时重传,但已经得不到客户端的响应了,因为客户端已经将连接关闭了。
服务器在经过若干次超时重发后得不到响应,最终也一定会将对应的连接关闭,但在服务器不断进行超时重传期间还需要维护这条废弃的连接,这样对服务器是非常不友好的。
为了避免这种情况,因此客户端在四次挥手后没有立即进入CLOSED状态,而是进入到了TIME_WAIT状态进行等待,此时要是第四次挥手的报文丢包了,客户端也能收到服务器重发的报文然后进行响应。
所以TIME_WAIT会保证最后一个ACK应答尽量被对方收到,而且可能断开之前发送的报文还滞留在网络中,那么TIME_WAIT就可以保证双方通信信道上的数据在网络中尽可能的消散。
而TIME_WAIT状态的时间过长,也会自动关闭连接,那么这个时间多长呢?
TCP协议规定,主动关闭连接的一方在四次挥手后要处于TIME_WAIT状态,等待两个MSL(报文最大生存时间)的时间才能进入CLOSED状态。
我们把从发送方到接收方经过的最大时间叫做MSL。
TIME_WAIT的等待时长设置为两个MSL的原因:
MSL是TCP报文的最大生存时间,因此TIME_WAIT状态持续存在2MSL的话,就能保证在两个传输方向上的尚未被接收或迟到的报文段都已经消失。
同时也是在理论上保证最后一个报文可靠到达的时间。
Centos7上默认配置的值是60s
在之前的代码里发现如果是服务端主动断开连接,就会导致一段时间内无法bind,就是因为服务器是TIME_WAIT状态,该端口和连接依旧存在,所以会绑定失败(端口被占用)。
服务器不能立即重启的危害:
比方说双十一的时候,连接过多导致服务器挂掉了,此时 我们想要立即重启却要等很久(60S),那么就会造成巨大的损失。
那么怎么解决这个问题呢?
使用setsockopt()设置socket描述符的 选项
SO_REUSEADDR
为1, 表示允许创建端口号相同但IP地址不同的多个socket描述符。
int opt = 1;
setsockopt(listenfd, SOL_SOCKET, SO_ REUSEADDR, &opt, sizeof(opt)) ;
TCP支持根据接收端的接收数据的能力来决定发送端发送数据的速度,这个机制叫做流量控制。
上面在讲16位窗口大小的时候就说了传输数据的时候速度要适中,所以报头中有16位窗口大小,来控制传输速度,通过填写16位窗口大小告诉对端自己的接收能力(接收缓冲区还剩多少)。
但是发送方怎么在第一次就知道对方的接受能力呢?
在通信之前,就已经三次握手了,所以在握手期间,就可以互相交换窗口大小了。
关于窗口大小如何控制传输速度已经在上面讲过,不做赘述。
这里并补充一点:
当发送端得知接收端接收数据的能力为0时会停止发送数据,此时发送端会通过以下两种方式来得知何时可以继续发送数据:
等待告知:接收端上层将接收缓冲区当中的数据读走后,接收端向发送端发送一个TCP报文,主动将自己的窗口大小告知发送端,发送端得知接收端的接收缓冲区有空间后就可以继续发送数据了。
主动询问:发送端每隔一段时间向接收端发送报文(窗口探测),该报文不携带有效数据,只是为了询问发送端的窗口大小,直到接收端的接收缓冲区有空间后发送端就可以继续发送数据了。
这两种策略在实际中是同时使用的,哪个先到就处理哪个。
在我们发送数据但是没收到应答之前,我们必须把数据暂时保存起来,以支持后续可能出现的超时重传。那么保存在哪里呢?
答案是发送缓冲区
前面说过,多个报文一般是并行发送,即还没收到应答,下一个报文就已经发送出去了,目的是为了提高效率。
那么我们就可以把发送缓冲区分成三个部分:
滑动窗口的本质就是发送缓冲区的一部分。 通过不断地滑动来重新划分三段区间。
把缓冲区看成一个数组,那么滑动窗口的移动其实就是下标进行更新。
滑动窗口的大小和对方的接收能力有关,未来不管怎么滑动,都要保证对方能够正常接收(滑动窗口大小 <= 对方接受能力)。具体多大在后面讲拥塞控制的时候会讲。
可能向右滑动,可能保持不变。因为可能数据在对方的接收缓冲区迟迟没有被拿走。
当发送端收到对方的响应时,如果响应当中的确认序号为ACK_SEQ
,窗口大小为tcp_win
,此时就可以将win_start更新为ACK_SEQ
,而将win_end更新为win_start + tcp_win
。
那么如果对方的上层一直不取走数据,发送发却一直发,就会导致tcp_win
越来越小,也就是滑动窗口的左侧一直向后移动,右侧却不变,最终滑动窗口会变为0。
因为TCP是可靠传输,不可能出现乱序,所以如果收到了中间数据的应答,一定是发生了丢包。
丢包可以分为两个情况:
1️⃣ 数据没丢,ACK应答丢了
根据确认序号的定义,如果收到的是3001,那么说明3000以前的数据全部都收到了,那么就把win_start移动到3001即可。
2️⃣ 数据真的丢了
当1001-2000的数据包丢失后,发送端会一直收到确认序号为1001的响应报文,就是在提醒发送端“下一次应该从序号为1001的字节数据开始发送”。
而如果连续收到三个同样的确认序号,就会触发重传机制。 这也叫做快重传:
快重传是能够快速进行数据的重发,当发送端连续收到三次相同的应答时就会触发快重传,而不像超时重传一样需要通过设置重传定时器,在固定的时间后才会进行重传。
总结一下:
滑动窗口的左端就是通过确认序号确定的,右端是通过左端和对方接收缓冲区的剩余空间决定的。
滑动窗口一直向右滑动,那么总有空间用完的时候,该如何处理呢?
发送缓冲区被内核组织成了环形结构。
1000个报文丢掉一两个很正常,重复发即可,但是如果1000个报文有999个都丢了,那我们还要重传么?
打个比方,考试一个班四十个人如果只有一个人挂了,那大概率是这个人的问题,如果挂了39个,那还是学生的问题吗?
针对这种大面积的丢包情况,TCP就会考虑是网络拥塞问题,此时重传就没什么用了,重传也只会加重网络故障问题。
当网络出现拥塞问题时,通信双方虽然不能提出特别有效的解决方案,但双方主机可以做到不加重网络的负担。
双方通信时如果出现大量丢包,不应该立即将这些报文进行重传,而应该少发数据甚至不发数据,等待网络状况恢复后双方再慢慢恢复数据的传输速率。
需要注意的是,网络拥塞时影响的不只是一台主机,而几乎是该网络当中的所有主机,此时所有使用TCP传输控制协议的主机都会执行拥塞避免算法。
TCP引入了慢启动机制,在刚开始通信时先发少量的数据探探路,摸清当前的网络拥堵状态,再决定按照多大的速度传输数据。
在讲慢启动机制之前先引入一个概念:拥塞窗口
其实就是一个数字,再超过这个数字的时候就可能引发网络拥塞问题。
最开始的时候定义为1,每次接收到一个ACK应答,就加1,每次发送数据包的时候,将拥塞窗口和接收端主机反馈的窗口大小做比较,取较小的值作为实际发送数据的窗口大小,即滑动窗口的大小。
滑动窗口大小 = min(拥塞窗口,窗口大小(对端的接受能力))
每收到一个ACK应答拥塞窗口的值就加1,此时拥塞窗口就是以指数级别进行增长的,如果先不考虑对方接收数据的能力,那么滑动窗口的大家就只取决于拥塞窗口的大小,此时拥塞窗口的大小变化为:1 2 4 8 ……
但我们知道指数增长是非常恐怖的,此时就有可能导致网络再次拥塞。
此时就引入了慢启动的阈值,当拥塞窗口的大小超过这个阈值时,就不再按指数的方式增长,而按线性的方式增长。
当TCP刚开始启动的时候,慢启动阈值设置为对方窗口大小的最大值。
在每次超时重发的时候,慢启动阈值会变成当前拥塞窗口的一半,同时拥塞窗口的值被重新置为1,如此循环下去。
如图:
前期慢开始是为了让网络自主恢复,后面指数增长是为了尽快恢复通信。
现在接收方缓冲区有很多数据,但是应用层有很大概率会马上把数据拿走,如果等一等再应答就可以返回更大的窗口。
需要注意的是,延迟应答的目的不是为了保证可靠性,而是留出一点时间让接收缓冲区中的数据尽可能被上层应用层消费掉,此时在进行ACK响应的时候报告的窗口大小就可以更大,从而增大网络吞吐量,进而提高数据的传输效率。
此外,不是所有的数据包都可以延迟应答。
延迟应答具体的数量和超时时间,依操作系统不同也有差异,一般N取2,超时时间取200ms。
我们知道接收方收到数据要给发送方一个应答,如果刚好接收方也要发送数据,是不是可以直接一起返回。
捎带应答最直观的角度实际也是发送数据的效率,此时双方通信时就可以不用再发送单纯的确认报文了。
当创建一个TCP的socket时,同时在内核中会创建一个发送缓冲区和一个接收缓冲区。
由于缓冲区的存在,TCP程序的读和写不需要一一匹配,例如:
实际对于TCP来说,它并不关心发送缓冲区当中的是什么数据,在TCP看来这些只是一个个的字节数据,它的任务就是将这些数据准确无误的发送到对方的接收缓冲区当中就行了,而至于如何解释这些数据完全由上层应用来决定,这就叫做面向字节流。
这里就可以对比UDP,UDP不是面向字节流的,发一次必须就要读一次,发10次就必须读十次。这种报文和报文在传输层有明显边界的的协议就叫做面向数据报。
因为TCP是面向字节流的,所以需要应用层来分开这些报文,如果处理的不好就会出现多读了或者少读了影响到了后续报文,这种问题就叫做粘包。
解决粘包问题的本质就是要确定报文与报文之间的边界。
对于定长的包,保证每次都按固定大小读取即可。
对于变长的包,可以在报头的位置,约定一个包总长度的字段,从而就知道了包的结束位置。比如HTTP报头当中就包含Content-Length属性,表示正文的长度。
对于变长的包,还可以在包和包之间使用明确的分隔符。因为应用层协议是程序员自己来定的,只要保证分隔符不和正文冲突即可。
两个已经建立连接的进程,其中一个进程突然挂掉了,此时建立好的连接会怎么样?
其实连接也是个文件,而文件描述符是随进程的,进程退出,操作系统就会close掉这个文件。所以操作系统会正常四次挥手断开连接,跟自己掉close没区别。
当重启主机时,操作系统会先杀掉所有进程然后再进行关机重启,因此机器重启和进程终止的情况是一样的,此时双方操作系统也会正常完成四次挥手,然后释放对应的连接资源。
当客户端掉线后,服务器端在短时间内无法知道客户端掉线了,因此在服务器端会维持与客户端建立的连接,但这个连接也不会一直维持,因为TCP是有保活策略的。
正常的一方会不停的询问对方连接是否还存在,发现不在了就直接断开。
TCP协议这么复杂就是因为TCP既要保证可靠性,同时又尽可能的提高性能。
检验和
序列号
确认应答
超时重传
连接管理
流量控制
拥塞控制
滑动窗口。
快速重传。
延迟应答。
捎带应答。
其实就是参考上面的可靠性。