libevent 笔记

Libevent 是一个用 C 语言编写的、轻量级的开源高性能事件通知库,主要有以下几个亮点:事件驱动(event-driven),高性能;轻量级,专注于网络,不如 ACE 那么臃肿庞大;源代码相当精炼、易读;跨平台,支持 Windows、Linux、BSD 和 Mac OS;支持多种 I/O 多路复用技术,epoll、poll、dev/poll、select 和 kqueue 等;支持 I/O,定时器和信号等事件;注册事件优先级;内置 OpenSSL 支持,编写 TLS/SSL 应用更加容易;内置 HTTP、DNS 异步实现,可以说很强大了。

libevent 安装

libevent 用法

event.h

为什么使用 libevent,可能大家都比较清楚,因为 Linux 的 epoll 不是很好用,很多时候我们只是想简单的实现异步 IO(socket 编程),所以 libevent 出现了。

在 epoll 中,要使用 epoll IO 多路复用,必须首先创建一个 epoll fd,这个 fd 可以看作是一个 epoll 对象,我们所有的添加、删除、修改 event 操作都是与这个 fd 相关联的。

而在 libevent 中,同样存在这么一个东西,它的名字叫做 event_base,event_base 是一个结构体,与 libevent 相关的事件都是在它上面进行操作的,作用与 epoll fd 相同。

注意,event_base 通常只能在一个线程中使用,如果需要 多线程 + libevent,那么你最好为每个线程分配一个 event_base 结构体,防止多线程访问同一个 event base 导致数据错误问题。

创建 event_base

运行 event loop

但是,上面只讲了如何 event base 的创建、运行、停止、删除,并未说明如何创建 event,毕竟 event 是 event loop 的基本运作单位,这里开始介绍 event 的创建、添加、删除等 API。

libevent 支持管理的事件有:准备读取/写入的文件描述符、超时到期、收到信号、用户触发的事件。

event 有相似的生命周期。使用 new 方法创建时,event 处于 initialized 状态。当你往 event base 里面添加 event 时,event 处于 pending 状态。当 event 被指定事件触发时,event 处于 active 状态,同时自动运行用户提供的回调函数(callback function)。如果将 event 配置为 persistent,那么这个 event 不会在触发(active)一次之后被自动移除,而是继续保持 pending 状态,等待下次 active(这通常是我们想要的,否则你需要重新 add event)。

创建 event

在 event_new() 方法中,我们通常希望传递当前 event 结构体进去(传递给我们设置的回调函数),但是此时 event 结构体还未创建,互相矛盾,为了解决这个问题,libevent 提供 void * event_self_cbarg() 函数,它的返回值就是当前的 event 指针,即 Java 中的 this 指针。如果你还不明白,那么请看下面的例子:

例子会每隔一秒执行一次 cb_func 回调函数,当调用 100 次后,cb_func 内会移除这个 event。

有必要强调一点,libevent 中,new/free 表示创建删除,特指内存/资源的创建删除,而 add/del 这些表示添加/移除事件。不要搞混了。现在介绍 add/del event 的 API:

检查 event 的状态,获取其中的某些信息的 API:

util.h

socket api 跨平台相关

bufferevent.h

大多数情况下,除了响应事件外,人们通常还要操作缓冲区中的数据(不能说通常吧,是一定要操作,不然 socket 编程就没有意义了),当然 libevent 也很清楚,所以它提供了更好用的 recv/send 包装 API,libevent 真香。

例如,当我们想要写数据时,通常的模式运行如下:

  • 将数据放入缓冲区
  • 等待连接变为可写
  • 尽可能多地写出数据
  • 记住我们写了多少,如果我们还有更多数据要写,则等待连接再次变为可写。

这种 buffer 的 IO 模式很常见,libevent 为它提供了一种通用的机制:"bufferevent",由底层传输(如套接字),读缓冲区和写缓冲区组成。当底层传输准备好被读取或写入时,bufferevent 会调用其用户提供的回调,而不是在读取或写入足够数据时调用其回调的常规事件。

Bufferevents 目前仅适用于 TCP 等面向流的协议(当然包括 Unix Domain Socket)。将来可能会支持 UDP 等面向数据报的协议。

bufferevents 和 evbuffers
每个 bufferevent 都有一个输入缓冲区和一个输出缓冲区。这些是 struct evbuffer 类型。当您要在 bufferevent 上写入数据时,将其添加到输出缓冲区; 当 bufferevent 有数据供你读取时,你将它从输入缓冲区中取出。

