ホームページ  >  記事  >  ウェブフロントエンド  >  Nodeを使用して軽量プロセスプールとスレッドプールを実装する方法について話しましょう

Nodeを使用して軽量プロセスプールとスレッドプールを実装する方法について話しましょう

青灯夜游
青灯夜游転載
2022-10-14 20:05:411912ブラウズ

Nodeを使用して軽量プロセスプールとスレッドプールを実装する方法について話しましょう

I. 前言

本文论点主要面向 Node.js 开发语言

>> Show Me Code,目前代码正在 dev 分支,已完成单元测试,尚待测试所有场景。

>> 建议通读 Node.js 官方文档 -【不要阻塞事件循环】

Node.js 即服务端 Javascript,得益于宿主环境的不同,它拥有比在浏览器上更多的能力。比如:完整的文件系统访问权限、网络协议、套接字编程、进程和线程操作、C++ 插件源码级的支持、Buffer 二进制、Crypto 加密套件的天然支持。【相关教程推荐:nodejs视频教程

Node.js 的是一门单线程的语言,它基于 V8 引擎开发,v8 在设计之初是在浏览器端对 JavaScript 语言的解析运行引擎,其最大的特点是单线程,这样的设计避免了一些多线程状态同步问题,使得其更轻量化易上手。

一、名词定义

1. 进程

学术上说,进程是一个具有一定独立功能的程序在一个数据集上的一次动态执行的过程,是操作系统进行资源分配和调度的一个独立单位,是应用程序运行的载体。我们这里将进程比喻为工厂的车间,它代表 CPU 所能处理的单个任务。任一时刻,CPU 总是运行一个进程,其他进程处于非运行状态。

进程具有以下特性:

  • 进程是拥有资源的基本单位,资源分配给进程,同一进程的所有线程共享该进程的所有资源;
  • 进程之间可以并发执行;
  • 在创建或撤消进程时,系统都要为之分配和回收资源,与线程相比系统开销较大;
  • 一个进程可以有多个线程,但至少有一个线程;

2. 线程

在早期的操作系统中并没有线程的概念,进程是能拥有资源和独立运行的最小单位,也是程序执行的最小单位。任务调度采用的是时间片轮转的抢占式调度方式,而进程是任务调度的最小单位,每个进程有各自独立的一块内存,使得各个进程之间内存地址相互隔离。

后来,随着计算机的发展,对 CPU 的要求越来越高,进程之间的切换开销较大,已经无法满足越来越复杂的程序的要求了。于是就发明了线程,线程是程序执行中一个单一的顺序控制流程,是程序执行流的最小单元。这里把线程比喻一个车间的工人,即一个车间可以允许由多个工人协同完成一个任务,即一个进程中可能包含多个线程。

线程具有以下特性:

  • 线程作为调度和分配的基本单位;
  • 多个线程之间也可并发执行;
  • 线程是真正用来执行程序的,执行计算的;
  • 线程不拥有系统资源,但可以访问隶属于进程的资源,一个线程只能属于一个进程;

Node.js 的多进程有助于充分利用 CPU 等资源,Node.js 的多线程提升了单进程上任务的并行处理能力。

在 Node.js 中,每个 worker 线程都有他自己的 V8 实例和事件循环机制 (Event Loop)。但是,和进程不同,workers 之间是可以共享内存的。

二、Node.js 异步机制

1. Node.js 内部线程池、异步机制以及宏任务优先级划分

Node.js 的单线程是指程序的主要执行线程是单线程,这个主线程同时也负责事件循环。而其实语言内部也会创建线程池来处理主线程程序的 网络 IO / 文件 IO / 定时器 等调用产生的异步任务。一个例子就是定时器 Timer 的实现:在 Node.js 中使用定时器时,Node.js 会开启一个定时器线程进行计时,计时结束时,定时器回调函数会被放入位于主线程的宏任务队列。当事件循环系统执行完主线程同步代码和当前阶段的所有微任务时,该回调任务最后再被取出执行。所以 Node.js 的定时器其实是不准确的,只能保证在预计时间时我们的回调任务被放入队列等待执行,而不是直接被执行。

Nodeを使用して軽量プロセスプールとスレッドプールを実装する方法について話しましょう

マルチスレッド メカニズムは Node.js のイベント ループ システムと連携し、開発者がタイマー、IO、ネットワーク リクエストなどの非同期メカニズムを 1 つのスレッドで使用できるようにします。ただし、応答性が高く、パフォーマンスの高いサーバーを実現するために、Node.js のイベント ループではマクロ タスクがさらに優先されます。

Nodeを使用して軽量プロセスプールとスレッドプールを実装する方法について話しましょう

