0%

Linux系统编程-I/O多路复用


专注于用户空间的系统级编程–即内核之上的所有内容。

I/O多路复用支持应用同时在多个文件描述符上阻塞,并在其中某个可以读写使收到通知。
在设计上遵循以下原则:

  • I/O多路复用:当任何一个文件描述符I/O就绪时进行通知
  • 都不可用?在有可用文件描述符之前一直处于睡眠状态
  • 唤醒:某个文件描述符可用?
  • 处理所有I/O就绪的文件描述符,没有阻塞
  • 返回1,重新开始

Linux提供了三种I/O多路复用方案:select、poll和epoll。首先讨论前两种,
epoll是Linux内核为处理大批量文件描述符而作了改进的poll,
是Linux下多路复用IO接口select/poll的增强版本,
它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率。

select

select()函数是4.2 BSD UNIX中引入的系统调用,其原型为:

int select(int numfds, 
            fd_set *readfds, 
            fd_set *writefds, 
            fd_set *exceptfds, 
            struct timeval *timeout);

其中readfds/writefds/exceptfds分别是select()监听的读/写和异常处理的文件描述符集合,
numfds的值是需要检查的最大的文件描述符加1。
timeout参数是一个指向struct timeval类型的指针,
它可以使select()在等待timeout时间后若没有文件描述符准备好则返回。

对于后四个参数来说如果不需要设置相关内容,传递NULL即可。

内核提供了以下宏用于监听描述符集合的设置与检查

FD_ZERO(fd_set *fdset)          //清除文件描述符集合
FD_SET(int fd, fd_set *fdset)   //将一个文件描述符添加到文件描述符集合中
FD_CLR(int fd, fd_set *fdset)   //将一个文件描述符从文件描述符集合中移除
FD_ISSET(int fd, fd_set *fdset) //判断文件描述符是否被置位

当应用程序使用FD_ZERO/FD_SET/FD_CLR宏设置好要监听的文件描述符集合后,
调用select()函数执行监听,如果没有一个描述符准备好IO并且没有指定超时时间,
那么select()函数会一直等待下去不会返回。
如果没有一个描述符处于I/O就绪状态并且指定了超时时间,那么在超时时间之后,
select()函数会返回,返回时,在不同的UNIX系统中,该结构体是未定义的,
因此每次调用必须(和文件描述符集一起)重新初始化

当函数正常返回后,监听的文件描述符集合中没有准备好的文件描述符会被删除,
只剩下已经准备好的文件描述符,之后可以使用FD_ISSET(fd, set)宏来判断set集合中是否有fd文件描述符来判断fd是否准备好IO。

示例代码:

#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<sys/types.h>
#include<sys/socket.h>
#include<netinet/in.h>
#include<arpa/inet.h>

#define MAX_LISTEN 5
#define PORT 1987
#define IP "127.0.0.1"

int main()
{
    int conn_fd;
    int sock_fd = socket(AF_INET,SOCK_STREAM,0);
    if (sock_fd < 0) {
        perror("create socket failed");
        exit(1);
    }

    struct sockaddr_in addr_client;
    int client_size = sizeof(struct sockaddr_in);

    struct sockaddr_in addr_serv;
    memset(&addr_serv, 0, sizeof(addr_serv));
    addr_serv.sin_family = AF_INET;
    addr_serv.sin_port = htons(PORT);
    addr_serv.sin_addr.s_addr = inet_addr(IP);

    if (bind(sock_fd,(struct sockaddr *)&addr_serv,sizeof(struct sockaddr_in)) < 0) {
        perror("bind error");
        exit(1);
    }

    if (listen(sock_fd,MAX_LISTEN) < 0) {
        perror("listen failed");
        exit(1);
    }

    int recv_num;
    int send_num;
    char recv_buf[100];
    char send_buf[100]; 

    //用一个数组记录描述符的状态
    int i, ready, max_fd;
    int client[FD_SETSIZE];
    for (i = 0;i < FD_SETSIZE;i ++) {
        client[i] = -1;
    }

    fd_set readset;
    max_fd = sock_fd;

    //最大可用描述符的个数,一般受操作系统内核的设置影响,环境下这个值是1024
    printf("max fd num %d\n",FD_SETSIZE);

    while (1) {

        //重置监听的描述符
        FD_ZERO(&readset);
        FD_SET(sock_fd,&readset);
        for (i = 0;i < FD_SETSIZE;i ++) {
            if (client[i] == 1) {
                FD_SET(i, &readset);
            }
        }

        //开始监听描述符,是异步的,不会阻塞
        ready = select(max_fd+1, &readset, NULL, NULL, NULL);

        //可用描述符如果是创建连接描述符,则创建一个新的连接
        if (FD_ISSET(sock_fd, &readset)) {
            conn_fd = accept(sock_fd, (struct sockaddr *)&addr_client, &client_size);
            if (conn_fd < 0) {
                perror("accept failed");
                exit(1);
            }

            FD_SET(conn_fd, &readset);
            FD_CLR(sock_fd, &readset);

            if (conn_fd > max_fd) {
                max_fd = conn_fd;
            }
            client[conn_fd] = 1;    
        }

        //检查所有的描述符,查看可读的是哪个,针对它进行IO读写
        for (i = 0; i < FD_SETSIZE; i ++) {
            if (FD_ISSET(i, &readset)) {

                recv_num = recv(i, recv_buf, sizeof(recv_buf), 0);
                if (recv_num <= 0) {
                    FD_CLR(i, &readset);
                    client[i] = -1;
                }
                recv_buf[recv_num] = '\0';
                memset(send_buf,0,sizeof(send_buf));
                sprintf(send_buf, "server proc got %d bytes\n", recv_num);
                send_num = send(i, send_buf, strlen(send_buf), 0);
                if (send_num <= 0) {
                    FD_CLR(i, &readset);
                    client[i] = -1;
                }
            }
        }
    }

    close(sock_fd);
    return 0;
}