其实简单的说,evbuffer 是缓存区的包装,bufferevent 是一个 event,针对 buffer。

Buffer Event 事件

Buffer Event 选项

创建 bufferevent

连接 bufferevent

释放 bufferevent

设置回调函数(读/写/发送错误)

启用 bufferevent 事件(读、写、读写)

获取 bufferevent 上的 input/output buffer(读缓冲区、写缓冲区)

这两个函数是缓冲区操作的基础:它们分别返回输入和输出缓冲区。操作 evbuffer 类型的缓冲区。

读取、写入 eevbuffer 中的数据

buffer.h

创建、释放 evbuffer

检查 evbuffer 长度

添加数据到 evbuffer

移动一个 buf 中的数据到另一个 buf

从 evbuffer 中删除数据

从 evbuffer 中窥探数据

基于行的数据读取,比如 HTTP 协议

在 evbuffer 中搜索字符串

peek 检查 evbuffer 里面的数据

在 socket IO 上使用 evbuffer

listener.h

evconnlistener 机制为您提供了一种侦听和接受传入 TCP 连接的方法。

创建、释放 evconnlistener

支持设置的 flags 标志

设置 listener 的回调函数

设置 listener 错误时的回调

获取 listener 管理的信息

libevent 例子

echo 服务器(单线程)

gcc -levent -o echo-single-thread echo-single-thread.c

echo 服务器(多线程)

注意:实际上这个程序有问题,event_base 默认不是线程安全的。
gcc -levent -lpthread -o echo-multi-thread echo-multi-thread.c

echo 服务器(多线程)

gcc -lpthread -levent -levent_pthreads -o echo-multi-thread echo-multi-thread.c

