node的CPU密集型之道

希望阅读本文,能让你在学习 child_process 和 cluster 模块使用方法的同时,对 node 应对 CPU 密集模型的处理姿态有一定的思考。

从node诞生以来,围绕它的颠覆性创造就从没停歇过。node提供了一个服务端的 JavaScript 运行环境,它不仅成为了前端开发者进军服务端的有力武器,其事件驱动(event-driven)和无阻塞(non-blocking)特性,也在某些 I/O 密集型的业务场景下(如限时抢购)体现出巨大的优势,不少后端工作者在吐槽排斥 JS 的同时,也不得不选择 node 来优化他们的业务体验。

node的诟病

但正所谓爱恨交加,node 饱受诟病的软肋—— CPU 密集型——也一直让许多开发者望而却步。在 Java 中,我们可以简单地利用多线程来解决这个问题。但是在 node 中,事件环机制保证了一切的任务总是按照事件队列的顺序执行,当某一个高CPU占用率的任务执行迟迟未完成时,后续队列中的监听回调、延时、nextTick()中的函数都因被阻塞而无法执行,造成了极严重的延迟。变慢还能忍,由于 node 把所有的请求都置于唯一进程中,当某一个请求抛出错误时,将有可能导致整个服务直接瘫痪。

我们都知道,node 是单线程的,但其实严格来说,node 的单线程只是针对开发层来讲,是由 JavaScript 的单线程特性决定的,而 node 在底层(libuv)其实是多线程的。node 采取了事件环机制,快速轮转的 event loop 不断地接受来自外部的请求,并把需要长期运行的 I/O 或本地 API 调用交给底层内部线程池处理,完成后经回调函数返回给主线程,这个设计,在 fs 、zlib 等针对 CPU 高占用率操作的模块中可见一斑。因此,除非涉及 c++ 模块开发,否则底层一般是对开发者屏蔽的,我们所见到的只有 node 的主线程。

基于此,在普通的开发任务里, 在 node 中使用多线程是很困难的,并且尽管 node 已经将文件操作、压缩处理等 CPU 密集型操作通过 libuv 有效处理,但在我们的常规业务中,始终无法规避遇到其他类型的 CPU 密集模型,譬如加密、解密之类的复杂运算。令人欣喜的是,考虑到多线程开发的复杂性,node 也针对单线程在服务端开发中的局限性,提供了 child_process 模块和 cluster 模块,作为在 node 程序中开启多进程的接口。

这里有人可能要被进程和线程这两个概念搞晕了,笔者就在这里简单阐述一下。一般来说,一个应用程序至少有一个进程,一个进程至少有一个线程,线程是进程的更具体划分单位。历史上,服务模型的变迁应该是先经历了多进程阶段,后才引入多线程的,两者在应对高并发问题上拥有不同的优劣势。而 node 提供给开发者解决 CPU 密集处理的手段采取的是多进程策略,是通过复制进程来分担程序运行压力的,这一点需要注意。

node的多进程姿态

接下来的实例操作代码,在没说明的情况下,默认是写在一个名为 test 的 js 文件里启动执行的,大家可以跟着我的演示一起操作一遍。

进程对象 process

要了解 node 的进程,首先必不可少的就是要先了解 node 中的进程对象—— process。与 Java 通过 Runable 接口和 Thread 类创建多线程的方式不同,node 的多进程是基于模块文件的,每个文件对应了一个被创建的进程。process 进程对象作为一个全局对象,独立存在于每个进程中,包含着关于调用者和运行环境的各种信息。这里就列举几个重要的属性、方法和事件。

1、属性

  • process.stdin
    用于读入标准输入流的对象,拥有 data 、end 等各种 emitter 事件,以及 pause 、resume 等用于读取数据的方法。

  • process.stdout
    用于写入标准输出流的对象,拥有 pipe、error 等各种 emitter 事件,以及 write 等用于写出数据的方法。

  • process.stderr
    用于写入标准错误输出流的对象,拥有 pipe、error 等各种 emitter 事件,以及 write 等用于写出数据的方法。

