立即领取|华润集团、宁德核电、东风岚图等 20+ 标杆企业数字化人才培养实践案例 了解详情
写点什么

Node.js 异步处理 CPU 密集型任务的新思路

  • 2014-06-17
  • 本文字数:3828 字

    阅读完需:约 13 分钟

Node.js 擅长数据密集型实时(data-intensive real-time)交互的应用场景。然而数据密集型实时应用程序并不是只有 I/O 密集型任务,当碰到 CPU 密集型任务时,比如要对数据加解密(node.bcrypt.js),数据压缩和解压(node-tar),或者要根据用户的身份对图片做些个性化处理,在这些场景下,主线程致力于做复杂的 CPU 计算,I/O 请求队列中的任务就被阻塞。

Node.js 主线程的 event loop 在处理所有的任务 / 事件时,都是沿着事件队列顺序执行的,所以在其中任何一个任务 / 事件本身没有完成之前,其它的回调、监听器、超时、nextTick() 的函数都得不到运行的机会,因为被阻塞的 event loop 根本没机会处理它们,此时程序最好的情况是变慢,最糟的情况是停滞不动,像死掉一样。

一个可行的解决方案是新开进程,通过 IPC 通信,将 CPU 密集型任务交给子进程,子进程计算完毕后,再通过 ipc 消息通知主进程,并将结果返回给主进程。

和创建线程相比,开辟新进程的系统资源占用率大,进程间通信效率也不高。如果能不开新进程而是新开线程,将 CPU 耗时任务交给一个工作线程去做,然后主线程立即返回,处理其他的 I/O 请求,等到工作线程计算完毕后,通知主线程并将结果返回给主线程。那么在同时面对 I/O 密集型和 CPU 密集型服务的场景下,Node 的主线程也会变得轻松,并能时刻保持高响应度。

因此,和开进程相比,一个更加优秀的解决方案是:

  1. 不开进程,而是将 CPU 耗时操作交给进程内的一个工作线程完成。
  2. CPU 耗时操作的具体逻辑支持通过 C++ 和 JS 实现。
  3. JS 使用这个机制与使用 I/O 库类似,方便高效。
  4. 在新线程中运行一个独立的 V8 VM,与主线程的 VM 并发执行,并且这个线程必须由我们自己托管。

为了实现以上四个目标,我们在 Node 中增加了一个 backgroundthread 线程,文章稍候会详细解释这个概念。在具体实现上,为 Node 增加了一个 pt_c 的内建 C++ 模块。这个模块负责把 CPU 耗时操作封装成一个 Task,抛给 backgroundthread,然后立即返回。具体的逻辑在另一个线程中处理,完成之后,设定结果,通知主线程。这个过程非常类似于异步 I/O 请求。具体逻辑如下图:

Node 提供了一种机制可以将 CPU 耗时操作交给其他线程去做,等到执行完毕后设置结果通知主线程执行 callback 函数。以下是一段代码,用来演示这个过程:

复制代码
int main() {
loop = uv_default_loop();
int data[FIB_UNTIL];
uv_work_t req[FIB_UNTIL];
int i;
for (i = 0; i < FIB_UNTIL; i++) {
data[i] = i;
req[i].data = (void *) &data[i];
uv_queue_work(loop, &req[i], fib, after_fib);
}
return uv_run(loop, UV_RUN_DEFAULT);
}

其中函数 uv_queue_work 的定义如下:

复制代码
UV_EXTERN int uv_queue_work(uv_loop_t* loop,
uv_work_t* req,
uv_work_cb work_cb,
uv_after_work_cb after_work_cb);

参数 work_cb 是在另外线程执行的函数指针,after_work_cb 相当于给主线程执行的回调函数。 在 windows 平台上,uv_queue_work 最终调用 API 函数 QueueUserWorkItem 来派发这个 task,最终执行 task 的线程是由操作系统托管的,每次可能都不一样。这不满足上述第四条。

因为我们要支持在线程中运行 js 代码,这就需要开一个 V8 VM,所以需要把这个线程固定下来,特定任务,只交给这个线程处理。并且一旦创建,不管有没有 task,都不能随便退出。这就需要我们自己维护一个线程对象,并且提供接口,使得使用者可以方便的生成一个对象并且提交给这个线程的任务队列。

在绑定内建模块 pt_c 的时候,会创建一个 background thread 的线程对象。这个线程拥有一个 taskloop,有任务就处理,没有任务就等待在一个信号量上。多线程要考虑线程间同步的问题。线程同步只发生在读写此线程的 incomming queue 的时候。Node 的主线程生成 task 后,提交到这个线程的 incomming queue 中,并激活信号量然后立即返回。在下一次循环中,backgroundthread 从 incomming queue 中取出所有的 task,放入 working queue,然后依次执行 working queue 中的 task。主线程不访问 working queue 因此不需要加锁。这样做可以降低冲突。

