目录
在上一篇HTTP应用层协议中,我们没有过多谈及传输层协议,只是简单的说了一下HTTP协议在传输层中用的就是TCP,应用层需要先将数据交给传输层,由传输层对数据做进一步处理后再将数据继续向下交付,该过程贯穿整个网络协议栈,最终才能将数据发送到网络当中。
端口号
端口号(port)标识一个主机上进行网络通信中唯一一个进程,为什么不使用pid原来也说过:
- **原因一:**并不是所有的进程都要进行网络服务的,使用pid后操作系统就要筛选那个进程要进行网络服务。
- **原因二:**不想让进程pid和网络的端口号强耦合在一起。
当主机从网络中获取到数据后,需要自底向上进行数据的交付,而这个数据应该交给哪个进程,就是由该进程当中的目的端口号来决定的,这些原来也是说过的。
在TCP/IP协议中,用"源IP地址","源端口号","目的IP地址","目的端口号","协议号"这样一个五元组来标识一个通信。我们使用netstat就可以查看这个五元组。
如果把本机当做一个服务器来说,Local Address表示的就是源IP地址和源端口号 也就是服务端,Foreign Address表示的就是目的IP地址和目的端口号也就是客户端,而Proto(Protocol)表示的就是协议类型使用的是udp还是tcp,netstat的使用之前也说过。
端口号范围
端口号的长度是16位,因此端口号的范围是0 ~ 65535(2的16次方):
- 0 ~ 1023:知名端口号。比如HTTP,FTP,SSH等这些广为使用的应用层协议,它们的端口号都是固定的,你不能随便绑定。
- 1024 ~ 65535:操作系统动态分配的端口号。客户端程序的端口号就是由操作系统从这个范围分配的,所以我一般使用的都是8080或者8081。
这些服务器的端口号一般都是固定的:
- ssh服务器,使用22端口。
- ftp服务器,使用21端口。
- telnet服务器,使用23端口。
- http服务器,使用80端口。
- https服务器,使用443端口。
我们平时使用的shell是什么呢?
我们可以看到这个进程的父进程是1,其中pid、pgid、sid都是一样的,这不就是我们原来说过的守护进程吗。所以这个服务不退出,我们使用shell连接的时候,登录成功就加载bash进程,打印终端,输入指令就把指令以网络的形式发送给远端的主机,执行完后把结果返回。
当我们打开/etc/services文件,就可以看到端口号和绑定的服务,这就是一个配置文件。
所以还是那个问题,端口号 是确定系统中唯一一个进程的,它只能被一个进程绑定 ,但是同一个进程就可以绑定多个端口号。
当一个报文到了的时候,是如何把报文交给进程的呢?当获取新连接的时候,一定会获取一个新的sockfd文件描述符 ,可以把一个连接看做一个文件,收到数据就是把数据放到这个文件缓冲区中,因为进程和文件是通过文件描述符表建立的映射关系,所以只要找到这个进程就可以对数据读写,当我们读取报文的时候一定会得到端口号,在内核中就有一个哈希建立端口号和进程的映射,所以拿到端口就可以把数据放到特定进程的文件缓冲区中。
pidof
这个命令可以查看进程id。
当我们想要杀掉这个进程就可以使用kill命令。
那为什么要使用xargs呢?当我们通过pidof查找到进程id时,使用管道后这个pid就变成了进程的标准输入,但是使用kill命令时需要的是命令行参数,而xargs就是把标准输入转化为命令行参数。
UDP协议
首先我们要明确的就是,之前我们写的不管是聊天还是计算器,都是使用了操作系统提供的系统接口完成的应用层服务,所以在应用层可以使用标准的服务进行二次开发,或者就像计算器自己定制协议。
任何协议 都要解决的就是如何将报头和有效载荷分离向上交付,如何将给有效载荷添加报头向下封装。
UDP协议格式
UDP全称为用户数据报协议(User Datagram Protocol)。
前8字节就叫做UDP的报头,数据就是有效载荷,也就是从应用层来的数据,如何对数据进行分离和封装呢?通过上一篇HTTP介绍,我们知道HTTP是通过空行让报头和有效载荷分离的。
UDP采用的就是定长报头 ,只要拿到报文,就截取8字节 ,剩下的就是有效载荷 ,这就是将报头和有效载荷分离,向上交付时就从报头中拿到16位目的端口号 ,拿到端口号通过哈希映射找到对应的进程,再向上交付给特定进程。所以我们在写代码的时候把端口号定义为uint16_t的类型。
想要获得整个报文就拿到报头中16位UDP长度,得到了UDP长度再减去8字节报头就拿到了报文,所以UDP可以将报文一个一个正确地接收,只要我收到了就一定是一个完整的,所以UDP是面向数据报的。
这就是简单理解一下怎样封装和解包,内核中的实现还是很复杂的,其中传输层的缓冲区也叫做内核缓冲区。
还有就是报头中16位UDP检验和 ,检验规则不考虑,只要知道校验成功向上交付报文 ,失败直接丢弃 ,对端不知道,不重传,也不关心。
UDP特点
特点我们原来也大致说过:
- 无连接:知道目的IP和目的端口号 就可以直接进行数据传输,不需要建立连接。
- 不可靠:这不是一个缺点,只是一个特点 ,为了保证可靠性一定要做更多的工作,使用和维护的成本就会更高,所以不可靠就对应了简单 ,没有确认机制,没有重传机制,更不会给应用层返回任何错误信息 。所以在允许少量丢包的情况下可以使用UDP,因为它简单。
- 面向数据报:UDP不管报文多长,都会原样发送,不拆分也不合并 ,所以他一定是一个完整报文 ,这就叫做面向数据报。
UDP缓冲区
原来我们就说过,我们使用的sendto、recvfrom、write、read、send、recv这类IO接口都叫做拷贝函数。
如果使用他们进行网络通信,他们并没有直接把数据传到对端的机器中,而是放在了内核的缓冲区中;同样,接收的时候也没有直接从对端的主机中接收,而是从内核缓冲区中拷贝到应用层的。这里的缓冲区一般都是传输层协议提供的缓冲区。
所以什么时候发,发多少,出错了怎么办都是OS帮我们管理的,或者说是传输层帮我们管的。
其实UDP并没有真正意义上的发送缓冲区 ,调用sendto就直接把数据交给内核,内核会立即通过网络协议栈向下封装执行后续操作。但UDP还是要有接收缓冲区 的,如果应用层没有调用recvfrom时数据已经发过来了,如果不接收就可能导致丢包 ,像这种的丢包UDP还是要管的。但是如果缓冲区满了 ,那再发过来的报文就会被直接丢弃。
而且网络传输的顺序也是不确定的,是因为在路由器转发的时候选择的路径不同 导致的,从而导致报文乱序,UDP不会解决这个问题,这也是可靠性问题,但UDP不关心,这些问题都是TCP要关心的,所以到TCP的时候再详细说明。
还有一点就是UDP是全双工的 ,对一个文件描述符既能读又能写,甚至在多线程下也没问题,那就是全双工的,只要保证接收和发送缓冲区不冲突就可以实现全双工 ,不使用同一个缓冲区,UDP在发送的时候并不会影响接收。
UDP的注意事项
UDP协议的报头中有16位UDP长度,也就是说UDP能传输的数据最大的长度就是2^16次方,也就是64KB ,这其中也包含了8字节报头长度。但是64KB在当前的互联网环境中是非常小的,如果超过64KB需要发送端手动分装,多次发送,接收端手动拼装。
基于UDP的应用层协议
- NFS:网络文件系统。
- TFTP:简单文件传输协议。
- DHCP:动态主机配置协议。
- BOOTP:启动协议(用于无盘设备启动)。
- DNS:域名解析协议。
到这里UDP协议就结束了,它很简单,可以说它什么都不管,而接下来的TCP他是什么都管。
TCP协议
TCP全称为传输控制协议(Transmission Control Protocol),它什么都要管,要对数据传输进行详细的控制,TCP协议是互联网中使用最广泛的传输层协议,最主要的就是他保证了可靠性。
我们知道了TCP也是全双工的,UDP也是全双工的,而UDP没有真正意义上的发送缓冲区,但是TCP是有的。
在应用层中使用系统调用只是把数据拷贝到了传输层的缓冲区 ,之后发送的事就是传输层的事情了,怎么发,发多少,发生了乱序,丢包都是传输层帮我们解决的,所以TCP才叫做传输控制协议。
接收端也只是从接收缓冲区中读取,所以发送数据的本质就是在两端的发送和接收缓冲区中来回拷贝 ,这就有了两对儿独立的发送和接收缓冲区 ,所以TCP才支持全双工通信。
TCP协议格式
还是那个问题,TCP如何进行解包和交付?
TCP报头采用的标准的20字节 ,其中就有4位首部长度 ,这个首部长度就是报头+选项的长度 ,4位就是0000->1111就是0->15,但是它的单位是4字节 ,理论上它可以表示0->60字节。TCP标准报头的长度就是20字节,所以真实的取值应该是[20, 60],对应的4位就应该是0101->1111也就是5->15。
正确的提取方式就是提取前20字节 ,根据这20字节提取4位首部长度 ,再乘4字节就是报头加选项的长度 ,如果这个数是20字节就表示没有选项,已经拿到了所有首部;如果不是20就用这个长度减去20就是选项的长度,到这里就已经读完了报头,剩下的就是有效载荷。
再拿到报头中的16位端口号通过哈希映射找到对应的进程id,最后向上交付。报头中的数据是如何拿到的也不再多说,还是通过结构体位段的方式实现。
序号与确认序号
如果一个客户端发送了一批请求,TCP帮我们全部接收了,如果发送的报文顺序和接收的报文顺序不一样怎么办呢,这就是乱序的问题,而乱序也是不可靠的一种体现。
所以就有了序号,我们可以保证TCP中的每一个报文一定携带了完整的报头,让发送的一方给每一个报文都标上序号,例如每次发送1000字节,一共发送1-1000、1001-2000、2001-3000三段报文,序号就是1、1001、2001。接收端发送回的响应报文中的确认序号就要设置为下一个要发送的位置,例如1001、2001、3001。
所以序号和确认序号就让请求和应答一一对应 ,而确认序号 表示的其实是确认序号之前的所有报文已经全部收到了,发送方下次发送就要从确认序号开始发送 ,如果只收到了3001,那么就可以保证3001之前的报文接收端全收到了,尽管没有收到1001和2001,所以允许确认部分丢失 ,或者不给应答。
那么为什么要有两个序号呢,原因也很简单,TCP也是全双工的 ,既可以发送,也可以接收,如果客户端请求了,服务端就要响应,在响应的同时服务端也想发送请求给客户端。客户端就要接收请求,也要有确认序号确认服务端发送的信息我收到了。
这样就可以解决乱序的问题,保证了报文按序到达。
窗口大小
在我们上课的时候,有一个老师可能讲的非常快,我还没有听懂,他就往后讲了,换成客户端向服务端发送信息发送的太快的场景,此时服务端的接收缓冲区可能已经满了,新的报文可能就要被丢弃,虽然TCP有相应的解决方案,但是从效率上来说这就是不合理的 ,既然接收方的缓冲区已经满了 ,那么发送方发送的就要慢一点。
但多慢才合理呢,老师讲的很慢,他说的你都听懂了,他还要再讲一遍,这也是不合理的。
如何保证发送的一方发送的速度是的呢?那么接收方就要有一定的反馈 ,在接收方给发送方应答的时候就要同步自己的接收能力 ,什么决定了接受能力呢?那就是自己的接收缓冲区剩余的空间大小 ,这就涉及到了流量控制 ,报头中的窗口大小就是剩余的接收缓冲区的空间大小。
所以在接收方封装TCP报头时,填入报头中的窗口大小就是自己接收缓冲区的剩余空间的大小,用于响应给发送方,让发送方得知接收方的接收能力。
6个标记位
我们这里说的是标准的6个标记位,不同平台下可能设置的标记位不同。
每个标记位的大小就是1个比特位,0代表假,1代表真,那为什么要设置这么多标记位呢?在客户端向服务端发送报文的时候,这个报文可能是一个建立连接的报文,就是connect,也可能是通信报文,亦或者是断开连接的报文等等。但是不同类型的报文也会用不同的处理方法 ,服务端会受到大量的不同类型的报文,想要区别这些报文就需要设置这些标记位。
简单介绍一下:
- **SYN:**标记该报文是一个连接请求报文。
- **FIN :**标记该报文是一个断开连接请求的报文。
- **ACK:**确认应答标记位,只要一个报文具有应答的特征,就会被设置为1,所以大部分报文都会被设置为1。
- **RST:**reset,连接重置,标记客户端连接建立异常,让客户达重新进行三次握手。
- **PSH:**push,如果对端的窗口大小长时间为0,该标记位表示督促对方尽快将数据进行向上交付。
- **URG:**紧急标记位,让本应该按序到达的报文紧急处理某个请求,标记报头中的紧急指针有效。
紧急指针
紧急指针只有报头中的URG标记位被设置才会使用,紧急指针并不是我们常用的指针,它标识的是紧急数据在报文中的偏移量,而它标识的位置只有一个字节的数据,所以并不知道这个偏移量后多少字节是紧急数据。
如果在一个机房中,网络状况也不是很好,其中一台机器维护了大量的连接,给这个机器发送消息也不回复,可能因为这台机器上层在处理大量请求,缓冲区中有大量数据堆积,就感觉这个机器完全卡住了,但是并不确定这台机器是否存活,这就可以使用URG+紧急指针的方式来询问该机器现在是什么状况,这个报文也不用排队,所以通常是用来做机器管理的一条命令。
下面我们就来说一下TCP是如何保证可靠性的。
确认应答机制
我们一直在说这个可靠性,那么为什么会出现不可靠的情况呢?
如果两个人面对面聊天 ,双方都可以清楚的听到对方在说什么,出现丢包和乱序的概率很小 。如果两个人聊天的距离增加 ,那就有可能一个人说了什么,另一个人没听清,那这样聊天的成本就增加了,可靠性就无法保证了,所以就是因为距离变长了才导致不可靠。
如果在同一个机器中,两个进程直接进行通信就不会谈及TCP/IP协议,但是一旦涉及到了网络,那就需要涉及TCP/IP协议。
在网络中长距离传输,主机A向另一台主机B发送"在吗?" ,主机A并不知道主机B有没有收到,直到主机B给主机A响应对应的应答 ,此时主机A就收到了请求,并且保证了主机B一定收到了主机A发送的"在吗?";同样的,主机B也不能保证刚才发送的响应主机A一定收到了。
通过这个例子就印证了在互联网中不存在100%可靠的协议 ,因为无法保证刚发出去的信息被对方收到了 ,所以只能保证在这次发送的信息的之前的信息对方收到了 ,这就是保证了局部上的数据100%可靠。只要我发出去的消息有匹配的应答就能够保证刚刚发出去的消息对方一定收到了。
以上我们举的例子就是TCP协议中的确认应答机制。
当把应用层中的数据拷贝到传输层的发送缓冲区中,把缓冲区想象成一个字符类型的数组,那就有了一个天然的序号,那就是数组的下标,当我们发送数组中下标为0-99的数据到对端的接收缓冲区,报头中的序号可能就是0,那么响应回来的报头的确认序号就是100。
连接管理机制
三次握手
三次握手和四次挥手叫做连接管理机制。
在这之前我们先来理解一下什么是连接:
在生活中,一个服务器一定会被大量的客户端连接,所以操作系统一定要管理这些连接 ,要管理就是先描述再组织 ,操作系统肯定要对每个连接使用数据结构进行描述,再通过某种数据结构将这些连接组织起来,组织起来后操作系统就要维护这些连接 ,维护这些连接也是要有成本 的,那就要花费内存和CPU的资源。
我们现在就可以介绍一下什么是三次握手了:
- 第一次握手: 客户端想要向服务端请求某种资源,就要先发送带有SYN标记位的连接请求报文 ,发送完后客户端的状态变成SYN_SENT。
- 第二次握手: 服务端接收报文的时间一定在客户端发送请求之后,因为网络传输是有时间的,接收到报文后,服务端变成状态变成SYN_RCVD,此时服务端也要向客户端发送建立连接的请求报文,还要发送ACK应答刚才客户端发来的请求。
- 第三次握手: 客户端接收到了服务端的连接请求 和刚才发送给服务端连接请求的应答 报文,再向服务端发送它刚才发送的请求建立连接的应答报文,只要发送应答报文,客户端状态变为ESTABLISHED已确认建立连接的状态 ,服务器收到应答报文后也会变成ESTABLISHED已确认建立连接的状态。
至此三次握手完成,客户端和服务端都要三次握手,但是并不一定要保证三次握手必须成功,如果一开始服务端就关闭了也不能保证连接建立成功,或者无法保证最后一次握手的报文对方收到了,因为没有对应的应答。
那么为什么要进行三次握手呢?
- 一次行不行呢? 如果一个客户端向服务端发送大量的连接请求报文SYN,只要服务器收到了就认为连接建立好了,那么服务端就会维护大量的连接,维护连接是有成本的 ,我们把这种向服务端发送大量SYN请求来消耗服务端资源的情况就叫做SYN洪水。
- **两次行不行呢?**只要服务端向客户端发送了建立连接请求报文的应答报文,服务器就认为连接建立好了,但是此时客户端收到了SYN+ACK报文就直接丢弃,所以服务器照样还是要维护大量连接。
- 为什么三次行呢? 三次就保证先建立连接成功的一定是客户端,服务端是收到之后才建立连接,如果客户端再向服务端发送大量连接请求,不像上面两种情况都是服务端要维护连接,三次就保证服务端中有连接时,客户端先要有连接,所以客户端和服务端的维护连接成本是等价的 。如果服务端没有收到响应,此时连接建立异常的成本就会嫁接到客户端。
这也就可以验证一下TCP全双工,对客户端而言,先发送建立连接请求,收到了应答,这就保证通信信道建立成功 ,客户端既能发也能收 ;对于服务端也是一样的,发送建立连接请求和应答,收到应答,这也保证通信信道建立成功 ,服务端也既能发也能收。
- 如果次数再多呢? 四次的情况和两次差不多,建立连接异常的成本又嫁接到服务端,服务端又要维护大量连接,所以奇数次握手,成本就在客户端,偶数次握手,成本就在服务端 。如果是五次或者七次,进行这么多次的握手没有意义,要以最小的成本做到让服务端和客户端维护连接的成本相同并验证一下全双工,所以三次就够了。
四次挥手
三次握手是想要建立连接,而四次挥手就是要断开连接了。在建立连接时一般都是客户端主动发起建立连接的请求,而断开连接时哪一端都有可能发起断开连接的请求,断开连接也是两个人的事情,所以两个人都要断开连接。
假如就让客户端先发起断开连接的请求,这就表示客户端的应用层不会再拷贝数据到发送缓冲区 了,发送缓冲区的数据也不会再拷贝给服务器的接收缓冲区了。
- 第一次挥手: 客户端向服务端发送带有FIN标记位的报文后,状态立即变为FIN_WAIT_1。
- 第二次挥手: 服务端收到了断开连接请求的报文,并向客户端发送带有ACK应答标记位的报文,之后状态立即变为CLOSE_WAIT ,客户端收到应答后状态变为FIN_WAIT_2。
- 第三次挥手: 服务端要向客户端发送断开连接请求的报文,之后状态变为LAST_ACK。
- 第四次挥手: 客户端收到服务端断开连接请求的报文后,向服务端发送应答报文,之后状态变为TIME_WAIT ,服务端收到应答,状态变为CLOSED ,过一段时间后,客户端的状态也变为CLOSED。
还是那个问题,四次挥手断开连接就一定成功吗?
如果在服务器中的机器下,使用netstat -ntp命令 查看,系统中出现了大量的CLOSE_WAIT状态的连接 ,这就是因为服务端虽然接收到了客户端发送的请求断开连接的报文,并给客户端响应,但没有给客户端发送断开连接请求的报文 ,这就是应用层代码出现了Bug ,原因就是服务端没有关闭对应的文件描述符,才导致服务端没有向客户端发送断开连接请求的报文。
原来我写过的代码中,服务端启动时的命令行参数中带有的端口号要么是8080,要么是8081,为什么要有两个呢,一直绑定一个端口号不就行了吗?当我们自己试过之后,一个服务端主动断开连接后再次绑定这个端口号就会绑定失败,现在我们就知道了为什么会绑定失败。
先启动服务端 ,再使用telnet连接服务端完成三次握手 ,此时telnet就是客户端,服务端收到了客户端的应答变为ESTABLISHED状态 ,我设置的10秒后服务端关闭 ,所以服务端主动关闭连接,关闭客户端的文件描述符 ,底层自动触发四次挥手 ,发送FIN报文,因为服务端关闭,客户端也向服务端发送FIN报文,服务端给客户端应答后,主动退出的一方要维持一段时间的TIME_WAIT状态 ,该状态下,连接已经结束 ,但是IP和端口号还被占用着 , 此时再次启动服务端就会发现绑定错误。
不同的系统下TIME_WAIT维持的时间是不同的,这个时间取决于OS自己的设置和用户设置的配置文件。
在现实中的服务器是一直运行的,像过节时的某些服务器的压力会很大,如果服务器挂了,就需要马上再启动,但此时的服务器上一定挂满了大量的TIME_WAIT连接,那就需要等一会儿,但是现实情况是不允许等待的,在等待的时候可能就会造成巨大的经济损失,所以要保证服务器即使挂了也要有立即重启的能力,所以操作系统就提供了一个接口设置套接字的属性。
cppint setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
cppint Socket() { // 创建socket int listensock = socket(AF_INET, SOCK_STREAM, 0); if (listensock < 0) { logMessage(FATAL, "%d:%s", errno, strerror(errno)); exit(2); } int opt = 1; setsockopt(listensock, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt)); logMessage(NORMAL, "sock: %d", listensock); return listensock; }
这就解释完什么是CLOSE_WAIT,什么是TIME_WAIT。
那么为什么要存在TIME_WAIT?
通常都是客户端在进行四次挥手后变成TIME_WAIT状态,双方通信的数据可能还没有到达,这个等待的时间通常是2MSL(Max Segment Life报文最大生存时间,CS传输报文一来一回的时间),TIME_WAIT状态还可以保证双方通信信道上的数据在网络中尽可能的消散,主要的原因还是客户端断开后可能马上再次连接,历史数据这时候就会影响再一次通信。
客户端在进行四次挥手后变成TIME_WAIT状态,如果第四次挥手的报文丢了 ,客户端在一段时间内仍然能够接收服务器重发的FIN报文 并对其进行响应,能够较大概率保证最后一个ACK报文被服务器收到。没有TIME_WAIT状态,服务端没有收到应答,可能就会重传FIN,客户端已经关闭,所以就只能让服务器来维护这个连接。
我们可以使用cat /proc/sys/net/ipv4/tcp_fin_timeout命令来查看MSL的值。
超时重传机制
关于重传机制前面多少也提到一点,这个机制可以有效的防止丢包问题。
当客户端向服务端发送报文时,客户端是无法知道这个报文服务端是否收到了,除非客户端收到了应答 ,要是服务端没有收到报文,也就无法应答,所以要设定一个时间间隔 ,如果在特定的时间间隔内,客户端没有收到应答,那就认为刚才发送的报文就超时了,证明这个报文丢失了,之后就要进行重传,客户端重新向服务端发送报文。
或者说客户端发送的请求没有丢包,服务端的应答丢包了。
如果服务端的应答一直丢包,是不是服务端就会收到大量重复的请求 ,这就是超时重传带来的问题,收到重复的数据也是不可靠 的一种表现,所以要根据序号对数据进行去重。
设置的超时时间 不能太长,导致服务端和客户端通信的效率降低 ,也不能太短,可能应答报文还在路上,客户端就重传了,可能会频繁发送重复报文 。这个时间也要随网络资源而变化,时间要和网络资源成反比,网络越好(差),时间越短(长)。
而Linux中的超时时间是以500ms为一个单位,每次判定超时重传的时间都是500ms的整数倍。如果已经重传了一次,还是没有应答,那么重传时间就设置为2 * 500ms;再一次就是4 * 500ms,倍数以指数形式递增 ,超时重传的次数过多 ,TCP认为网络或者对端出现异常,强制关闭连接。
流量控制
我们要知道接收端处理数据的的能力是有限的,如果发送端发的太快,导致接收端的缓冲区满了,这个时候如果发送端继续发送,就会造成丢包问题,引起丢包重传等一系列处理。
因此TCP支持根据接收端的处理能力,来决定发送端的发送速度。这个机制就叫做流量控制(Flow Control)。
- 接收端将自己可以接收的缓冲区大小 填入TCP 报头中的 "窗口大小",通过ACK报文通知发送端。
- 窗口大小越大,接收端的接收能力越强,说明网络的吞吐量越高。
- 接收端一旦发现自己的缓冲区快满了,就会将窗口大小设置成一个更小的值或者是0来通知发送端。
- 发送端接受到这个报文的窗口大小后,就会减慢自己的发送速度或停止发送。
我们知道TCP是全双工的,所以两个机器都可以发送和接收,流量控制也可以控制两个方向。
在两端发送数据之前一定进行了三次握手,这三次通信不能携带数据,但是可以交换报头中对方的窗口大小,这样就可以在通信之前得知对方的接收能力。
当发送端收到的接收端报文时,发现这个报文的窗口大小为0,就代表接收端的缓冲区已经满了,之后会有两种方式可使双方继续通信。
- 主动询问。发送端每隔一段时间会向接收端发送报文,该报文不携带数据,只为询问接收端的窗口大小,发送端收到应答后,获取窗口大小得知是否可以通信。
- 等待告知。接收端上层将接收缓冲区当中的数据读走后,接收端向发送端发送一个TCP报文,主动将自己的窗口大小告知发送端。
16为窗口大小,表示缓冲区最大就是65535字节,实际上,在TCP报头中的选项中包含一个窗口扩大因子,通过左移这个数可以扩大窗口大小,虽然扩大了窗口大小,也要扩大一下缓冲区,这些了解一下就可以了。
关于TCP如何保证可靠性的,上面已经介绍完了,TCP也为传输效率做出了努力,下面我们就来看看,TCP是如何提高传输效率的。
滑动窗口
上面我们说过TCP保证可靠性的确认应答,发送一个报文,接收到后应答,之后才能再发送下一个,这样做是保证了可靠性,但是性能较差,尤其数据在网络中运输的时间过长的情况,这样做就是串行的。
所以TCP使用的是一次性发送多个数据,暂时不需要确认就可以马上发送下一条,虽然发送一批数据,但也要控制在接收端的缓冲区范围之内,多次IO的时间是重叠在一起的,所以性能也会提高。
基于以上原因,我们可以把发送缓冲区分为一下几个区域:
滑动窗口是本质就是一次性向对端推送数据的上限 ,由对方的接收能力决定,滑动窗口既想给对方推送更多数据,又想保证对方能够接受,所以滑动窗口是兼顾可靠性和效率的一种策略。
上面就是滑动窗口的简单理解,下面我们再来完善一下这个概念。我们在刷题的时候也会看到有些题会使用滑动窗口来解决,其实也就是两个指针或者两个下标,现在定义为start和end。
- **第一个问题:**滑动窗口必须向右移动吗?
如果滑动窗口的第一个数据发送给对端,对端应答后发现窗口大小变小了,这就表示对端应用层没有读取数据,接受能力变小了,此时滑动窗口右端end不动,左端start向右移动一位(确认序号),表示刚才的报文已经收到了应答。
- **第二个问题:**滑动窗口可以为0吗?
可以为0,对端一直不读取数据,接受能力一直在变小,发送方收到应答,滑动窗口左端一直向右,直到与右端相等就为0了。更新窗口大小就是:start = 收到的应答报文中的确认序号,end = start + 收到的应答报文中的窗口大小。
- **第三个问题:**如果收到的应答报文中有丢包情况怎么办?
根据确认序号的概念,确认序号表示它之前的报文已经全部收到了,所以即使应答报文不全也没事。如果发送中有一个报文丢了,那么对端应答的确认序号就是已经收到序号的下一个,表示这个序号之前的报文收到了,所以这个丢失的报文没有对应的应答,就要进行超时重传,这个数据必须被暂时保存起来,所以左端的start不滑动,这个丢失的报文还在滑动窗口中。
- **第四个问题:**是一直向右移动会不会越界?
不存在越界的问题,因为TCP的发送缓冲区是环状的,使用指针加模运算就可以解决。
所以滑动窗口更侧重于解决传输的效率问题,可以一次性发送多个报文,它也配合超时重传解决了可靠性问题,以效率为主,可靠性为辅的一个特性。
当报文丢失后,会一直响应相同报文,达到3次就要重传,当收到了丢失的报文,那就直接响应已经收到的序号的下一个,这种机制就叫做"高速重发机制",也叫做"快重传"。
既然已经有了超时重传,为什么还要有快重传?因为快重传是有条件的,就是连续收到三个相同的应答,在快速发送大量报文中,如果有一个报文丢失,发送端会很快收到3个相同的应答,此时就会触发快重传,如果只收到两个,那就不触发快重传,时间到了触发超时重传,所以这两个是协作关系。
拥塞控制
至此,我们上面考虑的都是两个主机之间传输的可靠性和效率,但是不要忘了,我们是通过网络在传输数据的 ,如果触发少量丢包,那重传就好了,如果触发大量丢包,那可能就是网络的问题,或者说网络拥塞了。
上面我也说了,我们一直在考虑两台主机之间的通信,但这个问题就不能单单只局限与两台主机 ,网络拥塞并不是两台主机通信导致的,比如,如果出现网络拥塞,那个整个地区或整个网络都会出现网络拥塞的问题,出现大量丢包的情况,主机使用的都是TCP/IP协议,出现丢包就重传,此时可就是网络中所有的主机都触发重传,本来就已经很拥塞了,那么重传就会导致拥塞问题加重。
虽然TCP已经有了滑动窗口可以高效并可靠的发送大量数据,如果网络已经很拥堵了,一开始发送大量数据,就会出现问题,所以一旦出现网络拥塞,那就会马上触发TCP的拥塞控制算法。
虽然在网络中,我这一个主机不能决定网络拥塞的情况,但是只要知道了网络中出现了拥塞,所有的主机都要进行拥塞控制算法,每个主机发送的数据量都会减少,这就给了网络中例如路由器、交换机等设备"喘息"的机会,给他们充足的时间来处理缓存和排队的数据。
TCP就引入了慢启动机制, 在刚开始通信的时候发送少量报文,探一探网络拥塞情况,之后再加快传输速度。为了更好的处理,就有了一个新的概念------拥塞窗口。
这个拥塞窗口就是一个数字,表示单台主机一次向网络中发送大量数据时可能会引发网络拥塞问题的上限。所以滑动窗口不仅要考虑窗口大小,还要考虑网络拥塞。
cpp滑动窗口的大小 = min(拥塞窗口大小, 窗口大小);
刚开始的拥塞窗口的大小就是1,每收到一个ACK应答报文,拥塞窗口加上自己的值,也就是指数级增长,但是也不能一直指数级增长,指数增长前期增长慢,后期增长快,所以拥塞控制想要尽快解决网络问题,也可以尽快恢复双方通信的效率。
每当通信刚刚启动或者出现了网络拥塞 ,就会触发慢启动机制 ,一开始都发送少量数据,后面再快速增长,但也不能按指数级一直增长,这时就要设置一个阈值 ,超过这个阈值后不再按照指数方式增长,改用线性方式增长。
当TCP刚开始启动的时候,慢启动阈值设置为对方窗口大小的最大值。当发生了网络拥塞,慢启动阈值会变成当前拥塞窗口的一半 ,同时拥塞窗口的值被重新置为1 ,就这样一直循环。不要忘了,TCP发送数据不止看拥塞窗口 ,还要看对方的窗口大小。
所以少量丢包触发超时重传,大量丢包就认为网络拥塞,当TCP通信开始后,网络的吞吐量会逐渐上升,发生网络拥塞,网络的吞吐量会骤降,总结来说,拥塞控制是TCP协议尽可能快的把数据传输给对方,但又要避免给网络造成太大压力才出现的方案,它不仅保证了效率,也保证数据的可靠性。
延迟应答
当两台主机在进行网络通信时,一定会带有ACK应答报文,报文中也会有窗口大小来记录接收缓冲区的接收能力,换句话说,只要我给对端应答的窗口大小越大 ,那么我的接收能力就越强 ,对端也就能通过滑动窗口一次性给我发送更多数据 ,发送的数据多了,那么单次IO的效率就更高了。
那么如何让窗口大小更大呢?窗口的大小主要取决于接收缓冲区的剩余大小 ,只要应用层将数据取走,接收缓冲区就会变大 ,给对端应答的时候就有可能应答一个更大的窗口大小,所以就有了延迟应答的策略,窗口越大,网络的吞吐量就越大,传输效率就越高,可以在不拥塞的情况下尽可能提高效率。
那么怎么做呢?原来可能每个报文都要应答,现在可能就不是。
- **数量控制:**每N个报文就应答一次。
- **时间控制:**超过最大延迟时间就应答一次,时间肯定不能触发对方的超时重传机制。
具体的数量和时间根据不同的操作系统也会有不同的考虑,一般N都是2,时间为200ms。
捎带应答
我们平常使用时,不仅仅是客户端单向向服务端发送数据,服务端也要给客户端发数据,在服务端发送数据的时候,就会携带ACK标记位。所以捎带应答也是可以提高效率的机制,不只传输带有ACK标记位的报文,也携带了数据。
TCP总结
到了现在,我们也知道TCP不止保证了可靠性,还尽可能提高了性能,那我们来总结一下:
保证可靠性的机制:
- 报头中的检验和,检验失败直接丢弃
- 序号和确认序号,保证数据按序到达
- 确认应答机制
- 超市重传机制
- 连接管理机制
- 流量控制
- 拥塞控制
提高性能的机制:
- 滑动窗口
- 高速重发机制
- 延迟应答
- 捎带应答
面向字节流
现在也可以理解什么是面向字节流了,TCP在传输层有接收和发送缓冲区,调用系统接口把数据拷贝到缓冲区。
如果发送的字节太长,会被拆分成多个数据包;如果发送的字节太少,就会先在缓冲区中等待,等到数据多了等合适的时机再发送。
虽然说是从对方的发送缓冲区拷贝到接收缓冲区,但不要忘了传输层下还有网络层,数据也是从网卡驱动程序来的,之后再使用系统接口就可以从缓冲区中读数据。
TCP既可以发,也可以收,所以它是全双工的。
发送方想要发送数据,就把数据拷贝到发送缓冲区,什么时候发送,应用层不用管;接收方想要收数据,就从接收缓冲区中拷贝到上层,至于这个数据是什么时候来的也不用管,这就是面向字节流。
而面向数据报可以明确知道,只要收了一个完整报文,对端一定发了一个完整报文。而且UDP有报文长度,TCP不关心报文的长度,TCP只管把接收的报文拆掉报头,把数据拷贝到接受缓冲区后,所以这就是为什么原来我们写的应用层代码要自定义协议。
除了网络通信,我们在文件操作中使用的系统调用接口也是流式的,操作系统不关心你写入的是什么,等到合适的时机就刷新到磁盘。
粘包问题
在前几篇协议定制时我们就提到过,如果一个数据从缓冲区中读上来后,可能不是我们想要一个完整的数据,或者多出了一点数据,所以这就是粘包问题,TCP不管收到的数据是不是一个完整的,要解决粘包问题就要通过特定的方式,原来写过的网络计算器就解决了粘包问题。
- 使用定长的报文,保证每次都按固定大小读取。
- 使用变长的报文,网络计算器中解析报文的时候就会加入len和\r\n标记位,HTTP根据空行读取报头,再根据报头当中的Content-Length属性读取正文的长度。
TCP异常
TCP异常可能发生在双方主机的通信过程中,如果发生了异常,那么连接怎么办呢:
- **进程终止:**当进程终止了,操作系统会释放进程申请的资源,其中包括文件描述符,所以会关闭对应的文件描述符,关闭文件描述符的操作其实就是进行4次挥手,发送FIN标记位的报文。
- **机器关机:**在关机时如果还有连接,操作系统要询问是否关闭该进程,选择关闭进程就和进程终止一样触发4次挥手,要保证4次挥手完成,关机的时间也会长一些,所以这两种连接都会正常释放。
- **主机断电/断网:**被这样处理的主机是没有机会触发4次挥手的,先触发的异常,才识别连接异常,当我们使用浏览器时,突然断网,网页就会识别异常,直接断开连接,但是对端是意识不到的,对端认为连接依旧存在,一直保持连接就会有问题。当服务端网络异常恢复时,客户端发来请求,服务端认为要先建立连接,所以向客户端发送ACK+RST报文。如果客户端网络异常,服务器也要定期发送报文,如果一直得不到应答就会关闭连接。
还有一种情况就是我们使用QQ或者微信的时候,我们可能不发消息,这种连接就是长连接,如果一直使用,那么就回保持登录状态,断开连接,当要发送信息的时候再重新建立连接。
基于TCP的应用层协议
- HTTP(超文本传输协议)。
- HTTPS(安全数据传输协议)。
- SSH(安全外壳协议)。
- Telnet(远程终端协议)。
- FTP(文件传输协议)。
- SMTP(电子邮件传输协议)。
listen的第二个参数
前几篇使用TCP时,使用listen的时候就有两个参数:
cppint listen(int sockfd, int backlog);
在理解这个参数之前,我们先来说一说,当服务器设置成为监听状态后就可以接收连接了,当三次握手后,accept就可以直接获取建立好的连接,但是如果一直不调用accept呢?
如果来不及accept,并且底层还有大量的连接,那么这些连接是否都要建立成功呢?我们通过代码验证一下。
原来我们设置的backlog是20,现在我们直接设置成1。
cppconst static int g_backlog = 1; void Listen(int sock) { // tcp面向连接的,通信前要先建立连接,设置为监听状态,listen if (listen(sock, g_backlog) < 0) { logMessage(FATAL, "listen error, %d:%s", errno, strerror(errno)); exit(4); } logMessage(NORMAL, "listen success ..."); }
cpp#include <iostream> #include "Sock.hpp" using namespace std; int main() { Sock sock; int listensock = sock.Socket(); sock.Bind(listensock, 8080); sock.Listen(listensock); uint16_t clientport = 0; string clientip = ""; while (true) { sleep(1); } return 0; }
我们启动服务器,使用telnet连接服务器,此时的连接状态就是ESTABLISHED,表示建立连接成功。
如果多开几个telnet,可以看到这里开启了3个telnet,前两次的状态都是ESTABLISHED,但是最后一次的连接状态变成了SYN_RECV,查看上面的三次握手就是收到连接请求报文,但是不再应答SYN+ACK报文,过一会儿连接就会自动被释放。
所以listen接口的第二个参数的意义就是:backlog+1就是TCP全连接队列的长度。
- 全连接队列(accept 队列)。全连接队列用于保存处于ESTABLISHED状态,但没有被上层调用accept取走的连接。
- 半连接队列。半连接队列用于保存处于SYN_SENT和SYN_RCVD状态的连接,也就是还未完成三次握手的连接。
我们设置的backlog的值是1,那么全连接队列的长度就是2,所以在第三次连接时,这个连接的状态就是SYN_RECV,放到了半连接队列中,过一段时间就会自动释放。
那么为什么要维护连接队列呢?
- 如果有大量连接时,服务器中处理任务的线程已经被占满了,如果没有连接队列,客户端发来的请求就会直接被拒绝。
- 如果拒绝之后,服务器就有线程已经完成了任务,但是无法马上获取连接,直到有客户端发来请求,那么线程就会有一段空闲的状态,那就不能充分的利用资源。
但是这个全连接队列又不能设置的太长,如果太长的话,尾部的连接就迟迟得不到应答,而且维护连接队列也是有成本的,不如把维护连接队列的资源让给服务器,让服务器更好的为客户端提供服务。
而且全连接队列的长度不仅要考虑backlog的值,还要考虑别的因素。