Home

cuipf blog

10 Nov 2018

基础总结-网络相关

Linux下几种I/O复用方式总结

select

select系统调用的用途:在一段指定时间内,监听用户感兴趣的文件描述符上的可读、可写和异常事件;

步骤:

  1. 使用FD_SET添加感兴趣的fd, FD_CLR清除fdset的位fd;
  2. select函数等待感兴趣文件描述符就绪;
  3. 使用FD_ISSET测试fdset的位fd是否被设置;
  4. 如果被设置,这时就可以调用业务函数对该fd进行读取或者写入;

文件描述符就绪条件:

  1. socket可读
    • socket内核接收缓存区中的字节数大于或者等于其低水位标记(SO_RCVLOWAT);
    • socket通信对方关闭连接,此时读取该socket返回0;
    • 监听的socket上有新的连接到来;
    • socket上有未处理的错误;
  2. socket可写
    • socket内核发送缓存区中可用字节数大于或者等于其低水位标记SO_SNDLOWAT;
    • socket的写操作被关闭;写操作被关闭socket执行写的时候会触发一个SIGPIPE信号;
    • socket使用非阻塞connect连接成功或者失败之后;
    • socket上有未处理的错误;

poll

poll和select类似,在制定时间内轮询一定数量的文件描述符,以测试其是否有就绪着;

struct pollfd
{
    int fd;          //文件描述符
    short events;    //注册事件
    short revents;   //实际发生的事件,有内核填充;
}

步骤:

  1. pollfd可以指定一个我们感兴趣的fd,可包含可读、可写、异常等等事件;
  2. poll函数,第一参数即是pollfd的数组,可以表示全部我们感兴趣的事件;
  3. 有fd准备就绪,poll返回就绪的个数,再配合pollfd数组可以确定那个fd;
  4. 然后交给相关的业务函数处理;

epoll

区别于上面两种方法:epoll是linux特有的IO复用函数,而且与poll和select相比有较大的差异;

epoll需要的是一组函数来完成,而非单个函数;epoll把用户关心的文件描述符上的事件放在内核里一个事件表中,从而无须像select和poll那样每次调用都要重复传入fd集或者事件集; 但是,epoll需要一个额外的文件描述符号,来标识内核中的事件表;

步骤:

  1. epoll_create创建内核事件表文件描述符;
  2. epoll_ctl来添加、删除、修改注册事件;
  3. epoll_wait来等待注册事件就绪;
  4. epoll_wait返回是已经就绪的事件;不像select和poll那样数组参数即用于传入用户注册事件,有用于输出内核检测到的就绪事件,极大提高了应用程序索引就绪文件描述符的效率;

LT和ET模式

epoll对于文件描述符的操作有两种模式:LT(Level Trigger,水平触发)模式和ET(Edge Trigger)边沿触发模式。

水平触发模式:

  1. 是默认的工作模式;这种模式下,epoll相当于一个效率较高的poll;
  2. 工作方式:当epoll_wait 检测到其上有事件发送并将此事件通知应用程序后,应用程序可以不立即处理该事情;这样引用程序下次调用 epoll_wait时候,还会通知该事件;

边沿触发模式:

  1. 工作方式:当epoll_wait检测到其上有事件发生并将此事件通知应用程序后,应用程序必须立即处理该事件,因后续不会再向其应用程序通知;
  2. 边沿触发模式在很大程度上降低了同一个epoll事件被重复触发的次数,因此效率要比水平触发高;

三种IO复用函数的比较

相同点:

  1. 3组系统调用都可以监听多个文件描述符;
  2. 他们的超时时间都由timeout指定,直到一个或者多个文件描述符有事件发生时返回;

不同点

  1. select需要提供三个参数分别表示可读、可写、异常;另一方面内核会对fd_set集合进行在线修改,所以程序在下次调用select时候需要重新设置;
  2. poll比select聪明点,把文件描述符和关心事件、就绪事件定义在一个结构体,整体编程就简单很多;
  3. poll和select每次触发都会返回整个用户注册的全部事件,所以应用程序索引就绪事件时间复杂度是O(n);
  4. epoll采用与select和poll不一样,epoll在内核中维护了一个事件表,而且每次epoll_wait调用都直接从该内核事件表中取得用户注册的事件,无须反复从用户空间读入这些事件;
  5. epoll_wait 仅用来返回就绪的事件,这使得应用程序索引就绪的文件描述符时间复杂度就是O(1);
  6. poll和epoll_wait 分别用fds和maxevents参数指定最多监听多少个文件描述符和事件;这两个值都能达到系统允许打开的最大文件描述符数目;
  7. select允许监听的最大文件数通常是有限制的;
  8. 当活动连接数比较多的时候,epoll_wait的效率未必有select和poll高,因为此时回调函数被触发的过于频繁,所以epoll_wait适用于连接数量多,但活动连接数较少的情况;

TCP/IP 连接建立和关闭

TCP正常连接建立和终止所对应的状态图: TCP/IP

