.net后台怎么提取html中的多个图片的绝对地址_聊聊 Node.js 中的进程

1c404bf5c49cd4766e0455f7e253741a.png

## 前言

进程与线程作为操作系统的基础概念,是一个软件开发工程师必须需要掌握的。而在开发 Node.js 程序时,进程与线程也会发挥比较大的作用。

本文主要主要讲 Node.js 中多进程的需求场景与解决方案。

本文的所有 demo 见:https://github.com/ImHype/process-and-thread-in-nodejs

## 进程是什么?

进程是近现代操作系统的产物。计算机硬件层面其实没有进程与线程的概念,它做的事情就是不停地运行机器码。

早期的操作系统设计出来是单任务执行机器码的,任务的执行前后包含配置任务,和提取结果两个动作:我们把他们称为”上机”与”下机”。

而随着计算机计算能力的提升,人们发现“上机”和”下机”的动作会成为整个系统的瓶颈(人的速度太慢了)。所以便设计了多道程序批处理操作系统。

此时的操作系统能够给计算机安排一个队列了,让计算机不断从队列中读取任务,执行,最终将结果记录到存储介质里面,有点像现在的 CI Runner。

但问题似乎没有根本解决,人们发现当一些任务执行需要等待一些外部操作,比如网络请求、文件读写,此时计算资源还是会处于浪费状态。

问题得到实质性的解决,是时分复用操作系统的引入,每个任务按以下状态不停切换。

87ea5cb21d67d4cd1a8a394915aec6d2.png

通过这种方式,多个任务能够被「并发」地执行。创建任务后,通过时分复用的机制,由 CPU 调度执行;当任务需要等待外部资源时,则进入阻塞态,此时无法再由 CPU 调度执行,直到外部资源已就绪后,由系统中断触发,将其任务状态再次置为就绪态,而后该任务又可由 CPU 调度执行。

真正让多进程作用发挥到极致,是对称多处理技术 (SMP,允许多个处理器共享内存和计算机资源) 的成熟,能够让多任务的执行在多个处理器上并行运行。

上述提到的任务,就是进程,又称调度主体。同样作为调度主体而存在的还有「线程」,在 Linux 2.4 及更早,线程的实现其实也是进程;而在往后的版本里,任务调度的主体变成了线程,进程成为了线程容器。

进程作为线程的容器,具备彼此独立的内存空间。这块怎么理解呢?

我们在程序中使用的,都是逻辑地址(比如 0x0),这个地址映射到真实的物理地址需要一个重定向过程,由硬件层面的 MMU 完成。

在现代操作系统分帧分页的内存管理模式下,进程的逻辑地址被分段、分帧表示。分段是指:代码段、数据段、bss 段、堆段、栈段;分页是指将进程内的使用空间将逻辑地址分成一个个连续的大小相等的单元。将实际的物理内存上也分成一个个和页大小相等的单元,称之为帧,但帧内数据存储可以不连续。实际的内存读取过程是,MMU 会从逻辑地址计算得到页号和页内偏移量,结合 pid 查反向页表得到帧号,再拼接帧号和页内偏移量得到真实的物理地址。分页技术解决的是内存外部碎片问题。

通常情况下,不同的进程只有用到同一个动态链接库时,代码段会共享内存帧,其余的段都是不共享的。最终导致的是,不同的进程拥有各自独立的内存空间及资源,如文件描述符,端口等等;而同一个进程中的不同线程,可以共享进程容器的内存空间与资源。

## 多进程的需求

在常规的 web 服务器领域,默认模式下运行 Node 程序通常会有这两个痛点:

  1. 我们 Node.js 程序,会运行在非单核 CPU 的宿主环境,而上文提到 JavaScript 的执行是在单线程中执行的。也就是说如果我们的应用是 「计算密集型」 应用,单进程的运行模式下我们是无法充分利用 CPU 的资源的;
  2. 单进程的 Node.js 程序崩溃退出了怎么办?

## 多进程的解决方案

目前主流的有两个解决方案:

  1. 随着 Docker 的普及,本身提供了 Daemon 的机制,可以帮助重启我们的应用,并且 Docker 也支持分配 1 core 的计算资源;
  2. 使用进程管理工具 - PM2,以 “cluster” 模式启动。

如何对两种方案进行取舍呢?

