Jason Pan

libevent 学习笔记 1 -- 从网络IO说起

潘忠显 / 2020-06-30


Libevent是一个异步事件处理软件库。支持的事件包括文件描述符上事件、信号事件、超时等,支持多个操作系统之间事件相关代码移植,还提供了用于缓冲​​网络IO、限速、SSL等封装,还有对DNS、HTTP等几种简单协议的支持。

Libevent在官方文档中,《异步IO简介》一文由浅入深,介绍了为什么要使用异步IO、为什么要使用libevent。本文整理并补充了各种方式的网络请求方式代码,并对其中的部分代码做了解释。

文中可以作为网络编程入门参考,提及的示例代码都在 learn-libevent 项目中,支持BazelMake两种方式构建。

[TOC]

注:不同Stage的代码仅为了说明,有异常未进行处理,是为了避免影响对主要逻辑的理解。 如果发现逻辑问题,欢迎指正。

Stage 0: 同步网络请求

如何写一个访问某网站的程序?可以使用同步网络请求的实现一个 HTTP Client(代码地址)。

文档中依次使用了gethostbynameconnectsendrecv等网络函数。 其中的每次调用都是阻塞的,比如:

不足之处:

如果只有单一请求,最小耗时就是同步网络请求耗时。

但如果要有更多的请求时(如爬虫程序)或同时处理多连接请求(如服务端),同步网络请求是低效的,甚至是不可接受的。

Stage 1: 多进程 Server (fork)

几乎每本网络编程的教科书都会有一个fork子进程处理网络请求的Server示例(代码地址)。

  1. 父进程中创建socket,绑定监听端口,不断的调用accept()建立连接
  2. 每当accept成功就会创建子进程,并将连接的fd传递到子进程
  3. 子进程不断的从fd中接收数据、处理数据、发送数据,直到读或写报错

父进程和子进程中的连接均是阻塞的。通过创建不同的进程来处理不同的单独连接来提高处理性能。

不足之处:

Stage 2: 多线程 Server (pthread)

pthread就是"POSIX thread"库。现实中使用多线程处理并发网络请求,比多进程处理更普遍,因为前者的成本更低:

多线程Server的代码中,创建了一个处理连接的线程池。

  1. 主线程中创建socket,绑定监听端口,不断的accept()建立连接,与Stage1主进程类似
  2. 每当accept成功,会等待处理线程将fd拿走处理再进行下一次的accept(通过锁+全局变量)
  3. 如果连接数达到处理数,accept()的将会停止接收
  4. 子线程中不断的从fd中接收数据、处理数据、发送数据,直到读或写报错,与Stage1子进程类似

父进程和子进程中的连接均是阻塞的。通过创建不同的进程来处理不同的单独连接来提高处理性能。

不足之处:

Stage -: busy-polling

单线程处理多个fd,可以将fd设置为"非阻塞的",然后循环去从每个fd读/写。 如果能读到,就处理数据;如果读不到,会有EAGAIN的错误。

不足之处:

Stage 3: select Server

man 2 select 中对select相关函数的描述是"synchronous I/O multiplexing",即同步多路复用。

select()可以解决busy-polling的不足。可以让kernel等到有一个sokcet可读时通知调用者,并且告知是哪个fd可用。

例子解释

以fd为下标创建、访问fd_state数组,可以这么操作是因为一个进程中fd从0开始且递增的,如果前边又关闭,新开的fd会使用最小的未使用的fd

每次循环情况三个fd_set,重新设置,因为通过fd_state指针数组来维护已经建立连接的所有fd。 循环中可能有accept新创建的fd(分配fd_state并插入到数组中),或者关闭部分连接(销毁fd_state并从数组中删除),每次循环重新设置会简化fd管理

当无连接、无请求时,会hang在select的位置。 因为设置的超时为NULL。当有建立连接(listener可读)、可写、可读时会循环一次。

