Node:进程学习笔记

9 篇文章 0 订阅
7 篇文章 0 订阅

九、进程

1. 多进程架构

  • Node提供了child_process模块,并提供了child_process.fork()函数供我们复制进程。

  • 下面来看看一个例子:

    // worker.js
    const http = require('http');
    http.createServer((req,res)=>{
        res.writeHead(200,{
            'Content-Type' : 'text/plain'
        })
        res.end('hello,world');
    }).listen(Math.round((Math.random() + 1) * 1000),'127.0.0.1');
    
    
    // master.js
    const fork = require('child_process').fork;
    const cpus = require('os').cpus();	// cpu核心数
    
    for(let i=0;i<cpus.length;i++) {
        fork('./worker.js');    // 复制进程
    }
    
  • 当启动master.js时,这段代码将会根据当前机器的CPU核心数量复制出对应的Node进程。

  • 这就是著名的主从模式(master-worker模式),树已典型的分布式架构中用于并行处理业务的模式,具有良好的伸缩性和稳定性。

  • 主进程不负责具体的业务处理,而是负责调度和管理工作进程的,趋向稳定的。

  • 工作进程负责具体的业务处理,因为业务的多样性,所以工作进程的稳定性需要开发者注意。

  • 通过fork复制的进程是一个独立的进程,这个进程有着独立而全新的V8示例,需要至少30ms的启动时间和10m的内存。

  • Node的事件驱动解决了单线程上的并发问题,但是启动多个进程只是为了充分利用CPU资源。

1.1 创建子进程
  • child_process模块提供了四种创建子进程的方法:

    1. spawn():启动一个子进程来执行命令;
    2. exec():启动一个子进程来执行命令,与上一个的不同是多了一个回调参数,用于获取子进程的情况;
    3. execFile():启动一个子进程来执行可以执行的文件;
    4. fork():与spawn()相似,不同在于它创建子进程只需要指定要执行的JavaScript文件即可。
  • 1与2、3不同,后两者可以指定timeout属性,当进程超过执行时间就会被杀死;

  • 2和3又不同,前者的参数为命令,后者为执行文件。

  • const cp = require('child_process');
    cp.spawn('node',['worker.js']);
    cp.exec('node worker.js',function(err,stdout,stderr) {
        // do sth
    });
    
    cp.execFile('worker.js',function(err,stdout,stderr) {
        // do sth
    });
    cp.fork('./worker.js');
    
  • 4种方法的差异:

    类型回调异常进程类型执行类型可设置超时
    spawnno任意命令no
    execyes任意命令yes
    execFileyes任意可执行文件yes
    forknoNodeJavaScript文件no
  • 如果是JavaScript文件通过execFile运行,则需要在文件首行加上#!/usr/bin/env node

  • 事实上,后面三种方法都是spawn的延展。

1.2 进程间的通信
  • webWorker允许创建工作线程并在后台运行,让一些严重阻塞前端UI渲染的计算不再占用主线程。

  • 一个简单的例子:

    // index.js
    let worker = new Worker('worker.js');
    worker.onmessage = function(e) {
        document.getElementById('result').innerHTML = e.data;
    }
    
    
    // worker.js
    let n = 1;
    search : while(true) {
        n += 1;
        for(let i = 2; i <= Math.sqrt(n);i+= 1) {
            if(n % i == 0) {
                continue search;
            }
        }
        postMessage(n);
    }
    
    <!-- index.html -->
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Document</title>
    </head>
    <body>
        <div id="result"></div>
        <script src="./index.js"></script>
    </body>
    </html>
    

    以上例子,主进程index与工作线程worker通过postMessage和onmessage进行通信。子进程在后台进行静默计算,主线程负责接收子进程计算后的数据,并将其渲染到页面上。

  • 而进程向进程发送数据的方法是send(),线程和进程通过on()方法接收传递的数据。

    // parent.js
    const cp = require('child_process');
    let n  = cp.fork(__dirname + '/sub.js');
    
    n.on('message',function(m) {
        console.log('主进程收到数据:',m);
    })
    
    n.send("hello,i am parent");
    
    // sub.js
    process.on('message',function(m) {
        console.log('子进程收到数据:',m);
    })
    
    process.send('hi, i am child')
    
    
    // 运行parent.js的结果
    子进程收到数据: hello,i am parent
    主进程收到数据: hi, i am child
    
