了解进程

  • Sorzen
  • 8 Minutes
  • April 12, 2020

什么是线程与进程

进程是资源(CPU、内存等)分配的基本单位,它是程序执行时的一个实例。程序运行时系统就会创建一个进程,并为它分配资源,然后把该进程放入进程就绪队列,进程调度器选中它的时候就会为它分配CPU时间,程序开始真正运行。

线程是程序执行时的最小单位,它是进程的一个执行流,是CPU调度和分派的基本单位,一个进程可以由很多个线程组成,线程间共享进程的所有资源,每个线程有自己的堆栈和局部变量。线程由CPU独立调度执行,在多CPU环境下就允许多个线程同时运行。同样多线程也可以实现并发操作,每个请求分配一个线程来处理。

node遵循的是单线程单进程的模式,node的单线程是指js的引擎只有一个实例,且在nodejs的主线程中执行,同时node以事件驱动的方式处理IO等异步操作。node的单线程模式,只维持一个主线程,大大减少了线程间切换的开销。

但是node的单线程使得在主线程不能进行CPU密集型操作,否则会阻塞主线程。对于CPU密集型操作,在node中通过child_process可以创建独立的子进程,父子进程通过IPC通信,子进程可以是外部应用也可以是node子程序,子进程执行后可以将结果返回给父进程。

此外,node的单线程,以单一进程运行,因此无法利用多核CPU以及其他资源,为了调度多核CPU等资源,node还提供了cluster模块,利用多核CPU的资源,使得可以通过一串node子进程去处理负载任务,同时保证一定的负载均衡型。

多进程

面对单进程单线程对多核利用不足的问题,因此需要启动多进程。理想状态下每个进程各自利用一个CPU,以此实现多核CPU的利用。Node提供了child_process模块,也提供了child_process.fork()函数供我们实现进程的复制。

我们来简单模拟一下如创建worker.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const http = require('http')
const server = http.createServer((req, res) => {
res.writeHead(200, {
'Content-Type': 'text/plain'
})
res.end('Hello World')
})
server.listen(Math.round((1 + Math.random()) * 1000), '127.0.0.1')
通过node worker.js启动它,将会真听到1000-2000的随机端口开启。

我们再创建master.js,并通过node master.js启动它:

const cpus = require('os').cpus()
const fork = require('child_process').fork
cpus.forEach(() => {
fork('./worker.js')
})

上面代码会根据当前机器上CPU数量复制出对应的Node进程数,在Linux下可以通过ps aux | grep worker.js

psWorker

这相当于主从模式,进程主要分为两种:主进程和工作进程,主要用于分布式架构中用于并行处理业务的模式,举杯较好的可伸缩性和稳定性。主进程不负责具体的业务处理,而是负责调度和管理工作进程。工作进程负责具体的业务处理。
主从进程

通过fork()复制的进程都是一个独立的进程,尽管Node提供了fork()供我们复制进程使每个CPU内核都使用上,但是依然切记fork()进程是昂贵的。Node通过事件驱动的方式在单线程上解决了大并发的问题,这里启动多个进程只是为了充分将CPU资源利用起来,而不是为了解决并发问题。

进程间通信

在主从模式中,要实现主进程管理和调度工作进程的功能,需要主进程和工作进程之间通信。对于child_process模块,创建好了子进程,然后父子进程之间通信是十分容易的。
主线程与工作线程之间通过onmessage()和postmessage()进行通信,子进程对象则由send()方法实现主进程向子进程发送数据,message事件实现收听子进程发来的数据。通过消息传递内容,而不是共享或者直接操作相关资源,这是比较清凉和无依赖的做法。

eg:
// parent.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const cp = require('child_process')
const sub = cp.fork(`${__dirname}/sub.js`)

sub.on('message', (mes) => { // 主线程接收子线程发来的消息
console.log(`Parent get message: ${mes}`)
})

sub.send({hello: 'world'}) // 主线程向子线程发送消息
// sub.js

process.on('message', (mes) => { // 子线程接收父线程发来的消息
console.log(`Child get message: ${mes}`)
})

process.send({foo: 'bar'}) // 子线程发送消息给父线程

通过fork()或者其他API创建子进程之后,为了实现父子进程间的通信,父进程与子进程之间将会创建IPC通道。通过IPC通道父子进程之间才能通过message和send()传递消息。

进程间通信原理

IPC即进程间通信,进程间通信的目的就是为了让不同的进程能够互相访问资源并进行协调工作。实现进程间通信的技术有很多,如命名管道、匿名管道、socket、信号量、共享内存、消息队列、Domain Socket等。Node中实现IPC通道的是管道(pipe)技术。具体实现是由libuv提供。表现在应用层上的进程间通信只有简单的message事件和send()方法,接口十分简洁和消息化。
IPC

父进程在实际创建子进程之前,会创建IPC通道并监听它,然后才真正创建出子进程,并通过环境变量告诉子进程这个IPC通道的文件描述符。子进程启动的过程中,根据文件描述符去连接这个已存在的IPC通道,从而完成父子进程之间的连接。
创建IPC管道

建立连接之后的父子进程就可以自由的通信了。由于IPC通道是用命名管道或Domain Socket创建的,它们与网络socket的行为比较类似是双向通信。不同的是它们在系统内核中就完成了进程间的通信,而不用经过实际的网络层,非常高效。
在Node中,IPC通道被抽象为stream对象,在调用send()时发送数据,接受到的信息会通过message事件触发给应用层。

小结

通过创建子进程、进程间通信的IPC通道实现、句柄在进程间的发送和还原、端口共用等细节。通过这些基础技术,用child_process模块在单机上搭建Node集群是件相对容易的事情。因此在多核CPU的环境下,可以让Node进程能够充分利用资源。