do_read/do_write等函数类似于事件回调。每个连接需要一定的数据结构维护一些状态信息。

不足之处:

fd少的时候,性能可以。随着fd增多,性能会下降。

用户空间时间消耗与提供给select的fd个数成正比,而内核耗时与整个程序段总fd数成正比,而不是添加到select集合中的个数。

On the userspace side, generating and reading the bit arrays can be made to take time proportional to the number of fds that you provided for select(). But on the kernel side, reading the bit arrays takes time proportional to the largest fd in the bit array, which tends to be around the total number of fds in use in the whole program, regardless of how many fds are added to the sets in select().

需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大

这里说的传递数据结构具体是指什么?fdset?

Stage -: poll(Linux) server

不同平台下poll的含义和实现方式不一样,这里说的是Linux下的man 2 poll中的poll函数。

poll函数会将等待时间发生的fd放到struct pollfd,并且通过一个events字段来设置感兴趣的事件类型,会轮询检查每个fd的读、写、异常事件。 不需要分将fd分别放到多个集合中。

因为使用链表存储,相比于select少了1024的fd个数限制。

Stage 4: epoll Server

https://man7.org/linux/man-pages/man7/epoll.7.html

Stage 3: select Server中可以看到,如果要通过等待事件发生的方式处理fd,往往需要额外的数据结构维护fd状态和buffer。epoll Server的例子中,也是使用了类似的方式。

  1. 调用epoll_create创建一个epfd
  2. 创建监听fd并设置为非阻塞的
  3. 调用epoll_ctl将监听fd注册到epfd中,监听该fd上发生的可读事件
  4. 调用epoll_wait等待epfd上注册的监听事件发生,接收事件使用一个epoll_event数组
  5. 遍历epoll_event数组,处理读、写事件,处理方法同Stage 3

示例补充说明:示例中使用epoll的边缘触发模式(ET)。边缘触发的情况下,不仅要一次将数据读尽,还要遍历fd上的所有感兴趣类型的事件。

if (events[i].events & EPOLLIN) {
  r = do_read(fd, state[fd]);
} 
if (events[i].events & EPOLLOUT) {
  r = do_write(fd, state[fd]);
} 

而不能使用else if:

if (events[i].events & EPOLLIN) {
  r = do_read(fd, state[fd]);
} else if (events[i].events & EPOLLOUT) {
  r = do_write(fd, state[fd]);
} 

epoll的其他说明

  1. epoll 原理

O(1) performance for adding a socket, removing a socket, and for noticing that a socket is ready for IO. epoll 将用户空间的socket描述符的事件存放到内核的一个事件表中,在用户空间和内核空间的只拷贝一次。当epoll记录的socket产生就绪的时候,epoll会通过callback的方式来激活这个fd,这样子在epoll_wait便可以收到通知,告知应用层哪个socket就绪了。 以通知的方式是可以直接得到那个socket就绪的,相比于selectpoll,它不需要遍历socket列表,时间复杂度是O(1),不会因为记录的socket增多而导致开销变大。

  1. 边缘触发需要将fd设置为非阻塞的

An application that employs the EPOLLET flag should use nonblocking file descriptors to avoid having a blocking read or write starve a task that is handling multiple file descriptors.

  1. 根据用户ID限制epoll监听的fd总数,一个用户能够注册总的fd数可以通过以下方式估算
python -c "print(`cat /proc/sys/fs/epoll/max_user_watches` * 0.04)"
  1. 多线程监听同一个fd事件,只会有一个线程被唤醒,能够避免惊群效应(“thundering herd”)

不足之处:

Stage 5: Low-level Libevent Server

这里说的low-level是指封装的接口比较底层,比如event_newevent_addevent_base_dispatch等函数。

libevent的示例中更多的是callback+状态转移:

  1. 调用event_base_new创建一个event_base对象base
  2. 创建监听fd并设置为非阻塞的
  3. 调用event_new创建事件监听fd是否可读,设置其回调为do_accept,并且在回调时传入base
  4. 调用event_add将上边创建的事件进行注册
  5. 调用event_base_dispatch会循环等待事件发生

