lgwebdream/FE-Interview

Day376:说一下你对进程和线程的了解?Node 中进程间是如何进行通信的?

Genzhen opened this issue · 0 comments

每日一题会在下午四点在交流群集中讨论,五点小程序中更新答案
欢迎大家在下方发表自己的优质见解

二维码加载失败可点击 小程序二维码

扫描下方二维码,收藏关注,及时获取答案以及详细解析,同时可解锁800+道前端面试题。


一、进程和线程

用户下达运行程序的命令后,就会产生进程。同一程序可产生多个进程(一对多关系),以允许同时有多位用户运行同一程序,却不会相冲突。

进程需要一些资源才能完成工作,如 CPU 使用时间、存储器、文件以及 I/O 设备,且为依序逐一进行,也就是每个 CPU 核心任何时间内仅能运行一项进程。

进程与线程的区别:进程是计算机管理运行程序的一种方式,一个进程下可包含一个或者多个线程。

也就是说,进程是我们运行的程序代码和占用的资源总和,线程是进程的最小执行单位,当然也支持并发。可以说是把问题细化,分成一个个更小的问题,进而得以解决。

并且进程内的线程是共享进程资源的,处于同一地址空间,所以切换和通信相对成本小,而进程可以理解为没有公共的包裹容器。

但是如果进程间需要通信的话,也需要一个公共环境或者一个媒介,这个就是操作系统。

1.1 进程的演进

计算机有单核的、多核的,也有多种的组合方式:

  • 单进程

因为是一个进程,所以某一时刻只能处理一个事务,后续需要等待,体验不好

  • 多进程

为了解决上面的问题,但是如果有很多请求的话,会产生很多进程,开销本身就是一个不小的问题,而进程占据独立的内存,这么多响应使的进程难免会有重复的状态和数据,会造成资源浪费。

  • 多进程多线程

由之前的进程处理事务,改成使用线程处理事务,解决了开销大,资源浪费的问题,还可以使用线程池,预先创建就绪线程,减少创建和销毁线程的开销。

但是一个 cpu 某一时刻只能处理一个事务。像时间分片来调度线程的话,会导致线程切换频繁,是非常耗时的。

  • 单进程单线程

类似也就是 v8,基于事件驱动,有效的避免了内存开销和上下文切换,只需要线程间通信,即可在适当的时刻进行事务结果等的反馈。

但是遇到计算量很大的事务,会阻塞后续任务的执行。像这样:

img

  • 单进程单线程(多进程架构)

Node 提供了 cluster 和 child_process 两个模块进行进程的创建,也就是我们常说的主(Master)从(Worker) 模式。Master 负责任务调度和管理 Worker 进程,Worker 进行事务处理。

img

1.2 进程间的通信

Node 本身提供了 cluster 和 child_process 模块创建子进程,本质上 cluster.fork() 是 child_process.fork()的上层实现,cluster 带来的好处是可以监听共享端口,否则建议使用 child_process。

  • 1.2.1 child_process

child_process 提供了异步和同步的操作方法

常见的异步方法有:

  1. exec
  2. execFile
  3. fork
  4. spawn

除了 fork 出来的进程会长期驻存外,其他方式会在子进程任务完成后以流的方式返回并销毁进程。

异步方法会返回 ChildProcess 的实例,ChildProcess 不能直接创建,只能返回。

img

img

img

看个例子

有一个很长很长的循环,如果不开启子进程,会等循环之后才能执行之后的逻辑。

我们可以将耗时的循环放到子进程中,主进程会接受子进程的返回,不影响后续事物的处理。

// 主进程
const execFile = require('child_process').execFile;

execFile('.child.js',[],(err,stdout,stderr)=>{
    if(err){
        console.log(err);
        return;
    }
    console.log(`stdout:${stdout}`);
})
console.log('用户事务处理');

// 子进程
#!usr/bin/env node
for(let i = 0;i< 10000;i++){
    process.stdout.write(`${i}`);
}

而对于 fork,它是专门用来生产子进程的,也可以说是主进程的拷贝,返回的 ChildProcess 中会内置额外的通信通道,也就是 IPC 通道,允许消息在父子进程间传递,例如通过文件描述符,不过由于创建的是匿名通道,所以只有主进程可以与之通信,其他进程无法进行通信。但相对的还有命名通道

看个例子:

// parent.js
const cp = require("child_process");
const n = cp.fork(`${__dirname}/sub.js`);
n.on("message", (m) => {
  console.log("PARENT got message:", m);
});
n.send({ hello: "world" });

//sub.js
process.on("message", (m) => {
  console.log("CHILD got message:", m);
});
process.send({ foo: "bar" });

父进程通过 fork 返回的 ChildProcess 进行通信的监听和发送,子进程通过全局变量 process 进行监听和发送。

  • 1.2.2 cluster