三次握手建立连接

  1. 客户单发送标志SYN包到服务器,server收到syn之后,知道客户端要求建立连接;此时客户状态为SYC_SENT, 服务端为LISTEN,收到SYN之后,转为SYN_RCVD状态
  2. 服务器收到请求之后,回复客户端确认包,确认包包含:ACK = j + 1,表示对第一步请求的回复,同时回复包中还包含一个SYN = K标志(相当于询问客户端数据是否已经准备好了;此时客户端状态为ESTABLISHED,B的状态为SYN_RCVD;
  3. 客户端收到之后,检测ACK=J+1(即第一发送的值 + 1),如果正确,客户端再发送ACK=K+1,服务器收到之后;连接建立成功;此时客户端和服务器状态均为ESTABLISHED

四次挥手关闭连接 断开连接过程与建立连接类似

  1. client发送位码为FIN=1,用来关闭client到server的数据传送。此时client状态为FIN_WAIT_1;
  2. server收到这个FIN,它发回一个ACK,确认序号为收到的序号加1。此时client状态为FIN_WAIT_2,服务端CLOSE_WAIT
  3. server关闭与client的连接,发送一个FIN给client。此时client为TIME_WAIT,server为LAST_ACK
  4. client回复ACK确认包,并将确认序号设置为收到序号加1。服务端状态变为CLOSED;

注释:当2、3步中的ACK和FIN在一个包中发送时,client状态直接从FIN_WAIT_1变为TIME_WAIT;

为什么连接建立三次握手,而断开需要四次呢;

  1. 因为连接每个方向都需要一个FIN和ACK,当一端发送了FIN包之后,处于半关闭状态,此时仍然可以接收数据包。
  2. 在建立连接时,服务器可以把SYN和ACK放在一个包中发送。但是在断开连接时,如果一端收到FIN包,但此时仍有数据未发送完,此时就需要先向对端回复FIN包的ACK。等到将剩下的数据都发送完之后,再向对端发送FIN,断开这个方向的连接。
  3. 因此很多时候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状态变迁图

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后,会进入该状态。

    注释:

    1. 比如主动方是client,被动方是server;如果server端发送完ack后不再发送fin,那么这个状态(client为FIN_WAIT_2,server端为CLOSE_WAIT)有可能一直保持;
    2. 一般规定此时处于半关闭状态的一段设置空闲时间(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;

  • 窗口的大小和确认序号相对应的,发送方计算它的可用窗口,该窗口表明多少数据可以立即被发送;
  • 接收方确认数据之后,这个窗口不时的向右移动,窗口两个边沿的相对运动增加或者减少窗口的大小;

窗口的边沿运动描述如下:

滑动窗口边沿的移动

  1. 窗口合拢:左边沿像右边沿靠拢,一般发生在数据被发送和确认时;
  2. 窗口张开:右边沿移动允许发送更多的数据。发生在:另一端的接收进程读取已经确认的数据并释放了TCP的接收缓存时;
  3. 窗口收紧: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。

滑动窗口大小

  1. TCP窗口的大小通常由接收端来确认,也就是在TCP建立连接的第二个SYN+ACK报文的Win字段来确认。
  2. 程序可以随时改变这个窗口(缓存)的大小。默认的窗口大小是4096字节,但是对于文件传输来说这并不是一个理想的数字,如果程序的主要目的是传输文件,那么最好将这个缓存设置到最大,但是这样可能会造成发送端连续发送多个数据报文段后,接收方才反馈一个ACK的情况,当然,这也没有什么不可以的,只要不超时,就不算错。

PUSH标志

PUSH是TCP报头中的一个标志位,发送方在发送数据的时候可以设置这个标志位。该标志通知接收方将接收到的数据全部提交给接收进程。这里所说的数据包括与此PUSH包一起传输的数据以及之前就为该进程传输过来的数据。

当Server端收到这些数据后,它需要立刻将这些数据提交给应用层进程,而不再等待是否还有额外的数据到达。

那么应该合适设置PUSH标志呢?

目前大多数的API没有向应用程序提供通知其TCP设置PUSH标志的方法。的确,许多实现程序认为PUSH标志已经过时,一个好的 TCP实现能够自行决定何时设置这个标志。

TCP成块数据吞吐量(了解)

  1. TCP窗口大小,窗口流量控制,慢启动对TCP连接的吞吐量是相互作用的;
  2. RTT(Round-Trip Time):往返时间。是指一个报文段从发出去到收到此报文段的ACK所经历的时间。通常一个报文段的RTT与传播时延和发送时延两个因素相关。
  3. 当发送方和接收方之间的管道(pipe)被填满。此时不论拥塞窗口和通告窗口是多少,它都不能再容纳更多的数据。每当接收方在某一个时间单位从网络上移去 一个报文段,发送方就再发送一个报文段到网络上。但是不管有多少报文段填充了这个管道, 返回路径上总是具有相同数目的ACK 。这就是连接的理想稳定状态。

TCP的超时和重传

什么时候需要重传呢?

TCP提供可靠的运输层。它使用的方法之一就是确认从另一端收到的数据。但数据和确 认都有可能会丢失。TCP通过在发送时设置一个定时器来解决这种问题。如果当定时器 溢出时还没有收到确认,它就重传该数据;

所以对于任何实现而言,如何决定超时和重传的策略,怎么决定超时间隔和如何确定重传的频率,成为关键!

每个TCP链接上有多少个定时器呢?

  • 重传定时器使用于当希望收到另一端的确认。
  • 坚持(persist)定时器使窗口大小信息保持不断流动,即使另一端关闭了其接收窗口。
  • 保活(keepalive)定时器可检测到一个空闲连接的另一端何时崩溃或重启;
  • 2MSL定时器测量一个连接处于TIME_WAIT状态的时间。

往返时间

从前面的TCP重传机制我们知道Timeout的设置对于重传非常重要。

  • 设长了,重发就慢,丢了老半天才重发,没有效率,性能差;
  • 设短了会导致可能并没有丢就重发。于是重发的就快,会增加网络拥塞,导致更多的超时,更多的超时导致更多的重发;

具体的算法不赘述,参考TCP/IP详解第一卷; 关于网络拥塞等等相关内容参考这里

cuipf

scribble