Jason Pan

libevent 学习笔记 03

潘忠显 / 2020-07-21


本文使用了 Libevent 封装的一些低层次的函数。首先简单介绍了三种简单的特殊事件,然后介绍异步高并发的建立连接、发送数据、解析域名。文中各 Stage 对应不同的示例代码,详见仓库

在运行高并发网络请求的时候,会遇到各种网络疑难杂症,文中会穿插进行解释。其中,两个相关问题也在码客上被选为热门问答。

Stage 1: 三种简单的特殊事件

使用Libevent也可以很方便的创建定时器信号响应周期性事件

Stage 1中就创建了三个这样的特殊事件:

  1. 周期性任务:每秒打印一行,打印6次
  2. 定时任务:在3.5秒之后发送一个SIGUSR1信号给进程本身
  3. 信号处理:当进程接收到SIGUSR1信号时触发回调

具体的代码实现:

void Run() {
  struct event_base* base = event_base_new();
  struct timeval t1 = {1, 0};
  struct timeval t2 = {3, 500000};
  struct event* ev;

  // print per second.
  ev = event_new(base, -1, EV_PERSIST, PeriodTask, event_self_cbarg());
  event_add(ev, &t1);

  // send signal to self after 3.5 seconds.
  ev = evtimer_new(base, TimingTask, event_self_cbarg());
  evtimer_add(ev, &t2);

  // proc SIGUSR1 signal
  ev = evsignal_new(base, SIGUSR1, UsrSigCb, event_self_cbarg());
  evsignal_add(ev, NULL);

  event_base_dispatch(base);
}

三种事件交互过程中的输出:

cb_func called 1 times so far.
cb_func called 2 times so far.
cb_func called 3 times so far.
set signal SIGUSR1
get signal SIGUSR1
cb_func called 4 times so far.
cb_func called 5 times so far.
cb_func called 6 times so far.

实际上Stage 1 在event_base_dispatch(base)前后使用event_base_dump_events打印的base管理的事件的详细信息,内容如:

Inserted events:
  0x2422c88 [fd  4] Read Persist Internal
  0x2423240 [sig 10] Signal Persist
  0x24230d0 [fd  -1] Persist Timeout=1609741230.716137
  0x24231b0 [fd  -1] Timeout=1609741233.216137
Active events:

Stage 2: 异步创建大量连接

大多数介绍异步网络IO示例,会以服务端代码为例讲解:先监听fd,然后accept得到新的fd,最后监控fd上是否有可读或者可写事件发生,然后再调用read()write()进行读写。

本文特立独行,Stage 2 从高并发的客户端角度出发,首先要考虑快速创建大量连接。示例代码使用单核可以在几十毫秒(两地网络延迟级别)内建立2000个连接

如果要快速的创建大量连接,需要用到非阻塞的socket。具体的步骤:

  1. 创建 socket 并设置成非阻塞
  2. connect()无法立马建立连接(能直接返回0吗?),返回非0,错误码EINPROGRESS表示正在处理
  3. 使用select或者epoll监听 socket 可写
  4. 当可写时,使用getsockopt()判断是否连接完成

上述步骤可以通过man 2 connect查看EINPROGRESS一项的具体说明。

使用Libevent创建这些连接的部分实现:

void CreateConections() {
  struct event_base* base = event_base_new();
  struct timeval five_sec = {5, 0};

  for (int i = 0; i < max_connections; i++) {
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    fcntl(fd, F_SETFL, O_NONBLOCK);
    struct sockaddr_in sin;
    sin.sin_family = AF_INET;
    sin.sin_addr.s_addr = inet_addr(kDemoHttpServerIp);
    sin.sin_port = htons(kDemoHttpServerPort);
    if (connect(fd, (struct sockaddr*)&sin, sizeof(sin)) < 0) {
      if (errno != EINPROGRESS) {
        perror("connect failed");
        event_base_loopbreak(base);
        return;
      }
    } else {
      printf("connect return 0.");
    }
    struct event* e = event_new(base, fd, EV_WRITE | EV_TIMEOUT, OnConnect,
                                event_self_cbarg());
    event_add(e, &five_sec);
  }
  event_base_dispatch(base);
  return;
}

