BT

如何利用碎片时间提升技术认知与能力? 点击获取答案

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

| 作者 尤嘉 关注 0 他的粉丝 发布于 2014年6月18日. 估计阅读时间: 16 分钟 | Google、Facebook、Pinterest、阿里、腾讯 等顶尖技术团队的上百个可供参考的架构实例!

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)关注我们,并与我们的编辑和其他读者朋友交流。

评价本文

专业度
风格

您好,朋友!

您需要 注册一个InfoQ账号 或者 才能进行评论。在您完成注册后还需要进行一些设置。

获得来自InfoQ的更多体验。

告诉我们您的想法

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我
社区评论

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

讨论

登陆InfoQ,与你最关心的话题互动。


找回密码....

Follow

关注你最喜爱的话题和作者

快速浏览网站内你所感兴趣话题的精选内容。

Like

内容自由定制

选择想要阅读的主题和喜爱的作者定制自己的新闻源。

Notifications

获取更新

设置通知机制以获取内容更新对您而言是否重要

BT