libevent 学习笔记 1 -- 从网络IO说起
潘忠显 / 2020-06-30
Libevent是一个异步事件处理软件库。支持的事件包括文件描述符上事件、信号事件、超时等,支持多个操作系统之间事件相关代码移植,还提供了用于缓冲网络IO、限速、SSL等封装,还有对DNS、HTTP等几种简单协议的支持。
Libevent在官方文档中,《异步IO简介》一文由浅入深,介绍了为什么要使用异步IO、为什么要使用libevent。本文整理并补充了各种方式的网络请求方式代码,并对其中的部分代码做了解释。
文中可以作为网络编程入门参考,提及的示例代码都在 learn-libevent 项目中,支持Bazel和Make两种方式构建。
[TOC]
注:不同Stage的代码仅为了说明,有异常未进行处理,是为了避免影响对主要逻辑的理解。 如果发现逻辑问题,欢迎指正。
Stage 0: 同步网络请求
如何写一个访问某网站的程序?可以使用同步网络请求的实现一个 HTTP Client(代码地址)。
文档中依次使用了gethostbyname
、connect
、send
、recv
等网络函数。
其中的每次调用都是阻塞的,比如:
gethostbyname
会请求DNS Server(如果没有缓存),等到DNS Server返回IP后再进行下一步。send
操作可能会因为分包、网络延迟等原因,每次send
会阻塞一段时间,相对其他操作会耗时较长。send
之后,可能会因为服务器处理耗时,recv
会阻塞一段时间,直到有包返回。
不足之处:
如果只有单一请求,最小耗时就是同步网络请求耗时。
但如果要有更多的请求时(如爬虫程序)或同时处理多连接请求(如服务端),同步网络请求是低效的,甚至是不可接受的。
Stage 1: 多进程 Server (fork)
几乎每本网络编程的教科书都会有一个fork子进程处理网络请求的Server示例(代码地址)。
- 父进程中创建socket,绑定监听端口,不断的调用
accept()
建立连接 - 每当
accept
成功就会创建子进程,并将连接的fd传递到子进程 - 子进程不断的从fd中接收数据、处理数据、发送数据,直到读或写报错
父进程和子进程中的连接均是阻塞的。通过创建不同的进程来处理不同的单独连接来提高处理性能。
不足之处:
- 子进程在创建时,会拥有父进程地址空间、堆、栈的副本(共享正文段),会涉及到创建这些副本的工作量
- 进程间切换/调度的需要更多的时间
- 能创建的进程数有限,可以通过
cat /proc/sys/kernel/pid_max
查看
Stage 2: 多线程 Server (pthread)
pthread就是"POSIX thread"库。现实中使用多线程处理并发网络请求,比多进程处理更普遍,因为前者的成本更低:
- 创建线程比创建新进程成本低,新创建的线程使用的是当前进程的地址空间
- 线程间切换比进程间切换所需的时间更少,因为前者不包括地址空间之间的切换
- 线程间通信成本低,些线程会共享所有内容,特别是地址空间
多线程Server的代码中,创建了一个处理连接的线程池。
- 主线程中创建socket,绑定监听端口,不断的
accept()
建立连接,与Stage1主进程类似 - 每当accept成功,会等待处理线程将fd拿走处理再进行下一次的accept(通过锁+全局变量)
- 如果连接数达到处理数,
accept()
的将会停止接收 - 子线程中不断的从fd中接收数据、处理数据、发送数据,直到读或写报错,与Stage1子进程类似
父进程和子进程中的连接均是阻塞的。通过创建不同的进程来处理不同的单独连接来提高处理性能。
不足之处:
- 由于内存地址空间等限制,一个进程创建的线程是有限的
- 线程间切换/调度的需要时间
- 有上千个线程的时候效率会远低于每个CPU核有几个线程
- 设计不良可能造成惊群效应
Stage -: busy-polling
单线程处理多个fd,可以将fd设置为"非阻塞的",然后循环去从每个fd读/写。 如果能读到,就处理数据;如果读不到,会有EAGAIN的错误。
不足之处:
- 空转CPU
- 不断的进行系统调用
Stage 3: select
Server
man 2 select
中对select
相关函数的描述是"synchronous I/O multiplexing",即同步多路复用。
select()
可以解决busy-polling的不足。可以让kernel等到有一个sokcet可读时通知调用者,并且告知是哪个fd可用。
- 创建3个fd_set,分别用于监听可读、可写、异常事件
- 创建对应最大fd个数的
fd_state
指针数组,用于保存连接上的buffer和状态 - 创建socket,绑定监听端口,将该fd加入到读事件的
read_set
- 循环:
- 清空所有的
fd_set
- 将所有非空的
fd_state
中的fd加入到监听的fd_set
中 - 调用
select
函数,等待事件发生 - 处理读事件
- 如果是监听fd,则调用
accept
,给新的fd设置非阻塞并创建一个fd_state
- 如果不是监听fd,则调用
do_read
函数,读取所有的请求内容到buffer中
- 如果是监听fd,则调用
- 处理写事件:调用
do_write
函数处理
- 清空所有的
例子解释
以fd为下标创建、访问fd_state数组,可以这么操作是因为一个进程中fd从0开始且递增的,如果前边又关闭,新开的fd会使用最小的未使用的fd:
0/1/2
分别对应标准输入 stdin
、标准输出 stdout
、标准错误 stderr
- listener先于其他socket创建,所以listener的fd一定是
3
- 其他accept产生的fd是
4
、5
、6
…
每次循环情况三个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的例子中,也是使用了类似的方式。
- 调用
epoll_create
创建一个epfd - 创建监听fd并设置为非阻塞的
- 调用
epoll_ctl
将监听fd注册到epfd中,监听该fd上发生的可读事件 - 调用
epoll_wait
等待epfd上注册的监听事件发生,接收事件使用一个epoll_event数组 - 遍历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的其他说明
- 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就绪的,相比于
select
和poll
,它不需要遍历socket列表,时间复杂度是O(1),不会因为记录的socket增多而导致开销变大。
- 边缘触发需要将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.
- 根据用户ID限制epoll监听的fd总数,一个用户能够注册总的fd数可以通过以下方式估算:
python -c "print(`cat /proc/sys/fs/epoll/max_user_watches` * 0.04)"
- 多线程监听同一个fd事件,只会有一个线程被唤醒,能够避免惊群效应(“thundering herd”)
不足之处:
- 可移植性差,不同平台上接口也不同,比如:Linux-
epoll()
, BSDs(含Darwin)-kqueue()
, Solaris-evports
//dev/poll
- 接口较底层,需要用户自己用额外的数据结构管理fd和buffer
Stage 5: Low-level Libevent Server
这里说的low-level是指封装的接口比较底层,比如event_new
、event_add
、event_base_dispatch
等函数。
libevent的示例中更多的是callback+状态转移:
- 调用
event_base_new
创建一个event_base对象base - 创建监听fd并设置为非阻塞的
- 调用
event_new
创建事件监听fd是否可读,设置其回调为do_accept
,并且在回调时传入base - 调用
event_add
将上边创建的事件进行注册 - 调用
event_base_dispatch
会循环等待事件发生
do_accept()
中的针对新fd的具体操作:
- 为每个新fd创建一个结构体,维护着相关状态和buffer
- 创建可读事件,其回调函数是
do_read()
,在回调时接收state的指针作为参数 - 创建可写事件,其回调函数是
do_write()
,同样的,在回调时接收state的指针作为参数 - 将可读事件通过
event_add()
启动监控
do_read()
中,接收对应fd的state指针参数
- 读取到recv返回
<=0
的结果 - 处理接收到的数据,存入buffer
- 调用
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()
开始:
- 调用
bufferevent_socket_new
为每个新fd创建bufferevent - 调用
bufferevent_setcb
对上述bufferevent设置"读"事件回调readcb()
- 调用
bufferevent_enable
是事件监控开启
在readcb()
中:
- 获取bufferevent的两个buffer,input(读)和output(写)
- 调用
evbuffer_remove()
从input中读 - 数据处理
- 调用
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可以使用read
、write
、recv
、send
可以在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失效。
- 正在运行的进程完整上下文会被写入内存,系统调用的上下文会被加载到CPU
- 反过来再进行一次。
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 严重程度