【Nodejs】使用多线程实现Node的异步I/O模型

562 阅读4分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第20天,点击查看活动详情

前言

依靠 cluster集群worker_threads多线程,Nodejs 能够充分利用处理器的多核性能,以胜任CPU密集 的任务。但I/O密集的任务却无法依靠这些方法解决。对此,Nodejs给出的解决方案则是异步I/O和事件驱动模型。

Nodejs的I/O模型

在单线程同步编程模型中,由于阻塞I/O的存在导致CPU计算资源得不到充分的利用。众所周知,I/O是昂贵的,让CPU来等待I/O会导致损失大量性能:

异步I/O的提出是期望I/O的调用不再阻塞后续运算,将原有等待I/O完成的这段时间分配给CPU,来运行其余需要执行的逻辑:

非阻塞与阻塞I/O

二者的区别在于阻塞I/O完成整个获取数据的过程;而非阻塞I/O则不带数据直接返回文件描述符,要获取数据,还需要通过文件描述符再次读取。

虽然非阻塞I/O明显提高了CPU利用,但为了获取完整的数据,应用程序需要重复调用I/O操作来确认是否完成。重复调用判断操作是否完成的就要使用轮询。轮询的方法很多,但本质上仍是一种同步方法,需要将CPU用来遍历操作符或者等待IO返回。

我们期望的完美的异步I/O应该是应用程序发起非阻塞调用,无须通过遍历或者事件唤醒等方式轮询,可以直接处理下一个任务,只需在I/O完成后通过信号或回调将数据传递给应用程序即可:

多线程实现异步I/O

动用我们之前学习多线程时的思维,我们只需要另外创建一个I/O子线程。在主线程需要I/O操作时将请求发送到I/O子线程去,并创建一个事件监听器,当I/O线程读取完后触发向主进程触发该事件:

const { Worker } = require('node:worker_threads');

const date = Date.now();
// 创建一个子线程
const worker = new Worker('./worker.js');

worker.postMessage({
  filePath: './example.json',
  time: Date.now()
});

// 主线程计时器
const date3 = Date.now()
for(let i = 1; i < 40; i ++) {
  console.log(i);
}
const date2 = Date.now();

worker.on('message', msg => {
  const ima = new Date();
  console.log(`
    Main Thread Spend: ${date2 - date3} ms
    Read file Length: ${Buffer.from(msg.fileData).toString().length} words
    Transform Spend: ${msg.time} ms
    SubThread Spend: ${msg.spend} ms
    Total Spend Time: ${ima - date} ms
  `);
});
worker.on('exit', exitcode => {
  console.log(`---------worker exit with ${exitcode}---------`);
});
// worker.on('error', err => { /* ... */ });
// worker.on('online', () => { /* ... */ });

然后在子进程中调用CPU密集或IO密集任务:

const { parentPort } = require('node:worker_threads');
const fs = require('node:fs');
const fib = require('./fib');

parentPort.once('message', (msg) => {
  const date = Date.now();
  const fileData = fs.readFileSync(msg.filePath.toString());
  for(let i = 0; i < 40; i++) {
    fib(i);
  }
  parentPort.postMessage({
    fileData,
    time: date - msg.time,
    spend: Date.now() - date
  });
});

运行主线程:

image.png

💡 ./fib.js 是用递归编写的斐波那契数列,模拟CPU密集的操作;用 readFileSync 同步方法模拟IO密集的操作:

const fib = (input) => {
  const val = parseInt(input);
  if (val > 1) {
    return fib(val - 1) + fib(val - 2);
  } else {
    return 1;
  }
}

module.exports = fib

这样,通过让子线程进行阻塞I/O之类的长耗时任务,让主线程进行统筹处理,通过线程之间的通信将I/O得到的数据进行传递,这就轻松实现了异步I/O:

另外我们知道,创建、执行、销毁一个线程的开销很大。频繁创建和销毁线程消耗的 CPU 算力会抵消多线程带来的好处,越来越多的监听器甚至可能会导致内存溢出OOM,所以在具体的实践中我们需要创建一个线程池。

而在 Node 中,该操作是交给了 libuv线程池。整个流程则是依靠 Event Loop 来实现的:

  1. 首先JavaScript主线程发起异步调用,并设置一个I/O观察者。
  2. JavaScript主线程继续执行后面代码段的任务。而I/O操作在libuv线程池中等待执行。
  3. 子线程通知I/O结果,并归还线程给线程池。主进程I/O观察者获取结果执行回调。

通过Event Loop事件驱动ibuv线程池和线程间通信,Nodejs 实现了高性能的异步I/O模型。

总结

正是异步I/O和事件驱动模型,使得 Node 非常适合I/O密集型任务的处理。在开发中,我们可以使用Nodejs作为后端与前端的中间层,搭建BFF(Backend For Frontend)服务器,以提高吞吐量、方便数据的处理:类似Nginx的代理转发,在HTTP请求发送到前端前,先进行一次处理和封装。