C++ TinyWebServer项目总结(12. 高性能I/O框架库Libevent)

Linux服务器程序必须处理三类事件(I/O、信号和定时事件),在处理这三类事件时需要考虑以下问题:

  1. **统一事件源。**统一处理这三类事件既能使代码简单易懂,又能避免一些潜在的逻辑错误。实现统一事件源的一般方法:利用 I/O复用系统调用来管理所有事件。
  2. **可移植性。**不同的操作系统有不同的I/O复用方式,如Solaris的dev/poll文件、FreeBSD的kqueue机制、Linux的epoll系列系统调用。
  3. **对并发编程的支持。**在多进程和多线程环境下,我们需要考虑各执行实体如何协同处理客户连接、信号、定时器,以避免竞态条件。

开源社区提供了很多优秀的开源I/O框架库,它们不仅解决了以上问题,让开发者可以将精力完全放在程序的逻辑上,而且稳定性、性能等各方面都相当出色,如ACE、ASIO 和 Libevent,本章介绍其中相对轻量级的Libevent框架库。

I/O 框架库概述

I/O框架库以库函数的形式,封装了较为底层的系统调用,给应用程序提供了一组更便于使用的接口。

各种I/O框架库的实现基本原理相似,要么以Reactor模式实现,要么以Proactor模式实现,要么同时以这两种模式实现。例如,基于Reactor模式的I/O框架库包含以下组件:句柄(Handle)、事件多路分发器(EventDemultiplexer)、事件处理器(EventHandler)、具体的事件处理器(ConcreteEventHandler)、Reactor。这些组件的关系见下图:

句柄(Handle)

I/O框架库要处理的对象,即I/O事件、信号、定时事件,统一称为事件源。一个事件源通常和一个句柄绑定在一起。句柄的作用是,当内核检测到就绪事件时,它将通过句柄来通知应用进程这一事件。在Linux环境下,I/O事件对应的句柄是文件描述符,信号事件对应的句柄就是信号值。

事件多路分发器(EventDemultiplexer)

事件的到来时随机的、异步的,我们无法预知进程何时收到一个客户连接请求,或收到一个暂停信号,所以进程需要循环地等待并处理事件,这就是事件循环。在事件循环中,等待时间一般使用I/O复用技术来实现。I/O框架库一般将系统支持的各种I/O复用系统调用封装成统一的接口,称为事件多路分发器。事件多路分发器的demultiplex方法是等待事件的核心函数,其内部调用的是selectpollepoll_wait等函数。

此外,事件多路分发器还需实现register_eventremove_event方法,以供调用者往事件多路分发器中添加事件和从事件多路分发器中删除事件。

事件处理器(EventHandler)和具体事件处理器(ConcreteEventHandler)

事件处理器执行事件对应的业务逻辑。它通常包含一个或多个handle_event回调函数,这些回调函数在事件循环中被执行。I/O框架库提供的事件处理器通常是一个接口,用户需要继承它来实现自己的事件处理器,即具体事件处理器,因此,事件处理器中的回调函数一般被声明为虚函数,以支持用户的扩展。

此外,事件处理器一般还提供get_handle方法,它返回与该事件处理器关联的句柄。当事件多路分发器检测到有事件发生时,它是通过句柄来通知应用进程的,由于我们将句柄和事件处理器绑定,才在事件发生时获取到正确的事件处理器。

Reactor

它是I/O框架库的核心,它提供的几个主要方法是:

  1. handle_events。该方法执行事件循环,它重复以下过程:等待事件,然后依次处理所有就绪事件对应的事件处理器。
  2. register_handler。该方法调用事件多路分发器的register_event方法来往事件多路分发器中注册一个事件。
  3. remove_handler。该方法调用事件多路分发器的remove_event方法来删除事件多路分发器中的一个事件。

I/O 框架库的工作时序:

Libevent 源码分析

Libevent是开源的高性能I/O框架库,使用Livevent的著名案例有:高性能的分布式内存对象缓存软件memcached,Google浏览器Chromiun的Linux版本。Libevent的特点:

  1. 跨平台支持。Libevent支持Linux、UNIX、Windows。
  2. 统一事件源。Libevent对I/O事件、信号、定时事件提供统一的处理。
  3. 线程安全。Libevent使用libevent_pthreads库来提供线程安全支持。
  4. 基于Reactor模式实现。

Libevent的官网是libevent,其中提供Libevent源码的下载,以及Libevent框架库的第一手文档,且源码和文档的更新也较为频繁。作者游双大佬写书时使用的Libevent版本是2.0.19。

event 结构体