我认为如果你的公司提供了 Docker 的部署方案,可以尝试模拟下进程异常退出的情况,观察下进程重新拉起的时间,和 pm2 方案做一个简单对比,如果拉起的速度基本无差,可以使用方案 1;否则可以继续使用 pm2 的方案。

那么通常我们在 Docker 中运行 pm2,是要以 no-daemon 模式运行的(否则 Docker 容器会不断重启):

$ pm2 start server.js --no-daemon -i 2

好,目前为止,我们了解了 Node.js 在常规 web 服务开发的需求场景,接下来我们尝试一起来实现一个「麻雀虽小,五脏俱全」的进程管理工具,以帮助大家熟悉 Node.js 中原生的进程操作。

## 实现一款类似 pm2 的进程管理工具

特别说明,我们做这个进程管理工具的目的,不是为了让它能够在真实的线上环境上跑,而是为了通过这个例子,帮助加深对 Node.js 的进程机制的理解。

先整理下一个基本的进程管理工具所需要拥有的功能:

  1. 基础的子进程创建能力;
  2. 均衡地将请求分发到各个子进程;
  3. 子进程异常退出时,要能重启子进程;
  4. 守护进程要能在后台常驻运行;
  5. 接收退出命令,退出所有子进程,并释放资源;
  6. 收集工作进程的日志。

我们一个个来看。

### 创建子进程的能力

Node.js 提供的基础多进程处理能力几种在 child_process 模块

提供了四种创建子进程的方式:

  • exec
  • execFile
  • fork
  • spawn

其中,exec 与 execFile 主要用来执行系统命令;而 spawn 是其余三个函数的基础,其他三个函数内部都会调用 spawn 函数。

对于本次需求,我们主要讲解 fork 函数。

写个 例子 来感受下 fork 函数的基础用法:

首先是 main.js

// main.js
const { fork } = require('child_process');
const sub = fork('./child.js', {
   stdio: 'inherit'
});
sub.on('message', (msg) => {
   console.log(msg);
});

然后是 child.js

// child.js
process.send('hello');
console.log('hello world');

最后的输出是:

> hello world
> hello

一次简单的子进程创建便完成了。

### 多进程启动 web server

使用上一节学习到的 fork API 来试着创建我们的服务,看例子。

// fork.js
const { fork } = require('child_process');
for (let i = 0; i < 4; i++) {
    fork('./main.js');
}

// main.js
const http = require('http');
http.createServer((req, res) => {
    res.end('hello');
}).listen(3000);

似乎和我们 pm2 的使用姿势类似,期望能够实现我们的需求。但发现最终的结果是这样的:

9af5fd750cb8a009ac39595c92e75a0e.png

提示你端口被占用了,为什么会这样呢?

  • 前面我们提到进程是拥有独立的资源,包括内存空间以及端口及文件描述符等;
  • 所以,当一个端口被一个进程占用之后,其他进程就无法再占用了。

那问题怎么解决呢?一般有下面几种方案:

第一种方案是,类型 Nginx 的模式,起一个 master 进程用于接收 http 请求,再通过 http 代理的形式代理到子进程。但在同一台机器内的父子进程之间通过这样的形式进行交互是可能性能会出现瓶颈。

第二种方案便是应用 child_process 提供给我们的进程间通信机制:

  1. process.on(‘message’, callback);
  2. process.send(‘message’, handle);

来看例子。

// master.js
const { fork } = require('child_process');

const net = require('net');

const subprocess = fork('child.js', []);

const server = net.createServer((socket) => {
    subprocess.send('accept', socket);
});

server.listen(9999);


// child.js
process.on('message', (msg, handle) => {
    if (msg === 'accept') {
        const body = Buffer.from('<html><head><title>Wrox Homepage</title></head><body>hello world</body></html>');
        handle.end(`HTTP/1.1 200 OKrnDate: Sat, 31 Dec 2005 23:59:59 GMTrnContent-Type: text/html;charset=utf-8rnContent-Length: ${Buffer.byteLength(body)}rnrn${body}`);
    }
})

node master.js 执行,可以发现成功启动了,并且访问 localhost:9999 也成功响应了 hello world。 这个方案能运行的原理是:

  1. 文件描述符机制(以下称 fd);
  2. 系统级提供 IPC 机制允许进程见传递 fd;
  3. Node.js 层面利用以上系统级特性做了封装,支持 process.send 方式传递 socket。

但这个方案也暴露了一个问题, 对启动的 server 代码有侵入性。

