多路复用IO:select、poll、epoll


一、常见的IO模型

                 概念优点       缺点适用场景
阻塞IO
Blocking IO
当应用程序执行IO操作时,会被阻塞,直到数据准备好或者IO操作完成才会返回结果。实现简单。性能较低。适用于连接数较少且连接时间较长的场景,如传统的TCP服务器。
非阻塞IO
Non-blocking IO
应用程序在执行IO操作时不会被阻塞,立即返回结果,但如果数据还没有准备好,应用程序需要不断轮询以检查数据是否准备好。不被阻塞,系统资源利用率高。需要频繁轮询和状态判断,编程复杂度高。非阻塞IO适用于连接数较少且连接时间较短的场景,如实时通信系统。
多路复用IO
Multiplexing IO
多路,指的是多个socket网络连接。复用,指的是复用一个线程、使用一个线程来检查多个文件描述符的就绪状态。1. 更高效。
2. 系统资源利用率高。
3. 可扩展性强。
编程复杂度高。1. 适用于高并发网络服务器,如Web服务器、聊天服务器等。
2. 多路IO复用可以用于实时数据处理场景,如实时监控系统、实时数据采集等。
3. 多路IO复用可以用于构建高性能的代理服务器,同时处理多个客户端和目标服务器之间的通信。
信号驱动IO
Signal-driven IO
应用程序通过注册信号处理函数,在数据准备好时,操作系统会发送一个信号通知应用程序进行处理。高并发性能,应用程序可以同时处理多个IO事件。1. 编写和管理信号处理函数可能相对复杂,需要确保正确处理不同的信号事件。
2. 由于信号是异步到达的,可能会导致竞态条件的发生,需要特别注意处理。
信号驱动IO适用于连接数较少且连接时间较长的场景,例如异步文件读取、信号驱动的网络编程等。
异步IO
Asynchronous IO
应用程序发起IO操作后,可以继续执行其他任务,当IO操作完成后,操作系统会通知应用程序进行处理。1. 高性能和高并发。
2. 编程模型更简单。
3. 可扩展性强。
异步IO的编程复杂度较高,需要处理回调函数和事件驱动的逻辑。异步IO适用于连接数较多且连接时间较短的场景,例如高性能的网络服务器、实时数据处理系统等。

二、什么是多路IO复用?

多路IO复用是指单个进程/线程就可以同时处理多个IO请求。

用户将想要监视的文件描述符添加到select/poll/epoll函数中,由内核监视,函数阻塞。一旦有文件描述符就绪(读就绪或写就绪),或者超时(设置了超时时间),函数就会返回,然后该进程可以进行相应的读/写操作。

三、select、poll、epoll

select

select是一个系统调用函数,可以将多个文件描述符集合传递给内核,让内核帮忙监听需要关心的事件(可读、可写、异常)。在此期间,可以选择要等待的时长(无限长/一段时间/不等待)。一旦有文件描述符准备就绪,select函数会返回,程序可以根据文件描述符集合的状态来进行相应的读取、写入或异常处理操作。

简而言之,select函数将文件描述符集合交给内核去监听,并在事件发生时通知程序进行相应的处理,它是多路IO复用的实现方式之一。

select函数将文件描述符集合保存在fd_set中,fd_set类型变量的每一位代表了一个描述符。我们可以把它当成是由一个很多二进制位构成的数组。函数具体如下:

#include <sys/select.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>

int select(int nfds, fd_set *readfds, fd_set *writefds,
           fd_set *exceptfds, struct timeval *timeout);
/*
nfds:     监听的文件描述符最大值加1
readfds:  用于监听可读事件的文件描述符集合,同时充当入参与出参
writefds: 用于监听可写事件的文件描述符集合,同时充当入参与出参
exceptfds:用于监听异常事件的文件描述符集合,同时充当入参与出参
timeout:  超时时间,指定select()函数等待的最长时间。
*/

// 这些函数是用于操作文件描述符集合(fd_set)的函数,用于设置和操作需要监视的文件描述符集合。
void FD_CLR(int fd, fd_set *set);   // 从文件描述符集合中清除指定的文件描述符fd。
int  FD_ISSET(int fd, fd_set *set); // 检查给定的文件描述符是否在文件描述符集合中。
void FD_SET(int fd, fd_set *set);   // 将指定的文件描述符fd添加到文件描述符集合set中。
void FD_ZERO(fd_set *set);          // 将文件描述符集合set清空,即将所有的文件描述符从set中移除。

// pselect是select的增强版,由于它在POSIX标准中引入较晚,因此在一些旧的代码和平台上可能很少被使用。
// 但它提供了更好的可移植性,还可以用sigmask替代当前进程的阻塞信号集,调用返回后还原原有阻塞信号集。
#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);

官方示例:监听标准输入5秒,看它是否有数据输入

#include <stdio.h>
#include <stdlib.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>