Libevent中的事件处理器是event结构类型,event结构体封装了句柄、事件类型、回调函数、其他必要的标志和数据,该结构体在include/event2/event_struct.h文件中定义:

struct event
{
    TAILQ_ENTRY(event) ev_active_next; /* 活动事件队列 */
    TAILQ_ENTRY(event) ev_next; /* 注册事件队列 */
    union {
        TAILQ_ENTRY(event) ev_next_with_common_timeout;
        int min_heap_idx;
    } ev_timeout_pos;
    evutil_socket_t ev_fd;
    struct event_base *ev_base;
    
    union {
        struct {
            TAILQ_ENTRY(event) ev_io_next;
            struct timeval ev_timeout;
        } ev_io;
        
        struct {
            TAILQ_ENTRY(event) ev_signal_next;
            short ev_ncalls;
            short *ev_pncalls;
        } ev_signal;
    } _ev;
    
    short ev_events; /* 事件类型 */
    short ev_res;
    short ev_flags;
    ev_uint8_t ev_pri;
    ev_uint8_t ev_closure;
    struct timeval ev_timeout;
    
    void (*ev_callback)(evutil_socket_t, short, void *arg);
    void *ev_arg;
};

下面介绍event结构体中成员:

  • ev_events。它代表事件类型。

  • ev_next。所有已经注册的事件处理器(包括I/O事件处理器和信号事件处理器)通过该成员串联成一个尾队列,我们称之为注册事件队列。宏TAILQ_ENTRY是尾队列中的节点类型,它定义在compat/sys/queue.h文件中:

    #define TAILQ_ENTRY(type)
    struct {
    struct type* tqe_next; /*下一个元素 */
    struct type**tqe_prev; /前一个元素的地址/
    }

  • ev_active_next。所有被激活的事件处理器通过该成员串联成一个尾队列,我们称之为活动事件队列。活动事件队列不止一个,不同优先级的事件处理器被激活后将被插入不同的活动事件队列中。在事件循环中,Reactor将按优先级从高到低遍历所有活动事件队列,并依次处理其中的事件处理器。

  • ev_timeout_pos。这是一个联合体,它仅用于定时事件处理器,为讨论方便,后面我们称定时事件处理器为定时器,老版本的Libevent中,定时器都是由时间堆来管理的,但开发者认为有时使用简单的链表来管理定时器效率更高,因此,新版本Libevent引入了通用定时器的概念,这些定时器不是存储在时间堆中,而是存储在尾队列中,我们称之为通用定时器队列,对于通用定时器而言,ev_timeout_pos联合体的ev_next_with_common_timeout成员指出了该定时器在通用定时器队列中的位置,对于其他定时器而言,ev_time_pos联合体的min_heap_idx成员指出了该定时器在时间堆中的位置。一个定时器是否是通用定时器取决于其超时值大小,具体判断原则可参考event.c文件中的is_common_timeout函数。

  • _ev。这是一个联合体,所有具有相同文件描述符值的I/O事件处理器通过ev.ev_io.ev_io_next成员串联成一个尾队列,我们称之为I/O事件队列;所有具有相同信号值的信号事件处理器通过ev.ev_signal.ev_signal_next成员串联成一个尾队列,我们称之为信号事件队列,ev.ev_signal.ev_ncalls成员指定信号事件发生时,Reactor需要执行多少次该事件对应的事件处理器中的回调函数,ev.ev_signal.ev_pncalls指针成员要么是NULL,要么指向ev.ev_signal.ev_ncalls

在程序中,我们可能针对同一个socket文件描述符上的可读(可写)事件创建多个事件处理器(它们拥有不同的回调函数),当该文件描述符上有可读(可写)事件发生时,所有这些事件处理器都应该被处理,所以Libevent使用I/O事件队列将具有相同文件描述符值的事件处理器组织在一起,这样,当一个文件描述符上有事件发生时,事件多路分发器能很快把所有相关的事件处理器添加到活动事件队列中。信号事件队列的存在也是由于相同的原因。

  • ev_fd。对于I/O事件处理器,它是文件描述符值;对于信号事件处理器,它是信号值。
  • ev_base。该事件处理器从属的event_base实例。
  • ev_res。它记录当前激活事件的类型。
  • ev_flags。它是一些事件标志。
  • ev_pri。它指定事件处理器的优先级,值越小优先级越高。
  • ev_closure。它指定event_base执行事件处理器的回调函数时的行为,其可选值定义在event-internal.h文件中:
  • ev_timeout。它仅对定时器有效,指定定时器的超时值。
  • ev_callback。它是事件处理器的回调函数,由event_base调用,回调函数被调用时,它的3个参数分别被传入事件处理器的以下3个成员:ev_fdev_resev_arg
  • ev_arg。回调函数的参数。