Node.js マクロ タスク間の優先順位付け: タイマー > 保留中 > ポーリング > チェック > 閉じる。

  • タイマー コールバック: 時間に関しては、実行が早ければ早いほど正確になるため、これが最も優先され、理解しやすくなります。
  • 保留中のコールバック: ネットワーク、IO、その他の例外を処理するときのコールバック。一部の UNIX システムはエラーが報告されるのを待機するため、エラーを処理する必要があります。
  • ポーリング コールバック: IO データとネットワーク接続を処理し、サーバーが主に処理します。
  • Check Callback: setImmediateを実行するためのコールバックで、IO実行直後にコールバックできるのが特徴です。
  • Close Callback: リソースを閉じるためのコールバック。実行の遅延は影響せず、優先度が最も低くなります。

Node.js の最適化とマイクロタスク間の分割: process.nextTick > Promise。

2. Node.js マクロタスクとマイクロタスクの実行タイミング

ノード 11 より前では、Node.js のイベント ループが 1 つずつ実行されませんでした。ブラウザと同様に、マクロ タスクを実行し、その後すべてのマイクロ タスクを実行しますが、一定数のタイマー マクロ タスクを実行し、その後すべてのマイクロ タスクを実行し、次に一定数の保留中のマクロ タスクを実行し、その後すべてのマイクロ タスクを実行し、残りのタスクを実行します。ポーリング チェックとクローズのマクロ タスクにも同じことが当てはまります。ノード11以降はマクロタスクごとにすべてのマイクロタスクを実行するように変更されました。

Node.js のマクロ タスクにも優先順位があります。Node.js のイベント ループが現在の優先順位のすべてのマクロ タスクを毎回実行すると、次の優先順位が実行されます。マクロ タスクは、 「飢餓」状態の発生。あるステージのマクロタスクが多すぎると次のステージが実行されなくなるため、マクロタスクの種類ごとに実行回数を制限する仕組みを持ち、残りは後続のイベントループに引き継いで実行を継続します。 。

最終的なパフォーマンスは次のとおりです。つまり、一定数のタイマー マクロ タスクを実行し、各マクロ タスク間ですべてのマイクロ タスクを実行し、次に一定数の保留コールバック マクロ タスクを実行し、各マクロ タスク間ですべてのマイクロ タスクを実行します。各マクロタスク。タスク。

#3. Node.js マルチプロセス

##1. child_process メソッドを使用してプロセスを手動で作成します

Node.js プログラムは、child_process モジュールを通じて子プロセスを生成する機能を提供します。child_process は、子プロセスを作成するためのさまざまな方法を提供します。結果データはイベントでしか取得できないため、操作が面倒です。

const spawn = require('child_process').spawn;
const ls = spawn('ls', ['-lh', '/usr']);

ls.stdout.on('data', (data) => {
  console.log(`stdout: ${data}`);
});

ls.stderr.on('data', (data) => {
  console.log(`stderr: ${data}`);
});

ls.on('close', (code) => {
  console.log(`child process exited with code ${code}`);
});
  • execFile 新しいプロセスを作成し、その背後にあるファイル名に従って実行可能ファイルを実行します。オプションを指定して呼び出し結果を次の形式で返すことができます。 Complete データを取得できる方がはるかに便利です。
execFile('/path/to/node', ['--version'], function(error, stdout, stderr){
    if(error){
        throw error;
    }
    console.log(stdout);
});
  • exec 新しいプロセスを作成し、シェルコマンドを直接実行することでシェルコマンドの実行方法が簡略化され、実行結果はコールバックモードで返されます。
exec('ls -al', function(error, stdout, stderr){
    if(error) {
        console.error('error:' + error);
        return;
    }
    console.log('stdout:' + stdout);
    console.log('stderr:' + typeof stderr);
});
  • fork 新しいプロセスを作成し、ノード プログラムを実行します。プロセスには完全な V8 インスタンスがあります。作成後、メイン プロセスから子プロセスへの IPC 通信が行われます。プロセスが自動的に有効になり、リソースが占有されます。
var child = child_process.fork('./anotherSilentChild.js', {
    silent: true
});

child.stdout.setEncoding('utf8');
child.stdout.on('data', function(data){
    console.log(data);
});
その中で、spawn はすべてのメソッドの基礎であり、exec の最下層は execFile を呼び出します。
  • 2. クラスター メソッドを使用してプロセスを半自動的に作成する
次は、

Cluster

モジュールを使用してプロセスを作成する簡単な例です。 http サービス クラスター。この例では、クラスターの作成時に同じ Js 実行ファイルが使用されており、ファイル内で