connect()等网络接口的默认超时与系统配置和接口都有关系,是开发无法控制的。connect()为例,超时由/proc/sys/net/ipv4/tcp_syn_retries(SYN重试次数)决定,因为每次重试时间从"初始RTO"–1秒开始,每次翻倍,所以超时时间是2**tcp_syn_retries - 1。比如我系统配置的tcp_syn_retries6,则connect的超时是63。详见下文的 #网络知识点:syn重传

因此超时时间需要在epoll或者select上加指定,而不是默认超时时间。

同样的,在使用Libevent创建连接的时候,我们也是增加一个EV_TIMEOUT的标志,然后再event_add的时候,设置超时时间。

网络知识点: txqueuelen参数的含义与设置

在使用上述代码的过程中,遇到过下边这个问题:

  1. 当最大连接数设置成500时,可以在50ms以内完成所有连接的建立
  2. 当最大连接数设置成2000时,所有连接完成建立时间超过1秒
  3. 通过抓包发现,在0.07秒之内,完成了1500个左右连接的连接的建立,剩下的500个左右的连接在1秒钟之后才开始发送第一次SYN包
  4. 抓包看不到SYN包重试,看上去是操作系统主动延迟了SYN包的发送
  5. 换另一台机器可以没有出现这种现象

抓包部分截图:

Wireshark统计SYN包分布情况:

看到1秒,有一定网络问题排查经验的人会联想到SYN的第一次重传时间也是1秒。当Server端接收到SYN没有响应ACK(通常是backlog设置的比较小),服务器端接收到SYN没有响应ACK,则Client端会在1秒之后重新发送SYN。

当前遇到的现象跟之前的情况不同:

  1. 1秒钟才开始发的SYN包,看上去并不是重传,因为没有抓到对应端口之前有SYN
  2. 换了Client机器没有问题,说明服务端没有问题,而是两个环境某个变量不同

在StackOverflow上提问并得到答案:txqueuelen这个值限制的,该值默认是1000,当大量请求同时发出的时候,队列容纳不了的就回被drop掉。

Stage 2中的大量SYN包被同时往队列里塞的时候,就有部分因为超出队列长度被丢弃。这些包没有发送,不会被抓到。系统依然会在1秒钟之后重新发送这些被丢弃的SYN,这也就是我们抓包看到的1秒钟之后才发出的第一个SYN。

使用ifconfig eth1 txqueuelen 10000将该值调大之后,上述现象消失。

Stage 3: 非阻塞fd上的收发

在Stage 2的创建了2000个连接的socket上,Stage 3中会创建5000个连接,短时间内再发出HTTP请求:

其中HTTP请求就是简单的:

GET / HTTP/1.1\r\n
Host: www.baidu.com\r\n\r\n

代码太长,详见仓库。代码为了明了展示,存在一些缺陷。

网络知识点: SYN重传

示例中使用5000个连接,某次运行过程中统计客户端发出的SYNFIN的分布情况如下:

当在最初的SYN同时发出之后,服务端并没有相应所有的SYN,客户端会在1秒之后进行重传。通过以下命令过滤重传包(共450个):

tcp.flags.syn == 1 && tcp.dstport == 80 && tcp.analysis.retransmission

通过WireShark的Statistics-Conversation工具可以统计出有450个进行了一次SYN重传,无多次SYN重传的情况。

SYN重传的时候,因为没有收到对方的回包无法得到RTT,所以也没法计算RTO

在Linux内核的net/ipv4/inet_connection_sock.c文件中,有如下代码说明SYNRTO的设置是每次增大一倍的:

if (!expire &&
    (!resend ||
     !inet_rtx_syn_ack(sk_listener, req) ||
     inet_rsk(req)->acked)) {
        unsigned long timeo;

        if (req->num_timeout++ == 0)
                atomic_dec(&queue->young);
        timeo = min(TCP_TIMEOUT_INIT << req->num_timeout, TCP_RTO_MAX);
        mod_timer(&req->rsk_timer, jiffies + timeo);
        return;
}

网络知识点: close()使用RST而非FIN的情况

示例代码的一个缺陷是:没有处理read()返回-1且errno为EAGAINEWOULDBLOCK的情况。这种情况下需要再继续等待数据,或者通过内容来判断HTTP包结束,而不是直接关闭连接。

该缺陷除了会使得HTTP回包不能被完整接收之外,还造成了一种现象:调用close()的时候,会发送Flag为RST的TCP包,而不是发送FIN

客户端主动发送RST关闭的原因是:接收缓冲区中有数据没有读出而进行关闭。

网络知识点: 三次握手最后一次ACK如果丢失会怎样?

TCP是连接是双向的。

Client端看,当ACK发出之后,就从SYN-RECEIVED变成了ESTABLISHED,不会管ACK是否会丢掉。

Server端看,如果发出SYN,ACK之后将会转成SYN-RECEIVED状态,如果最后一个ACK没收到会有两种情况发生:

下图中3554帧后的3742帧不是第三次握手的ACK,而是PSH数据,而此时Server端也在3930确认了这条消息。

还未找到答案的问题:Client先发出ACK还是先触发的可写事件?

网络知识点: 是重传(Retransmission)还是重复(Duplicate)?

重传包和重复包,在TCP层的上的序列号都是一样的,但是重传包的IP identification不同

网络知识点: Linux内核代码关于超时处理如何看?

码客问答中,davidfgao指出了查看Linux内核源码的路径:

tcp_retransmit_timer中,调用tcp_rtx_queue_head获得待重传的skb 然后流程是tcp_retransmit_skb->tcp_transmit_skb->ip_queue_xmit->ip_select_ident_segs

Stage 4-1: 阻塞的域名解析

获得中国网站Alexa排名Top 100的网站地址,使用的脚本详见tools目录下的get_china_top_websites.py,使用脚本会将网址存放至文件中,三列分别是网站名称、网站URL(均为HTTP)、网站编码。比如:

baidu.com           http://baidu.com           utf8
qq.com              http://qq.com              gbk
taobao.com          http://taobao.com          utf8
sohu.com            http://sohu.com            utf8
hao123.com          http://hao123.com          utf8

我们首先使用同步的方式去测试一下依次解析这100个域名需要的时间。为了测试耗时情况,现将DNS缓存服务给关掉:

systemctl stop nscd.service
systemctl stop nscd.socket

而且为了让效果更明显,直接将DNS服务器改成另外一个城市的,ping耗时39.1ms

测试发现,所有域名解析总共耗时6秒左右(剔除其中一个域名sq.cn,耗时19.623秒,sq.cn域名似乎已经不存在了,所以请求各个服务器均无法获得该域名对应的IP地址)

显然,上述同步域名解析的方式是低效的:如果要并发请求到大量不同的网站,排序在后面的网址,进行地址解析和建连开始都有很大的延迟了。

因此,域名解析也需要使用异步非阻塞的方式去进行。

网络知识点: gethostbyname()/getaddrinfo()/getaddrinfo_a()

示例代码中的gethostbyname()可以使用getaddrinfo()代替,而这两个函数的区别可以通过man getaddrinfo来获得:

The getaddrinfo() function combines the functionality provided by the gethostbyname(3) and getservbyname(3) functions into a single interface, but unlike the latter functions, getaddrinfo() is reentrant and allows programs to eliminate IPv4-versus-IPv6 dependencies.