往注册事件队列中添加事件处理器

创建一个event对象的函数是event_new(及其变体),它在event.c文件中实现,该函数很简单,主要给event对象分配内存并初始化它的部分成员,因此我们不讨论它。event对象创建好后,应用需要调用event_add函数将其添加到注册事件队列中,并将对应的事件注册到事件多路分发器上。event_add函数在event.c文件中实现,主要是调用另一个内部函数event_add_internal,它的内部调用了几个重要函数:

  • evmap_io_add。该函数将I/O事件添加到事件多路分发器中,并将对应事件处理器添加到I/O事件队列中,同时建立I/O 事件和I/O事件处理器之间的映射关系。
  • evmap_signal_add。该函数将信号事件添加到事件多路分发器中,并将对应的事件处理器添加到信号事件队列中,同时建立信号事件和信号事件处理器之间的映射关系。
  • event_queue_insert。该函数将事件处理器添加到各种事件队列中:将I/O事件处理器和信号事件处理器插入注册事件队列;将定时器插入通用定时器队列或时间堆;将被激活的事件处理器添加到活动事件队列中。

往事件多路分发器中注册事件

以上event_queue_insert函数所做的仅仅是将一个事件处理器加入event_base的某个事件队列中,对于新添加的I/O事件处理器和信号事件处理器,我们还需让事件多路分发器来监听其对应的事件,同时建立文件描述符、信号值、事件处理器之间的映射关系,这需要通过evmap_io_addevmap_signal_add函数来完成,这两个函数相当于事件多路分发器中的register_event方法,它们由evmap.c文件实现。

eventop 结构体

eventop结构体封装了I/O复用机制必要的一些操作,如注册事件、等待事件。它为event_base支持的所有后端I/O复用机制提供了一个统一的接口,该结构体定义在event-internal.h文件中:

struct eventop {
    // 后端I/O复用技术的名称
    const char *name;
    // 初始化函数
    void *(*init)(struct event_base *);
    // 注册事件
    int (*add)(struct event_base *, evutil_socket_t fd, short old, short events, void *fdinfo);
    // 删除事件
    int (*del)(struct event_base *, evutil_socket_t fd, short old, short events, void *fdinfo);
    // 等待事件
    int (*dispatch)(struct event_base *, struct timeval *);
    // 释放I/O复用机制使用的资源
    void (*dealloc)(struct event_base *);
    // 程序调用fork后是否需要重新初始化event_base
    int need_reinit;
    // I/O复用技术支持的一些特性,是以下可选值的按位或:
    // EV_FEATURE_ET:支持边沿触发事件EV_ET
    // EV_FEATURE_O1:事件检测算法的复杂度是O(1)
    // EV_FEATURE_FDS:不仅能监听socket上的事件,还能监听其他类型文件描述符上的事件
    enum event_method_feature features;
    // 有的I/O复用机制需要为每个I/O事件队列和信号事件队列分配额外的内存,该内存用于存放文件描述符
    // 以避免同一个文件描述符被重复插入IO复用机制的事件表中
    // evmap_io_add和evmap_io_del函数在调用eventop的add或del方法时,将这段内存的起始地址传递给该方法
    // fdinfo_len指定了这段内存的长度
    size_t fdinfo_len;
};

devpoll.ckqueue.cevport.cselect.cwin32select.cpoll.cepoll.c文件分别针对不同的I/O复用技术实现了eventop定义的这套接口,在支持多种I/O复用技术的系统上,Libevent选择使用哪个取决于这些I/O复用技术的优先级。Libevent支持的后端I/O复用技术及它们的优先级定义在event.c文件中,在Linux下,Libevent默认选择的后端I/O复用技术是epoll

event_base 结构体

结构体event_base是Libevent的Reactor,它定义在event-internal.h文件中:

struct event_base {
    // 初始化Reactor时选择一种后端I/O复用机制,并记录在该字段中
    const struct eventop *evsel;
    // 指向I/O复用机制真正存储的数据,它通过evsel成员的init函数来初始化
    void *evbase;
    // 事件变化队列,用途是:如果一个文件描述符上注册的事件被多次修改,则可使用缓冲来避免重复的系统调用(如epoll_ctl函数)
    // 它仅能用于时间复杂度为O(1)的IO复用技术
    struct event_changelist changelist;
    // 指向信号的后端处理机制,目前仅在signal.h文件中定义了一种处理方法
    const struct eventop *evsigsel;
    // 信号事件处理器使用的数据结构,其中封装了一个由socketpair函数创建的管道
    // 它用于信号处理函数和事件多路分发器之间的通信,与统一事件源的思路相同
    struct evsig_info sig;
    // 添加到该event_base的虚拟事件、所有事件、激活事件的数量
    int virtual_event_count;
    int event_count;
    int event_count_active;
    // 是否执行完活动事件队列上剩余的任务后就退出事件循环
    int event_gotterm;
    // 是否立即退出事件循环,而不管是否还有任务需要处理
    int event_break;
    // 是否应启动一个新的事件循环
    int event_continue;
    // 目前正在处理的活动事件队列的优先级
    int event_running_priority;
    // 事件循环是否已启动
    int running_loop;
    // 活动事件队列数组,索引值越小的队列,优先级越高,高优先级的活动事件队列中的事件处理器将被优先处理
    struct event_list *activequeues;
    // 活动事件队列数组的大小,即该event_base一共有nactivequeues个不同优先级的活动事件队列
    int nactivequeues;
    // 以下3个成员管理通用定时器队列
    struct common_timeout_list **common_timeout_queues;
    int n_common_timeouts;
    int n_common_timeouts_allocated;
    // 存放延迟回调函数的链表,事件循环每次成功处理完一个活动事件队列中的所有事件后,就调用一次延迟回调函数
    struct deferred_cb_queue defer_queue;
    // 文件描述符和I/O事件之间的映射关系表
    struct event_io_map io;
    // 信号值和信号事件之间的映射关系表
    struct event_signal_map sigmap;
    // 注册事件队列,存放I/O事件处理器和信号事件处理器
    struct event_list eventqueue;
    // 时间堆
    struct min_heap timeheap;
    // 管理系统时间的一些成员
    struct timeval event_tv;
    struct timeval tv_cache;
#if defined(_EVENT_HAVE_CLOCK_GETTIME) && defined(CLOCK_MONOTONIC)
    struct timeval tv_clock_diff;
    time_t last_updated_clock_diff;
#endif

// 多线程支持
#ifndef _EVENT_DISABLE_THREAD_SUPPORT
    // 当前运行该event_base的事件循环的线程
    unsigned long th_owner_id;
    // 对event_base的独占锁
    void *th_base_lock;
    // 当前事件循环正在执行哪个事件处理器的回调函数
    struct event *current_event;
    // 条件变量,用于唤醒正在等待某个事件处理完毕的线程
    void *current_event_cond;
    // 等待current_event_cond的线程数
    int current_event_waiters;
#endif

#ifdef WIN32
    struct event_iocp_port *iocp;
#endif

    // 该event_base的一些配置参数
    enum event_base_config_flag flags;
    // 以下成员给工作线程唤醒主线程提供了方法(使用socketpair函数创建的管道)
    int is_notify_pending;
    evutil_socket_t th_notify_fd[2];
    struct event th_notify;
    int (*th_notify_fn)(struct event_base *base);
};

事件循环

最后讨论一下Libevent的动力,即事件循环。Libevent中实现事件循环的函数是event_base_loop,该函数首先调用I/O事件多路分发器的事件监听函数,以等待事件,当有事件发生时,就依次处理之。

实战 9:利用 Libevent 库实现一个"Hello World"程序

代码位于:

#include <sys/signal.h>
#include <event.h>

/* 这个回调函数在捕获到信号后被调用,它设定了一个两秒后的延迟退出事件循环。 */
void signal_cb(int fd, short event, void *argc) {
    struct event_base *base = (event_base *)argc;
    struct timeval delay = {2, 0};
    printf("Caught an interrupt signal; exiting cleanly in two seconds...\n");
    event_base_loopexit(base, &delay);
}

void timeout_cb(int fd, short event, void *argc) {
    printf("timeout\n");
}

int main() {
    struct event_base *base = event_init();

    /* 这里创建了一个信号事件处理器,用来捕获 SIGINT(通常是用户按下 Ctrl+C 生成的中断信号)。当信号发生时,调用 signal_cb 回调函数。 */
    struct event *signal_event = evsignal_new(base, SIGINT, signal_cb, base);
    event_add(signal_event, NULL);

    /* 创建一个定时事件处理器,1秒后触发一次 timeout_cb 回调函数。 */
    timeval tv = {1, 0};
    struct event *timeout_event = evtimer_new(base, timeout_cb, NULL);
    event_add(timeout_event, &tv);

    /* 开始事件处理循环,这行代码将会阻塞,直到事件循环被终止。 */
    event_base_dispatch(base);

    /* 资源释放 */
    event_free(timeout_event);
    event_free(signal_event);
    event_base_free(base);
}