cluster.isPrimary を使用して、現在の実行環境がメインプロセスにあるか子プロセスにあるかを判断します。メインプロセスの場合、現在の実行ファイルを使用して子プロセスインスタンスを作成し、子プロセスの場合は子プロセスの業務処理フローに入ります。
/*
  简单示例:使用同一个 JS 执行文件创建子进程集群 Cluster
*/
const cluster = require('node:cluster');
const http = require('node:http');
const numCPUs = require('node:os').cpus().length;
const process = require('node:process');

if (cluster.isPrimary) {
  console.log(`Primary ${process.pid} is running`);
  // Fork workers.
  for (let i = 0; i  {
    console.log(`worker ${worker.process.pid} died`);
  });
} else {
  // Workers can share any TCP connection
  http.createServer((req, res) => {
    res.writeHead(200);
    res.end('hello world\n');
  }).listen(8000);
  console.log(`Worker ${process.pid} started`);
}

Cluster このモジュールでは、メイン プロセスといくつかのサブプロセスを確立できます。child_process.fork() を使用して、内部的にサブプロセスを暗黙的に作成します。メインプロセスによって監視および制御され、子プロセスの実行を調整します。

プロセス間通信は、サブプロセス間のメッセージの交換に使用されます。クラスター モジュールにはロード バランサーが組み込まれており、ラウンドロビン アルゴリズム (順番に実行) を使用してサブプロセス間の負荷を調整します。 。実行中、新しく確立された接続はすべてメイン プロセスによって完了され、メイン プロセスは指定された子プロセスに TCP 接続を割り当てます。

クラスターを使用して作成された子プロセスは、同じポートを使用できます。Node.js は、http/net 組み込みモジュールを特別にサポートしています。 Node.js のメイン プロセスはターゲット ポートをリッスンする役割を果たし、リクエストを受信した後、負荷分散ポリシーに従ってリクエストを子プロセスに分散します。

3. 使用基于 Cluster 封装的 PM2 工具全自动创建进程

PM2 是常用的 node 进程管理工具,它可以提供 node.js 应用管理能力,如自动重载、性能监控、负载均衡等。

其主要用于 独立应用 的进程化管理,在 Node.js 单机服务部署方面比较适合。可以用于生产环境下启动同个应用的多个实例提高 CPU 利用率、抗风险、热加载等能力。

由于是外部库,需要使用 npm 包管理器安装:

$: npm install -g pm2

pm2 支持直接运行 server.js 启动项目,如下:

$: pm2 start server.js

即可启动 Node.js 应用,成功后会看到打印的信息:

┌──────────┬────┬─────────┬──────┬───────┬────────┬─────────┬────────┬─────┬───────────┬───────┬──────────┐
│ App name │ id │ version │ mode │ pid   │ status │ restart │ uptime │ cpu │ mem       │ user  │ watching │
├──────────┼────┼─────────┼──────┼───────┼────────┼─────────┼────────┼─────┼───────────┼───────┼──────────┤
│ server   │ 0  │ 1.0.0   │ fork │ 24776 │ online │ 9       │ 19m    │ 0%  │ 35.4 MB   │ 23101 │ disabled │
└──────────┴────┴─────────┴──────┴───────┴────────┴─────────┴────────┴─────┴───────────┴───────┴──────────┘

pm2 也支持配置文件启动,通过配置文件 ecosystem.config.js 可以定制 pm2 的各项参数:

module.exports = {
  apps : [{
    name: 'API', // 应用名
    script: 'app.js', // 启动脚本
    args: 'one two', // 命令行参数
    instances: 1, // 启动实例数量
    autorestart: true, // 自动重启
    watch: false, // 文件更改监听器
    max_memory_restart: '1G', // 最大内存使用亮
    env: { // development 默认环境变量
      // pm2 start ecosystem.config.js --watch --env development
      NODE_ENV: 'development'
    },
    env_production: { // production 自定义环境变量
      NODE_ENV: 'production'
    }
  }],

  deploy : {
    production : {
      user : 'node',
      host : '212.83.163.1',
      ref  : 'origin/master',
      repo : 'git@github.com:repo.git',
      path : '/var/www/production',
      'post-deploy' : 'npm install && pm2 reload ecosystem.config.js --env production'
    }
  }
};

pm2 logs 日志功能也十分强大:

$: pm2 logs

II. Node.js 中进程池和线程池的适用场景