/**
 * process.stdin 监听标准流输入,并在接收到数据之后,利用 process.stdout 输出
 * 默认情况下,标准输入流处于暂停状态,因此需要使用 process.stdin.resume() 将其恢复。
 */
process.stdin.resume();
process.stdin.on('data', function(chunk){
    process.stdout.write('进程接收到数据:'+chunk) 
})
  • argv
    一个包含了运行该 node 应用程序时所有的命令行参数的数组。值得注意的是,数组的第一项是“node”,第二项是运行的脚本文件名,第三项之后,才是传入的命令行参数。
/**
 * git bash 输入 "node test.js hello mangee"
 */
console.log(process.argv);    // ['node', 'D:\code\test.js', 'hello', 'mangee']
  • pid
    process id,作为每一个进程的唯一标识使用。

2、方法

  • nextTick()
    nextTick 方法是 node 异步编程里一个重要角色,它用于将一个函数推迟到同步代码执行完毕后调用,作用类似于 setTimeout( callback, 0 ),但在与 setTimeout 同时存在时,执行顺序优于 setTimeout。
setTimeout(function(){
    console.log('setTimeout');
}, 0);
process.nextTick(function(){
    console.log('nextTick');
});
console.log('synchronous code');

// synchronous code
// nextTick
// setTimeout
  • exit()
    exit 方法用于退出运行 node 应用程序的进程,使用一个参数,用于指定为操作系统提供退出代码,为 0 时表示正常退出。

  • kill()
    一开始看到这个方法以为是主进程关闭子进程的手段,结果事实上它用于向一个进程发送信号,使用方式如下。

process.kill(pid, [signal])

kill 方法接受两个参数,pid 参数为目标进程的 pid,signal 参数可选,为一个字符串,指定需要发送的信号,默认为“SIGTERM”(表示中止该进程)。

3、事件

  • exit 事件
    当进程调用了 process.exit() 时触发,可以通过指定事件回调函数来指定进程退出时所需执行的处理。
process.on('exit', function(){
    console.log('进程退出');
});
process.exit();
  • close 事件
    close 事件与 exit 事件相似,都是子进程退出时触发,不同的是,close 事件要求子进程的所有标准输入/输出都终止,而 exit 事件只需要子进程退出就能触发,这是由于多个子进程可以共享一个输入/输出,因此当进程退出时,输入/输出未必终止。
child_process 模块

在 node 应用程序中,child_process 模块开启多个子进程来执行 node 模块文件和可执行文件。执行开启的进程称为主进程,被开启的进程称为子进程,主进程与子进程之间的通信,或由标准输入输出流(即 process.stdin 和 process.stdout )完成,或由专用的 IPC 通道完成。child_process 提供了4个用于创建子进程的方法。

1、spawn()
spawn 方法通过执行 node 程序启动命令来创建一个子进程,并返回了该子进程的句柄。使用方式如下。

child_process.spawn(command, [args], [options])

spawn 方法接受三个参数:command 参数指定需要运行的命令;args 参数为一个数组,包含了执行命令时传入的参数,可在子进程中利用 process.argv 获取到;option 是一个配置对象,存放着关于创建子进程的配置信息,我将一些重要的配置属性列举成表,如下。

属性 类型 作用
cwd 字符串 指定子进程的当前工作目录
stdio 字符串/三元数组 设置子进程的标准输入/输出,三个元素分别指定子进程的 stdin 、stdout 、stderr
detached 布尔值 为 true 时,若父进程退出,子进程依然可以独立存在
uid 数值 设置子进程的用户标识
gid 数值 设置子进程的组标识

其中,stdio 属性的可选值比较多,挑选其中常用的几个,我又重新列了张简化的子表,完整的 stdio 配置规则十分复杂,有意深入学习的同学可以查阅官方文档。

