欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

I/O 复用:select 、poll 和 epoll 函数

程序员文章站 2022-06-14 11:25:51
...

1. 简介

       I/O指数据在内存和外部设备(磁盘、网卡、键盘等)之间来回复制的一个过程。

       我们以数据由外部设备复制到内存为例,其经历如下两个过程:

  1. 等待数据准备好
  2. 从内核向进程复制数据

       对于一个套接字上的输入操作,第一步通常涉及等待数据从网络中到达。当所等待分组到达时,它被复制到内核中的某个缓冲区。第二步就是把数据从内核缓冲区复制到应用进程缓冲区。

2. I/O 模型

2.1 阻塞式 I/O

       最流行的I/O模型是阻塞式I/O (blocking I/O)模型。默认情形下,所有套接字都是阻塞的。

       我们把recvfrom函数视为系统调用,因为我们正在区分应用进程和内核。不论它如何实现,一般都会从在应用进程空间中运行切换到在内核空间中运行,一段时间之后再切换回来。

       在下图中,进程调用recvfrom,其系统调用直到数据报到达且被复制到应用进程的缓冲区中或者发生错误才返回。最常见的错误是系统调用被信号中断。进程在从调用recvfrom开始到它返回的整段时间内是被阻塞的。recvfrom成功返回后,应用进程开始处理数据报。

I/O 复用:select 、poll 和 epoll 函数

2.2 非阻塞式 I/O

       进程把一个套接字设置成非阻塞是在通知内核:当所请求的I/O操作非得把本进程投入睡眠才能完成时(此时数据还没有准备好,理论上应该让其睡眠等待数据准备好),不要把本进程投入睡眠,而是返回一个立即错误。

I/O 复用:select 、poll 和 epoll 函数

       前三次调用recvfrom时没有数据可返回,因此内核转而立即返回一个EWOULDBLOCK错误。

       第四次调用recvfrom时已有一个数据报准备好,它被复制到应用进程缓冲区,于是recvfrom成功返回。我们接着处理数据。

       当一个应用进程像这样对一个非阻塞描述符循环调用recvfrom时,我们称之为轮询(polling)。应用进程持续轮询内核,以查看某个操作是否就绪。这么做往往耗费大量CPU时间,不过这种模型偶尔也会遇到,通常是在专门提供某-种功能的系统中才有。

2.3 I/O 复用 (select、poll、epoll)

       有了IO复用(I/O multiplexing),我们就可以调用selectpoll,阻塞在这两个系统调用中的某一个之上,而不是阻塞在真正的I/O系统调用上。

I/O 复用:select 、poll 和 epoll 函数
       我们阻塞于select调用,等待数据报套接字变为可读。当select返回套接字可读这一条件时,我们调用recvfrom把所读数据报复制到应用进程缓冲区。

       比较图6-3和图6-1I/O复用并不显得有什么优势,事实上由于使用select需要两个而不是单个系统调用,I/O复用还稍有劣势。使用select的优势在于我们可以等待多个描述符就绪。

       与I/O复用密切相关的另一种I/O模型是在多线程中使用阻塞式I/O。这种模型与上述模型极为相似,但它没有使用select阻塞在多个文件描述符上,而是使用多个线程(每个文件描述符一个线程),这样每个线程都可以*地调用诸如recvfrom之类的阻塞式I/O系统调用了。

2.4 信号驱动式 I/O (SIGIO)

       我们也可以用信号,让内核在描述符就绪时发送SIGIO信号通知我们。我们称这种模型为信号驱动式I/O ( signal-driven I/O)。

I/O 复用:select 、poll 和 epoll 函数

       我们首先开启套接字的信号驱动式I/O功能,并通过sigaction系统调用安装一个信号处理函数。该系统调用将立即返回,我们的进程继续工作,也就是说它没有被阻塞。

       当数据报准备好读取时,内核就为该进程产生一个sigIO信号。我们随后既可以在信号处理函数中调用recvfrom读取数据报,并通知主循环数据已准备好待处理,也可以立即通知主循环,让它读取数据报。

       无论如何处理sigIO信号,这种模型的优势在于等待数据报到达期间进程不被阻塞。主循环可以继续执行,只要等待来自信号处理函数的通知:既可以是数据已准备好被处理,也可以是数据报已准备好被读取。

2.5 异步 I/O (AIO)

       告知内核启动某个操作,并让内核在整个操作(包括将数据从内核复制到我们自己的缓冲区)完成后通知我们。

       这种模型与前一节介绍的信号驱动模型的主要区别在于:信号驱动式I/O是由内核通知我们何时可以启动一个I/O操作, 而异步I/O模型是由内核通知我们I/O操作何时完成。

I/O 复用:select 、poll 和 epoll 函数

       我们调用aio_ read函数给内核传递描述符、缓冲区指针、缓冲区大小(与read相同的三个参数)和文件偏移(与lseek类似),并告诉内核当整个操作完成时如何通知我们。

       该系统调用立即返回,而且在等待I/O完成期间,我们的进程不
被阻塞。我们假设要求内核在操作完成时产生某个信号。该信号直到数据已复制到应用进程缓冲区才产生,这一点不同于信号驱动式I/O模型。

2.6 I/O 模型对比

I/O 复用:select 、poll 和 epoll 函数
       可以看出,前4种模型的主要区别在于第一阶段,因为它们的第二阶段是一样的: 在数据从内核复制到调用者的缓冲区期间,进程阻塞于recvfrom调用。相反,异步I/O模型在这两个阶段都要处理,从而不同于其他4种模型。

