node进程与线程

javascript/jquery

浏览数:145

2020-6-15

一、进程

进程是指正在进行中的程序,即正在运行的程序。进程是操作系统进行资源分配和调度的最小单位,所以每个进程都拥有自己独立的虚拟地址空间,一般情况下,包括文本区域(text region)、数据区域(data region)和堆栈区域(stack region)。文本区域存储处理器执行的代码;数据区域存储全局的变量和常量;堆栈区域其中栈用于存放函数的参数和函数内定义的局部变量堆用于存放由程序员创建的对象

二、process模块

node提供了一个process模块,用于获取当前进程中的相关信息,这是一个全局的对象无需require即可直接使用。如:

  • process.title,可以指定创建进程的名称
  • process.pid,可以获取当前进程的id
  • process.ppid,可以获取当前进程的父进程id,比如我们在一个命令行窗口(bash)中执行node server.js,那么server.js这个程序进程的父进程id就是这个命令行窗口的进程id
  • process.env,可以指定当前进程的环境变量,例如可以通过process.env.NODE_ENV标识当前项目是开发环境还是生产环境
  • process.cwd(),可以获取当前进程的工作目录
  • process.platform,可以获取当前进程运行的操作系统平台
  • process.nextTick(callback),可以为事件循环设置一项任务,node.js会在进入下次事件循环前调用callback**
  • process.uptime(),可以获取当前进程已运行时间
  • process.on(),可以用于进程监听事件
  • process.stdoutprocess.stdinprocess.stderr,表示标准输出标准输入标准错误输出
console.log("hello node process.");
process.title = "我是node进程"; // 设置进程的名称
console.log(`进程id为 ${process.pid},${process.uptime()}`); // 打印进程id和运行时间

当我们运行一下代码的时候,我们会发现在电脑的活动监视器中并未发现名称为我是node进程的进程,因为该段代码都是很简单的同步代码,只是进行一下简单的输出,没有异步操作或等待操作,执行速度非常快,输出完毕后程序就结束了,从代码输出结果看,该进程运行时间为126ms,所以该进程创建后代码运行完毕很快就被系统杀掉了,所以我们看不到对应的进程名,即进程是进行中的程序
要想能够看到我们创建的进程,我们需要让程序运行的时间久一点,比如开启一个web服务器等待请求、开启一个setTimeout等,如下:

const http = require("http");
console.log("hello node process.");
const server = http.createServer();
server.listen(3000, () => {
    process.title = "我的node进程"; // 设置进程的名称
    console.log(`进程id为 ${process.pid},${process.uptime()}`); // 打印进程id和运行时间
});

这个时候,我们就能在活动监视器中查看到我们刚刚创建的名为我的node进程的进程了。

三、线程

线程是程序执行的最小单位,是一个进程中代码的不同执行路线,一个进程中可以包含多个线程,所以说进程是线程的容器进程之间是相互独立的,但同一进程下的各个线程之间可以共享该进程的资源
我们知道Node程序运行的时候是单线程的,也就是说程序的执行路径只有一条,所以其代码会按顺序同步执行,如果执行过程中遇到太多耗时的同步操作,那么node的线程就会被阻塞,导致后续响应无法处理。所以我们不要在请求处理的回调中包含太多耗时的代码,虽然请求过来,node会立即将这个请求的回调加入到事件循环相应的队列中,但是事件循环中的代码执行也是需要到主线程中执行的,也就是说,处理请求的回调函数要到主线程中执行,如果回调中有大量耗时的计算,那么后续请求就会被阻塞。