意义
‘pipe’ 在父进程与子进程之间创建一个管道,使得父进程可以通过子进程句柄的 stdio 属性(三元数组)获取到子进程的 stdin 、stdout 、stderr
'ipc' 在父进程与子进程之间创建一个 IPC 通道,以此来启用进程通信的 IPC 模式

来一个实例说明一下用法。

/**
 * 文件结构
 * test.js
 * compute
 * —— fibonacci.js
 */

...
// test.js 文件
var cp = require('child_process');

var sp1 = cp.spawn('node', ['fibonacci.js', 40], { cwd: './compute' });
sp1.stdout.on('data', function(chunk){
    console.log('子进程输出:'+chunk);
});
sp1.on('exit', function(chunk){
    console.log('子进程退出!');
});
console.log('主进程不阻塞');
...

...
// fibonacci.js 文件
var result = (function fibonacci(n){
    return n>1? fibonacci(n-1) + fibonacci(n-2) : 1;
})(process.argv.pop());

process.stdout.write(result.toString());
process.exit();
...

// 主进程不阻塞
// 子进程输出:165580141
// 子进程退出!

在这个例子中,主进程利用 spawn 方法创建一个子进程,把一个 Fibonacci 数列运算交付给子进程去计算,并在计算完成之后通过回调返回结果。主进程与子进程的通信是通过标准输入/输出实现的,这也是默认的通信方式。

当然,我们也可以通过 spawn 方法的第三个参数——子进程配置对象——来启用 IPC 通信,基于 node 实现的 Eletron 框架使用的进程通信就是这类模式。

/**
 * 文件结构
 * test.js
 * compute
 * —— fibonacci.js
 */

...
// test.js 文件
var cp = require('child_process');

// change1
var sp1 = cp.spawn('node', ['fibonacci.js', 40], { cwd: './compute', stdio: ['ipc', 'pipe', 'ignore'] });
// change2
sp1.on('message', function(msg){
    console.log('子进程输出:'+msg);
})
sp1.on('exit', function(chunk){
    console.log('子进程退出!');
});
console.log('主进程不阻塞');
...

...
// fibonacci.js 文件
var result = (function fibonacci(n){
    return n>1? fibonacci(n-1) + fibonacci(n-2) : 1;
})(process.argv.pop());

// change3
process.send(result);
process.exit();
...

// 主进程不阻塞
// 子进程输出:165580141
// 子进程退出!

对比标准输入/输出模式的通信,个人还是比较喜欢风格清爽的 IPC 通道模式,便于阅读。

2、fork()
同样,利用 fork 方法也可以创建一个子进程,但与 spawn 方法不同的是,fork 方法默认使用 IPC 通道,因此,想使用 IPC 通信方式但又苦于 spawn 方法的复杂配置的时候,fork 方法就是个绝佳的选择,其使用方式如下。

child_process.fork(modulePath, [args], [options])

与 spawn 方法大同小异,fork 方法接受三个参数,modulePath 参数指定需要运行的 node 模块文件路径及文件名,args 参数是一个数组,指定了运行时要传入的参数,options 是关于子进程的配置对象,这个配置对象就比较简单了,稍微列个表吧。

属性 类型 作用
cwd 字符串 指定子进程的当前工作目录
encoding 字符串 指定 stdin 、stdout 、stderr 的编码格式
silent 布尔值 为 true 时,子进程与主进程不共享标准输入/输出

fork 的用法与启用了 IPC 通道的 spawn 方法很像,只需改动一行代码,如下。

var sp1 = cp.spawn('node', ['fibonacci.js', 40], { cwd: './compute', stdio: ['ipc', 'pipe', 'ignore'] });

改为:

var sp1 = cp.fork('fibonacci.js', [40], { cwd: './compute' });

运行结果不变。