一般我们使用计算机执行的任务包含以下几种类型的任务:

  • 计算密集型任务:任务包含大量计算,CPU 占用率高。

    const matrix = {};
    for (let i = 0; i 
  • IO 密集型任务:任务包含频繁的、持续的网络 IO 和磁盘 IO 的调用。

    const {copyFileSync, constants} = require('fs');
    copyFileSync('big-file.zip', 'destination.zip');
  • 混合型任务:既有计算也有 IO。

一、进程池的适用场景

使用进程池的最大意义在于充分利用多核 CPU 资源,同时减少子进程创建和销毁的资源消耗

进程是操作系统分配资源的基本单位,使用多进程架构能够更多的获取 CPU 时间、内存等资源。为了应对 CPU-Sensitive 场景,以及充分发挥 CPU 多核性能,Node 提供了 child_process 模块用于创建子进程。

子进程的创建和销毁需要较大的资源成本,因此池化子进程的创建和销毁过程,利用进程池来管理所有子进程。

除了这一点,Node.js 中子进程也是唯一的执行二进制文件的方式,Node.js 可通过流 (stdin/stdout/stderr) 或 IPC 和子进程通信。

通过 Stream 通信

const {spawn} = require('child_process');
const ls = spawn('ls', ['-lh', '/usr']);

ls.stdout.on('data', (data) => {
  console.log(`stdout: ${data}`);
});

ls.stderr.on('data', (data) => {
  console.error(`stderr: ${data}`);
});

ls.on('close', (code) => {
  console.log(`child process exited with code ${code}`);
});

通过 IPC 通信

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'});

二、线程池的适用场景

使用线程池的最大意义在于多任务并行,为主线程降压,同时减少线程创建和销毁的资源消耗。单个 CPU 密集性的计算任务使用线程执行并不会更快,甚至线程的创建、销毁、上下文切换、线程通信、数据序列化等操作还会额外增加资源消耗。

但是如果一个计算机程序中有很多同一类型的阻塞任务需要执行,那么将他们交给线程池可以成倍的减少任务总的执行时间,因为在同一时刻多个线程在并行进行计算。如果多个任务只使用主线程执行,那么最终消耗的时间是线性叠加的,同时主线程阻塞之后也会影响其它任务的处理。

特别是对 Node.js 这种单主线程的语言来讲,主线程如果消耗了过多的时间来执行这些耗时任务,那么对整个 Node.js 单个进程实例的性能影响将是致命的。这些占用着 CPU 时间的操作将导致其它任务获取的 CPU 时间不足或 CPU 响应不够及时,被影响的任务将进入 “饥饿” 状态。

因此 Node.js 启动后主线程应尽量承担调度的角色,批量重型 CPU 占用任务的执行应交由额外的工作线程处理,主线程最后拿到工作线程的执行结果再返回给任务调用方。另一方面由于 IO 操作 Node.js 内部作了优化和支持,因此 IO 操作应该直接交给主线程,主线程再使用内部线程池处理。

Node.js 的异步能不能解决过多占用 CPU 任务的执行问题?

答案是:不能,过多的异步 CPU 占用任务会阻塞事件循环。

Node.js 的异步在 网络 IO / 磁盘 IO 处理上很有用,宏任务微任务系统 + 内部线程调用能分担主进程的执行压力。但是如果单独将 CPU 占用任务放入宏任务队列或微任务队列,对任务的执行速度提升没有任何帮助,只是一种任务调度方式的优化而已。

我们只是延迟了任务的执行或是将巨大任务分散成多个再分批执行,但是任务最终还是要在主线程被执行。如果这类任务过多,那么任务分片和延迟的效果将完全消失,一个任务可以,那十个一百个呢?量变将会引起质变。

以下是 Node.js 官方博客中的原文:

“如果你需要做更复杂的任务,拆分可能也不是一个好选项。这是因为拆分之后任务仍然在事件循环线程中执行,并且你无法利用机器的多核硬件能力。 请记住,事件循环线程只负责协调客户端的请求,而不是独自执行完所有任务。 对一个复杂的任务,最好把它从事件循环线程转移到工作线程池上。”

  • 场景:间歇性让主进程 瘫痪

每一秒钟,主线程有一半时间被占用

// this task costs 100ms
function doHeavyTask() { ...}

setInterval(() => {
  doHeavyTask(); // 100ms
  doHeavyTask(); // 200ms
  doHeavyTask(); // 300ms
  doHeavyTask(); // 400ms
  doHeavyTask(); // 500ms
}, 1e3);
  • 场景:高频性让主进程 半瘫痪

每 200ms,主线程有一半时间被占用

// this task costs 100ms
function doHeavyTask() { ...}

setInterval(() => {
  doHeavyTask();
}, 1e3);

setInterval(() => {
  doHeavyTask();
}, 1.2e3);

setInterval(() => {
  doHeavyTask();
}, 1.4e3);

setInterval(() => {
  doHeavyTask();
}, 1.6e3);

setInterval(() => {
  doHeavyTask();
}, 1.8e3);

以下是官方博客的原文摘录:

“因此,你应该保证永远不要阻塞事件轮询线程。换句话说,每个 JavaScript 回调应该快速完成。这些当然对于 await,Promise.then 也同样适用。”

III. 进程池

进程池是对进程的创建、执行任务、销毁等流程进行管控的一个应用或是一套程序逻辑。之所以称之为池是因为其内部包含多个进程实例,进程实例随时都在进程池内进行着状态流转,多个创建的实例可以被重复利用,而不是每次执行完一系列任务后就被销毁。因此,进程池的部分存在目的是为了减少进程创建的资源消耗。

此外进程池最重要的一个作用就是负责将任务分发给各个进程执行,各个进程的任务执行优先级取决于进程池上的负载均衡运算,由算法决定应该将当前任务派发给哪个进程,以达到最高的 CPU 和内存利用率。常见的负载均衡算法有:

  • POLLING - 轮询:子进程轮流处理请求
  • WEIGHTS - 权重:子进程根据设置的权重来处理请求
  • RANDOM - 随机:子进程随机处理请求
  • SPECIFY - 指定:子进程根据指定的进程 id 处理请求
  • WEIGHTS_POLLING - 权重轮询:权重轮询策略与轮询策略类似,但是权重轮询策略会根据权重来计算子进程的轮询次数,从而稳定每个子进程的平均处理请求数量。
  • WEIGHTS_RANDOM - 权重随机:权重随机策略与随机策略类似,但是权重随机策略会根据权重来计算子进程的随机次数,从而稳定每个子进程的平均处理请求数量。
  • MINIMUM_CONNECTION - 最小连接数:选择子进程上具有最小连接活动数量的子进程处理请求。
  • WEIGHTS_MINIMUM_CONNECTION - 权重最小连接数:权重最小连接数策略与最小连接数策略类似,不过各个子进程被选中的概率由连接数和权重共同决定。

一、要点

「 对单一任务的控制不重要,对单个进程宏观的资源占用更需关注 」

二、流程设计

进程池架构图参考之前的进程管理工具开发相关 文章,本文只需关注进程池部分。

Nodeを使用して軽量プロセスプールとスレッドプールを実装する方法について話しましょう

1. 关键流程

  • 进程池创建进程时会初始化进程实例内的 ProcessHost 事务对象,进程实例向事务对象注册多种任务监听器。
  • 用户向进程池发起单个任务调用请求,可传入进程绑定的 ID 和指定的任务名。
  • 判断用户是否传入 ID 参数指定使用某个进程执行任务,如果未指定 ID:
    • 进程池判断当前进程池进程数量是否已超过最大值,如果未超过则创建新进程,用此进程处理当前任务,并将进程放入进程池。
    • 如果进程池进程数量已达最大值,则根据负载均衡算法选择一个进程处理当前任务。
  • 指定 ID 时:
    • 通过用户传入的 ID 参数找到对应进程,将任务分发给此进程执行。
    • 如果未找到 ID 所对应的进程,则向用户抛出异常。
  • 任务由进程池派发给目标进程后,ProcessHost 事务对象会根据该任务的任务名触发子进程内的监听器。
  • 子进程内的监听器函数可执行同步任务和异步任务,异步任务返回 Promise 对象,同步任务返回值。
  • ProcessHost 事务对象的监听器函数执行完毕后,会将任务结果返回给进程池,进程池再将结果通过异步回调函数返回给用户。
  • 用户也可向进程池所有子进程发起个任务调用请求,最终将会通过 Promise 的返回所有子进程的任务执行结果。

2. 名词解释

  • ProcessHost 事务中心:运行在子进程中,用于事件触发以及和主进程通信。开发者在子进程执行文件中向其注册多个具有特定任务名的任务事件,主进程会向某个子进程发送任务请求,并由事务中心调用指定的事件监听器处理请求。
  • LoadBalancer 负载均衡器:用于选择一个进程处理任务,可根据不同的负载均衡算法实现不同的选择策略。
  • LifeCycle: 设计之初用于管控子进程的智能启停,某个进程在长时间未被使用时进入休眠状态,当有新任务到来时再唤醒进程。目前还有些难点需要解决,比如进程的唤醒和休眠不好实现,进程的使用情况不好统计,该功能暂时不可用。

三、进程池使用方式

更多示例见:进程池 mocha 单元测试

1. 创建进程池

main.js

const { ChildProcessPool, LoadBalancer } = require('electron-re');

const processPool = new ChildProcessPool({
  path: path.join(__dirname, 'child_process/child.js'),
  max: 4,
  strategy: LoadBalancer.ALGORITHM.POLLING,
);

child.js

const { ProcessHost } = require('electron-re');

ProcessHost
  .registry('test1', (params) => {
    console.log('test1');
    return 1 + 1;
  })
  .registry('test2', (params) => {
    console.log('test2');
    return new Promise((resolve) => resolve(true));
  });

2. 向一个子进程发送任务请求

processPool.send('test1', { value: "test1"}).then((result) => {
  console.log(result);
});

3. 向所有子进程发送任务请求

processPool.sendToAll('test1', { value: "test1"}).then((results) => {
  console.log(results);
});

四、进程池实际使用场景

1. Electron 网页代理工具中多进程的应用

1)基本代理原理:

Nodeを使用して軽量プロセスプールとスレッドプールを実装する方法について話しましょう

2)单进程下客户端执行原理:

  • 通过用户预先保存的服务器配置信息,使用 node.js 子进程来启动 ss-local 可执行文件建立和 ss 服务器的连接来代理用户本地电脑的流量,每个子进程占用一个 socket 端口。
  • 其它支持 socks5 代理的 proxy 工具比如:浏览器上的 SwitchOmega 插件会和这个端口的 tcp 服务建立连接,将 tcp 流量加密后通过代理服务器转发给我们需要访问的目标服务器。

