什么是 I/O 多路复用

把标准输入、套接字等都看做 I/O 的一路,多路复用的意思,就是在任何一路 I/O 有“事件”发生的情况下,通知应用程序去处理相应的 I/O 事件。这样就可以同时监听多个 I/O 事件。

select

介绍

int select(int maxfd, fd_set *readset, fd_set *writeset, fd_set *exceptset, const struct timeval *timeout);

  • 返回值:若有就绪描述符则为其数目,若超时则为0,若出错则为-1
  • maxfd
    • bitmap中待监听的操作符个数
    • 该值为最大fd+1,原因为fd从0开始,故操作符个数为最大的fd+1
  • readset读描述符集合,writeset写描述符集合
    • set类似bitmap,index为fd,value为0or1, 0 代表不需要处理,1 代表需要处理
    • 实际用一个整型数组来表示一个描述字集合的,一个 32 位的整型数可以表示 32 个描述字,例如第一个整型数表示 0-31 描述字,第二个整型数可以表示 32-63 描述字
    • 默认最大值为 1024,即32个32位整型数组数组
  • exceptset异常描述符集合 exceptset
  • timeout
    • 设置成空 (NULL),表示如果没有 I/O 事件发生,则 select 一直等待下去。
    • 设置一个非零的值,这个表示等待固定的一段时间后从 select 阻塞调用中返回
    • 将 timeval的tv_sec 和 tv_usec 都设置成 0,表示根本不等待,检测完毕立即返回。这种情况使用得比较少

示例

int main(int argc, char **argv) {
    if (argc != 2) {
        error(1, 0, "usage: select01 <IPaddress>");
    }
    int socket_fd = tcp_client(argv[1], SERV_PORT);
    char recv_line[MAXLINE], send_line[MAXLINE];
    int n;
    fd_set readmask;
    fd_set allreads;
    FD_ZERO(&allreads);
    FD_SET(0, &allreads);
    FD_SET(socket_fd, &allreads);
    for (;;) {
        readmask = allreads;
        int rc = select(socket_fd + 1, &readmask, NULL, NULL, NULL);
        if (rc <= 0) {
            error(1, errno, "select failed");
        }
        if (FD_ISSET(socket_fd, &readmask)) {
            n = read(socket_fd, recv_line, MAXLINE);
            if (n < 0) {
                error(1, errno, "read error");
            } else if (n == 0) {
                error(1, 0, "server terminated \n");
            }
            recv_line[n] = 0;
            fputs(recv_line, stdout);
            fputs("\n", stdout);
        }
        if (FD_ISSET(STDIN_FILENO, &readmask)) {
            if (fgets(send_line, MAXLINE, stdin) != NULL) {
                int i = strlen(send_line);
                if (send_line[i - 1] == '\n') {
                    send_line[i - 1] = 0;
                }
                printf("now sending %s\n", send_line);
                size_t rt = write(socket_fd, send_line, strlen(send_line));
                if (rt < 0) {
                    error(1, errno, "write failed ");
                }
                printf("send bytes: %zu \n", rt);
            }
        }
    }
}

  • 方法介绍
    • FD_ZERO 用来将这个向量的所有元素都设置成 0;
    • FD_SET 用来把对应套接字 fd 的元素,a[fd]设置成 1;
    • FD_CLR 用来把对应套接字 fd 的元素,a[fd]设置成 0;
    • FD_ISSET 对这个向量进行检测,判断出对应套接字的元素 a[fd]是 0 还是 1。
  • 代码介绍
    • 通过 FD_ZERO 初始化了一个描述符集合,这个描述符读集合是空的
    • 使用 FD_SET 将描述符 0,即标准输入,以及连接套接字描述符 3 设置为待检测
    • readmask = allreads在调用select前需要重新赋值readmask,因为select方法会修改readmask的值
    • 通过 select 来检测套接字描述字有数据可读,或者标准输入有数据可读。
      • 比如,当用户通过标准输入使得标准输入描述符可读时,返回的 readmask 的值为索引位置0上的值为1
    • select 调用返回,可以使用 FD_ISSET 来判断哪个描述符准备好可读了
      • FD_ISSET(STDIN_FILENO, &readmask)标准输入可读,程序读入后发送给对端
      • 连接描述字准备好可读了,使用 read 将套接字数据读出

标准输入温习:每个程序的0~2操作符分别为标准输入、标准输出及错误输出,我们在启动一个前台程序时,它们三个均为当前线程所在屏幕,当启动后台线程时,我们可以通过 > 修改标准输出至文件

poll

介绍

int poll(struct pollfd *fds, unsigned long nfds, int timeout); 

struct pollfd {
    int    fd;       /* file descriptor */
    short  events;   /* events to look for */
    short  revents;  /* events returned */
};
  • 和 select 相比,它和内核交互的数据结构有所变化
  • 突破了文件描述符的个数限制
  • 返回值:若有就绪描述符则为其数目,若超时则为0,若出错则为-1
    • 当有错误发生时,poll 函数的返回值为 -1;
    • 如果在指定的时间到达之前没有任何事件发生则返回 0,否则就返回检测到的事件个数,也就是“returned events”中非 0 的描述符个数。
  • 第一个参数是pollfd 的数组
    • 描述符 fd
    • 描述符上待检测的事件类型 events,注意这里的 events 可以表示多个不同的事件,具体的实现可以通过使用二进制掩码位操作来完成
    • 和 select 非常不同的地方在于,poll 每次检测之后的结果不会修改原来的传入值,而是将结果保留在 revents 字段中,这样就不需要每次检测完都得重置待检测的描述字和感兴趣的事件。我们可以把 revents 理解成“returned events”。
    • revents值为0 or 1,0表示该fd未监听到事件,不为0时是通过掩码位操作的方式区分是可写事件还是可读事件
  • 参数 nfds 描述的是数组 fds 的大小,简单说,就是向 poll 申请的事件检测的个数。
  • timeout,描述了 poll 的行为。
    • 如果是一个 <0 的数,表示在有事件发生之前永远等待;
    • 如果是 0,表示不阻塞进程,立即返回;
    • 如果是一个 >0 的数,表示 poll 调用方等待指定的毫秒数后返回。

