发布在即!企业 AIGC 应用程度测评,3 步定制专属评估报告。抢首批测评权益>>> 了解详情
写点什么

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:4116318
用户头像

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

关注

评论

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

作业

云随心

产品训练营作业

玩家永远是对的——认知失调

Justin

心理学 28天写作 游戏设计

滚动加载的网页只需点 10 下鼠标即可抓取,无编码学爬虫之四

梦想橡皮擦

Python 28天写作 3月日更

ICDAR2021首届文档图像与自然语言处理研讨会征稿开始

爱极客侠

极客时间购买课程的流程图

踏凌霄

产品更新 | 阿里云CDN边缘图像处理功能开放内测

阿里云Edge Plus

CDN 边缘计算 图像处理

第五周

Jove

在云中应用自动化的5种方法

浪潮云

云计算

抽奖助手--抽奖用例流程图

小蜜蜂

0 期产品训练营第五周作业 - 学情周报活动图

skylar

第五六周心得

Trigger

极客时间 产品经理训练营

To B产品经理需要哪些能力呢?

博文视点Broadview

产品经理训练营第四章作业(二)

猫。

第五周作业

z

架构学习20210302日(001)

张小胖

产品文档总结

mas

数仓GaussDB(DWS)全量备份总结

华为云开发者联盟

数据安全 GaussDB 备份 Roach 数据备份

MySQL异常问题经验贴

华为云开发者联盟

MySQL 数据库 时区 SSL 连接

产品经理训练营第四章作业(二)

新盛

抽奖助手小程序 发起抽奖流程图

Shine

产品

3月份开始了

Nydia

Elasticsearch search scroll 游标查询

escray

elastic 七日更 28天写作 死磕Elasticsearch 60天通过Elastic认证考试 3月日更

来自农村的大学生开发者,用技术改变自己的家乡

华为云开发者联盟

开发者 物联网 IoT 华为云 智慧大棚

关于数码产品

ES_her0

28天写作 3月日更

第5周作业

KYoKO

挑一个产品,想一个你想做的新功能,然后写个相对完整的文档

mas

算法刷题提醒——微信小程序 [持续优化ing]

小匚

深度思考 随笔杂谈 成长与思考

「产品经理训练营」作业 05

🌟

产品 产品经理 产品经理训练营 产品训练营 产品训练营作业

时间管理这件事儿「Day 11」

道伟

28天写作

设计模式【3.1】-- 浅谈代理模式之静态、动态、cglib代理

秦怀杂货店

动态代理 代理模式

一文帮你搞懂 Android 文件描述符

vivo互联网技术

android 文件 文件系统

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