const http = require("http");
console.log("hello node process.");
const server = http.createServer();
const longRuning = () => {
    let result = 0;
    for(let i = 0; i < 3000; i++) {
        for(let j= 0; j < 1000; j++) {
            for(let k=0; k< 1000; k++) {
                result = result + i + j + k;
            }
        }
    }
    return result;
};
server.on("request", (req, res) => {
    console.log("request");
    if (req.url === "/test") {
        console.log(`开始处理请求`);
        const startTime = Date.now();
        const result = longRuning();
        const endTime = Date.now();
        console.log(endTime - startTime);
        res.end(`result is ${result}`);
    } else {
        res.end("ok");
    }
});
server.listen(3000, () => {
    process.title = "我的node进程"; // 设置进程的名称
    console.log(`进程id为 ${process.pid},${process.uptime()}`); // 打印进程id和运行时间
});

可以看到当node服务器监听到请求后会注册一个回调函数处理请求,而回调函数内需要进行一个耗时的计算,大约需要15秒,这样主线程就被阻塞了。

四、node单线程的理解

我们都知道node是单线程的,其实准确的说是,node的主线程是单线程的。比如,我们启动一个node应用,我们可以看到这个进程中包含了8个线程,而不是一个线程

这其中有一个是主线程,而其他7个是非主线程,因为我们的node应用启动后,会创建V8引擎实例,而这个实例是多线程的,主要有以下:

  • 主线程: 负责编译、执行JS代码;
  • 编译/优化线程: 在主线程执行的时候,可以优化代码;
  • 分析器线程: 记录分析代码运行时间,为Crankshaft优化代码执行提供依据;
  • 垃圾回收的几个线程

异步IO对线程数量的影响
当我们通过fs模块去读取文件的时候,我们会发现进程中的线程数量立即增加了4个,因为在node中进行一些IO操作(DNS、FS)和一些CPU密集计算(Zlib、Crypto)会启用node的线程池,而node的线程池默认为4个,所以线程数量会变为12个,如:

当然,我们也可以手动修改默认线程池的数量,如:

process.env.UV_THREADPOOL_SIZE = 10; // 将node默认线程池数量修改为10个

刚开始,process.env是没有UV_THREADPOOL_SIZE这个常量的,需要我们手动设置后才能读取出来,经过以上配置后,我们再次运行代码,发现node进程中的线程数量变为了18个,如:

  • node虽然是单线程模型,但是其是基于事件驱动的,所以其天生可以应对高并发请求,因为web请求一过来,node就能监听事件,然后将请求处理的回调函数加入到事件环中,同时其IO是异步非阻塞的,进行IO操作的时候无需等待,可以在进行IO操作的同时处理其他请求
  • 所以node请求处理的快慢关键在于事件循环中回调函数执行的快慢,如果回调中存在大量计算,那么就会占用大量的CPU资源,导致无法继续处理后续请求。
  • 基于上面,我们可以通过多进程,来将大量的计算放到子进程中,避免过多占用主线程资源。

五、多进程

当你的应用中包含大量的计算的时候,就会占用大量的CPU计算时间,CPU在计算主线程就会被阻塞,后续请求也会被阻塞,后一个请求需要等待前一个请求执行完毕,最后一个请求的响应时间将是所有请求时间的总和,请求多的话,最后一个请求的响应时间将变得非常可怕。所以我们可以充分利用多核CPU的特性,开启多个进程,让子进程去进行耗时的计算,需要注意的是开启多进程并不是为了解决高并发,而是为了充分利用CPU,node应用自身已经可以处理高并发,因为请求一来就会将请求加入事件循环中,无需等待
node创建子进程是通过child_process模块或者cluster模块

  • child_process模块

child_process模块是node的内置模块,但是需require才能使用。我们可以通过child_process模块提供的fork()方法来创建新的进程,由于系统资源是有限的,并且fork出来的是一个独立的进程,这个进程中有着独立而全新V8实例它至少需要10M的内存,所以不建议衍生太多子进程,通常可以根据cpu核心数来定,一个CPU对应一个进程。
创建子进程的时候,我们需要给fork()方法传递一个模块路径,然后其会返回创建的子进程,其实就是将一个模块的处理交给子进程处理

