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 普通数据可读,等同于POLLIN
POLLRDBAND 优先级带数据可读(Linux不常用)
POLLPRI 高优先级数据可读,比如TCP带外数据
POLLOUT 数据(包括普通数据和优先级数据)可写
POLLWRNORM 普通数据可写,等同于POLLOUT
POLLWRBAND 优先级带数据可写
PLLRDHUP TCP连接被对方关闭,或者对方关闭了写操作。它由GNU引入
POLLERR 发生了错误
POLLHUP 挂起。比如管道的写端被关闭后,读端描述符上将收到POLLHUP事件
POLLNVAL 无效的请求,一般是文件描述符(fd)没有打开

如何区分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
内核实现和工作效率 采用轮询方式检测就绪事件 采用轮询方式来检测就绪事件 采用回调方式检测就绪事件

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

posted @ 2022-05-31 15:45  明明1109  阅读(749)  评论(0编辑  收藏  举报