Node-玩转进程

JavaScript运行在单个进程的单个线程上,它带来的好处是:程序的状态是单一的,在没有多线程的情况下没有锁、线程同步的问题,操作系统在调度时也因为较少上下文的切换,可以很好地提高CPU的使用率,但是单进程单线程并非完美的结构,如何充分利用多核CPU服务器是Node需要面对的问题,另一个问题则是一旦单线程上抛出的异常没有被捕获,将会引起整个进程的崩溃,这就是Node的实际应用需要面对的第二个问题,如何保证进程的健壮性和稳定性。

从严格意义上而言,Node并非真正的单线程架构,Node自身还有一定的I/O线程存在,这些I/O线程由底层libuv处理,这部分线程对于JavaScript开发者而言是透明的,只在C++扩展开发时才会关注到。JavaScript代码永远运行在V8上,是单线程的。

多进程架构

面对单进程单线程对多核利用不足的问题,前人的经验是启动多进程即可,理想状态下每个进程各自利用一个CPU,以此实现多核CPU的利用,Node提供了child_process模块,并且提供了chind_process.fork()函数供我们实现进程的复制。

// worker.js
var http = require('http'); http.createServer(function (req, res) {
  res.writeHead(200, {'Content-Type': 'text/plain'});
  res.end('Hello World');
}).listen(Math.round((1 + Math.random()) * 1000), '127.0.0.1');

// master.js
// 根据cpu数量复制对应的Node进程
var fork = require('child_process').fork; 
var cpus = require('os').cpus();
for (var i = 0; i < cpus.length; i++) {
  fork('./worker.js'); 
}

可以使用ps aux | grep worker.js查看进程的数量。

这就是Master-Worker模式,又称主从模式,分为主进程和工作进程,是典型的分布式架构中用于并行处理业务的模式,具有较好的可伸缩性和稳定性。主进程不负责具体的业务处理,而是负责调度或管理工作进程,它是趋向于稳定的。工作进程负责具体的业务处理,因为业务的多种多样,甚至一项业务由多人开发完成,所以工作进程的稳定性值得开发者关注。

通过fork()复制的进程都是一个独立的进程,这个进程中有着独立而全新的V8实例,它需要至少30毫秒的启动时间和至少10MB的内存,但是fork()的代价还是昂贵的,这里使用fork进程的方式可以将CPU充分的利用起来。

创建子进程

child_process有4中方式可以创建子进程:

  • spawn():启动一个子进程里执行命令。

  • exec():与spawn方法一样,但是有一个回调函数获知子进程的状况。

  • execfile():启动一个子进程来执行可执行文件

  • fork():与spawn()类似,不同点在于它创建Node的子进程只需指定要执行的JavaScript文件模块即可。

Node-玩转进程_第1张图片
process.png

如果是JavaScript文件通过execfile()运行,它的首行内容必须添加如下代码

#!/usr/bin/env node

进程间通信

进程之间通过send()方法发送数据、message事件接收数据。

// parent.js
var cp = require('child_process');
var n = cp.fork(__dirname + '/sub.js');

n.on('message', function (m) {
  console.log('PARENT got message:', m);
});

n.send({hello: 'world'});

// sub.js

process.on('message', function (m) {
  console.log('CHILD got message:', m);
});

process.send({foo: 'bar'});

为了实现父子进程之间的通信,父进程与子进程之间将会创建IPC通道,通过IPC通道,父子进程之间才能通过message和send传递消息。

IPC的全程是Inter-Process Communication,即进程间通信,Node中实现IPC通道的是管道技术,在Node中管道是个抽象层面的称呼,具体细节实现由libuv提供,在Windows下由命名管道(named pipe)实现,*nux系统则采用Unix Domain Socket实现,表现在应用层上进程间通信只有简单的message事件和send()方法。

父进程在实际创建子进程之前,会创建IPC通道并监听它,然后才真正创建出子进程,并通过环境变量(NODE_CHANNEL_FD)告知子进程这个IPC通道的文件描述符。子进程在启动的过程中,根据文件描述符去连接这个已存在的IPC通道,从而完成父子进程之间的连接。IPC通道属于双向通信,但是是在系统内核中完成了进程间的通信,而不是经过实际的网络层,非常高效。

