• Linux I/O复用技术:select, poll, epoll使用区别


    I/O复用简介

    传统的编程模型中,要确定某个文件描述符是否发生关心的事件,需要对其进行轮询。一旦要监听的文件描述符数量众多,可能会导致效率很低。

    I/O 复用技术能有效减少需要轮询的文件描述符数量,将其缩减至1个,即I/O复用的系统调用本身,同时,程序也能监听多个文件描述符。这对提高程序性能很重要。I/O复用本身是阻塞的,并不能让程序并发运行。但I/O复用通过监听文件描述符事件,如果事件就绪,就通知应用程序执行相应的处理流程;如果没有就绪事件,就阻塞在等待事件就绪的一个select/poll/epoll 调用上,而不是每个文件描述符,从而实现并发运行。

    使用I/O复用技术的常见场景:

    • 客户端程序要同时处理多个socket,比如非阻塞connect;
    • 客户端程序要同时处理多个用户输入和网络连接,比如网络聊天室;
    • TCP服务器要同时处理监听socket和连接socket。这是I/O复用使用最多的场合;
    • 服务器要同时处理TCP请求和UDP请求;
    • 服务器要同时监听多个端口,或者处理多种服务,比如xinetd服务器;

    简单来说,I/O复用就是适合应用程序程序要同时处理多个IO事件,而IO事件通常也不是一次性完成的,需要一个过程来完成。

    Linux上,实现I/O复用的系统调用有3个:select、poll、epoll。

    文件描述符就绪

    谈这3个I/O复用前,先了解一下什么是文件描述符就绪。哪些情况下文件描述符就绪?
    所谓文件描述符就绪,是指文件描述符对应文件可读、可写或者出现异常。

    例如,在网络编程中,下列情况下socket可读:
    1)socket 内核接收缓冲区的字节数 >= 低水平位标记SO_RCVLOWAT。此时,可无阻塞read该socket,返回的字节数>0;

    2)socket 通信对端关闭连接。此时,对该socket的read操作将返回0;

    3)监听socket 上有新的连接请求;

    4)socket上有未处理的错误。可使用getsockopt来读取和清除该错误(SO_ERROR);

    下列情况下socket可写:
    1)socket 内核发送缓冲区中可用字节数 >= 低水平位标记SO_SNDLOWAT。此时,可无阻塞地write该socket,返回字节数>0;

    2)socket的写操作被关闭。对写操作被关闭的socket执行写操作,将触发一个SIGPIPE信号;

    3)socket使用非阻塞connect连接成功或失败(超时)之后;

    4)socket上有未处理的错误。此时,可用使用getsockopt来读取和清除该错误(SO_ERROR);

    socket能处理的异常情况只有一种:socket上收到带外数据(out-of-band data)。

    当然,I/O复用不仅用于监听socket,还可以用于监听外部设备,本地管道、消息队列、UNIX Domain Socket(域套接字)、timerfd(Linux特有定时器)、eventfd(Linux特有事件通知)等等有对应fd存在的地方。

    select系统调用

    用途:在一段指定时间内,监听用户感兴趣的文件描述符上的可读、可写和异常事件。(3个文件描述符集合,用数组表示)

    特点:
    1)要设置监听的fd时,分别设置3个集合:readfs、writefd、exceptfds,分别用于监听读事件、写事件、异常事件 这3类事件。
    2)用一组宏定义FD_ZERO/FD_SET/FD_CLR/FD_ISSET,对监听的fd进行操作。
    3)每个while循环里面,都需要为select重新设置监听的3类集合。将fd集合从用户态拷贝到内核态,在监听的fd数量较多时,开销也会比较大。
    4)监听的fd数量有上限限制(默认1024)。这是select使用场景的重要限制。
    5)监听到有就绪事件时,不知道具体是哪个,需要用FD_ISSET对所有fd逐个检测,从而判断具体是哪个fd发生就绪事件。这是select性能相比epoll较低重要原因。

    select函数原型

    /* According to POSIX.1-2001 */
    #include <sys/select.h>
    
    int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
    
    #include <sys/select.h>
    
    int pselect(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, const struct timespec *timeout, const sigset_t *sigmask);
    
    • 参数

    1)ndfs: 指定被监听的文件描述符总数。通常被设置为select监听的所有文件描述符最大值 + 1,因为文件描述符是从0开始计数的。

    2)readfds, writefds, exceptfds: 分别指向可读、可写和异常等事件对于的文件描述符集合。应用程序调用select函数时,通过这3个参数传入自己感兴趣的文件描述符。select调用返回3个集合中文件描述符就绪的总数,由内核修改,用于通知应用程序有就绪事件。这3个参数是fd_set*类型。

    #include <typesizes.h>
    #define __FD_SETSIZE 1024
    
    #include <sys/select.h>
    #define FD_SETSIZE  __FD_SETSIZE
    typedef long int __fd_mask;
    #define __NFDBITS (8 * (int) sizeof(__fd_mask) )       /* 1个long int类型数 8byte, 每1byte 8个bit */
    
    typedef struct {
        __fd_mask fds_bits[__FD_SETSIZE / __NFDBITS];      /* 数组长度(byte数)为 (fd最大值+1) / (8 * sizeof(long int) ) */
    #define __FDS_BITS(set) ((set)->fds_bits)
    } fd_set;
    

    fd_set结构体包含一个整型数组,该数组的每个元素的每个bit位标记一个文件描述符。fd_set能容纳的文件描述符数量由FD_SETSIZE指定,这也限制了select能同时处理的文件描述符的总量。

    由于操作繁琐,可以使用下面一系列宏来访问fd_set结构体中的bit位:

    #include <sys/select.h>
    
    void FD_CLR(int fd, fd_set *set);     /* 清除set的所有位 */
    int  FD_ISSET(int fd, fd_set *set);   /* 设置set的位fd */
    void FD_SET(int fd, fd_set *set);     /* 清除set的位fd */
    void FD_ZERO(fd_set *set);            /* 测试set的位fd是否被设置 */
    

    3)timeout 用于设置select()超时时间。timeval是值-结果参数,内核将修改它,以告诉应用程序select等待了多久。不过,不能完全信任select返回的timeout值,如调用失败时timeout值不确定。

    timeval结构体定义:

    #include <sys/time.h>
    
    struct timeval {
        long    tv_sec;         /* seconds */
        long    tv_usec;        /* microseconds */
    };
    
    • 返回值

    如果timeout.tv_sec和.tv_usec都传递0,则select将立即返回;如果timeout传递NULL,则select将一直阻塞,直到监听的某个文件描述符就绪。
    select成功返回就绪(可读、可写、异常)文件描述符的总数。
    如果在超时时间内,没有任何文件描述符就绪,select将返回0;出错时,返回-1并设置errno;
    如果在select等待期间,程序接收到信号,则select立即返回-1,并设置errno为EINTR。

    select调用模型

    int fd1, fd2, fd3, ..., fdn;
    ...
    int max_fd = fdn; // 要监听的文件描述符中, 数值上最大值, 要求 < 1024 (0..1023)
    struct timeval timeout = {sec, usec}; // select超时时间
    
    while(1) {
        // 重新设置3个集合readfds, writefds, exceptionfds
        FD_SET(fdi1..fdi2, &readfds);
        FD_SET(fdj1..fdj2, &writefds);
        FD_SET(fdk1..fdk2, &exceptionfds);
    
        int n = select(max_fd + 1, readfds, writefds, exceptionfds, &timeout);
        if (n < 0) {
            // error
        }
        else {
            // 逐个检测文件描述符就绪事件, 如果检测到监听的事件发生, 就调用相应的处理事件代码
            if (FD_ISSET(fdi1, readfds)) {
               // 处理事件
            }
            ...
            if (FD_ISSET(fdi2, readfds)) {
                // 处理事件
            }
    
            if (FD_ISSET(fdj1, writefds)) {
                // 处理事件
            }
            ...
            if (FD_ISSET(fdj2, writefds)) {
                // 处理事件
            }
    
            if (FD_ISSET(fdk1, exceptionfds)) {
                // 处理事件
            }
            ...
            if (FD_ISSET(fdk2, exceptionfds)) {
                // 处理事件
            }
        }
    }
    

    示例:处理带外数据

    socket上接收到普通数据和带外数据,都将使select返回,但socket处于不同的就绪状态:前者处于可读状态,后者处于异常状态。下面的程序描述了select如何同时处理二者:

    只展示使用select的核心部分代码,详细代码见Giteeselect_outoufband.c | Gitee地址

    // from https://gitee.com/fortunely/linuxstudy/blob/master/advancedio/select/select_outoufband.c
    
    int main()
    {
        ...
        int connfd = accept(listenfd, ...);
    
        char buf[1024];
        fd_set read_fds;
        fd_set exception_fds;
        FD_ZERO(&read_fds);
        FD_ZERO(&exception_fds);
    
        while (1) {
            memset(buf, '\0', sizeof(buf));
    
            FD_SET(connfd, &read_fds);
            FD_SET(connfd, &exception_fds);
    
            ret = select(connfd + 1, &read_fds, NULL, &exception_fds, NULL); // 有几个事件(fd)就绪, select返回值就是多少
            if (ret < 0) {
                printf("select failure\n");
                break;
            }
            
            // 遍历select监听事件数组, 判断事件是否就绪, 如果就绪, 就处理事件
    
            /* 对于可读事件,采用普通的recv函数读取数据 */
            if (FD_ISSET(connfd, &read_fds)) {
                ret = recv(connfd, buf, sizeof(buf) - 1, 0);
                if (ret <= 0) break;
                buf[ret] = '\0'; /* buf末尾添加null终结符,转化为字符串 */
                printf("get %d bytes of normal data: %s\n", ret, buf);
            }
            /* 对于异常事件,采用MSG_OOB标志的recv函数读取带外数据 */
            else if (FD_ISSET(connfd, &exception_fds)) {
                ret = recv(connfd, buf, sizeof(buf) - 1, MSG_OOB);
                if (ret < 0)
                    break;
                buf[ret] = '\0';
                printf("get %d bytes of oob data: %s\n", ret, buf);
            }
        }
        ...
    }
    

    POLL系统调用

    poll类似于select,也是在指定时间内轮询一定数量的文件描述符,以测试其中是否有就绪者。

    特点:
    1)不受监听的文件描述符数量上限 (select是1024)的限制。
    2)poll接受一个pollfd结构数组作为要监听的文件描述符集合,以参数传入。 pollfd结构包含要监听的文件描述符、事件类型,以及实际发生的就绪事件。
    3)要监听的数组本身,不要每个循环都重新设置。但同select,每次循环都要将监听的fd集合,作为poll参数,从用户态传拷贝到内核态。
    4)就绪事件发生时,同select,要对每个监听的文件描述符逐一进行检测。这是导致poll相比epoll更低效的重要原因。

    poll函数原型

    #include <poll.h>
    
    int poll(struct pollfd *fds, nfds_t nfds, int timeout);
    
    #define _GNU_SOURCE         /* See feature_test_macros(7) */
    #include <poll.h>
    
    int ppoll(struct pollfd *fds, nfds_t nfds, const struct timespec *timeout_ts, const sigset_t *sigmask);
    
    • 参数

    1)fds pollfd结构的数组,指定所有感兴趣的文件描述符上发生的可读、可写、异常事件。pollfd结构定义:

    struct pollfd {
        int fd;        /* file descriptor */
        short events;  /* requested events */
        short revents; /* returned events */
    }
    

    poll支持的事件类型(pollfd.events/.revents支持的值):

    事件 描述 可作为输入? 可作为输出?
    POLLIN 数据(包括普通数据和优先数据)可读
    POLLRDNORM 普通数据可读
    POLLRDBAND 优先级带数据可读(Linux不支持)
    POLLPRI 高优先级数据可读,比如TCP带外数据
    POLLOUT 数据(包括普通数据和优先级数据)可写
    POLLWRNORM 普通数据可写
    POLLWRBAND 优先级带数据可写
    PLLRDHUP TCP连接被对方关闭,或者对方关闭了写操作。它由GNU引入
    POLLERR 错误
    POLLHUP 挂起。比如管道的写端被关闭后,读端描述符上将收到POLLHUP事件
    POLLNVAL 文件描述符没有打开

    如何区分socket上接收到的,是有效数据,还是对方关闭连接的请求?
    有两种方式:
    方式一:根据recv调用,返回值如果是0,说明对方关闭了连接请求。

    方式二:Linux 2.6.17后,GNU为poll系统调用增加POLLRDHUP事件,在socket上接收到对方关闭连接的请求之后触发。不过使用POLLRDHUP事件时,需要住代码的最开始处定义_GNU_SOURCE。

    2)nfds 指定被监听事件集合fds的大小。类型nfds_t定义:

    typedef unsigned long int nfds_t;
    

    3)timeout 指定poll的超时值,单位ms。当timeout为-1时,poll调用将永远阻塞,直到某个事件发生;当timeout为0时,poll调用将立即返回。

    • 返回值
      含义同select返回值。

    poll调用模型

    struct pollfd *fds = (struct pollfd*)malloc(sizeof(struct pollfd) * fds_num);
    
    // e.g.
    fds[0].fd = STDIN_FILENO; // 要监听的文件描述符
    fds[0].events = POLLIN;     // 设置请求事件
    // 无需设置.revents, 该值由内核维护
    
    // 设置 fds[1..fds_num-1] 文件描述符及请求事件
    
    int timeout = num; // 超时时间, 单位: ms
    
    while (1) {
        do {
            int n = poll(fds, fds_num, num);
        } while(n == -1 && errno == EINTR); // 多设置一层do-while, 是为了信号唤醒后能恢复poll调用
    
        if (n >) { // 监听到有文件描述符就绪, 对所有监听事件逐一检测
            if (fds[0].events == fds[0].revents) { // 只有请求事件与返回事件一致时, 才说明是poll监听到的就绪事件
                // 处理事件
            }
            ...
            if (fds[fds_num - 1].events = fds[fds_num - 1].revents) {
                // 处理事件
            }
        }
    }
    
    free(fds);
    

    poll示例:同时监听键盘输入事件和鼠标移动事件

    完整代码见 Gitee地址 poll.c | Gitee

    // from https://gitee.com/fortunely/linuxstudy/blob/master/advancedio/poll/poll.c
    
    int main() {
        int ret;
        char buf1[100];
        int buf2 = 0;
        struct pollfd fds[POLLFD_NUM];
    
        // 具体是哪个mouse, 可以cat /dev/input/mouse? 进行测试
        int mousefd = open("/dev/input/mouse1", O_RDONLY);
        
        fds[0].fd = STDIN_FILENO; // 标准输入文件描述符,进程启动时已默认打开
        fds[0].events = POLLIN; // 请求事件
        fds[1].fd = mousefd;
        fds[1].events = POLLIN; // 请求事件
    
        while (1) {
            do {
    //            ret = poll(fds, POLLFD_NUM, -1); // 第三个参数timeout = -1 无限期等待
                  ret = poll(fds, POLLFD_NUM, 3000); // 超时时间3000ms
            }while(ret == -1 && errno == EINTR);
    
            if (ret > 0) { // 有动静的fd数量
                if (fds[0].events == fds[0].revents) {// 请求事件与返回的实际事件一致
                    memset(buf1, 0, sizeof buf1);
                    ret = read(fds[0].fd, buf1, sizeof buf1);
                }
                if (fds[1].events == fds[1].revents) {
                    buf2 = 0;
                    ret = read(fds[1].fd, &buf2, sizeof buf2); // 注意buf2是一个int变量,而非地址
                }
            }
            else if (ret == 0) printf("time out\n");
        }
    
        close(mousefd);
    }
    

    epoll系统调用

    内核事件表

    epoll 是Linux 特有I/O复用函数,实现上和使用上与select、poll有很大差异:
    首先,epoll使用一组函数来完成任务,而不是单个函数。
    其次,epoll把用户关心的文件描述符上的事件放在内核里的一个事件表中,从而无须像select和poll那样每次调用都要重复传入文件描述符集或事件集。但epoll需要使用一个额外的文件描述符,来唯一标识内核中的这个事件表。

    epoll函数组

    epoll_create 函数

    这个文件描述符,如何创建?
    使用如下epoll_create函数创建:

    #include <sys/epoll.h>
    
    int epoll_create(int size);
    int epoll_create1(int flags);
    

    size:现在不起作用,只是给内核一个提示,告诉它事件表需要多大。该函数返回的文件描述符将用作其他所有epoll系统调用的第一个参数,以指定要访问的内核事件表。

    epoll_ctl 函数

    epoll_ctl用来操作epoll的内核事件表:

    #include <sys/epoll.h>
    
    int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
    

    epfd:由epoll_create创建文件描述符,对应内核中一个epoll监听事件表。

    fd:要操作的文件描述符,op参数指定操作类型。操作类型有3种:

    • EPOLL_CTL_ADD 往事件表中注册fd上的事件;
    • EPOLL_CTL_MOD 修改fd上的注册事件;
    • EPOLL_CTL_DEL 删除fd上的注册事件;

    event:指定要监听的事件,是epoll_event结构类型指针。epoll_event定义:

    struct epoll_event {
        __unit32_t events;  /* epoll事件类型 */
        epoll_data_t data;  /* 用户数据 */
    };
    

    其中,events成员描述事件类型。epoll支持的事件类型和poll几部相同。表示epoll事件类型的宏是在poll对应的事件类型宏前加“E”,比如epoll的数据可读事件是EPOLLIN。但epoll有2个额外的事件类型:EPOLLET,EPOLLONESHOT。它们对于epoll的高效运作非常关键。data成员用于存储用户数据,其类型epoll_data_t定义:

    typedef union epoll_data {
        void *ptr;
        int fd;
        uint32_t u32;
        uint64_t u64;
    } epoll_data_t;
    

    epoll_data_t 是一个联合体,其4个成员使用最多的是fd,它指定事件所从属的目标文件描述符。ptr成员可用来指定与fd相关的用户数据。但由于epoll_data_t是一个联合体,不能同时使用ptr成员和fd成员。

    epoll_ctl成功时返回0,失败返回-1并设置errno。

    epoll_wait函数

    epoll系列系统调用主要接口epoll_wait函数,它在一段超时时间内等待一组文件描述符上的事件,其原型:

    #include <sys/epoll.h>
    
    int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
    int epoll_pwait(int epfd, struct epoll_event *events, int maxevents, int timeout, const sigset_t *sigmask);
    

    成功时,返回就绪的文件描述符的个数;失败时,返回-1并设置errno。

    timeout: 含义与poll接口的timeout参数相同;

    maxevents: 指定最多监听多少个事件,必须 > 0;

    epoll_wait函数如果检测到事件,就将所有就绪的事件从内核事件表(由epfd参数指定)中复制到它的第二个参数events指向的数组中。该数组只用于输出epoll_wait检测到的就绪事件,而不像select和poll的数组那样,既用于传入用户注册事件,又用于输出内核检测到的就就绪事件。这样,极大地提高了应用程序索引就绪文件描述符的效率。

    poll和epoll在使用上的差别:

    /* 如何索引poll返回的就绪文件描述符 */
    int ret = poll(fds, MAX_EVENT_NUMBER, -1); /* 阻塞等待监听文件描述符对应事件 */
    
    /* 遍历所有已注册文件描述符,找到其中就绪者 */
    for (int i = 0; i < MAX_EVENT_NUBMER; ++i) {
        if (fds[i].revents & POLLIN) { /* 判断第i个文件描述符是否就绪 */
            int sockfd = fds[i].fd;
            /* 处理sockfd */
        }
    }
    
    /* 如何索引epoll返回的就绪文件描述符 */
    int ret = epoll_wait(epollfd, events, MAX_EVENT_NUMBER, -1); /* 阻塞等待注册的文件描述符 */
    /* 只需要遍历就绪的ret文件描述符 */
    for (int i = 0; i < ret; i++) {
        int sockfd = events[i].data.fd;
        /* sockfd 肯定就绪, 直接处理 */
    }
    

    epoll调用模型:LT和ET模式

    epoll对文件描述符操作,有两种模式:LT(Level Trigger,电平触发)模式,ET(Edge Trigger,边沿触发)模式。LT模式是默认的工作模式,该模式下,epoll相当于一个效率较高的poll。当往epoll内核事件表中注册一个文件描述符上的EPOLLET事件时,epoll将以ET模式来操作该文件描述符。

    LT模式

    采用LT工作模式的文件描述符,当epoll_wait检测到其上有事件发生并将事件通知应用程序后,应用程序可以不立即处理该事件。这样,当应用程序下一次调用epoll_wait时,epoll_wait还会再次向应用程序通告此事件,直到该事件被处理。

    ET模式

    采用ET模式的文件描述符,当epoll_wait检测到其上有事件发生,并将事件通知应用程序后,应用程序必须立即处理该事件,因为后续的epoll_wait调用将不再向应用程序通知这一事件。

    ET模式在很大程度上降低了同一个epoll事件被重复触发的次数,因此ET模式效率比LT模式要高。

    LT和ET模式服务器调用例程

    同样,只展示部分关键代码。完整代码,详见Gitee地址 epoll.c | Gitee地址

    // from https://gitee.com/fortunely/linuxstudy/blob/master/advancedio/epoll/epoll.c
    
    /* 将文件描述符fd上的EPOLLIN注册到epollfd指示的epoll内核事件表中,参数enable_et指定是否对fd启用ET模式 */
    void addfd(int epollfd, int fd, bool enable_et)
    {
            struct epoll_event event;
            event.data.fd = fd;
            event.events = EPOLLIN;
            if (enable_et) {
                    event.events |= EPOLLET; // 注意: 这里的.events 添加了EPOLLET标识, 表示对该事件启动ET模式
            }
            epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &event);
            setnonblocking(fd);
    }
    
    /* LT模式的工作流程 */
    void lt(struct epoll_event* events, int number, int epollfd, int listenfd)
    {
            char buf[BUFFER_SIZE];
            for (int i = 0; i < number; i++) {
                    int sockfd = events[i].data.fd;
                    if (sockfd == listenfd) { // 监听连接到listenfd事件就绪, 说明有连接请求
                           // 处理连接就绪事件
                           ...
                int connfd = accept(listenfd, (SA *)&client_address, &client_addrlength); // accept新连接
                addfd(epollfd, connfd, false); /* 对connfd禁用ET mode */
                    }
                    else if (events[i].events & EPOLLN) { // 其他输入事件
                            /* 只要 socket读缓存中还有未读出的数据,这段代码就被触发 */
                            memset(buf, '\0', BUFFER_SIZE);
                            int ret = recv(sockfd, buf, BUFFER_SIZE - 1, 0);
                            ...
                    }
                    else {
                            printf("something else happened \n");
                    }
            }
    }
    
    voi et (struct epoll_event *evets, int number, int epollfd, int listenfd)
    {
            char buf[BUFFER_SIZE]; // 1024
            for (int i = 0 ; i < number; i++) {
                    int sockfd = events[i].data.fd;
                    if (sockfd == listenfd) {
                            struct sockaddr_in client_address;
                            socklen_t client_addrlength = sizeof(client_address);
                            addfd(epollfd, connfd, true); /* 对connfd开启ET mode */
                    }
                    else if(events[i].event & EPOLLIN) {
                            /* 这段代码不会被重复触发,所以我们循环读取数据,以确保把socket读缓存中的所有数据读出 */
                            printf("event trigger once\n");
                            while (1) {
                                    memset(buf, '\0', BUFFER_SIZE, 0);
                                    int ret = recv(sockfd, buf, BUFFER_SIZE - 1, 0);
                                    if (ret < 0) {
                                            /* 对于非阻塞IO,下面的条件成立表示数据已经全部读取完毕。此后,epoll就能再次触发sockfd上的EPOLLIN事件,以驱动下一次读操作 */
                                            if ((errno == EAGAIN) || (errno == EWOULDBLOCK)) {
                                                    printf("read later\n");
                                                    break;
                                            }
                                            close(sockfd);
                                            break;
                                    }
                                    else if (ret == 0 )
                                    {
                                            close(sockfd);
                                    }
                                    else {
                                            printf("get %d bytes of content: %s\n", ret, buf);
                                    }
                            }
                    }
                    else {
                            printf("get %d bytes of contet: %s\n", ret, buf);
                    }
            }
    }
    
    int main(int argc, char *argv[]) 
    {
        ...
            int ret = 0;
            int listenfd = socket(AF_IENT, SOCK_STREAM, 0);
    
            ret = bind(listenfd, (SA *)&address, sizeof(address));
            ret = listen(listenfd, 5);
    
            epoll_event events[MAX_EVENT_NUMBER];
            int epollfd = epoll_create(5);
    
            addfd(epollfd, listenfd, true);
            while (1) {
                    int ret = epoll_wait(epollfd, events, MAX_EVENT_NUMBER, -1);
    
                    lt(events, ret, epollfd, listenfd); /* use LT mode*/
                    // et(events, ret, epollfd, listenfd); /* use ET mode */
            }
    
            close(listenfd);
            return 0;
    }
    

    注意:
    1)ET模式下,事件被触发的次数要比LT模式下少很多;
    2)每个使用ET模式的文件描述符都应该是非阻塞的。如果文件描述符是阻塞的,那么读或写操作将会因为没有后续的事件,而一直处于阻塞状态(饥渴状态);

    EPOLLONESHOT事件

    即使使用ET模式,一个socket上的某个事件还是可能被触发多次,在这并发程序中会引起一个文件,比如一个线程(或进程)在读取完某个socket上的数据后开始处理这些数据,而在数据的处理过程中该socket上又有新数据可读(EPOLLIN再次被触发),此时另外一个线程被唤醒来读取这些新的数据。于是出现2个线程同时操作一个socket的局面。
    ----这不是期望的,我们期望的是:一个socket连接在任一时刻,都只会被一个线程处理。 这点可以使用epoll的EPOLLONESHOT事件实现。

    注册了EPOLLONESHOT事件的文件描述符,OS最多触发其上注册的一个可读、可写或异常事件,且只触发一次,除非我们使用epoll_ctl函数重置该文件描述符上注册的EPOLLONESHOT事件。这样,当一个线程在处理某个socket时,其他线程不可能有机会操作该socket。反过来,注册了EPOLLONESHOT事件的socket一旦被某个线程处理完毕,该线程就应该立刻重置这个socket上的EPOLLONESHOT事件,以确保这个socket下一次可读时,其EPOLLIN事件就能被触发,进而让其他工作线程有机会继续处理该socket。

    PS:epoll只会触发一次EPOLLONESHOT事件,直到重置该事件,接着允许再触发一次。

    在fd上注册EPOLLONESHOT事件方式:

    /* 将fd上的EPOLLIN和EPOLLET事件注册到epollfd指示的epoll内核事件表中,参数oneshot指定是否注册fd上的EPOLLONESHOT事件  */
    void addfd (int epollfd, int fd, bool oneshot)
    {
        struct epoll_event event;
        event.data.fd = fd;
        event.events = EPOLLIN | EPOLLET;
        if (oneshot)
            event.events |= EPOLLONESHOT;
        epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &event);
        setnonblocking(fd); // 注意: 如果将fd注册为ET模式(EPOLLET事件), 则文件描述符应设为non-blocking
    }
    
    /* 将fd设为non-blocking */
    int setnonblocking(int fd)
    {
        int old_option = fcntl(fd, F_GETFL);
        int new_option = old_option | O_NONBLOCK;
        fcntl(fd, F_SETFL, new_option);
        return old_option;
    }
    

    重置fd上的EPOLLONESHOT事件方式:

    /* 重置fd上的事件。这样操作之后,尽管fd上的EPOLLONESHOT事件被注册,但是OS仍然会触发fd上的EPOLLIN事件,且只触发一次 */
    void reset_oneshot(int epollfd, int fd)
    {
        struct epoll_event event;
        event.data.fd = fd;
        event.events = EPOLLIN | EPOLLET | EPOLLONESHOT; // 必须注册EPOLLONESHOT事件, 其他事件根据实际情况决定
        epoll_ctl(epollfd, EPOLL_CTL_MOD, fd, &event);   // epoll_ctl重置fd上注册的EPOLLONESHOT事件
    }
    

    EPOLLONESHOT例程

    两个线程同时对注册EPOLLONESHOT事件的同一个非阻塞sockfd,进行阻塞操作recv

    演示如何使用epoll对fd重置EPOLLONESHOT事件。同样的只展示部分核心代码,完整代码见epoll_EPOLLONESHOT.c

    // from https://gitee.com/fortunely/linuxstudy/blob/master/advancedio/epoll/epoll_EPOLLONESHOT.c
    
    struct fds {
        int epollfd;
        int sockfd;
    };
    
    int main()
    {
        int listenfd = socekt();
        bind(listenfd);
        listen(listenfd);
        
        struct epoll_event events[MAX_EVENT_NUMBER]; // 1024
        int epollfd = epoll_create(5);   /* 5没有意义,但必须>0 */
        
        /* 注意:监听socket listenfd 上是不能注册EPOLLONESHOT事件的,否则应用程序只能处理一个客户连接!因为后续的客户连接请求将不再触发listenfd上的EPOLLIN事件 */
        addfd(epollfd, listenfd, false);
    
        while (1) {
            int ret = epoll_wait(epollfd, events, MAX_EVENT_NUMBER, -1);
            for (int i = 0; i < ret; i++) {
                int sockfd = events[i].data.fd;
                if (sockfd == listenfd) {
                    struct sockaddr_in client_address;
                    socklen_t client_addrlength = sizeof(client_address);
                    int connfd = accept(listenfd, (SA *)&client_address,  &client_addrlength);
    
                    /* 对每个非监听文件描述符都注册EPOLLONESHOT事件 */
                    addfd(epollfd, connfd, true);
                }
                else if (events[i].events & EPOLLIN ) { /* 普通可读数据就绪 */
                    pthread_t thread;
                    struct fds fds_for_new_worker;
                    fds_for_new_worker.epollfd = epollfd;
                    fds_for_new_worker.sockfd = sockfd;
    
                    /* 新启动一个工作线程为sockfd服务 */
                    pthread_create(&thread, NULL, worker, (void *)&fds_for_new_worker);
                }
                else {
                    printf("something else happend\n");
                }
            }
        }
    }
    
    /* 工作线程 */
    void *worker(void *arg)
    {
        int sockfd = ((struct fds *)arg)->sockfd;
        int epollfd = ((struct fds *)arg)->epollfd;
        printf("start new thread to receive data on fd: %d\n", sockfd);
        char buf[BUFFER_SIZE]; // 1024
        memset(buf, '\0', BUFFER_SIZE);
    
        /* 循环读取sockfd上的数据,直到遇到EAGAIN错误 */
        while (1) {
            int ret = recv(sockfd, buf, BUFFER_SIZE - 1, 0);
            if (ret == 0) {
                close(sockfd);
                printf("foreiner closed the connection\n");
                break;
            }
            else if (ret < 0) {
                if (errno == EAGAIN) { // 在ET模式下, 对non-blocking fd调用阻塞操作recv,  该操作可能没有完成就返回, 从而产生该错误码, 但不会破坏socket
                    reset_oneshot(epollfd, sockfd); // 重置sockfd上注册的EPOLLONESHOT事件以及其他事件
                    printf("read later\n");
                    break;
                }
            }
            else {
                printf("get content: %s\n", buf);
                /* 休眠5秒,模拟数据处理过程 */
                sleep(5);
            }
        }
        printf("end thread receiving data on fd: %d\n", sockfd);
    }
    

    select/poll/epoll 比较

    select、poll、epoll三组I/O复用系统调用,共同点:
    1)都能同时监听多个文件描述符。
    2)都有超时等待功能,由timeout参数指定超时时间,或者到一个或多个文件描述符上有监听事件发生时返回。
    3)返回值是就绪的文件描述符的数量。返回0表示没有事件发生。

    那么它们有什么区别?在什么情况下选用哪种系统调用呢?
    下面从事件集、最大支持文件描述符数、工作模式和编程模型,等4个方面进一步比较它们的异同,以明确在实际应用中应该选择使用哪个或哪些。

    1)事件集
    select通过fd_set告诉内核监听哪些文件描述符,不过fd_set并没有将文件描述符和事件绑定,因此需要提供3个这种类型的参数(readfds/writefds/exceptionfds),分别传入要监听的可读、可写、异常事件集。正因为这样,select不能处理更多类型的事件,另一方面由于内核对fd_set集合的在线修改,应用程序下次调用select前不得不重置这3个fd_set,因此每次select调用前,都要重新设置这3个fd_set并拷贝到内核。

    poll的事件集pollfd更“聪明”一些。它把文件描述符和事件都定义绑定到一起,任何事件都被统一处理,从而使得编程接口更简洁。并且,内核每次修改的是pollfd结构体的revents成员(return events),而events不变(request events),因此下次调用poll时应用程序无需重置pollfd类型的事件集参数

    由于每次select和poll调用都返回整个用户注册的事件集合(就绪的,和未就绪的),所以应用程序索引就绪文件描述符的时间复杂度O(n)。

    epoll采用与select和poll完全不同的方式来管理用户注册的事件:它在内核中维护一个事件表,并提供一个独立的系统调用epoll_ctl,来控制往其中添加、删除、修改事件。这样,每次epoll_wait调用都直接从该内核事件表中取得用户注册的事件,而无需反复从用户控件读入这些事件。epoll_wait系统调用的events参数仅仅用了返回就绪的事件,应用程序索引就绪文件描述符的复杂度O(1)。

    PS:epoll_wait 传出的事件集,就是就绪事件集。

    2)最大支持文件描述符数
    poll和epoll_wait分别用nfds和maxevents参数指定最多监听多少个文件描述符和事件,这2个数制都能达到系统允许打开的最大文件描述符数目,即65535($ cat /proc/sys/fs/file-max查看);select允许监听的文件描述符的最大数量有限制(通常是1024),虽然用户可以修改该限制,但可能导致不可预期的后果。

    3)工作模式
    select和poll都只能工作在相对低效的LT模式,而epoll可以工作在ET高效模式。而且epoll还支持EPOLLONESHOT事件,能进一步减少可读、可写和异常等事件被触发的次数。

    4)编程模型
    select和poll采用的都是轮询的方式,即每次调用都扫描整个注册文件描述符集合,并将其中就绪的文件描述符返回给用户选择,检测就绪事件算法时间复杂度O(n)。
    epoll采用回调的方式,内核检测到就绪的文件描述符时,将触发回调函数,回调函数就将该文件描述符上对应的事件插入内核就绪事件队列。内核最后在适当的时机将该就绪事件队列中的内容拷贝到用户控件。因此,epoll_wait无需轮询整个文件描述符集合来检测哪些事件已经就绪,算法时间复杂度O(1)。
    当活动连接比较多的时候,epoll_wait效率未必比select和poll高,因为此时回调函数被触发得过于频繁。因此,epoll_wait适用于连接数较多,但活动连接较少的情况。

    活动连接多时,会发生频繁回调,占用大量CPU开销。
    连接数多时,select、poll每次轮询不得不遍历所有注册事件的集合,浪费大量CPU时间。

    总结

    系统调用 select poll epoll
    事件集合 用户通过3个参数分别传入感兴趣的可读、可写及异常等事件,
    内核通过对这些参数的在线修改来返回其中的就绪事件。
    这使得每次调用select都要重置这3个集合参数
    统一处理所有事件类型,因此只需要一个事件集参数。
    用户通过pollfd,events传入感兴趣的事件,
    内核通过修改pollfd,revents反馈其中就绪的事件
    内核通过一个事件表直接管理用户感兴趣的所有事件。
    因此每次调用epoll_wait时,无须反复传入用户感兴趣的事件。
    epoll_wait系统调用的参数events仅用来反馈就绪的事件
    应用程序索引就绪文件描述符的时间复杂度 O(n) O(n) O(1)
    最大支持文件描述符数 一般有最大值限制FD_SETSIZE 系统支持的最大文件描述符数(如65535),
    也就是说不受限于FD_SETSIZE
    系统支持的最大文件描述符数(如65535),
    不受限于FD_SETSIZE
    工作模式 LT LT LT和ET
    内核实现和工作效率 采用轮询方式检测就绪事件 采用轮询方式来检测就绪事件 采用回调方式检测就绪事件

    另附一张从网上找到的一个简单汇总图(具体出处已忘记):

  • 相关阅读:
    UVa-10317
    UVa-1595
    UVa-10391
    UVa-10763
    UVa-10935
    UVa-1594
    UVa-1593
    从CSDN搬过来了
    memset会显著增加时间和空间的消耗吗
    memset对数组的初始化
  • 原文地址:https://www.cnblogs.com/fortunely/p/16330701.html
Copyright © 2020-2023  润新知