多路转接——select、poll和epoll以及Reactor模式

文章详细介绍了Linux系统中的多路转接机制,包括select、poll和epoll。select和poll允许进程监视多个文件描述符,而epoll在处理大量并发时表现出更高的效率,尤其适合高性能网络服务器。epoll使用红黑树和回调机制,提供水平触发和边缘触发两种工作模式,支持更高效的事件处理。此外,文章还提到了Reactor模式,这是一种基于事件驱动的网络编程模式,利用epoll实现高并发和高性能。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

        在Linux系统中,多路转接(Multiplexing)是一种重要的事件处理机制,它允许一个进程或线程同时监视多个文件描述符(sockets、文件等),然后一次性提取出有多个就绪事件的文件描述符进行挨个处理。将多进程多线程以及多路转接结合在一起,是实现高并发服务器的常用手段。

一、select

        select()是一个系统调用,用于在一组文件描述符中选择已准备好进行某种I/O操作的文件描述符。select()函数的原型如下:

#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
  • nfds:待测试的文件描述符个数,即fd_set集合中所有文件描述符的范围,即所有文件描述符的最大值加1。
  • readfds:可读文件描述符集合。
  • writefds:可写文件描述符集合。
  • exceptfds:异常文件描述符集合。
  • timeout:超时时间。

返回值:

  • -1:出错,错误原因存于errno中。
  • 0:超时时间到
  • 正整数:准备好的文件描述符数目。

        其中,readfds、writefds、exceptfds都是指向fd_set结构体的指针,它们是开关,比如readfds指向的fd_set值为......00000001的话,即意味着让selsect监视文件描述符0的可读事件(fd_set第0位为0),而不关心文件描述符2、3、4......的可读事件(fd_set其余位皆为0)。fd_set默认是一个结构体,内部是一个数组,作为位图的载体,占128字节,1024位,每一位都代表了一个文件描述符,可以操控0-1024的文件描述符。 

需要使用规定得宏才能对fd_set结构体进行操作:

  • FD_ZERO(fd_set *set):将set清零。
  • FD_SET(int fd, fd_set *set):将fd加入set中。
  • FD_CLR(int fd, fd_set *set):将fd从set中删除。
  • FD_ISSET(int fd, fd_set *set):测试fd是否在set中。

timeout参数至关重要,它所指向得结构体结构如下:

struct timeval {
    __time_t tv_sec;        /* Seconds. */
    __suseconds_t tv_usec; /* Microseconds. */
};

         其中,tv_sec代表秒数,tv_usec为微秒数,即秒后面的零头。

        当timeout值大于0时(结构体内两个值有任一大于0),select会等待timeout时间,其间任一监控的文件有就绪事件,该函数立刻返回,返回值为就绪文件数量,timeout同时是一个输出型参数,内部存有剩余的时间;readfds、writefds、exceptfds也是输出型参数,此时内部第n位为1,就代表文件描述符n有就绪事件。如果超过了timeout时间仍没有文件产生就绪事件,函数返回0。

        如果timeout参数值为NULL,意味着select进入阻塞模式,直到有至少一个监控的文件内产生就绪事件才返回,否则陷入阻塞。


优点:

        可以一次等待多个fd,在一定程度,提高I0的效率。
缺点:
        1. 由于函数参数是输入输出型参数,因此每次函数返回后,再次调用都要重新设置;每次完成之后,也需要遍历检测readfds、writefds、exceptfds的值。

        2. fd_set,它能够让select同时检测的fd是有上限的,默认为0-1024。

        3. select底层原理是通过轮询检测的方式得知哪些fd就绪了,效率有限。

二、poll

        在Linux系统中,除了select之外,还有另一种多路转接机制称为poll。与select类似,poll也允许一个进程或线程监视多个文件描述符,并在有就绪事件时进行处理。相比select,poll有一些优点,但也有一些局限性,相比select其实并没有特别大的提升,较少sh。

函数原型:

#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
  • struct pollfd:是一个结构体数组,用于传递待监视的文件描述符信息和就绪事件。
  • nfds:待测试的文件描述符个数,即结构体数组中元素的数量。
  • timeout:超时时间,与select的timeout类似,单位是毫秒。
  • 返回值:与select一样,返回准备好的文件描述符数目,0表示超时,-1表示出错,错误原因存于errno中。

结构体struct pollfd

struct pollfd {
    int fd;       // 待监视的文件描述符
    short events; // 期望的事件类型,由下面的宏组合而成
    short revents; // 实际发生的事件类型,由内核填充
};

事件类型宏:

  • POLLIN:表示文件描述符可以读取(包括TCP连接中的新数据、socket接收缓冲中的数据)。
  • POLLOUT:表示文件描述符可以写入。
  • POLLERR:表示文件描述符发生了错误。
  • POLLHUP:表示文件描述符挂起(连接关闭)。
  • POLLNVAL:表示文件描述符没有打开。

使用方法:

        和select不同,poll不需要对大型位图进行操作,而是使用结构体数组,每个数组元素对应一个文件描述符。然后用户从pollfd数组元素的revents中,得知该pollfd中存储的fd是否有监控的就绪事件类型被触发。

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

int main() {
    struct pollfd fds[2];
    int timeout = 5000; // 5秒超时

    // 设置第一个文件描述符
    fds[0].fd = fd1;
    fds[0].events = POLLIN; // 只关注可读事件

    // 设置第二个文件描述符
    fds[1].fd = fd2;
    fds[1].events = POLLIN | POLLOUT; // 同时关注可读和可写事件

    int ret = poll(fds, 2, timeout);
    if (ret == -1) {
        perror("poll");
        return 1;
    } else if (ret == 0) {
        printf("Timeout\n");
    } else {
        // 检查就绪事件
        for (int i = 0; i < 2; i++) {
            if (fds[i].revents & POLLIN) {
                printf("fd%d is ready for reading\n", i+1);
            }
            if (fds[i].revents & POLLOUT) {
                printf("fd%d is ready for writing\n", i+1);
            }
            if (fds[i].revents & (POLLERR | POLLHUP | POLLNVAL)) {
                printf("Error on fd%d\n", i+1);
            }
        }
    }

    return 0;
}

优点:

  • 不需要使用fd_set,避免了select中的1024文件描述符的限制。
  • 不需要每次调用都重新设置监视的文件描述符,使用结构体数组更加灵活。

缺点:

  • 和select一样,也是轮询检测,效率有限。当文件描述符数量较大时,性能可能不如其他更高级的机制,如epoll。

三、epoll

        在Linux系统中,epoll是一种高效的多路转接(Multiplexing)机制,用于监视多个文件描述符,并在有就绪事件时进行处理。相较于select和poll,epoll在处理大量文件描述符的并发时性能更优。

        epoll的底层原理要比select和poll复杂的多,首先它有三个接口:

#include <sys/epoll.h>
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实例,返回epoll文件描述符(epfd)。
  • epoll_ctl:控制epoll的行为,如添加、修改、删除监视的文件描述符。
  • epoll_wait:等待就绪事件,并返回就绪的文件描述符。

(1)epoll_create

        epoll_creat函数的作用,是新申请一个文件描述符(即返回值),其内部创建了一个红黑树,用来存储epoll需要监控的文件描述符以及需要监控的就绪时间类型(可读、可写、异常等)。

        红黑树的每一个节点都是一个epoll_event:

struct epoll_event {
    __uint32_t events; // 存放发生的事件类型,可以是多个事件的位掩码
    epoll_data_t data; // 用户数据,可以是指针、文件描述符等
};

typedef union epoll_data {
    void *ptr;
    int fd;
    __uint32_t u32;
    __uint64_t u64;
} epoll_data_t;

        其中event中存储的是需要监控的就绪事件类型,data是一个共用体,用户可以自己决定用来存储什么信息,比如存储对应的文件描述符,或是一个结构体指针,结构体内部定义更丰富的信息。红黑树内得每个节点,都代表着内核要监控的文件及就绪事件类型。