{{% notice tip %}}
只有启动的子进程是Node进程时,子进程才会根据环境变量去连接IPC通道,对于其他类型的子进程无法实现进程间通信,除非其他进程也按约定去连接这个已经创建好的IPC通道
{{% /notice %}}

句柄传递

之前我们根据CPU的数量fork对应数量的子进程的时候,我们对子进程监听的端口采用随机1000-2000之间的方法,是因为多个进程无法监听同一个端口,会出现端口被占用的报错,要解决这个问题,通常的做法是让每个进程监听不同的端口,其中主进程监听主端口,主进程对外接收所有网络请求,再将这些请求分别代理到不同的端口的进程上。通代理避免了端口不能重复监听的问题,甚至可以在代理进程上做适当的负载均衡。

由于进程每接收到一个连接,将会用掉一个文件描述符,因此代理方案中客户端连接到代理进程,代理进程连接到工作进程的过程需要用掉两个文件描述符,操作系统的文件描述符是有限的,代理方案会浪费一倍数量的文件描述符,为了解决这个问题,Node引入了进程间发送句柄的功能,send()方法除了能通过IPC发送数据外,还能发送句柄,第二个可选参数就是句柄:child.send(message, [sendHandle])

句柄是一种可以用来标识资源的引用,它的内部包含了指向对象的文件描述符,比如句柄可以用来标识一个服务器端的socket对象、一个客户端的socket对象、一个UDP套接字、一个管道等。

发送句柄意味着我们可以去掉代理这种方案,使主进程接收到socket请求后,将这个socket直接发送给工作进程,而不是重新与工作进程之间建立新的socket连接来转发数据,这样就可以解决文件描述符浪费的问题。

// master.js
var n = require('child_process').fork('child.js');

var server = require('net').createServer();
server.on('connection', function (socket) {
  socket.end('handled by parent\n');
});

server.listen(1337, function () {
  n.send('server', server);
});

// child.js
process.on('message', function (m, server) {
  if (m === 'server') {
    // 接收到子进程传递的句柄 并与父进程一样监听connection事件
    server.on('connection', function (socket) {
      socket.end('handled by child\n');
    });
  }
});

响应的结果也是很不可思议的,这里的子进程和父进程都有可能处理我们客户端发起的请求,这是在TCP层面上完成的事情,我们可以将其转化到HTTP层面,而且可以让主进程将句柄发送给子进程之后就关闭掉服务器的监听,只让子进程处理请求,让主进程更轻量。

// parent.js
var cp = require('child_process');
var child1 = cp.fork('child.js');
var child2 = cp.fork('child.js');

var server = require('net').createServer();
server.listen(1337, function () {
  child1.send('server', server);
  child2.send('server', server);
  // 关掉
  server.close();
});

// child.js
var http = require('http');
var server = http.createServer(function (req, res) {
  res.writeHead(200, {'Content-Type': 'text/plain'});
  res.end('handled by child, pid is ' + process.pid + '\n');
});

process.on('message', function (m, tcp) {
  if (m === 'server') {
    tcp.on('connection', function (socket) {
      // 触发子进程http服务的connection事件
      server.emit('connection', socket);
    });
  }
});

句柄的发送和还原

句柄的传递是否真的将服务器对象发送给了子进程,这里就涉及到句柄的发送和还原。目前send()方法可以发送的句柄包括如下几种:

  • net.Socket: TCP套接字

  • net.Server: TCP服务器,任意建立在TCP服务上的应用层服务都可以享受到它带来的好处

  • net.Native: C++层面的TCP套接字或IPC管道

  • dgram.Socket: UDP套接字

  • dgram.Native: C++层面的UDP套接字

send()方法在将消息发送到IPC管道前,将消息组装成两个对象,一个参数是handle,另一个参数就是message,message参数如下:

{
  cmd: 'NODE_HANDLE',
  type: 'net.Server',
  msg: message
}

发送到IPC管道中的实际上是我们要发送的句柄文件描述符,文件描述符实际上一个整数值,这个message对象在写入IPC管道使会通过JSON.stringify()进行序列化,所以最终发送到IPC通道中的信息都是字符串,连接了IPC通道的子进程可以读取到父进程发来的消息,将字符串通过JSON.parse()解析还原为对象后,才触发message事件将消息体传递给应用层使用,在这个过程中,还要进行过滤处理,message.cmd的值如果以NODE_为前缀,它将响应一个内部事件internalMessage,如果message.cmd值为NODE_HANDLE,它将取出message.type值和得到的文件描述符一起还原出一个对应的对象。所以Node进程之间只有消息的传递,不会真正的传递对象。