更加有趣的是,子进程对象的 send 方法拥有第二个参数,可选,可指定为一个接收到对方回发消息后执行的回调函数,或者诸如服务器或 socket 之类的任何对象。我们可以利用这一点实现一些好玩的事情,比如当传入一个服务器对象时,可以使得子进程与主进程共享该服务器对象,于是当该服务器接收到客户端请求时,将会自动分配给一个当前处于空闲状态的进程,来处理相应的请求回调,听起来其实有些像 Apache 的网络编程模型。

3、exec()
exec 方法与 spawn 更加相似,利用它可以开启一个用于运行某个命令的子进程并缓存子进程中的输出结果。使用方式如下。

child_process.exec(conmand, [options], [callback])

其中,command 参数指定需要运行的命令,options 参数为开启子进程的配置对象,callback 参数为子进程终止时调用的回调函数。关于 options 的配置信息,老规矩,列个表。

属性 类型 作用
cwd 字符串 指定子进程的当前工作目录
encoding 字符串 指定 stdin 、stdout 、stderr 的编码格式
timeout 整数值 指定子进程超时时间,单位毫秒,超时则强制关闭
killSignal 字符串 指定关闭子进程的信号,默认为"SIGTERM"

exec 方法和 spawn 方法最大的区别是,spawn 方法可以在主进程中实时接收子进程输出的流数据,是一个异步方法,而使用 exec 方法时,主进程必须等待子进程所有流数据缓存完毕才能接收,因此是一个同步方法。

4、execFile()
execFile 方法效果与 exec 方法基本一致,看来看去都觉得其实就是 exec 的变形,使用方法如下。

child_process.execFile(file, [args], [options], [callback])

参数 file 指定将要执行的 node 模块文件,args 参数为执行时传入的参数,options 与 callback 的解读则跟 exec 中的完全相同,这里不多赘述。

cluster 模块

理论上来说,child_process 模块已经完成了 node 在多进程协同方面的所有工作,但从上边的演示可以看出,主进程和子进程处于不同的文件,这对于业务逻辑驱动的项目来讲,有时候不免造成代码冗余和逻辑不明的问题。基于此,node 为我们提供了另外一种清爽的写法——cluster 模块。

1、fork()

cluster 模块提供了一个与 spawn.fork 同名的 fork 方法,同样是用于开启一个子进程,只不过所执行的文件不是其他文件,而是默认为当前运行的 node 程序主模块文件。并且 cluster 模块分别为你提供了一个 isMAster 属性和 isWorker 属性,来判断程序是运行在主进程中还是子进程中,于是你可以大胆地把主进程和子进程的代码写在一个 node 模块文件里了。

当然,你完全可以自己用 child_process 模块实现 cluster 模块的写法,只不过 node 帮我们做了而已。

var cluster = require('cluster');

if(cluster.isMaster){
    cluster.fork();
    console.log('我是从主进程发出来的');

    cluster.on('fork', function(worker){
        console.log('子进程%d被开启', worker.id);
    });
    cluster.on('online', function(worker){
        console.log('已接收到来自子进程%d的反馈信息', worker.id);
    });
}else{
    console.log('我是从子进程中发出来的');
}

// 我是从主进程发出来的
// 子进程1被开启
// 已接收到来自子进程1的反馈信息
// 我是从子进程中发出来的

实例中,程序运行时先判断是否位于主进程中,如果位于主进程中,则 fork 一个新的子进程。其中用到了两个事件,fork 事件触发于主进程尝试调用 fork 时,而 online 事件触发于子进程的应用程序运行后向主进程送达一个反馈消息时,因此先触发 fork 事件再触发 online 事件。而回调函数传入的 worker 对象,就是创建的子进程对象的句柄。

2、setupMaster()
fork 方法默认执行应用程序所在的 node 模块文件来开启新的子进程,然而 cluster 也提供了一个 setupMaster 方法来设置所要执行的目的文件。用法如下。

cluster.setupMaster([settings])

