[后台开发工程师总结系列] 5.网络IO模型

网络IO模型

IO是计算机体系中的重要部分,IO外设有打印机、键盘、复印机等;储存设备有硬盘、磁盘、U盘等;通信设备有网卡,路由器等。不同的IO设备通信很难统一。

IO有两种操作,同步IO和异步IO,同步IO必须等IO操作完成后控制权才返回给用户进程,而异步IO无需等待IO操作完成,就将控制权返回给用户进程。

当一个IO发生时,它涉及两个系统对象,一个是调用IO的进程,一个是系统内核。一个read操作两个阶段,1等待数据准备 2 数据从内核拷贝到进程。

下面针对网络IO的四种模型分别讲解:阻塞IO、非阻塞IO、多路IO复用、异步IO

阻塞IO

在linux中, 默认情况下所有的socket都是阻塞的,典型的流程如下:

1550713270622

阻塞和非阻塞的概念是描述用户调用内核IO的方式:阻塞是指IO操作彻底完成后才返回用户空间;而非阻塞IO是IO操作调用后立即给用户一个返回值,不需要IO操作彻底完成。

当进程调用了 recvfrom 这个系统调用后,系统内核就开始了IO的第一阶段:准备数据。对于网络IO来说,很多时候数据还没到达(还没收到完整的TCP包)系统等待足够的数据到来。而用户这边整个进程会被阻塞。当系统等待数据准备好了,他就会从系统内核拷贝到用户内存中,然后才返回结果,用户进程接触阻塞状态。阻塞IO的特点是IO的两个阶段(准备数据和拷贝数据)都阻塞。

阻塞IO只适用于小规模的相应,其相应改进例如多进程、多线程或线程池都难以完成大规模响应的任务。

非阻塞IO

Linux下可以设置使得socket变为非阻塞状态。一个非阻塞的socket流程如图

1550713845144

当用户发出read操作时, 如果内核中的数据还没准备好,它不会block用户进程,而是立即返回一个错误。从用户进程的角度来讲,它发起一个read操作后不需要等待,而是马上得到一个错误。当用户进程得到错误后,它就知道数据还没准备好,它就可以再次发起read操作,一旦内核中的数据准备好了,并又收到了用户的系统调用,它就可以将数据复制到用户内存中,然后返回正确的返回值。

所以在非阻塞IO中,用户需要不断的询问kernel数据是否准备好。非阻塞的接口相对于阻塞IO显著差异在于调用后立即返回。在非阻塞状态下recv接口调用后立即返回,返回值有不同的含义:

  1. recv() 返回值大于0, 表示接受数据完毕,返回值即是字节数
  2. recv() 返回0, 表示连接正常断开
  3. recv() 返回-1, 且errono 等于EAGAIN, 表示操作尚未完成
  4. recv() 返回-1, 且errono 不等于EAGAIN, 表示recv操作遇到系统错误

可以看到服务器可以循环调用recv 接口,在单个线程内实现对所有连接数据的接收工作。但是上述模型并不推荐,因为循环盗用recv将大幅占用CPU使用率, 而且recv更多的检测“操作是否完成”的工作,实际操作系统中提供更为完善的接口,例如select() 多路复用模式。

多路IO复用

多路IO复用,也被称为事件驱动IO, 它的基本原理是有个函数(比如select)会不断的轮询所有的socket,当有某个socket的数据到达了,就通知用户进程,其流程如图所示

1550714868104

当用户进程掉用了select, 那么整个进程会被阻塞,同时内核会监视所有的select负责的socket, 任何一个准备好了其就会返回, 用户进程便可以再次调用 read 从内核拷贝数据 到用户进程。

这个模型和阻塞IO没有太大的不同,事实上还更差一些。涉及到两个系统调用(select 和 recvfrom), 但是select解决 了阻塞IO连接数的问题。

多路IO复用中,每个socket一般都设置为非阻塞的,但是真个用户进程都是阻塞的,不过是被select阻塞,而不是被socketIO阻塞,因此selcet效果与非阻塞IO类似。

异步IO模型

当用户发起read操作后,立刻就可以去做其他事;另一方名从内核角度,当他收到read后会理解返回,它不会对用户进程产生任何阻塞。然后内核会等待数据准备完成,之后将数据拷贝到用户内存中,当着一切都完成后,内核给用户进程发一个信号返回read操作完成的信息。

1550715693981

调用阻塞IO会一直阻塞对应的进程直到操作完成,而阻塞IO在内核准备数据的情况下会立即返回。两者的区别在于同步IO进程IO操作时会阻塞进程。按照这个定义,之前的阻塞IO、非阻塞IO、多路IO复用都属于同步IO,实际上,真生的IO操作,即 recvfrom 系统调用,在数据没准备好时不阻塞进程,而数据准备好拷贝进程时被阻塞。 但是异步IO不一样,进程发起IO后直接返回,知道内核发送信号,整个过程IO不阻塞。