端口共同监听

我们独立启动的进程中,TCP服务器端的socket套接字的文件描述符并不相同,导致监听到相同的端口时会抛出异常,由于独立启动的进程互相之间并不知道文件描述符,所以监听相同端口会失败,但对于send()发送的句柄还原出来的服务而言,它们的文件描述符是相同的,所以监听相同端口不会引起异常。多个应用监听相同端口时,文件描述符同一时间只能被某个进程使用,换言之网络请求向服务器端发送时,只有一个幸运的进程能够抢到连接,也就是说只有它能为这个请求进行服务,这些进程服务是抢占式的。

集群稳定之路

进程事件

子进程除了message事件外,还存在如下事件:

  • error: 当子进程无法被复制创建、无法被杀死、无法发送消息时会触发该事件。

  • exit: 子进程退出时触发该事件,如果是正常退出,第一个参数为退出码,否则为null,如果是被kill()方法杀死的,会得到第二个参数,表示杀死进程时的信号。

  • close: 在子进程的标出输入输出流种植时触发,参数与exit相同

  • disconnect:在父进程或子进程中调用disconnect()方法时触发,调用该方法将关闭监听IPC通道。

除了send()外,还能通过kill()方法给子进程发送消息,kill()方法并不能真正地将通过IPC相连的子进程杀死,它只是给子进程发送了一个系统信号,默认情况下这个信号是SIGTERM。

// 发送给子进程
child.kill(signal)

// 发送给目标进程
process.kill(pid, signal)

可使用kill -l命令行看到详细的信号列表,其中每个信号都有对于的数值,例如代表SIGKILL的是9,可以使用kill -9 pid杀死进程。

这些信号是用来通知进程的,每个信号事件有不同的含义,每个进程都可以监听这些信号事件,在收到信号时,应当做出约定的行为,如SIGTERM是软件终止信号,进程收到该信号时应当退出。

自动重启

我们可以通过监听子进程的exit事件来获知其退出的信息,此时就可以重新启动一个进程来继续服务

// master.js
var fork = require('child_process').fork;
var cpus = require('os').cpus();

var server = require('net').createServer();
server.listen(1337);

var workers = {};
var createWorker = function () {
  var worker = fork(__dirname + '/worker.js');

  // 退出时重新启动新的进程
  worker.on('exit', function () {
    console.log('Worker ' + worker.pid + ' exited.');
    delete workers[worker.pid];
    createWorker();
  });

  // 句柄转发
  worker.send('server', server);
  workers[worker.pid] = worker;
  console.log('Create worker. pid: ' + worker.pid);
};

for (var i = 0; i < cpus.length; i++) {
  createWorker();
}

// 进程自己退出时,让所有工作进程退出。
process.on('exit', function () {
  for (var pid in workers) {
    workers[pid].kill();
  }
});

在实际业务中,可能有隐藏的bug导致工作进程退出,那么我们需要仔细处理这种异常。

// worker.js
var http = require('http');
var server = http.createServer(function (req, res) {
  res.writeHead(200, {'Content-Type': 'text/plain'});
  res.end('handled by child, pid is ' + process.pid + '\n');
});

var worker;
process.on('message', function (m, tcp) {
  if (m === 'server') {
    worker = tcp;
    worker.on('connection', function (socket) {
      server.emit('connection', socket);
    });
  }
});

process.on('uncaughtException', function () {
  // 停止接收新的连接
  worker.close(function () {
    // 所有已有连接断开后,退出进程 然后就会被主进程监听到立即启动新的进程
    process.exit(1);
  });
});

自杀信号

上述的代码存在的问题是等到已有的所有连接断开后进程才退出,在极端的情况下,所有的工作进程都发生了不可捕获的异常,都在等待所有已有连接的断开才退出,同时都不再接受新的连接,这种情况下会丢掉大部分请求。