getaddrinfo()有一个异步版本的接口getaddrinfo_a(),可以同时传入多个域名(或服务名),然后进行异步的查找。也GAI_WAITGAI_NOWAIT等不同的模式选择。但是直接使用这个接口处理起来会相对复杂。

而且getaddrinfo_a()手册中也说明了这个函数需要#define _GNU_SOURCE,编译的时候需要增加-lanl选项,也就是说这个函数是依赖GNU extensions的。

使用getaddrinfo()的时候,有的返回-7(EAI_SOCKTYPE,对应关系在文件netdb.h中可以查到),原因是hints.ai_socktypehints.ai_protocol不一致。使用gai_strerror())可以将返回值转成可读的错误信息。

因为只设置了ai_socktype,而ai_protocol未设置是随机的内容。

struct addrinfo hints;
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_STREAM;
hints.ai_protocol = IPPROTO_TCP;  // 最初没这行,有返回-7

Stage 4-2: 可移植的阻塞域名解析

Libevent 提供了可移植的阻塞的名字解析函数,结构体和函数前面都增加evutil_前缀。

Linux Libevent
addrinfo evutil_addrinfo
getaddrinfo evutil_getaddrinfo
freeaddrinfo evutil_freeaddrinfo
gai_strerror evutil_gai_strerror

示例中将4-1和4-2两种情况写到了一个文件中,使用USING_LIBEVENT的宏定义区分使用何种方式。

注意到,如果是使用libevent的<event2/util.h>可以替换<netdb.h><sys/socket.h>,这一改动带来了可移植性。

Stage 4-1和Stage 4-2的耗时相当:

> time ../bazel-bin/02_low_level_api_demo/04_2_portable_blocking_hostname_resolving
cost:  0.045657s   url: http://baidu.com           ip: 39.156.69.79;220.181.38.148;
cost:  0.041354s   url: http://qq.com              ip: 58.250.137.36;125.39.52.26;58.247.214.47;
... ...
cost:  0.045921s   url: http://k618.cn             ip: 123.103.56.42; 

real	0m5.930s

Stage 5: 非阻塞的域名解析

Libevent库在event_base的基础上可以创建一个evdns_base用来维护DNS解析事件,这些结构体和函数使用evdns_作为前缀。

使用方法:

  1. 调用event_base_new(),创建event_base
  2. 调用evdns_base_new(),创建evdns_base
  3. 调用evdns_getaddrinfo(),设置所有需要解析的域名和evdns_getaddrinfo_cb回调
  4. callback要利用传入的evutil_addrinfo* addr指针,获得地址结果
  5. 获取发完之后要主动释放evutil_addrinfo* addr指针
  6. 调用event_base_dispatch(base)开始事件循环
  7. 待所有事件结束,调用evdns_base_free(),释放evdns_base
  8. 调用event_base_free,释放event_base

evdns_getaddrinfo_cb的定义如下:

typedef void (*evdns_getaddrinfo_cb)(
    int result, struct evutil_addrinfo *res, void *arg);

evutil_getaddrinfo()函数是异步的,调用之后会立马返回,因此需要传入一个回调函数,以便在解析成功或者失败时,做后续的处理。

使用与Stage 4中同样多的域名进行测试,实际好耗时0.37秒(取决于最大的一次解析时间)。统计了每个域名的耗时相加为4.9秒。说明非阻塞的方式进行域名解析也可以大大的提高网络相关代码的效率。

> time ../bazel-bin/02_low_level_api_demo/05_non_blocking_hostname_resolving
cost:  0.048625s   url: http://baidu.com           ip: 220.181.38.148;39.156.69.79;
cost:  0.030323s   url: http://qq.com              ip: 61.129.7.47;123.151.137.18;183.3.226.35;
... ...
cost:  0.034535s   url: http://k618.cn             ip: 123.103.56.42; 

total cost: 4.891716s

real	0m0.379s