setupMaster 方法使用一个可选参数 settings,用于设置子进程的 node 应用程序的各种默认行为,列表如下。

属性 类型 作用
exec 字符串 子进程中运行文件的完整路径及文件名
args 数组 指定启动子进程应用程序时所需的各项参数
silent 布尔值 为 true 时,子进程与主进程不共享标准输入/输出

通过这个方法,你可以把 cluster 模块转换成 child_process 模块来使用,虽然显得有点折腾。

3、send() 和 message 事件
使用 cluster 模块,默认开启 IPC 通道模式,因此,你可以在主进程和子进程中使用 send 方法来发送消息:

// 在主进程中向子进程发送消息
worker.send(message, [sendHandle])
// 在子进程中向主进程发送消息
process.send(message, [sendHandle])

同时,你也可以使用 message 事件来接收消息:

// 在主进程中向子进程发送消息
worker.on('message', function(msg, setHandle){
   // some code
})
// 在子进程中向主进程发送消息
process.on('message', function(msg, setHandle){
   // some code
})

cluster 模块中的 send 方法和 message 事件与 child_process 模块中的完全相同,这里不做赘述。

4、disconnect() 和 disconnect 事件
使用子进程对象的 disconnect 方法,可以使该子进程不再接收外部连接。当子进程旧有的所有连接都断开时,自动关闭主进程与该子进程之间的 IPC 通道。此时将触发子进程对象的 disconnect 事件,如实例。

var cluster = require('cluster');

if(cluster.isMaster){
    var worker = cluster.fork();
    console.log('我是从主进程中发出来的');

    worker.on('disconnect', function(){
        console.log('子进程IPC通道%d被关闭', worker.id);
    });

    setTimeout(function(){
        worker.disconnect();
    }, 1000)
}else{
    console.log('我是从子进程中发出来的');
}

// 我是从主进程中发出来的
// 我是从子进程中发出来的
// 子进程IPC通道1被关闭

实例中,主进程创建了一个子进程,并在1秒钟后调用 disconnect 方法关闭 IPC 通道,触发 disconnect 事件。

好不好得试试才知道

说了这么多,那么 node 对多进程的实现,究竟在应对 CPU 密集业务时发挥了多大的作用呢,我们通过斐波那契数列运算来模拟一段,当然,实验手段极其粗糙,不能完全说明问题,只能依靠结果大致看看多进程实现能否改善 node 面对 CPU 密集业务的窘态。
我通过模拟同时间内主进程接收到多个请求,并执行了一个计算量极大的任务,获取从开始执行到完成计算的时间差,来看看多进程实践的优势到底如何。

/**
 * 单线程状态执行的结果
 */
function fibonacci(n){
    return n>1? fibonacci(n-1) + fibonacci(n-2) : 1;
}

var result=[], start = Date.now();

for(var i=0; i<10; i++){
    result.push(fibonacci(40));
}
console.log(Date.now()-start);

// 输出结果: 23598
/**
 * 多进程实践的结果
 */
var cluster = require('cluster');
var result=[], start = Date.now();

if(cluster.isMaster){    
    for(var i=0; i<50; i++){
        cluster.fork()
        .on('message', function(msg){
            result.push(msg);
            if(result.length==10){
                console.log(Date.now()-start);
            }
        });
    }
}else{
    process.send(fibonacci(40));
}

function fibonacci(n){
    return n>1? fibonacci(n-1) + fibonacci(n-2) : 1;
}

// 输出结果: 11627

结果虽然不精准,但不难看出,多进程实践的效果还是相当给力的。多进程的时间开销主要来自于进程创建和销毁的开销,而针对这个问题,也有人提供了另外一种解决方案,通过引入 tagg 库,使得 node 支持多线程编程,测评效果听说很不错,有兴趣的话大家也可以尝试一下。

最后,本文产自新手,能力有限,多有错漏,烦请赐教。

你可能感兴趣的:(node的CPU密集型之道)