基础总结-网络相关
Linux下几种I/O复用方式总结
select
select系统调用的用途:在一段指定时间内,监听用户感兴趣的文件描述符上的可读、可写和异常事件;
步骤:
- 使用FD_SET添加感兴趣的fd, FD_CLR清除fdset的位fd;
- select函数等待感兴趣文件描述符就绪;
- 使用FD_ISSET测试fdset的位fd是否被设置;
- 如果被设置,这时就可以调用业务函数对该fd进行读取或者写入;
文件描述符就绪条件:
- socket可读
- socket内核接收缓存区中的字节数大于或者等于其低水位标记(SO_RCVLOWAT);
- socket通信对方关闭连接,此时读取该socket返回0;
- 监听的socket上有新的连接到来;
- socket上有未处理的错误;
- socket可写
- socket内核发送缓存区中可用字节数大于或者等于其低水位标记SO_SNDLOWAT;
- socket的写操作被关闭;写操作被关闭socket执行写的时候会触发一个SIGPIPE信号;
- socket使用非阻塞connect连接成功或者失败之后;
- socket上有未处理的错误;
poll
poll和select类似,在制定时间内轮询一定数量的文件描述符,以测试其是否有就绪着;
struct pollfd
{
int fd; //文件描述符
short events; //注册事件
short revents; //实际发生的事件,有内核填充;
}
步骤:
- pollfd可以指定一个我们感兴趣的fd,可包含可读、可写、异常等等事件;
- poll函数,第一参数即是pollfd的数组,可以表示全部我们感兴趣的事件;
- 有fd准备就绪,poll返回就绪的个数,再配合pollfd数组可以确定那个fd;
- 然后交给相关的业务函数处理;
epoll
区别于上面两种方法:epoll是linux特有的IO复用函数,而且与poll和select相比有较大的差异;
epoll需要的是一组函数来完成,而非单个函数;epoll把用户关心的文件描述符上的事件放在内核里一个事件表中,从而无须像select和poll那样每次调用都要重复传入fd集或者事件集; 但是,epoll需要一个额外的文件描述符号,来标识内核中的事件表;
步骤:
- epoll_create创建内核事件表文件描述符;
- epoll_ctl来添加、删除、修改注册事件;
- epoll_wait来等待注册事件就绪;
- epoll_wait返回是已经就绪的事件;不像select和poll那样数组参数即用于传入用户注册事件,有用于输出内核检测到的就绪事件,极大提高了应用程序索引就绪文件描述符的效率;
LT和ET模式
epoll对于文件描述符的操作有两种模式:LT(Level Trigger,水平触发)模式和ET(Edge Trigger)边沿触发模式。
水平触发模式:
- 是默认的工作模式;这种模式下,epoll相当于一个效率较高的poll;
- 工作方式:当epoll_wait 检测到其上有事件发送并将此事件通知应用程序后,应用程序可以不立即处理该事情;这样引用程序下次调用 epoll_wait时候,还会通知该事件;
边沿触发模式:
- 工作方式:当epoll_wait检测到其上有事件发生并将此事件通知应用程序后,应用程序必须立即处理该事件,因后续不会再向其应用程序通知;
- 边沿触发模式在很大程度上降低了同一个epoll事件被重复触发的次数,因此效率要比水平触发高;
三种IO复用函数的比较
相同点:
- 3组系统调用都可以监听多个文件描述符;
- 他们的超时时间都由timeout指定,直到一个或者多个文件描述符有事件发生时返回;
不同点
- select需要提供三个参数分别表示可读、可写、异常;另一方面内核会对fd_set集合进行在线修改,所以程序在下次调用select时候需要重新设置;
- poll比select聪明点,把文件描述符和关心事件、就绪事件定义在一个结构体,整体编程就简单很多;
- poll和select每次触发都会返回整个用户注册的全部事件,所以应用程序索引就绪事件时间复杂度是O(n);
- epoll采用与select和poll不一样,epoll在内核中维护了一个事件表,而且每次epoll_wait调用都直接从该内核事件表中取得用户注册的事件,无须反复从用户空间读入这些事件;
- epoll_wait 仅用来返回就绪的事件,这使得应用程序索引就绪的文件描述符时间复杂度就是O(1);
- poll和epoll_wait 分别用fds和maxevents参数指定最多监听多少个文件描述符和事件;这两个值都能达到系统允许打开的最大文件描述符数目;
- select允许监听的最大文件数通常是有限制的;
- 当活动连接数比较多的时候,epoll_wait的效率未必有select和poll高,因为此时回调函数被触发的过于频繁,所以epoll_wait适用于连接数量多,但活动连接数较少的情况;
TCP/IP 连接建立和关闭
TCP正常连接建立和终止所对应的状态图:
三次握手建立连接
- 客户单发送标志SYN包到服务器,server收到syn之后,知道客户端要求建立连接;此时客户状态为SYC_SENT, 服务端为LISTEN,收到SYN之后,转为SYN_RCVD状态;
- 服务器收到请求之后,回复客户端确认包,确认包包含:ACK = j + 1,表示对第一步请求的回复,同时回复包中还包含一个SYN = K标志(相当于询问客户端数据是否已经准备好了;此时客户端状态为ESTABLISHED,B的状态为SYN_RCVD;
- 客户端收到之后,检测ACK=J+1(即第一发送的值 + 1),如果正确,客户端再发送ACK=K+1,服务器收到之后;连接建立成功;此时客户端和服务器状态均为ESTABLISHED;
四次挥手关闭连接 断开连接过程与建立连接类似
- client发送位码为FIN=1,用来关闭client到server的数据传送。此时client状态为FIN_WAIT_1;
- server收到这个FIN,它发回一个ACK,确认序号为收到的序号加1。此时client状态为FIN_WAIT_2,服务端CLOSE_WAIT;
- server关闭与client的连接,发送一个FIN给client。此时client为TIME_WAIT,server为LAST_ACK;
- client回复ACK确认包,并将确认序号设置为收到序号加1。服务端状态变为CLOSED;
注释:当2、3步中的ACK和FIN在一个包中发送时,client状态直接从FIN_WAIT_1变为TIME_WAIT;
为什么连接建立三次握手,而断开需要四次呢;
- 因为连接每个方向都需要一个FIN和ACK,当一端发送了FIN包之后,处于半关闭状态,此时仍然可以接收数据包。
- 在建立连接时,服务器可以把SYN和ACK放在一个包中发送。但是在断开连接时,如果一端收到FIN包,但此时仍有数据未发送完,此时就需要先向对端回复FIN包的ACK。等到将剩下的数据都发送完之后,再向对端发送FIN,断开这个方向的连接。
- 因此很多时候FIN和ACK需要在两个数据包中发送,因此需要四次握手
为什么TIME_WAIT状态还需要2MSL才可以进入CLOSED状态?
TIME_WAIT状态也称之为2MSL状态,MSL(Maximum Segment Lifetime)报文的最大生存时间,它是任何报文被丢弃前在网络内的最大时间。该事件是有限的,我们知道TCP报文以IP数据报在网络内传输,而IP数据报则有生存时间TTL字段;
原因: 如果网络不可靠,你无法保证client最后发送的ACK一定会被对方收到,也就是说对于处理LASK_ACK的服务端可能因为超时而需要重新传递FIN,所以这个TIME_WAIT状态的作用就是用来重发可能丢失的ACK报文;从而保证断开是可靠的。
另一个结果
连接在2MSL等待期间,客户端的IP和端口,服务端的IP和端口不能被重用;只有在2MSL结束以后才可以复用;
关闭连接必须是四次挥手吗?
不一定,4次挥手关闭TCP连接是最安全的做法。但在有些时候,我们不喜欢TIME_WAIT 状态(如当MSL数值设置过大导致服务器端有太多TIME_WAIT状态的TCP连接,减少这些条目数可以更快地关闭连接,为新连接释放更多资源),这时我们可以通过设置SOCKET变量的SO_LINGER标志来避免SOCKET在close()之后进入TIME_WAIT状态,这时将通过发送RST强制终止TCP连接(取代正常的TCP四次握手的终止方式)。但这并不是一个很好的主意,TIME_WAIT 对于我们来说往往是有利的。
TCP状态变迁图
说明:
- CLOSED:表示初始状态。对服务端和C客户端双方都一样。在超时或者连接关闭时候进入此状态。
- LISTEN:表示监听状态。服务端一次调用Socket、bind、listen函数,进入此状态,可以开始accept连接了。
- SYN_SENT:表示客户端已经发送了SYN报文。当客户端调用connect函数发起连接时,首先发SYN给服务端,然后自己进入SYN_SENT状态,并等待服务端发送ACK+SYN。
- SYN_RCVD:表示服务端收到客户端发送SYN报文。服务端收到这个报文后,进入SYN_RCVD状态,然后发送ACK+SYN给客户端。
- ESTABLISHED:表示连接已经建立成功了。服务端发送完ACK+SYN后进入该状态,客户端收到ACK后也进入该状态。
- FIN_WAIT_1:表示主动关闭连接。无论哪方调用close函数发送FIN报文都会进入这个这个状态。
-
FIN_WAIT_2:表示被动关闭方同意关闭连接。主动关闭连接方收到被动关闭方返回的ACK后,会进入该状态。
注释:
- 比如主动方是client,被动方是server;如果server端发送完ack后不再发送fin,那么这个状态(client为FIN_WAIT_2,server端为CLOSE_WAIT)有可能一直保持;
- 一般规定此时处于半关闭状态的一段设置空闲时间(10分钟75s)TCP将进入close状态;
- TIME_WAIT:表示收到对方的FIN报文并发送了ACK报文,就等2MSL后即可回到CLOSED状态了。如果FIN_WAIT_1状态下,收到对方同时带FIN标志和ACK标志的报文时,可以直接进入TIME_WAIT状态,而无须经过FIN_WAIT_2状态。
- CLOSING:表示双方同时关闭连接。如果双方几乎同时调用close函数,那么会出现双方同时发送FIN报文的情况,就会出现CLOSING状态,表示双方都在关闭连接。
- CLOSE_WAIT:表示被动关闭方等待关闭。当收到对方调用close函数发送的FIN报文时,回应对方ACK报文,此时进入CLOSE_WAIT状态。
- LAST_ACK:表示被动关闭方发送FIN报文后,等待对方的ACK报文状态,当收到ACK后进入CLOSED状态。
半关闭状态的链接
如果一方已经关闭或者异常终止而另一方却不知道,我们将这种链接称为半关闭打开;任何一段主机异常都可能发生这种情况;只要不在该连接上传输数据,仍处于链接状态的一方就不会检测另一方已经出现异常。
如果设置了keepalive,即可以及时发现异常的一方;
同时打开
同时打开状态如下:
说明:
- 两个应用程序同时彼此执行主动打开的情况是可能;这需要每一方使用一个对方熟知的端口最为本地端口;
- 此时的每一端既是客户端又是服务端;
同时关闭
说明:
- 双方都执行主动关闭也是可能的,TCP协议也允许这样同时关闭;
- 注意此时的状态变化,在应用层发送关闭命令,两端均从ESTABLISHED状态变成FIN_WAIT_1。这将导致两端各发送一个FIN,两个FIN经过网络传输之后分别到达另一端。收到FIN。状态由FIN_WAIT_1转变为CLOSING,并发送ACK,收到ACK后,状态变化为TIME_WAIT;
TCP的交互数据流
经受时延的确认技术
TCP的交互式数据流通常使用“经过时延的确认”技术。通常Server在接收到从Client发送过来的数据时,并不马上发送ACK,而是等一小段时间,看看本机是否有数据要反馈给Client,如果有,就将数据包含在此ACK包中,以前发送给Client。一般情况下这个时延为200ms。需要注意的时这个200ms的定时器时相对于内核的时钟的, 假如一个数据分组到达后,此定时器已经pass了100ms,那么再过100ms ACK才会被发送,如果在这100ms内有数据要反馈,则在100ms后ACK会和数据一起发送。
Nagle算法分析
问题
客户端向服务端发送小的数据包,此时你会发现,接收到这个包的回应ACK,会在很长一段时间之后,这是问什么?这也是网络服务不允许发生的?
分析
TCP/IP协议中,无论发送多少数据,总是要在数据前面加上协议头(20字节的IP首部和20字节的TCP首部),如果此时你的数据包只有一个字节,这样大大浪费了网络传输效率;
为了尽可能的提高效率,利用网络带宽,TCP总是希望尽可能发送MSS尺寸的数据(MSS Maxitum Segment Size最大分段大小);
Nagle算法:主要就是为了预防网络中小的数据块的,尽可能大的发送大的数据块。Nagle算法要求一个TCP连接中,最多只能存在一个未被确认的小数据包!
解决与分析
举个例子,一开始client端调用socket的write操作将一个int型数据(称为A块)写入到网络中,由于此时连接是空闲的(也就是说还没有未被确认的小段),因此这个int型数据会被马上发送到server端,接着,client端又调用write操作写入‘/r/n’(简称B块),这个时候,A块的ACK没有返回,所以可以认为已经存在了一个未被确认的小段,所以B块没有立即被发送,一直等待A块的ACK收到(大概40ms之后),B块才被发送。整个过程如图所示:
sequenceDiagram
C->>S: 数据块A
C->>S: ACK
C->>S: 数据块B
S->>C: ACK
这里还隐藏了一个问题,就是A块数据的ACK为什么40ms之后才收到?这是因为TCP/IP中不仅仅有nagle算法,还有一个ACK延迟机制。当Server端收到数据之后,它并不会马上向client端发送ACK,而是会将ACK的发送延迟一段时间(假设为t),它希望在t时间内server端会向client端发送应答数据,这样ACK就能够和应答数据一起发送,就像是应答数据捎带着ACK过去。在我之前的时间中,t大概就是40ms。这就解释了为什么’/r/n’(B块)总是在A块之后40ms才发出。
方法:
如果你觉着nagle算法太捣乱了,那么可以通过设置TCP_NODELAY将其禁用。当然,更合理的方案还是应该使用一次大数据的写操作,而不是多次小数据的写操作。
TCP成块数据流
与TCP成块数据流相关的东西很多,比如流量控制、紧急数据传输、数据窗口大小调整等等。
正常数据流
TCP通常不会对每个到达的数据包进行确认操作,通常一个ACK报文可以确认多个成块数据段报文;
原因如下:
当收到一个报文后,此TCP连接被标识为一个未完成的时延确认,当再次收到一个数据报文后,此连接有两个未确认的报文段,TCP马上发送一个ACK,当第三个数据报文到达后,第四个报文到达前,通常此TCP连接已经经过了200ms延时,因此一个ACK被发送,这样的循环周而复始,从而出现了一个ACK确认两个数据报文的情况。当然,ACK的产生很大程度上和其接收数据报文段的时间紧密相关,也就是和Client段发送数据的频率相关,和网络拥塞程度相关,和Client与Server两端的处理能力相关,总是是一个多因素决定的结果。
滑动窗口协议
注意:
- TCP使用滑动窗口协议来进行流量控制。
- 滑动窗口是一个抽象的概念,它是针对每一个TCP连接的,而且是有方向的;
- 一个TCP连接应该有两个滑动窗口,每个数据传输方向上有一个,而不是针对连接的每一端的。
TCP滑动窗口可视化表示
说明:
- 提供的窗口是由接收方告知的;这种窗口称之为提出窗口(字节4–字节9);
表示:接收方已经确认了包括第3个字节在内的数据,且通告窗口大小为6;
- 窗口的大小和确认序号相对应的,发送方计算它的可用窗口,该窗口表明多少数据可以立即被发送;
- 接收方确认数据之后,这个窗口不时的向右移动,窗口两个边沿的相对运动增加或者减少窗口的大小;
窗口的边沿运动描述如下:
- 窗口合拢:左边沿像右边沿靠拢,一般发生在数据被发送和确认时;
- 窗口张开:右边沿移动允许发送更多的数据。发生在:另一端的接收进程读取已经确认的数据并释放了TCP的接收缓存时;
- 窗口收紧:RFC强烈建议不要使用这种方式;
zero window
当接收端处理缓慢,很容易把发送端的滑动窗口大小降为0;如果左边沿到达右边沿,则称之为零窗口,此时发送方不能发送任何数据。
滑动窗口大小为0,发送端就不发送数据了;但是如果一段时间后,Window size可用了,怎么通知发送端呢??
解决办法:
解决这个问题,TCP使用了Zero Window Probe技术,缩写为ZWP,也就是说,发送端在窗口变成0后,会发ZWP的包给接收方,让接收方来ack他的Window尺寸,一般这个值会设置成3次,第次大约30-60秒(不同的实现可能会不一样)。如果3次过后还是0的话,有的TCP实现就会发RST把链接断了
注意:
只要有等待的地方都可能出现DDoS攻击,Zero Window也不例外,一些攻击者会在和HTTP建好链发完GET请求后,就把Window设置为0,然后服务端就只能等待进行ZWP,于是攻击者会并发大量的这样的请求,把服务器端的资源耗尽。
数据传输过程中滑动窗口的动态性
如图:
说明:
以该图为例可以总结如下几点:
- 发送方不必发送一个全窗口大小的数据。
- 来自接收方的一个报文段确认数据并把窗口向右边滑动。这是因为窗口的大小是相对 于确认序号的。
- 正如从报文段 7到报文段 8中变化的那样,窗口的大小可以减小,但是窗口的右边沿却 不能够向左移动。
- 接收方在发送一个 ACK前不必等待窗口被填满。在前面我们看到许多实现每收到两个报文段就会发送一个ACK。
滑动窗口大小
- TCP窗口的大小通常由接收端来确认,也就是在TCP建立连接的第二个SYN+ACK报文的Win字段来确认。
- 程序可以随时改变这个窗口(缓存)的大小。默认的窗口大小是4096字节,但是对于文件传输来说这并不是一个理想的数字,如果程序的主要目的是传输文件,那么最好将这个缓存设置到最大,但是这样可能会造成发送端连续发送多个数据报文段后,接收方才反馈一个ACK的情况,当然,这也没有什么不可以的,只要不超时,就不算错。
PUSH标志
PUSH是TCP报头中的一个标志位,发送方在发送数据的时候可以设置这个标志位。该标志通知接收方将接收到的数据全部提交给接收进程。这里所说的数据包括与此PUSH包一起传输的数据以及之前就为该进程传输过来的数据。
当Server端收到这些数据后,它需要立刻将这些数据提交给应用层进程,而不再等待是否还有额外的数据到达。
那么应该合适设置PUSH标志呢?
目前大多数的API没有向应用程序提供通知其TCP设置PUSH标志的方法。的确,许多实现程序认为PUSH标志已经过时,一个好的 TCP实现能够自行决定何时设置这个标志。
TCP成块数据吞吐量(了解)
- TCP窗口大小,窗口流量控制,慢启动对TCP连接的吞吐量是相互作用的;
- RTT(Round-Trip Time):往返时间。是指一个报文段从发出去到收到此报文段的ACK所经历的时间。通常一个报文段的RTT与传播时延和发送时延两个因素相关。
- 当发送方和接收方之间的管道(pipe)被填满。此时不论拥塞窗口和通告窗口是多少,它都不能再容纳更多的数据。每当接收方在某一个时间单位从网络上移去 一个报文段,发送方就再发送一个报文段到网络上。但是不管有多少报文段填充了这个管道, 返回路径上总是具有相同数目的ACK 。这就是连接的理想稳定状态。
TCP的超时和重传
什么时候需要重传呢?
TCP提供可靠的运输层。它使用的方法之一就是确认从另一端收到的数据。但数据和确 认都有可能会丢失。TCP通过在发送时设置一个定时器来解决这种问题。如果当定时器 溢出时还没有收到确认,它就重传该数据;
所以对于任何实现而言,如何决定超时和重传的策略,怎么决定超时间隔和如何确定重传的频率,成为关键!
每个TCP链接上有多少个定时器呢?
- 重传定时器使用于当希望收到另一端的确认。
- 坚持(persist)定时器使窗口大小信息保持不断流动,即使另一端关闭了其接收窗口。
- 保活(keepalive)定时器可检测到一个空闲连接的另一端何时崩溃或重启;
- 2MSL定时器测量一个连接处于TIME_WAIT状态的时间。
往返时间
从前面的TCP重传机制我们知道Timeout的设置对于重传非常重要。
- 设长了,重发就慢,丢了老半天才重发,没有效率,性能差;
- 设短了会导致可能并没有丢就重发。于是重发的就快,会增加网络拥塞,导致更多的超时,更多的超时导致更多的重发;
具体的算法不赘述,参考TCP/IP详解第一卷; 关于网络拥塞等等相关内容参考这里
cuipf
