写点什么

Node.js 软肋之 CPU 密集型任务

  • 2014-03-07
  • 本文字数:6366 字

    阅读完需:约 21 分钟

Node.js 在官网上是这样定义的:“一个搭建在Chrome JavaScript 运行时上的平台,用于构建高速、可伸缩的网络程序。Node.js 采用的事件驱动、非阻塞I/O 模型使它既轻量又高效,是构建运行在分布式设备上的数据密集型实时程序的完美选择。”Web 站点早已不仅限于内容的呈现,很多交互性和协作型环境也逐渐被搬到了网站上,而且这种需求还在不断地增长。这就是所谓的数据密集型实时(data-intensive real-time)应用程序,比如在线协作的白板,多人在线游戏等,这种web 应用程序需要一个能够实时响应大量并发用户请求的平台支撑它们,这正是Node.js 擅长的领域。

用Node.js 处理I/O 密集型任务相当简单,只需要调用它准备好的异步非阻塞函数就行了。然而数据密集型实时(data-intensive real-time)应用程序并不是只有I/O 密集型任务,当碰到CPU 密集型任务时,比如要对数据加解密( node.bcrypt.js ),数据压缩和解压( node-tar ),或者要根据用户的身份对图片做些个性化处理,这时候该怎么办呢?我们先来了解下 Node.js 自身的编程模型。

Node.js 的先天条件

网络编程策略

上世纪 90 年代提出了一个著名的 C10K 问题。大概意思是当用户数超过 1 万时,很多没设计好的网络服务程序性能将急剧下降,甚至瘫痪。这时候升级硬件也不管用了,问题的根源是系统处理请求的策略,有再多的硬件资源它也用不起来。后来人们总结出了四种典型的网络编程策略:

  1. 服务器为每个客户端请求分配一个线程 / 进程,使用阻塞式 I/O。Java 就是这种策略,Apache 也是,这种策略还是很多交互式应用的首选。因为阻塞,这种策略很难实现高性能,但非常简单,可以实现复杂的交互逻辑。
  2. 服务器用一个线程处理所有客户端请求,使用非阻塞的 I/O 及事件机制。node.js 采用的就是这种策略。这种策略实现起来比较简单,方便移植,也能提供足够的性能,但无法充分利用多核 CPU 资源。
  3. 服务器会分配多个线程来处理请求,但每个线程只处理其中一组客户端的请求,使用非阻塞的 I/O 及事件机制。这是对第二种策略的简单改进,在多线程并发上容易出现 bug。
  4. 服务器会分配多个线程来处理请求,但每个线程只处理其中一组客户端的请求,使用异步 I/O。这种策略在支持异步 I/O 的操作系统上性能非常高,但实现起来很难,主要用在 windows 平台上。

因为大多数网站的服务器端都不会做太多的计算,它们只是接收请求,交给其它服务(比如文件系统或数据库),然后等着结果返回再发给客户端。所以聪明的 Node.js 针对这一事实采用了第二种策略,它不会为每个接入请求繁衍出一个线程,而是用一个主线程处理所有请求。避开了创建、销毁线程以及在线程间切换所需的开销和复杂性。这个主线程是一个非常快速的 event loop,它接收请求,把需要长时间处理的操作交出去,然后继续接收新的请求,服务其他用户。下图描绘了 Node.js 程序的请求处理流程:

主线程 event loop 收到客户端的请求后,将请求对象、响应对象以及回调函数交给与请求对应的函数处理。这个函数可以将需要长期运行的 I/O 或本地 API 调用交给内部线程池处理,在线程池中的线程处理完后,通过回调函数将结果返回给主线程,然后由主线程将响应发送给客户端。那么 event loop 是如何实现这一流程的呢?这要归功于 Node.js 平台的 V8 引擎 libuv

Event Loop 和 Tick

每个 Node 程序的主线程都有一个 event loop,JavaScript 代码全在这个单线程下运行。所有的 I/O 操作以及对本地 API 的调用,或者是异步的(借助程序所在平台的机制),或者运行在另外的线程中。这全都是通过 libuv 处理的。所以当 socket 上有数据过来,或本地 API 函数返回时,需要有种同步的方式调用对刚发生的这一特定事件感兴趣的 JavaScript 函数。