const http = require("http");
const fork = require("child_process").fork;
console.log("hello node process.");
const server = http.createServer();
server.on("request", (req, res) => {
    console.log("request");
    if (req.url === "/test") {
        console.log(`开始处理请求`);
        const computeProcess = fork("./fork_compute.js"); // 传入需要子进程处理的模块路径,创建子进程
        computeProcess.send("开启了一个新的子进程,用于完成耗时的计算.");
        computeProcess.on("message", (result) => { // 子进程接收计算结果数据
            console.log(`子进程计算完毕,结果为${result}`);
            res.end(`result is ${result}`);
            computeProcess.kill(); // 杀掉子进程
        });
        computeProcess.on("close", (code, signal) => {
            console.log(`子进程收到close事件,收到${signal}信号,退出码为${code}`);
            computeProcess.kill();
        });
        console.log("请求处理完毕");
    } else {
        res.end("ok");
    }
});
server.listen(3000, () => {
    process.title = "我的node进程"; // 设置进程的名称
    console.log(`进程id为 ${process.pid},${process.uptime()}`); // 打印进程id和运行时间
});

新建一个fork_compute.js文件,用于进行长计算。

const longRuning = () => {
    let result = 0;
    for(let i = 0; i < 5000; i++) {
        for(let j= 0; j < 1000; j++) {
            for(let k=0; k< 1000; k++) {
                result = result + i + j + k;
            }
        }
    }
    return result;
};
process.on("message", (msg) => { // 这里的process指的是创建的子进程
    console.log(`收到子进程发送的msg,${msg},子进程的id为${process.pid}`);
    const result = longRuning(); // 子进程开始执行耗时的计算代码
    process.send(result); // 通过子进程对象将结果发送出去,然后在父进程中用子进程对象接收即可
});

使用子进程后,我们发现处理请求的回调中没有长计算代码的执行了,取而代之的是创建子进程,子进程监听message事件,这里没有耗时的长计算代码,所以请求很快处理完毕,等子进程处理完耗时的计算后再将结果发送回来即可。

  • cluster模块

cluster其实就是对child_process的一层封装,cluster也是通过fork()方法创建子进程,但是其不需要传递任何参数必须由主进程调用,因为其是根据主进程复制出一个子进程主进程不负责处理请求只负责绑定端口和工作任务的调度,其通过内置的负载均衡来管理子进程

const http = require("http");
const cluster = require("cluster");
const cpuNums = require('os').cpus().length; // 获取CPU核数量

if (cluster.isMaster) { // 如果是主进程
    process.title = "我的node进程"; // 设置主进程的名称
    for (let i = 0; i < cpuNums; i++) {
        cluster.fork(); // 在主进程中根据CPU核心数量复制出相应的子进程
    }
    cluster.on('exit', (worker) => { // 监听子进程退出事件
        console.log(`worker${worker.id} exit.`)
    });
    cluster.on('fork', (worker) => { // 监听子进程创建事件
        console.log(`fork:worker${worker.id}`)
    });
    cluster.on('listening', (worker, addr) => { // 监听子进程进入监听事件
        console.log(`worker${worker.id} listening on ${addr.address}:${addr.port}`)
    });
    cluster.on('online', (worker) => { // 监听子进程创建成功事件
        console.log(`worker${worker.id} is online now`)
    });
} else {
    process.title = `我的worker进程${cluster.worker.id}`
    http.createServer((req,res)=>{
        console.log(cluster.worker.id);
        if (req.url === "/test") {
            console.log(`开始处理请求`);
            const startTime = Date.now();
            const result = longRuning();
            const endTime = Date.now();
            console.log(endTime - startTime);
            res.end(`result is ${result}`);
        } else {
            res.end("ok");
        }
    }).listen(3000);
}

const longRuning = () => {
    let result = 0;
    for(let i = 0; i < 3000; i++) {
        for(let j= 0; j < 1000; j++) {
            for(let k=0; k< 1000; k++) {
                result = result + i + j + k;
            }
        }
    }
    return result;
};

