Libevent教程001: 简介与配置

时间:2022-04-11 00:18:38

本文内容大致翻译自 libevent-book, 但不是照本翻译. 成文时, libevent最新的稳定版为 2.1.8 stable. 即本文如无特殊说明, 所有描述均以 2.1.8 stable 版本为准.

本文为系列文章的第一篇, 对应libevent-book的 chapter 0 + chapter 1 + R0 + R1

0. 前提条件

这个文档是对libevent的介绍与指导, 阅读文档需要你具有以下的能力:

  1. 你精通C语言
  2. 你至少了解Unix网络编程.
  3. 你会安装libevent
  4. 你大致知道libevent是干什么用的.

1. 基本概念: 阻塞/非阻塞/同步/异步/回调机制的讨论

这里首先要解释四个名词: 阻塞, 非阻塞, 同步, 异步. 它们都是修饰"接口"的形容词, 或者说的土一点, 它们都是修饰"函数"的形容词.

同步, 还是异步, 是从"消息通信"的视角去描述这个接口的行为. 而所谓的消息通信, 你可以简单的把"函数"想象成一个淘宝客服, 把"调用方"想象成你自己. 调用函数的过程其实就是三步:

  1. "你"询问"淘宝客服"一个问题. 比如, "在吗?". 在这个场景中, 你就是"调用方", "淘宝客服"是函数, 而那句"在吗?", 则是函数参数, 你把函数参数传递给函数.
  2. "淘宝客服"进行后台处理. 这时淘宝客服接收到了你的询问消息, 如果他没有在忙, 那么他可以立即回复你. 如果他现在正在忙, 比如正在吃饭, 比如正在和老婆吵架, 比如淘宝客服需要先看一下你之前的行为记录, 然后再决定如何回复你.(比如他看到你正在浏览一双袜子,觉得你在潜在的买家, 他决定回复你. 比如他看到你三天前下单买了一双袜子, 但袜子还没有发货, 他觉得你有退货的风险, 从而决定不理你, 假装不在.) 这个客服思考决断的过程, 就是函数内部进行处理运算的过程. 当然这个例子很简单, 有些牵强.
  3. 最终, 淘宝客服回复了你, "在的, 亲". 这里, 回复这个动作, 就是函数返回, 而"在的, 亲"这句话, 就是函数的返回值.

你从这个角度去看, 函数调用, 就是消息通信的过程, 你发送消息给函数, 函数经过一番运算思考, 把结果再回发给你.

所谓的同步, 异步, 指的是:

  1. 这个淘宝客服很老实, 对于每个顾客发来的问题, 他都需要经过一番思考, 再进行答复. 这个函数很老实, 对于每个函数调用, 都很老实的根据传入参数进行计算, 再返回结果. 也是是说, 在淘宝客服思考结束之前, 这个客服不会向你发送答复, 你也收不到答复. 也就是说, 在函数运算结束之前, 函数不会返回, 你也得不到返回值. 那么, 这个客服是同步的, 这个函数调用的过程是同步调用, 这个函数是同步的.
  2. 假如这个淘宝客服很不老实, 他装了一个自动答复小程序. 对于每个询问的顾客, 都先自动回复一句"亲, 现在很忙哟, 客服MM可能过一会才能给你答复". 也就是说, 顾客在发出询问之后, 立即就能得到一个答复. 也就是说, 调用方在调用一个函数之后, 这个函数就立即返回了. 而真正的结果, 可能在过五分钟之后才会给你. 即是五分钟之后客服对你说"在的呢, 亲". 这样的函数, 就叫异步函数.

异步客服需要解决一个问题: 当真正的运算结果得出之后, 被调用的客服如何通知作为调用方的你, 取走答案. 在淘宝客户端上, 是通过手机的震动消息提醒, 是通过聊天框的红点.

所以, 关于同步, 和异步, 这里做一个稍微正式一点的总结:

  1. 同步的过程: 调用方传参->函数运算->函数返回运算结果.
  2. 异步的过程: 调用方传参->函数说我知道了, 然后过了五分钟, 函数说我算出来了, 结果在这里, 你来取.

这里我们着眼于消息的传递, 通讯方式, 也就是站在函数的角度去看, 结果是如何传递给调用方的. 同步接口, 运算结果在"函数调用"这个场景下就返回给了调用方. 异步接口: 运算结果在"函数调用"这个场景之后的某个不定的时刻, 通过某种通知方式, 传递给调用方.

整个过程中我们忽略了一件事: 就是, 在函数执行运算的过程中, 调用方在干什么. 也是是, 在淘宝客服内心思考如何回复你的时候, 你在干什么.

这就引出了阻塞与非阻塞:

  1. 阻塞: 在函数执行运算的过程中, 当前线程什么也做不了. 在等待客服回复的过程中, 你什么也不做, 就在那干等着, 直到他回复了你.
  2. 非阻塞: 在函数执行去处的过程中, 当前线程可以去做其它事情. 在等待客服回复的过程中, 你上了个厕所, 还顺便洗了个澡.

换句话说:

  1. 同步与异步, 描述的是 被调用的函数, 如何将结果返回给调用者
  2. 阻塞与非阻塞, 描述的是 调用方, 在得到结果之前能不能脱身

这是两个维度上的逻辑概念, 这两个维度互相有一定的干涉, 并不是完全正交的两个维度, 这样, 既然是两个维度, 那么就有四种组合.

  1. 同步, 且阻塞: 调用方发起调用直至得到结果之前, 都不能干其它事情. 被调函数接收到参数直到运算结束之前, 都不会返回.
  2. 同步, 非阻塞: 调用方发起调用直至得到结果之前这段时间, 可以做其它事情. 但被调函数接收到参数直到运算结束之前, 都不会返回. 很显然这个逻辑概念说得通, 但其实是反常理的. 因为: 如果调用方在发起调用之后, 得到结果(函数返回)之前, 要去做其它事情, 那么就有一个隐含的前提条件: 调用方必须知道本次调用的耗时, 且被调方(函数)严格遵守这个时间约定. 一毫秒不多, 一毫秒不少. 这在代码的世界里是很难达到的.
  3. 异步, 且阻塞: 调用方发起调用直至得到结果之前, 都不能干其它事情. 被调用函数接收到参数之后立即返回, 但在随后的某个时间点才把运算结果传递给调用方. 之后调用方继续活动. 这个逻辑概念依然说得通, 但是很别扭. 这就相当于, 在你问淘宝客服问题的时候, 淘宝客服的自动回复机器人已经给你说了"客服很忙哟, 可能过一会才能答复你", 但你就是啥也不干, 非得等到客服答复你之后, 才去上厕所. 这种情景在代码世界里可能发生, 但似乎很智障.
  4. 异步, 非阻塞: 调用方发起调用直至得到结果之前这段时间, 可以做其它事情. 被调函数接收到参数后立即返回, 但在之后的某一个时间点才把运算结果传递给调用方. 这说起来很绕口, 举个栗子, 还是客服:

    1. 你拿出手机, 向客服发送消息, "在吗?". 然后把手机放桌子上, 转向上厕所去了.
    2. 客服收到你的消息, 机器人回复你"不好意思, 客服现在很忙, 但我们会尽快答复你的, 亲!".
    3. 你上厕所回来了, 看手机没消息, 又去吃饭了.
    4. 客服开始处理你的消息, 终于开始给你真正的回复"亲, 2333号客服为您服务, 你有什么要了解的吗?".
    5. 你吃饭的过程中, 手机震动, 你点开淘宝, 发现有了回复. 整个流程结束.

可以看到

  1. 阻塞方式下, 调用方总是能第一时间拿到调用结果. 因为在阻塞期间, 调用方啥也不干, 就等着函数返回结果. 非阻塞方式下, 调用方一般都是在函数返回了结果之后才去查看运算结果.
  2. 异步方式下, 被调用方可以推迟处理任务. 客服收到你的消息后可以先把饭吃完, 函数收到你的调用后并不一定立即就开始运算.
  3. 同步且阻塞, 双方都是杠精, 都是老实人. 理解起来比较自然.
  4. 异步非阻塞, 调用方不在乎什么时候能得到运算结果. 被调用方不在乎调用方着急不着急, 双方都是佛系青年. 理解起来也比较自然.

还有一个点要给大家介绍到, 就是回调函数. 在上面讲过, 异步调用, 需要函数以某种机制, 在运算结果得出之后, 将运算结果传递给调用方. 但回调函数又绕了一个弯.

假设没有回调函数机制, 异步流程就是:

  1. 顾客询问客服, "你们家有没有红色36D的胸罩啊? 我想给我老婆买一件, 我老婆的胸是36D的". 然后去上厕所去了
  2. 自动机器人向顾客回复"很忙哟, 请耐心等待"
  3. 客服开始处理顾客的询问. 去库房查货.
  4. 库房有货, 客服要想办法将这个信息送到顾客手中. 他通过淘宝客户端发表了答复, 淘宝客户端导致手机震动, 这个震动信号通知了顾客.
  5. 顾客在厕所正拉屎, 看到手机上的消息提醒, 思考了一分钟, 顾客下单购买了这个胸罩.

这个流程里顾客做了两件事:

  1. 询问客服"有没有36D的红色胸罩". 这是调用函数的行为
  2. 在得到肯定的答复之后, 下单购买了这个胸罩. 这是得到函数返回的运算结果, 并根据运算结果进一步执行程序流程.(调用了另外一个函数: 购买)