首先确保安装了 Libevent 库。 编译程序时,需要链接 libevent 库:

gcc -o example example.cpp -levent

运行程序,然后按 Ctrl+C 触发信号处理, 查看程序如何响应并在两秒后优雅退出。

程序启动后,经过 1s 触发定时事件处理器的回调函数,执行printf("timeout\n");,按下Ctrl+C 后,

执行信号事件处理器的回调函数,printf("Caught an interrupt signal; exiting cleanly in two seconds...\n");并在 2s 后退出。

代码解析

  1. 调用event_init函数创建event_base对象。一个event_base对象相当于一个Reactor实例。event_base 是 libevent 处理事件的核心结构。

    struct event_base *base = event_init();

  2. 创建具体的事件处理器,并设置它们所从属的Reactor实例。evsignal_newevtimer_new分别用于创建信号事件处理器和定时事件处理器,它们是定义在include/event2/event.h文件中的宏:

    #define evsignal_new(b, x, cb, arg)
    event_new((b), (x), EV_SIGNAL|EV_PERSIST, (cb), (arg))
    #define evtimer_new(b, cb, arg) event_new((b), -1, 0, (cb), (arg))

它们的统一入口是event_new函数,即用于创建通用事件处理器(图12-1中的EventHandler)的函数,其定义是:

struct event* event_new(struct event_base* base, evutil_socket_t fd, short events, 
                            void (*cb)(evutil_socket_t, short, void*), void* arg);
  • base:指定新创建的事件处理器的从属Reactor。
  • fd:指定与该事件处理器关联的句柄。对于I/O事件处理器,fd是文件描述符;对于信号事件处理器,fd是信号值;对于定时事件处理器,要给fd传递-1。
  • events:指定事件类型
  • cb:指定目标事件对应的回调函数,相当于图12-1中事件处理器的handle_event回调函数。
  • arg:Reactor传递给回调函数的参数。

event_new函数成功时返回一个event类型的对象,即 libevent的事件处理器。Libevent用event描述事件处理器,而不是事件,可能会使读者混乱,因此我们有如下约定:

  • 事件指的是一个句柄上绑定的事件,如文件描述符0上的可读事件。
  • 事件处理器,也就是event结构体对象,除了包含事件必须具备的两个要素(句柄和事件类型)外,还有其他成员,如回调函数。
  • 事件由事件多路分发器管理,事件处理器则由事件队列管理。事件队列包括多种,如event_base中的注册事件队列、活动事件队列、通用定时器队列,以及evmap中的I/O事件队列、信号事件队列。
  • 事件循环对一个被激活事件(就绪事件)的处理,指的是执行该事件对应的事件处理器中的回调函数。
  1. 调用event_add函数,将事件处理器添加到注册事件队列中,并将该事件处理器对应的事件添加到事件多路分发器中。event_add函数相当于Reactor中的register_handler方法。
  2. 调用event_base_dispatch指定事件循环。
  3. 时间循环结束后,使用*_free系列函数释放系统资源。

参考文章

  1. Linux高性能服务器编程 学习笔记 第十二章 高性能IO框架库Libevent-CSDN博客
  2. Linux高性能服务器编程-游双------第十二章 高性能IO框架库Libevent_游双的linux高性能服务器编-CSDN博客
相关推荐
染指111016 分钟前
50.第二阶段x86游戏实战2-lua获取本地寻路,跨地图寻路和获取当前地图id
c++·windows·lua·游戏安全·反游戏外挂·游戏逆向·luastudio
Code out the future34 分钟前
【C++——临时对象,const T&】
开发语言·c++
Zmxcl-00737 分钟前
IIS解析漏洞
服务器·数据库·microsoft
Stark、37 分钟前
【Linux】文件IO--fcntl/lseek/阻塞与非阻塞/文件偏移
linux·运维·服务器·c语言·后端
sam-zy1 小时前
MFC用List Control 和Picture控件实现界面切换效果
c++·mfc
aaasssdddd961 小时前
C++的封装(十四):《设计模式》这本书
数据结构·c++·设计模式
发呆小天才O.oᯅ1 小时前
YOLOv8目标检测——详细记录使用OpenCV的DNN模块进行推理部署C++实现
c++·图像处理·人工智能·opencv·yolo·目标检测·dnn
新手上路狂踩坑1 小时前
Android Studio的笔记--BusyBox相关
android·linux·笔记·android studio·busybox
qincjun2 小时前
文件I/O操作:C++
开发语言·c++
星语心愿.2 小时前
D4——贪心练习
c++·算法·贪心算法