1.3 进程间的通信原理
  • 通过4种方法创建子进程后,主进程和子进程间回建立起一条IPC通道;
  • IPC的全称为Inter-Process Communication,即进程间通信。进程间通信是为了让进程能够互相访问资源并进行协调工作。
  • 实现进程通信的技术有很多,如命名管道,匿名管道,socket,信号量,共享内存,消息队列,Domain Socket
  • IPC属于管道技术,具体实现是由libuv提供的,在Windows下是通过命名管道实现的。
  • 父进程在创建子进程的过程中,会先创建IPC通道并监听它,再创建真正的子进程。并通过环境变量(NODE_CHANNEL_FD)来告诉子进程这个IPC通道的文件描述符,子进程通过文件描述符去连接这个IPC通道,从而完成与父进程之间的连接。
  • IPC通道类似于Socket,属于双向通信,但是不需要经过网络层,而是直接在内存中完成通信,十分高效。
  • 注意:只有Node子进程才能连接IPC通道,其他类型的子进程无法实现进程间的通信。除非其他进程也按照约定去连接IPC
1.4 句柄传递
  • 在Node中,一个端口只能由一个进程监听,新的进程无法监听同一个端口。

  • 因此,要解决多个进程监听,通常的做法是主进程监听主端口(80),接收网络请求后,再将这些请求分别代理到不同端口的进程上。

  • 可以对代理进程进行负载均衡,让每个进程能够较为均衡地执行任务。

  • 但是,使用代理有个缺点。由于每个进程没接收到一个连接,需要用掉一个文件描述符,因此代理方案中客户端连接到代理进程,代理进程连接主进程的过程中需要使用两个文件描述符,而操作系统的文件描述符是有限的,代理方案浪费了一倍的文件描述符影响力系统的扩展能力。

  • 为了解决上面的问题,Node引入了进程间发送句柄的功能。

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

  • 发送句柄,意味着我们可以取消代理方案。主进程接收到socket请求后,这个socket直接发送给工作进程,让工作进程直接连接socket,而无需再让主进程于子进程建立连接来转发数据。

    // master.js
    const child = require('child_process').fork('child.js');
    
    let server = require('net').createServer();
    
    server.on('connection',function(socket){
        socket.end('这是主线程发出的响应');
    })
    
    server.listen(3000,function() {
        child.send('server',server);
    })
    
    // child.js
    process.on('message',function(m,server) {
        if(m === 'server') {
            server.on('connection',function(socket) {
                socket.end('这是子进程处理的响应');
            })
        }   
    })
    
    // 测试结果
    // 启动master.js,访问http://localhost:3000后,服务器返回:
    // 这是子进程处理的响应
    
    
  • 在测试中,有时会是主进程处理响应,但大多数情况下都是子进程响应。

  • 接下来测试HTTP层的情况、现在我们要做的是将HTTP句柄发送给子进程处理后,主进程关闭服务器的监听,让子进程来监听。

    // master.js
    const child1 = require('child_process').fork('child.js');
    const child2 = require('child_process').fork('child.js');
    
    const server = require('net').createServer();
    
    server.listen(3000,function() {
        child1.send('server',server);
        child2.send('server',server);
        // 关闭主进程监听
        server.close();
    })
    
    // child.js
    let http = require('http');
    
    let server = http.createServer(function(req,res) {
        res.writeHead(200,{
            'Content-Type' : 'text/plain'
        })
        res.end('这是子进程处理的响应,pid='+ process.pid)
    });
    
    process.on('message',function(m,tcp) {
        if(m === 'server') {
            tcp.on('connection',function(socket) {
                server.emit('connection',socket)
            })
        }
    })
    
    // 测试结果
    // 子程序成功响应
    
  • 以上代码中,主进程将连接传递给子进程后随即关闭自己的连接;子进程接收到TCP连接后,触发HTTP响应。

1.4.1 句柄的发送与还原
  • 目前,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
    }
    
  • 发送到管道的实际上是我们的文件描述符,文件描述符是一个整数,这个message对象也会通过Stringify方法将其转化为字符串,再送入管道中。

  • 连接了IPC管道的子进程接收到message对象字符串后,将其转化为对象,才触发message事件。

  • Node进程间传递的只是字符串,而不是真正地传递对象。

  • message.cmd如果以NODE_开头,则将响应一个内部事件。像上面的例子,如果是’NODE_HANDLE’,它将取出type值,然后和文件描述符组合后还原出一个对象。

1.4.2 端口共同监听
  • Node底层对每个端口监听都设置了 SO_REUSEADDR,意为不同进程之间可以就相同的网卡和端口进行监听,这个服务器端的套接字可以被不同的进程复用。
  • 由于独立启动的进程之间互相不知道文件描述符,因此监听相同的端口会失败。但对于句柄发送的文件描述符,他们都是一样的,因此可以监听同一个端口。
  • 多个应用监听相同端口,只有一个可以成功监听,这些进程服务是抢断式(内卷)的。

2. 集群稳定

