再谈端口号
TCP/IP协议用“源IP”,“源端口号”,“目的IP”,“目的端口号”,“协议号”,这样一个五元组来标识一个通信(可以用netstat -n来查看)。
端口号的划分和知名端口号
我们之前就说过,[0,1023]这个区间的端口号普通用户是不能随意绑定的,这些端口号就是知名端口号。[1024,65535]这个区间的端口号就是可以分配给用户的,比如客户端由系统随机绑定的端口号就是从这里面选的。
为了方便使用,人们对常用的服务器的端口号做了约定,都是用的固定端口号:
执行以下命令可以查看知名端口号
cat /etc/services
但是有些服务器的端口号并不在[0,1023]这个区间,比如mysql就是3306。
注意:一个进程可以绑定多个端口号,但是一个端口号不能被多个进程绑定。
netstat
netstat是查看网络状态的重要工具
常用选项:
netstat -tulnp
pidof
这个用来查看服务器的进程名很方便
pidof + (进程的pid)
就可以通过pid来查看进程名。
UDP协议
UDP协议的格式
UDP的报头就是8字节,报文去掉这8字节就是有效载荷。
从这里我们看到端口号都是16位的,这也就解释了,为什么之前端口号都是16位的,因为协议报头这里大小已经规定好了的。
UDP长度就是表示整个数据报的长度(UDP首部+UDP数据)。
UDP检验和就是检验这个数据报是否完整,如果不完整,那么就会直接丢掉这个报文。
这个数据就很广泛了,可以是 “heelo” ,也是可以josn的序列化字符串,或者是自定义的字符串。
需要注意:这个UDP长度表明最大的长度是16位,也就是说一个UDP能传输的数据最大长度是64kb(包括报头),如果超过64kb,那么就需要用户手动控制大小,分批次传输。
UDP特点
UDP传输的过程类似寄信,我们也不知道对方是否收到,收到也不一定会有反馈。
面向数据报
应用层交给UDP多长的报文,UDP都会原样发送,既不会拆分,也不会合并。
比如用UDP发送100字节的数据:
如果发送端调用sendto一次性发送100字节,那么接收端也只能调用一次recvform接收100字节,而不是通过循环调用10次recvform,每次接收10字节。
UDP的缓冲区
UDP并没有真正意义上的发送缓冲区,调用sendto会直接将数据交给内核,然后由内核将数据传给网络层协议进行后续的传输动作。
UDP有接收缓冲区,但是这个接收缓冲区不能保证收到UDP报文和发送UDP报文的顺序一致。并且如果缓冲区满了,也不会告诉发送方,而是直接丢掉溢出的数据。
UDP的套接字既能读也能写,所以也是全双工的。
不过UDP虽然没有真正意义的发送缓冲区,但是比如在Linux系统下,有多个用户都在发送UDP协议的数据,我们的操作系统也是要一个一个处理好再发送的,那么这些在内核中的UDP协议数据肯定也是要被先描述,再组织起来的。 内核会开辟一段空间,并有结构体来进行描述
比如描述报头信息的
struct udp_headr
{uint32_t src_port:16;uint32_t dst_port:16;uint32_t length:16;uint32_t check_code:16;
};
// 位段
还有管理UDP协议数据的
struct sk_buff
{char* start;char* end;char* pos;int type;//..... 省略struct sk_buff* _Next; // 指向下一个,像链表一样串起来
};
最后用指针将各个结构体串起来,进行管理。
基于UDP的应用层协议
我们之前在简单实现http的时候我们就知道了http是基于TCP,那么基于UDP的应用层协议有哪些呢?
TCP协议相关背景
文件操作 && 网络通信
这里谈一下我们在网络通信中使用的read,write,recv,send这样的接口,其实这些接口的本质都是在对数据进行拷贝,比如从内核中拷到用户,或者从用户拷到内核。
我们对文件操作也叫做IO,是通过文件描述符来完成的,而我们的网络通信,也是使用这些接口,通过文件描述符来完成的,因此我们网络通信其实本质也是IO。不过就是我们的数据在发送缓冲区里,拷贝到对方的接收缓冲区里,只是要通过网络。
而网络是长距离传输,又不像本地的磁盘一样,出错概率很低,因此才需要各种各样的策略。比如接下来的重点TCP协议。
缓冲区
关于缓冲区,我们知道,操作系统为了管理内存,将内存划分成一块块4kb大小,并用struct page这样的结构体管理起来,其实这个缓冲区也就是由一块一块的内存块组成的。
传输控制协议
传输协议好理解,但是传输控制协议,这个控制该怎么理解呢?我们知道,UDP没有正真意义的发送缓冲区,它一旦调用了send这样的接口,那么立马就会通过内核发送出去。
而今天学习的TCP协议有发送缓冲区,那么就不会跟UDP一样了,我们调用了send之后,数据会先呆在发送缓冲区里,至于要怎么发送,要发送多少,出错了怎么办,都不再是用户该关心的,而是由TCP控制的,因此TCP也就叫做传输控制协议,控制特点就体现在这里。
TCP协议
同为传输层的协议,TCP也叫传输层控制协议,为什么UDP不带控制二字呢? 带着问题先看
TCP协议段格式
TCP协议主要分为三个部分,标准报头+选项+数据(有效载荷)
我们看到,TCP协议的报头比UDP要复杂的多。并且还有一个选项,这个后面说。
之前UDP重复的,这里就不再多说了,比如16位的源端口号和目的端口号,还有16位检验和。
4位首部长度
其中这个地方就是表示了这个TCP首部的总长度(报头+选项),可以它只有4位,那么表示的范围不是只有[0,15]吗?
这是因为,它的单位并不是1字节,而是4字节,所以它实际能表示的范围就是[0,60]字节。而我们看到,选项上面的报头是固定字节大小,也就是20字节,那么说明选项最大可以有40字节。
通过这个来实现报头和有效载荷的分离。
流量控制
说到可靠传输,那么不可靠传输是什么样的呢?
比如数据传输重复,出现乱序,出现丢包等等情况,都是不可靠。因此可靠的数据传输,一定要规避这些情况,
在TCP协议段格式中,在标准报头中,我们看到有个16位窗口大小,这是什么呢?
我们知道,TCP协议有发送缓冲区和接收缓冲区,无论是用户端还是服务端都是。
假设有一天,服务端太忙了,客户端在向服务端发送数据,但是服务端来不及调用read或者recv这样的接口来拿取数据,但是客户端并不清除服务端那边的情况,就一直向服务端发,服务端已经被写满了,依旧再发的话,那么就会导致出现大面积丢包现象。
因此,为了规避这种情况,当服务端的接收缓冲区空间紧张的时候,我们应该想办法让用户端发送数据的速度慢点,或者直接不发了。
所以,这种通过控制客户端发送数据的速度,以便能让服务端来得及处理数据,从而规避大面积丢包的情况,这种策略就叫做流量控制。
另外说下,TCP协议其实还有一个策略叫做数据重传,也就是如果数据丢包了,那么就重新传一份。那我们丢包了重传不行吗?为什么还要流量控制呢?因为我们也知道,一个TCP协议的报文,光是标准表头就是固定20字节大小,还不算选项和有效载荷,如果出现大面积丢包,重传确实可以解决问题,但是浪费的资源太多,效率太低效。
那么,客户端控制发送数据的依据是什么呢?答案就是服务端在返回给客户端的响应中,16位窗口大小就是服务端的接收缓冲区当前还剩多少空间。客户端就可以根据这个剩余的空间来制定合理的发送数据的速度。
并且,我们要能想到,服务端也可能会给客户端发消息,那么客户端也会给服务端响应,那么此时这个响应中的16位窗口大小就是客户端的接收缓冲区还剩多少空间。
也就是说,双方都可以进行流量控制。
另外,第一次发送数据的时候,怎么保证发送的数据量是合理的呢?这里我们需要理解,三次握手不只是建立链接,双方也交换了报文,也就是已经协商了双方的接收能力!并且第三次握手的时候,是可以携带数据的!(当然前两次不行),也就是捎带应答。所以说TCP在保证可靠性的同时,也在想办法提高效率。
当客户端发送一批报文过去,当服务器的接收缓冲区为0的时候,客户端从应答中得知服务器的缓冲区已经没有空间时,就会停止发送。
当服务器缓冲区有空间时,会给服务器发送窗口更新通知来提醒客户端。如果客户端过了重发时间之后还是没有收到窗口更新的通知,就会发送一个窗口探测的包给服务器。不过需要注意的是,这里发送给服务器的窗口探测的包是不敢携带数据的,也就是说这只是一个报头,毕竟都不知道服务器的缓冲区的空间怎么样。
最后,我们发现窗口是16位的,16位数字最大表示65535,那么TCP缓冲区最大只有65535字节吗?其实在TCP首部的40字节里还包含了一个窗口扩大因子M,实际窗口大小是窗口字段的值向左移M位。
总的来说流量控制主要是为了可靠性(比如防止大面积丢包),但是它也变相的提高了效率,避免了资源的浪费。
另外,我们要时刻记住,TCP的通信双方地位是相等的,流量控制不一定只是为了控制客户端的发送速度,也同样会控制服务器的发送速度。
确认应答(ACK)机制
首先我们要知道,在这个世界上不存在100%可靠的网络协议。因为我们判断对方是否完整的收到了我们的数据,只有是否接收到对方的传来的响应才可以。但是假设作为客户端接收到了服务端的响应,知道了数据对方接收到了,这没问题,但是服务端它又怎么知道它的响应完好的送到了客户端呢?那不又得收到从客户端发来的响应才行。那么这样就陷入了鸡生蛋,蛋生鸡的问题。但是话又说回来,我们其实只要保证客户端到服务端的数据完好就可以了,服务端没必要非要知道自己的响应是否完好送达,因此客户端发送数据后,只要一段时间内没收到来自服务端的响应,那么无论什么原因,都认为这次发送失败了,就会进行重发。总之,最新的一条消息(也就是应答),是没有应答的(看起来有点绕,也就是应答不需要应答)。不过有个细节就是,客户端在接收到响应之前,还是会把数据存在缓冲区里。
客户端在向客户端发送数据的时候,每次发送完一个数据,客户端不会马上接着发数据,而是会等待从服务端传来的应答后,再发送数据,这样就能避免数据传输中不可靠的问题,比如大面积丢包,服务端已经满了,但是客户端依旧在不停的发数据;又或者是数据都压根没传到服务端上去,客户端也还是不停的发送。
首先,我们客户端要发送的数据,已经存在TCP的发送缓冲区中了,因为TCP是面向字节流的,这个缓冲区我们可以看作是char类型的大数组,那么每一个空间就是一个字节,并且还有对应的下标,那么也就是说,每一个字节天然就有自己的编号。我们拷贝在缓冲区里的数据是按顺序存储的。客户端会在TCP的报头中的32位序号中写入,本次发送的数据,在这个缓冲区中的最后一个下标。比如这个数据有1000字节,并且下标是从0开始的,那么这个32位序号写入的值就是0。
然后,服务端在收到数据之后,会将应答的确认序号设置成对方开始的序号 + 接收到的字节数量,返还给对方。
这个确认序号的意义可以理解为,在这个确认序号之前的数据,已经全部收到了。
并且如果这样还有一个好处就是,它允许应答有少量的丢失,比如客户端发送了1000,2000,3000这样的数据,但是1001和2001丢失了,但是3001没有丢失,那么也能代表服务器收到了3001之前的数据。
但是这里我们可以思考一个问题,在这个场景下,我们其实只需要一个32位序号就可以解决问题了,没必要再使用一个确认序号。TCP为什么要这样设计呢?可以从两个场景来理解:
1.有时候,一个报文可能有双重身份,比如服务器除了返回应答,它还想给客户端传送数据,这种既是应答又是数据的响应,叫做捎带应答,那么此时确认序号就是为了告诉客户但序号的前多少位已经收到了,序号就是告诉客户端,从服务器发来的数据是从哪里结尾的。
2.有时候,并不是总是客户端给服务器发消息,服务器也会给客户端发消息,双方地位是对等的,如果只用一个序号位,就搞不清谁发谁收,因此还是需要两种序号位。
另外我们要知道,服务端一般都是一批数据连着发,如果是发一个数据,还得等服务器返回一个应答之后再继续发,那样效率太低了。但是这里有一个问题,就是这批数据本来是按一定顺序发送的,但是服务器却不一定按相应顺序接收的。这就是数据乱序问题。UDP是没有办法的,毕竟是不可靠传输,那么TCP是如何解决的呢?
这里还是得靠TCP报头中的32位序号,它除了能够确认应答,还能保证数据按序到达!
关于标记位
首先我们要知道,服务器与客户端的数量关系是1:n的。并且由于tcp是面向连接的,那么通信过程中的建立连接,正常通信和断开连接,都要给服务器发送tcp报文。而这些操作就使得tcp报文本身也是具有类型的,不同的类型决定了服务器要做不同的动作。
标记位存在的意义就是区分tcp报文的类型。 接下来就谈谈六种标记位
ACK:确认序号位是否有效。只要这个tcp报文具有应答属性,那么该标记位就要被置为1。
SYN:请求建立链接,我们把携带SYN标识的称为同步报文段。
FIN:通知对方,本端要关闭了。
PSH:提示接收端应用程序,立刻把TCP接收缓冲区数据读走。
因为TCP协议是传输层的协议,所以这些标志位都不会直接暴露给用户的,系统会提供一些接口来让我们进行设置。比如说SYN,我们之前用套接字的时候有一个系统调用就是connnet,我们调用它也就是告诉OS,我们需要与服务端建立链接,发起三次握手。
再比如说close,也是告诉OS,给对方发送带FIN标记位的报文。
不过很多时候还是OS自己设置报文的标记位。
在tcp的缓冲区中,有人把数据塞进去,也有人把数据拿出来,这一看像不像生产者消费者模型?所以流量控制本质就是对发送和拿取进行同步!
RST:对方要求重新建立链接;我们把携带RST标记位的称为复位报文段。
再谈TCP的链接
TCP虽然保证可靠性,但是允许链接会建立失败。
首先我们要清楚,作为服务端,它的OS内部肯定会同时存在多个已经被建立好的链接,毕竟我们在应用层会让用户获取多个链接所对应的文件描述符。同理,客户端可能会同时访问多个服务端,所以也会同时存在多个链接。
那么要对这些链接进行创建,删除等管理操作还有保存属性,就需要先描述,再组织。所以建立链接的本质也是双方OS要为链接创建结构体来管理链接。
因此,维护链接也是要有成本的。比如要时间和空间。
三次握手和RST
注意:之所以是代表双方通信的线是斜线,是因为发送的过程是有时间消耗的。
在这里,我们要还要注意,客户端和服务器的链接结构体是什么时候创建的呢?
对于客户端,在第三次握手,也就是给服务器发ACK的时候,客户端此时就认为自己的链接已经建立好了,那么对应的链接结构体也就是在发出这个报文后创建的。
对于服务器来说,它是在第三次握手时,接收到来自对方的ACK报文时,才认为自己的链接已经建立好,然后创建链接结构体。
也就是说,客户端和服务器认为链接已建立好的时机是不一样的,也就是客户端和服务器对链接建立的认知不一致。
如果第三次握手的ACK报文丢了,那么客户端认为自己的链接建立好了,但是服务器没有收到ACK报文,因此并不认为链接建立好了。 但是因为客户端以为链接建立好了,然后就开始给服务器发送数据了,如果服务器收到了数据,但是服务器认为链接没有建立好,那么服务器就会给对方发送RST标记位置为1的报文,以提醒对方需要重新建立链接。那么客户端在收到这个报文后,也就能知道自己的链接是建立失败的,就会删掉原来的链接管理结构体,然后重新向服务器发起三次握手以此重新建立链接。
URG:紧急指针是否有效。
谈这个标志位之前,我们已经知道了,TCP的报文都是按序到达的,因此正常情况下是不可能插队的。但有时候我们就是想让服务器优先把某个数据读上来,然后进行处理。那么此时就需要将URG标志位置为1,此时就代表报文中的16位紧急指针是有效的。而紧急指针则是代表需要优先处理的数据,在报文的有效载荷中(从开头)的偏移量是多少。
那么这个需要紧急处理的数据是多少字节呢?其实一般就只有1个字节。
我们在flags参数那里设置MSG_OOB,就可以发送紧急数据了。
服务端接收紧急数据也要设置。
我们把紧急数据在应用层叫做带外数据,正常数据叫做带内数据。
虽然URG使用的场景很少,但还是存在的,比如在服务器设置的时候,我们就可以设置一些状态字段,比如1代表要检查服务器状态,2代表查询某些资源等等,然后有一天我们发现我们的服务器很卡,但是还没挂,我们就可以通过发送紧急数据来了解服务器的状态,服务器读取到紧急数据之后,立刻做出相应的处理,然后再以紧急数据的形式给我们返回。
超时重传
假设主机A给主机B发送了数据,但是可能会有各种原因导致A没有收到来自B主机的应答,那么在等待特定的时间间隔后,A主机就会重新再给B主机发送。这就是超时重传。
由此可以看出,主机对发出去的报文是否丢失是无法判定的,因此必须规定一个特定的时间间隔来决定是否要重传。
另外主机B因此就可能收到重复的报文,而重复报文在通信里面就属于不可靠传输,因此主机B就需要去重。去重的办法当然就是看报文的32位序号是否相同,相同那么就是重复的。所以,这个序号除了能允许少量的应答丢失外,还有去重的功能。
最后就是关于这个时间间隔的设定
在最理想的情况下,要找到一个最小的时间,要能保证“最小应答一定能在这个时间内返回”。
但是这个时间的长短,随着网络环境的不同,是存在差异的。
如果超时时间设置的太长,那么就会影响重传效率。
如果超时时间设置的太短,那么有可能会频繁的发重复的包。
因此,这个时间间隔是动态的,且与网络状况相关。
在Linux中(BSD Unix,Windows也是如此),超时已500ms为一个单位进行控制,每次判定超时重发的时间都是500ms的整数倍。
如果重发一次,仍然得不到应答,那么时间间隔就改为2*500ms。如果仍然得不到应答,那么时间间隔就变为4*500ms,以此类推,以指数形式递增。
累计到一定重传次数时,TCP认为网络,或者对方主机出现异常,会强制关闭连接。
这就是超时重传,并且学到这里,越能体会到通信的细节,而这些细节我们在应用层编码的时候是不关心的,我们只关心自己的应用层该怎么设计,越发的体会到应用层和协议栈各司其职。
连接管理机制
到这里我们也大概知道了TCP协议连接时需要三次握手,断开时需要四次挥手,今天我们再来谈其中的细节。
到这里我们看到这个图,根据主机发送和接收到报文所对应的状态已经很熟悉了。我们已经知道,每一次握手都是在干嘛了。
首先第一次握手,客户端发送SYN给服务器,第二次握手服务器发送SYN+ACK返回给客户端,并且此时客户端收到报文后,认为链接已经建立成功,第三次握手客户端给服务器发送一个ACK,服务器接收到之后也认为链接已经建立好,至此三次握手完成,链接建立完毕。
其中也有一些细节,比如客户端建立链接是我们用户调用connect发起的,也就是发起三次握手,但是三次握手是由两端的操作系统完成的,与用户无关。还有服务器在请求来之前一直在accept阻塞等待链接。
四次挥手
我们知道tcp通信是基于连接的,建立和断开连接就需要三次握手和四次挥手。
那么为什么要三次握手和四次挥手呢?
其实三次握手本质上也是四次握手,这是因为因为服务器发给客户端的SYN跟ACK是压缩在一起,也就是捎带应答,所以是三次。
三次握手的理由:
1.为了验证全双工通路是否通畅,就是验证双方的接收和发送数据的可靠性。
一次握手或者两次握手为什么不行?
如果一次握手就可以确定链接建立成功,也就是说客户端发一次SYN,服务器接收到就得创建链接管理结构体,这种已经建立好的链接释放周期比较长,如果客户端恶意的不停发送SYN,哪怕自身内存都不足了也发,服务器就会一直创建链接管理结构体。这种就叫做“SYN洪水”。因此一次握手是肯定不行的。
那么两次握手行不行呢?
两次握手我们发现这样一个问题, 服务器是先建立链接的。那么此时万一客户端是有问题的,它拿到ACK之后直接扔掉了,就算不考虑恶意攻击行为,客户端刚发送SYN,就挂掉了,而服务器不需要应答,它就把链接建立好并维护一段时间。也就是两次握手是优先让服务器建立链接的,也就是建立异常的成本嫁接到了服务器上。因此理由2就是
2.三次握手,可以使一般握手失败的链接成本是嫁接在客户端上的,能减轻服务器的压力。
另外其实只要是奇数次握手,那么链接失败的成本都是在客户端上的,但是一次太少了,肯定是不行的,它都没有验证双方的全双工通道是否通畅,那么五次七次为什么不行呢?因为三次握手是验证全双工的最少次数,多次验证没有意义,只会增加成本。(仅从第一个理由来看也得是三次握手)
三次握手如此看来,单机想要把服务器搞崩是很困难的,因为恶意攻击服务器明显是自己收到的伤害更高,但是现在很多的网络攻击都是通过邮件或者网址给用户植入木马病毒,这个病毒会在固定向黑客主机领取任务,然后疯狂的访问某个服务器,就算是正常的握手,数量太过于庞大,也会将服务器的资源消耗殆尽,这种攻击方式也叫做“肉机”。这种攻击TCP本身是没有办法的,一些大公司只能通过添加防火墙这样的策略来解决。
三次握手的问题解答了,那为什么要四次挥手呢?
首先我们要知道,断开链接的本质是:没有数据要给对方发送了,发送数据是双方都有可能发,所以必须断开两次。
因为建立的时候,服务器必须无条件同意,而断开链接的时候,二者就带有协商的部分。比如在断开链接的时候,客户端已经没有要再发送给服务器的数据了,因此发送FIN,但是服务器还有数据要发给客户端, 所以FIN跟ACK压缩在一起只能是巧合。
详谈连接和断开的状态
首先我们需要清楚一点:
链接建立成功跟上层有没有accept没有关系,三次握手是双方操作系统自动完成的。
那么服务器不需要调用accept也可以进行三次握手并建立链接,但是这个链接没有通过调用accept也就获取文件描述符,也就无法进行通信,但是这个链接既然创建了,那么该由谁管呢?
我们之前在写套接字的时候,调用过listen函数来进行监听。
其中第二个参数是用来设置一个叫 全链接队列 的长度的。这个全链接的长度 = backlog + 1。服务器只要三次握手成功了,那么就会创建链接,并把链接放在这个队列当中,等待上层通过accept拿去,这里是不是也很像生产者消费者模型。
如果三次握手成功,服务器对该链接的状态就是ESTABLISHED,但是如果这个链接还没有通过accept拿去,就会放在全链接队列里面。现在我们知道,这个全连接队列也是有大小的,如果这个队列满了,但还是有链接来会怎么办呢?
如果满了,那么接下来的三次握手中,第三次握手时的ACK被服务器拿到时,会选择直接丢掉,那么服务器此时的状态SYN_RCVD,而客户端的状态就是 ESTABLISHED。也就是说此时客户端认为链接建立好了,但是服务器并不这么认为,这我们称为半链接。
根据之前学到的,如果客户端此时向服务器发送数据,服务器认为链接都还没建立好,就会返回 RST的报文,要求客户端重新建立链接。
但是话又说回来,就算链接没有建立成功,但是操作系统也要对这个半链接结点进行管理,就会把它放在一个 半链接队列 里面,不过一般操作系统不会维护这个链接结点太长时间,而已经建立好的链接操作系统会维护的很久。另外半链接队列的长度跟listen的第二个参数也有一点关系,一般是由系统自动算出来的,我们不做重点讨论。
到这里我们发现,如果一个链接要进入全链接队列,那么首先它要先进入半链接队列,所以如果有不法客户端一直发送很多的SYN链接给服务器,虽然服务器维护半链接的成本比较低,不会对服务器造成太大的影响,但是如果SYN链接太多,会导致半链接队列都被打满了,那么正常用户也无法访问了,这就是SYN洪水。
一道面试题:为什么listen的第二个参数不能太长,又为什么不能没有呢?
首先,这个队列没必要太长,因为当服务器很忙的时候,本来就需要很多资源,而且队列太长也会让队列后面的结点等待时间边长,这些链接结点一边想让服务器快点处理,一边这些链接结点还占着服务器的资源,那我们完全可以减少队列的长度,将腾出来的资源用来给服务器运算。
但是如果完全不要这个全链接队列其实也可以,但是这种方式太过于简单粗暴了,假设服务器开始很忙,然后突然就闲下来了,但是此时又没有新链接到来,那么就会导致服务器的资源得不到充分利用。
谈完了握手的细节,我们再来看看四次挥手的
在图中我们发现,主动断开链接的一方(最开始发FIN),在四次挥手完成后,会进入TIME_WAIT的状态,然后会等待一段时间之后再自动进入CLOSED状态。一般这个时间在30s~60s。(不过这里的链接是要通过accept获取上来的链接才可以)。
我们之前在写套接字的时候,如果我们将服务器退出,那么有可能会出现绑定失败的情况,这就是因为我们服务器作为主动断开链接的一方,会进入TIME_WAIT状态,也就是链接还没有彻底断开,那么原来的ip和port还在被继续使用,而我们知道,端口号只能绑定唯一的一个进程,所以就会绑定失败,导致服务器无法立马重启。
所以很多服务器调用一个setsockopt,这个系统调用的作用是:如果这个服务器是因为TIME_WAIT而无法启动的话,请允许利空重启这个服务器。
用法
进行设置之后,虽然服务器如果出现异常挂掉了,还是会有TIME_WAIT状态,但是允许服务器立即重启。
客户端是完全不用担心这个事情的,因为客户端的端口号都是由系统随机绑定的。
TCP协议规定,主动关闭链接的一方,要先进入TIME_WAIT状态,等待两个MSL((maximum segment lifetime)的时间后才能进入到CLOSED状态。
报文从客户端发出,在到达服务器之前,数据包是存在网络当中的,这个报文在网络存在的最长时间就是一个MSL,也就是A端到B端的最长时间。
为什么要等待两个MSL呢?
1.让通信双方历史数据得以消散。
2.让断开的链接的四次挥手时,具有较好的容错性。比如假设四次挥手的ACK丢了,服务器迟迟没有收到ACK,那么它就会再发送FIN给客户端给服务器重发,正是因为客户端现在链接还没有关闭,还可以重发ACK给服务器。
另外这个让历史数据消散的意义主要还是让双方把历史的数据在接收到之后把它们丢掉,毕竟这些数据超时,早就重发了。并且这些数据还可能会影响后续的通信。
MSL在RFC1122中规定为两分钟,不同的系统在实现上会有些差别,在Ubuntu和CentOS7中是60s。
cat /proc/sys/net/ipv4/tcp_fin_timeout
这个命令可以查看当前系统的MSL是多少s。
滑动窗口
我们知道,发一个信息然后等待应答,然后再发,这样的效率太低了。因此客户端一般发送数据一般是一批一批的发送的,服务器返回的应答也是一批一批返回的。
那么已经发出去的,但是还没收到应答的报文,要被tcp暂时保存在发送缓冲区里的。毕竟我们发送数据的本质就是将发送缓冲区里的数据拷贝给网卡,然后再由网络发送给对方。
那么这个缓冲区其实是被划分成好几个区域的,比如 已经发送且被确认的数据 区域,或者 未发送 区域,还有 已发送未确认数据 等区域,实际上还有更复杂的划分,并且在这里也再一次体现了计算机没有真正意义上的删除,只有覆盖。
所以滑动窗口其实是我们发送缓冲区的一部分,也就是缓冲区中,可以直接发送且未被确认的区域。
内核是通过指针/下标来进行区域划分的。
另外我们发现滑动窗口其实是被划分成一块一块的,为什么不直接整成一起发送呢?这是因为在硬件层面上不允许给网卡发送太大的数据,所以只能分批发送。
在收到ACK后,滑动窗口就会从左边向右移动,并且我们滑动窗口的大小至少不能超过对方的接收缓冲区剩余空间的大小,即应答报文中的窗口大小。
关于丢包问题
如图,发送方发送了一批数据,一直到5001。
应答丢了
只要不是应答全丢了,那么问题不大,因为TCP允许少量的应答丢失,只要返回了一个应答,我们通过应答中的确认序号是5001,就知道5001之前的数据已全部接收,那么滑动窗口会直接右移到5001。
数据丢了
假设是2001~3000的数据丢失了,即便后面3001~4000和4001~5000的数据没丢,返回的应答中的确认序号依旧是2001,那么滑动窗口只会向右移动到2001。
所以确认序号保证了滑动窗口线性的连续的向后更新,不会出现跳跃的情况。
快重传
假设我们发送了一批数据,但是1001~2000的数据丢失了,那么应答中的确认序号全是1001,按理来说本应该等待超时重传的,但是如果发送方收到了3次具有相同确认序号的ACK,那么就会直接补发1001~2000的数据,而不是继续等待超时重传,如果这次补发对方收到了,那么下次返回的ACK的确认序号就直接是5001了,这种机制就是快重传。
已经有了快重传,为什么还要超时重传呢?
这是因为快重传是有条件的,它必须收到三个具有相同确认序号的ACK才能触发快重传,如果在数据传送量少的时候,它不能触发这个条件,那么数据丢失就不会重发了。因此,快重传它主要是提高效率的,而超时重传是保证可靠性的。滑动窗口既保证了可靠性也保证了效率,并且滑动窗口也是流量控制的底层实现。
滑动窗口的移动
滑动窗口会向左移动吗?
原则上是不会的,因为滑动窗口左侧的区域是 已发送,已接收的区域。
滑动窗口向右移动,大小会变化吗?怎么变化,会为0吗?
滑动窗口的大小是会动态变化的,跟对方接收缓冲区剩余空间的大小,滑动窗口的大小有可能会变大(滑动窗口左向右移动的速度 < 右边向右移动的速度),也有可能会变小(同理),也有可能不变(速度相等),如果对方一直不把接收缓冲区的数据读走,直到把缓冲区给打满,那么对应的滑动窗口大小也会变成0。
那么如何用计算机语言来理解滑动窗口呢?
我们可以用两个指针,比如
一个 int start = 根据确认序号来设置。
另一个 int end = 确认序号 + win大小(应答中的窗口大小)。
根据上面的分析来理解,我们直到两个指针是同时增大的,所以滑动窗口在移动,本质上就是下标在移动。
所以流量控制是通过滑动窗口来实现的!
所以流量控制也不一定只能减缓发送方的发送速度,如果对方的接收能力非常强大,那么滑动窗口就会被设置的很大,那么发送速度也会跟着提升。
另外用来表示滑动窗口的下标是一直增大的,那么它会不会越界呢?
答案是不会的,tcp采用了类似环状算法,这个就像环形数组一样,大小到一定程度时会重置的。
另外通信时这个序号不一定是从0开始的,因为即便有TIME_WAIT,数据也不一定完全消散,历史数据依旧有可能会对后续通信造成影响,因此双方在握手的时候就协商好了一个随机的序号,发送数据就从这个随机序号开始发送。
延迟应答
首先如果发送方一次能发送更多的数据的话,那么发送的效率也就越高。
但是发送方能发送多少数据取决于对方接收缓冲区剩余空间的大小。
那么接收方如何能给对方通知一个更大的窗口大小呢?
于是接收方在接收到报文的时候,不会立马返回应答,而是先等一等,当然也不会超过重传时间,等会时间尽量让上层把数据拿走,这样就可以给对方通知一个更大的窗口大小了。
所以我们把收到报文,但不着急应答的策略叫做延迟应答。
不过延迟应答也不一定会百分之百提高效率的,它也是博概率的,如果上层迟迟不把数据拿走,那也不会提高效率。
因此延迟应答带给我们在编程上面的建议就是,上层每次应该尽快通过read,recv这样的接口把数据从内核中拷贝上来,这样就能尽量在TCP底层给对方更新更大的窗口。
注意:不是所有的包都可以延迟应答,延迟应答也有限制的:
1.数量限制:每个N个包就应答一次。
2.时间限制:超过最大延迟时间就应答一次。
具体的数量和时间对于不同的操作系统也是有差异的,一般N取2,最大延迟时间为200ms。
捎带应答
这里我们在三次握手的时候就说过了,比如第二次握手时是ACK + SYN。
我们直到TCP是发一条数据,就要收到来自对方的应答,但是如果对方也想给我方发送数据呢?那这个应答和数据放在一起的应答就是捎带应答,本质也是提高了TCP通信的效率。
TCP小结
相比于UDP,TCP可以说是非常复杂了,因为它既要保证可靠性,也要保证效率。
保证可靠性的:
校验和
序列号(按序到达,去重)
确认应答(TCP核心)
超时重传
连接管理
流量控制(也提高了效率)
拥塞控制
保证效率的:
滑动窗口
快速重传
延迟应答
捎带应答
顺便总结一下三次握手:
1.建立连接
2.协商起始序号
3.协商双方接收缓冲区大小
到这里我们讲的TCP所有的策略都是作用在两台机器上的,其实TCP还考虑的了网络的,这一策略就是拥塞控制。
拥塞控制
拥塞控制的概念
如果发送数据,出现了问题,不一定是对方主机出了问题,也可能是网络出了问题。
如果有一天,通信双方数据出现大量丢包(滑动窗口内大量数据都超时了),那么TCP会判断网络出问题了,也就是网络拥塞了。
出现大量丢包,那么应该将报文重发吗?
要么是硬件设备问题,要么是网络中数据量太大,阻塞了。如果是硬件问题,那么再怎么重发也没用,如果是网络中数据量太大而阻塞了,那么就更不应该重发了,这样只会让网络更加阻塞。
网络是共享资源,TCP协议实现了多主机面对网络出现拥塞是共识的!那么只要做到你不发,我不发,就可以减少网络数据的数量,使网络恢复通畅。
因此TCP不仅仅是规定了双方两台主机,对于两台毫不相关的主机之间也有约定。
拥塞控制的策略(慢启动)
虽然TCP已经可以通过滑动窗口来高效可靠的发送大量数据,但是如果网络状况已经很拥堵了,在不知道网络状况的情况下,仍发送大量数据,是会让这个情况雪上加霜的。
TCP引入了慢启动机制, 先发送少量数据用来摸清网络拥堵状态,再决定以多大的速度传输数据。
并且在这里引入一个拥塞窗口的概念。刚开始发送的时候,定义拥塞窗口大小为1,每次收到一个ACK应答,拥塞窗口大小加1。每次发送数据包的时候,将拥塞窗口的大小和对方反馈的窗口大小作比较,取较小值作为实际的发送窗口。
所以滑动窗口的大小 = min(窗口大小,拥塞窗口大小)。也就是不仅仅考虑对方的接收能力,也要考虑网络的接收能力。
因为网络状况是动态变化的,因此拥塞窗口也是动态变化的。
虽然说TCP是慢启动的,但它只是初始的时候很慢,它的增长速度是很快的。
至于为什么要采用前期慢,指数的增长速度也很好理解,毕竟刚开始发的时候是为了试探网络的状况,如果网络状况很好,那么就应该尽快恢复正常通信。
为了后面的增长也不能太快,所以还引入了慢启动的阈值。当窗口超过了这个阈值,那么拥塞窗口便不再以指数形式增长,而是线性增长。
假设只考虑网络状况如下
并且我们可以看到,这个阈值在每次遇到网络拥塞的时候,会乘法减小,并且此时的拥塞窗口大小也会重置成1。
面向字节流
首先我们知道UDP是面向数据报的,它就好像我们发快递一样,我们发多少次,那么对方就得接多少次,既不能多也不能少。在UDP的报头中,就有UDP整个报文自身大小的信息。
所以UDP要么不发,要么就要把完整的UDP报文发出来,并且UDP是没有发送缓冲区的,上层把数据交给内核,内核处理好就直接发送了。而对方可以根据UDP报头中的UDP长度,把每一个UDP报文区分开,那么上层就能拿到完整的UDP的有效载荷了。
再来说说TCP,双方在通信的时候都有发送和接收缓冲区,对于发送方而言,假设它使用的是http协议,它有封装好了4个请求,一共100字节,然后通过write,send这样的接口将这个4个请求拷贝到了发送缓冲区,但是对于发送缓冲区,它并不关心这是多少请求,它只关心这是多少字节,然后TCP打算发多少字节,什么时候发,出错了怎么办都是由TCP决定的,跟用户已经没有关系了。
那么接收方又是怎么处理的呢?首先也是对接收到的数据进行报头和有效载荷的分离,将TCP报头分离后,将有效载荷放入了接收缓冲区中,上层也不知道这是几个请求,上层只管将数据读取上来,然后在用户层对读取上来的数据进行解析,比如当时实现的网络版本计算器一样。而且我们在用户层还设置一个缓冲区,一有数据就先读取到缓冲区中,然后再进行解析处理。
所以这里也就解释了为什么TCP的报头中只有首部长度大小,而不是整个报文的大小。
所以双方缓冲区对数据不做任何处理,由应用层来统一处理,就是面向字节流。
大概是数据就像水流一样传输,才叫字节流吧,所以平常接收方接收到一个TCP报文,它不一定是一个完整的请求,当然也有可能是多个请求。所以用户对报文的处理必须得一个一个处理,将字节流变成一个一个完整的请求,但是一个UDP报文就一定只是一个请求。
粘包问题
我们知道,TCP为了效率,会尽可能的把缓冲区里的数据全部读取上来,然后再进行解析处理,但是TCP是面向字节流的,因此用户层读取上来的是一个一个的字节,这些字节有可能刚好是一个请求,也有可能是半个请求,也有可能是多个请求,这就是数据粘包问题。
而解决粘包问题的办法就是定协议,在之前我们实现的TCP网络版本计算器那里就是对读取上来的数据保存下来,然后进行解析,如果解析出一个完整的报文就拿走,如果不是就先放着不动,等待下一次读取。
解决粘包问题核心就是要在应用层通过协议,明确报文和报文之间的边界。
定协议的方式:
1.定长报文(比如规定数据的大小只能是100字节,每次读也只读100字节)。
2.使用特殊字符。
3.使用自描述字段 + 定长报头。(自描述字段参考Http)
4.使用自描述字段 + 特殊字符
另外UDP协议是不会有粘包问题的,因为它的报文本身就是具有边界的。
TCP异常情况
假如有一天,双方通过TCP协议通信,已经建立好链接了,但是突然出现了异常的情况,比如
1.进程终止:
首先我们要知道,对于操作系统而言,进程无论是正常退出了还是异常挂掉了,都是进程退出了。而链接本身跟文件是直接相关的,而我们知道文件的生命周期是随进程的。而我们知道链接的建立也是由用户connnet然后操作系统自动完成的,所以对操作系统而言无论这个进程是什么理由终止了,都是退出了,于是操作系统会正常的自动进行四次挥手来断开链接。
2.机器重启:
机器重启的时候会先杀掉所有进程,然后再重启,那么就回到了情况1,还是操作系统正常的进行四次挥手断开链接,然后再关机重启而已。
3.机器掉电/网线断开:
不同于前两种情况,如果机器掉电或者网线断开,操作系统是没有机会进行四次挥手的,假设客户端断电,那么不用多说,客户端的链接也就直接没了,但是服务器依旧认为链接存在,这就是链接认知不一致的问题,接着服务器会向客户端发送RST要求重新建立链接。
并且链接也是有保活机制的,如果服务器发先客户端长时间不发送数据,会给客户端发送保活信息来确认客户端的状况,如果发现链接异常,那么服务器也就会立马断开链接。
UDP实现可靠性
一般会有这样的面试题,问:UDP要实现可靠性的话要怎么做?
回答题目前,可以先问问面试官,实现这个可靠性的场景是什么,如果这个场景对可靠性要求很高,那么直接就说使用TCP协议就好;如果没那么高,那么也只需要往TCP保证可靠性的方法靠就行,比如引入序号:保证数据按序到达,还可以去重;还有加入确认应答机制,保证数据到达等等。