压榨计算机性能:浏览器和 Node.js 的异步计算

系统 浏览器
所有的软件代码最终都是通过 CPU 来跑的,能不能把 CPU 高效利用起来是区分性能高低的标志,也就是说不能让它空转。

[[421495]]

本文转载自微信公众号「神光的编程秘籍」,作者神说要有光。转载本文请联系神光的编程秘籍公众号。

都说 Node.js 可以实现高性能的服务器,那什么是高性能呢?

所有的软件代码最终都是通过 CPU 来跑的,能不能把 CPU 高效利用起来是区分性能高低的标志,也就是说不能让它空转。

那什么时候会空转呢?

  • 当程序在进行网络和磁盘的 IO 的时候,这时候 CPU 是空闲的,也就是在空转。
  • 多核 CPU 可以同时跑多个程序,如果只利用了其中一核,那么其他核也是在空转。

所以,要想达到高性能,就要解决这两个问题。

操作系统提供了线程的抽象,对应代码不同的执行分支,都是可以同时上不同的 CPU 跑的,这是利用好多核 CPU 性能的方式。

而如果有的线程在进行 IO 了,也就是要阻塞的等待读写完成,这种是比较低效的方式,所以操作系统实现了 DMA 的机制,就是设备控制器,由硬件来负责从设备到内存的搬运,在搬完了告诉 CPU 一声。这样当有的线程在 IO 的时候就可以把线程暂停掉,等收到 DMA 运输数据完成的通知再继续跑。

多线程、DMA,这是利用好多核 CPU 优势、解决 CPU 阻塞等 IO 的问题的操作系统提供的解决方案。

而各种编程语言对这种机制做了封装,Node.js 也是,Node.js 之所以是高性能,就是因为异步 IO 的设计。

Node.js 的异步 IO 的实现在 libuv,基于操作系统提供的异步的系统调用,这种一般是硬件级别的异步,比如 DMA 搬运数据。但是其中有一些同步的系统调用,通过 libuv 封装以后也会变成异步的,这是因为 libuv 内有个线程池,来执行这些任务,把同步的 API 变成异步的。这个线程池的大小可以通过 UV_THREADPOOL_SIZE 的环境变量设置,默认是 4。

我们在代码里调用的异步 API,很多都是通过线程来实现的。

比如:

  1. const fsPromises = require('fs').promises; 
  2.  
  3. const data = await fsPromises.readFile('./filename'); 

但是,这种异步 API 只解决了 IO 的问题,那如何利用多核 CPU 的优势来做计算呢?

Node.js 在 10.5 实验性的引入(在 12 正式引入)了 worker_thread 模块,可以创建线程,最终用多个 CPU 跑,这是利用多核 CPU 的做计算的方式。

异步 API 可以利用多线程做 IO,而 worker_thread 可以创建线程做计算,用于不同的目的。

要聊清楚 worker_thread,还得从浏览器的 web worker 聊起。

浏览器的 web worker

浏览器也同样面临不能利用多核 CPU 做计算的问题,所以 html5 引入了 web worker,可以通过另一个线程做计算。

我们创建一个 Worker 对象,指定跑在另一个线程的 js 代码,然后通过 postMessage 传递消息给它,通过 onMessage 接收消息。这个过程也是异步的,我们进一步把它封装成了 promise。

然后在 webWorker.js 里面接收数据,做计算,之后通过 postMessage 传回结果。

  1. <!DOCTYPE html> 
  2. <html lang="en"
  3. <head></head> 
  4. <body> 
  5.     <script> 
  6.         (async function () { 
  7.             const res = await runCalcWorker(2, 3, 3, 3); 
  8.             console.log(res); 
  9.         })(); 
  10.  
  11.         function runCalcWorker(...nums) { 
  12.             return new Promise((resolve, reject) => { 
  13.                 const calcWorker = new Worker('./webWorker.js'); 
  14.                 calcWorker.postMessage(nums) 
  15.                 calcWorker.onmessage = function (msg) { 
  16.                     resolve(msg.data); 
  17.                 }; 
  18.                 calcWorker.onerror = reject; 
  19.             }); 
  20.         } 
  21.     </script> 
  22.  
  23. </body> 
  24. </html> 

这样,我们就利用了另一个 CPU 核来跑了这段计算,对写代码来说和普通的异步代码没啥区别。但这个异步实际上不是 IO 的异步,而是计算的异步。

Node.js 的 worker thread 和 web worker 类似,我甚至怀疑 worker thread 的名字就是受 web worker 影响的。

Node.js 的 worker thread

把上面那段异步计算的逻辑在 Node.js 里面实现话,是这样的:

  1. const runCalcWorker = require('./runCalcWorker'); 
  2.  
  3. (async function () { 
  4.     const res = await runCalcWorker(2, 3, 3, 3); 
  5.     console.log(res); 
  6. })(); 