第三个方案是使用 Node.js 本身提供的 cluster 机制,由于 cluster 模块内部以 hack 的形式帮我们做了类似方案二的事情,使得多进程启动的逻辑不再侵入 server 的代码,看例子。

// master.js
const cluster = require('cluster');

cluster.setupMaster({
  exec: 'worker.js',
});

const workers = [];

for (let i = 0; i < 4; i++) {
  const child = cluster.fork()
  workers.push(child);
}

// worker.js
const http = require('http');

http.createServer((req, res) => {
    res.end('hello');
}).listen(9000, () => {
    console.log('process %s started', process.pid)
});

方案三 cluster 的机制看起来是一种较优的机制,用于解决多进程启动的问题。

### 进程守护

如果我们的 server 因为某个异常导致了进程退出,这个时候该怎么办?

作为一款进程管理工具,需要做的就是重启子进程,我们称它为进程守护。大致分为两个步骤:

  1. 监听子进程退出;
  2. 进程退出后重新 `refork` 子进程。

监听子进程退出,需要利用的 Node.js 提供的机制是子进程事件机制,尤其要用到 exit 事件。来看例子。我们的 master.js 可以改成这个样子。

// master.js
const cluster = require('cluster');

cluster.setupMaster({
  exec: 'worker.js',
});

const workers = [];

const fork = () => {
  const child = cluster.fork();
  console.log('pid %s', child.pid);

  child.on('exit', () => {
    workers.splice(workers.indexOf(child), 1);
    setTimeout(() => {
      fork();
    }, 1000);
  });

  workers.push(child);
}

for (let i = 0; i < 4; i++) {
  fork();
}

这个时候我们试着用 kill 命令杀掉对应的子进程,发现子进程重启成功。

2f441f037155d2bcb4be09781a8a4c6b.png

也就是我们的进程守护已生效。

### 后台常驻的需求

我们知道 pm2 有个进程常驻后台运行的功能。

70a6a5aa472745ced586fa7c4be95b50.png

我们希望我们的程序在终端退出之后还能运行,怎么实现呢?

熟悉 bash 的同学知道 bash 里面可以这样

$ nohup server.js &

对应到 c 语言里面有个 setsid,也是干这个事情。那 node.js 里面怎么实现这个需求?

可以在调用 fork 时传入 options.detached,我们来看个例子。

在之前创建的 master.js 和 worker.js 的同级目录下,创建 command.js,内容如下。

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

const child = fork('./master.js', {
    detached: true,
    stdio: 'ignore'
});

process.exit(0);

执行 node command.js 之后,发现当前进程马上退出了,而 ps 查看发现 worker 和 master 仍在后台常驻,且 master 进程的 ppid 变成了 1。

1d1a36235033755f5e042c6b93f858ec.png

显然是符合我们期望的。

### 停止后台运行的进程

在前台运行的进程,如果我们想要结束运行,似乎我们可以按下键盘的 ctrl + c。

而进程在后台运行之后,如果我们想要结束它的时候,该怎么办呢?

这里要介绍 信号机制。

实际上我们按下键盘的 ctrl + c 的操作,其实也是触发了对当前前台进程的一个信号,这个信号就是 SIGINT。同样的,还有我们常用的 kill 命令,是 shell 给用户提供的触发目标进程信号的方式。比如说以下操作会强制结束进程运行。

$ kill -9 pid

当然我们更推荐使用 SIGTERM 信号,因为进程在接收到这个信号时,一般允许做一些回收工作,比如“优雅”断开与远程服务的长连接。

$ kill pid

那 Node.js 里面怎么使用呢,我们直接给 command.js 加一些代码,以实现我们需要的“停止后台运行进程”功能。

const { fork } = require('child_process');
const fs = require('fs');
const pidfile = '.pid';

if (process.argv[2] === 'stop') {
    const pid = fs.readFileSync(pidfile, 'utf8');

    process.kill(Number(pid), 'SIGTERM');

    fs.unlinkSync(pidfile);
} else {
    const child = fork('./master.js', {
        detached: true,
        stdio: 'ignore'
    });
    
    fs.writeFileSync(pidfile, String(child.pid));

    process.exit(0);
}

然后试着在本地执行,发现能够成功结束在后台运行的进程。

注意:之前运行的进程要先手动杀掉。

### 优雅退出

