(十四)支持多种IO多路复用的技术

本文详细解析了libevent库如何通过统一的接口支持多种I/O多路复用机制,特别是针对epoll的具体实现细节。

前言

众所周知,libevent支持多种I/O多路复用,如select、poll、epoll、kqueue等。那么其中是如何实现的呢?
主要就是结构体eventop,它内部成员有几个函数指针,统一了每种I/O多路复用的接口,也就是说,要想libevent支持某种I/O多路复用,就必须实现这几种接口。结构体eventop位于event-internal.h中。

eventop

struct eventop
{
  const char *name;
  void *(*init)(struct event_base *); //初始化
  int (*add)(void *, struct event *); //添加事件
  int (*del)(void *, struct event *); //删除事件
  int (*dispatch)(struct event_base *, void *, struct timeval *); //分发事件
  void (*dealloc)(struct event_base *, void *); //释放资源
  int need_reinit;
};

这5个函数指针分别指向某个具体的多路I/O复用机制的初始化、添加事件、删除事件、分发事件、释放资源这5个操作上。这样,libevent就可以支持该多路I/O复用机制了。
但是其实我们使用的时候并没有选择用哪一种,说明是有默认选项的。

event.c文件中,有一个静态全局的eventops数组,并且按照优先级选择用哪一种多路I/O复用机制。定义如下:

static const struct eventop *eventops[] = {
#ifdef HAVE_EVENT_PORTS
        &evportops,
#endif
#ifdef HAVE_WORKING_KQUEUE
        &kqops,
#endif
#ifdef HAVE_EPOLL
        &epollops,
#endif
#ifdef HAVE_DEVPOLL
        &devpollops,
#endif
#ifdef HAVE_POLL
        &pollops,
#endif
#ifdef HAVE_SELECT
        &selectops,
#endif
#ifdef WIN32
        &win32ops,
#endif  
        NULL
};

这里主要运用了条件编译,在config.h中define了可以支持的多路I/O复用,由于只是简单的define,并且代码比较多,就不在这里列举了,可以自己看一看。

epollops

接下来,我们以epoll为例来看一看epollops的真面目。
epoll.c文件中:

const struct eventop epollops = {
    "epoll",
    epoll_init,
    epoll_add,
    epoll_del,
    epoll_dispatch,
    epoll_dealloc,
    1 /* need reinit */
  };

那么只用实现epoll_initepoll_addepoll_delepoll_dispatchepoll_dealloc这几个函数就行了。
首先先看一下这几个函数要用到的结构体

//对应读和写事件
struct evepoll {
    struct event *evread;
    struct event *evwrite;
};

struct epollop {
    //每个fd可对应读/写事件
    struct evepoll *fds;
    //fd的数量
    int nfds;
    //epoll事件
    struct epoll_event *events;
    //事件的数量
    int nevents;
    //epoll专用文件描述符
    int epfd;
};

epoll_init

接下来是epoll_init:

static void *
epoll_init(struct event_base *base)
{
    int epfd;
    struct epollop *epollop;

    /* Disable epollueue when this environment variable is set */
    if (evutil_getenv("EVENT_NOEPOLL"))
        return (NULL);

    /* Initalize the kernel queue */
    //创建epoll句柄
    if ((epfd = epoll_create(32000)) == -1) {
        if (errno != ENOSYS)
            event_warn("epoll_create");
        return (NULL);
    }
    //这是为了防止在使用多进程时,子进程继承父进程打开的文件描述符及权限。所以设置FD_CLOEXEC标志。
    FD_CLOSEONEXEC(epfd);
    //给epollop申请空间
    if (!(epollop = calloc(1, sizeof(struct epollop))))
        return (NULL);

    epollop->epfd = epfd;

    /* Initalize fields */
    epollop->events = malloc(INITIAL_NEVENTS * sizeof(struct epoll_event));
    //当申请空间失败时,把之前申请的也释放了,然后return
    if (epollop->events == NULL) {
        free(epollop);
        return (NULL);
    }
    epollop->nevents = INITIAL_NEVENTS;

    epollop->fds = calloc(INITIAL_NFILES, sizeof(struct evepoll));
    //同理
  if (epollop->fds == NULL) {
        free(epollop->events);
        free(epollop);
        return (NULL);
    }
    epollop->nfds = INITIAL_NFILES;
    //由于libevent为了将signal也集成到事件主循环中,所以使用了套结字对(socket pair)。这个函数就用于创建socket pair和初始化evsignal_info
    evsignal_init(base);

    return (epollop);
}