以异步的方式调用,因为异步计算和异步 IO 在使用方式上没啥区别。

  1. // runCalcWorker.js 
  2. const  { Worker } = require('worker_threads'); 
  3.  
  4. module.exports = function(...nums) { 
  5.     return new Promise(function(resolve, reject) { 
  6.         const calcWorker = new Worker('./nodeWorker.js'); 
  7.         calcWorker.postMessage(nums); 
  8.  
  9.         calcWorker.on('message', resolve); 
  10.         calcWorker.on('error', reject); 
  11.     }); 

然后异步计算的实现是通过创建 Worker 对象,指定在另一个线程跑的 JS,然后通过 postMessage 传递消息,通过 message 接收消息。这个和 web worker 很类似。

  1. // nodeWorker.js 
  2. const { 
  3.     parentPort 
  4. } = require('worker_threads'); 
  5.  
  6. parentPort.on('message', (data) => { 
  7.     const res = data.reduce((total, cur) => { 
  8.         return total += cur; 
  9.     }, 0); 
  10.     parentPort.postMessage(res); 
  11. }); 

在具体执行计算的 nodeWorker.js 里面,监听 message 消息,然后进行计算,通过 parentPost.postMessage 传回数据。

对比下 web worker,你会发现特别的像。所以,我觉得 Node.js 的 worker thread 的 api 是参考 web worker 来设计的。

但是,其实 worker thread 也支持在创建的时候就通过 wokerData 传递数据:

  1. const  { Worker } = require('worker_threads'); 
  2.  
  3. module.exports = function(...nums) { 
  4.     return new Promise(function(resolve, reject) { 
  5.         const calcWorker = new Worker('./nodeWorker.js', { 
  6.             workerData: nums 
  7.         }); 
  8.         calcWorker.on('message', resolve); 
  9.         calcWorker.on('error', reject); 
  10.     }); 

然后 worker 线程里通过 workerData 来取:

  1. const { 
  2.     parentPort, 
  3.     workerData 
  4. } = require('worker_threads'); 
  5.  
  6. const data = workerData; 
  7. const res = data.reduce((total, cur) => { 
  8.     return total += cur; 
  9. }, 0); 
  10. parentPort.postMessage(res); 

因为有个传递消息的机制,所以要做序列化和反序列化,像函数这种无法被序列化的数据就无法传输了。这也是 worker thread 的特点。

Node.js 的 worker thread 和 浏览器 web woker 的对比

从使用上来看,都可以封装成普通的异步调用,和其他异步 API 用起来没啥区别。

都要经过数据的序列化反序列化,都支持 postMessage、onMessage 来收发消息。

除了 message,Node.js 的 worker thread 支持传递数据的方式更多,比如还有 workerData。

但从本质上来看,两者都是为了实现异步计算,充分利用多核 CPU 的性能,没啥区别。

总结

高性能的程序也就是要充分利用 CPU 资源,不要让它空转,也就是 IO 的时候不要让 CPU 等,多核 CPU 也要能同时利用起来做计算。操作系统提供了线程、DMA的机制来解决这种问题。Node.js 也做了相应的封装,也就是 libuv 实现的异步 IO 的 API,但是计算的异步是 Node 12 才正式引入的,也就是 worker thread,API 设计参考了浏览器的 web worker,传递消息通过 postMessage、onMessage,需要做数据的序列化,所以函数是没法传递的。 

从使用上来看异步计算、异步 IO 使用方式一样,但是异步 IO 只是让 cpu 不同阻塞的等待 IO 完成,异步计算是利用了多核 CPU 同时进行并行的计算。

 

责任编辑:武晓燕 来源: 神光的编程秘籍
相关推荐

2022-08-21 07:30:55

程序并发Golang编码

2022-01-04 21:36:33

JS浏览器设计

2021-12-08 07:55:41

EventLoop浏览器事件

2012-03-09 09:11:29

Node.js

2021-03-04 23:12:57

Node.js异步迭代器开发

2020-09-15 08:26:25

浏览器缓存

2021-04-06 10:15:29

Node.jsHooks前端

2009-04-01 08:56:59

2021-03-16 16:16:41

GeneratorWebsockets前端

2011-12-23 13:58:57

node.js

2020-12-08 06:28:47

Node.js异步迭代器

2019-07-09 14:50:15

Node.js前端工具

2021-09-07 07:53:43

工具

2021-08-12 15:00:01

Linux终端

2021-01-26 08:07:44

Node.js模块 Async

2018-02-27 14:20:18

Python异步并发连接

2013-11-01 09:34:56

Node.js技术

2019-09-19 09:30:53

量子计算机芯片超算

2015-03-10 10:59:18

Node.js开发指南基础介绍

2020-12-14 15:40:59

Nodefastifyjs
点赞
收藏

51CTO技术栈公众号