Nodeを使用して軽量プロセスプールとスレッドプールを実装する方法について話しましょう

3)多进程下客户端执行原理:

以上描述的是客户端连接单个节点的工作模式,节点订阅组中的负载均衡模式需要同时启动多个子进程,每个子进程启动 ss-local 执行文件占用一个本地端口并连接到远端一个服务器节点。

每个子进程启动时选择的端口是会变化的,因为某些端口可能已经被系统占用,程序需要先选择未被使用的端口。并且浏览器 proxy 工具也不可能同时连接到我们本地启动的子进程上的多个 ss-local 服务上。因此需要一个占用固定端口的中间节点接收 proxy 工具发出的连接请求,然后按照某种分发规则将 tcp 流量转发到各个子进程的 ss-local 服务的端口上。

Nodeを使用して軽量プロセスプールとスレッドプールを実装する方法について話しましょう

2. 複数プロセスのファイル分割アップロード Electron クライアント

以前、SMB プロトコルの複数ファイル分割アップロード、Node.js 側でのアップロードタスク管理をサポートするクライアントを作成しました。 、IO 操作などはすべて 1 つのバージョンでマルチプロセスを使用して実装されていますが、gitlab 実験ブランチで自分で (エスケープして) 実行しました。

Nodeを使用して軽量プロセスプールとスレッドプールを実装する方法について話しましょう