在发生事件的线程中直接调用 JS 函数是不安全的,因为那样也会遇到常规多线程程序遇到的问题,竞态条件、非原子操作的内存访问等等。所以要以一种线程安全的方式把事件放在队列中,如果写成代码,大致应该是这样的:

复制代码
lock (queue) {
queue.push(event);
}

然后在执行 JavaScript 的主线程中(即 event loop 的 c 代码):

复制代码
while (true) {
// tick 开始
lock (queue) {
var tickEvents = copy(queue);
// 将当前队列中的条目复制的线程自有的内存中
queue.empty(); // .. 清空共享的队列
}
for (var i = 0; i < tickEvents.length; i++) {
InvokeJSFunction(tickEvents[i]);
}
// tick 结束
}

while (true) (在真正的 node 源码中并不是这样的;这里只是为了说明) 表示 event loop。里面的for为队列中的每个事件调用 JS 函数。Event loop 在每个tick中都会调用与外部事件相关联的零个或多个回调函数,一旦队列被清空,并且最后一个函数返回后,tick就结束了。然后回到开始(下一个 tick),重新开始检查其它线程在 JavaScript 运行时加到队列中的事件。

那么这个队列中的东西都是谁放进来的呢?

  • process.nextTick
  • setTimeout/setInterval
  • I/O (来自 fs、net 等)
  • crypto 中的 CPU 密集型函数,比如 crypto streams、pbkdf2 和 PRNG
  • 所有使用 libuv 工作队列异步调用 C/C++ 库的本地模块

当 Event loop 遇到 CPU 密集型任务

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

被阻塞的 event loop

下面给出两段代码,看一下 event loop 被阻塞住时的具体表现。

这段代码中的 event loop 以最快的速度运转,不断地向控制台中输出.

代码清单 1. 快速行进的 event loop

复制代码
(function spinForever () {
process.stdout.write(".");
process.nextTick(spinForever);
})();

然后我们在这段代码中再加上一个计算斐波那契数列的任务。

代码清单 2. 被高 CPU 占用率计算阻塞的 event loop

复制代码
function fibo (n) {
return n > 1 ? fibo(n - 1) + fibo(n - 2) : 1;
}
(function fiboLoop () {
process.stdout.write(fibo(45).toString());
process.nextTick(fiboLoop);
})();
(function spinForever() {
process.stdout.write(".");
process.nextTick(spinForever);
})();

计算斐波那契数列是一个 CPU 密集型的任务,event loop 要在计算结果出来后才有机会进入下一个 tick,执行输出.的简单任务,感觉就像服务器死掉了一样。在我的机器上计算斐波那契数列时,取值45可以明显感觉到程序的停滞,你可以根据自己的 CPU 性能调节该值。

process.nextTick()

在 Node 0.8(及之前)的版本中,process.nextTick()中指定的函数通常会比其它任何 I/O 先被调用,然而并不能保证一定会这样。但很多开发人员(包括 Node.js 的内部团队)开始用process.nextTick实现“稍后再做,但要在任何真正的 I/O 执行之前”。然而在负载比较大时,因为 I/O 很多,可能导致nextTick被别的东西占先,从而引发一些很怪异的错误。所以在 v.0.10 之后,netxtTick的语义被改了,那些函数变成在每次从 C++ 进入 JavaScript 的调用之后马上运行。也就是说,如果你的 JavaScript 代码调用了process.nextTick,只要代码即将运行完成时,在回到 event loop 之前那个回调就会被调用。

然而还有很多程序用递归调用process.nextTick,以免长期运行的任务抢占了 I/O event loop。为了不把这些程序都搞垮,Node 现在会输出一个废弃警告,提示你在这些任务中使用setImmediate。不过对我们这个例子来说,这两个版本之间的差异没有影响。

被闲置的 CPU 内核