2.1 进程事件
  • 除了message事件,Node还有以下进程事件:

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

    exit:当子进程退出时触发该事件。正常退出,第一个参数为退出码,否则为null;若被杀死,则会在第二个参数返回杀死进程的信号。

    close:在子进程的标准输入输出流中止时触发,参数与exit相同。

    disconnect:在父进程或者子进程调用disconnect方法时触发,将关闭IPC通道。

  • 以上是父进程监听子进程状态的事件。除了send方法外,他还有kill方法用来给子进程推送信号,但是并不能将连接IPC的子进程真正杀死,只是给其推送了个信号SIGTERM,表示终止软件信号,进程收到时应该退出。

    process.kill(pid,[signal]); // 当前进程child.kill([signal]); // 子进程
    
2.2 自动重启
  • 我们可以在主进程中对子进程进行一些管理机制,比如重新启动一个新的进程来继续服务。

    // master.jsconst fork = require('child_process').fork;const cpus = require('os').cpus();const server = require('net').createServer();server.listen(1337);let workers = {};let 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 ' + worker.pid);}for (let i=0;i<cpus.length;i++) {    createWorker();}// 进程自己退出时,关闭所有工作进程process.on('exit',function () {    for (let pid in workers) {        workers[pid].kill();    }})// worker.jsconst http = require('http');let server = http.createServer(function(req,res) {    res.writeHead(200,{        'Content-Type' : 'text/plain'    })    res.end('handled by worker ' + 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);    })})// 测试结果create worker 4016create worker 3808create worker 1808create worker 14352create worker 13176create worker 10848create worker 13856create worker 3204$ kill 3204worker 3204 exitedcreate worker 3460
    
  • 在测试中,我们将3204进程杀死,子进程触发’uncaughtException’错误,于是停止接收连接;主进程监听到exit事件,将对应的子进程删除后,重新启动一个新的进程来代替,以保证整个集群一直有进程在为用户服务。

2.2.1 自杀信号
  • 在极端情况下,如果所有的工作进程同时被杀死,关闭了接收请求的通道,在重新启动新的线程过程中,所有新的请求都会被拒之门外,因此会丢掉打大部分请求;

  • 因此需要对上面的代码进行修改,不能等到完全退出后再重新启动线程。我们需要在退出的过程中添加一个自杀信号

  • 当主进程接收到自杀信号的时候,就立即创建新的进程。

    // worker.jsprocess.on('uncaughtException',function() {	process.send({        act : 'suicide'    })    worker.close(function () {        process.exit(1);    })})// master.jslet createWorker = function() {    var worker = fork(__dirname + '/worker.js');    // 接收到自杀信号,立即重启一个新的进程    worker.on('message',function(message) {        if(message.act === 'suicide') {            createWorker();        }    })    worker.on('exit',function() {        console.log('worker ' + worker.pid + ' exited');        delete workers[worker.pid];    })    // 句柄转发    worker.send('server',server);    workers[worker.pid] = worker;    console.log('create worker ' + worker.pid);}
    
  • 这里存在的问题是我们的连接可能是长连接,不是HTTP短链接,等待长连接断开可能需要比较久的时间。因此,需要对已有的连接设定一个超时时间,在限定时间内强制退出。

  • 同时,如果出现未捕获的错误时,将其记录在日志中,方便追踪代码异常。

    // worker.jsprocess.on('uncaughtException',function(err) {    logger.log(err);	process.send({        act : 'suicide'    })    worker.close(function () {        process.exit(1);    })    // 5s后退出进程    setTimeout(function () {		process.exit(1);    },5000)})
    
2.2.2 限量重启
  • 还有一个问题,进程不能无限制启动。如果一个错误是一直发生的而不被解决,那么一旦启动一个新的线程,就会再次捕获这个错误并重新启动,如此下去会导致工作进程频繁自启。

  • 因此,我们需要设定一个指标作为重启次数限制,一旦超过便触发giveup事件,告知主进程放弃重启工作进程。

    var limit = 10;// 时间单位let duration = 60000;let restart = [];let isTooFrequent = function() {    // 记录重启时间    let time = Date.now();    let length = restart.push(time);    if(length > limit) {        // 取出后10个时间        restart = restart.slice(limit * (-1));    }    return restart.length >= limit && restart[restart.length - 1] - restart[0] < duration;}// .......let createWorker = function() {    var worker = fork(__dirname + '/worker.js');    // 检查是否过于频繁    if(isTooFrequent()) {        process.emit('giveup',length,duration);        return ;    }    // 退出时重启进程    worker.on('message',function(message) {        if(message.act === 'suicide') {            createWorker();        }    })    worker.on('exit',function() {        console.log('worker ' + worker.pid + ' exited');        delete workers[worker.pid];    })    // 句柄转发    worker.send('server',server);    workers[worker.pid] = worker;    console.log('create worker ' + worker.pid);}
    
  • giveup事件比未捕获异常更加严重,因为他会导致整个集群没有任何进程可以服务了。

  • 同样,为了健壮性,我们需要在giveup事件里添加上日志,让监控系统监视到这个严重的错误。