(2)epoll_ctl

        epoll_ctl函数得作用其实就是向epoll_create所创建得红黑树内部添加、删除或修改节点。

#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
  • epfd是epoll文件描述符,通过epoll_create创建的。

  • op是操作类型,有三种取值:

    • EPOLL_CTL_ADD:将文件描述符fd添加到epoll实例中。
    • EPOLL_CTL_MOD:修改文件描述符fd在epoll实例中的监视事件。
    • EPOLL_CTL_DEL:从epoll实例中删除文件描述符fd
  • fd是要进行操作的文件描述符,可以是socket、文件等。

  • event是一个指向struct epoll_event结构体的指针,用于指定要添加或修改的事件。struct epoll_event结构体中的events字段用于指定感兴趣的事件类型,data字段用于存储用户数据,可以是指针、文件描述符等。

  • 函数返回值:成功返回0;失败返回-1,失败原因存储在errno中。

(3)epoll_wait

                理所当然,epoll_wait就是用来从内核中获取哪些被监控的文件产生了就绪事件的接口了。

#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
  • epfd是epoll文件描述符,通过epoll_create创建的。

  • events是一个指向struct epoll_event结构体数组的指针,用于存储就绪的事件。

  • maxeventsevents数组的大小,即最多可以处理的就绪事件数量。

  • timeout是超时时间,单位为毫秒。当timeout为-1时,表示阻塞模式,即直到有就绪事件才返回;当timeout为0时,表示非阻塞模式,即立即返回,不管是否有就绪事件;当timeout大于0时,表示等待指定时间,超时后如果没有就绪事件则返回。

  • 函数返回值:成功返回就绪的文件描述符数量,如果超时返回0;失败返回-1,失败原因存储在errno中。

        epoll与select和poll最大的不同就在这里了,epoll底层不仅存在着一个红黑树,还要一个回调机制,和就绪队列。

        当文件描述符上有就绪事件时,内核会触发内部的回调机制,内核检查该文件描述符是否在epoll对象的红黑树中(log2 N的时间复杂度),如果在,则再检测该就绪事件是否是红黑树对应节点中的events所设置了要监控的。如果是就将该节点的epoll_event存储到内核中的就绪epoll_event队列中,等待用户调用epoll_wait函数后,就将内核中的epoll_event就绪队列中的内容拷贝到用户通过epoll_wait函数提供的struct epoll_event *events参数中去。

        如此一来,用户直接就可以从struct epoll_event *events直接得出哪些fd产生了就绪事件,不需要再像select和poll中那样对位图或数组进行遍历查询了,大幅提高了效率。

优点:

  • 在大量文件描述符的并发情况下,epoll性能更高,因为它使用了回调机制,不需要像select和poll一样轮询检测。

  • 支持ET(边缘触发)和LT(水平触发)两种工作模式。默认是LT模式,ET模式可以通过epoll_ctl函数的EPOLLET标志来设置。

  • 不受文件描述符数量限制,因为它是基于红黑树实现的。

缺点:

  • epoll是Linux特有的系统调用,在跨平台要求的情况下需要考虑其他方案。

  • 对于少量文件描述符的情况,select和poll可能比epoll略微更高效,因为epoll需要维护红黑树的数据结构。

        总的来说,epoll是Linux下高效的多路转接机制,特别适用于处理大量并发连接的网络服务器。在实际开发中,可以根据具体的需求选择合适的多路转接机制。

(4)epoll的两种触发模式

水平触发模式(LT,Level-Triggered):

        默认情况,在水平触发模式下,当文件描述符上有就绪事件时,内核会持续触发回调机制,直到用户程序将事件处理完毕。也就是说,只要文件描述符处于就绪状态,每次调用epoll_wait都会返回该文件描述符,无论用户程序是否处理了就绪事件。这种模式需要用户程序自己负责处理所有就绪事件。