最开始,线程只是用于分配单个处理器处理时间的一种机制。但假如操作系统本身支持多个 CPU/ 内核,那么每个线程都可以得到一个不同自己的 CPU/ 内核,实现真正的“并行运算”。在这种情况下,多线程程序可以提高资源使用效率。Node.js 是单线程程序,它只有一个 event loop,也只占用一个 CPU/ 内核。现在大部分服务器都是多 CPU 或多核的,当 Node.js 程序的 event loop 被 CPU 密集型的任务占用,导致有其它任务被阻塞时,却还有 CPU/ 内核处于闲置的状态,造成资源的浪费。

你可以再次运行代码清单 2 中的代码,启动top(或者 Windows 的任务管理器)查看 CPU 的使用情况。我这台 Mac 上是一个双核的 i7 处理器,当 node 的 CPU 占用率在 100% 左右浮动时,系统的 CPU 占用率却只有 28% 左右。

既然 Node.js 程序几乎完全运行在单个 CPU/ 内核上,所以我们需要做些额外的工作才能提升它的扩展性。Node.js 提供了一组管理进程的 API,还允许你给它编写本地扩展,所以有很多种不同的办法可以让程序的代码并行运行。

把 CPU 密集型任务分给子线程

自 Node.js 诞生之日起,就有人质疑它的单线程模型面对协作式多任务时的处理能力。但这个实际上并不是Node.js 产生的新问题,在JavaScript 中由来已久,可以采用 Web Worker 模式应对。因此我们的问题就变成了如何在 Node.js 程序中实现 Web Worker 模式,首先来看一个在 Node.js 中控制进程的 API。

复制代码
child_process.fork()

Node.js 中有管理子进程的 child_process模块,可以用fork()方法创建新的子进程实例。这个子进程是用 IPC 通道添加的,可以通过.send(message)函数发送消息给它,用.on('message')监听它发送的消息。而在子进程中,可以用process.on('message',callback)监听父进程发送的消息,并通过process.send(message)向父进程发送消息。接下来我们fork()一个子进程,把计算斐波那契数列的任务交给它,这需要两个文件。

代码清单 3. 主进程文件 forkParent.js

复制代码
var cp = require('child_process');
var child = cp.fork(__dirname+'/forkChild.js');
child.on('message', function(m) {
process.stdout.write(m.result.toString());
});
(function fiboLoop () {
child.send({v:40});
process.nextTick(fiboLoop);
})();
(function spinForever () {
process.stdout.write(".");
process.nextTick(spinForever);
})();

在主进程中用cp.fork()创建了子进程child,并用child.on('message', callback)监听子进程发来的消息,输出计算结果。现在的fiboLoop()也不再执行具体的计算操作,只是用child.send({v:40});不停的发消息给子进程。

代码清单 4. 计算斐波那契数列的子进程文件 forkChild.js

复制代码
function fibo (n) {
return n > 1 ? fibo(n - 1) + fibo(n - 2) : 1;
}
process.on('message', function(m) {
process.send({ result: fibo(m.v) });
});

子进程文件很简单,还是原来那个计算用的函数,以及一个监听消息的process.on('message',callback),计算结果并用process.send(message, [sendHandle])发送消息给父进程。此外,父进程和子进程两者之间发送消息是同步的,所以两边是有来有往,工作开展地井然有序。运行node forkParent.js,结果跟我们预期的一样,输出.的任务不再受到阻塞,欢快地在屏幕上刷了一大堆.,然后每隔一段输出一个165580141。我们再用top查看一下资源的使用情况,会发现有两个 node 进程,CPU 占用率也增加了很多。

实际上fork()得到的并不是进程,而是一个全新的 Node.js 程序实例。并且每个新实例至少需要 30ms 的启动时间和 10M 内存,也就是说通过fork()繁衍进程,不光是充分利用了 CPU,也需要很多内存,所以不能fork()太多。如果你有兴趣,可以再fork()一个或几个进程,并创建跟这个(些)进程交互的函数,查看下资源占用情况。

cluster

