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

评论

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

引入了绩效管理,团队反而一天不如一天了?(一)

无箭的丘比特

团队管理 企业文化 绩效

带你100% 地了解 Redis 6.0 的客户端缓存

程序员历小冰

redis 缓存 redis6.0.0

MacOS使用指南之我并不需要系统菜单栏

lmymirror

macos 高效工作 完美主义 操作系统 新手指南

回"疫"录(13):不信谣,不传谣

小天同学

疫情 回忆录 现实纪录 纪实 谣言

找到自己的领域,然后封神

一尘观世界

成长 提升 领域 机遇 趋势

谈一谈自由职业者的心态

Bob Jiang

自由职业 写作 心态 营销

【Howe 学 JAVA】Java 类集框架2——集合输出

Howe

Java 集合 输出 类集

你觉得你是哪类人?

Janenesome

读书笔记 思考

和儿子装一台 Hackintosh

苏锐

DIY Hackintosh 装机

Flink 1.10 细粒度资源管理解析

Apache Flink

大数据 flink 流计算 实时计算

个人的投资原则

史前靓仔

人生就是一场说走就走的旅行

kimmking

Web3极客日报#136

谢锐 | Frozen

区块链 独立开发者 技术社区 Rebase Web3 Daily

CTO股权”避坑“,你根本不知道我们多努力

赵新龙

TGO鲲鹏会 股权 CTO

云函数中使用Python-ORM: Peewee

刘宇

当你不知道怎么学习新技术时

石君

学习 方法论

CentOS7使用Iptables做网络转发

wong

Centos 7 iptables

Mac 自带软件-聚焦搜索

Winann

macos Mac spotlight

TL如何在团队中培养出更多前端技术专家

贵重

大前端 团队建设 技术管理

Spring Boot可执行JAR的原理

小判

Spring Boot 类加载 Fat-JAR deflate JAR URL

基于Serverless架构的Git代码统计

刘宇

《Linux就该这么学》笔记(一)

编程随想曲

Linux

可能是最最最最简单的搭建博客方法

彭宏豪95

GitHub 写作 博客 GitPress

抽象

落英亭郎

系统设计 面向对象 抽象

(乱记)“怎样培养优秀孩子”

启润

裸机Ubuntu18.04 配置实现人脸识别的第三方库

月夜

dlib face_recognition 人脸识别 环境配置

我跑步的时候会想些什么

养牛致富带头人

跑步 运动 锻炼

Web3极客日报#137

谢锐 | Frozen

区块链 独立开发者 技术社区 Rebase Web3 Daily

《CSS 选择器世界》读书笔记

云走

CSS Java html 读书笔记 大前端 张鑫旭

回文串解题记录

晓刚学代码

Java 算法

我的编程之路-3(熟练)

顿晓

c++ 调试 经历 项目 疑问

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