IV. スレッド プール

CPU を集中的に使用するタスク計算のシステム オーバーヘッドを削減するために、Node.js では新機能:worker_threads。v10.5.0 で実験的な機能として初めて登場しました。プロセス内では、worker_threads を介して複数のスレッドを作成できます。メイン スレッドとワーカー スレッドは、parentPort を使用して通信し、ワーカー スレッドは、MessageChannel を介して直接通信できます。開発者がスレッドを使用するための重要な機能として、worker_threads は v12.11.0 の安定版では実稼働環境で通常どおり使用できます。

ただし、スレッドの作成には追加の CPU リソースとメモリ リソースが必要です。スレッドを複数回使用する場合は、スレッドを保存する必要があります。スレッドがまったく使用されない場合は、適切なタイミングでスレッドを閉じる必要があります。メモリ使用量を削減します。スレッドを使用する必要があるときに直接スレッドを作成し、使用後すぐに破棄することを想像してください。おそらく、スレッド自体の作成と破棄にかかるコストが、スレッド自体を使用することで節約されるリソースのコストを超えている可能性があります。 Node.js は内部的にスレッド プールを使用しますが、完全に透過的で開発者には見えないため、スレッドのライフ サイクルを維持できるスレッド プール ツールをカプセル化することの重要性が反映されています。

複数の非同期タスクのスケジューリングを強化するために、スレッド プールはスレッドを維持する機能を提供するだけでなく、タスク キューを維持する機能も提供します。非同期タスクを実行するリクエストをスレッド プールに送信するときに、スレッド プールにアイドル状態のスレッドがない場合、タスクは直接破棄されますが、これは明らかに望ましい結果ではありません。

したがって、スレッド プールにタスク キューのスケジューリング ロジックを追加することを検討できます。スレッド プールにアイドル状態のスレッドがない場合、タスクをタスク キューに入れて実行 (FIFO) すると、スレッド プールはある機会にタスクを取り出し、アイドル状態のスレッドで実行され、実行完了後に非同期コールバック関数がトリガーされ、実行結果がリクエスト呼び出し元に返されます。ただし、スレッド プールのタスク キュー内のタスクの数は、スレッド プールの過剰な負荷が Node.js アプリケーションの全体的なパフォーマンスに影響を与えるのを防ぐために、特別な値に制限する必要があります。