这个线程在进入 taskloop 循环之前会建立一个独立的 V8 VM,专门用来执行 backgroundjs 的代码。主线程的 v8 引擎和这个线程的可以并行执行。它的生命周期与 Node 进程的生命周期一致。

复制代码
// pt_c 模块的初始化代码
void Init(Handle<Object> target,
Handle<Value> unused,
Handle<Context> context,
void* priv) {
//Create working thread, focus on cup intensive task
if(!CWorkingThread::GetInstance().Start()){
return;
}
Environment* env = Environment::GetCurrent(context);
// load dll, Including all the cpu-intensive functions
NODE_SET_METHOD(target, "registermodule", RegisterModule);
NODE_SET_METHOD(target, "posttask", PostTask);
// post a task that run a cpu-intensive function defined in backgroundjs
NODE_SET_METHOD(target, "jstask", JsTask);
}

可以把所有 CPU 耗时逻辑放入 backgroundJs 中,主线程通过生成一个 task,指定好运行的函数和参数,抛给工作线程。工作线程在执行 task 的过程中调用在 backgroundJs 中的函数。BackgroundJs 是一个.js 文件,在里面添加 CPU 耗时函数。

background.js 代码示例:

复制代码
var globalFunction = function(v){
var obj;
try {
obj = JSON.parse(v);
 } catch(e) {
return e;
}
 var a = obj.param1;
 var b = obj.param2;
 var i;
 // simulate CPU intensive process...
 for(i = 0; i < 95550000; ++i) {
   i += 100;
i -= 100;
 }
return (a + b).toString();
}

运行 Node,在控制台输入:

复制代码
var bind = process.binding('pt_c');
var obj = {param1: 123,param2: 456};
bind.jstask('globalFunction', JSON.stringify(obj), function (err, data) {
if (err) {
console.log("err");
} else {
console.log(data);
}
});

调用的方法是 bind.jstask,稍后会解释这个函数的用法。

以下是测试结果:

上面这个实验操作步骤如下:

  1. 首先绑定 pt_c 内建模块。绑定的过程会调用模块初始化函数,在这个函数中,创建新线程。
  2. 快速多次调用 backgroundjs 中的 CPU 耗时函数,上面的实验中连续调用了三次。

当 backgroundjs 中的函数完成后,主线程接到通知, 在新一轮的 evenloop 中,调用回调函数,打印出结果。这个实验说明了 CPU 耗时操作异步执行。

方法 jstask 总共三个参数,前两个参数为字符串,分别是 background.js 中的全局函数名称,传给函数的参数。最后一个参数是一个 callback 函数,异步留给主线程运行。

为什么用字符串做参数?

为了适应各种不同的参数类型,就需要为 C++ 函数提供各种不同的函数实现,这是非常受限制的。C++ 根据函数名获取 backgroundjs 中的函数然后将参数传递给 js。在 js 中,处理 json 字符串是非常容易的,因此采用字符串,简化了 C++ 的逻辑,js 又能够方便的生成和解析参数。同样的理由,backgroundjs 中函数的返回值也为 json 串。

对 C++ 的支持

在苛求性能的场景,pt_c 允许加载一个.dll 文件到 node 进程,这个 dll 文件包含 CPU 耗时操作。js 加载 pt_c 的时候,指定文件名即可完成加载。

代码示例:

复制代码
var bind = process.binding('pt_c');
bind.registermodule('node_pt_c.dll', 'DllInit', 'Json to Init');
bind.posttask('Func_example', 'Json_Param', function (err, data) {
if (err) {
console.log("err");
} else {
console.log(data);
}
});

与 backgroundjs 相比,加载 C++ 模块多了一个步骤,这个步骤是调用 bind.registermodule。这个函数负责将加载 dll 并负责对其初始化。一旦成功后,不能再加载其他模块。所有的 CPU 耗时操作函数都应该在这个 dll 文件中实现。

总结

这篇文章提出了 backgroundjs 这个新的概念,扩展了 Node.js 的能力, 解决了 Node 在处理 CPU 密集任务时的短板。这个解决方案使得使用 Node 的开发人员只需要关注 backgroundjs 中的函数。比起多开进程或者新添加模块的解决方案更高效,通用和一致。我们的代码已经开源,您可以在 https://github.com/classfellow/node/tree/Ansy-CPU-intensive-work--in-one-process 下载。

支持 backgroundjs 一个稳定 Node 版本您可以在 http://www.witch91.com/nodejs.rar 下载。