而淘宝客服只做了一件事:

  1. 查询库房里是否有货

而有了回调机制后, 异步流程就是这样的:

  1. 顾客询问客服, "你们家有没有红色36D的胸罩?". 然后顾客把手机交给秘书, 叮嘱道:"你盯着这个客服, 如果她说有, 你就下单买了, 地址写我家, 如果没有, 你就啥也不做". 然后顾客坐上了出差的飞机
  2. 自动机器人向顾客回复"很忙哟, 请耐心等待"
  3. 客服开始处理顾客的询问. 去库房查货.
  4. 库房有货, 客服要想办法将这个信息送到顾客手中. 他通过淘宝客户端发表了答复, 淘宝客户端导致手机震动, 这个震动信号通知了秘书.
  5. 秘书根据老板的指示, 下单购买了这个胸罩.

这个流程里, 顾客做了两件事:

  1. 询问客服"有没有36D的胸罩". 这是调用函数行为.
  2. 向秘书叮嘱. 这是向消息监控方注册回调函数的行为. 消息监控方负责接收函数的返回结果. 回调函数则是: "如果有, 就买给老板夫人, 如果没有, 就什么也不做"

淘宝客服只做了一件事:

  1. 查询库房里是否有货

而消息监控方, 也就是秘书, 做了一件事:

  1. 根据客服的答复选择不同的行为. 即在函数调用结果得出之后, 调用回调函数.

这就是回调函数的一个生动的例子, 回调函数机制中有了一个调用结果监控方, 就是秘书, 这个角色承担着非常重要的职责: 即是在函数返回结果之后, 调用对应的回调函数. 回调机制一般都实现在异步调用框架之中, 对于写代码的人来说是透明的, 它简化了调用方的职责与智力负担, 一定程度上抽象了代码逻辑, 简化了编程模型(注意: 是一定程度上!). 有了回调机制:

  1. 调用方不必再去关心函数返回结果以及返回时机. 不必通过轮询或其它方式去检查异步函数是否返回了结果.
  2. 调用方在调用时就向调用结果监控方注册合适的回调, 在调用函数那一刻, 将后续业务逻辑写在回调函数中, 只负责调用就行了. 代码越写越像状态机.

不过正所谓回调一时爽, 调试火葬厂. 写过JavaScript的同学对这一点一定是深有体会. 当程序不能正确运行的时候, 调试很蛋疼. 异步框架本身由于函数返回时机不确定, 调试就比较蛋疼, 再加上回调机制, 那真是火葬厂了. 特别是回调嵌套回调, 里面套个七八层的时候, 那真是把图灵从坟里挖出来也没用的绝望场景.

2. 异步IO与多路复用技术

我们先来看一段经典的同步且阻塞的HTTP客户端程序:

#include <netinet/in.h> // for socketaddr_in
#include <sys/socket.h> // for socket functions
#include <netdb.h> // for gethostbyname
#include <sys/errno.h> // for errno

#include <unistd.h>
#include <string.h>
#include <stdio.h>

int main(int argc, char ** argv)
{
    const char query[] = "GET / HTTP/1.0\r\n"
                         "Host: www.baidu.com\r\n"
                         "\r\n";
    const char hostname[] = "www.baidu.com";

    struct sockaddr_in sin;
    struct hostent * h;
    const char * cp;
    int fd;
    ssize_t n_written, remaining;

    char buf[4096];

    /*
 * Look up the IP address for the hostname.
 * Watch out; this isn't threadsafe on most platforms.
 */
    h = gethostbyname(hostname);

    if(!h)
    {
        fprintf(stderr, "E: gethostbyname(%s) failed. ErrMsg: %s\n", hostname, hstrerror(h_errno));
        return -__LINE__;
    }

    if(h->h_addrtype != AF_INET)
    {
        fprintf(stderr, "E: gethostbyname(%s) returned an non AF_INET address.\n", hostname);
        return -__LINE__;
    }

    /*
 * Allocate a new socket
 */
    fd = socket(AF_INET, SOCK_STREAM, 0);
    if(fd < 0)
    {
        fprintf(stderr, "E: socket failed: %s\n", strerror(errno));
        return -__LINE__;
    }

    /*
 * Connect to the remote host
 */
    sin.sin_family = AF_INET;
    sin.sin_port = htons(80);
    sin.sin_addr = *((struct in_addr *)(h->h_addr));
    if(connect(fd, (struct sockaddr *)(&sin), sizeof(sin)) != 0)
    {
        fprintf(stderr, "E: connect to %s failed: %s\n", hostname, strerror(errno));
        close(fd);
        return -__LINE__;
    }

    /*
 * Write the query
 * XXX Can send succeed partially?
 */
    cp = query;
    remaining = strlen(query);
    while(remaining)
    {
        n_written = send(fd, cp, remaining, 0);
        if(n_written < 0)
        {
            fprintf(stderr, "E: send failed: %s\n", strerror(errno));
            close(fd);
            return -__LINE__;
        }

        remaining -= n_written;
        cp += n_written;
    }

    /*
 * Get an answer back
 */
    while(1)
    {
        ssize_t result = recv(fd, buf, sizeof(buf), 0);
        if(result == 0)
        {
            break;
        }
        else if(result < 0)
        {
            fprintf(stderr, "recv failed: %s\n", strerror(errno));
            close(fd);
            return -__LINE__;
        }

        fwrite(buf, 1, result, stdout);
    }

    close(fd);

    return 0;
}

在上面的示例代码里, 大部分有关网络与IO的函数调用, 都是阻塞式的. 比如gethostbyname, 在DNS解析成功域名之前是不返回的(或者解析失败了会返回失败), connect函数, 在与对端主机成功建立TCP连接之前是不返回的(或者连接失败), 再比如recvsend函数, 在成功操作, 或明确失败之前, 也是不返回的.

阻塞式IO确实比较土, 上面的程序编译运行的时候, 如果你网络状况不好, 可能会卡一两秒才会读到百度的首页, 这卡就是因为阻塞IO的缘故. 当然, 虽然比较土, 但像这样的场合, 使用阻塞IO是没什么问题的. 但假如你想写一个程序同时读取两个网站的首页的话, 就比较麻烦了: 因为你不知道哪个网站会先响应你的请求.. 你可以写一些, 比如像下面这样的, 很土的代码:

char buf[4096];
int i, n;
while(i_still_want_to_read())
{
    for(i = 0; i < n_sockets; ++i)
    {
        n = recv(fd[i], buf, sizeof(buf), 0);

        if(n == 0)
        {
            handle_close(fd[i]);
        }
        else if(n < 0)
        {
            handle_error(fd[i], errno);
        }
        else
        {
            handle_input(fd[i], buf, n);
        }
    }
}

如果你的fd[]数组里有两个网站的连接, fd[0]接着百度, fd[1]接着hao123, 假如hao123正常响应了, 可以从fd[1]里读出数据了, 但百度的服务器被李老板炸了, 响应不了了, 这时, 上面的代码就会卡在i==0时循环里的n = recv(fd[0], buf, sizeof(buf), 0)这条语句中, 直到李老板把服务器修好. 这就很蛋疼.

当然, 你可以用多线程解决这个问题, 多数情况下, 你有一个问题, 你尝试使用多线程解决, 然后你多个了有问题.

上面是一个冷笑话, 多线程或多进程是一个解决方案, 通常情况下, 最简单的套路是使用一个线程或进程去建立TCP连接, 然后连接建立成功后, 为每个连接创建独立的线程或进程来进行IO读写. 这样即使一个网站抽风了, 也只阻塞属于它自己的那个读写线程或进程, 不会影响到其它网站的响应.

下面是另外一个例子程序, 这是一个服务端程序, 监听40173端口上的TCP连接请求, 然后把客户端发送的数据按ROT13法再回写给客户端, 一次处理一行数据. 这个程序使用Unix上的fork()函数为每个客户端的连接创建一个独立的处理进程.

#include <netinet/in.h> // for sockaddr_in
#include <sys/socket.h> // for socket functions
#include <sys/errno.h> // for errno

#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <stdlib.h>

#define MAX_LINE 16384

char rot13_char(char c)
{
    if(
        (c >= 'a' && c <= 'm') ||
        (c >= 'A' && c <= 'M')
    )
    {
        return c+13;
    }
    else if(
        (c >= 'n' && c <= 'z') ||
        (c >= 'N' && c <= 'Z')
    )
    {
        return c-13;
    }
    else
    {
        return c;
    }
}

void child(int fd)
{
    char outbuf[MAX_LINE + 1];  // extra byte for '\0'
    size_t outbuf_used = 0;
    ssize_t result;

    while(1)
    {
        char ch;
        result = recv(fd, &ch, 1, 0);

        if(result == 0)
        {
            break;
        }
        else if(result == -1)
        {
            perror("read");
            break;
        }

        if(outbuf_used < sizeof(outbuf))
        {
            outbuf[outbuf_used++] = rot13_char(ch);
        }

        if(ch == '\n')
        {
            send(fd, outbuf, outbuf_used, 0);
            outbuf_used = 0;
            continue;
        }
    }
}

