五种IO模型及Epoll

2021/02/04 操作系统
如果文章图片无法显示,可尝试配置host:修复Github图片资源无法显示问题
  • unix提供的IO模型有几种,分别有哪些?
  • 高并发IO底层原理?
  • 各种IO模型的特点是什么?他们有什么区别?
  • 阻塞,非阻塞,同步,异步的区别?
  • epoll为什么高效?

数据输入操作步骤

普通输入操作包含的步骤

  • 等待数据准备好
  • 从内核向进程复制数据

网络数据输入包含的步骤

  • 等待数据从网络送达,到达后被复制到内核缓冲区
  • 把数据从内核缓冲区复制到应用程序缓冲区

五种IO模型

img

阻塞式IO

  • 使用系统调用,并一直阻塞直到内核将数据准备好,之后再由内核缓冲区复制到用户态,在等待内核准备的这段时间什么也干不了
  • 下图函数调用期间,一直被阻塞,直到数据准备好且从内核复制到用户程序才返回,这种IO模型为阻塞式IO
  • 阻塞式IO式最流行的IO模型

image.png

image.png

优缺点

优点:开发简单,容易入门;在阻塞等待期间,用户线程挂起,在挂起期间不会占用CPU资源。

缺点:一个线程维护一个IO,不适合大并发,在并发量大的时候需要创建大量的线程来维护网络连接,内存、线程开销非常大。

非阻塞式IO

  • 内核在没有准备好数据的时候会返回错误码,而调用程序不会休眠,而是不断轮询询问内核数据是否准备好
  • 下图函数调用时,如果数据没有准备好,不像阻塞式IO那样一直被阻塞,而是返回一个错误码。数据准备好时,函数成功返回。
  • 应用程序对这样一个非阻塞描述符循环调用成为轮询。
  • 非阻塞式IO的轮询会耗费大量cpu,通常在专门提供某一功能的系统中才会使用。通过为套接字的描述符属性设置非阻塞式,可使用该功能

image.png

优缺点

同步非阻塞IO优点:每次发起IO调用,在内核等待数据的过程中可以立即返回,用户线程不会阻塞,实时性较好。

同步非阻塞IO缺点:多个线程不断轮询内核是否有数据,占用大量CPU时间,效率不高。一般Web服务器不会采用此模式。

多路复用IO

  • 类似与非阻塞,只不过轮询不是由用户线程去执行,而是由内核去轮询,内核监听程序监听到数据准备好后,调用内核函数复制数据到用户态
  • 下图中select这个系统调用,充当代理类的角色,不断轮询注册到它这里的所有需要IO的文件描述符,有结果时,把结果告诉被代理的recvfrom函数,它本尊再亲自出马去拿数据
  • IO多路复用至少有两次系统调用,如果只有一个代理对象,性能上是不如前面的IO模型的,但是由于它可以同时监听很多套接字,所以性能比前两者高image

image.png

  • 多路复用包括:

    • select:线性扫描所有监听的文件描述符,不管他们是不是活跃的。有最大数量限制(32位系统1024,64位系统2048)
    • poll:同select,不过数据结构不同,需要分配一个pollfd结构数组,维护在内核中。它没有大小限制,不过需要很多复制操作
    • epoll:用于代替poll和select,没有大小限制。使用一个文件描述符管理多个文件描述符,使用红黑树存储。同时用事件驱动代替了轮询。epoll_ctl中注册的文件描述符在事件触发的时候会通过回调机制激活该文件描述符。epoll_wait便会收到通知。最后,epoll还采用了mmap虚拟内存映射技术减少用户态和内核态数据传输的开销

优缺点** **

IO多路复用优点:系统不必创建维护大量线程,只使用一个线程,一个选择器即可同时处理成千上万个连接,大大减少了系统开销。

IO多路复用缺点:本质上,select/epoll系统调用是阻塞式的,属于同步IO,需要在读写事件就绪后,由系统调用进行阻塞的读写。

信号驱动式IO

  • 使用信号,内核在数据准备就绪时通过信号来进行通知
  • 首先开启信号驱动io套接字,并使用sigaction系统调用来安装信号处理程序,内核直接返回,不会阻塞用户态
  • 数据准备好时,内核会发送SIGIO信号,收到信号后开始进行io操作image

异步IO

  • 异步IO依赖信号处理程序来进行通知
  • 不过异步IO与前面IO模型不同的是:前面的都是数据准备阶段的阻塞与非阻塞,异步IO模型通知的是IO操作已经完成,而不是数据准备完成
  • 异步IO才是真正的非阻塞,主进程只负责做自己的事情,等IO操作完成(数据成功从内核缓存区复制到应用程序缓冲区)时通过回调函数对数据进行处理
  • unix中异步io函数以aio_或lio_打头image

优缺点

异步IO优点:真正实现了异步非阻塞,吞吐量在这几种模式中是最高的。

异步IO缺点:应用程序只需要进行事件的注册与接收,其余工作都交给了操作系统内核,所以需要内核提供支持。在Linux系统中,异步IO在其2.6才引入,目前也还不是灰常完善,其底层实现仍使用epoll,与IO多路复用相同,因此在性能上没有明显占优

