Node.js 的进程操作

2,365 阅读10分钟

进程与线程是一个程序员的必知概念,面试经常被问及。

进程

什么是进程?

进程是程序的执行实例。也有人说,进程是程序在 CPU 上执行时的活动。

实际上进程并没有明确的定义,只有一些规则。

最重要的规则就是一个进程可以创建另一个进程。

当我们打开一个 chrome 页面,会发现打开了新的进程,可以在任务管理器中看到。

CPU

因为进程的定义是,程序在 CPU 上执行时的活动。所以要了解进程,首先要了解 CPU。 目前计算机都是多核 CPU,情况比较复杂。这里我们做简化处理,只讨论一个单核的 CPU。

一个时刻只能做一件事情

一个单核 CPU 有一个特点,在一个时刻只能做一件事情。

那么操作系统是怎么让我们能够同时听音乐同时写代码呢?

答案是 CPU 在不同进程中快速切换。

CPU的执行速度是非常快的。类似 int i=0;i++ 计算操作,大约会花掉几次指令。比如3GHZ的CPU,每秒会操作30亿次指令。

多程序并发执行

表面上看起来所有事情在同时进行,但实际上是多程序并发执行。在宏观上并行, 但在微观上是串行,一个接一个的操作。

每个进程会出现 执行-暂停-执行 的规律。

比如CPU正在执行看电影的操作,这时你说我要去听音乐了。 CPU说,好吧,我去播放音乐了。就暂停了电影,开始播放音乐。 但 CPU 的执行速度是非常快的,在我们感知中,放电影和放音乐是同时进行的。

抢资源

多个进程之间会出现抢资源的现象。 比如一个进程占用了打印机,当另一个进程去访问打印机时,是不能连接成功的。

执行过程

要了解什么进程的执行过程,需要先了解进程的两个状态:非运行态、运行态。

那这两个状态是如何切换的呢?

  • 当我们新打开应用(比如浏览器)时,cpu可能在忙,先进入非运行态。
  • 等cpu忙完,可以执行进程的时候,cpu分派进程,浏览器进程进入运行态,可以在cpu上运行。
  • 等到浏览器进程运行完成的时候,cpu就退出了。
  • 如果浏览器进程还没有运行完成,但你打开了其他进程。这时浏览器进程会暂停运行,进入非运行态。
  • 等cpu有空了,又分派进程,chrome继续进入运行态。
  • 在整个过程中,进程会不停地在两个状态中切换。

我们把这个过程放大来看,小格子是进程,右侧是cpu。

4个进程正在排队分派,依次让cpu执行。

在cpu执行一个进程一段时间后,会先暂停,执行下一个进程。

上一个进程如果执行完毕,会自动退出。如果没有执行完毕,会进入队尾,等待下次执行。 直到所有进程执行完毕。

阻塞

我们了解了,进程有时候在排队等待cpu执行。

如果cpu很快,就不需要等待;如果cpu不快,就会有很多进程等待。这个等待的过程,就叫阻塞。

比如A进程正在读取一个文件,这个文件有100MB,大概需要10s。

此刻把A进程分配给cpu就会出现问题,因为cpu没有事情可以做,只能等待文件读取完成。 这就叫进程阻塞。

在等待执行的过程中,都处于非运行态。

其中进程A在等待cpu资源,而进程B在等待I/O完成,比如文件资源的读取。如果这个时候把cpu分配给B进程,B还是在等待I/O。我们把B叫做阻塞进程。

因此,分派程序只会把cpu分配给非阻塞进程。

线程 Thread 的引入

在远古时代,操作系统是没有线程的,只有进程。

在面向进程设计的系统中,进程是程序的基本执行实体。

也就是说,如果我们运行一个任务,就是运行一个进程。不会存在比进程更小的实体。

但后来引入了线程,在面向线程设计的系统中,进程本身不是基本运行单位。

也就是说,执行一个任务,并不是运行一个进程,而是运行一个进程里面的线程。 进程就变成了线程的容器。

那为什么要引入线程呢?

引入原因

进程是执行的基本实体,也是资源分配的基本实体。

比如说给进程分配一个内存空间,一个打印机或者是一个文件。

这时会有一些问题,如果我同时做很多事情,这些事情都是相似的。

那会同时开启很多进程,导致进程的创建、切换、销毁太消耗cpu时间了。

进程虽然好用,但不够轻量。于是引入更轻量的线程,作为执行的基本实体。

此时,进程只作为资源分配的基本实体。

也就是说,我把内存分配给这个进程,进程再自己给线程分配内存。

至于程序如何运行,进程并不关心。此时的进程只做资源分配的工作。

通过把事情分成两件,每件事情会变的更加轻量一些。

线程

在最新的操作系统中,线程才是 cpu 调度和执行的最小单元。

一个进程中至少有一个线程,也可以有多个线程。

比如现代浏览器。浏览器的渲染进程里有渲染引擎、V8引擎,每个模块都可以放在一个线程里。

一个进程中的线程共享该进程的所有资源。 这些资源指的是内存空间、文件、外接设备等等,和用户相关的东西。 如果一个打印机被占用了,那是指被一个进程占用,而不是被线程占用。

进程的第一个线程叫做初始化线程。初始化线程可以开启其他线程。

线程的调度可以由操作系统负责,也可以是用户自己负责。 一些很关键的线程就由操作系统负责,但我们也可以用代码操作。

总而言之线程是轻量级的进程,在一个进程中的线程可以共享资源。

子进程 vs 线程

看到这里,你可能会有新的疑问了。在子进程和线程中,我该如何选择呢? 当然是优先使用线程了,因为线程更加轻量。除非需要单独的资源分配,才会用到子进程。

Node.js 的进程控制

child_process