为此不能等到工作进程退出后才重新启动新的工作进程,当然也不能暴力退出进程,这样会导致已连接的用户直接断开,于是在退出的流程中增加一个自杀(suicide)信号,工作进程在得知要退出时,向主进程发送一个自杀信号,然后才停止接收新的连接,当所有连接都断开后才退出,主进程在接收自杀信号后,立即创建新的工作进程服务。

// worker.js
process.on('uncaughtException', function (err) {
  // 记录错误日志
  logger.error(err);

  // 发送信号
  process.send({act: 'suicide'});

  worker.close(function () {
    process.exit(1);
  });

  // 设置超时时间,因为我们的连接可能是TCP长连接,不是HTTP服务这种短的连接
  // 等待长连接的断开可能需要较久的时间
  // 5秒后退出进程
  setTimeout(function () {
    process.exit(1);
  }, 5000);
});

// master.js
// 主进程也要针对自杀信号做修改
// 将创建的新启程放在转移到收到自杀信号,而不是在监听到子进程的退出后

worker.on('message', function (message) {
  if (message.act === 'suicide') {
    createWorker();
  }
});

因此做到了创建新工程在前,退出异常进程在后,这样就做到了总有新的进程来顶替异常进程的岗位,这样就使应用的稳定性和健壮性大大提高。

限量重启

工作进程不能无限制的被重启,如果启动的过程中就发生了错误,或者启动后接收到连接就收到错误,会导致工作进程被频繁的重启,这种频繁重启不属于我们捕捉未知异常的情况,因为这种短时间内的频繁重启已经不符合预期的设置,极有可能是程序编写的错误,因此,我们应该有一个机制,在单位时间内只能重启多少次,超过限制就触发giveup事件告知放弃重启工作进程这个重要事件。

// 重启次数
var limit = 10;
// 时间单位
var during = 60000;
var restart = [];
var isTooFrequently = function () {
  // 记录重启时间
  var time = Date.now();
  var length = restart.push(time);
  if (length > limit) {
    // 取出最后10个记录
    restart = restart.slice(limit * -1);
  }
  // 返回一个布尔值,当重启的数量超过了限制的次数
  // 并且最后一次重启与第一次重启相隔的时间小于限制的时间则返回true 表示过于频繁
  return restart.length >= limit && restart[restart.length - 1] - restart[0] < during;
};

// createWorker方法则需要在开始出加入这样一段代码检查是否重启的过于频繁
// 如果过于频繁,则触发giveup事件,并不在重启

if (isTooFrequently()) {
  process.emit('giveup', length, during);
  return;
}

// 我们需要对giveup事件作出监听, 因为giveup事件触发后,集群中没有任何进程服务了,十分危险
// 在监听giveup事件的时候需要添加重要日志,并让监控系统监视到这个严重的错误,进而报警等。

负载均衡

保证多个处理单元工作量公平的策略叫负载均衡,Node默认提供的机制采用操作系统的抢占式策略,即在一堆工作进程中,闲着的进程对到来的请求进行争抢,谁抢到谁服务,各个进程根据自己的繁忙度来进行抢占,影响抢占的是CPU的繁忙度,但是对于Node而言,繁忙与否是由CPU、I/O两个部分构成,可能存在I/O繁忙,但是CPU较为空闲的时候,这可能造成某个进程能够抢到较多请求,形成负载不均衡的情况。

为此Node在v0.11中提供了新的策略是的负载均衡更合理,叫做Round-Robin,轮叫调度。即主进程接收连接,将其依次给工作进程,分发的策略是在N个工作进程中,每次选择第i=(i+1) % n个进程来发送连接,在cluster模块中启用它的方式如下:

// 启用Round-Robin
cluster.schedulingPolicy = cluster.SCHED_RR
// 不启用Round-Robin
cluster.schedulingPolicy = cluster.SCHED_NONE

或者在环境变量中设置NODE_CLUSTER_SCHED_POLICY值:

export NODE_CLUSTER_SCHED_POLICY=rr
export NODE_CLUSTER_SCHED_POLICY=none

状态共享

在Node进程中不宜存放太多数据,因为它会加重垃圾回收的负担,进而影响性能,Node也不允许在多个进程中共享数据,但是在实际的业务中,往往需要一些共享数据,为此我们需要一种方案和机制来实现数据在多个进程之间中共享。