五种IO模型对比

  • 前面四种IO模型的主要区别在第一阶段,他们第二阶段是一样的:数据从内核缓冲区复制到调用者缓冲区期间都被阻塞住!
  • 前面四种IO都是同步IO:IO操作导致请求进程阻塞,直到IO操作完成
  • 异步IO:IO操作不导致请求进程阻塞image

Epoll详解

  • 与select,poll一样,对I/O多路复用的技术
  • 只关心“活跃”的链接,无需遍历全部描述符集合
  • 能够处理大量的链接请求(系统可以打开的文件数目)

epoll就是一种事件通知机制。用于高I/O并发程序。

epoll是一种当文件描述符的内核缓冲区非空的时候,发出可读信号进行通知,当写缓冲区不满的时候,发出可写信号通知的机制

(1)创建EPOLL

/**
 * @param size 告诉内核监听的数目
 *
 * @returns 返回一个epoll句柄(即一个文件描述符)
 */
int epoll_create(int size);

使用

int epfd = epoll_create(1000);

image

创建一个epoll句柄,实际上是在内核空间,建立一个root根节点,这个根节点的关系与epfd相对应。

(2)控制epoll

/**
* @param epfd 用epoll_create所创建的epoll句柄
* @param op 表示对epoll监控描述符控制的动作
** EPOLL_CTL_ADD(注册新的fd到epfd)
* EPOLL_CTL_MOD(修改已经注册的fd的监听事件)
* EPOLL_CTL_DEL(epfd删除一个fd)
** @param fd 需要监听的文件描述符
* @param event 告诉内核需要监听的事件
** @returns 成功返回0,失败返回-1, errno查看错误信息
*/
int epoll_ctl(int epfd, int op, int fd,
struct epoll_event *event);
struct epoll_event {
    __uint32_t events; /* epoll 事件 */
    epoll_data_t data; /* 用户传递的数据 */
}
/*
 * events : {EPOLLIN, EPOLLOUT, EPOLLPRI,
                         EPOLLHUP, EPOLLET, EPOLLONESHOT}
 */
typedef union epoll_data {
    void *ptr;
    int fd;
    uint32_t u32;
    uint64_t u64;
} epoll_data_t;

使用

struct epoll_event new_event;
new_event.events = EPOLLIN | EPOLLOUT;
new_event.data.fd = 5;
epoll_ctl(epfd, EPOLL_CTL_ADD, 5, &new_event);

创建一个用户态的事件,绑定到某个fd上,然后添加到内核中的epoll红黑树中。

image

(3)等待EPOLL

/**
** @param epfd 用epoll_create所创建的epoll句柄
* @param event 从内核得到的事件集合
* @param maxevents 告知内核这个events有多大,
* 注意: 值 不能大于创建epoll_create()时的size.
* @param timeout 超时时间
* -1: 永久阻塞
* 0: 立即返回,非阻塞
* >0: 指定微秒
** @returns 成功: 有多少文件描述符就绪,时间到时返回0
* 失败: -1, errno 查看错误
*/
int epoll_wait(int epfd, struct epoll_event *event,
                             int maxevents, int timeout);

使用

struct epoll_event my_event[1000];
int event_cnt = epoll_wait(epfd, my_event, 1000, -1);

epoll_wait是一个阻塞的状态,如果内核检测到IO的读写响应,会抛给上层的epoll_wait, 返回给用户态一个已经触发的事件队列,同时阻塞返回。开发者可以从队列中取出事件来处理,其中事件里就有绑定的对应fd是哪个(之前添加epoll事件的时候已经绑定)。

image

(4) 使用epoll编程主流程骨架

int epfd = epoll_crete(1000);
//将 listen_fd 添加进 epoll 中
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd,&listen_event);
while (1) {
    //阻塞等待 epoll 中 的fd 触发
    int active_cnt = epoll_wait(epfd, events, 1000, -1);
    for (i = 0 ; i < active_cnt; i++) {
        if (evnets[i].data.fd == listen_fd) {
            //accept. 并且将新accept 的fd 加进epoll中.
        }
        else if (events[i].events & EPOLLIN) {
            //对此fd 进行读操作
        }
        else if (events[i].events & EPOLLOUT) {
            //对此fd 进行写操作
        }
    }
}

(5)epoll的触发模式

水平触发

image

image

水平触发的主要特点是,如果用户在监听epoll事件,当内核有事件的时候,会拷贝给用户态事件,但是如果用户只处理了一次,那么剩下没有处理的会在下一次epoll_wait再次返回该事件。

这样如果用户永远不处理这个事件,就导致每次都会有该事件从内核到用户的拷贝,耗费性能,但是水平触发相对安全,最起码事件不会丢掉,除非用户处理完毕。

边缘触发

image

image

边缘触发,相对跟水平触发相反,当内核有事件到达, 只会通知用户一次,至于用户处理还是不处理,以后将不会再通知。这样减少了拷贝过程,增加了性能,但是相对来说,如果用户马虎忘记处理,将会产生事件丢的情况。

参考资料


公众号:豆仔gogo

golang、算法、后端知识、面试手册

豆仔gogo

Search

    公众号:豆仔gogo

    豆仔gogo

    Post Directory