void run(void)
{
    int listener;
    struct sockaddr_in sin;

    sin.sin_family = AF_INET;
    sin.sin_addr.s_addr = 0;
    sin.sin_port = htons(40713);

    listener = socket(AF_INET, SOCK_STREAM, 0);

    int one = 1;
    setsockopt(listener, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));

    if(bind(listener, (struct sockaddr *)(&sin), sizeof(sin)) < 0)
    {
        perror("bind");
        return;
    }

    if(listen(listener, 16) < 0)
    {
        perror("listen");
        return;
    }

    while(1)
    {
        struct sockaddr_storage ss;
        socklen_t slen = sizeof(ss);
        int fd = accept(listener, (struct sockaddr *)(&ss), &slen);

        if(fd < 0)
        {
            perror("accept");
        }
        else
        {
            if(fork() == 0)
            {
                child(fd);
                exit(0);
            }
        }
    }
}

int main(int argc, char ** argv)
{
    run();
    return 0;
}

你可以使用下面的命令行, 通过netcat工具向本机的40713发送数据, 来试验一下上面的服务端代码:

printf "abcdefghijklmnopqrstuvwxyz\n" | nc -4 -w1 localhost 40713

多进程或多线程确实是一个还算是比较优雅的, 应对并发连接的解决方案. 这种解决方案的缺陷是: 进程或线程的创建是有开销的, 在某些平台上, 这个开销还是比较大的. 这里优化的方案是使用线程, 并使用线程池策略. 如果你的机器需要处理上千上万的并发连接, 这就意味着你需要创建成千上万个线程, 想象一下, 服务器一般也就十几个核心, 64个不得了了, 如果有五千并发连接, 5000个线程排除轮64个核心的大米, 线程调度肯定是个大开销.

这个时候我们就需要了解一下非阻塞了, 通过下面的Unix调用, 可以将一个文件描述符设置为"非阻塞"的. 明确一下: "非阻塞"描述的是IO函数的行为, 将一个文件描述符设置为"非阻塞"的, 其实是指, 在这个文件描述符上执行IO操作, 函数的行为会变成非阻塞的.

fcntl(fd, F_SETFL, O_NONBLOCK);

当这个文件描述符是socket的文件描述符时, 我们一般也会直接称, "把一个socket设置为非阻塞". 将一个socket设置为非阻塞之后, 在对应的文件描述符上, 无论是执行网络编程相关的函数, 还是执行IO相关的函数, 函数行为都会变成非阻塞的, 即函数在调用之后就立即返回: 要么立即返回成功, 要把立即告诉调用者: "暂时不可用, 请稍后再试"

有了非阻塞这种手段, 我们就可以改写我们的访问网页程序了: 我们这时可以正确的处理同时下载两个网站的数据的需求了. 代码片断如下:

int i, n;
char buf[1024];

for(i = 0; i < n_sockets; ++i)
{
    fcntl(fd[i], F_SETFL, O_NONBLOCK);
}

while(i_still_want_to_read)
{
    for(int i = 0; i < n_sockets; ++i)
    {
        n = recv(fd[i], buf, sizeof(buf), 0);
        if(n == 0)
        {
            handle_close(fd[i]);        // peer was closed
        }
        else if(n < 0)
        {
            if(errno == EAGAIN)
            {
                // do nothing, the kernel didn't have any data for us to read
                // retry
            }
            else
            {
                handle_error(fd[i], errno);
            }
        }
        else
        {
            handle_input(fd[i], buf, n);    // read success
        }
    }
}

这样写确实解决了问题, 但是, 在对端网站还没有成功响应的那几百毫秒里, 这段代码将会疯狂的死循环, 会把你的一个核心占满. 这是一个很蛋疼的解决方案, 原因是: 对于真正的数据何时到达, 我们无法确定, 只能开个死循环轮询.

旧式的改进方案是使用一个叫select()的系统调用函数. select()函数内部维护了三个集合:

  1. 有数据可供读取的文件描述符
  2. 可以进行写入操作的文件描述符
  3. 出现异常的文件描述符

select()函数在这三个集合有至少一个集合不为空的时候返回. 如果三个集合都为空, 那么select()函数将阻塞.

下面是使用select()改进后的代码片断:

fd_set readset;
int i, n;
char buf[1024];

while(i_still_want_to_read)
{
    int maxfd = -1;
    FD_ZERO(&readset);

    // add all of the interesting fds to readset
    for(i = 0; i < n_sockets; ++i)
    {
        if(fd[i] > maxfd)
        {
            maxfd = fd[i];
        }

        FD_SET(fd[i], &readset):
    }

    select(maxfd+1, &readset, NULL, NULL, NULL);

    for(int i = 0; i < n_sockets; ++i)
    {
        if(FDD_ISSET(fd[i], &readset))
        {
            n = recv(fd[i], &readset);

            if(n == 0)
            {
                handle_close(fd[i]);
            }
            else if(n < 0)
            {
                if(errno == EAGAIN)
                {
                    // the kernel didn't have any data for us to read
                }
                else
                {
                    handle_error(fd[i], errno);
                }
            }
            else
            {
                handle_input(fd[i], buf, n);
            }
        }
    }
}

使用select()改进了程序, 但select()蛋疼的地方在于: 它只告诉你, 三集合中有数据了, 但是: 哪个fd可读, 哪个fd可写, 哪个fd有异常, 这些具体的信息, 它还是没告诉你. 如果你的fd数量不多, OK, 上面的代码没什么问题, 但如果你持有着上千个并发连接, 那每次select()返回时, 你都需要把所有fd都轮一遍.

下面是使用select()调用对rot13服务端示例代码的重构

#include <netinet/in.h> // for sockaddr_in
#include <sys/socket.h> // for socket functions
#include <sys/errno.h> // for errno
#include <fcntl.h> // for fcntl
#include <sys/select.h> // for select

#include <assert.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <stdlib.h>

#define MAX_LINE 16384

char rot13_char(char c)
{
    if(
        (c >= 'a' && c <= 'm') ||
        (c >= 'A' && c <= 'M')
    )
    {
        return c+13;
    }
    else if(
        (c >= 'n' && c <= 'z') ||
        (c >= 'N' && c <= 'Z')
    )
    {
        return c-13;
    }
    else
    {
        return c;
    }
}

struct fd_state{
    char buffer[MAX_LINE];
    size_t buffer_used;

    int writing;
    size_t n_written;
    size_t write_upto;
};

struct fd_state * alloc_fd_state(void)
{
    struct fd_state * state = malloc(sizeof(struct fd_state));
    if(!state)
    {
        return NULL;
    }
    
    state->buffer_used = state->n_written = state->writing = state->write_upto = 0;
    return state;
}

void free_fd_state(struct fd_state * state)
{
    free(state);
}

void make_nonblocking(int fd)
{
    fcntl(fd, F_SETFL, O_NONBLOCK);
}

int do_read(int fd, struct fd_state * state)
{
    char buf[1024];
    int i;
    ssize_t result;

    while(1)
    {
        result = recv(fd, buf, sizeof(buf), 0);

        if(result <= 0)
        {
            break;
        }

        for(int i = 0; i < result; ++i)
        {
            if(state->buffer_used < sizeof(state->buffer))
            {
                state->buffer[state->buffer_used++] = rot13_char(buf[i]);
            }

            if(buf[i] == '\n')
            {
                state->writing = 1;
                state->write_upto = state->buffer_used;
            }
        }
    }

    if(result == 0)
    {
        return 1;
    }
    else if(result < 0)
    {
        if(errno == EAGAIN)
        {
            return 0;
        }

        return -1;
    }

    return 0;
}

int do_write(int fd, struct fd_state * state)
{
    while(state->n_written < state->write_upto)
    {
        ssize_t result = send(fd, state->buffer + state->n_written, state->write_upto - state->n_written, 0);

        if(result < 0)
        {
            if(errno == EAGAIN)
            {
                return 0;
            }

            return -1;
        }

        assert(result != 0);

        state->n_written += result;
    }

    if(state->n_written == state->buffer_used)
    {
        state->n_written = state->write_upto = state->buffer_used = 0;
    }

    state->writing = 0;

    return 0;
}

void run(void)
{
    int listener;
    struct fd_state * state[FD_SETSIZE];
    struct sockaddr_in sin;
    int i, maxfd;
    fd_set readset, writeset, exset;

    sin.sin_family = AF_INET;
    sin.sin_addr.s_addr = 0;
    sin.sin_port = htons(40713);
    
    for(i = 0; i < FD_SETSIZE; ++i)
    {
        state[i] = NULL;
    }

    listener = socket(AF_INET, SOCK_STREAM, 0);
    make_nonblocking(listener);

    int one = 1;
    setsockopt(listener, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));

    if(bind(listener, (struct sockaddr *)(&sin), sizeof(sin)) < 0)
    {
        perror("bind");
        return;
    }

    if(listen(listener, 16) < 0)
    {
        perror("listen");
        return;
    }

    FD_ZERO(&readset);
    FD_ZERO(&writeset);
    FD_ZERO(&exset);

    while(1)
    {
        maxfd = listener;

        FD_ZERO(&readset);
        FD_ZERO(&writeset);
        FD_ZERO(&exset);

        FD_SET(listener, &readset);

        for(i = 0; i < FD_SETSIZE; ++i)
        {
            if(state[i])
            {
                if(i > maxfd)
                {
                    maxfd = i;
                }

                FD_SET(i, &readset);

                if(state[i]->writing)
                {
                    FD_SET(i, &writeset);
                }
            }
        }

        if(select(maxfd + 1, &readset, &writeset, &exset, NULL) < 0)
        {
            perror("select");
            return;
        }

        if(FD_ISSET(listener, &readset))
        {
            struct sockaddr_storage ss;
            socklen_t slen = sizeof(ss);

            int fd = accept(listener, (struct sockaddr *)(&ss), &slen);

            if(fd < 0)
            {
                perror("accept");
            }
            else if(fd > FD_SETSIZE)
            {
                close(fd);
            }
            else
            {
                make_nonblocking(fd);
                state[fd] = alloc_fd_state();
                assert(state[fd]);
            }
        }

        for(i = 0; i < maxfd + 1; ++i)
        {
            int r = 0;

            if(i == listener)
            {
                continue;
            }

            if(FD_ISSET(i, &readset))
            {
                r = do_read(i, state[i]);
            }

            if(r == 0 && FD_ISSET(i, &writeset))
            {
                r = do_write(i, state[i]);
            }

            if(r)
            {
                free_fd_state(state[i]);
                state[i] = NULL;
                close(i);
            }
        }
    }
}

