一 概述
github地址
官方文档
libuv是跨平台、轻量级的异步I/O库,由Node.js团队发起和维护。它提供了事件循环、定时器、异步文件和网络操作等功能,使开发者可以方便地处理各种I/O任务。
libuv提供了一套强大而易用的异步I/O编程接口,在网络编程、文件系统操作、定时器等方面具有广泛的应用场景。由于其开源、跨平台、高效、稳定等优点,被越来越多的开发者采用并集成在自己的项目中。
无论是在Node.js还是其他项目中,libuv都负责处理底层的事件循环和I/O操作,使开发者能够编写高效且非阻塞的代码。它在不同的操作系统上使用不同的后端实现,如epoll
、kqueue
、IOCP
等,以便充分利用各个平台的特性和性能。
“uv” 是指"Unicorn Velociraptor"的缩写。"Unicorn Velociraptor"是libuv的创始人 Bert Belder 的幽默取名,没有特殊的技术含义。
libuv的主要特点包括:
跨平台:libuv可以在多种操作系统上运行,包括Windows、Linux、macOS等,使得开发者无需考虑操作系统的差异性。
异步模型:libuv基于事件驱动模型实现异步I/O,允许应用程序在处理资源紧张、高并发的客户端请求时,不阻塞主线程,提高可伸缩性和响应速度。
网络编程支持:libuv提供了对TCP/UDP以及TLS/SSL等协议的支持,可以轻松实现网络通信功能。
文件系统支持:libuv支持异步文件操作,包括读取、写入、修改、删除等操作,避免文件操作导致的线程阻塞或死锁问题。
定时器支持:libuv提供定时器功能,允许应用程序在一定时间后执行指定的回调函数。
多线程支持:libuv可以创建多个事件循环对象,每个事件循环对象都有自己的I/O线程池,应用程序可以分配不同的任务给不同的事件循环处理。
单以 Linux 平台来看,libuv 主要工作可以简单划为两部分:
- 围绕 epoll,处理那些被 epoll 支持的 IO 操作;
- 线程池(Thread pool),处理那些不被 epoll 支持的 IO 操作;
二 epoll 简介
为了追本溯源,我们将从 epoll 开始,简单来说,epoll 是由 Linux 内核提供的一个系统调用(system call),我们的应用程序可以通过它:
- 告诉系统帮助我们同时监控多个文件描述符;
- 当这其中的一个或者多个文件描述符的 I/O 可操作状态改变时,我们的应用程序会接收到来自系统的事件提示(event notification);
1 事件循环
我们通过一小段伪代码来演示使用 epoll 时的核心步骤:
// 创建 epoll 实例
int epfd = epoll_create(MAX_EVENTS);
// 向 epoll 实例中添加需要监听的文件描述符,这里是 `listen_sock`
epoll_ctl_add(epfd, listen_sock, EPOLLIN | EPOLLOUT | EPOLLET);
while(1)
{
// 等待来自 epoll 的通知,通知会在其中的文件描述符状态改变时
// 由系统通知应用。通知的形式如下:
// epoll_wait 调用不会立即返回,系统会在其中的文件描述符状态发生
// 变化时返回
// epoll_wait 调用返回后:
// nfds 表示发生变化的文件描述符数量
// events 会保存当前的事件,它的数量就是 nfds
int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
// 遍历 events,对事件作出符合应用预期的响应
for (int i = 0; i < nfds; i++)
{
// consume events[i]
}
}
上面的代码中已经包含了注释,可以大致概括为下图:
所以处于 libuv
底层的 epoll
也是有「事件循环」的概念,可见事件循环并不是 libuv
独创。
提到 epoll
,不得不提它的两种触发模式:水平触发(Level-triggered)、边缘触发(Edge-triggered)。不得不提是因为它们关系到 epoll 的事件触发机制,加上名字取得又有些晦涩。
2 水平触发
这两个术语都源自电子学领域,我们从它们的原始含义开始理解
首先是水平触发:
上图是表示电压变化的时序图,VH 表示电压的峰值,VL 表示电话的谷值。水平触发的含义是,随着时间的变化,只要电压处于峰值,系统就会激活对应的电路(触发)
3 边缘触发
上图依然是表示电压变化的时序图,不过激活电路(触发)的条件是电压的改变,即电压由 VH -> VL、VL -> VH 的状态变化,在图中通过边来表示这个变化,即 Rising edge
和 Falling edge
,所以称为 Edge-triggered
即边缘触发。
我们可以大致理解它们的形式与差别,继续结合下面的 epoll 中的表现进行理解。
4 在 epoll 中
回到 epoll
中,水平触发和边缘触发作为原始含义的衍生,当然还是具有类似电子学领域中的含义,我们通过一个例子来理解,比如我们有一个 fd(File descriptor)
表示刚建立的客户端连接,随后客户端给我们发送了 5 bytes 的内容。
如果是水平触发:
- 我们的应用会被系统唤醒,因为 fd 此时状态变为了可读
- 我们从系统的缓冲区中读取 1 byte 的内容,并做了一些业务操作
- 进入到新的一次事件循环,等待系统下一次唤醒
- 系统继续唤醒我们的应用,因为缓冲区还有未读取的 4 bytes 内容
如果是边缘触发:
- 我们的应用会被系统唤醒,因为 fd 此时状态变为了可读
- 我们从系统的缓冲区中读取 1 byte 的内容,并做了一些业务操作
- 进入到新的一次事件循环,等待系统下一次唤醒
- 此时系统并不会唤醒我们的应用,直到下一次客户端发送了一些内容,比如发送了 2 bytes(因为直到下一次客户端发送了请求之前,fd 的状态并没有改变,所以在边缘触发下系统不会唤醒应用)
- 系统唤醒我们的应用,此时缓冲区有 6 bytes = (4 + 2) bytes
我们很难将水平触发、边缘触发的字面意思与上面的行为联系起来,好在我们已经预先了解过它们在电子学领域的含义:
水平触发,因为已经是可读状态,所以它会一直触发,直到我们读完缓冲区,且系统缓冲区没有新的客户端发送的内容;边缘触发对应的是状态的变化,每次有新的客户端发送内容,都会设置可读状态,因此只会在这个时机触发;
水平触发是 epoll 默认的触发模式,并且 libuv 中使用的也是水平触发。在了解了水平触发和边缘触发的区别后,我们其实就可以猜测 libuv 使用水平触发而不是边缘触发背后的考量:
如果是边缘触发,在 epoll 的客观能力上,我们不被要求一次读取完缓冲区的内容(可以等到下一次客户端发送内容时继续读取)。但是实际业务中,客户端此时很可能在等待我们的响应(可以结合 HTTP 协议理解),而我们还在等待客户端的下一次写入,因此会陷入死锁的逻辑。由此一来,一次读取完缓冲区的内容几乎就成了边缘触发模式下的必选方式,这就不可避免的造成其他回调的等待时间变长,让 CPU 时间分配在各个回调之间显得不够均匀
5 局限性
epoll 并不能够作用在所有的 IO 操作上,比如文件的读写操作,就无法享受到 epoll 的便利性,所以 libuv 的工作可以大致概括为:
- 将各种操作系统上的类似 epoll 的系统调用(比如 Unix 上的 kqueue 和 Windows 上的 IOCP)抽象出统一的 API(内部 API);
- 对于可以利用系统调用的 IO 操作,优先使用统一后的 API;
- 对于不支持或者支持度不够的 IO 操作,使用线程池(Thread pool)的方式模拟出异步 API;
- 最后,将上面的细节封装在内部,对外提供统一的 API。
三 回到 libuv
回到 libuv,我们将以 event-loop 为主要脉络,结合上文提到的 epoll,以及下面将会介绍到的线程池,继续 libuv 在 Linux 上的实现细节一探究竟。
1 event-loop
我们将结合源码来回顾一下 event-loop 基本概念
下面这幅图也取自 libuv 官网,它描述了 event-loop 内部的工作:
参考
Libuv 之 - 只看这篇是不够的
我应该跟libuv说声对不起,我错怪了libuv
网络库libevent、libev、libuv对比
libev调用epoll路径
网络开发库从libuv说到epoll
libuv线程池和主线程通信原理
libuv中文API手册,中文教程
libuv 源码分析1: loop和poll
有了 libevent、libev、libuv等库,C++的小伙伴还有人在手撸epoll吗
libuv
【libuv高效编程】libuv学习超详细教程8——libuv signal 信号句柄解读
网络开发库从libuv说到epoll