2.3 负载均衡
  • Node默认的机制是采用系统的抢占式策略,各个进程根据自己的繁忙度来进行抢占。

  • 对于Node而言,区分繁忙度的由CPU和IO两部分构成,影响抢占的是CPU。

  • 某些业务,可能在IO比较繁忙,CPU空闲的情况,这时可能会造成某个进程将所有的请求都包揽了,形成负载不均衡的情况。

  • 为此,Node提供了一种叫做“Round-Robin”的策略,即轮叫调度。工作方式是由主进程接收连接,将其依次分发给工作线程(取模分配),保证公平分配。

  • 但是这种策略也是通过代理服务器来实现的,所以会导致服务器上消耗的文件描述符是平常的两倍。

  • 启用方式:

    1. 在cluster模块中启动:

      // 启动cluster.schedulingPolicy = cluster.SCHED_RR;// 关闭cluster.schedulingPolicy = cluster.SCHED_NONE;
      
    2. 在环境变量中启动:

      export NODE_CLUSTER_SCHED_POLICY = rr;export NODE_CLUSTER_SCHED_POLICY = none;
      
2.4 状态共享(第三方数据存储)
  • 将数据存放到数据库、磁盘文件、缓存服务中,所有工作进程启动时读取并存进内存中。
  • 但是,如果有数据更新,就必须有一种机制通知所有子进程重新获取数据,使得内部状态获得更新。
2.4.1 定时轮询
  • 各个进程定时向第三方数据存储轮询。
  • 如果轮询过密,子进程一多,就会形成并发处理,数据没发生改变,这些轮询就会没有意义,白白花费开销;
  • 如果轮询时间太长,数据发生改变时,无法及时更新。
2.4.2 主动通知
  • 当数据发生改变时主动通知子进程更新数据。
  • 这种依然需要依靠轮询来实现,但是轮询的进程数量会大大减少,我们只需要开启一个工作进程,称为通知进程。这个进程只负责轮询和通知。
  • 这种机制在跨多台服务器就会失效,故可以考虑TCP和UDP的方案。
  • 通知进程启动时,获取第一次数据后,还将进程信息注册到通知服务中,一旦轮询发现数据更新,则根据注册信息,将更新后的数据发送给工作进程。

3. Cluster模块

  • cluster模块用于解决多核CPU的利用率问题,用以处理进程的健壮性问题。

  • 第一节提到的创建子进程,利用cluster可以简单实现:

    // cluster.jsconst cluster = require("cluster");const cpus = require("os").cpus();// 推荐cluster.setupMaster({    exec : 'worker.js'})for (let i = 0; i < cpus.length; i++) {  cluster.fork();}
    

    这种方式创建的子进程集群与前文一致,不过官方文档还提供了另外一种方式:

    const cluster = require("cluster");const http = require("http");const cpus = require("os").cpus();if (cluster.isMaster) {    // 如果是主线程  for (let i = 0; i < cpus.length; i++) {    cluster.fork();  }  cluster.on("exit", function (worker, code, signal) {    console.log("worker " + worker.process.pid + " died");  });} else {  // 不是主线程,则直接搭建TCP连接共享  http    .createServer(function (req, res) {      res.writeHead(200);      res.end("hello!");    })    .listen(3000);}
    

    这种方式在进程中判断是否为主进程,取决于环节变量中是否有NODE_UNIQUE_ID。这个是用来识别子进程的。

3.1 工作原理
  • cluster是child_process和net模块的综合运用。
  • cluster启动时,会在内部启动TCP服务器,在调用fork()创建子进程时,cluster会将这个TCP服务器端的socket的文件描述符发送给工作进程。
  • 如果进程通过fork来复制的,那么他的环境变量里就会存在一个NODE_UNIQUE_ID。若工作进程存在listen()监听网络端口时,他就会拿到这个文件描述符,通过SO_REUSEADDR端口复用,实现多个子进程共享端口。
  • 而普通的进程复制却没有这样的文件描述符传递和共享。
  • 在cluster模块中,一个主进程只能控制一组工作进程。
  • 但是自己通过child_process来操作可以更加灵活控制工作进程,甚至控制多组工作进程。
  • 原因是在自行操作子进程时可以隐式地构造多个TCP服务器,从而可以共享多个socket文件描述符。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值