当我们通过主进程执行以上代码后,如果在主进程中使用了cluster模块创建子进程,那么以上代码又会被创建好的子进程重新执行一遍,所以当以上代码运行在子进程中的时候,我们可以用子进程去创建服务器,即执行创建服务器的代码,这样就相当于是一个子进程对应一个服务器了,当一个子进程在处理长计算的时候,另一个子进程可以立即处理新来的请求了,从而充分利用CPU。
需要注意的是,应用代码中必须进行cluster.isMaster的判断,因为子进程是不能调用fork()方法的

// 查询哪个进程在使用3000端口
lsof -i :3000

我们可以通过该命令查看到究竟是哪个经常在使用3000端口,从查询结果,我们可以看到,其实是主进程在监听3000端口。可以说是,主进程创建了一个socket并且绑定监听到该目标端口,然后主进程与子进程之间通过IPC通道进行通信通过调用子进程的send方法将主进程的socket(句柄)传递过去,其内部通过RoundRobin负载均衡技术再将请求转发到子进程中,后面会讲到句柄传递。

需要注意的是,子进程对主进程是有依赖关系的。也就是说,主进程退出,那么所有的子进程都会跟着退出但是子进程退出并不会影响主进程

六、node多进程端口监听问题

通常来说,一个端口只能被一个进程监听,如果某个端口已经被监听了,另一个进程尝试监听该端口,那么就会报错,提示端口已经被占用,如下所示:

events.js:174
      throw er; // Unhandled 'error' event
      ^
Error: listen EADDRINUSE: address already in use :::3000
  

那么node是如何解决多个进程同时监听同一个端口的呢?
所谓句柄,就是一种可以用来标识资源的引用,它的内部包含了指向对象的文件描述符,可以看做是内核资源对应的指针,句柄可以是服务器socket套接字以及任何带有底层_handle属性的东西。node可以通过发送句柄的方式来避免句柄资源的浪费
子进程对象的send(message, handle),其第二个参数即为句柄

// parent.js
const http = require("http");
const childProcess = require("child_process");
const server = http.createServer((req, res) => {
    res.end(`我是主进程--${process.pid}`);
});
const childProcess1 = childProcess.fork("./child.js"); // 创建子进程1
const childProcess2 = childProcess.fork("./child.js"); // 创建子进程2

server.listen(3000, () => {
    global.server = server; // 保存主进程中创建的server对象
    childProcess1.send("server", server); // 将主进程创建的server当做句柄传递给其子进程
    childProcess2.send("server", server); // 将主进程创建的server当做句柄传递给其子进程
    server.close(); // 主进程关闭连接后并不会影响子进程
});
// child.js
const http = require("http");
const server = http.createServer((req, res) => {
    res.end(`我是子进程--${process.pid}`);
});

process.on("message", (message, handle) => {
    // handle.close(); // 这里不能关闭,否则无法监听到connection事件
    console.log(global.server === handle); // false,表示传递句柄的时候不是直接传递的对象
    handle.on("connection", (socket) => { // 子进程拿到主进程传递过来的
        server.emit("connection", socket); // 触发server请求监听回调
    });
});
# 通过以下命令连续发送10次请求测试
for((i=1;i<=10;i++));do curl http://localhost:3000; echo "";done

可以看到,主线程中创建的server关闭close连接后并不会影响子进程对connection事件的监听,因为子进程对象的send方法传递的其实不是对象,而是句柄,即server._handle,但是传递的东西还是同一类对象,所以子进程拿到该handle后会创建一个新的Server对象,所以子进程获取的server对象并不是主进程中创建的那个server对象,因为新创建的server对象具有和主线程一样的句柄,所以也能够监听到3000端口的连接,然后给子进程创建的server发起connection事件,并将socket传过去,所以子进程的server也能够处理请求了。

node多进程之所以不会造成端口冲突主要有以下两个原因:

  • 子进程中调用的listen方法是经过hack过的,所以子进程的listen其实是不起作用的
  • node可以通过句柄传递的方式实现句柄的共享。

作者:李鸿彪