2.7 同步 I/O 和异步 I/O 对比

       同步I/O操作(synchronous IO opetation)导致请求进程阻塞,直到I/O操作完成;

       异步I/O操作(asynchronous I/O opetation)不导致请求进程阻塞。

3. select 函数

       该函数允许进程指示内核等待多个事件中的任何一个发生,并只在有一个或多个事件发生或经历一段指定的时间后才唤醒它。

       作为一个例子,我们可以调用select,告知内核仅在下列情况发生时才返回:

  • 集合{1,4,5}中的任何描述符准备好读;
  • 集合{2,7}中的任何描述符准备好写;
  • 集合{1,4}中的任何描述符有异常条件待处理;
  • 已经历了10.2秒。

       也就是说,我们调用select告知内核对哪些描述符(就读、写或异常条件)感兴趣以及等待多长时间。

I/O 复用:select 、poll 和 epoll 函数

  • timeout:告知内核等待所指定描述符中的任何个就绪可花多长时间。
  • readsetwritesetexceptset指定我们要让内核测试读、写和异常条件的描述符。
  • maxfdpl:参数指定待测试的描述符个数,它的值是待测试的最大描述符加1 ,描述符0, 1, 2 … 直到maxfdpl - 1均将被测试。

       该函数返回后,我们使用FD_ISSET宏来测试fd_set数据类型中的描述符。描述符集内任何与未就绪描述符对应的位返回时均清成0。为此,每次重新调用select函数时,我们都得再次把所有描述符集内所关心的位均置为1

       最初设计select时,操作系统通常对每个进程可用的最大描述符数设置
了上限(4.2BSD的限制为31),select 就使用相同的限制值。然而当今的Unix版本允许每个进程使用事实上无限数目的描述符(往往仅受限于内存总量和管理性限制)。

I/O 复用:select 、poll 和 epoll 函数
       为了弄清楚到底出了什么差错,上图中内核的FD_SETSIZE定义作为上限使用。因此增大描述符集大小的唯一方法是先增大FD_SETSIZE的值,再重新编译内核。不重新编译内核而改变其值是不够的。

       接下来,我们总结一下select的局限:

  1. 每次套接字返回后,都需要重新对fd_set重新置位
  2. 可监听的套接字个数有限
  3. 并不能直接返回哪个套接字有数据产生

4. poll 函数

       poll提供的功能与select类似,不过在处理流设备时,它能够提供额外的信息。

I/O 复用:select 、poll 和 epoll 函数
       第一个参数是指向一个结构数组第一个元素的指针。每个数组元素都是一个pollfd结构,用于指定测试某个给定描述符fd的条件。

I/O 复用:select 、poll 和 epoll 函数
       要测试的条件由events成员指定,函数在相应的revents成员中返回该描述符的状态。

       timeout参数指定poll函数返回前等待多长时间。

       接下来,我们总结一下poll的相比于select有哪些改进:

  1. 使用结构体数组代替FD_SET,数组的长度很大,足够存储应用进程所需要的套接字
  2. 不需要重置整个数组,只需要将对应套接字的revents重置即可。

5. epoll 函数

       epoll执行与poll类似的任务:监视多个文件描述符以查看其中的任何文件是否可以进行I/O

       epoll实例:

  1. epoll_create创建一个epoll实例并返回引用该实例的文件描述符。
  2. epoll_ctl注册对特定文件描述符的兴趣。
  3. epoll.wait()等待给定epoll实例关联的文件描述符上的事件。
#define MAX_EVENTS 10
struct epoll_event ev, events[MAX_EVENTS];
int listen_sock, conn_sock, nfds, epollfd;

/* Set up listening socket, 'listen_sock' (socket(),
   bind(), listen()) */

epollfd = epoll_create(10);
if (epollfd == -1) {
    perror("epoll_create");
    exit(EXIT_FAILURE);
}

ev.events = EPOLLIN;
ev.data.fd = listen_sock;
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, listen_sock, &ev) == -1) {
    perror("epoll_ctl: listen_sock");
    exit(EXIT_FAILURE);
}

for (;;) {
    nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1);
    if (nfds == -1) {
        perror("epoll_pwait");
        exit(EXIT_FAILURE);
    }

    for (n = 0; n < nfds; ++n) {
        if (events[n].data.fd == listen_sock) {
            conn_sock = accept(listen_sock,
                            (struct sockaddr *) &local, &addrlen);
            if (conn_sock == -1) {
                perror("accept");
                exit(EXIT_FAILURE);
            }
            setnonblocking(conn_sock);
            ev.events = EPOLLIN | EPOLLET;
            ev.data.fd = conn_sock;
            if (epoll_ctl(epollfd, EPOLL_CTL_ADD, conn_sock,
                        &ev) == -1) {
                perror("epoll_ctl: conn_sock");
                exit(EXIT_FAILURE);
            }
        } else {
            do_use_fd(events[n].data.fd);
        }
    }
}

       如上便是epoll的一个使用实例,nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1);返回值表示有事件发生套接字的个数,并且我们直接遍历了[0, nfds)范围内的套接字,为什么不遍历后面的?

       这是因为epoll做了一个内排,将有事件发生的套接字排序到了前面,这样我们不需要遍历整个文件描述符的集合,poll()select()每次调用时都需要所有被监听的文件描述符。内核必须遍历所有被监视的文件描述符。当这个表变得很大时一包含上百,甚至上千个文件描述符时,每次调用时的遍历就成为了明显的瓶颈。

       时间复杂度由O(N)变为O(1)