目录
在网络通信中,假设主机A要给主机B的发送数据,那么本质上就是主机A的某个进程要与主机B的某个进行实现进程间通信。主机A发数据时,通过全网中唯一的IP地址,可以定位到主机B,再通过目的端口号,即可获取主机B上要接受主机A发送信息的进程。通过 IP地址 + 端口号,可以确定全网中唯一的一个进程,实现网络环境下的进程间通信。
网络通信五元组:源IP、目的IP、源端口号、目的端口号、协议号。
这里的协议号,我们可以理解为传输层所使用的协议,即:udp(面向数据报协议)或tcp(传输控制协议)。
如图1.1所示,假设两个客户端都打开了某一浏览器,那么客户端就要与服务器建立通信。对于服务器而言,需要属于其自身的独立端口号,以便客户端确定接受信息的进程。而客户端的可能打开多个浏览器画面,每个浏览器画面都是一个独立的进程,拥有独立的端口号。
在图1.1中,服务器就是目的端,其ip和提高服务的进程的端口号,就是目的端口号。而客户端的ip和打开浏览器界面的进程的端口号,就是源端口号。传输层使用的tcp/udp协议,可以理解为协议号。
一个进程可以绑定多个端口号,但一个端口号不能绑定多个进程。
端口号用16位的数据表示,范围是0~65535,根据编号数值,将端口号划分为知名端口号和操作系统动态分配的端口号:
常见的知名端口号:http(80)、https(443)、ftp(21)、ssh(22)、telnet(23)。
netstat指令的功能为查看OS中的网络状态。
语法:netstat [选项]
netstat常见选项及功能:
pidof可以直接获取进程对应的pid,避免使用ps axj | grep ... 来查看进程pid。
语法:pidof [进程名称]
udp协议,全程面向数据报协议,是在网络通信中传输层用到的协议。udp协议具有以下的特点:
udp协议的报头固定8字节长度,其中16位目的端口号用于目的主机找的接受数据的进程,16位源端口号用于目的主机向源主机发回数据时确认端口号。
udp协议是面向数据报的,每次发送和接受的数据大小固定,而面向数据报就是由udp报头中的16位窗口大小支持的。
16位窗口大小是整个udp报文(数据端)的大小,有效数据的大小 = 16位窗口大小 - 8字节。
如果16位校验喝出错,报文就会直接被丢弃。
udp的报头,是通过结构体(位段)来定义的,使用位段是由于网络资源相对紧张,使用位段能够有效减少报头的长度,节约网络资源,在tcp协议中报头也是使用位段来组织的。下面为一段udp报头定义的demo代码。
- struct udp_header
- {
- uint32_t src_port : 16;
- uint32_t dst_port : 16;
- uint32_t udp_size : 16;
- uint32_t udp_check : 16;
- };
对于任何协议的报文,都需要结局两个关键问题:
对于udp协议的报文,我们看到,其报头的大小是固定的。因此,通过一下的操作,可以提取出报文中的有效数据并向上交付:
对于udp协议面向数据报的理解:udp协议报文的长度是可以确定的,而由于其报头长度又是8字节,这样就可以确定有效数据的大小。接受方通过获取有效数据的大小,就可以读取固定长度的数据,这样也就实现了面向数据报。在udp中,一次发生的数据,接收方不可以分为多次读取。
对于应用层发送数据和读取数据的理解:在udp/tcp协议中,要维护发生缓冲区和接受缓冲区,udp协议由于是面向数据报的,一次发送固定长度的数据,所以udp没有发送缓冲区,但是udp有接收缓冲区。而tcp协议则既有发送缓冲区也有接受缓冲区。
应用层调用send/sendto/recv/recvfrom这一类网络IO接口发送和接受数据,本质上并不是将数据直接传到网络中,而是将数据拷贝到内核中协议维护的缓冲区里,或者从缓冲区中拷贝数据到应用层,至于数据什么时候发送的网络,则是由OS关心的,与上层用户和协议无关。
tcp协议,全称传输控制协议,属于网络通信中的传输层协议,传输控制协议,顾名思义就是可以保证网络通信可靠性的协议。
tcp协议具有以下特点:
图5.1为tcp协议端的一般格式,分为三部分组成:20字节定长报头 + 选项 + 数据。
如图5.1所示,选项之前的20字节数据为tcp协议的定长报头,其中每一部分的含义和功能为:
图6.1位三次握手和四次挥手通信双方(主机A和主机B)向对方发送的报文标识位信息以及双方主机所处的状态。当三次握手/四次挥手全流程结束后,通信双方的连接才正式的建立/断开。
如图6.1所示,三次握手建立连接时,主机A和主机B进行的操作流程为:
SYN -> SYN + ACK -> ACK的过程称为三次握手。
问题:为什么不能是一次握手,两次握手或四次握手?-- 从安全性、可靠性两方面考虑。
三次握手能在一定程度上提高安全性:
三次握手保证可靠性(验证全双工):
如图6.1所示,通信双方需要通过四次挥手断开连接,四次挥手过程中,主机A和主机B所执行的操作流程为:
在四次挥手中,有两个状态值的关注,第一个是TMIE_WAIT,另一个是CLOSE_WAIT。
TIME_WAIT状态的理解:
某些时候,如果服务器挂掉不能立刻重启,会造成很大的损失,因此需要能够在TIME_WAIT状态下重启,通过setsockopt函数可以实现上面的功能。
int opt = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
CLOSE_WAIT状态的理解:
如果在网络通信中,某连接长期处于CLOSE_WAIT状态,或者有大量连接处于CLOSE_WAIT状态,那么则是服务器程序写出了BUG。
造成大量CLOSE_WAIT状态的原因:服务器没有close(connfd)。这种情况下,服务器就不会向客户端发送FIN报文,也就无法将状态变换为LAST_ACK状态,造成服务器中存在大量的连接,消耗CPU和内存资源,最终导致服务器崩溃。
在tcp协议中,主机每接收到一条报文,就要向对端发送一个ACK应答,告知对方收到了报文。
如图5.1所示,在tcp报头中,存在32位序号和32位确认序号,每一条带有ACK标志位的报文,其携带的确认序号的意义在于:告知对端该序号之前的报文已经收到了,并且告知对端下一次发送数据的起始序号。
确认序号的两个作用:
在网络状况良好的情况,几乎不存在丢包问题,确认序号 = 序号 + 1。
如图7.1所示,我们可以将发送缓冲区看做是一个char类型的数组,其中每个待发送的char类型数据都有一个对应的下标位置,tcp协议单次发送数据的下标范围记为 [left, right],其中我们可以将发送的最后一个字符对应的下标right记为序号,right+1即是确认序号。
在tcp协议通信的过程也如图7.1所示,我们可以暂时将其视为是串行的过程,假设第一次发送报文的确认序号为1000,表示发生了下标为1~1000的数据,那么就会收到确认序号1001,下次从下标1001的位置开始发送数据,以此类推就是确认应答机制的工作原理。
如图7.2所示,假设主机A发送给主机B的数据发生了丢包,或者主机B发送给主机A的确认应答报文发生了丢包,就会导致主机A迟迟收不到来自主机B的应答,那么主机A就会判定刚发出去的数据可能丢包,会触发超时重传机制,再次发送没有收到确认应答的报文。
接收端接受数据的能力是有限的,这是因为接受缓冲区的剩余大小是有限的。换句话说,就是主机从网络中接受数据的能力取决于接受缓冲区的剩余空间大小。
如果接受缓冲区的剩余空间过小,而又有大量的数据从网络中发送到主机,那么就会造成接受缓冲区溢出的问题,溢出的这部分数据就会被丢弃,引发丢包造成通信过程不可靠。
但是,在保证可靠性的前提下,还应当注重网络通信效率的提升。我们希望达到这样的目的:对端接收能力强发送数据就快,接受能力弱发送数据就慢。为此,数据发送方就必须要获取对端接受缓冲区剩余空间的大小。
在tcp协议的报头中,有16位窗口大小,用于表示对端接收数据的能力(对端接收缓冲区大小),数据发送方根据窗口大小动态调整发送数据的速度。如果检测到16位窗口大小为0,则表示对端接收缓冲区没有剩余空间,会暂时停止数据的发送。
当由于检测到窗口大小为0而停止发送数据后,数据发送方会每隔一定的时间间隔向对端发送探测信息,以确定对端接收缓冲区是否有了剩余空间可以接收数据,如果检测到了窗口大小更新,那么数据发送就会恢复。
图7.3为流量控制机制的工作原理图。
提问:第一次发送数据时还没有收到应答,怎样确认对端的接收能力?在三次握手建立连接时,通信双方向对端发送的报文中,会携带窗口大小。
如果tcp协议按照图7.1中表示的串行方式发送数据,那么一个数据在发出后,只有收到对端的应答,才可以发送下一条数据,这样tcp协议相对于udp协议,效率就会十分低下。
为提高效率,tcp引入滑动窗口,即:在滑动窗口内的数据,不需要获取对端的应答即可发送。
滑动窗口的大小,由对端接收能力和网络状况共同决定,一般我们认为:滑动窗口大小 = min(16位接收窗口大小, 网络拥塞窗口大小)。
滑动窗口,是位于发送缓冲区的滑动窗口,图7.6位滑动窗口的物理结构示意图,在存在滑动窗口的情况下,可以将发送缓冲区分为三块来看待:
当数据发送出去且收到应答,并且对端窗口的大小不变或者变大时,滑动窗口的右边界会向右移动,数据收到对端的确认应答后左边界也会向右移动。滑动窗口内的数据,是不断动态变化的。
如图7.5所示,主机A检测到主机B接受能力为4000,那么滑动窗口最初的范围就是[1,4000],其中的数据在没有收到确应答的时候就可以发出。假设主机A收到了确认序号为1001的报文,表示1~1000的数据被对端获取到了,且对端的接收能力变为4000不变,那么滑动窗口的左右边界都会向后移动,变为[1001,5000]。
提问:滑动窗口向后移动,是否会出现越界问题?
答案是不会的。因为虽然发送缓冲区的物理结构是线性数组,但是其逻辑结构实际上为环形数组,假设滑动窗口的右边界为indexRight,那么其在实际的线性数组中对应的下标是 indexRight % N,其中N为发送缓冲区的最大容量。
这种情况下,如果主机B的某个确认应答发生了丢包,如图7.7所示,假设主机B的2001和3001确认应答在传输过程中丢包,但是4001号确认应答被主机收到了,这并不会影响后序数据的发送,因为确认序号的含义为:告知对端在确认序号之前的所有报文都被成功接收了。
因此,只要主机A收到4001确认应答,就会自动认为4001之前的所有数据都被对端接收了,哪怕没有收到之前的确认应答。
还是以滑动窗口的场景为例,在图7.5中,假设数据1001~2000发生了丢包,那么即使主机B收到了3001~4000、4001~5000等报文,只要1001~2000还没有收到,主机B就只能给主机A发送1001确认应答,在这种情况下,主机A就会收到连续3个1001号确认应答,此时就会判定1001~2000可能发生了丢包,该数据会自动重传。
而主机B在收到重传后的1001~2000数据后,就会直接将确认序号更新到接收的序号最大的数据。
快重传机制与超时重传机制是相互配合的,如果一个报文丢失,但对端在一定时间内没有向源主机发送三个一样的确认应答,那么就必须由超时重传机制触发重传数据。
虽然tcp协议有滑动窗口以及流量控制机制可以提高数据传输的效率,但是,这只是在通信双方的主机层面来考虑的,在实际的网络数据传输中,还要考虑网络状况的好坏来动态调整发送数据的速度。为此,tcp协议引入了网络拥塞控制机制来根据网络状态调整数据发送频率。
图7.8为网络拥塞控制及调整的策略,遵循慢启动机制,即:在通信开始的时候,先发送少量的报文探测网络情况,如果能够顺利接受到确认应答,再不断加大数据大发送量,最多不能超过对端的接收能力。
慢启动机制,只是在发送数据的初期启动缓慢,其增长速度遵循指数级别增长,因此发送数据几次后,就会快速增长。
慢启动机制的控制策略可以总结为:
由此,我们可以认为,慢启动机制在在初期遵循指数级别的增长,而指数级别增长由于后期会增长过快,导致网络中可能一瞬间塞满大量数据引发网络拥堵,为此引入一个阈值,当网络窗口大小达到这个阈值之后就不再符合指数增长,而是遵循线性增长策略。当网络窗口大小增长到发生网络拥塞时,就会重新开始慢启动机制,并将新的阈值更新为拥塞窗口大小的1/2。
总结网络窗口动态调整的策略为:
假设主机A和主机B在进行通讯,主机B收到主机A发送过来的消息后,如果马上给主机A确认应答,那么新发过来的数据不会被上层应用取走,这样就会造成主机B接收缓冲区的剩余空间变少,将变少的剩余空间填入ACK报文的16位窗口大小处,就会降低主机A发送数据的速度。
如果在主机B收到报文后,不立即做出应答,而是等一段时间,让上层应用取走一部分数据,此时接收缓冲区就有了更多的剩余空间,这是后做出应答,对端就不会认为接收能力减弱了。
从接收报文个数N和接受后的间隔时间T来控制延时应答:
N和T在不同的操作系统中有所不同,一般取N=2,T=200ms,图7.10为延时应答策略的实现原理。
如图7.11所示,tcp协议通信在执行四次挥手断开连接时,主机B应答主机A断开连接请求的ACK报文和其自身的FIN请求可以合并为一个报文发送给对端,从而在事实上执行“三次挥手”。
listen函数 -- 设置监听状态,等待对端连接
函数原型:int listen(int sockfd, int backlog)
函数参数:
- socked:socket文件描述符。
- backlog:连接队列的长度。
返回值:成功返回对应的监听文件描述符,失败返回-1。
我们知道,在tcp通信时,服务器在接收客户端的连接请求是,是通过accept函数来实现的,但是accept本质上不参与三次握手建立连接的过程。
accept的功能:将连接从内核层拿到应用层进行通信。
如果不调用accept,那么连接依旧可以被建立,只是没有拿到应用层中。而listen的第二个参数,我们可以称之为全连接队列的最大长度。如果服务器收到了大量的请求,但是应用层来不及调用accept来取走连接,那么就会在内核层暂时将这些连接使用队列的形式维护起来,等待有连接关闭的时候,就可以立即accept取走内核中连接队列中维护的连接。
backlog用于指定全连接队列的最大长度,这个参数不能太大也不能太小。
用生活中的场景来理解这个参数,假设一家很火爆的餐馆在高峰期店内桌子全部被占用了,这是再有顾客希望来就餐时,店家就会在店门口准备若干桌椅,将这些客户暂时“保存维护”起来,如果店内有客人离开,就立马可以有排队的客户进店用餐,这样能够最大程度上保证店内资源的利用率。
如果“等待队列”维护的太短或者干脆不维护,那么就有可能存在店内客户离开,空桌在一段时间内没有被利用起来,造成经济损失。如果维护的太长,那么队列后面的连接就需要等待很长时间才能够被上层拿走,不符合实际需求,况且可以将“等待队列”的一部分资源用于扩充店内桌椅。
综上,backlog不能太小是为了保证资源在任何时候都能够被充分利用,不能太大是为了避免全连接队列尾部的连接等待较长时间才能够被accept取走。
在OS内核中,会维护两种关于连接的队列:
全连接队列的长度由listen的第二个参数决定,如果全连接队列的长度达到了最大值,那么就无法让更多的连接进入到ESTABLISHED状态,进而处于半连接状态。处于半连接状态的连接有一定的生命周期,如果在一定时间内不能完成三次握手进入ESTABLISHED状态,那么半连接就会被销毁。
建立起一个基于tcp协议的通信,会在操作系统内核中创建一个发送缓冲区和一个接收缓冲区,对于发送缓冲区/接受缓冲区及面向字节流的理解如下:
面向字节流,就好比通过自来水管取水,我们一次拿到的水,可能是供水商分多次供给的,也可能是供水商一次供水量的一部分。面向数据报,就好比取快递,我们那N个快递,那发送方就一定发了N个快递,不能看发快递的和取快递的次数不匹配。
在tcp协议中,应用层只负责交付数据,至于怎么发送、什么时候发送,则完全由tcp协议控制,应用层完全不需要关系,用于层也无需关系数据的格式,只管向下交付数据即可。
什么是粘包问题:
由此可见,粘包问题主要是由于数据包之间边界不明确引起的,那么要解决粘包问题,就必须要明确不同数据包之间的边界,解决粘包问题是应用层的责任:
仿照tcp实现可靠通信的方法即可,只不过是把实现可靠通信的手段从内核层移动至应用层。
可以考虑的方法有: