NVIDIA 初创加速计划,免费加速您的创业启动 了解详情
写点什么

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:2013672

评论

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

软件测试 | 测试开发 | 测试面试真题|抖音的广告系统前后台,该如何测试?

测吧(北京)科技有限公司

测试

软件测试 | 测试开发 | 一道大厂测试开发面试真题,你需要几分钟解答?

测吧(北京)科技有限公司

测试

测试开发面试真题 | 测试老兵进阶突破,成功挑战大厂 P7 Offer!

测吧(北京)科技有限公司

测试

小程序容器,组装式应用的一种方案

Geek_99967b

小程序

从融云社交泛娱乐出海白皮书,看「社交+X」的全球攻略

融云 RongCloud

即时通讯 白皮书 泛娱乐社交

Java高手怎样炼成?阿里大牛一份火爆GitHub的1046页笔记帮你解决

钟奕礼

Java 程序员 架构 后端 java面试

软件测试 | 测试开发 | 什么是软件缺陷

测吧(北京)科技有限公司

测试

自有APP小程序如何打通微信登陆体系

Geek_99967b

小程序 小程序生态

概述服务网格的优劣势

穿过生命散发芬芳

服务网格 9月月更

软件测试 | 测试开发 | Web测试方法与技术之CSS讲解

测吧(北京)科技有限公司

测试

软件测试 | 测试开发 | 测试面经 | 从测试螺丝钉到大厂测试开发,三点成长心得和面试经验

测吧(北京)科技有限公司

测试

公司内部分享文档应该怎么写?看这篇就够了

Baklib

软件测试 | 测试开发 | 黑盒测试方法论—因果图

测吧(北京)科技有限公司

测试

软件测试 | 测试开发 | WEB 端常见 Bug 解析

测吧(北京)科技有限公司

测试

小程序怎样影响传媒产业的数字化

Geek_99967b

小程序

八家知名大厂联合手写的Java面试手册刚上线!竟就到达巅峰?

钟奕礼

Java 架构 后端 java面试

软件测试 | 测试开发 | 常用测试策略与测试手段

测吧(北京)科技有限公司

测试

小程序与工业互联网上的概况

Geek_99967b

小程序

软件测试 | 测试开发 | web前端的HTML浅析

测吧(北京)科技有限公司

测试

软件测试 | 测试开发 | Web测试方法与技术之JavaScript 讲解

测吧(北京)科技有限公司

测试

软件测试 | 测试开发 | Web自动化之Selenium安装

测吧(北京)科技有限公司

测试

面试凉凉,阿里学长甩我一份24w字Java核心技术面试手册,真香

钟奕礼

Java 架构 后端 java面试

iMazing高效便捷的数据转移功能

淋雨

ios iphone

软件测试 | 测试开发 | 测试环境搭建

测吧(北京)科技有限公司

测试

软件测试 | 测试开发 | Web测试方法与技术实战演练

测吧(北京)科技有限公司

测试

软件测试 | 测试开发 | Selenium 测试用例编写

测吧(北京)科技有限公司

测试

从规模化平台工程实践,我们学到了什么?

SOFAStack

BATJ互联网月薪38K的Java岗面试题首曝光,掌握这些大厂Offer指定跑不了

程序知音

Java java面试 后端技术 秋招 Java面试八股文

阿里面试官内部题库,阿里发布2022年Java岗(正式版)面试题

程序知音

Java java面试 后端技术 秋招 Java面试八股文

2021 金三银四面试必备?体系化带你学习:分布式进阶技术手册

钟奕礼

Java 架构 后端 java面试

22年程序员更卷了,金九银十“面试必备小册”最新开源

程序知音

Java 阿里 后端技术 秋招 Java面试题

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