参考文献

  1. Node.js 软肋之 CPU 密集型任务
  2. Why you should use Node.js for CPU-bound tasks,Neil Kandalgaonkar,2013.4.30;
  3. http://nikhilm.github.io/uvbook/threads.html#inter-thread-communication
  4. 深入浅出 Node.js 朴灵

补充和校正

上文叙述了如何扩展 node,建立一种事件驱动的解决 CPU 密集型任务的机制。但有几个局限:

1 代码实现上只做了 Windows,没有对 Linux 的支持;
2 源代码级扩展 node,需要下载分支代码编译 node;
3 缺少对 C++ 层面的扩展支持。

后来用 node 扩展模块的方式重新实现为 rcib 模块,改进如下:

1 增加了对 Linux 的支持;
2 通过 npm install rcib 安装,即作为一个 node C++ 第三方扩展使用;
3 增加了 C++ 扩展的支持,rcib 本身可作为一种扩展模式,可以方便的在此基础上,实现基于事件驱动的 C++ 扩展,任意扩展 node 能力;
4 源码位置 : https://github.com/classfellow/rcib

感谢田永强对本文的审校。

给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ )或者腾讯微博( @InfoQ )关注我们,并与我们的编辑和其他读者朋友交流。

2014-06-17 00:2013868

评论

发布
暂无评论
发现更多内容

AI Fabric:通往 GenAI 和数据驱动型企业的最快途径

Altair RapidMiner

人工智能 机器学习 数据分析 altair RapidMiner

故障演练的逻辑

陈一之

架构设计 混沌工程 故障演练 技术思维

《使用Gin框架构建分布式应用》读后感

codists

golang gin 编程人

精选8款银行开发管理系统,提升工作效率

爱吃小舅的鱼

需求管理工具 需求管理软件 需求管理系统

TDengine 荣膺双奖:引领储能与数据库创新

TDengine

tdengine 时序数据库 数据库·

Fluent Editor 富文本开源2个月的总结:增加格式刷、截屏、TypeScript 类型声明等新特性

OpenTiny社区

富文本编辑器 OpenTiny 前端开源

Ubuntu 22报错:PAM unable to dlopen(pam_tally2.so)

百度搜索:蓝易云

软件测试学习笔记丨Flask操作数据库-多对多

测试人

数据库 软件测试 测试开发

TDengine 检测数据最佳压缩算法工具,助你一键找出最优压缩方案

TDengine

tdengine 时序数据库 数据库·

鸿蒙Banner图一多适配不同屏幕

龙儿筝

codigger体验过程记录

梦笔生花

codigger

如何评估项目管理工具的性价比?8款工具

爱吃小舅的鱼

项目管理工具

Zypher Network:全栈式 Web3 游戏引擎,引领服务器抽象叙事

西柚子

小公司团队管理:沟通与激励的艺术

爱吃小舅的鱼

团队管理

矩阵起源 CEO 王龙出席 1024 超互联(苏州)总部节点发布会

MatrixOrigin

为什么使用海外云手机进行TikTok矩阵化运营?

Ogcloud

云手机 海外云手机 tiktok云手机 tiktok运营 TikTok矩阵运营

如何选择?开发体育赛事直播平台时的源码质量对比!

软件开发-梦幻运营部

TDengine 签约山东港,赋能港口数字化转型

TDengine

数据库 tdengine 时序数据库

逐步教你如何获取DeepL翻译API密钥

幂简集成

DeepL API

火山引擎大模型网关 x 地瓜机器人教你玩转主流大模型

火山引擎边缘云

边缘计算 机器人 大模型 边缘智能

Net5.5G智能IP网络峰会成功举办,全球Net5.5G加速商用部署

财见

Web3 游戏周报(10.27 - 11.02)

Footprint Analytics

链游

「胖钱包」理论解析:钱包为何将超越协议与应用,赢下「最终用户」争夺战?

TechubNews

存储数据库的传输效率提升-ETLCloud结合HBASE

RestCloud

数据库 HBase 数据传输 ETL 数据集成

Python装饰器执行的顺序你知道吗

LLLibra146

Python 装饰器 代码技巧

K8s网络基本原理

陈一之

Kubernetes 容器 k8s 网络

快递鸟上门取件API接口代码流程

快递鸟

快递物流

NFTScan | 10.28~11.3 NFT 市场热点汇总

NFT Research

NFT\ NFTScan

故障测试 Byteman 上手实践

FunTester

如何成为高效的中层管理人员:管理水平提升指南

爱吃小舅的鱼

中层管理人员

MatrixOne 助力西安天能替换MySQL+MongoDB+ES打造一体化物联网平台

MatrixOrigin

Node.js异步处理CPU密集型任务的新思路_架构/框架_尤嘉_InfoQ精选文章