1550716252243

经过上述介绍,发现阻塞IO和异步IO复用区别很明显,非阻塞IO大部分时间都不阻塞,但是它仍然要求进程去主动检查,并且数据准备完成后需要主动调用 recvfrom , 而异步IO完全不同,它将IO交给了内核,并被通知,在此期间不需要主动的拷贝数据。

常用函数

select

select函数在socket编程中非常重要,select是典型的多路IO复用的原型程序,几乎所有的平台都提供。select原型是 :

1
int select(int maxfpd, fd_set *readfds, fd_set *writefds, fd_set *errorfds, struct timeval *timeout);

这里用到了两个结构体,fd_set 和timeval, 结构体fd_set 可以理解为一个集合,这个集合中存放的是文件描述符(即文件句柄)这可以被认为是普通的文件。所以一个socket就是一个文件。结构体timeval 是一个常用代表时间的数据结构,秒和毫秒数。

下面讲解select各个参数的含义,

  1. maxfdp 是一个整数值,是指所有文件描述符的范围,即所有文件描述符的最大值加1
  2. readfds 是指向 fd_set 结构的指针,这个集合中应该包括文件描述符。若集合中有文件可读, select就会返回大于0的值;如果没有可读文件,则根据tiemout 再判断超时,若超出timeout 时间,返回0
  3. writefds, errorfds 同 readfds, 分别用了写和监视文件错误异常。
  4. timeout 是select的超时时间,这个参数至关重要,若传入NULL, select就会一直阻塞, 一直等到监视文件描述符某个文件描述符变化为止;若将其设为0,就变成一个存粹的非阻塞函数,不管文件描述符是否变化都返回立即执行;最后一种就是设置一个整数,这样其在timeout时间内 等待事件到来,超时过后一定返回。

poll

和select函数一样,poll函数也用于执行多路IO复用,

1
2
3
4
5
6
7
8
#include <poll.h>
int poll(struct pollfd* fds, unisgned int nfds, int timeout);

struct pollfd{
int fd; //文件描述符
short events; //等待的事件
short revents; // 实际发生的事件
}

每一个pollfd 结构体指定一个被监视的文件描述符,可以传递给多个结构体,指示poll监视多个文件描述符。每个结构体的events域是监视事件掩码,由用户来设置。poll() 不需要显示的请求异常情况报告。

1550736255434

Poll函数利用这些事件代码代替了select的读、写、错误事件

poll() 函数的timeout机制同 select函数基本一样

epoll

epoll是在linux2.6内核中提出的, 是select和poll的增强版本。相对于select和poll来说,epoll更加的灵活,没有描述符的限制。epoll使用一个文件描述符管理多个描述符,将用户的福安息文件描述符放到内核一个事件表中,这样用户空间和内核空间的数据拷贝只需要一次。

epoll函数接口

1
2
3
4
#include <sys/epoll.h>
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event* events, int maxevents, int timeout);

epoll_create 创建一个epoll句柄,size用来告诉内核监听的数目。这个不同于select中的第一个参数。需要注意的是,创建好epoll句柄后,他会占用一个fd值,所以使用完后要关闭

epoll_ctl 事件注册函数,不同于select() 监听事件时告诉内核监听什么样的事件, 而是先注册监听事件的类型。第一个参数是epoll_create()的返回值,第二个参数表示动作。(CTL的增删改)第三个参数是需要监听的参数,第四个参数告诉内核需要监听什么事。events是几个宏 的集合(类似poll)

select 、poll、epoll 的区别

select、poll、epoll 都是多路IO复用机制,多路IO复用通过一种机制可以监视多个描述符,一旦某个描述符就绪(一般是读或写就绪)就能够通知程序进行相应的读写操作。但select、poll、epoll 本质上都是同步IO,以为他们都需要在读写时间就绪后自己负责读写,即使是阻塞的,而异步IO 无需自己负责读写,异步IO的实现会把负责数据从内核拷贝到用户空间,下面将这几种IO做对比

  1. 首先看常见的select和poll,对于网络编程来说,一般认为poll() 比 select() 要高级一些。这主要是由于以下原因。
  • poll不要求开发者计算最大文件描述符时+1操作
  • poll应对大数目文件描述符时速度更快,应为select对于内核来说需要检测fd_set中的每一个比特位,比较费时
  • select可以监控文件描述符的数据是固定的(1024,2048)如果监控数值较大的文件描述符,或是分布稀疏的描述符,效率也会很低。而对于poll() 函数来说可以创建特定大小的数组开保存描述符,不受文件描述符值的限制,poll可以监控的数据量远大于select
  • 对于select来说,fd_set 在select返回后会发生变换,所以下次进入select之前都需要初始化fd_set, 而poll函数间监控将输入输出事件分开,不需要重新初始化
  • select 函数超时参数在返回时也是未定义的,每次进入都需重新设置
  1. select 优点
  • select的可移植性好, 某些UNIX系统不支持poll
  • select对于超时精度较好
  1. epoll 的优点
  • 支持一个进程打开最大数目的socket描述符(FD)