poll

poll()函数是System V中引入的系统调用,其原型为:

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

select()使用了基于文件描述符的三位掩码的解决方案,效率不高;
poll()使用了有nfds个pollfd结构体构成的数组,fds指针指向该数组。
pollfd结构体定义如下:

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

每一个pollfd结构体指定了一个被监视的文件描述符,
可以传递多个结构体,指示poll()监视多个文件描述符。
每个结构体的events域是监视该文件描述符的事件掩码,
由用户来设置这个域。
revents域是文件描述符的操作结果事件掩码,内核在调用返回时设置这个域。

events域中请求的任何事件都可能在revents域中返回。合法的事件如下:

POLLIN        有数据可读。
POLLRDNORM        有普通数据可读。
POLLRDBAND        有优先数据可读。
POLLPRI        有紧迫数据可读。
POLLOUT        写数据不会导致阻塞。
POLLWRNORM        写普通数据不会导致阻塞。
POLLWRBAND        写优先数据不会导致阻塞。
POLLMSGSIGPOLL        消息可用。

此外,revents域中还可能返回下列事件:

POLLER        指定的文件描述符发生错误。
POLLHUP        指定的文件描述符挂起事件。
POLLNVAL        指定的文件描述符非法。

这些事件在events域中无意义,因为它们在合适的时候总是会从revents中返回。
使用poll()和select()不一样,你不需要显式地请求异常情况报告。

POLLIN | POLLPRI等价于select()的读事件,POLLOUT |POLLWRBAND等价于select()的写事件。
POLLIN等价于POLLRDNORM |POLLRDBAND,而POLLOUT则等价于POLLWRNORM。

例如,要同时监视一个文件描述符是否可读和可写,
我们可以设置 events为POLLIN |POLLOUT。
在poll返回时,我们可以检查revents中的标志,
对应于文件描述符请求的events结构体。
如果POLLIN事件被设置,则文件描述符可以被读取而不阻塞。
如果POLLOUT被设置,则文件描述符可以写入而不导致阻塞。
这些标志并不是互斥的:它们可能被同时设置,
表示这个文件描述符的读取和写入操作都会正常返回而不阻塞。

timeout参数指定等待的毫秒数,无论I/O是否准备好,poll都会返回。
timeout指定为负数值表示无限超时,使poll()一直挂起直到一个指定事件发生;
timeout为0指示poll调用立即返回并列出准备好I/O的文件描述符,
但并不等待其它的事件。这种情况下,poll()就像它的名字那样,一旦选举出来,立即返回。

返回值和错误代码

成功时,poll()返回结构体中revents域不为0的文件描述符个数;
如果在超时前没有任何事件发生,poll()返回0;
失败时,poll()返回-1,并设置errno为下列值之一:

EBADF         一个或多个结构体中指定的文件描述符无效。 
EFAULTfds   指针指向的地址超出进程的地址空间。 
EINTR      请求的事件之前产生一个信号,调用可以重新发起。 
EINVALnfds  参数超出PLIMIT_NOFILE值。 
ENOMEM       可用内存不足,无法完成请求。

示例代码:

#include <stdio.h>
#include <unistd.h>
#include <poll.h>

#define TIMEOUT 5

int main(void)
{
    struct pollfd fds[2];
    int ret;

    //watch stdin for input
    fds[0].fd = STDIN_FILENO;
    fds[0].events = POLLIN;

    //watch stdout for ability to write
    fds[1].fd = STDOUT_FILENO;
    fds[1].events = POLLOUT;

    ret = poll(fds, 2, TIMEOUT*1000);
    if(-1 == ret) {
        perror("poll");
        return 1;
    }

    if(!ret) {
        printf("%d seconds elapsed.\n", TIMEOUT);
        return 0;
    }

    if(fds[0].revents & POLLIN)
        printf("stdin is readable\n");

    if(fds[1].revents & POLLOUT)
        printf("stdout is writable\n");

    return 0;
}

在实际应用中,不需要在每次调用时都重新构建pollfd结构体。
该结构体会被重复传递多次,内核会在必要时把revents清空。

poll()和select()区别

select()和poll()函数本质上没有多大差别,
管理多个描述符也是进行轮询,根据描述符的状态进行处理,
但是poll没有最大文件描述符数量的限制。
并且select()返回后,之前没有准备好的文件描述符会从集合当中删除,
这样如果下次需要再次添加所有文件描述符或者使用两个相同的文件描述符集合,
一个用于备份,一个用于监听,比较复杂。poll不需要这个复杂的操作,不需要重新构建pollfd结构体。
poll和select同样存在一个缺点就是包含大量文件描述符的数组被整体复制于用户态和内核的地址空间之间,
而无论这些文件描述符是否就绪。
它的开销随着文件描述符数量的增加而线性增加。