int main(int argc, char ** argv)
{
    setvbuf(stdout, NULL, _IONBF, 0);

    run();

    return 0;
}

但这样还不够好: FD_SETSIZE是一个很大的值, 至少不小于1024. 当要监听的fd的值比较大的时候, 就很恶心, 遍历会遍历很多次. 对于非阻塞IO接口来讲, select是一个很粗糙的解决方案, 这个系统调用提供的功能比较薄弱, 只能说是够用, 但接口确实太屎了, 不好用, 性能也堪优.

不同的操作系统平台上提供了很多select的替代品, 它们都用于配套非阻塞IO接口来使单线程程序也有一定的并发能力. 这些替代品有poll(), epoll(), kqueue(), evports/dev/poll. 并且这些替代品的性能都比select()要好的多. 但比较蛋疼的是, 上面提到的所有接口, 几乎都不是跨平台的. epoll()是Linux独有的, kqueue()是BSD系列(包括OS X)独有的. evports/dev/poll是Solaris独有的. 是的, select()属于POSIX标准的一部分, 但就是性能捉急. 也就是说, 如果你写的程序想跨平台, 高性能, 你就得自己写一层抽象, 把不同平台对于IO多路复用的底层统一起来: 这也就是Libevent干的事情.

libevent的低级API为IO多路复用提供了统一的接口, 其底层实现在不同的操作系统平台上都是最高效的实现.

下面, 我们将使用libevent对上面的程序进行重构. 注意: fd_sets不见了, 取而代之的是一个叫event_base的结构体.

/* For sockaddr_in */
#include <netinet/in.h>
/* For socket functions */
#include <sys/socket.h>
/* For fcntl */
#include <fcntl.h>

#include <event2/event.h>

#include <assert.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>

#define MAX_LINE 16384

void do_read(evutil_socket_t fd, short events, void *arg);
void do_write(evutil_socket_t fd, short events, void *arg);

char
rot13_char(char c)
{
    /* We don't want to use isalpha here; setting the locale would change
 * which characters are considered alphabetical. */
    if ((c >= 'a' && c <= 'm') || (c >= 'A' && c <= 'M'))
        return c + 13;
    else if ((c >= 'n' && c <= 'z') || (c >= 'N' && c <= 'Z'))
        return c - 13;
    else
        return c;
}

struct fd_state {
    char buffer[MAX_LINE];
    size_t buffer_used;

    size_t n_written;
    size_t write_upto;

    struct event *read_event;
    struct event *write_event;
};

struct fd_state *
alloc_fd_state(struct event_base *base, evutil_socket_t fd)
{
    struct fd_state *state = malloc(sizeof(struct fd_state));
    if (!state)
        return NULL;
    state->read_event = event_new(base, fd, EV_READ|EV_PERSIST, do_read, state);
    if (!state->read_event) {
        free(state);
        return NULL;
    }
    state->write_event =
        event_new(base, fd, EV_WRITE|EV_PERSIST, do_write, state);

    if (!state->write_event) {
        event_free(state->read_event);
        free(state);
        return NULL;
    }

    state->buffer_used = state->n_written = state->write_upto = 0;

    assert(state->write_event);
    return state;
}

void
free_fd_state(struct fd_state *state)
{
    event_free(state->read_event);
    event_free(state->write_event);
    free(state);
}

void
do_read(evutil_socket_t fd, short events, void *arg)
{
    struct fd_state *state = arg;
    char buf[1024];
    int i;
    ssize_t result;
    while (1) {
        assert(state->write_event);
        result = recv(fd, buf, sizeof(buf), 0);
        if (result <= 0)
            break;

        for (i=0; i < result; ++i)  {
            if (state->buffer_used < sizeof(state->buffer))
                state->buffer[state->buffer_used++] = rot13_char(buf[i]);
            if (buf[i] == '\n') {
                assert(state->write_event);
                event_add(state->write_event, NULL);
                state->write_upto = state->buffer_used;
            }
        }
    }

    if (result == 0) {
        free_fd_state(state);
    } else if (result < 0) {
        if (errno == EAGAIN) // XXXX use evutil macro
            return;
        perror("recv");
        free_fd_state(state);
    }
}

void
do_write(evutil_socket_t fd, short events, void *arg)
{
    struct fd_state *state = arg;

    while (state->n_written < state->write_upto) {
        ssize_t result = send(fd, state->buffer + state->n_written,
                              state->write_upto - state->n_written, 0);
        if (result < 0) {
            if (errno == EAGAIN) // XXX use evutil macro
                return;
            free_fd_state(state);
            return;
        }
        assert(result != 0);

        state->n_written += result;
    }

    if (state->n_written == state->buffer_used)
        state->n_written = state->write_upto = state->buffer_used = 1;

    event_del(state->write_event);
}

void
do_accept(evutil_socket_t listener, short event, void *arg)
{
    struct event_base *base = arg;
    struct sockaddr_storage ss;
    socklen_t slen = sizeof(ss);
    int fd = accept(listener, (struct sockaddr*)&ss, &slen);
    if (fd < 0) { // XXXX eagain??
        perror("accept");
    } else if (fd > FD_SETSIZE) {
        close(fd); // XXX replace all closes with EVUTIL_CLOSESOCKET */
    } else {
        struct fd_state *state;
        evutil_make_socket_nonblocking(fd);
        state = alloc_fd_state(base, fd);
        assert(state); /*XXX err*/
        assert(state->write_event);
        event_add(state->read_event, NULL);
    }
}

