java timer是同步执行还是异步的_深入 Event loop 看 Node.js 计算密集逻辑的异步优化实践...

本文通过分析`async/await`、`setImmediate()`等技术,探讨了如何使计算密集型函数异步执行,避免阻塞主线程。详细解释了Node.js的Event loop机制,指出递归调用micro-task可能导致阻塞,而`setImmediate()`可在不同Event loop阶段执行,避免阻塞。同时,介绍了Node.js中异步API的实现原理,如`fs.readFile()`利用libuv线程池实现异步IO。
摘要由CSDN通过智能技术生成

加上 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 这个函数让他加上 awaitPromise

// 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 里的执行过程就不难理解:

  1. node 开始运行 main.js
  2. 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
  1. for loop 结束,紧跟其后的 console.log('end counting') 被放入了一个新的隐式 Promise 的 then 的回调当中,而此时 then 的 callback 就被添加到了 micro-tasks的 queue 中,暂缓执行
  2. 执行 console.log('print me before the count ends.') . main.js 的同步代码执行结束,Event loop 开始
  3. 事件循环检查 mirco-tasks 队列,调用 then 中的 callback,执行并打印 end counting
  4. 事件循环进行到 timer 阶段,发现 setTimeout 的 callback 早已超时,赶紧执行并打印 print me after 300ms
  5. 进程结束。

可见,如果在 for loop 过程中没有被挂起,那么主线程就会被一直 block,直到 for loop 完成为止。

现在知道了为什么 setTimeoutconsole.log('print me before the count ends.') 被延缓执行。随之上一个小节末尾提出的问题也能被很好的解答了:

asyncawait 兄弟两实际上只是 Promise 的语法糖,初衷是为了避免无止尽的 then,而不是让我误解。 async 仅仅是用于将函数标记为,这是个 resumable(可恢复) 函数。标记的作用是如果在函数内部发现了 await,就创建一个新的隐式 Promise对象,然后把函数剩下的操作放到该 then 的 callback 中。此时 then 中的 callback 才是被异步执行的。

继续改进:在单线程中的「异步」

如何才能让 for loop 不阻塞住主线程呢,既然 PromiseonFulfilled/onRejected 的 callback 可以被异步执行,那我们就参考一下它的实现:

在 Promise/A+ 规范 中并没有明确 onFulfilledonRejected 是以micro-task 还是 macro-task 形式放入队列。但在最新的 ECMAScript 规范 中,明确了 Promise 必须以 Promise Job 的形式入 Job 队列(也就是 micro-task),并仅在没有运行的 stack(stack 为空的情况下)才可以初始化执行。 HTML 规范 也提出,在 stack 清空后,执行 micro-task 的检查方法。

我们其他以 micro-task/Promise Job 形式实现的方法有:process.nextTickpostMessageMessageChannel 等。我们可以让任务分片并使用它们来达到不阻塞主线程的异步效果。

回顾一下刚才的代码,因为 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 是如何执行的:

c160efba15c34b17be9f04741c903290.png
来源于 Node 文档并稍作修改

可以看到在其中包含了不同的六个阶段,其中不同阶段的作用可以参考文档,这里不过多赘述。 刚才我们提到的 micro-tasks 会在各个 Event loop 阶段之间执行(Node.js 环境),一旦执行,要直到 nextTick 队列被清空,才会进入到下一个 Event loop 阶段,所以如果递归调用作为 micro-task 的 process.nextTick ,就会导致后续包括 timer 阶段被 block,迟迟不能被执行,同样还会出现 I/O starving(饥饿)的问题。

既然用 nextTick 的结果差强人意,我们就再来看看 setImmediate 能否解决这个问题。

setImmediate()

虽然从语义上来说, nextTicksetImmediate 听起听起来要更靠后执行一点。但事实是,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 实例,它的开销并不低。

7ae3d1c96a0eadaa692006152ec4c1d2.png
来源: Understanding Worker Threads in Node.js - NodeSource

再探 Node.js 提供的异步 API 是如何实现的

在上文中我们了解到,通过 Promise.then setImmediate , 等方法可以使得一段操作被暂时加入不同的队列中,然后最后以不同的时机被唤起执行从而实现异步。那么 Node.js 提供的诸如 fs/http/crypto 等模块是通过诸如此类的方式实现异步的吗?答案是:更高端一些。

8c7bfe7a33122aeb2358d88d26317f55.png

可以看到,在 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 方法把操作提交到队列中等待执行。

整个读取文件的过程大致如下:

  1. node, libuv 初始化;
  2. node_file.cc 中的 Read 方法调用 libuv(fs.c)uv_fs_read ,封装请求;
  3. libuv 将请求封装成 uv_work, 提交到任务队列尾部,触发信号;
  4. 此时主线程的 read 调用返回。
  5. 线程池从 uv_work 队列中取出一个请求,开始执行 read IO;
  6. 向主线程发送信号表明任务完成,等待执行 read 调用后的其它操作。
  7. 主线程 epoll,从响应队列取已经完成的请求;
  8. 主线程响应 epoll事件;
  9. 主线程执行请求的 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理解事件循环 - 知乎

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值