1. 重要なポイント

「単一のタスクを制御することが重要であり、リソースの占有を気にする必要はありません」シングルスレッドの「

2. 詳細設計

Nodeを使用して軽量プロセスプールとスレッドプールを実装する方法について話しましょう

タスクフロープロセス

  • 呼び出し元は、StaticPool/StaticExcutor/DynamicPool/DynamicExcutor インスタンスを通じてタスクをスレッド プールにディスパッチできます (重要な用語については以下で説明します)。インスタンスはパラメータの動的能力です。

  • タスクはスレッド プールによって内部的に生成されます。生成後、タスクはメインの転送キャリアとして使用されます。一方で、タスクはユーザーによって渡されたタスク計算パラメータを運びます。一方、タスク転送プロセスのステータス (タスクのステータス、開始時刻、終了時刻、タスク ID、タスクのリトライ回数、タスクがリトライをサポートするかどうか、タスクの種類など) の変更を記録します。

  • タスク生成後、まず現在のスレッドプールのスレッド数が上限に達しているかどうかを確認し、上限に達していない場合は新規スレッドを作成し、それをスレッド格納領域に置き、そのスレッドを使用して現在のタスクを直接実行します。

  • スレッド プールのスレッド数が制限を超えた場合は、タスクを実行していないアイドル状態のスレッドがあるかどうかを確認し、アイドル状態のスレッドを取得した後、このスレッドを使用して現在のタスクを直接実行します。

  • アイドルスレッドがない場合は、現在待機中のタスクキューが満杯であるかどうかが判断され、タスクキューが満杯の場合はエラーがスローされるため、呼び出し元はそれを認識できます。タスクが初めて正常に実行されていないことを意味します。

  • タスクキューがいっぱいでない場合は、タスクをタスクキューに入れ、タスクサイクルシステムがタスクを取り出して実行するのを待ちます。

  • 上記手順 4/5/6 の 3 つのケースでタスクが実行された後、タスクの実行が成功したかどうかが判定され、成功した場合は成功コールバック関数がトリガーされ、 Promise ステータスが満たされます。失敗した場合は、再試行がサポートされているかどうかを確認します。再試行がサポートされている場合、タスクは 1 回再試行され、タスク キューの最後に戻されます。タスクが再試行をサポートしていない場合、タスクは直接失敗し、失敗した非同期コールバック関数がトリガーされ、Promise ステータスは拒否されます。

  • スレッド プールのライフ サイクル全体には、タスク サイクル システムがあり、一定の周期でタスク キューの先頭からタスクを取得し、スレッド ストレージからアイドル スレッドを取得します。タスクの処理もステップ7の記述に従います。

  • 任务循环系统除了取任务执行,如果线程池设置了任务超时时间的话,也会判断正在执行中的任务是否超时,超时后会终止该线程的所有运行中的代码。

模块说明

  • StaticPool
    • 定义:静态线程池,可使用固定的 execFunction/execString/execFile 执行参数来启动工作线程,执行参数在进程池创建后不能更改。
    • 进程池创建之后除了执行参数不可变外,其它参数比如:任务超时时间、任务重试次数、线程池任务轮询间隔时间、最大任务数、最大线程数、是否懒创建线程等都可以通过 API 随时更改。
  • StaticExcutor
    • 定义:静态线程池的执行器实例,继承所属线程池的固定执行参数 execFunction/execString/execFile 且不可更改。
    • 执行器实例创建之后除了执行参数不可变外,其它参数比如:任务超时时间、任务重试次数、transferList 等都可以通过 API 随时更改。
    • 静态线程池的各个执行器实例的参数设置互不影响,参数默认继承于所属线程池,参数在执行器上更改后具有比所属线程池同名参数更高的优先级。
  • DynamicPool
    • 定义:动态线程池,无需使用 execFunction/execString/execFile 执行参数即可创建线程池。执行参数在调用 exec() 方法时动态传入,因此执行参数可能不固定。
    • 线程池创建之后执行参数默认为 null,其它参数比如:任务超时时间、任务重试次数、transferList 等都可以通过 API 随时更改。
  • DynamicExcutor
    • 定义:动态线程池的执行器实例,继承所属线程池的其它参数,执行参数为 null
    • 执行器实例创建之后,其它参数比如:任务超时时间、任务重试次数、transferList 等都可以通过 API 随时更改。
    • 动态线程池的各个执行器实例的参数设置互不影响,参数默认继承于所属线程池,参数在执行器上更改后具有比所属线程池同名参数更高的优先级。
    • 动态执行器实例在执行任务之前需要先设置执行参数 execFunction/execString/execFile,执行参数可以随时改变。
  • ThreadGenerator
    • 定义:线程创建的工厂方法,会进行参数校验。
  • Thread
    • 定义:线程实例,内部简单封装了 worker_threads API。
  • TaskGenerator
    • 定义:任务创建的工厂方法,会进行参数校验。
  • Task
    • 定义:单个任务,记录了任务执行状态、任务开始结束时间、任务重试次数、任务携带参数等。
  • TaskQueue
    • 定义:任务队列,在数组中存放任务,以先入先出方式 (FIFO) 向线程池提供任务,使用 Map 来存储 taskId 和 task 之间的映射关系。
  • Task Loop
    • 任务循环,每个循环的默认时间间隔为 2S,每次循环中会处理超时任务、将新任务派发给空闲线程等。