void
run(void)
{
    evutil_socket_t listener;
    struct sockaddr_in sin;
    struct event_base *base;
    struct event *listener_event;

    base = event_base_new();
    if (!base)
        return; /*XXXerr*/

    sin.sin_family = AF_INET;
    sin.sin_addr.s_addr = 0;
    sin.sin_port = htons(40713);

    listener = socket(AF_INET, SOCK_STREAM, 0);
    evutil_make_socket_nonblocking(listener);

#ifndef WIN32
    {
        int one = 1;
        setsockopt(listener, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
    }
#endif

    if (bind(listener, (struct sockaddr*)&sin, sizeof(sin)) < 0) {
        perror("bind");
        return;
    }

    if (listen(listener, 16)<0) {
        perror("listen");
        return;
    }

    listener_event = event_new(base, listener, EV_READ|EV_PERSIST, do_accept, (void*)base);
    /*XXX check it */
    event_add(listener_event, NULL);

    event_base_dispatch(base);
}

int
main(int c, char **v)
{
    setvbuf(stdout, NULL, _IONBF, 0);

    run();
    return 0;
}

总之:

  1. 注意链接的时候加上 -levent
  2. 代码量没有减少, 逻辑也没有简化. libevent只是给你提供了一个通用的多路IO接口. 或者叫事件监听接口.
  3. evutil_socket_t类型的使用, 与evutil_make_socket_nonblocking()函数的使用, 均是为也跨平台兼容性. 使用这些类型名与工具函数, 使得在windows平台上代码也能跑起来.

现在, 你看, 异步IO+事件处理(或者叫多路IO复用), 是单线程单进程程序取得并发能力的最佳途径, 而libevent则是把多平台的IO多路复用库给你抽象统一成一层接口了. 这样代写的代码不需要改动, 就可以运行在多个平台上.

这样就有了三个问题:

  1. 如果我的代码需要跨平台, 或者只需要跨部分平台(比如我只考虑Linux和BSD用户, 完全不考虑Windows平台), 我为什么不自己把多路IO库做个简单的封装, 为什么要使用libevent呢? 典型的就是Redis, 用了很薄的一层封装, 下面统一了epoll, kqueue, evport, select等. 为什么, 我需要使用libevent呢?
  2. 如果将libevent作为一个黑盒去用, 不可避免的问题就是: 它的性能怎么样? 它封装了多个多路IO库, 在封装上是否有性能损失?
  3. 现在是个*都说自己解决了跨平台问题, 那么libevent在windows上表现怎么样? 它能兼容IOCP式多路IO库吗? 毕竟IOCP的设计思路和epoll``select``evport``kqueue等都不一样.

答案在这里:

  1. 你没有任何理由非得使用libevent, redis就是一个很好的例子. libevent有不少功能, 但如果你只是跨小部分平台, 并且只关注在多路IO复用上, 那么真的没什么必要非得用libevent. 你完全可以像redis那样, 用几百行简单的把多路IO库自己封装一下.
  2. 基本上这么讲吧: 你使用系统原生异步IO多路复用接口的性能是多少, 使用libevent就是多少. 说实施libevent里没太多的抽象, 接口也没有多么好用, 封闭很薄, 和你使用原生接口基本一样.
  3. libevent从版本2开始就能搞定windows了. 上面我们使用的是libevent很底层的接口, 其设计思路是遵循*nix上的事件处理模型的, 典型的就是selectepoll: 当网络可读写时, 通知应用程序去读去写. 而windows上IOCP的设计思路是: 当网络可读可写时不通知应用程序, 而是先完成读与写, 再通知应用程序, 应用程序直接拿到的就是数据. 当在libevent 2提供的bufferevents系列接口中, 它将*nix平台下的设计, 改巴改巴改成了IOCP式的. 使用这个系列的接口不可避免的, 对*nix平台有性能损失(这和asio封装网络库是一样的做法), 但实话讲, IOCP式的设计确实对程序员更友好, 代码可读性高了不少.

总的来说, 你应该在如下的场合使用libevent

  1. 代码需要跨多个平台, 甚至是windows
  2. 想在*nix平台上使用IOCP式的事件接口编程
  3. 你不想自己封装多个平台上的多路IO接口, 并且自认为, 就算自己做, 做的也肯定没有libevent好. libevent是一个久经考验的很基础的库. 身经百战.

下面是使用bufferevents系列接口, 以IOCP式风格对之前例子代码的重构, 体验一下更人性的事件处理方式:

/* For sockaddr_in */
#include <netinet/in.h>
/* For socket functions */
#include <sys/socket.h>
/* For fcntl */
#include <fcntl.h>

#include <event2/event.h>
#include <event2/buffer.h>
#include <event2/bufferevent.h>

#include <assert.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>

#define MAX_LINE 16384

void do_read(evutil_socket_t fd, short events, void *arg);
void do_write(evutil_socket_t fd, short events, void *arg);

char
rot13_char(char c)
{
    /* We don't want to use isalpha here; setting the locale would change
 * which characters are considered alphabetical. */
    if ((c >= 'a' && c <= 'm') || (c >= 'A' && c <= 'M'))
        return c + 13;
    else if ((c >= 'n' && c <= 'z') || (c >= 'N' && c <= 'Z'))
        return c - 13;
    else
        return c;
}

void
readcb(struct bufferevent *bev, void *ctx)
{
    struct evbuffer *input, *output;
    char *line;
    size_t n;
    int i;
    input = bufferevent_get_input(bev);
    output = bufferevent_get_output(bev);

    while ((line = evbuffer_readln(input, &n, EVBUFFER_EOL_LF))) {
        for (i = 0; i < n; ++i)
            line[i] = rot13_char(line[i]);
        evbuffer_add(output, line, n);
        evbuffer_add(output, "\n", 1);
        free(line);
    }

    if (evbuffer_get_length(input) >= MAX_LINE) {
        /* Too long; just process what there is and go on so that the buffer
 * doesn't grow infinitely long. */
        char buf[1024];
        while (evbuffer_get_length(input)) {
            int n = evbuffer_remove(input, buf, sizeof(buf));
            for (i = 0; i < n; ++i)
                buf[i] = rot13_char(buf[i]);
            evbuffer_add(output, buf, n);
        }
        evbuffer_add(output, "\n", 1);
    }
}

void
errorcb(struct bufferevent *bev, short error, void *ctx)
{
    if (error & BEV_EVENT_EOF) {
        /* connection has been closed, do any clean up here */
        /* ... */
    } else if (error & BEV_EVENT_ERROR) {
        /* check errno to see what error occurred */
        /* ... */
    } else if (error & BEV_EVENT_TIMEOUT) {
        /* must be a timeout event handle, handle it */
        /* ... */
    }
    bufferevent_free(bev);
}

void
do_accept(evutil_socket_t listener, short event, void *arg)
{
    struct event_base *base = arg;
    struct sockaddr_storage ss;
    socklen_t slen = sizeof(ss);
    int fd = accept(listener, (struct sockaddr*)&ss, &slen);
    if (fd < 0) {
        perror("accept");
    } else if (fd > FD_SETSIZE) {
        close(fd);
    } else {
        struct bufferevent *bev;
        evutil_make_socket_nonblocking(fd);
        bev = bufferevent_socket_new(base, fd, BEV_OPT_CLOSE_ON_FREE);
        bufferevent_setcb(bev, readcb, NULL, errorcb, NULL);
        bufferevent_setwatermark(bev, EV_READ, 0, MAX_LINE);
        bufferevent_enable(bev, EV_READ|EV_WRITE);
    }
}

void
run(void)
{
    evutil_socket_t listener;
    struct sockaddr_in sin;
    struct event_base *base;
    struct event *listener_event;

    base = event_base_new();
    if (!base)
        return; /*XXXerr*/

    sin.sin_family = AF_INET;
    sin.sin_addr.s_addr = 0;
    sin.sin_port = htons(40713);

    listener = socket(AF_INET, SOCK_STREAM, 0);
    evutil_make_socket_nonblocking(listener);

#ifndef WIN32
    {
        int one = 1;
        setsockopt(listener, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
    }
#endif

    if (bind(listener, (struct sockaddr*)&sin, sizeof(sin)) < 0) {
        perror("bind");
        return;
    }

    if (listen(listener, 16)<0) {
        perror("listen");
        return;
    }

    listener_event = event_new(base, listener, EV_READ|EV_PERSIST, do_accept, (void*)base);
    /*XXX check it */
    event_add(listener_event, NULL);

    event_base_dispatch(base);
}

int
main(int c, char ** argv)
{
    setvbuf(stdout, NULL, _IONBF, 0);

    run();
    return 0;
}

说实话也没人性到哪里去, 底层库就是这样, libevent还是太基础了. 算不上十分友好的*.

3. Libevent 简介

现在我们要正式介绍Libevent

3.1 libevent的卖点

  1. 代码跨平台.
  2. 性能高. libevent在非阻塞IO+多路复用的底层实现上, 选取的是特定平台上最快的接口. 比如Linux上用epoll, BSD上用kqueue
  3. 高并发可扩展. libevent就是为了那种, 需要维持成千上万的活动socket连接的应用程序使用的.
  4. 接口友好. 虽然并没有友好多少, 但至少比原生的epoll要好一点.

3.2 libevent下的各个子模块

  1. evutil 通用类型定义, 跨平台相关的通用定义, 以及一些通用小函数
  2. event and event_base 核心模块. 事件库. *nix风格的事件模型: 在socket可读可写时通知应用程序.
  3. bufferevent 对核心事件库的再一层封装, IOCP式的事件模型: 在数据已读已写后通知应用程序
  4. evbuffer 这是bufferevent模块内部使用的缓冲区实现.
  5. evhttp 简单的HTTP C/S实现
  6. evdns 简单的 DNS C/S实现
  7. evrpc 简单的 RPC实现

总的来说, 作为使用者, 需要关心的是:

  1. evutil是需要关心的
  2. 对于主在*nix平台上写后台服务端程序的人: 只需要关心 event and event_base 核心库的用法即可.
  3. 对于跨平台, 特别是包含win平台的开发人员: 需要关注 buffereventevbuffer, 对于核心库event and event_base, 可以不关心
  4. evhttp, evdns, evrpc, 如无需要, 可以不用关心

3.3 libevent下的二进制库

以下是在链接你的代码的时候, 你需要了解的二进制库.

  1. libevent_core 包含event and event_base, evutil, evbuffer, bufferevent中的所有函数
  2. libevent_extra 包含协议相关的函数. 包括 HTTP/DNS/RPC 等. 如果你用不到 evhttp/evdns/evrpc里的函数, 那么这个库不用链接.
  3. libevent 满清遗老, 包含了上面两个库里的所有函数. 官方不建议在使用libevent 2.0以上的版本时链接这个库. 这是个懒人库.
  4. libevent_pthreads 如果你编写多线程应用程序. 那么这个库里包含了基于POSIX线程库的相关函数实现. 如果你没有用到libevent中有关的多线程函数, 那么这个库不用链接. 以前这些函数是划分在libevent_core中的, 后来被单独割出来了.注意: 这个库不是全平台的.
  5. libevent_openssl 这个库里的与OpenSSL相关的函数实现. 如果你没有用到libevent中有关OpenSSL的函数, 那么这个库不用链接. 以前这些函数也算在libevent_core中, 最后也割出来了. 注意: 这个库也不是全平台的

3.4 libevent中的头文件

libevent中的头文件分为三类, 所有头文件都位于event2目录下. 也就是说在代码中你应当这样写:

#include <event2/xxxx>
#include <event2/xxxx>
#include <event2/xxxx>
#include <event2/xxxx>

具体有哪些头文件在后续章节会详细介绍, 目前只介绍这个分类:

  1. API 头文件. 这些头文件定义了libevent对外的接口. 这些头文件没有特定前缀.
  2. 兼容性 头文件. 这些头文件是为了向前兼容老版本libevent存在的, 它们里面定义了老版本的一些废弃接口. 除非你是在做老代码迁移工作, 否则不建议使用这些头文件.
  3. 类型定义 头文件. 定义了libevent库中相关的类型. 这些头文件有共同后缀_struct.h

3.5 如何将老版本的代码迁移到libevent 2上

官方建议大家使用版本2, 但有时候这个世界就是不那么让人舒服, 如果你需要和版本1的历史代码打交道, 你可以参照下面的对照表: 老头文件与新头文件的对照表

旧头文件 新头文件
event.h event2/event*.h, event2/buffer*.h, event2/bufferevent*.h, event2/tag*.h
evdns.h event2/dns*.h
evhttp.h event2/http*.h
evrpc.h event2/rpc*.h
evutil.h event2/util*.h

在当前的2.0版本中, 老的旧头文件其实是不需要替换的, 这些旧头文件依然存在. 但还是建议将他们替换成新头文件, 因为说不定50年后libevent升级到3.0版本, 这些旧头文件就被扔了.

另外还有一些点需要你注意:

  1. 在1.4版本之前, 只有一个二进制库文件. libevent, 里面是libevent的所有实现. 如今这些实现被分割到了 libevent_corelibevent_extra两个库中.
  2. 在2.0之前, libevent不支持锁. 也就是说, 2.0之前如果要写出线程安全的代码, 你只能避免在线程间共享数据实例. 没有其它办法.

3.6 满清遗老

官方对待老版本是这样建议的:

  1. 1.4.7之前的版本被正式废弃了
  2. 1.3之前的版本有一堆bug, 用的时候看脸吧.
  3. 推荐使用2.0后的版本

我对老版本的态度是这样的: 能干活就好. 没有特殊原因, 我是不会做代码迁移的. 并且考虑到应用场景, 有时候用老版本也挺好的.

1.4.x版本的libevent被大量项目使用, 其实挺稳定的, 官方不建议使用, 只是官方不再在1.4版本上再加特性修bug了. 1.4版本最后的一个小版本号就停止在7上不动了. 而对于1.3版本, 确实不应该再碰了.

4. 使用libevent的正确姿势

libevent有几项全局设定, 如果你需要改动这几项设定, 那么确保在代码初始化的时候设定好值, 一旦你的代码流程开始了, 调用了第一个libevent中的任何函数, 后续强烈建议不要再更改设定值, 否则会引起不可预知的后果.

4.1 libevent中的日志

libevent默认情况下将把错误与警告日志写进stderr, 并且如果你需要一些libevent内部的调试日志的话, 也可以通过更改设定来让其输出调试日志, 以在程序崩溃时提供更多的参考信息. 这些行为都可以通过自行实现日志函数进行更改. 下面是libevent相关的日志接口.

// 以下是日志级别
#define EVENT_LOG_DEBUG 0
#define EVENT_LOG_MSG 1
#define EVENT_LOG_WARN 2
#define EVENT_LOG_ERR 3

// 以下是已经被废弃的日志级别定义
/* Deprecated; see note at the end of this section */
#define _EVENT_LOG_DEBUG EVENT_LOG_DEBUG
#define _EVENT_LOG_MSG EVENT_LOG_MSG
#define _EVENT_LOG_WARN EVENT_LOG_WARN
#define _EVENT_LOG_ERR EVENT_LOG_ERR

// 这个是一个函数指针类型别名, 指向日志输出函数
// 日志输出函数应当是一个双参, 无返回值的函数, 第一个参数severity为日志级别, 第二个参数为日志字符串
typedef void (*event_log_cb)(int severity, const char *msg);

// 这是用户自定义设置日志处理函数的接口. 如果调用该函数时入参设置为NULL
// 则将采用默认行为
void event_set_log_callback(event_log_cb cb);

比如下面我需要改写libevent记录日志的方式:

#include <event2/event.h>
#include <stdio.h>

// 丢弃日志
static void discard_cb(int severity, const char * msg)
{
    // 这个日志函数内部什么也不做
}

// 将日志记录至文件
static FILE * logfile = NULL;
static void write_to_file_cb(int severity, const char * msg)
{
    const char * s;
    if(!logfile)
    {
        return;
    }

    switch(severity)
    {
        case EVENT_LOG_DEBUG:   s = "[DBG]";    break;
        case EVENT_LOG_MSG:     s = "[MSG]";    break;
        case EVENT_LOG_WARN:    s = "[WRN]";    break;
        case EVENT_LOG_ERR:     s = "[ERR]";    break;
        default:                s = "[???]";    break;
    }

    fprintf(logfile, "[%s][%s][%s] %s\n", __FILE__, __func__, s, msg);
}

void suppress_logging(void)
{
    event_set_log_callback(discard_cb);
}

void set_logfile(FILE * f)
{
    logfile = f;
    event_set_log_callback(write_to_file_cb);
}

注意: 在自定义的日志输出函数中, 不要调用其它libevent中的函数! 比如, 如果你要把日志远程输出至网络socket上去, 你还使用了bufferevent来输出你的日志, 在目前的libevent版本中, 这会导致一些奇怪的bug. libevent官方也承认这是一个设计上没有考虑到的点, 这可能在后续版本中被移除, 但截止目前的2.1.8 stable版本, 这个问题都还没有解决. 不要作死.

默认情况下的日志级别是EVENT_LOG_MSG, 也就是说EVENT_LOG_DEBUG级别的日志不会调用至日志输出函数. 要让libevent输出调试级别的日志, 请使用下面的接口:

#define EVENT_DBG_NONE 0
#define EVENT_DBG_ALL 0xffffffffu

// 如果传入 EVENT_DBG_NONE, 将保持默认状态: 不输出调试日志
// 如果传入 EVENT_DEG_ALL, 将开启调试日志的输出
void event_enable_debug_logging(ev_uint32_t which);

调试日志很详尽, 通常情况下对于libevent的使用者而言是没有输出的必要的. 因为要用到调试级别日志的场合, 是你百般无奈, 开始怀疑libevent本身有bug的时候. 虽然从宏的命名上, 仿佛还存在着 EVENT_DGB_SOMETHING 这样, 可以单独控制某个模块的调试日志输出的参数, 但实际上并没有: 调试日志要么全开, 要么全关. 没有中间地带. 官方宣称可能在后续的版本中细化调试日志的控制.

而如果你要控制其它日志级别的输出与否, 请自行实现日志输出函数. 比如忽略掉EVENT_LOG_MSG级别的日志之类的. 上面的接口只是控制"如果产生了调试日志, libevent调用或不调用日志输出函数"而已.

上面有关日志的接口均定义在<event2/event.h>中.

  1. 日志输出函数相关接口早在版本1.0时就有了.
  2. event_enable_debug_logging()接口在2.1.1版本之后才有
  3. 日志级别宏名, 在2.0.19之前, 是以下划线开头的, 即_DEBUG_LOG_XXX, 但现在已经废弃掉了这种定义, 在新版本中请使用不带下划线开头的版本.

4.2 正确处理致命错误

当libevent检测到有致命的内部错误发生时(比如踩内存了之类的不可恢复的错误), 默认行为是调用exit()abort(). 出现这种情况99.99的原因是使用者自身的代码出现了严重的bug, 另外0.01%的原因是libevent自身有bug.

如果你希望在进程退出之前做点额外的事情, 写几行带fxxk的日志之类的, libevent提供了相关的入口, 这可以改写libevent对待致命错误的默认行为.

typedef void (*event_fatal_cb)(int err);
void event_set_fatal_callback(event_fatal_cb cb);

注意, 不要试图强行恢复这种致命错误, 也就是说, 虽然libevent给你提供了这么个接口, 但不要在注册的函数中试图让进程继续执行. 因为这个时候libevent内部已经有坑了, 如果继续强行恢复, 结果是不可预知的. 换个说法: 这个函数应该提供的是临终遗言, 而不应该试图救死扶伤.

这个函数也定义在 <event2/event.h>中, 在2.0.3版本之后可用.

4.3 内存管理

默认情况下, libevent使用的是标准C库中的内存管理函数, 即malloc(), realloc(), free()等. libevent允许你使用其它的内存管理库, 比如tcmallocjemalloc. 相关接口如下:

void event_set_mem_functions(void *(*malloc_fn)(size_t sz),
                             void *(*realloc_fn)(void *ptr, size_t sz),
                             void (*free_fn)(void *ptr));

接口的第一个参数是内存分配函数指针, 第二个参数是内存重分配函数指针, 第三个参数是内存释放函数指针.

下面是一个使用的例子:

#include <event2/event.h>
#include <sys/types.h>
#include <stdlib.h>

union alignment
{
    size_t sz;
    void * ptr;
    double dbl;
};

#define ALIGNMENT sizeof(union alignment)

#define OUTPTR(ptr) (((char *)ptr) + ALIGNMENT)
#define INPTR(ptr) (((char *)ptr) - ALIGNMENT)

static size_t total_allocated = 0;

static void * my_malloc(size_t sz)
{
    void * chunk = malloc(sz + ALIGNMENT);
    if(!chunk)  return chunk;

    total_allocated += sz;

    *(size_t *)chunk = sz;

    return OUTPTR(chunk);
}

static void * my_realloc(void * ptr, size_t sz)
{
    size_t old_size = 0;

    if(ptr)
    {
        ptr = INPTR(ptr);
        old_size = *(size_t*)ptr;
    }

    ptr = realloc(ptr, sz + ALIGNMENT);

    if(!ptr)
    {
        return NULL;
    }

    *(size_t *)ptr = sz;

    total_allocated = total_allocated - old_size + sz;

    return OUTPTR(ptr);
}

static void my_free(void * ptr)
{
    ptr = INPTR(ptr);
    total_allocated -= *(size_t *)ptr;
    free(ptr);
}

void start_counting_bytes(void)
{
    event_set_mem_functions(
        my_malloc, my_realloc, my_free
    );
}

上面这个例子中, 提供了一种记录全局内存使用量的简单方案, 非线程安全.

对于自定义内存管理接口, 需要注意的有:

  1. 再次重申, 这是一个全局设定, 一旦设定, 后续所有的libevent函数内部的内存操作都会受影响. 并且不要在代码流程中途更改设定.
  2. 自定义的内存管理函数, 在分配内存时, 返回的指针后必须确保在至少sz个字节可用.
  3. 自定义的内存重分配函数, 必须正确处理realloc(NULL, sz)这种情况: 即, 使之行为等同于 malloc(sz). 也必须正确处理realloc(ptr, 0)这种情况: 即, 使之行为与free(ptr)相同且返回NULL.
  4. 自定义的内存释放函数, 必须正确处理 free(NULL): 什么也不做.
  5. 自定义的内在分配函数, 必须正确处理 malloc(0): 返回NULL.
  6. 如果你在多线程环境中使用libevent, 请务必确保内存分配函数是线程安全的.
  7. 如果你要释放一个由libevent创建来的内存区域, 请确认你使用的free()版本与libevent内部使用的内存管理函数是一致的. 也就是说: 如果要操作libevent相关的内存区域, 请确保相关的内存处理函数和libevent内部使用的内在管理函数是一致的. 或者简单一点: 如果你决定使用某个内存管理库, 那么在整个项目范围内都使用它, 这样最简单, 不容易出乱子. 否则应该尽力避免在外部操作libevent创建的内存区域.

event_set_mem_functions()接口也定义在<event2/event.h>中, 在2.0.2版本后可用.

需要注意的是: libevent在编译安装的时候, 可以关闭event_set_mem_functions()这个特性. 如果关闭了这个特性, 而在项目中又使用了这个特性, 那么在项目编译时, 编译将报错. 如果要检测当前引入的libevent库是否启用了这个功能, 可以通过检测宏EVENT_SET_MEM_FUNCTIONS_IMPLEMENTED宏是否被定义来判断.

4.4 线程与锁

多线程程序设计里的数据访问是个大难题. 目前的版本里, libevent支持了多线程编程, 但这个支持法呢, 怎么讲呢, 使用者还是需要知道不少细节才能正确的写出多线程应用. libevent中的数据结构分为三类:

  1. 有一些数据结构就是非线程安全的. 这是历史遗留问题, libevent在大版本号更新为2后才支持多线程, 这些数据结构是从版本1一路继承下来的, 不要在多线程*享这些实例. 没办法.
  2. 有一些数据结构的实例可以用锁保护起来, 以在多线程环境*享. 如果你需要在多个线程中访问某个实例, 那么你需要给libevent说明这个情况, 然后libevent会为这个实例加上适当的锁保护, 以确保你在多线程访问它时是安全的. 加锁不需要你去加, 你需要做的只是告诉libevent一声, 如何具体操作后面再讲.
  3. 有些数据结构, 天生就是带锁的. 如果你带 libevent_pthreads 库链接你的程序, 那么这些结构的实例在多线程环境中一定的安全的. 你想让它不安全都没办法.

虽然libevent为你写了一些加锁解锁的无聊代码, 你不必要手动为每个对象加锁了, 但libevent还是需要你指定加锁的函数. 就像你可以为libevent指定其它的内存管理库一样. 注意这也是一个全局设定, 请遵循我们一再强调的使用规则: 进程初始化时就定好, 后续不许再更改.

如果你使用的是POSIX线程库, 或者标准的windows原生线程库, 那么简单了一些. 设置加解锁函数只需要一行函数调用, 接口如下:

#ifdef WIN32
int evthread_use_windows_threads(void);
#define EVTHREAD_USE_WINDOWS_THREADS_IMPLEMENTED
#endif

#ifdef _EVENT_HAVE_PTHREADS
int evthread_use_pthreads();
#define EVTHREAD_USE_PTHREADS_IMPLEMENTED
#endif

这两个函数在成功时都返回0, 失败时返回-1.

这只是加解锁. 但如果你想要自定义的是整个线程库, 那么你就需要手动指定如下的函数与结构定义

  1. 锁的定义
  2. 加锁函数
  3. 解锁函数
  4. 锁分配函数
  5. 锁释放函数
  6. 条件变量定义
  7. 条件变量创建函数
  8. 条件变量释放函数
  9. 条件变量等待函数
  10. 通知/广播条件变量的函数
  11. 线程定义
  12. 线程ID检测函数

这里需要注意的是: libevent并不会为你写哪怕一行的多线程代码, libevent内部也不会去创建线程. 你要使用多线程, OK, 你用哪种线程库都行, 没问题. 但你需要将配套的锁/条件变量/线程检测函数以及相关定义告诉libevent, 这样libevent才会知道如何在多线程环境中保护自己的实例, 以供你在多线程环境中安全的访问.

  1. 如果你使用的是POSIX线程或者windows原生线程库, 就方便了一点, 调一行函数的事情.
  2. 如果你在使用POSIX纯种或windows原生线程库时, 你不想使用POSIX配套的锁, 那OK, 你在调用完evthread_use_xxx_threads()之后, 把你自己的锁函数或者条件变量函数提供给libevent就好了. 注意这种情况下, 在你的程序的其它地方也需要使用你指定的锁或条件变量.
  3. 而如果你使用的是其它线程库, 也OK, 只不过麻烦一点, 要提供锁的相关信息, 要提供条件变量的相关信息, 也要提供线程ID检测函数

下面是相关的接口

// 锁模式是 lock 与 unlock 函数的参数, 它指定了加锁解锁时的一些额外信息
// 如果调用 lock 或 unlock 时的锁都不满足下面的三种模式, 参数传0即可
#define EVTHREAD_WRITE 0x04 // 锁模式: 仅对读写锁使用: 获取或释放写锁
#define EVTHREAD_READ 0x08 // 锁模式: 仅对读写锁使用: 获取或释放读锁
#define EVTHREAD_TRY 0x10 // 锁模式: 仅在加锁时使用: 仅在可以立即加锁的时候才去加锁. 
                                    // 若当前不可加锁, 则lock函数立即返回失败, 而不是阻塞

// 锁类型是 alloc 与 free 函数的参数, 它指定了创建与销毁的锁的类型
// 锁类型可以是 EVTHREAD_LOCKTYPE_XXX 之一或者为0
// 所有支持的锁类型均需要被登记在 supported_locktypes 中, 如果支持多种锁, 则多个宏之间用 | 连结构成该字段的值
                                            // 当锁类型为0时, 指的是普通的, 非递归锁
#define EVTHREAD_LOCKTYPE_RECURSIVE 1 // 锁类型: 递归锁, 你必须提供一种递归锁给libevent使用
#define EVTHREAD_LOCKTYPE_READWRITE 2 // 锁类型: 读写锁, 在2.0.4版本之前, libevent内部没有使用到读写锁

#define EVTHREAD_LOCK_API_VERSION 1

// 将你要用的有关锁的所有信息放在这个结构里
struct evthread_lock_callbacks {
       int lock_api_version;                // 必须与宏 EVTHREAD_LOCK_API_VERSION的值一致
       unsigned supported_locktypes;        // 必须是宏 EVTHREAD_LOCKTYPE_XXX 的或组合, 或为0
       void *(*alloc)(unsigned locktype);               // 锁分配, 需要指定锁类型
       void (*free)(void *lock, unsigned locktype);     // 锁销毁, 需要指定锁类型
       int (*lock)(unsigned mode, void *lock);          // 加锁, 需要指定锁模式
       int (*unlock)(unsigned mode, void *lock);        // 解锁, 需要指定锁模式
};

// 调用该函数以设置相关锁函数
int evthread_set_lock_callbacks(const struct evthread_lock_callbacks *);

// 调该函数以设置线程ID检测函数
void evthread_set_id_callback(unsigned long (*id_fn)(void));

// 将你要用的有关条件变量的所有信息都放在这个结构里
struct evthread_condition_callbacks {
        int condition_api_version;
        void *(*alloc_condition)(unsigned condtype);
        void (*free_condition)(void *cond);
        int (*signal_condition)(void *cond, int broadcast);
        int (*wait_condition)(void *cond, void *lock,
            const struct timeval *timeout);
};

// 通过该函数以设置相关的条件变量函数
int evthread_set_condition_callbacks(
        const struct evthread_condition_callbacks *);

要探究具体如何使用这些函数, 请看libevent源代码中的evthread_pthread.cevthread_win32.c文件.

对于大多数普通用户来说, 只需要调用一下evthread_use_windows_threads()evthread_use_pthreads()就行了.

上面这些函数均定义在 <event2/thread.h>中. 在2.0.4版本后这些函数才可用. 2.0.1至2.0.3版本中使用了一些旧接口, event_use_pthreads()等. 有关条件变量的相关接口直至2.0.7版本才可用, 引入条件变量是为了解决之前libevent出现的死锁问题.

libevent本身可以被编译成不支持锁的二进制库, 用这种二进制库链接你的多线程代码, bomshakalaka, 跑不起来. 这算是个无用知识点.

另外额外注意: 多线程程序, 并且还使用了POSIX线程库和配套的锁, 那么你需要链接libevent_pthreads. windows平台则不用.

4.5 小知识点: 有关锁的调试

libevent有一个额外的特性叫"锁调试", 开启这种特性后, libevent将把它内部有关锁的所有调用都再包装一层, 以检测/获取在锁调用过程中出现的错误, 比如:

  1. 解了一个没有持有的锁
  2. 对一个非递归锁进行了二次加锁

如果出现了上述错误, 则libevent会导致进程退出, 并附送一个断言错误

要开启这个特性, 调用下面的接口:

void evthread_enable_lock_debugging(void);
#define evthread_enable_lock_debuging() evthread_enable_lock_debugging()

注意, 这也是一个全局设置项, 请遵循: 一次设置, 初始化时就设置, 永不改动的规则.

这个特性在2.0.4版本中开始支持, 当时接口函数名拼写错误了, 少写了一个g: evthread_enable_lock_debuging(). 后来在2.1.2版本中把这个错误的拼写修正过来了. 但还是兼容了之前的错误拼写.

这个特性吧, 很明显是libevent内部开发时使用的. 现在开放出来估计是考虑到, 如果你的代码中出现了一个bug是由libevent内部加解锁失误导致的, 那么用个特性可以定位到libevent内部. 否则你很难把锅甩给libevent. 当然这种情况很少见.

4.6 小知识点: 排除不正确的使用姿势

libevent是一个比较薄的库, 薄的好处是性能很好, 坏处是没有在接口上对使用者做过多的约束. 这就导致一些二把刀使用者经常会错误的使用libevent. 常见的智障行为有:

  1. 向相关接口传递了一个未初始化的事件结构实例
  2. 试图第二次初始化一个正在被使用的事件结构实例

这种错误其实挺难发现的, 为了解决这个痛点, libevent额外开发了一个新特性: 在发生上述情况的时候, libevent给你报错.

但这是一个会额外消耗资源的特性, libevent内部其实是追踪了每个事件结构的初始化与销毁, 所以仅在开发测试的时候打开它, 发现问题, 解决问题. 在实际部署的时候, 不要使用这个特性. 开启这个特性的接口如下:

void event_enable_debug_mode(void);

再不厌其烦的讲一遍: 全局设定, 初始化时设定, 一次设定, 永不更改.

这个特性开启后, 也有一个比较蛋疼的事情: 就是如果你的代码里大量使用了event_assign()来创建事件结构, 可能你的程序在这个特性下会OOM挂掉..原因是: libevent可以通过对event_new()event_free()的追踪来检测事件结构实例是否未被初始化, 或者被多次初始化, 或者被非法使用. 但是对于event_assign()拷贝来的事件结构, 这追踪就无能为力了, 并且蛋疼的是event_assign()还是浅拷贝. 这样, 如果你的代码里大量的使用了event_assign(), 这就会导致内置的的追踪功能一旦追上车就下不来了, 完事车太多就OOM挂掉了.

为了避免在这个特性下由于追踪event_assign()创建的事件实例(或许这里叫实例已经不合适了, 应该叫句柄)而导致程序OOM, 可以调用下面的函数以解除对这种事件实例的追踪, 以避免OOM

void event_debug_unassign(struct event * ev);

这样, 调试模式下, 相关的追踪检测就会放弃追踪由event_assign创建的事件. 所以你看, 这个特性也不是万能的, 有缺陷, 凑合用吧. 在不开启调试模式下, 调用event_debug_unassign()函数没有任何影响

下面是一个例子:

#include <event2/event.h>
#include <event2/event_struct.h>

#include <stdlib.h>

void cb(evutil_socket_t fd, short what, void *ptr)
{
    struct event *ev = ptr;

    if (ev)     // 通过判断入参是否为NULL, 来确认入参携带的事件实例是event_new来的还是event_assign来的
        event_debug_unassign(ev); // 如果是event_assign来的, 那么就放弃对它的追踪
}

/*
 * 下面是一个简单的循环, 等待fd1与fd2同时可读
 */
void mainloop(evutil_socket_t fd1, evutil_socket_t fd2, int debug_mode)
{
    struct event_base *base;
    struct event event_on_stack, *event_on_heap;        // 一个是栈上的事件实例, 一个是堆上的事件实例

    if (debug_mode)
       event_enable_debug_mode();       // 开启调试模式

    base = event_base_new();

    event_on_heap = event_new(base, fd1, EV_READ, cb, NULL);    // 通过event_new来创建堆上的实例, 并把事件回调的入参设置为NULL
    event_assign(&event_on_stack, base, fd2, EV_READ, cb, &event_on_stack); // 通过event_assign来初始化栈上的实例, 并把事件回调的入参设置为事件实例自身的指针

    event_add(event_on_heap, NULL);
    event_add(&event_on_stack, NULL);

    event_base_dispatch(base);

    event_free(event_on_heap);
    event_base_free(base);
}

这个例子也写的比较蛋疼, 凑合看吧.

另外, 调试模式下的详情调试信息, 只能通过在编译时额外定义宏USE_DEBUG来附加. 即在编译时加上-DUSE_DEBUG来开启. 加上这个编译时的宏定义后, libevent就会输出一大坨有关其内部流程的详情日志, 包括但不限于

  1. 事件的增加
  2. 事件的删除
  3. 与具体平台相关的事件通知信息

这些详情不能通过调用API的方式开启或关闭. 而开启调试模式的API, 在2.0.4版本后才可用.

4.7 检测当前项目中引用的libevent的版本

接口很简单, 如下:

#define LIBEVENT_VERSION_NUMBER 0x02000300
#define LIBEVENT_VERSION "2.0.3-alpha"
const char *event_get_version(void);        // 获取字符串形式的版本信息
ev_uint32_t event_get_version_number(void); // 获取值形式的版本信息

值形式的版本信息由一个uint32_t类型存储, 从高位到低位, 每8位代表一个版本号. 比如 0x02000300代表的版本号就是02.00.03.00. 三级版本号后可能还有一个小版本号, 比如就存在过一个2.0.1.18的版本

下面是一个在编译期检查libevent版本的写法, 若版本小于2.0.1, 则编译不通过. 需要注意的是, 编译期检查的是宏里的值, 如果你的项目构建比较混乱, 很可能出现头文件的版本, 和最终链接的二进制库的版本不一致的情况. 所以编译期检查也不一定靠谱

#include <event2/event.h>

#if !defined(LIBEVENT_VERSION_NUMBER) || LIBEVENT_VERSION_NUMBER < 0x02000100
#error "This version of Libevent is not supported; Get 2.0.1-alpha or later."
#endif

int
make_sandwich(void)
{
        /* Let's suppose that Libevent 6.0.5 introduces a make-me-a
 sandwich function. */
#if LIBEVENT_VERSION_NUMBER >= 0x06000500
        evutil_make_me_a_sandwich();
        return 0;
#else
        return -1;
#endif
}

下面是一个在运行时检查libdvent版本的写法. 检查运行期的版本是通过函数调用检查的, 这就保证了返回的版本号一定是链接进的库的版本号. 这个比较靠谱. 另外需要注意的是, 数值形式的版本号在libevent2.0.1之后才提供. 所以只能比较蠢的用比较字符串的方式去判断版本号

#include <event2/event.h>
#include <string.h>

int
check_for_old_version(void)
{
    const char *v = event_get_version();
    /* This is a dumb way to do it, but it is the only thing that works
 before Libevent 2.0. */
    if (!strncmp(v, "0.", 2) ||
        !strncmp(v, "1.1", 3) ||
        !strncmp(v, "1.2", 3) ||
        !strncmp(v, "1.3", 3)) {

        printf("Your version of Libevent is very old. If you run into bugs,"
               " consider upgrading.\n");
        return -1;
    } else {
        printf("Running with Libevent version %s\n", v);
        return 0;
    }
}

int
check_version_match(void)
{
    ev_uint32_t v_compile, v_run;
    v_compile = LIBEVENT_VERSION_NUMBER;
    v_run = event_get_version_number();
    if ((v_compile & 0xffff0000) != (v_run & 0xffff0000)) {
        printf("Running with a Libevent version (%s) very different from the "
               "one we were built with (%s).\n", event_get_version(),
               LIBEVENT_VERSION);
        return -1;
    }
    return 0;
}

接口和宏的定义位于 <event2/event.h>中, 字符串形式的版本号在1.0版本就提供了, 数值形式的版本号直至2.0.1才提供

4.8 一键释放所有全局实例

就算你手动释放了所有在程序代码初始化时创建的libevent对象, 在程序退出之前, 也依然有一些内置的, 对使用者不可见的libevent内部实例以及一些全局配置实例存在着, 并且存在在堆区. 一般情况下不用管它们: 程序都退出了, 释放不释放有什么区别呢? 反正操作系统会帮你清除的. 但有时你想引入一些第三方的分析工具, 比如检测内存泄漏的工具时, 就会导致这些工具误报内存泄漏.

你可以简单的调一下下面这个函数, 完成一键完全清除:

void libevent_global_shutdown(void);

注意哦: 这个函数不会帮你释放你自己调用libevent接口创建出来的对象哦! 还没那么智能哦!

另外, 很显然的一点是, 当调用了这个函数之后, 再去调用其它libevent接口, 可能会出现异常哦! 所以没事不要调用它, 如果你调用它, 那么一定是自杀前的最后一秒.

函数定义在<event2/event.h>中, 2.1.1版本后可用