select最不能忍的一个地方是 一个进程打开的FD是有一定限制的,由于FD_SETSIZE的默认大小是1024/2048 对于支持上万连接的IM服务器来说太少了。这时候可以选择修改内核然后重新编译。不过epoll没有这个限制,它所支持的FD上限是最大可以打开的文件数目,一般远大于2048。举例而言,1G内存空间这个数目一般是10万左右。

  • IO效率不随FD数目增加而线性下降

传统的select、poll 有一个致命弱点是当有一个很大的socket集合时, 由于网络延迟,只有一部分socket是活跃的,但是 select、poll 每次调用都会扫描全局,导致效率下降。而epoll不会出现这个问题,它只会对活跃的socket 进行操作 – 这是因为epoll是根据每个fd上的回调函数实现的,只有活跃的socket才会调用回调函数,epoll实现了一个伪AIO

  • 使用mmap 是加速内核与空间的消息传递

三种方法都需要把fd三种消息传递到用户空间,如何避免不必要的内存拷贝尤为重要,这点上epoll通过内核与用户空间mmap同处一块内存实现。

例题

  1. Linux io模型(select, poll, epoll的区别,水平触发和边缘触发的区别)

    水平触发:如果文件描述符已经就绪可以非阻塞的执行IO操作了,此时会触发通知.允许在任意时刻重复检测IO的状态.select,poll就属于水平触发.

    边缘触发:如果文件描述符自上次状态改变后有新的IO活动到来,此时会触发通知.在收到一个IO事件通知后要尽可能多的执行IO操作,因为如果在一次通知中没有执行完IO那么就需要等到下一次新的IO活动到来才能获取到就绪的描述符.信号驱动式IO就属于边缘触发.

    ​ 写过单片机的人可以从另一方理解水平触发和边缘触发的区别:

    水平触发:就是只有高电平(1)或低电平(0)时才触发通知,只要在这两种状态就能得到通知.上面提到的只要有数据可读(描述符就绪)那么水平触发的epoll就立即返回.

边缘触发:只有电平发生变化(高电平到低电平,或者低电平到高电平)的时候才触发通知.上面提到即使有数据可读,但是io状态没有变化epoll也不会立即返回.

​ epoll既可以采用水平触发,也可以采用边缘触发.

EPOLL事件有两种模型:
Edge Triggered (ET) 边缘触发只有数据到来,才触发,不管缓存区中是否还有数据。
Level Triggered (LT) 水平触发只要有数据都会触发。

首先介绍一下LT工作模式:

LT(level triggered)是缺省的工作方式,并且同时支持block和no-block socket.在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的,所以,这种模式编程出错误可能性要小一点。传统的select/poll都是这种模型的代表.

优点:当进行socket通信的时候,保证了数据的完整输出,进行IO操作的时候,如果还有数据,就会一直的通知你。

缺点:由于只要还有数据,内核就会不停的从内核空间转到用户空间,所有占用了大量内核资源,试想一下当有大量数据到来的时候,每次读取一个字节,这样就会不停的进行切换。内核资源的浪费严重。效率来讲也是很低的。

ET:

ET(edge-triggered)是高速工作方式,只支持no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知。请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once).

优点:每次内核只会通知一次,大大减少了内核资源的浪费,提高效率。

缺点:不能保证数据的完整。不能及时的取出所有的数据。

  1. 网络IO模型?什么是多路复用IO?select和epoll的差别?select具体过程?

更多关于epoll

epoll是Linux内核为了处理大量句柄而做出相对于poll的改进,是linux下IO接口select、poll的增强版本,它能显著的较少程序在大量并发接口中有少量活跃链接情况下系统CPU的利用率。

epoll的优点

  1. 支持进程打开大数目的socket描述符
  2. IO数据不随FD数目而线性下降(传统的selcet、poll每次调用都会线性扫描全部结合,导致效率下降)
  3. 使用mmap加速内核与用户空间的消息传递,无论是哪种方法都需要内核把FD消息通知给用户空间,如何避免不必要的内存拷贝很重要,epoll使用了内核、用户公用内存实现。

select 和 poll 的缺点

  1. 每次调用都要重复读取参数
  2. 每次调用重复扫描文件描述符
  3. 每次开始时放入等待队列,调用结束后再从队列中删除。