加上 async 就能使函数变成异步吗?
问题来源于我对 async 的误解,假如我有一个标记了 async 的计算密集的函数:
// main.js
let startTime = Date.now();
async function cpuIntensiveFunc() {
let countStartTime = Date.now();
console.log("start counting");
for (let i = 0; i < 1e10; i++);
console.log(`end counting, take ${Date.now() - countStartTime}ms`);
}
cpuIntensiveFunc().catch(console.error);
setTimeout(() => {
console.log(`print me after 300 ms, result ${Date.now() - startTime}`);
}, 300);
console.log('print me after the counting started.');
// result:
// start counting
// end counting, take 10738ms.
// print me after the counting started.
// print me after 300ms, take 11006ms.
而预计达到的效果是:
cpuIntensiveFunc
被异步执行, 并打印start counting
。print me after the counting started.
马上被打印。300ms
的计时器回调被调用,print me after 300ms
被打印,时间约等于 300ms。- 累加在一段时间后结束,
end counting
被打印。
print me after the counting started.
的打印位置和预期却不一样。 在现在看来,原因其实很简单,上面的代码中 async 和没写没差,为什么?如果不熟悉 Promise
的同学可能会想,是因为没有 await
一个 Promise
吗?
改进:加上 await Promise
我们小改一下 cpuIntensiveFunc
这个函数让他加上 await
和 Promise
:
// main.js
// ...
async function cpuIntensiveFunc() {
let countStartTime = Date.now();
console.log("start counting");
await new Promise(resolve => {
for (let i = 0; i < 1e10; i++);
resolve();
});
console.log(`end counting, take ${Date.now() - countStartTime}ms`);
}
// ...
// result:
// start counting
// print me after the counting started.
// end counting, take 10939ms
// print me after 300 ms, take 11239ms
It works!
虽然 print me before the count ends.
的打印位置符合预期,但是问题又来了,print me before the count ends.
和 end counting
是在代码执行开始后过了很久在最后几乎同时被打印出来的,setTimeout
callback 的执行时间也远远超过预计。
Node.js 和 Event loop 的执行机制
其实刚才的问题我们简单分析一下它在 Node.js 里的执行过程就不难理解:
- node 开始运行
main.js
。 cpuIntensiveFunc
被执行,根据下面的代码可以知道,传入Promise
构造函数的回调也被立即执行。所以 for loop 就已经同步开始了。
PromiseConstructor = function Promise(executor) {
//...
try {
executor(bind(internalResolve, this, state), bind(internalReject, this, state));
} catch (err) {
internalReject(this, state, err);
}
};
来源: babel/core-js/promise-polyfill
- for loop 结束,紧跟其后的
console.log('end counting')
被放入了一个新的隐式 Promise 的 then 的回调当中,而此时 then 的 callback 就被添加到了 micro-tasks的 queue 中,暂缓执行。 - 执行
console.log('print me before the count ends.')
.main.js
的同步代码执行结束,Event loop 开始。 - 事件循环检查 mirco-tasks 队列,调用 then 中的 callback,执行并打印
end counting
- 事件循环进行到 timer 阶段,发现 setTimeout 的 callback 早已超时,赶紧执行并打印
print me after 300ms
。 - 进程结束。
可见,如果在 for loop 过程中没有被挂起,那么主线程就会被一直 block,直到 for loop 完成为止。
现在知道了为什么 setTimeout
和 console.log('print me before the count ends.')
被延缓执行。随之上一个小节末尾提出的问题也能被很好的解答了:
async
和 await
兄弟两实际上只是 Promise 的语法糖,初衷是为了避免无止尽的 then,而不是让我误解。 async
仅仅是用于将函数标记为,这是个 resumable(可恢复) 函数。标记的作用是如果在函数内部发现了 await
,就创建一个新的隐式 Promise对象,然后把函数剩下的操作放到该 then 的 callback 中。此时 then 中的 callback 才是被异步执行的。
继续改进:在单线程中的「异步」
如何才能让 for loop 不阻塞住主线程呢,既然 Promise
的 onFulfilled/onRejected
的 callback 可以被异步执行,那我们就参考一下它的实现:
在 Promise/A+ 规范 中并没有明确onFulfilled
和onRejected
是以micro-task 还是 macro-task 形式放入队列。但在最新的 ECMAScript 规范 中,明确了 Promise 必须以 Promise Job 的形式入 Job 队列(也就是 micro-task),并仅在没有运行的 stack(stack 为空的情况下)才可以初始化执行。 HTML 规范 也提出,在 stack 清空后,执行 micro-task 的检查方法。
我们其他以 micro-task/Promise Job 形式实现的方法有:process.nextTick
,postMessage
,MessageChannel
等。我们可以让任务分片并使用它们来达到不阻塞主线程的异步效果。
回顾一下刚才的代码,因为 Promise
构造函数中传入的 for loop 在在主线程中被同步执行,所以 block 住了后续代码。那么我们就用 process.nextTick
再改进一下,把每次累加都拆分成一个单独的 micro-task 再试一次。
// ...
async function count() {
return new Promise(resolve => {
_count(0, resolve);
});
function _count(i, callback) {
if (i < 1e10) {
process.nextTick(() => {
_count(++i, callback);
});
} else {
callback(i);
}
}
}
async function cpuIntensiveFunc() {
let countStartTime = Date.now();
console.log("start counting");
await count();
console.log(`end counting, take ${Date.now() - countStartTime}ms`);
}
// ...
// start counting
// print me before the count ends.
// end counting, take 589897ms
// print me after 300 ms, take 589899ms
幸运的是,这次 print me befor the count ends
被立马打印了出来。 不幸的是,整个计算时间比之前慢了五十倍左右,而 setTimeout
的 callback 同样被 block 住。
要弄清楚 setTimeout
callback 被 block 的原因,我们就又得再来复习一下 Node.js 的 Event loop 是如何执行的:
来源于 Node 文档并稍作修改
可以看到在其中包含了不同的六个阶段,其中不同阶段的作用可以参考文档,这里不过多赘述。 刚才我们提到的 micro-tasks 会在各个 Event loop 阶段之间执行(Node.js 环境),一旦执行,要直到 nextTick 队列被清空,才会进入到下一个 Event loop 阶段,所以如果递归调用作为 micro-task 的 process.nextTick
,就会导致后续包括 timer 阶段被 block,迟迟不能被执行,同样还会出现 I/O starving(饥饿)的问题。
既然用 nextTick
的结果差强人意,我们就再来看看 setImmediate
能否解决这个问题。
setImmediate()
虽然从语义上来说, nextTick
比 setImmediate
听起听起来要更靠后执行一点。但事实是,setImmediate
的 callback 是在上图的 check 阶段被执行的, 然而在阶段开始之前,nextTick
的 callback 会被先执行。这个命名的问题是历史原因也很难再改变。
而 setImmediate
还有个特性就是,嵌套调用的 setImmediate
的 callback 会被排到下一次 Event loop。所以用它我们就不会出现阻塞。
于是我们又把 cpuIntensiveFunc
函数中的 nextTick
更换为 setImmediate
后得到的执行结果是:
// start counting
// print me before the count ends.
// print me after 300ms, take 315ms.
// end counting, take (直到我文章写完都还没跑完的时间)ms
可以推测,之所以使用 nextTick
的递归会比直接循环慢50倍,是因为一直在进行 进/出 队列等与 counting 无关的其他操作。而 setImmediate
更慢则是因为每次循环都关系着一次 Event loop。
现在虽然达到了在不阻塞主进程的情况下执行 counting,但执行时间确实太太太太长了。那还能怎么改进一下呢。
不折腾了,还是上线程吧
在 Node.js V10.5.0 中提供了实验性的worker_threads
模块,它比child_process
或 cluster更轻量级。 与child_process
或 不同,worker_threads
可以共享内存,通过传输 ArrayBuffer 实例或共享 SharedArrayBuffer 实例来实现。
// cpuIntensive.js
const {parentPort} = require("worker_threads");
let i;
let countStartTime = Date.now();
console.log("start counting");
for (i = 0; i < 1e10; i++);
parentPort.postMessage(countStartTime.toString());
// main.js
const {Worker} = require("worker_threads");
let startTime = Date.now();
async function cpuIntensiveFunc() {
let worker = new Worker("./cpuIntensive.js");
return new Promise((resolve, reject) => {
worker.on("message", resolve);
worker.on("error", reject);
worker.on("exit", code => {
if (code !== 0) reject(new Error(`exit code ${code}`));
});
});
}
cpuIntensiveFunc()
.then(countStartTime => {
console.log(`end counting, take ${Date.now() - countStartTime}ms`);
})
.catch(console.error);
console.log("print me before the count ends.");
setTimeout(() => {
console.log(`print me after 300 ms, take ${Date.now() - startTime}ms`);
}, 300);
// print me before the count ends.
// start counting
// print me after 300 ms, take 317ms
// end counting, take 10671ms
在这里我们通过 worker_thread
实现了线程级的异步,还通过 postMessage
向主线程传递了消息 。这次和预计的结果就几乎一致了,因为平时用到 Node.js 线程/进程相关的 API 的场景并不多,所以这里不过多赘述。有兴趣可以通过 官方文档 继续了解。 即使完美达成目标,也不应该骄傲,应当注意的是 worker_threads
不可滥用,正如下图:每启动一个 worker,对应的都会启动一个 v8 和 libuv 以及 Node.js 实例,它的开销并不低。
来源: Understanding Worker Threads in Node.js - NodeSource
再探 Node.js 提供的异步 API 是如何实现的
在上文中我们了解到,通过 Promise.then
setImmediate
, 等方法可以使得一段操作被暂时加入不同的队列中,然后最后以不同的时机被唤起执行从而实现异步。那么 Node.js 提供的诸如 fs/http/crypto
等模块是通过诸如此类的方式实现异步的吗?答案是:更高端一些。
可以看到,在 Node standard library 背后有许多的支持。Node Bindings 是 JS 和 C/C++ 的桥梁 其中 libuv 我们也提到过,Node.js 的 Event loop / 调用系统接口,都是基于它来实现的。 比如我们使用的 http
模块,使用的是内核级异步。而 crypto/fs
等,他们都是基于 libuv 的线程池来模拟异步。 听着实在太抽象,不如让我们来康康源码:
// fs.js
// 通过 binding 和胶水层 fs.c 连接
const binding = internalBinding('fs');
const { FSReqCallback, statValues } = binding;
//...
function readFile(path, options, callback) {
//...
if (!ReadFileContext)
ReadFileContext = require('internal/fs/read_file_context');
const context = new ReadFileContext(callback, options.encoding);
context.isUserFd = isFd(path); // File descriptor ownership
const req = new FSReqCallback();
req.context = context;
// 赋值回调函数
req.oncomplete = readFileAfterOpen;
// 如果是文件夹,则在下一个 tick 提前 complete
if (context.isUserFd) {
process.nextTick(function tick() {
req.oncomplete(null, path);
});
return;
}
path = getValidatedPath(path);
const flagsNumber = stringToFlags(options.flags);
binding.open(pathModule.toNamespacedPath(path),
flagsNumber,
0o666,
req);
}
可以看到,readFile
中创建了一个 FSReqCallback 对象,同时把回调包裹后赋值在它的 oncomplete
属性上。 然后在过程中还涉及到了两个关键文件:fs.c
, node_file.cc
.
// node_file.cc
static void Read(const FunctionCallbackInfo<Value>& args) {
//...
FSReqBase* req_wrap_async = GetReqWrap(env, args[5]);
if (req_wrap_async != nullptr) {
AsyncCall(env, req_wrap_async, args, "read", UTF8, AfterInteger,
uv_fs_read, fd, &uvbuf, 1, pos);
} else {
CHECK_EQ(argc, 7);
FSReqWrapSync req_wrap_sync;
FS_SYNC_TRACE_BEGIN(read);
const int bytesRead = SyncCall(env, args[6], &req_wrap_sync, "read",
uv_fs_read, fd, &uvbuf, 1, pos);
FS_SYNC_TRACE_END(read, "bytesRead", bytesRead);
args.GetReturnValue().Set(bytesRead);
}
}
在 node_file.cc
的 Read 方法中,会根据传入的第 6 个参数的类型来判断是同步调用还是异步调用。如果是异步调用,则会调用 AsyncCall
,参数中的 uv_fs_read
则在 fs.c(unix实现)
中,如下:
//unix/fs.c
int uv_fs_read(uv_loop_t* loop, uv_fs_t* req,
uv_file file,
const uv_buf_t bufs[],
unsigned int nbufs,
int64_t off,
uv_fs_cb cb) {
INIT(READ);
if (bufs == NULL || nbufs == 0)
return UV_EINVAL;
req->file = file;
req->nbufs = nbufs;
req->bufs = req->bufsml;
if (nbufs > ARRAY_SIZE(req->bufsml))
req->bufs = uv__malloc(nbufs * sizeof(*bufs));
if (req->bufs == NULL)
return UV_ENOMEM;
memcpy(req->bufs, bufs, nbufs * sizeof(*bufs));
req->off = off;
POST;
}
#define POST
do {
if (cb != NULL) {
uv__work_submit(loop, &req->work_req, uv__fs_work, uv__fs_done);
return 0;
} else {
uv__fs_work(&req->work_req);
return req->result;
}
} while (0)
在 fs.c
中,在当传入回调时,libuv 会通过 uv__work_submit
方法把操作提交到队列中等待执行。
整个读取文件的过程大致如下:
node
,libuv
初始化;node_file.cc
中的Read
方法调用libuv(fs.c)
的uv_fs_read
,封装请求;libuv
将请求封装成uv_work
, 提交到任务队列尾部,触发信号;- 此时主线程的
read
调用返回。 - 线程池从
uv_work
队列中取出一个请求,开始执行 read IO; - 向主线程发送信号表明任务完成,等待执行
read
调用后的其它操作。 - 主线程
epoll
,从响应队列取已经完成的请求; - 主线程响应
epoll
事件; - 主线程执行请求的
callback
函数。
以上可见 FS.readFile
这个操作不仅仅是 nextTick
那么简单,也应证了本节开始的那句话,它是通过 libuv 的线程池来完成的整个异步操作。而处理 nextTick
的执行虽然也是通过 libuv 实现,但所有 nextTick
中的操作都是排在队列里的。不能实现类似于 readFile
这样「线程级」的异步。
总结
本文通过了两种方式来使一个计算密集的函数变为异步,期间也复习了一下 Promise 和 Evnet loop 等相关知识,同时也知道了 Node.js 的一些 API 是如何实现异步的。 对于通过线程/进程 或 分片通过 Event loop 来实现异步这两种办法没有哪种更好,它们在不同的场景下都有各自的优势。
在折腾之后也能总结出一些有用的知识:
- Event loop 是在入口文件中的(所有同步任务执行/同步任务中的异步操作发出请求/规划好同步任务中的定时器/
process.nextTick()
等)完成后,才真正的开始。 - 如果递归调用 micro-task ,会出因为现的问题。但
setImmediate()
则不会。 - Node.js 和 浏览器的 Event loop 实现方式有区别:如 Node.js 是在每次时间阶段间执行 micro-task,而浏览器是在每个 macro-task 执行完后就执行 micro-task,具体例子可以参考。
- Node.js 的
fs/crypto
等模块是基于 libuv 线程池的异步。
Makeflow (makeflow.com) 是以流程为核心的项目管理工具,让研发团队能更容易地落地和改善工作流,提升任务流转体验及效率。如果你正为了研发流程停留在口头讨论、无法落实而烦恼,Makeflow 或许是一个可以尝试的选项。如果你认为 Makeflow 缺少了某些必要的特性,或者有任何建议反馈,可以通过 GitHub、语雀或页面客服与我们建立连接。
Ref
Node.js event loop workflow & lifecycle in low level | Void Canvas
深入理解js事件循环机制(Node.js篇) - lynnelv's blog
更快的异步函数和 Promise · V8
Tasks, microtasks, queues and schedules - JakeArchibald.com
The Node.js Event Loop, Timers, and process.nextTick() | Node.js
Node.js Event loop 原理 - 掘金
写一个符合 Promises/A+ 规范并可配合 ES7 async/await 使用的 Promisedeep-into-node-2 | 淘系前端团队
Node.js源码解析:深入Libuv理解事件循环 - 知乎