cluster 本质上也是通过 child_process.fork 创建子进程,他还能帮我们合理的管理进程。

const cluster = require("cluster");
// 判断是否为主进程
if (cluster.isMaster) {
  const cpuNum = require("os").cpus().length;
  for (let i = 0; i < cpuNum; i++) {
    cluster.fork();
  }
  cluster.on("online", (worker) => {
    console.log("Create worker-" + worker.process.pid);
  });
  cluster.on("exit", (worker, code, signal) => {
    console.log(
      "[Master] worker" +
        worker.process.pid +
        " died with code:" +
        code +
        ",and" +
        signal
    );
    cluster.fork(); // 重启子进程
  });
} else {
  const net = require("net");
  net
    .createServer()
    .on("connection", (socket) => {
      setTimeout(() => {
        socket.end("Request handled by worker-" + process.pid);
      }, 10);
    })
    .listen(8989);
}

细心地你可能发现多个子进程监听了同一个端口,这样不会 EADDRIUNS 吗?

其实不然,真正监听端口的是主进程,当前端请求到达时,会将句柄发送给某个子进程。

二、进程间的通信

2.1 进程间通信分类

每个进程都有各自不同的用户地址空间,任何一个进程的全局变量在另一个进程中都看不到,所以进程之间要交换数据必须通过内核,在内核中开辟一个缓冲区,进程 A 把数据从用户空间拷贝到内核缓冲区,进程 B 再从该缓冲区把数据读走,内核提供的这种机制称为进程间通信。

进程间通信(IPC)大概有这种

  • 匿名通道
    • 管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间调用。进程的亲缘关系通常是指父子进程关系。
  • 命名管道
    • 命名管道也是半双工的通信方式,但是它允许无亲缘关系进程间通信
  • 信号量
    • 信号量是一个计数器,可以用来控制多个进程对共享资源的访问。它常作为一种锁机制,防止某进程正在访问共享资源时,其它进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间同步的手段。
  • 消息队列
    • 消息队列是由消息的链表,存放在内核中并由消息队列标识符标识。消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。
  • 信号
    • 信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生。
  • 共享内存
    • 共享内存就是映射一段能被其它进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC 方式,它是针对其它进程间通信方式运行效率低而专门设计的。它往往与其它通信机制,如信号量配合使用,来实现进程间的同步和通信。
  • 套接字
    • 套接子也是一种进程间通信机制,与其它通信机制不同的是,它可用于不同机器间的进程通信。

从技术上又可以划分为以下四种:

  1. 消息传递(管道、FIFO、消息队列)
  2. 同步(互斥量、条件变量、读写锁等)
  3. 共享内存(匿名的、命名的)
  4. 远程过程调用

上边提了很多实现进程间通信的方式,那 Node 进程间通信是以什么为基础的呢?

2.2 Node 进程间通信方式

NodeIPC 通过通道技术加事件循环方式进行通信,管道技术在 Windows 下由命名管道实现。在*nix 系统则由 Unix Domain Socket 实现,提供给我们简单的 message 事件和 send 方法。

这里提到了管道,那管道是什么?

2.3 什么是管道

管道实际上是在内核中开辟一块缓冲区,它有一个读端一个写端,并传给用户程序两个文件描述符,一个指向读端,一个指向写端口,然后该缓冲存储不同进程间写入的内容,并供不同进程读取内容,进而达到通信的目的。

管道又分为匿名管道和命名管道,匿名管道常见于一个进程 fork 出一个子进程,只能亲缘进程同喜,而命名管道可以让非亲缘进程进行通信。

img

其实本质上来说进程间通信是利用内核管理一块内存,不同进程可以读写这块内容,进而可以互相通信。

这里又提到了文件描述符,再来了解下文件描述符

2.4 什么是文件描述符

在 linux 中一切皆文件,linux 会给每个文件分配一个 id,这个 id 就是文件描述符,指针也是文件描述符的一种。这个很好理解,不过我们可以再往深了说,一个进程启动后,会在内核空间(虚拟空间的一部分)创建一个 PCB 控制块,PCB 内部有一个文件描述符表,记录着当前进程所有可用的文件描述符(即当前进程所有打开的文件)。系统除了维护文件描述符表外,还需要维护打开文件表(Open file table)和 i-node 表(i-node table)。

文件打开表(Open file table)包含文件偏移量,状态标志,i-node 表指针等信息

i-node 表(i-node table)包括文件类型,文件大小,时间戳,文件锁等信息

文件描述符不是一对一的,它可以:

  1. 同一进程的不同文件描述符指向同一文件
  2. 不同进程可以拥有相同的文件描述符(比如 fork 出的子进程拥有和父进程一样的文件描述符,或者不同进程打开同一文件)
  3. 不同进程的同一文件描述符也可以指向不同的文件
  4. 不同进程的不同文件描述符也可以指向同一个文件