epoll

epoll方法

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_create() 方法创建了一个 epoll 实例
    • 返回值: 若成功返回一个大于0的值,表示epoll实例;若返回-1表示出错
  • epoll_ctl
    • 在创建完 epoll 实例之后,可以通过调用 epoll_ctl 往这个 epoll 实例增加或删除监控的事件
    • 返回值: 若成功返回0;若返回-1表示出错
    • 第一个参数 epfd 是调用 epoll_create 创建的 epoll 实例描述字,可以简单理解成是 epoll 句柄。
    • 第二个参数表示增加还是删除一个监控事件,它有三个选项可供选择:
      • EPOLL_CTL_ADD: 向 epoll 实例注册文件描述符对应的事件;
      • EPOLL_CTL_DEL:向 epoll 实例删除文件描述符对应的事件;
      • EPOLL_CTL_MOD: 修改文件描述符对应的事件。
    • 第三个参数是注册的事件的文件描述符,比如一个监听套接字。
    • 第四个参数表示的是注册的事件类型,并且可以在这个结构体里设置用户需要的数据,其中最为常见的是使用联合结构里的 fd 字段,表示事件所对应的文件描述符。
  • epoll_wait
    • 返回值: 成功返回的是一个大于0的数,表示事件的个数;返回0表示的是超时时间到;若出错返回-1.
    • epoll_wait() 函数类似之前的 poll 和 select 函数,调用者进程被挂起,在等待内核 I/O 事件的分发。
    • 这个函数的第一个参数是 epoll 实例描述字,也就是 epoll 句柄。
    • 第二个参数返回给用户空间需要处理的 I/O 事件,这是一个数组
      • 数组的大小由 epoll_wait 的返回值决定
      • 这个数组的每个元素都是一个需要待处理的 I/O 事件
      • 其中 events 表示具体的事件类型,事件类型取值和 epoll_ctl 可设置的值一样
      • 这个 epoll_event 结构体里的 data 值就是在 epoll_ctl 那里设置的 data,也就是用户空间和内核空间调用时需要的数据。
    • 第三个参数是一个大于 0 的整数,表示 epoll_wait 可以返回的最大事件值。
    • 第四个参数是 epoll_wait 阻塞调用的超时值,如果这个值设置为 -1,表示不超时;如果设置为 0 则立即返回,即使没有任何 I/O 事件发生

总结

poll&select的区别

  • 支持fd数量的区别
    • 在 select 里面,文件描述符的个数已经随着 fd_set 的实现而固定,没有办法对此进行配置
    • poll 函数里,我们可以控制 pollfd 结构的数组大小,这意味着我们可以突破原来 select 函数最大描述符的限制,在这种情况下,应用程序调用者需要分配 pollfd 数组并通知 poll 函数该数组的大小
  • 参数格式区别
    • 在select中直接修改传入的readmask,故每次调用select前需要重新初始化readmask
    • 在poll中由revents返回响应的事件

epoll和poll、select的区别

  • 事件集合
    在每次使用 poll 或 select 之前,都需要准备一个感兴趣的事件集合,系统内核拿到事件集合,进行分析并在内核空间构建相应的数据结构来完成对事件集合的注册。而 epoll 则不是这样,epoll 维护了一个全局的事件集合,通过 epoll 句柄,可以操纵这个事件集合,增加、删除或修改这个事件集合里的某个元素。要知道在绝大多数情况下,事件集合的变化没有那么的大,这样操纵系统内核就不需要每次重新扫描事件集合,构建内核空间数据结构。
  • 就绪列表
    每次在使用 poll 或者 select 之后,应用程序都需要扫描整个感兴趣的事件集合,从中找出真正活动的事件,这个列表如果增长到 10K 以上,每次扫描的时间损耗也是惊人的。事实上,很多情况下扫描完一圈,可能发现只有几个真正活动的事件。而 epoll 则不是这样,epoll 返回的直接就是活动的事件列表,应用程序减少了大量的扫描时间。
  • 触发模式
    epoll是交由内核维护状态,故内核会记录该事件是否已触发,故epoll支持条件触发和边缘触发两种模式。条件触发的意思是只要满足事件的条件,比如有数据需要读,就一直不断地把这个事件传递给用户;而边缘触发的意思是只有第一次满足条件的时候才触发,之后就不会再传递同样的事件了

举例说明如果某个套接字有 100 个字节可以读,边缘触发(edge-triggered)和条件触发(level-triggered)都会产生 read ready notification 事件,如果应用程序只读取了 50 个字节,边缘触发就会陷入等待;而条件触发则会因为还有 50 个字节没有读取完,不断地产生 read ready notification 事件。

在条件触发下(level-triggered),如果某个套接字缓冲区可以写,会无限次返回 write ready notification 事件,在这种情况下,如果应用程序没有准备好,不需要发送数据,一定需要解除套接字上的 ready notification 事件,否则 CPU 就直接跪了。
我们简单地总结一下,边缘触发只会产生一次活动事件,性能和效率更高。不过,程序处理起来要更为小心。