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 的主线程也会变得轻松,并能时刻保持高响应度。
因此,和开进程相比,一个更加优秀的解决方案是:
- 不开进程,而是将 CPU 耗时操作交给进程内的一个工作线程完成。
- CPU 耗时操作的具体逻辑支持通过 C++ 和 JS 实现。
- JS 使用这个机制与使用 I/O 库类似,方便高效。
- 在新线程中运行一个独立的 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,稍后会解释这个函数的用法。
以下是测试结果:
上面这个实验操作步骤如下:
- 首先绑定 pt_c 内建模块。绑定的过程会调用模块初始化函数,在这个函数中,创建新线程。
- 快速多次调用 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 下载。
参考文献
- Node.js 软肋之 CPU 密集型任务
- Why you should use Node.js for CPU-bound tasks,Neil Kandalgaonkar,2013.4.30;
- http://nikhilm.github.io/uvbook/threads.html#inter-thread-communication
- 深入浅出 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 )关注我们,并与我们的编辑和其他读者朋友交流。
评论