int main(void)
{
    fd_set rfds;
    struct timeval tv;
    int retval;

    /* Watch stdin (fd 0) to see when it has input. */
    FD_ZERO(&rfds);
    FD_SET(0, &rfds);

    /* Wait up to five seconds. */
    tv.tv_sec = 5;
    tv.tv_usec = 0;

    retval = select(1, &rfds, NULL, NULL, &tv);
    /* Don't rely on the value of tv now! */

    if (retval == -1)
        perror("select()");
    else if (retval)
        printf("Data is available now.\n");
        /* FD_ISSET(0, &rfds) will be true. */
    else
        printf("No data within five seconds.\n");

    exit(EXIT_SUCCESS);
}

缺陷:

  • 文件描述符集大小限制:select函数使用 fd_set 结构来表示文件描述符集,其中包含了固定数量的位。在某些平台上,这个位图的大小是有限制的,通常是1024或更小。这意味着 select函数一次只能处理有限数量的文件描述符,对于大规模的应用程序可能会受到限制。
  • 效率问题:select函数在每次调用时都需要遍历整个文件描述符集,以检查每个文件描述符的状态。这对于大型的文件描述符集来说效率较低,因为即使只有少数几个文件描述符就绪,也需要遍历整个集合。
  • 阻塞式调用:select函数是一个阻塞式调用,即在没有任何文件描述符就绪时会一直阻塞等待。这可能会导致程序在没有任何活动时仍然被阻塞,从而降低了程序的响应性。
  • 不支持信号掩码:select函数没有提供直接的方式来控制在选择操作期间阻塞或解除阻塞特定的信号。这可能导致在信号处理程序中出现竞争条件或其他问题。

poll

poll函数在select功能上进行了优化,它不再需要维护文件描述符集合,而是通过一个结构体数组来指定要监视的文件描述符和事件,更加简洁。它也没有文件描述符数量限制,可以监听更多的文件描述符。

#include <poll.h>

int poll(struct pollfd *fds, nfds_t nfds, int timeout);
/*
fds:     一个指向 pollfd 结构体数组的指针,每个结构体描述一个要监视的文件描述符及其所关注的事件。
nfds:    要监视的文件描述符的数量。
timeout: 超时时间,以毫秒为单位。
         如果设置为负数,表示无限等待;
         如果设置为0,表示立即返回;
         如果设置为正数,表示等待指定的时间。
*/

struct pollfd {
    int   fd;      /* 文件描述符 */
    short events;  /* 要监听的事件 */
    short revents; /* 实际发生的事件 */
};

// 增强版ppoll
#define _GNU_SOURCE
#include <signal.h>
#include <poll.h>

int ppoll(struct pollfd *fds, nfds_t nfds,
        const struct timespec *tmo_p, const sigset_t *sigmask);

其中监听的事件events可以是以下常量的按位或:

POLLIN:   有数据可读。
POLLPRI:  文件描述符上存在异常。
POLLOUT:  可以进行写操作。
POLLERR:  发生错误。
POLLHUP:  挂起事件。
POLLNVAL: 无效的文件描述符。
...

缺陷:

  • 效率问题:函数需要遍历所有的文件描述符,并检查每个文件描述符上的事件。
  • 阻塞问题:poll() 函数在等待事件时是阻塞的,直到有事件发生或超时。这意味着在等待期间无法执行其他任务。如果需要同时处理其他任务,可能需要使用多线程或异步编程模型。

epoll

epoll在poll的基础上进一步增强,它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率。epoll 使用基于事件驱动的方式,只有在有事件发生时才会通知应用程序,而不需要遍历所有的文件描述符。这使得 epoll 在处理大量文件描述符时更加高效,因为它不会浪费时间在空闲的文件描述符上。但是,epoll是Linux特有的机制,而非POSIX标准的一部分。

此外,epoll提供了两种触发模式:边缘触发(Edge Triggered, ET)水平触发(Level Triggered, LT)。边缘触发模式只在状态发生变化时通知应用程序,而水平触发模式则在状态保持的情况下持续通知。边缘触发模式可以减少不必要的通知,提高效率。

函数声明:

#include <sys/epoll.h>

// 创建一个 epoll 实例,返回一个文件描述符,用于后续的操作。
int epoll_create(int size);
int epoll_create1(int flags);
/*
size必须是一个大于0的值,目前这个参数已被忽略,没有意义。
epoll_create1是epoll_create函数的一个变种,它可以通过传递不同的flag参数来设置一些额外的选项。
函数返回值是一个文件描述符,用于标识创建的 epoll 实例。如果创建失败,返回值-1,并且错误码会被设置。
当epoll实例不再使用,需要关闭,内核才会销毁释放相应的资源。
*/