三、线程池使用方式

更多示例见:线程池 mocha 单元测试

1. 创建静态线程池

main.js

const { StaticThreadPool } = require(`electron-re`);
const threadPool = new StaticThreadPool({
  execPath: path.join(__dirname, './worker_threads/worker.js'),
  lazyLoad: true, // 懒加载
  maxThreads: 24, // 最大线程数
  maxTasks: 48, // 最大任务数
  taskRetry: 1, // 任务重试次数
  taskLoopTime: 1e3, // 任务轮询时间
});
const executor = threadPool.createExecutor();

worker.js

const fibonaccis = (n) => {
  if (n  {
  return fibonaccis(value);
}

2. 使用静态线程池发送任务请求

threadPool.exec(15).then((res) => {
  console.log(+res.data === 610)
});

executor
  .setTaskRetry(2) // 不影响 pool 的全局设置
  .setTaskTimeout(2e3) // 不影响 pool 的全局设置
  .exec(15).then((res) => {
    console.log(+res.data === 610)
  });

3. 动态线程池和动态执行器

const { DynamicThreadPool } = require(`electron-re`);
const threadPool = new DynamicThreadPool({
  maxThreads: 24, // 最大线程数
  maxTasks: 48, // 最大任务数
  taskRetry: 1, // 任务重试次数
});
const executor = threadPool.createExecutor({
  execFunction: (value) => { return 'dynamic:' + value; },
});

threadPool.exec('test', {
  execString: `module.exports = (value) => { return 'dynamic:' + value; };`,
});
executor.exec('test');
executor
  .setExecPath('/path/to/exec-file.js')
  .exec('test');

四、线程池实际使用场景

暂未在项目中实际使用,可考虑在前端图片像素处理、音视频转码处理等 CPU 密集性任务中进行实践。

这里有篇文章写了 web_worker 的一些应用场景,web_worker 和 worker_threads 是类似的,宿主环境不同,一些权限和能力的不同而已。

V.終わり

始まりProjectはElectronアプリケーション開発用のツールセットとして提供されていますBrowserService/ChildProcessPool/簡易プロセス監視UI/Inter -プロセス通信およびその他の機能では、スレッド プールの追加は実際には当初計画されていませんでした。スレッド プール自体は独立しており、electron-re 内の他のモジュール機能に依存せず、独立したものである必要があります。未来。

プロセス プールとスレッド プールの実装を改善する必要があります。

たとえば、プロセス プールは、リソース占有を解放するためのアイドル状態の子プロセスの自動終了をサポートしていません。その際、ProcessHost のタスクの実行状況を監視する別のバージョンが作成されました。子プロセスがアイドル状態のときにスリープするので、この方法でリソースの占有を節約したかったのです。ただし、子プロセスのアイドル状態を区別するためのnode.js API レベルのサポートがなく、子プロセスのスリープ/ウェイクアップ機能は比較的役に立たないため (SIGSTOP を送信することでこれを実現しようとする試みがあります) /SIGCONT は子プロセスにシグナルを送ります)、最終的にこの機能は非推奨になりました。

CPU/メモリ負荷分散アルゴリズムのサポートについては、後で検討することができますが、現時点では、プロジェクトの ProcessManager モジュールを通じてリソース占有率の収集を実装しています。

スレッド プールの相対的な可用性は依然として高く、pool/excutor の 2 レベルの呼び出し管理を提供し、チェーン呼び出しをサポートし、データ送信パフォーマンスが必要な一部のシナリオでサポートされています。データのクローン作成を回避するための transferList メソッド。他のオープンソースのノードスレッドプールソリューションと比較して、タスクキュー機能の強化に重点を置き、タスクリトライやタスクタイムアウトなどの機能をサポートします。

ノード関連の知識の詳細については、nodejs チュートリアル を参照してください。

以上がNodeを使用して軽量プロセスプールとスレッドプールを実装する方法について話しましょうの詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。

声明:
この記事はjuejin.cnで複製されています。侵害がある場合は、admin@php.cn までご連絡ください。