child_process 是 Node.js 的一个模块,用于新建子进程。

目的

目的是去执行一个命令行程序,得到运行结果。 Node.js 会将子进程的运行结果存储在系统缓存之中(最大200kb)。 等到子进程运行结束以后,主进程再用回调函数读取子进程的运行结果。 这样,我们就能拿到子进程的运行结果了。

比如下段代码,可以拿到上层文件目录。

API

exec

方法一:使用回调函数

const child_process = require('child_process')
const {exec} = child_process
// exec(cmd, options, fn) execute 的缩写,用于执行bash命令
exec('ls ../', (error, stdout, stderr) => {
  console.log('错误' + error)
  console.log('标准输出' + stdout)
  console.log('标准错误' + stderr)
})
// 同步版本execSync
const result = exec('ls ../')

方法二:使用流

const child_process = require('child_process')
const {exec} = child_process

// 方法二:使用流
const streams = exec('ls -l ../')

streams.stdout.on('data', (chunk) => {
  console.log('得到了数据') // 如果数据比较多,就会被多次调用
  console.log(chunk)
})

streams.stderr.on('data')

方法三:Promise

可以使其 Promise 化,用util.promisify

const util = require('util')
const child_process = require('child_process')
const {exec} = child_process

const exec2 = util.promisify(exec)

// 方法三:使用 Promise
exec2('ls -l ../')
  .then(({stdout, stderr}) => {
    console.log(stdout, stderr)
  })

有漏洞

如果cmd被注入了,可能执行意外的代码。

const util = require('util')
const child_process = require('child_process')
const {exec} = child_process

const exec2 = util.promisify(exec)
// 漏洞注入
const userInput = '. && pwd'
exec2(`ls ${userInput}`)
  .then(({stdout, stderr}) => {
    console.log(stdout, stderr) // 1.js /Users/admin/Desktop/child_process_demo
  })

所以exec是很危险的操作,如果注入代码是. && -rf /呢?咱们的整个项目都会被删除 所以更推荐使用 execFile,更安全。

execFile

execFile 也会执行特定的程序。不过命令行的参数只能使用数组的形式传入,无法注入。 同步的版本是 execFileSync。

const child_process = require('child_process')
const { execFile } = child_process;

const userInput = ". && pwd"
// 支持回调函数
execFile('ls', ['-la', userInput], {
    cwd: 'C:\\', // current working directory  当前工作目录
    env: {NODE_ENV: 'development'}, // 环境变量
    shell: 'bash', // 用什么 shell
    maxBuffet: 1024*1024 // 最大缓存,默认1024*11024字节
}
  (error, stdout) => {
    console.log('error, stdout', error, stdout)
})

spawn

用法与 execFile 方法类似。没有回调函数,只能通过流事件获取结果。 那么使用时,什么时候用execFile,什么时候用spawn呢? 答案很简单,能用spawn的时候就不要用execFile,因为execFile 有大小的限制。 而 spawn 是流,没有最大200kb的限制。

const child_process = require('child_process')
const { spawn } = child_process;

const userInput = "."
// 支持回调函数
const streams = spawn('ls', ['-la', userInput], {
    cwd: 'C:\\' // current working directory  当前工作目录
})
streams.stdout.on('data', (chunk) => {
  console.log(chunk.toString())
})

fork

fork 只能执行 Node 脚本。 fork的功能也能通过spawn实现, 比如 fork('./child.js') 相当于 spawn('node', ['./child.js'])。 执行的时候,fork会创建一个子进程。 特点是会多出message事件,用于父子通信。会多出一个send方法。

父子进程通信:

  • father.js
const child_process = require('child_process')
const n = child_process.fork('./child.js')
n.on('message', function (m) {
  console.log('父进程得到值', m) // { foo: 'bar' }
})
n.send({hello: 'world'})
  • child.js
const process = require('process')

process.on('message', function (m) {
    console.log('子进程得到值', m) // { hello: 'world' }
})
process.send({foo: 'bar'})

总结一下,到目前为止,我们学习了 node.js 四种操作进程的 API。 exec、execFile、spawn、fork,这四个api层层递进。

  • exec 可以创建一个进程并获取结果,但是存在注入风险
  • execFile 可以创建一个进程并获取结果,但如果使用回调形式,结果最大不能超过 200kb
  • spawn 可以创建一个进程并获取结果,只支持流的形式获取结果
  • fork 可以创建一个 Node.js 进程并获取结果。
    • 在使用时,从下往上选择。
    • 如果操作node脚本,最先选择fork;如果操作其他脚本,最先选择spawn;
    • 在spawn不满足需求时,再选择execFile;
    • 永远不要使用exec。

Node.js 的线程控制

看完了Node.js的进程控制,下面来看看Node.js的线程控制。

线程控制的 API 是 new Worker() 工作线程,目前效率不高。

在Node.js官方文档 中是这么描述工作线程的: 工作线程对于执行 CPU 密集型的 JavaScript 操作非常有用。 它们在 I/O 密集型的工作中用途不大。 Node.js 的内置的异步 I/O 操作比工作线程效率更高。

CPU 密集型是指很多数字计算的情况,比如加密、解密。 I/O 密集型是指数据库访问、网络访问密集的情况,我们写的web应用,就是I/O密集型。

这句话的意思是,做类似加密、解密等CPU密集型情况下,需要多线程运算,这时可以使用工作线程。 在web应用中,还不如直接使用Node.js的http、fork操作。

总结

  • 进程是资源分配的基本实体,线程是执行的基本实体。
  • 进程在运行过程中有两种状态:运行、非运行。
  • 阻塞就是进程在排队等待cpu执行的过程。
  • Node.js 进程控制 API: exec/execFile/spawn/fork。