Bootstrap

Linux高性能服务器编程 | 读书笔记 | 10. 高性能I/O框架库Libevent

10. 高性能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框架库。

Libevent库优点:开源,精简,跨平台,专注于网络通信。

0.libevent安装

Ubuntu安装libevent库-CSDN博客

1.I/O 框架库概述

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

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

img

1.句柄(Handle)

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

2.事件多路分发器(EventDemultiplexer)

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

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

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

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

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

4.Reactor

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

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

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

img

2.libevent基本使用–处理常规事件event

根据02-libevent简介_bilibili_哔哩哔哩_bilibili所做笔记

不做特殊说明,int类型返回值就是成功0失败-1

1.基本的libevetn框架使用流程

1. 创建 event_base		(乐高底座)

	struct event_base *event_base_new(void);

	struct event_base *base = event_base_new();

2. 创建 事件evnet	(乐高的积木)

	常规事件 event	--> event_new(); 

	bufferevent --> bufferevent_socket_new();

3. 将事件 添加到 base上	  (将积木插入底座)

	int event_add(struct event *ev, const struct timeval *tv)

4. 循环监听事件满足

	int event_base_dispatch(struct event_base *base);

		event_base_dispatch(base);

5. 释放 event_base

	event_base_free(base);

2.创建和销毁event_base

创建
创建 event_base		(乐高底座)

	struct event_base *event_base_new(void);

	struct event_base *base = event_base_new();

没有参数

返回值就是创建好的base

通常不用event_init(),这个版本比较旧,new这个版本比较新,功能更加强大

删除
event_base_free(base);

参数base就是之前创建好的base

3.创建事件evnet

struct event *ev;

struct event *event_new(struct event_base *base,evutil_socket_t fd,short what,event_callback_fn cb;  void *arg);

参数:

  • base: event_base_new()返回值。

  • fd: 绑定到 event 上的 文件描述符

  • what:对应的事件(r读、w写、e异常)

    常用:

​ EV_READ 一次 读事件

​ EV_WRTIE 一次 写事件

​ EV_PERSIST 持续触发,使该事件成为永久事件。 结合 event_base_dispatch 函数使用,生效。

  • cb:一旦事件满足监听条件,回调的函数。

  • typedef void (*event_callback_fn)(evutil_socket_t fd, short, void *)

  • arg: 回调的函数的参数。

返回值:成功创建的 event

4.添加事件到 event_base

int event_add(struct event *ev, const struct timeval *tv);

参数

  • ev: event_new() 的返回值,就是咱自己建的底座
  • tv:NULL,超时事件的话才需要

5.从event_base上摘下事件

int event_del(struct event *ev);

参数

  • ev: event_new() 的返回值。

6.销毁事件

int event_free(struct event *ev);

	ev: event_new() 的返回值。

参数

  • ev: event_new() 的返回值。

8.应用:实现本地进程间通信

打破固有印象,libevent不止可以用用来网络通信,还可以进程间通信

read.c
//read.c
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<unistd.h>
#include<error.h>
#include<event2/event.h>
#include<fcntl.h>
#include<pthread.h>
#include<sys/types.h>
#include<sys/stat.h>

void sys_err(const char *str)
{
    perror(str);
    exit(1);
}

void read_cb(evutil_socket_t fd,short what,void *arg)
{
    char buf[1024]={0};
    int len=read(fd,buf,sizeof(buf));
    printf("what=%s,read from write :%s\n",what &EV_READ? "read满足":"read不满足",buf);
    sleep(1);
    return;
}

int main(int argc,char* argv[])
{
    unlink("testfifo");
    mkfifo("testfifo",0644);

    int fd=open("testfifo",O_RDONLY | O_NONBLOCK);

    if(fd==-1)
    {
        sys_err("open err");
    }

    struct event_base* base=event_base_new();

    struct event *ev=NULL;

    ev=event_new(base,fd,EV_READ|EV_PERSIST,read_cb,NULL);

    event_add(ev,NULL);

    event_base_dispatch(base);

    event_base_free(base);

    return 0;
}
write.c
//write.c
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<unistd.h>
#include<error.h>
#include<event2/event.h>
#include<fcntl.h>
#include<pthread.h>
#include<sys/types.h>
#include<sys/stat.h>

void sys_err(const char *str)
{
    perror(str);
    exit(1);
}