特点:

  • 在水平触发模式下,用户程序需要注意在处理就绪事件时确保数据完全读取或写入,否则下次调用epoll_wait时该文件描述符仍然会被返回,导致重复触发。

  • 水平触发模式是默认的工作模式,也是与select和poll的行为相似的模式。

边缘触发模式(ET,Edge-Triggered):

在边缘触发模式下,当文件描述符上的事件状态从未就绪变为就绪时,内核会触发回调机制,确保只通知一次。也就是说,只有当文件描述符的就绪状态发生变化时,epoll_wait才会返回该文件描述符,一旦返回,用户程序需要处理所有就绪事件,否则下次调用epoll_wait时不会再次返回该文件描述符。边缘触发模式要求用户程序能够及时处理就绪事件。

特点:

  • 边缘触发模式在应对大量并发连接时具有更高的性能,因为它避免了重复通知的开销。

  • 在边缘触发模式下,用户程序需要注意在处理就绪事件时尽可能多地读取或写入数据,确保将缓冲区中所有的就绪数据处理完毕,以免造成数据丢失或阻塞。

  • 用户程序需要谨慎处理ET模式,因为它要求用户能够以更高的效率处理文件描述符上的数据。

工作模式选择:

在使用epoll时,可以通过设置epoll_ctl函数的EPOLLET标志来选择工作模式。默认情况下,epoll是使用水平触发模式的。

示例:

struct epoll_event event;
event.events = EPOLLIN | EPOLLET; // 设置边缘触发模式
event.data.fd = fd; // 你要监视的文件描述符fd
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &event);

        需要注意的是,边缘触发模式可能会导致更多的通知事件,因此在使用边缘触发模式时,用户程序需要更加高效地处理就绪事件,以免造成性能问题。另外,边缘触发模式在一些特定情况下可能会有一些坑,需要谨慎使用和处理。如果用户程序无法及时处理就绪事件,可能会导致文件描述符一直处于就绪状态,导致epoll_wait一直返回该文件描述符,进而引起CPU高占用。

四、Reactor反应堆模式

        有了epoll后,就可以做一些有趣的事了,将一个文件描述符与将对它进行读写等处理的函数进行封装(通过函数指针或者仿函数),组成一个对象;再将若干个这样的对象交由一个基于epoll的对象进行管理,即可实现自动化处理文件I/O操作。这样的模式,就叫做Reactor模式,这个管理若干个fd对象的基于epoll的对象就像一个反应堆一样,一个个fd对象就是原材料,一旦加入到反应堆中去,就会被自动调用其内部封装的各个函数,这也叫做事件驱动。

  • Reactor模式是一种基于事件驱动的网络编程模式,它将I/O事件和业务逻辑分离,实现了高内聚和低耦合的设计原则。
  • Reactor模式有四个主要角色:Reactor、Handler、Acceptor和Dispatcher。
    • Reactor是一个单例对象,它负责创建和管理一个epoll对象,并提供注册和取消注册Handler的接口。
    • Handler是一个抽象类,它定义了处理I/O事件的接口,如handle_read、handle_write等。每个Handler都关联一个fd和一个Reactor对象。
    • Acceptor是一个特殊的Handler,它负责监听客户端连接请求,并创建新的Handler来处理连接。
    • Dispatcher是一个线程池对象,它负责调度Handler来执行相应的业务逻辑。
  • Reactor模式的工作流程如下:
    • 首先,Reactor对象创建一个epoll对象,并注册一个Acceptor对象来监听listen_fd。
    • 然后,Reactor对象调用epoll_wait来等待I/O事件,并将就绪的事件集合传递给Dispatcher对象。
    • 接着,Dispatcher对象根据事件类型,从线程池中分配一个线程来执行对应的Handler对象的回调函数。
    • 最后,Handler对象根据事件类型,执行相应的业务逻辑,如读取或发送数据,或者关闭连接。

        总的来说,Reactor模式是一种基于事件驱动的网络编程模式,它利用epoll的事件驱动机制,将I/O事件和业务逻辑分离,实现了高并发和高性能的服务器。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值