// 用于注册和管理需要监视的文件描述符。可以通过添加、修改或删除文件描述符来设置监视的事件类型和其他参数。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
/*
epfd: epoll实例的文件描述符,通过epoll_create或epoll_create1创建得到。
op:   操作类型,可以是以下三个值之一:
           EPOLL_CTL_ADD:将文件描述符fd添加到epoll实例中。
           EPOLL_CTL_MOD:修改文件描述符fd在epoll实例中的监视事件。
           EPOLL_CTL_DEL:从epoll实例中删除文件描述符fd。
fd:   需要注册、修改或删除的文件描述符。
event:指向epoll_event结构体的指针,用于设置监视的事件类型和其他参数。
*/

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

struct epoll_event {
    uint32_t     events; // 监视的事件类型
    epoll_data_t data;   // 用户数据
};
/* 
events字段用于设置监视的事件类型,可以是以下几个值的位掩码组合:
    EPOLLIN:     可读事件。
    EPOLLOUT:    可写事件。
    EPOLLRDHUP:  对端关闭连接或关闭写入一半的连接。
    EPOLLPRI:    有紧急数据可读。
    EPOLLERR:    错误事件。
    EPOLLHUP:    挂起事件。
    EPOLLET:     边缘触发模式。
    EPOLLONESHOT:一次性事件。
    
data字段用于设置用户数据,可以是一个指针、一个文件描述符或一个整数。
*/

// 等待事件的发生。当有事件发生时,将返回一个就绪事件的数组,应用程序可以遍历这个数组来处理就绪的事件。
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);
/*
epfd:     epoll实例的文件描述符,通过epoll_create或epoll_create1创建得到。
events:   指向epoll_event结构体数组的指针,用于存储就绪的事件。
maxevents:events数组的大小,即最多可以存储多少个事件。
timeout:  等待事件的超时时间,单位为毫秒。可以有以下几种取值:
                -1:无限等待,直到有事件发生。
                0: 立即返回,不等待事件。
                >0:等待指定的毫秒数后返回,即使没有事件发生。

epoll_wait函数会阻塞当前线程,直到有事件发生或超时。
当有事件发生时,函数会将就绪的事件填充到events数组中,并返回就绪事件的数量。
如果超时时间到达而没有事件发生,则函数返回0。如果函数调用出错,则返回-1,并设置相应的错误码。

epoll_pwait与epoll_wait的关系,类似于select与pselect两者之间的关系。
它与epoll_wait的区别在于多了一个sigmask参数,用于指定一个信号屏蔽集。
在调用epoll_pwait函数时,如果有信号被传递给进程,并且该信号在sigmask中没有被阻塞,则函数会返回,并将EINTR错误码设置为errno。
这样,我们可以通过检查返回值和errno来判断是因为信号中断而返回,还是因为事件发生或超时而返回。
*/

epoll默认为LT模式,如果需要开启ET模式,需要在监视的事件类型中加上EPOLLET。当文件描述符上有新的事件发生时,边缘触发只会通知一次,而不会重复通知。这就使得用户空间程序有可能缓存IO状态,减少epoll_waitepoll_pwait的调用,提高应用程序效率。需要注意的是,边缘触发模式需要配合非阻塞 I/O 使用,以确保在处理事件时不会阻塞程序。

官方示例:

#define MAX_EVENTS 10
struct epoll_event ev, events[MAX_EVENTS];
int listen_sock, conn_sock, nfds, epollfd;

/* Code to set up listening socket, 'listen_sock',
   (socket(), bind(), listen()) omitted */

epollfd = epoll_create1(0);
if (epollfd == -1) {
    perror("epoll_create1");
    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_wait");
        exit(EXIT_FAILURE);
    }

    for (n = 0; n < nfds; ++n) {
        if (events[n].data.fd == listen_sock) {
            conn_sock = accept(listen_sock,
                               (struct sockaddr *) &addr, &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监听一个监听套接字listen_sock,并在有新连接到达时接受连接,并将连接套接字conn_sock添加到 epoll 实例中。

  1. 通过 epoll_create1 函数创建一个epoll实例,并将返回的文件描述符保存在 epollfd 中。
  2. 将监听套接字listen_sock添加到epoll 实例中,以便在有新连接到达时能够检测到。使用 epoll_ctl 函数,指定操作为 EPOLL_CTL_ADD,并将监听套接字和事件结构体ev作为参数。
  3. 使用无限循环来等待事件的发生。调用 epoll_wait 函数等待事件的发生,它会阻塞程序直到有事件发生或出错。epoll_wait 函数返回事件的数量,并将事件保存在 events 数组中。
  4. 遍历 events 数组,处理每个事件。如果事件对应的文件描述符是监听套接字listen_sock,则表示有新连接到达。使用 accept 函数接受连接,并将连接套接字设置为非阻塞模式,然后将连接套接字添加到 epoll 实例中
  5. 如果事件对应的文件描述符不是监听套接字,则表示有数据可读或可写。在这个示例中,调用 do_use_fd 函数来处理这些事件。


四、总结

select、poll、epoll对比如下:
在这里插入图片描述

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值