void write_cb(evutil_socket_t fd,short what,void *arg)
{
    char buf[]="hello libevent";
    write(fd,buf,sizeof(buf)+1);
    sleep(1);
    return;
}

int main(int argc,char* argv[])
{
    int fd=open("testfifo",O_WRONLY | O_NONBLOCK);

    if(fd==-1)
    {
        sys_err("open err");
    }

    struct event_base* base=event_base_new();

    struct event *ev=NULL;

    ev=event_new(base,fd,EV_WRITE|EV_PERSIST,write_cb,NULL);

    event_add(ev,NULL);

    event_base_dispatch(base);

    event_base_free(base);

    return 0;
}

运行结果:

image-20241217193109519

你可能会有的疑惑

1.写了一段时间的数据以后,我关闭写进程,为啥读进程不会关闭?而仅仅只是读取不到数据了?

  1. 关于进程和事件循环(libevent)的基本原理
    • 在这个程序中,使用了 libevent 库来构建基于事件驱动的进程通信。event_base是事件循环的核心结构,用于管理和分发事件。event结构则表示一个具体的事件,包括事件对应的文件描述符(fd)、事件类型(如EV_READEV_WRITE)等信息。
    • 读进程创建了一个命名管道(mkfifo),并以非阻塞方式打开用于读取(O_RDONLY | O_NONBLOCK),然后将读事件添加到事件循环中。写进程以非阻塞方式打开同一个命名管道用于写入(O_WRONLY | O_NONBLOCK),并将写事件添加到事件循环中。
  2. 写进程关闭后读进程不关闭的原因
    • 事件循环未结束:在libevent的事件驱动模型中,读进程的event_base_dispatch函数会一直循环等待事件的发生,直到没有更多的事件需要处理或者遇到错误。当写进程关闭时,读进程只是发现没有数据可读(因为管道写端关闭),但这并不影响事件循环本身的运行。从事件循环的角度来看,它仍然在等待可能的读事件,只是暂时没有数据到来。
    • 文件描述符状态:当写进程关闭管道的写端时,对于读进程来说,管道的读端仍然是打开的。在操作系统层面,管道的读端只要不被关闭,读进程就可以继续存在。读进程会一直处于等待数据的状态(在事件循环中等待EV_READ事件),只是一直获取不到新的数据而已。
    • 事件的持续性设置(EV_PERSIST:在创建事件时,使用了EV_PERSIST标志。这意味着事件会一直存在于事件循环中,除非手动删除。对于读进程的读事件,即使没有数据可读,由于EV_PERSIST的存在,事件不会自动从事件循环中移除,所以读进程不会因为暂时没有数据可读而退出。

读进程不会关闭是因为事件循环仍在运行、管道读端未关闭以及事件的持续性设置,只是在写进程关闭后无法获取新的数据可读。

2.既然libevent因为我设置了EV_PERSIST,成为了永久事件,那为什么只要我读过数据的话,在写进程关闭以后还是会一直调用我的读进程绑定的回调函数呢?

  1. 关于EV_PERSIST标志的理解
    • 当你在 Libevent 中设置EV_PERSIST标志时,意味着这个事件会一直被添加在事件循环中,不会因为一次事件触发并处理完成后就被移除。这是为了方便处理持续可能发生的事件,比如一个持续监听网络套接字可读状态的服务器程序。
  2. 读进程回调函数持续调用的原因
    • 事件循环和文件描述符状态:在 Libevent 的事件驱动模型中,事件循环(event_base_dispatch)会不断地检查是否有事件发生。当写进程关闭后,对于读进程的管道读端文件描述符来说,它仍然处于打开状态。虽然没有数据可读,但从事件循环的角度来看,它还是会不断地检查这个可读事件(因为EV_PERSIST)。
    • read函数的行为:在read_cb函数中,当调用read函数读取管道数据时,因为写进程已经关闭,管道的写端关闭后,read函数在管道没有数据可读时会返回0(表示文件结束符)或者 - 1(表示出错)。在代码中,read函数返回值len并没有被正确检查来判断是否真的读取到数据或者遇到文件结束的情况。
    • 以下是一个修改后的read_cb函数,用于正确处理这种情况:
void read_cb(evutil_socket_t fd, short what, void *arg)
{
    char buf[1024] = {0};
    int len = read(fd, buf, sizeof(buf));
    if (len > 0)
    {
        printf("what=%s,read from write :%s\n", what & EV_READ? "read满足" : "read不满足", buf);
    }
    else if (len == 0)
    {
        // 文件结束,即写端关闭
        printf("写端已经关闭,停止读取\n");
        // 这里可以考虑移除事件或者退出事件循环
    }
    else
    {
        perror("read error");
    }
    // 不需要sleep(1),这可能会导致不必要的延迟
    return;
}

在这个修改后的函数中,当read返回0时,表示写端已经关闭,这时候可以根据实际需求选择移除事件(通过event_del函数)或者直接退出事件循环(通过event_base_loopbreak等函数),而不是继续无意义地调用回调函数。

9.事件的未决和非未决

image-20241217193355689

3.libevent基本使用–处理带缓冲区的事件 bufferevent

基于12-bufferevent特性_bilibili_哔哩哔哩_bilibili所做

这个专门用于网络套接字通信

上面处理常规事件的那套也可以用于网络套接字通信,但是只要是网络通信我们一般都会首选bufferevent

0.基本框架

与常规事件event流程一样,在常规事件中,第二步是创建常规事件

在这里是创建带缓冲区的事件bufferevent

1.bufferevent

#inlcude<event2/bufferevent.h>

原理:

bufferevent有两个缓冲区,分别是读缓冲区和写缓冲区,是由队列实现,先进先出,数据只能被读一次,读走了就没了

读:

1.读缓冲区中有数据

2.出发读缓冲对应的回调函数

3.使用bufferevent_read()读从读缓冲区中数据(顶替read,在bufferevent中不能用read,原因是bufferevent把fd封装到内部里面去了)

写:

1.使用bufferevent_wirte()往写缓冲区中写数据。这个写缓冲一旦有数据就会自动刷新,所谓的刷新就是会把写的东西发送给socket的对端

2.如果我写缓冲的数据成功发送给了socket的对端,就会回调写缓冲对应的回调函数,但是这时其实已经写完数据了,所以这个回调就很鸡肋。起的作用就是通知本进程写数据这个活已经干完了。

2.带缓冲区的事件创建和释放

创建
struct bufferevent *ev;

struct bufferevent *bufferevent_socket_new(struct event_base *base, evutil_socket_t fd, enum bufferevent_options options);

参数

  • base: event_base

  • fd: 封装到bufferevent内的 fd,以后用到fd的地方都用ev来表示了就

  • options:BEV_OPT_CLOSE_ON_FREE 关闭的时候释放底层套接字(记住这个就行)

返回值: 成功创建的 bufferevent事件对象。

销毁
void  bufferevent_socket_free(struct bufferevent *ev);

参数就是咱们创建的ev

3.给bufferevent设置回调

1.给bufferevent设置回调的大概过程

对比event的过程:

1.event_new( fd, callback );  					
2.event_add() -- 挂到 event_base 上

bufferevent的过程:

1.bufferevent_socket_new(fd)

2.bufferevent_setcb(callback)

这两个函数一起完成了event_new的工作,指定fd和绑定回调函数(因为bufferevent_socket_new参数中没有回调函数)

2.给读写缓冲区设置回调
void bufferevent_setcb(struct bufferevent * bufev,
			bufferevent_data_cb readcb,
			bufferevent_data_cb writecb,
			bufferevent_event_cb eventcb,
			void *cbarg );

参数

  • bufev: bufferevent_socket_new() 返回值

  • readcb: 设置 bufferevent 读缓冲回调,对应回调 read_cb{ bufferevent_read() 读数据 }

  • writecb: 设置 bufferevent 写缓冲回调,对应回调 write_cb { } – 给调用者,发送写成功通知。 可以 为NULL,即因为它很鸡肋就不设置了

  • eventcb: 设置 事件回调。 也可传NULL

重点是BEV_EVENT_CONNECTED

  • cbarg:上述回调函数使用的 参数。
3.具体的回调函数
eventcb
事件回调函数原型:
typedef void (*bufferevent_event_cb)(struct bufferevent *bev,  short events, void *ctx);

//示例
void event_cb(struct bufferevent *bev,  short events, void *ctx)
{
	...
}

设置这些标志之后 在发生对应的操作之后就会回调eventcb

比如设置为EV_EVENT_READING,那么在我读缓冲满足回调readcb读数据的时候,也会回调eventcb

readcb
读缓冲区回调函数类型:
	typedef void (*bufferevent_data_cb)(struct bufferevent *bev, void*ctx);
	// 参数对应bufferevent_event_cb的bev和cbarg参数 如:
	void read_cb(struct bufferevent *bev, void *cbarg )
	{
		.....
		bufferevent_read();   //类似read()的作用
	}

读数据就是从bufferevent输入缓冲区中移除数据

bufferevent_read()函数的原型:
	size_t bufferevent_read(struct bufferevent *bev, void *buf, size_t bufsize);

作用:在readcb中代替read

writecb
读缓冲区回调函数类型:
	typedef void (*bufferevent_data_cb)(struct bufferevent *bev, void*ctx);
	// 参数对应bufferevent_event_cb的bev和cbarg参数 如:
	void write_cb(struct bufferevent *bev, void *cbarg )
	{
		.....
		bufferevent_write();   //类似read()的作用
	}
bufferevent_write()函数的原型:
	int bufferevent_write(struct bufferevent *bufev, const void *data,  size_t size); 

4.缓冲区的开启和关闭

默认:新建的bufferevent中 写缓冲是enable 读缓冲是disable

开启
void bufferevent_enable(struct bufferevent *bufev, short events);   启动	
	//例子
	bufferevent_enable(evev, EV_READ);		-- 开启读缓冲。

参数events: EV_READ、EV_WRITE、EV_READ|EV_WRITE,分别是读缓冲,写缓冲,读写两个缓冲

关闭
void bufferevent_disable(struct bufferevent *bufev, short events);   启动	
	//例子
	bufferevent_disable(evev, EV_READ);		-- 关闭读缓冲。

参数events: EV_READ、EV_WRITE、EV_READ|EV_WRITE,分别是读缓冲,写缓冲,读写两个缓冲

获取状态
short bufferevent_get_enabled(struct bufferevent *bufev);   启动	

获取缓冲区的禁用状态,需要借助&来得到

EV_READ、EV_WRITE、EV_READ|EV_WRITE和返回值进行&

是真就说明有,假就是没有

5.客户端和服务端连接和监听

1.客户端连接服务器
socket();connect();

int bufferevent_socket_connect(struct bufferevent *bev, struct sockaddr *address, int addrlen);

参数

  • bev: bufferevent 事件对象(封装了fd)

  • address、len:等同于 connect()

2.服务器创建监听器

头文件

#include<event2/listener.h>
struct evconnlistener * listner;

//相当于调用了socket();bind();listen();accept();
struct evconnlistener *evconnlistener_new_bind (	
	struct event_base *base,
	evconnlistener_cb cb, 
	void *ptr, 
	unsigned flags,
	int backlog,
	const struct sockaddr *sa,
	int socklen);

参数

  • base: event_base

  • cb: 监听的回调函数。一旦被回调,说明在其内部应该与客户端建立连接完成,然后就与用户进行通信,完成数据读写操作即可。

  • ptr: 回调函数的参数

  • flags: 可识别的标志 记住两个就行

    • LEV_OPT_CLOSE_ON_FREE 释放bufferevent的时候把底层套接字给也给关了

    • LEV_OPT_REUSEABLE 端口复用

    • 这两个可以 ‘|’ 连接

  • backlog: 设置监听的上限,和listen() 的第2参一样。 -1 表最大值

  • sa:服务器自己的地址结构体 IP+Port

  • socklen:服务器自己的地址结构体大小。

返回值:成功创建的监听器对象listener

**注意:**accpet可以用传出参数传出客户端的地址结构,可以用返回值返回服务器新创建的socketfd

该函数如何表示这两个东西呢?

都在监听器的回调函数中。

3.监听器的回调函数
typedef void (*evconnlistener_cb) (
struct evconnlistener *listener,
evutil_socket_t sock, 
struct sockaddr *addr,
int len, 
void *ptr);

参数

  • listener:evconnlistener_new_bind 函数的返回值,即我们创建的监听器对象
  • sock:用于通信的文件描述符cfd,就是服务器新创建的那个文件描述符,会被回调函数传出
  • addr:客户端的地址结构 IP+端口
  • len : addr的 len
  • ptr:外部ptr传递进来值。evconnlistener_new_bind中的ptr传给了这里的ptr

注:该回调函数,不由我们调用,是框架自动调用。因此,只需知晓参数含义即可。

4.释放监听服务器
void evconnlistener_free(struct evconnlistener *lev);

6.客户端连接服务器函数

  1. 函数基本功能与用途
    • bufferevent_socket_connect是 Libevent 库中的一个函数,用于创建一个基于套接字(Socket)的缓冲事件(Buffered Event),并尝试连接到指定的服务器地址。这个函数主要用于网络编程,特别是在构建客户端程序时,它简化了连接服务器的过程,并为后续的网络数据读写操作提供了缓冲功能。
  2. 函数参数解释
    • 参数一:struct event_base *base
      • 这是 Libevent 的事件基础(Event Base)。它是整个事件驱动架构的核心部分,所有的事件(包括定时器事件、I/O 事件等)都在这个事件基础上进行管理和调度。bufferevent_socket_connect函数需要这个事件基础来注册与连接操作相关的事件,比如连接成功或连接失败的事件。
    • 参数二:struct bufferevent **bev
      • 这是一个指向struct bufferevent类型指针的指针。struct bufferevent是 Libevent 中用于缓冲事件的结构体,用于管理缓冲的读写操作。通过这个参数,函数可以创建或者修改一个已有的缓冲事件对象。在函数调用成功后,这个缓冲事件对象会被初始化并用于后续的网络通信。
    • 参数三:struct sockaddr *address
      • 这是一个指向套接字地址结构(struct sockaddr)的指针。这个地址结构包含了要连接的服务器的 IP 地址和端口号等信息。具体的地址结构类型(如sockaddr_in用于 IPv4 地址,sockaddr_in6用于 IPv6 地址)需要根据实际的网络环境和服务器地址类型来确定。函数会根据这个地址信息尝试连接到指定的服务器。
    • 参数四:int addrlen
      • 这是address参数所指向的套接字地址结构的长度。它用于确保函数正确地解析和使用地址信息。对于不同类型的地址结构,其长度可能会有所不同,例如sockaddr_in结构的长度通常是sizeof(struct sockaddr_in),而sockaddr_in6结构的长度通常是sizeof(struct sockaddr_in6)
  3. 函数的工作流程和内部机制
    • 当bufferevent_socket_connect函数被调用时:
      • 首先,它会在event_base(事件基础)上创建一个新的缓冲事件(bufferevent)或者更新一个已有的缓冲事件对象,这取决于bev参数是否已经指向一个有效的缓冲事件对象。
      • 然后,它会使用addressaddrlen参数所提供的服务器地址信息,尝试通过底层的套接字函数(如connect)来建立与服务器的连接。在这个过程中,Libevent 会在内部为连接操作注册相关的事件,比如连接成功事件(BEV_EVENT_CONNECTED)和连接失败事件(BEV_EVENT_ERROR)。
      • 如果连接成功,缓冲事件对象会被配置为可以进行后续的网络数据读写操作。这些读写操作会通过缓冲机制来进行,以提高网络通信的效率和稳定性。例如,写入数据时,数据会先被放入缓冲区,然后根据网络的实际情况(如套接字的可写状态)发送出去;读取数据时,数据会从缓冲区中获取,而不是直接从套接字读取,这样可以避免频繁的系统调用。
  4. 返回值与错误处理
    • 返回值:如果函数成功地发起连接请求并且初始化了缓冲事件对象,它会返回0。如果在连接过程中出现错误,比如无法创建缓冲事件对象、无法解析服务器地址或者底层的connect函数返回错误,函数会返回 - 1
    • 错误处理示例:当函数返回 - 1时,通常需要通过errno来获取具体的错误信息,以确定出错的原因。例如,可以像下面这样处理错误:

7.Libevent实现TCP服务器

Libevent实现TCP客户端服务器-CSDN博客

4.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。

0.一个示范的例子

利用 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 后退出。

img

代码解析
  1. 调用event_init函数创建event_base对象。一个event_base对象相当于一个Reactor实例。event_base 是 libevent 处理事件的核心结构。
struct event_base *base = event_init();
  1. 创建具体的事件处理器,并设置它们所从属的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:指定事件类型

img

  • 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.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。回调函数的参数。

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

创建一个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事件处理器和信号事件处理器插入注册事件队列;将定时器插入通用定时器队列或时间堆;将被激活的事件处理器添加到活动事件队列中。

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

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

4.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

5.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);
};

6.事件循环

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

悦读

道可道,非常道;名可名,非常名。 无名,天地之始,有名,万物之母。 故常无欲,以观其妙,常有欲,以观其徼。 此两者,同出而异名,同谓之玄,玄之又玄,众妙之门。

;