do_accept()中的针对新fd的具体操作:

  1. 为每个新fd创建一个结构体,维护着相关状态和buffer
  2. 创建可读事件,其回调函数是do_read(),在回调时接收state的指针作为参数
  3. 创建可写事件,其回调函数是do_write(),同样的,在回调时接收state的指针作为参数
  4. 将可读事件通过event_add()启动监控

do_read()中,接收对应fd的state指针参数

  1. 读取到recv返回<=0的结果
  2. 处理接收到的数据,存入buffer
  3. 调用event_add()将该fd对应的可写监听打开

do_write()中,也是接收对应fd的state指针参数。然后不断地调用send()发送,直到send返回<=0

使用低层次Libevent接口较轻松的可以实现可移植高性能异步的应用程序。

但是,可以看到代码量没有减少,开发相对复杂

Stage 6: High-level Libevent Server

从Stage 3、Stage 4、Stage 5中可以看到,处理非阻塞网络IO时,通常都会给每个fd创建对应的buffer缓存。

这个很好理解,因为一个fd的四种状态可读-已读完-可写-已写完之间有一定的时间间隔,而这些间隔内,我们可能因为新的fd可读、可写而去处理新的连接。因此buffer来缓存已读或未写的数据,buffer是必须的

基于此,Libevent在普通event的基础上,封装了bufferevent结构。这样可以大大简化Stage 5中的代码,代码简化从do_accept()开始:

  1. 调用bufferevent_socket_new为每个新fd创建bufferevent
  2. 调用bufferevent_setcb对上述bufferevent设置"读"事件回调readcb()
  3. 调用bufferevent_enable是事件监控开启

readcb()中:

  1. 获取bufferevent的两个buffer,input(读)和output(写)
  2. 调用evbuffer_remove()从input中读
  3. 数据处理
  4. 调用evbuffer_add()将返回写入output中

只要将待返回的数据,写入到output buffer中即可,Libevent会在fd可写时将output buffer中的数据写出去,开发者无需关心何时可写。

Stage 5 和 Stage 6的参数与使用的详细说明,在此不做展开,会在下一篇Libevent学习笔记中呈现。

其他通用说明

ROT13

一种简易替换密码,26个字母成对映射。前13个字母与后13个字母一一映射:A<->N, M<->Z

TIME_WAIT状态

如果有连接建立,使用"Ctrl + C"终止Server,会造成Server端主动关闭连接,状态转为TIME_WAIT,如果没有设置tw_reuse,短时间内再启动Server会报错:

bind: Address already in use

“阻塞"是谁的属性

是否非阻塞是fd的属性,非阻塞的fd可以使用readwriterecvsend 可以在open时指定,也可以使用 fcntl 函数可以设置fd的属性,O_NONBLOCK

fcntl(fd, F_SETFL, O_NONBLOCK);

非阻塞的socket fd在读写的时候会立马返回。要么成功,要么设置errno。

调用一次返回两次的函数

Q: 什么函数调用一次返回两次(which is called once but returns twice) A: fork()

系统调用耗费高

Q: 文中在举例busy-polling的时候,提到性能差除了CPU空转之外,还有每次都会进行系统调用。为什么系统调用会是Expensive?

A: 系统调用会引起上下文切换。也可能造成部分Cache失效。

Q: 上下文切换会有哪些动作?

A: 上下文切换会有一系列的动作:saving and loading registers and memory maps, updating various tables and lists, etc.

以Linux为例,context switching involves switching registers, stack pointer, and program counter, but is independent of address space switching, though in a process switch an address space switch also happens

https://www.quora.com/Why-are-system-calls-expensive-in-operating-systems

WORDS

paradigm 范例,示例

spin (尤指快速)旋转

divergent 发散的

severity 严重程度