epoll_add

epoll_add:

static int
epoll_add(void *arg, struct event *ev)
{
    struct epollop *epollop = arg;
    struct epoll_event epev = {0, {0}};
    struct evepoll *evep;
    int fd, op, events;

    //如果是signal事件,直接调用evsignal_add来添加就行了
    if (ev->ev_events & EV_SIGNAL)
        return (evsignal_add(ev));

    fd = ev->ev_fd;
    //当前的文件描述符大于nfds时,需要重新扩展。(这点是利用了linux系统优先分配空闲的最小值fd)
    if (fd >= epollop->nfds) {
        /* Extent the file descriptor array as necessary */
        if (epoll_recalc(ev->ev_base, epollop, fd) == -1)
            return (-1);
    }
    //evep是当前fd(需要add的)对应的struct evepoll(里面是读/写事件)
    evep = &epollop->fds[fd];
    //对应的默认操作是添加操作
    op = EPOLL_CTL_ADD;
    events = 0;
    //如果已经指向了一个读事件,证明该fd已经在epoll监听中了,所以应该将操作改为EPOLL_CTL_MOD,但是为了防止以前的监听读事件标志被覆盖,所以重新加上。
    if (evep->evread != NULL) {
        //监听读事件
        events |= EPOLLIN;
        op = EPOLL_CTL_MOD;
    }
    //同理
    if (evep->evwrite != NULL) {
        events |= EPOLLOUT;
        op = EPOLL_CTL_MOD;
    }
    //如果设置了EV_READ标志,说明是读事件
    if (ev->ev_events & EV_READ)
        events |= EPOLLIN;
    //如果设置了EV_WRITE标志,说明是写事件
    if (ev->ev_events & EV_WRITE)
        events |= EPOLLOUT;

    //设置struct epoll_event
    epev.data.fd = fd;
    epev.events = events;
    //修改/增加fd到监听的epollop->epfd中去
    if (epoll_ctl(epollop->epfd, op, ev->ev_fd, &epev) == -1)
            return (-1);

    /* Update events responsible */
    //如果是读事件,那么让evread指向该event
    if (ev->ev_events & EV_READ)
        evep->evread = ev;
    //如果是写事件,那么让evwrite指向该event
    if (ev->ev_events & EV_WRITE)
        evep->evwrite = ev;

    return (0);
}

epoll_dispatch

接下来便是最复杂的epoll_dispatch了:

static int
epoll_dispatch(struct event_base *base, void *arg, struct timeval *tv)
{
    struct epollop *epollop = arg;
    struct epoll_event *events = epollop->events;
    struct evepoll *evep;
    int i, res, timeout = -1;
    //如果设置了超时等待时间,那么就将这时间具体多少ms算出来
    if (tv != NULL)
        timeout = tv->tv_sec * 1000 + (tv->tv_usec + 999) / 1000;
    //如果该等待时间大于了最长的等待时间,那就直接设置为最长等待时间(该最长等待时间是epoll.c里面设定的)
    //#define MAX_EPOLL_TIMEOUT_MSEC (35*60*1000)
    //即最久等35分钟.....不过可以自己修改
    if (timeout > MAX_EPOLL_TIMEOUT_MSEC) {
        /* Linux kernels can wait forever if the timeout is too big;
         * see comment on MAX_EPOLL_TIMEOUT_MSEC. */
        //linux内核是可以无限等的,但是关键还是看MAX_EPOLL_TIMEOUT_MSEC
        timeout = MAX_EPOLL_TIMEOUT_MSEC;
    }

    //epoll_wait函数我相信你应该懂的,返回值是触发了的事件总数
    res = epoll_wait(epollop->epfd, events, epollop->nevents, timeout);
    //返回-1代表出错了
    if (res == -1) {
        //如果不是被信号中断的,那么直接报错了
        if (errno != EINTR) {
            event_warn("epoll_wait");
            return (-1);
        }
        //处理signal事件
        evsignal_process(base);
        return (0);
    } else if
    //查看是否有信号的标志,如若发生,则处理signal事件
   (base->sig.evsignal_caught) {
        evsignal_process(base);
    }

    event_debug(("%s: epoll_wait reports %d", __func__, res));
    //依次处理被触发的事件
    for (i = 0; i < res; i++) {
        //what:记录什么类型的事件
        int what = events[i].events;
        struct event *evread = NULL, *evwrite = NULL;
        int fd = events[i].data.fd;

        if (fd < 0 || fd >= epollop->nfds)
            continue;
        evep = &epollop->fds[fd];
        //如果是被挂断或者出错导致被触发
        if (what & (EPOLLHUP|EPOLLERR)) {
            evread = evep->evread;
            evwrite = evep->evwrite;
        } else {
            if (what & EPOLLIN) {
                evread = evep->evread;
            }

            if (what & EPOLLOUT) {
                evwrite = evep->evwrite;
            }
        }
        //如果读/写事件都没有,直接结束本次循环
        if (!(evread||evwrite))
            continue;
        //手动激活读/写事件
        if (evread != NULL)
            event_active(evread, EV_READ, 1);
        if (evwrite != NULL)
            event_active(evwrite, EV_WRITE, 1);
    }
    //当nevents不够用的时候,重新分配
    if (res == epollop->nevents && epollop->nevents < MAX_NEVENTS) {
        /* We used all of the event space this time.  We should
           be ready for more events next time. */
        int new_nevents = epollop->nevents * 2;
        struct epoll_event *new_events;

        new_events = realloc(epollop->events,
            new_nevents * sizeof(struct epoll_event));
        if (new_events) {
            epollop->events = new_events;
            epollop->nevents = new_nevents;
        }
    }

    return (0);
}

注意epoll_dispatch函数中并没有调用处理事件的业务,而是在event_base_loop中由event_process_actice调用。相当于它的工作是只负责把事件添加到激活队列中,然后由event_process_actice处理。

接下来只剩epoll_delepoll_dealloc还有epoll_recalc了,epoll_del的逻辑和epoll_add大致相似,就不在这里列出了。而epoll_recalc无非就是重新分配内存,也没什么需要注意的,最后就来个epoll_dealloc收尾吧。

epoll_dealloc

epoll_dealloc:

static void
epoll_dealloc(struct event_base *base, void *arg)
{
    struct epollop *epollop = arg;

    evsignal_dealloc(base);
    if (epollop->fds)
        free(epollop->fds);
    if (epollop->events)
        free(epollop->events);
    if (epollop->epfd >= 0)
        close(epollop->epfd);

    //释放了空间之后,别忘了将指针指向的地方赋为null,不然指向的是已经释放了的空间,造成野指针
    memset(epollop, 0, sizeof(struct epollop));
    free(epollop);
}

这里我们来关注一下void *arg,前面已经出现过多次,可能你会对其产生疑惑,而在event_init中的函数类型也是void *,而返回的是struct epollop *,在event_add或者event_base_loop中,调用的都是evsel->add(evbase, ev)或者evsel->dispatch(base, evbase, tv_p)这样的,这说明每一个多路I/O复用都对应有它自己的struct xxxop *。之所以返回值是void *,是为了将不同的struct xxxop *转换成统一的指针类型。

小结

在本节中,我们终于知道了神秘的libevent如何同时支持了那么多种多路I/O复用机制,再配合上前面讲的主循环以及event还有event_base以及信号、定时事件。你应该可以跟着事件的主循环,在脑海中浮现出一个一个的事件是如何被添加到事件链表,以及被激活,等待调度,调度之后被销毁等等的场景了。
接下来,我们将研究libevent库中缓冲区的部分。