使用 cluster 模块可以充分利用多核 CPU 资源,在 Node.js 的 0.6 版被纳入核心模块,但目前(V0.10.26)仍处于实验状态。借助 cluster 模块,Node.js 程序可以同时在不同的内核上运行多个”工人进程“,每个”工人进程“做的都是相同的事情,并且可以接受来在同一个 TCP/IP 端口的请求。相对于在 Ngnix 或 Apache 后面启动几个 Node.js 程序实例而言,cluster 用起来更加简单便捷。虽然 cluster 模块繁衍线程实际上用的也是child_process.fork,但它对资源的管理要比我们自己直接用child_process.fork管理得更好。下面是用 cluster 实现的代码:

代码清单 5. 用 cluster 繁衍工人进程计算斐波那契数列

复制代码
function fibo (n) {
return n > 1 ? fibo(n - 1) + fibo(n - 2) : 1;
}
var cluster= require('cluster');
if (cluster.isMaster) {
cluster.fork();
} else {
(function fiboLoop () {
process.stdout.write(fibo(40).toString());
process.nextTick(fiboLoop);
})();
}
(function spinForever () {
process.stdout.write(".");
process.nextTick(spinForever);
})();

代码很简单,如果是主进程,就fork()工人进程,这里也可以用循环遍历,根据 CPU 内核的个数繁衍相应数量甚至更多的进程;如果是工人进程,就执行fiboLoop。你可以自行用top查看一下资源占用情况,你会发现这种方式用得资源比上面那种方式要少。

虽然 cluster 模块可以充分利用资源,用起来也比较简单,但它只是解决了负载分配的问题。但其实做得也不是特别好,在 0.10 版本之前,cluster 把负载分配的工作交给了操作系统,然而实践证明,最终负载都落在了两三个进程上,分配并不均衡。所以在 0.12 版中,cluster 改用 round-robin 方式分配负载。详情请参见这里

第三方模块

除了Node.js 官方提供的API,Node.js 社区也为这个问题贡献了几个模块。

比如Mozilla Identity 团队为Persona 开发的 node-compute-cluster 。这个模块可以繁衍和管理完成特定计算的一组进程。你可以设定最大进程数,然后由 node-compute-cluste 根据负载确定进程数量。它还会追踪运行进程的数量,以及工作完成的平均时长等统计信息,方便你分析系统的处理能力。下面是一个简单的例子:

复制代码
const computecluster = require('compute-cluster');
// 分配计算集群
var cc = new computecluster({ module: './worker.js' });
// 并行运行工作
cc.enqueue({ input: 35 }, function (error, result) {
console.log("35:", result);
});
cc.enqueue({ input: 40 }, function (error, result) {
console.log("40:", result);
});

文件 worker.js 中的代码应该响应message事件处理队列中的任务:

复制代码
process.on('message', function(m) {
var output;
var output = fibo(m.input);
process.send(output);
});

还有功能强大的 threads_a_gogo 。参考文献中的第一篇文章介绍了一个拼字游戏解密程序 LetterPwn ,本文在很大程度上是受这篇文章的启发而写的,其中就是用 threads_a_gogo 管理 CPU 密集型计算线程的。由于篇幅所限,就不再展开介绍了。不过最后我们用 threads_a_gogo 线程池的例子作为结尾:

复制代码
function fibo (n) {
return n > 1 ? fibo(n - 1) + fibo(n - 2) : 1;
}
var numThreads= 10;
var threadPool= require('threads_a_gogo').createPool(numThreads).all.eval(fibo);
threadPool.all.eval('fibo(40)', function cb (err, data) {
process.stdout.write(" ["+ this.id+ "]"+ data);
this.eval('fibo(40)', cb);
});
(function spinForever () {
process.stdout.write(".");
process.nextTick(spinForever);
})();

参考文献

  1. Why you should use Node.js for CPU-bound tasks ,Neil Kandalgaonkar,2013.4.30;
  2. TAGG 项目文档
  3. Understanding process.nextTick() ,Kishore Nallan,2013.5.13
  4. Node v0.10.0 changes from 0.8:FASTER PROCESS.NEXTTICK
  5. What exactly is a Node.js event loop tick? ,StackOverflow,2013.11.6
  6. Fully Loaded Node – A Node.JS Holiday Season, part 2 ,Lloyd Hilaiel,2012.11.20
  7. 本文源码