最直接、简单的方式就是通过第三方来进行数据存储,例如数据库、磁盘文件、缓存服务。所有工作进程在启动时将其读取进内存中,但是这种方式的问题在于如果数据发生改变,需要一种机制通知各个进程,使它们的内部状态也得到更新。

实现状态同步的机制有两种,一种是各个子进程去向第三方进行定时轮询,但是定时轮询带来的问题是,轮询时间不能过密,如果子进程过多,会形成并发处理,如果数据没有发生改变,轮询没有意义,白白增加查询状态的开销,如果轮询时间过长,状态发生改变不能及时更新到子进程中,会产生延迟。

另一种方式是主动通知,当数据发生改变时主动通知子进程,当然,即使是主动通知也需要一种机制来获取数据的改变,这个过程仍然不能脱离轮询,但我们可以减少轮询的进程数量,我们不再让子进程去轮询,而是创建一个用来发送通知和查询状态是否更改的进程,称为通知进程,让这个进程只进行轮询和通知,不处理任何业务逻辑。

这种推送机制如果按进程间信号传递,在跨多台服务器时会无效,是故可以考虑采用TCP或UDP的方案,每个进程在启动时从通知服务处读取第一次数据外, 并将当前进程信息注册在通知服务处,一旦通知进程通过轮询发现数据有更新后,根据注册信息,将数据发送给工作进程,由于只有通知进程去进行状态查询,因此状态响应处的压力不至于太过巨大,因此可以将轮询时间调整的较短,一旦发现更新,就能实时推送到各个子进程中。

Cluster模块

在v0.8版本之前,实现多进程架构必须通过child_process来实现,要创建单机Node集群,有很多细节需要处理,对于普通工程师而言是一件相对较难的工作,于是v0.8时直接引入了cluster模块,用以解决多核CPU利用率的问题,并提供了完善的API用以处理进程的健壮性问题。

// cluster.js
var cluster = require('cluster');
cluster.setupMaster({
  exec: "worker.js"
});

var cpus = require('os').cpus();
for (var i = 0; i < cpus.length; i++) {
  cluster.fork();
} 

// 得到与前文创建子进程集群相同的效果

在进程中判断是主进程还是工作进程,主要取决于环境变量中是否有NODE_UNIQUE_ID。

cluster.isWorker = ('NODE_UNIQUE_ID' in process.env);
cluster.isMaster = (cluster.isWorker === false);

工作原理

cluster启动时,与前文使用child_process和net模块实现时一样,会在内部启动TCP服务器,在cluster.fork()子进程时,将这个TCP服务器端socket的文件描述符发送给工作进程,如果进程是通过cluster.fork()复制出来的,那么它的环境变量中就存在NODE_UNIQUE_ID,如果工作进程中存在listen()侦听网络端口的调用,它将拿到该文件描述符,通过SO_REUSEADD重用,从而实现多个子进程共享端口。对于普通方法启动的进程,则不存在文件描述符传递共享等事情。

在cluster模块应用中,一个主进程只能管理一组工作进程,不如child_process灵活,其原因在于自行通过child_process操作子进程时可以隐式创建多个TCP服务器,使得子进程可以共享多个服务器端socket。

Cluster事件

对于健壮性的处理,cluster模块也暴露了相当多的事件。

  • fork: 复制一个工作进程后触发。

  • online: 复制好一个工作进程后,工作进程主动发送一条online消息给主进程,主进程在收到消息后,触发该事件。

  • listening: 工作进程中调用listen()(共享了服务器端Socket)后,发送一条listening消息给主进程,主进程在接收到消息后,触发该事件。

  • disconnect: 主进程和工作进程之间IPC通道断开后触发

  • exit: 有工作进程退出时触发该事件

  • setup: cluster.setupMaster()执行后触发。

尽管Node从单线程的角度来讲它有够脆弱的:既不能充分利用多核CPU资源,稳定性也无法得到保障,但是群体的力量是强大的,通过简单的主从模式,就可以将应用的质量提升一个档次。在实际的复杂业务中,我们可能要启动很多子进程来处理任务,结构甚至远比主从模式复杂,但是每个子进程应当是简单到只做好一件事,然后通过进程间通信技术将它们连接即可,这符合Unix的设计理念,每个进程只做一件事,并做好一件事,将复杂分解为简单,将简单组合成强大。

你可能感兴趣的:(Node-玩转进程)