Node.js 异步 api 的本质和 libuv

1,104 阅读5分钟

Node.js 是一个 Javascript 的运行时,提供了系统能力的 api,主要是文件、网络相关的 IO api,而 IO api 的实现是在 libuv,提供了同步异步两种形式的 api。

本来就来探究下 libuv 的功能和提供的 api 的形式。

同步异步、事件循环

cpu 是顺序执行代码的,通过 pc 寄存器来存储着下一条指令的内存地址。代码的执行流程叫做控制流。但是对于一些 IO 操作来说,并不需要 cpu 做计算,而是在等待硬盘设备、网络设备的数据读取,这时候 cpu 是空闲的,所以一条控制流不行,会导致 cpu 利用率太低。所以操作系统又提供了进程、线程的功能,进程是分配资源的单位,而执行代码主要是靠线程,一个线程就是一条控制流,它是 cpu 调度的基本单位,也就是说可以在多个控制流之间切换,当一个线程在做 IO 的时候就释放 cpu,让其他线程去跑。

一个线程阻塞的等待 IO 的方式就是同步,会比较浪费 cpu,而多个线程切换,在做 IO 的时候让其他线程上 cpu 跑,执行完 IO 再申请 cpu 来继续后续处理,这种方式就是异步

异步最终是多线程来实现的,但是在 Node.js 里面又进一步通过 event loop 做了封装,比如执行文件读取、网络访问的时候并不需要开发者去创建线程,而是调用 api,指定回调函数就可以了,这是对多线程的进一步封装。

异步的底层实现都是通过线程的切换,但是暴露给开发者的形式有两种:

第一种是开发者控制线程的创建和销毁,指定线程执行的内容,java 是这种。

第二种是提供事件循环机制,提供一系列异步 api,这些异步 api 最终是由线程来执行的,但是开发者不需要手动管理线程。javascript 是这种。

libuv

在 Node.js 里面,实现 event loop 的就是 libuv,它是一个异步 IO 库,负责文件和网络的 io,提供了事件形式的异步 api。

image.png

libuv 里有一个线程池负责维护一些线程用来执行异步 api,这个线程池的大小是可以设置的,通过环境变量 UV_THREADPOOL_SIZE 可以设置。

在 Node.js 文档中搜索 UV_THREADPOOL_SIZE 可以看到这段介绍:

就是说 libuv 是负责 IO 的 api 的异步实现的,基于更底层的操作系统 api。这些操作系统 api 有的是异步的,有的不是,对于不是异步 api 的那些,就要由 libuv 的线程池中的线程来执行,变成异步的形式。

这些 api 包括:

  • 所有的文件 fs api,除了 watch 的 api
  • 加密 crypto 的异步 api
  • dns.loopup
  • 所有压缩的 zlib api

这些 api 共用一个线程池,那么肯定会相互影响,所以有的时候需要加大线程池的线程数量,默认是 4,当需要的时候可以设置这个环境变量来加大。

这让我想起了 Node.js 的 --max-old-space-size=SIZE 可以设置堆大小一样,都是性能调优的参数。

当面试问到 Node.js 性能调优的时候,可以答设置 libuv 的线程池大小,堆大小设置的这两个参数/环境变量。

看了文档之后,我们更确认了异步最底层的实现就是线程,只不过是通过 libuv 的线程池做了封装。libuv 提供了 IO 相关的 api,在 Node.js 的架构中的位置如下:

IO api 的 3 种形式

梳理清楚了同步异步方式的实现原理,我们再来看下 Node.js 都怎么提供这两种 api 的:

同步的 api 比较简单,就是调用函数,拿到返回值就行,很多 xxSync 的 api 都是同步的,比如:

const fs = require('fs');

const data = fs.readFileSync('tmp.txt', 'utf8');
console.log(data);

而异步的 api 则分为了两种形式,callback 和 promise:

const fs = require('fs');

fs.readFile('tmp.txt', 'utf8', (err, data) => {
    console.log(data);
});

还有 promise 的版本:

const fsPromises = require('fs').promises;

(async function () {
	const data = await fsPromises.readFile('tmp.txt', 'utf-8');
	console.log(data);
})();

其中 promise 的版本只有两个模块有,fs 和 dns,是在 Node.js 10.x 引入的,方便使用 async、await 来组织代码。

const dnsPromises = require('dns').promises;

(async function () {
	const result = await dnsPromises.lookup('www.baidu.com');
	console.log(result.address);
})();

用到 fs 和 dns 这两个模块的时候,推荐使用 promise 形式的异步 api,当然,必须是 Node.js 10 以上的版本。

总结

程序在进行 IO 的时候, cpu 是空闲的,为了更好的利用 cpu,操作系统提供了进程、线程的功能,一个线程就是一条控制流。当在 IO 的时候,切换到别的线程,等 IO 结束之后再继续执行的方式就是异步,而相应的一个线程阻塞的等待的方式就是同步。

异步最终是由线程实现的,但是提供给开发者的有两种形式:一种是提供线程 api,让开发者自己管理线程,另一种方式就是提供事件循环,对于异步 api 通过线程来实现。第二种方式对开发者更透明,不需要关心线程的创建和切换。

Node.js 里面的 event loop 的实现是在 libuv,它提供了文件和网络的异步 IO 的 api,从文档中我们可以看到,libuv 是基于操作系统的 api 实现的,而其中一些同步的 api,则是由 libuv 的线程池来执行,达到异步的目的。但是线程池的大小默认是 4,有的时候需要加大,可以通过修改 UV_THREADPOOL_SIZE 的方式。

Node.js 提供的 api 有 3 种形式,一种是同步的,一种是异步 callback、一种是异步 promise。其中异步 promises 的形式是推荐的写法,但是只有在 fs、dns 两个模块可用,并且要在 Node.js 10 以上才行。

希望本文能够帮大家理清异步的本质,libuv 的作用,Node.js api 的形式,以及如何做 libuv 的调优。