感谢水羽哲对本文的审校。

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

2014-03-07 04:4116693
用户头像

发布了 45 篇内容, 共 25.1 次阅读, 收获喜欢 11 次。

关注

评论

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

2022 世界人工智能大会|人工智能与开源技术先锋论坛即将开幕

Kyligence

开源技术 人工智能大会

参加大数据培训机构学习前景怎么样

小谷哥

另眼旁观 Linkerd 2.12 的发布:服务网格标准的曙光?

张晓辉

云原生 kuberne Linkerd 服务网格

研发管理 DevOps 最佳实践之三问三答

极狐GitLab

DevOps gitlab CI/CD 代码规范 gitops

leetcode 227. Basic Calculator II 基本计算器 II(中等)

okokabcd

LeetCode 算法与数据结构

项目经理和ScrumMaster可以是同一个人吗?

ShineScrum

Scrum 敏捷 ScrumMaster 项目经理

NFT平台开发:NFT数字馆藏平台开发

开源直播系统源码

数字藏品软件开发 数字藏品开发 数字藏品系统

如何有效改进回顾会议(下)?

敏捷开发

Scrum 回顾会 Scrum团队

分分钟带你了解 ES2022 最重要的 4 个特性!

掘金安东尼

前端 8月月更 ES2022

10大常用的排序算法(算法分析+动图演示)

Five

算法 排序算法 8月月更

【小程序】view视图,swiper轮播图,scroll-view滑动列表 (在线详细手册)

计算机魔术师

8月月更

为什么MatrixOne 0.5变慢了

MatrixOrigin

矩阵起源 MatirxOrigin MatirxOne 因子化

详解 Sqllogictest

Databend

大数据 databend Sqllogictest

SpringCloud 注册中心 (Eureka) 快速入门

微服务 Eureka SpringCould 8月月更

Tapdata 获得阿里云首批产品生态集成认证,携手阿里云共建新合作

阿里巴巴云原生

阿里云 Serverless 云原生 SAE 合作

web前端培训学习应该注意什么

小谷哥

SpringCloud Eureka参数配置项详解

echoes

ClickHouse与Elasticsearch压测实践

京东科技开发者

elasticsearch 分布式 数据分析 Clickhouse 数据库·

设计模式的艺术 第十章桥接设计模式练习(设计一个数据转换工具,可以将数据库中的数据转换成多种文件格式,例如txt、xml、pdf等格式,同时该工具需要支持多种不同的数据库)

代廉洁

设计模式的艺术

【小程序项目开发-- 京东商城】uni-app开发之配置 tabBar & 窗口样式

计算机魔术师

8月月更

深圳选择java培训机构哪家靠谱?

小谷哥

一文读懂数据科学Notebook

Baihai IDP

人工智能 ide AI notebook 数据科学

建成 5000 多间「梦想中心」后,他们决定将技术开源

腾源会

开源 公益 腾源会

蓝牙5.0简介、nRF52832 BLE样例工程框架及main函数初始化流程简析

矜辰所致

蓝牙 启动流程 8月月更 nRF52832

无需编写一行代码,实现任何方法的流量防护能力

阿里巴巴云原生

阿里云 微服务 云原生 流量

【小程序项目开发 --- 京东商城】 启航篇之uni-app项目搭建

计算机魔术师

8月月更

你还有什么问题吗?

AlwaysBeta

程序员 面试

选择web前端培训机构需要注意什么?

小谷哥

如何快速地学习东西(下篇)

宇宙之一粟

学习方法 8月月更

大厂裁员小厂跑路,是时候做这件事了,否则到时可别后悔!!!

CRMEB

java程序员培训学习需要多长时间?

小谷哥

Node.js软肋之CPU密集型任务_架构/框架_吴海星_InfoQ精选文章