libevent 其它说明

  1. 在 libevent 运行完成之后(也就是程序退出之前),可以调用 void libevent_global_shutdown() 方法,来释放 libevent 申请的所有全局数据结构。注意,此函数是幂等的,即无论是调用一次还是调用多次,都是一样的结果,不会导致问题。还有,注意此函数必须位于所有 libevent 函数之后调用,否则其他 libevent 函数是无法感知到的,可能导致意想不到的错误。

  2. 创建 event_base 时不要使用默认的 event_base_new() 函数了,因为默认情况下 event_base 是有锁的,而我现在的应用场景完全不需要锁,所以需要使用 config 来定制 event_base。如下:

    1. 关于 event_base_loop。默认情况下,它会阻塞当前线程,直到处理完该 base 上所有注册的 event 为止(也就是说如果此时 base 上没有任何已添加的 event,那么该函数就会返回)。强调一点,所谓注册事件也就是 event_add()。默认情况下,struct event 是临时事件,也就是说,在 add 之后,当事件触发时,它只会运行一次,事件触发后,该 event 会自动被取消注册,即 event_del,但是不要忘记了 event_free() 啊,否则就内存泄漏了!!!当然,libevent 允许你创建持久性事件,所谓持久事件就说除非你显式 event_del 取消注册,否则不会自动 event_del。

    2. 当然,如果 event_base_loop 前,这个 base 里面没有任何已注册的 event,那么该调用会直接返回,不会阻塞。因为没事做啊,如果想让他不返回,也就是说我稍后会注册事件给你,则可以传递 EVLOOP_NO_EXIT_ON_EMPTY 这个 flag,告诉他即使没有 event 可循环,也不要返回!

    3. 事件的生命周期:新建状态(event_new)、挂起状态(event_add)、活动状态(事件触发时)

      • 如果是临时事件:则事件触发后会变成为新建状态,即 活动状态 -> 新建状态。默认
      • 如果是持久事件:则事件触发后继续保持挂起状态,即 活动状态 -> 挂起状态。循环
      • 你可以在 event 的挂起、活动状态下调用 event_del、event_free,它们是安全的
      • event_new、event_add、event_del、event_free,记住它们的对应关系,不要搞混了
      • 常用的 EVENT 事件类型:EV_READEV_TIMEOUTEV_PERSIST,多个类型使用 | 连接
    4. 在创建 event 时,如果需要将当前 event 指针传给回调函数,可以使用 void *event_self_cbarg() 方法,它返回的指针其实就是待会 event_new() 返回的指针。具体的原理我猜测是这样的:当你调用 event_self_cbarg 时,event 内部会 malloc 一个 event 的指针,然后返回给调用者。紧接着,当调用 event_new 函数时,event 内部检测到我们调用了 event_self_cbarg 函数,于是不再分配新的内存,而是直接将创建的 event 数据结构写入到这个指针当中,然后返回。于是就达到了传递 event 指针的目的。注意中间不要进行其他操作,否则可能出现不可预知的问题。。猜测错误,经过实际测试得知,event_self_cbarg 返回的是一个固定的地址,也就是某个静态指针,具体的原理只能通过查看源码得知了。(暂时留个坑吧,主要是怕这个函数不是线程安全的)。我猜错了,原来它的工作原理是这么简单粗暴,event_self_cbarg 返回的应该是个 flag 指针,它存储的内容没有实际意义,他只不过是用来标识 event_new 时,如果传入的 arg 的值与他相等,则表示它想接受 this 指针,实际上传递给 cbfunc 的不是传入的这个指针,而是内部会重新传入正确的指针给它。也就是说 event_self_cbarg() 函数的用途仅限:struct event *ev = event_new(base, sock, events, cbfunc, event_self_cbarg()),其他方式(比如尝试将这个指针放在结构体中,是不现实的),但是我们可以这样做:

    因为我还没运行 event,所以此时完全来得及将 ev 指针传递给结构体,待会直接用就行了。

    1. evutil_socket_t 在 unix 中其实就是 int 类型。如果发生套接字 IO 错误,直接使用 errnostrerror(errno) 即可,不需要使用所谓跨平台宏,没必要。注意,errno 是线程安全的(thread local 存储),但是 strerror() 返回的是静态指针,不是线程安全的,需使用 strerror_r() 线程安全版本(和 time 的那个函数类似,貌似后缀 _r 的都是线程安全的,即自己提供 buffer,为了线程安全,最好的方式就是,这个 buffer 是栈上的变量,如字符数组)。

    2. 分配内存时建议使用 calloc,而不是 malloc() + memset(),前者更快!除非你不担心垃圾值。

    3. 每个 bufferevent 都有 4 个水位线,读水位线 2 个,写水位线 2 个,我们主要关心读水位线,因为写的话只要负责 write 就行,libevent 会自动发送出去,通常情况下 write 都不会失败。读水位线有两个,一高一低。先说低水位线,只有当 bufferevent 的 input 缓冲区的数据大小超过(或达到)低水位线时,设置的 read 回调函数才会被调用,读低水位线默认为 0,即只要有数据可读就会调用读回调,一般我们不需要改动这个,默认就行。我们主要关心一下读高水位线,高水位线是说,如果 bufferevent 的 input 缓冲区的数据大小达到此值时,bufferevent 会停止从 socket 中读取数据(TCP 滑动窗口变小),直到 read 回调处理了其中的数据,让数据大小低于高水位线后才会继续从 socket 中读取。这个高水位线默认是无限制的,这可能会导致一些问题,比如代理软件,他要管理两个连接,一个是与 client 的,一个是与 server 的,如果 client 这边的网速很慢,而 server 这边的网速很快,那么 server 这边的 bufferevent 的 input 缓冲区将会很快膨胀,即内存占用很大,直到这些数据都转发给了 client 那边。这时我们必须设置一个高水位线,防止 server 这边的 input 缓冲区无限膨胀,导致内存吃得过多,而被系统 kill 掉。比如最大设置为 3~5M 就行了。(但仔细想想好像没这么简单,稍后再说吧)

    4. bufferevent 的回调函数中会接收到 events 参数,我们可以通过 BEV_EVENT_READING 来判断是不是读数据期间发生了事件,同样的,可以通过 BEV_EVENT_WRITING 来判断是不是写数据期间发生了事件。

    5. 再次重申,libevent 中的所有 socket 套接字都必须设置为 non-blocking 的,以免出现问题。

    6. bufferevent 的 add 事件是通过 bufferevent_enable 启用的,对应的是 disable。事件只有两个,EV_READ、EV_WRITE。如果没有启用对应事件,bev 将不会尝试读取或写入数据。默认情况下,新创建的 bev 已经启用了 EV_WRITE 事件,你也可以通过 disable 来取消注册 EV_WRITE 事件。有必要再声明几点:启用 WRITE 事件是指,当 output 缓冲区中有数据时,libevent 会自动调用 send 将数据发送出去(写到 socket 缓冲区);而 READ 事件是指:当 socket 缓冲区中有数据时,libevent 会自动从 socket 缓冲区将数据剪切到 bufferevent 的 input 缓冲区,供 read 回调读取。

    7. 设置水位线的函数:

    网络编程个人总结

    OSI 七层网络模型 从下到上分别为:

    1. 物理层:光纤、以太网、同轴电缆 等
    2. 链路层:以太网、WiFi、PPPoE、GPRS 等
    3. 网络层:IPv4、IPv6、ICMP、BGP、OSPF 等
    4. 传输层:TCP、UDP、DCCP、SCTP、PPTP、RSVP 等
    5. 会话层:已弃用,应用层可替代
    6. 表示层:已弃用,应用层可替代
    7. 应用层:HTTP、FTP、DNS、NTP、DHCP、SMTP、POP3、SSH 等

    其实我们现在应该将其称为 OSI 五层网络模型,因为会话层和表示层没有存在的意义和必要。作为网络开发者,需要了解的是 网络层(IP)、传输层(TCP、UDP)、应用层(HTTP、FTP 等)。但是日常使用中,接触的最多的只是 应用层,网络层用不着你操心,传输层有 TCP、UDP 两个就够了。而应用层说到底还是数据格式而已,HTTP、FTP、DNS 还是其他的什么协议,其实都是规定的数据格式而已。我们完全可以自己创建一个应用层协议,格式随便定义。

    TCP 和 UDP 的区别

    一句话总结:TCP 就是打电话,UDP 则是发短信

    先说 UDP 吧,发短信我们知道,一条短信有 2 个元信息:发信人收信人。而数据就是我们所发的短信内容了。发短信是没有所谓的状态的(或者叫做连接吧,更好理解一点),我只管发送出去,而对方是否收到了,是否查看了短信,我们是无从得知的。UDP 也是如此,一个 UDP 包就像一条短信,它也有两个元信息:发送方 IP:Port接收方 IP:Port,然后就是里面的数据。发送方将一个 UDP 包发送出去后,就不会再管它了,对方接收到了也好,没接收到也好,都不关发送方的事。而接收方在接收到一个 UDP 包之后,可以从中读取到发送者的 IP:Port(也就是 recvfrom 后面的两个参数),然后接下来处理其中的数据即可(相当于短信接收方读取短信内容一样)。有必要强调一点,如果 UDP 包在发送的过程中(网络传输过程中)丢失了(如 IP 层分片,某个分片不见了,导致 UDP 包是坏的),那么接收方是不会收到这个 UDP 包的,所以接收方收到的 UDP 包一定是完整的 UDP 包。而且 UDP 包也没有所谓的粘包问题,因为你收到的只能是一个完整的 UDP 包,其他的丢失的、坏的包你是收不到的(底层网络栈会收到这些坏包,但是它不会交由应用程序处理)。总之,对 UDP 编程有疑问的时候,多联想一下发送短信、接收短信的过程就清楚了。因为这个特点,UDP 只适用于一应一答的场景,如 DNS,客户端发送一个 DNS 请求,服务器收到之后,发送一个 DNS 响应,这个任务就算完成了。总之和发短信一样。

    再说 TCP,TCP 就是打电话,所以每次与对方交流之前,必须先拨通对方的电话,这个拨号的过程叫做 TCP 握手,拨号成功后,双方都进入已连接状态,也就是电话打通了,可以交流了(双向交流,即你可以发送数据,他也可以发送数据),如果聊完了,就需要挂掉电话,这个挂电话也就是 TCP 挥手。所以 TCP 是流式的传输、面向连接的、可靠的传输层协议。UDP 则是用户数据报协议,”数据报”=”短信”。TCP 有所谓的粘包问题,也就是如何分清界线,比如我让对方发送一个文件给我,我该如何知道这个文件是发完了还是没发完,这就是应用层协议自己该考虑的问题了,比如 HTTP 协议使用 Content-Length 头部来解决这个问题,这个头部用来指示携带的数据有多少字节,这样接收方就能判断是否收完了数据。

    最后聊一下 REDIRECT 和 TPROXY 两种不同的透明代理方式,REDIRECT 只适用于 TCP,而 TPROXY 支持 TCP 和 UDP。REDIRECT 其实就是 DNAT 的一种,只不过它的目的地址始终是 127.0.0.1 而已。

    REDIRECT 实现 TCP 透明代理很简单,proxy 在收到新连接之后,调用 getsockopt 就可以获取这个连接的原目的地址和端口信息了,然后 proxy 再与目的地址建立连接,之后无脑转发数据就行,简单。

    而 TPROXY 则稍微麻烦一点,首先 TPROXY 在必要的前提下会改变数据包的 dst_port。proxy 监听一个端口,假设为 60053,并且假设为 UDP 端口。proxy 的监听地址为 0.0.0.0(任意 IP 地址),所以,我们只需要设置好路由(local 类型的路由),将这些需要代理的 UDP 包路由到 proxy 上即可。比如客户端往 8.8.8.8:53/udp 发送 DNS 请求,那么在经过网关时,TPROXY 规则会将这个 UDP 包的目的端口改为 60053(原端口会写入到 socket msg 之中,稍后通过 recvmsg 可读取到),然后进过路由,被那条 local 路由命中,于是发往本机的 proxy 进程,proxy 收到后(调用 recvmsg 而不是 recvfrom,因为需要读取原目的地址和目的端口信息),获取到目的地址和目的端口,然后创建一个新的 UDP socket,发送给目的服务器。当这个 UDP socket 接收到服务器返回的响应之后,proxy 又创建过一个新的 UDP socket 用于响应客户端,这个 UDP socket 绑定的 IP 和 Port 就是之前读取到的 dst_addr、dst_port(需要给 socket 设置特殊套接字选项才能绑定成功),然后使用这个 socket 发送响应回客户端即可。在客户端开来,这的确是我刚才请求的 dst_addr:dst_port 返回的数据,所以没有问题,也即透明代理。对于 TPROXY 如何代理 TCP,我目前不太清楚 proxy 如何写。


    如何正确关闭 TCP Socket

    情景假设:正在编写一个 proxy 程序,每个 TCP 代理都与两个 TCP Socket 关联。
    A <---> P <---> B,P 是我们的 proxy 程序,A、B 则是两侧的 TCP 套接字。

    其实上面的图形完全是下面的两个图的结合:

    A 给 B 发送数据:A ---> P ---> B
    B 给 A 发送数据:A <--- P <--- B

    Proxy 做的工作很简单,完全就是单纯的转发对方的数据而已。

    socket 的关闭分为两种

    1. Proxy 从任意一方收到 ERR(假设从 A 这边收到 ERR)
    2. Proxy 从任意一方收到 EOF(假设从 A 这边收到 EOF)

    第一种,从 A 收到 ERR(收到错误)
    因为 A 套接字已经发生了 ERROR,不可能恢复,所以我们可放心的关闭 A 套接字;但是对于 B 套接字,我们不能直接立即关闭,因为 B 套接字上我们可能还有需要发送的数据,如果此时直接关闭,会导致 B 套接字的对端收到不完整的数据,导致 bug。对于 B 套接字,我们其实不需要从中读取数据了,因为即使读取了也不能发给 A,因为 A 已经被 close 了。所以在 bufferevent 上,我们可以立即对 B 套接字调用 disable(EV_READ),来关闭 B 这边的读取操作。然后检查 B 的发送缓冲区的数据大小,如果为 0,说明没有数据可发送,那么可以直接关闭 B 套接字。如果不是 0,那么需要为 B 套接字设置 write 写回调,水位线设为 0,表示写完了才会调用 write_cb,而在 write_cb 中我们会关闭 B 套接字。同时我们还需要为 B 套接字设置 event 事件回调,当我们从 B 中收到 ERROR 之后,可直接关闭 B 套接字;让我们收到 EOF 之后,应该设置一个 3s 超时时间,如果 3s 之后 write_cb 仍未调用(即仍然没有写完)则直接关闭 B 套接字。如果 3s 之内写完了,则 write_cb 自然会关闭套接字 B,并且删除 3s 超时的 event。

    第二种,从 A 收到 EOF(正常关闭)
    从 A 收到 EOF 表示 A 不会再发送数据了,也就是 A -> P -> B 方向的通道可以关闭了,但是 A 此时和此后仍然可以调用 recv 来接收数据,因为它并没有关闭读通道。所以,我们的代理软件应该对 A 套接字调用 disable(EV_READ)。然后对于 B 套接字,首先应该检查 B 的写缓冲区的大小,如果为 0,那么我们可直接对 B 套接字调用 socket_shutdown(SEND),表示我们也不会向 B 发送数据了;如果不是 0,那么我们应该为 B 注册 write_cb,当写完之后,我们再对其调用 socket_shutdown(SEND),当然 B 套接字的 event 回调也不能少,如果此期间从 B 收到了 EOF/ERR 信息,则直接关闭 B 套接字,然后检查 A 套接字的发送缓冲区,如果为 0,则关闭 A 套接字,如果不为 0,则设置 A 套接字的 write 回调,写完之后就直接关闭 A 套接字,同时为 A 设置一个 EVENT 回调,如果收到 ERROR,则直接关闭 A;同时还要设置一个 3s 超时时间,如果 3s 之内仍然为发送完,则直接关闭 A。