首先要介绍一个孤儿进程的概念。当前进程在退出时,如果子进程还在运行,子进程会称为孤儿进程,表现是 ppid 会变成 1(好像和后台常驻进程的结果很像。例子在这里。

所以进程守护工具在结束进程时,要考虑到这一点,也就是在接收到进程退出的命令,结束掉所有的子进程,还是通过上一节提到的信号机制。

这里进程管理工具需要做两件事情:

  1. 接收退出信号,对应到 Node.js 内是信号的事件机制;
  2. 利用 ps 命令找出当前进程的所有子进程(可以借助 npm 包 ps-tree 实现。
const cluster = require('cluster');
const util = require('util');
const pstree = require('ps-tree');

cluster.setupMaster({
  exec: 'worker.js',
});

const workers = [];

const fork = () => {
  const child = cluster.fork();

  child.on('exit', () => {
    workers.splice(workers.indexOf(workers), 1);
    setTimeout(() => {
      fork();
    }, 1000);
  });

  workers.push(child);
}

for (let i = 0; i < 4; i++) {
  fork();
}

process.on('SIGTERM', signalExit);
process.on('SIGQUIT', signalExit);
process.on('SIGINT', signalExit);

function signalExit() {
  Promise.all(workers.map((w) => new Promise((resolve) => {
    pstree(w.process.pid, (e, children) => {
      if (e) {
        console.log(e);
        resolve([]);
      } else {
        resolve(children);
      }
    })
  })))
    .then((results) => {
      results.forEach((pids) => {
        pids.forEach((pid) => {
          process.kill(pid.PID, 'SIGTERM');
        })
      });
      process.exit(0);
    }).catch(() => {
      process.exit(1);
    });
}

同样地,我们的 SIGTERM 里面还可以继续做一些其他的回收工作,比如断开与服务端的长连接,清除本地的缓存文件等等。

### 日志记录

到目前为止,我们都没有处理标准输出流,因为我们的进程直接退出了,一些 console 打印的日志似乎就消失无踪了,这是不被希望的。我们希望的是能够将子进程的标准输出重定向到本地的一些日志文件。

可以借助 fork 时传入的 options.stdio 来实现这个需求。

console.log, console.error 等操作其实是往 1 和 2 的文件描述符里面写入内容。而 options.stdio 能够重新定向的文件描述符。以下的操作就能够将 1 和 2 的描述符指向本地文件。

const { fork } = require('child_process');
const fs = require('fs');
const pidfile = '.pid';

if (process.argv[2] === 'stop') {
    const pid = fs.readFileSync(pidfile, 'utf8');

    process.kill(Number(pid), 'SIGTERM');

    fs.unlinkSync(pidfile);
} else {
    const applog = fs.openSync('./app.log', 'a+');
    const errorlog = fs.openSync('./app-error.log', 'a+');
    const child = fork('./master.js', {
        detached: true,
        stdio: [0, applog, errorlog, 'ipc']
    });
    
    fs.writeFileSync(pidfile, String(child.pid));

    process.exit(0);
}

这次启动之后,发现在子进程里面的标准输出最后进入到了 app.log 和 app-error.log 文件内。完成需求。

## 小结

通过本文的学习,我们通过一个进程管理工具的轮子,熟悉了

  • 创建子进程的方式;
    • exec/execFile/spawn/fork
  • IPC 机制实现进程间共用 Socket 的需求;
    • IPC 通信传递 socket(原理是系统级的 IPC 通信传递文件描述符;
  • 最终使用 cluster 的解决方案;
  • 使用进程事件监听子进程退出,然后进行 refork;
  • 使用 options.detached 来创建后台常驻进程;
  • 通过 信号机制 实现后台常驻进程的退出;
  • 了解孤儿进程的存在,并通过 监听信号 的方式,来实现优雅退出;
  • 理解文件描述符,通过 options.stdio 指定子进程的文件描述符,实现标准输入输出重定向到日志的需求。

标粗的部分可以结合 Node.js 官方文档深入理解。再次放上本文例子的链接:https://github.com/ImHype/process-and-thread-in-nodejs。

插播一条广告。字节跳动诚邀优秀的前端工程师和Node.js工程师加入,一起做有趣的事情,欢迎有意者私信联系,或发送简历至 xujunyu.joey@bytedance.com

校招戳 这里(同样欢迎实习生同学。

好了,我们下期再会。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值