IO多路复用是一种高效的网络编程技术,它允许单个线程同时监视多个输入/输出(I/O)通道,并在其中任何一个通道变为可读或可写时立即得到通知。这种机制使得程序能够高效地处理大量并发连接,而不需要为每个连接分配独立的线程或者进程。 ### 原理详解 在传统的阻塞式 I/O 模型中,如果一个线程正在等待某个文件描述符上的数据到达,则该线程会一直被阻塞,直到数据准备好并且被读取完毕。这种方式对于少量的连接是可行的,但当需要处理成千上万的连接时,就会导致资源浪费和性能下降。 IO多路复用通过使用特定的系统调用如 `select`、`poll` 或者更现代的 `epoll` (Linux) 和 `kqueue` (BSD) 来解决这个问题。这些系统调用可以监听一组文件描述符,一旦集合中的任一描述符就绪(即可以进行读写操作),它们就会返回给应用程序进行相应的处理。这样做的好处是可以避免不必要的轮询以及减少上下文切换带来的开销。 例如,在使用 `epoll` 时,内核维护了一个事件表,每当有新的事件发生时,比如客户端发送了数据过来,`epoll_wait` 调用就会返回那些已经准备好的文件描述符列表,从而让服务端可以针对性地对这些就绪的连接执行读写操作 [^4]。 ### 使用场景分析 - **高并发服务器**:当构建Web服务器、数据库服务器等需要支持大量并发用户的场景下,采用IO多路复用技术能显著提高效率。它可以确保即使面对数以万计的同时连接,也能保持较低的延迟和较高的吞吐量。 - **实时通信应用**:像即时消息传递系统或是在线游戏这类要求快速响应的应用场合,利用IO多路复用可以帮助实现低延迟的消息传递与交互体验优化 [^1]。 - **混合协议处理**:若某一服务需同时处理TCP与UDP流量,或是涉及多种网络协议的情况,IO多路复用提供了一种统一管理不同类型的网络连接的方法 [^2]。 - **资源受限环境**:在内存或其他计算资源有限制的情况下,比如嵌入式设备上运行的服务,采用此技术有助于节省系统资源消耗,因为不需要为每一个连接创建额外的工作线程 [^3]。 综上所述,IO多路复用不仅解决了传统多线程模型中存在的诸多问题,而且非常适合用来开发高性能、大规模并发处理能力的服务端软件。通过对底层操作系统提供的API的有效利用,开发者能够编写出既稳定又高效的网络应用程序。 ```c // 示例代码 - 使用 epoll 创建简单的 IO 多路复用示例 #include <sys/epoll.h> #include <unistd.h> #include <stdio.h> #define MAX_EVENTS 10 int main() { int epfd = epoll_create1(0); // 创建 epoll 文件描述符 if (epfd == -1) { perror("epoll_create1"); return 1; } struct epoll_event ev, events[MAX_EVENTS]; ev.events = EPOLLIN; // 监听读事件 ev.data.fd = STDIN_FILENO; // 标准输入作为监听对象 if (epoll_ctl(epfd, EPOLL_CTL_ADD, STDIN_FILENO, &ev) == -1) { // 添加到epoll监控 perror("epoll_ctl: add"); return 1; } while (1) { int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1); // 等待事件触发 for (int i = 0; i < nfds; ++i) { if (events[i].data.fd == STDIN_FILENO) { char buffer[128]; ssize_t count = read(STDIN_FILENO, buffer, sizeof(buffer)); // 读取标准输入 if (count > 0) { printf("Read %zd bytes: %.*s\n", count, (int)count, buffer); } } } } close(epfd); return 0; } ``` 这段C语言代码展示了如何使用`epoll`来设置一个简单的IO多路复用实例,这里监听的是标准输入流。当用户向终端输入数据时,程序将捕获这一事件并打印接收到的内容。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值