头图

>> Original link

Some of the tools and methods implemented in the article are in the early/testing stage and are still being optimized, for reference only...

Develop/test on Ubuntu20.04, can be used for Electron project, test version: Electron@8.2.0 / 9.3.5

Contents


├── Contents (you are here!)
│
├── I. 前言
├── II. 架构图
│
├── III.electron-re 可以用来做什么?
│   ├── 1) 用于 Electron 应用
│   └── 2) 用于 Electron/Nodejs 应用
│
├── IV. UI 功能介绍
│   ├── 主界面
│   ├── 功能1:Kill 进程
│   ├── 功能2:一键开启 DevTools
│   ├── 功能3:查看进程日志
│   ├── 功能4:查看进程 CPU/Memory 占用趋势
│   └── 功能5:查看 MessageChannel 请求发送日志
│
├── V. 新特性:进程池负载均衡
│   ├── 关于负载均衡
│   ├── 负载均衡策略说明
│   ├── 负载均衡策略的简易实现
│   ├── 负载均衡器的实现
│   └── 进程池配合 LoadBalancer 来实现负载均衡
│
├── VI. 新特性:子进程智能启停
│   ├── 使进程休眠的各种方式
│   ├── 生命周期 LifeCycle 的实现
│   └── 进程互斥锁的雏形
│
├── VII. 存在的已知问题
├── VIII. Next To Do
│
├── IX. 几个实际使用示例
│   ├── 1) Service/MessageChannel 使用示例
│   ├── 2) 一个实际用于生产项目的例子
│   ├── 3) ChildProcessPool/ProcessHost 使用示例
│   ├── 3) test 测试目录示例
│   └── 4) github README 说明
│

I. Preface


When I was doing Electron application development, I wrote an Electron process management tool electron-re , which supports Electron/Node multi-process management, service simulation, real-time process monitoring (UI function), Node.js process pool and other features. It has been released as an npm component and can be installed directly (the latest features have not been released online, and need to be tested):

>> github address

$: npm install electron-re --save
# or
$: yarn add electron-re

The first two articles on this topic:

  1. "Electron/Node Multi-Process Tool Development Diary" describes the electron-re , the targeted problem scenarios and detailed usage methods.
  2. "Electron Multi-Process Tool Development Diary 2" introduces the development and use of the new feature "Multi-Process Management UI". UI interface based electron-re existing BrowserService/MessageChannel and ChildProcessPool/ProcessHost infrastructure drive, use React17 / Babel7 development.

This article mainly describes the recently supported new features of the process pool module-"process pool load balancing" and "child process intelligent start and stop", and related basic implementation principles. At the same time, I put forward some of the problems I encountered, as well as the thinking and solutions to these problems, and some ideas for subsequent iterations.

II. electron-re architecture diagram


archtecture

  • Electron Core : A series of core functions of the Electron application, including the main application process, rendering process, window, etc. (Electron comes with it).
  • BrowserWindow : Rendering window process, generally used for UI rendering (Electron comes with it).
  • ProcessManager : Process manager, responsible for process occupancy resource collection, asynchronous refresh UI, response and sending various process management signals, as an observer object to provide services to other modules and UI (electron-re introduction).
  • MessageChannel : A message sending tool suitable for the main process, rendering process, and service process. It is based on native IPC encapsulation and mainly serves BrowserService. It can also replace the native IPC communication method (introduced by electron-re).
  • ChildProcess : A child process generated by the child_process.fork method, but a simple process sleep and wakeup logic (introduced by electron-re) is added to it in the form of a decorator.
  • ProcessHost : A tool used in conjunction with the process pool. I call it the "Process Transaction Center". It encapsulates the process.send / process.on and provides a Promise call method to make the communication of IPC messages between the main process and the child process easier (electron-re Introduced).
  • LoadBalancer : Load balancer serving process pool (introduced by electron-re).
  • LifeCycle : Serves the life cycle of the process pool (introduced by electron-re).
  • ChildProcessPool : A process pool based on the Node.js- child_process.fork method, internally manages multiple ChildProcess instance objects, supports custom load balancing strategies, intelligent start and stop of child processes, automatic restart after child processes exit abnormally and other features (electron-re Introduced).
  • BrowserService : The Service process based on BrowserWindow can be regarded as a hidden rendering window process running in the background, allowing Node injection, but only supports the CommonJs specification (introduced by electron-re).

III. What can electron-re be used for?


1. For Electron applications

  • BrowserService
  • MessageChannel

In some of Electron's "best practices", it is recommended to put the code that occupies the CPU in the rendering process instead of directly in the main process. Here, let's take a look at the architecture diagram of Chromium:

archtecture

Each rendering process has a global object RenderProcess, used to manage the communication with the parent browser process, while maintaining a global state. The browser process maintains a RenderProcessHost object for each rendering process to manage the browser state and communication with the rendering process. The browser process and the rendering process use Chromium's IPC system to communicate. In chromium, when the page is rendered, the UI process needs to be continuously IPC synchronized with the main process. If the main process is busy at this time, the UI process will be blocked during IPC. Therefore, if the main process continues to perform tasks that consume CPU time or tasks that block synchronous IO, it will be blocked to a certain extent, which will affect the IPC communication between the main process and each rendering process. IPC communication is delayed or blocked, rendering The progress window will freeze and drop frames, or even get stuck in severe cases.

Therefore, electron-re Service concept on the basis of Electron's existing Main Process main process and Renderer Process Service is a background process that does not need to display the interface. It does not participate in UI interaction. It provides services for the main process or other rendering processes separately. Its underlying implementation is a __ hidden rendering window process node injection and remote calls. .

In this way, CPU-consuming operations in the code (such as maintaining a queue of thousands of upload tasks during file upload) can be written into a single js file, and then use the BrowserService Service with the address of the js file path as the parameter Instance to separate them from the main process. If you say that this part of the CPU-consuming operation can be directly placed in the rendering window process? This actually depends on the project's own architectural design and the trade-offs between data transmission performance loss and transmission time between processes. Create a simple example of Service

const { BrowserService } = require('electron-re');
const myServcie = new BrowserService('app', path.join(__dirname, 'path/to/app.service.js'));

If the BrowserService , then, to the main process, the rendering process, send each other messages will be used between the service process electron-re provided MessageChannel communications tool, its interface design with built-in Electron IPC basically the same, also based on the underlying native IPC is realized by the principle of asynchronous communication. A simple example is as follows:

/* ---- main.js ---- */
const { BrowserService } = require('electron-re');
// 主进程中向一个 service 'app' 发送消息
MessageChannel.send('app', 'channel1', { value: 'test1' });

2. For Electron/Nodejs applications

  • ChildProcessPool
  • ProcessHost

In addition, if you are creating a number of child processes (not dependent on the relevant reference nodejs Electron runtime child_process ), you can use electron-re specifically for nodejs run time of writing process pool provided ChildProcessPool . Because the cost of creating the process itself is very large, use the process pool to reuse the created child processes to maximize the performance benefits brought by the multi-process architecture. A simple example is as follows:

/* --- 主进程中 --- */
const { ChildProcessPool, LoadBalancer } = require('electron-re');

const pool = new ChildProcessPool({
  path: path.join(app.getAppPath(), 'app/services/child.js'), // 子进程执行文件路径
  max: 3, // 最大进程数
  strategy: LoadBalancer.ALGORITHM.WEIGHTS, // 负载均衡策略 - 权重
  weights: [1, 2, 3], // 权重分配
});

pool
  .send('sync-work', params)
  .then(rsp => console.log(rsp));

In general, in our sub-process execution file, in order to synchronize data between the main process and the sub-process, you can use process.send('channel', params) and process.on('channel', function) (provided that the process fork or manually opened IPC communication). But while processing the business logic, it also forces us to pay attention to the communication between processes. You need to know when the child process can be processed, and then use process.send to return the data to the main process, which is cumbersome to use.

electron-re introduced the ProcessHost , which I call "Process Transaction Center". In actual use, in the subprocess execution file, you only need to ProcessHost.registry('task-name', function) , and then cooperate with the process pool ChildProcessPool.send('task-name', params) to trigger the call of the subprocess transaction logic. ChildProcessPool.send() will also return a Promise instance. In order to get the callback data, a simple example is as follows:

/* --- 子进程中 --- */
const { ProcessHost } = require('electron-re');

ProcessHost
  .registry('sync-work', (params) => {
    return { value: 'task-value' };
  })
  .registry('async-work', (params) => {
    return fetch(params.url);
  });

IV. UI function introduction


The UI function is electron-re ProcessManager main process through asynchronous IPC, and refreshes the process status in real time. The operator can manually kill the process, view the process console data, view the CPU/Memory occupancy trend of the number of processes, and view the request sending record of the MessageChannel

Main interface

UI reference electron-process-manager design

preview:

process-manager.main.png

The main functions are as follows:

  1. Show all open processes in the Electron application, including the main process, the normal rendering process, the Service process (introduced by electron-re), and the child process created by ChildProcessPool (introduced by electron-re).
  2. The process list displays the process ID, process ID, parent process ID, memory usage, CPU usage percentage, etc. All process IDs are divided into: main (main process), service (service process), renderer (rendering process), node (Process pool sub-process), click on the header of the table to sort an item in increasing/decreasing order.
  3. After selecting a process, you can kill the process, view the process console data, and view the trend of process CPU/Memory usage within 1 minute. If the process is a rendering process, you can also use the DevTools button to open the built-in debugging tool with one click.
  4. The child process created by ChildProcessPool does not support directly opening DevTools for debugging, but because the --inspect was added when creating the child process, you can use chrome's chrome://inspect for remote debugging.
  5. Click the Signals button to view MessageChannel tool, including simple request parameters, request name, request return data, etc.

Function: Kill process

kill.gif

Function: Open DevTools with one key

devtools.gif

Function: View process log

console.gif

Function: View the trend of process CPU/Memory occupancy

trends.gif

Function: View MessageChannel request sending log

console.gif

V. New feature: process pool load balancing


Simplified first version implementation

>> Code address

➣ About load balancing

"Load balancing, the English name is Load Balance, which means to balance and distribute the load (work tasks) to multiple operation units for operation, such as FTP server, Web server, enterprise core application server and other main task servers, etc. , So as to complete work tasks collaboratively.
Load balancing is built on the original network structure. It provides a transparent and cheap and effective way to expand the bandwidth of servers and network equipment, strengthen network data processing capabilities, increase throughput, and improve network availability and flexibility. " -- "Baidu Encyclopedia"

➣ Description of load balancing strategy

In the previous implementation, after the process pool is created, when the pool is used to send a request, two methods are used to process the request sending strategy:

  1. By default, the polling strategy is used to select a child process to process the request, which can only guarantee the basic average distribution of the request.
  2. Another use case is to manually specify the additional parameter id when sending the request: pool.send(channel, params, id) , so that the id is sent to the same child process. An applicable scenario is: the first time we send a request to a child process, the child process stores some processing results in its runtime memory space after processing the request, and then in a certain situation, the processing results generated by the previous request need to be changed. id main process back again, at this time you need to use 061c55df877167 to distinguish the request.

The new version introduces some load balancing strategies, including:

  • POLLING -Polling: child processes take turns to handle requests
  • WEIGHTS -Weight: The child process processes the request according to the set weight
  • RANDOM -Random: The child process randomly processes the request
  • SPECIFY -Specified: The child process processes the request according to the specified process id
  • WEIGHTS_POLLING -Weighted polling: The weighted polling strategy is similar to the polling strategy, but the weighted polling strategy will calculate the polling times of the child process based on the weight, thereby stabilizing the average number of processing requests for each child process.
  • WEIGHTS_RANDOM -Weight random: The weight random strategy is similar to the random strategy, but the weight random strategy calculates the random number of child processes based on the weight, thereby stabilizing the average number of processing requests for each child process.
  • MINIMUM_CONNECTION -Minimum number of connections: Select the child process with the smallest number of connection activities on the child process to process the request.
  • WEIGHTS_MINIMUM_CONNECTION -Weighted minimum number of connections: The weighted minimum number of connections strategy is similar to the minimum number of connections strategy, but the probability of each child process being selected is determined by the number of connections and the weight.

➣ Simple implementation of load balancing strategy

Parameter Description:

  • tasks: task array, an example: [{id: 11101, weight: 2}, {id: 11102, weight: 1}] .
  • currentIndex: the current task index, the default is 0, it will automatically increase by 1 each time it is called, and the modulo will be automatically taken when the length of the task array is exceeded.
  • context: The main process parameter context, used to dynamically update the current task index and weight index.
  • weightIndex: weight index, used for weight strategy, the default is 0, it will automatically increase by 1 each time it is called, and it will automatically take the modulus when the total weight is exceeded.
  • weightTotal: the sum of weights, used for calculations related to weighting strategies.
  • connectionsMap: The mapping of the number of active connections in each process, used for calculations related to the minimum number of connections strategy.
1. Polling Strategy (POLLING)
Principle: The index value increases, and it will automatically increase by 1 each time it is called. When the length of the task array is exceeded, it will automatically take the modulus to ensure the average call.
Time complexity O(n) = 1
/* polling algorithm */
module.exports = function (tasks, currentIndex, context) {
  if (!tasks.length) return null;

  const task = tasks[currentIndex];
  context.currentIndex ++;
  context.currentIndex %= tasks.length;

  return task || null;
};
2. Weighting Strategy (WEIGHTS)
Principle: Each process generates a final calculated value according to (weight value + (weight sum * random factor)), and the maximum value in the final calculated value is hit.
Time complexity O(n) = n
/* weight algorithm */
module.exports = function (tasks, weightTotal, context) {

  if (!tasks.length) return null;

  let max = tasks[0].weight, maxIndex = 0, sum;

  for (let i = 0; i < tasks.length; i++) {
    sum = (tasks[i].weight || 0) + Math.random() * weightTotal;
    if (sum >= max) {
      max = sum;
      maxIndex = i;
    }
  }

  context.weightIndex += 1;
  context.weightIndex %= (weightTotal + 1);

  return tasks[maxIndex];
};
3. Random Strategy (RANDOM)
Principle: Random function can choose any index in [0, length)
Time complexity O(n) = 1
/* random algorithm */
module.exports = function (tasks) {

  const length = tasks.length;
  const target = tasks[Math.floor(Math.random() * length)];

  return target || null;
};
4. Weight polling strategy (WEIGHTS_POLLING)
Principle: Similar to the polling strategy, but the polling interval is: [minimum weight value, weight sum], and the hit interval is calculated according to the accumulated value of each weight. The weight index will automatically increase by 1 each time it is called, and the modulus will be automatically taken when the total weight is exceeded.
Time complexity O(n) = n
/* weights polling */
module.exports = function (tasks, weightIndex, weightTotal, context) {

  if (!tasks.length) return null;

  let weight = 0;
  let task;

  for (let i = 0; i < tasks.length; i++) {
    weight += tasks[i].weight || 0;
    if (weight >= weightIndex) {
      task = tasks[i];
      break;
    }
  }

  context.weightIndex += 1;
  context.weightIndex %= (weightTotal + 1);

  return task;
};
5. Weight Random Strategy (WEIGHTS_RANDOM)
Principle: The calculated value is generated by (weight sum * random factor), and each weight value is subtracted from it, and the first final value not greater than zero is hit.
Time complexity O(n) = n
/* weights random algorithm */
module.exports = function (tasks, weightTotal) {
  let task;
  let weight = Math.ceil(Math.random() * weightTotal);

  for (let i = 0; i < tasks.length; i++) {
    weight -= tasks[i].weight || 0;
    if (weight <= 0) {
      task = tasks[i];
      break;
    }
  }

  return task || null;
};
6. The minimum number of connections strategy (MINIMUM_CONNECTION)
Principle: Just select the item with the smallest number of connections currently.
Time complexity O(n) = n
/* minimum connections algorithm */
module.exports = function (tasks, connectionsMap={}) {
  if (tasks.length < 2) return tasks[0] || null;

  let min = connectionsMap[tasks[0].id];
  let minIndex = 0;

  for (let i = 1; i < tasks.length; i++) {
    const con = connectionsMap[tasks[i].id] || 0;
    if (con <= min) {
      min = con;
      minIndex = i;
    }
  }

  return tasks[minIndex] || null;
};
7. Weighted minimum number of connections (WEIGHTS_MINIMUM_CONNECTION)
Principle: weight + (random factor weight sum) + (connections accounted for weight sum) three factors, calculate the final value, compare according to the size of the final value, the item represented by the minimum value is hit.
Time complexity O(n) = n
/* weights minimum connections algorithm */
module.exports = function (tasks, weightTotal, connectionsMap, context) {

  if (!tasks.length) return null;

  let min = tasks[0].weight, minIndex = 0, sum;

  const connectionsTotal = tasks.reduce((total, cur) => {
    total += (connectionsMap[cur.id] || 0);
    return total;
  }, 0);

  // algorithm: (weight + connections'weight) + random factor
  for (let i = 0; i < tasks.length; i++) {
    sum =
      (tasks[i].weight || 0) + (Math.random() * weightTotal) +
      (( (connectionsMap[tasks[i].id] || 0) * weightTotal ) / connectionsTotal);
    if (sum <= min) {
      min = sum;
      minIndex = i;
    }
  }

  context.weightIndex += 1;
  context.weightIndex %= (weightTotal + 1);

  return tasks[minIndex];
};

➣ Implementation of load balancer

The code is not complicated, there are a few points to explain:

  1. params object saves some parameters for various strategy calculations, such as weight index, weight sum, number of connections, CPU/Memory usage, and so on.
  2. scheduler The object is used to call various strategies for calculation. scheduler.calculate() will return a hit process id.
  3. targets are all target processes used for calculation, but only the pid of the target process and its weight weight are stored: [{id: [pid], weight: [number]}, ...] .
  4. algorithm is a specific load balancing strategy, and the default value is a polling strategy.
  5. __ProcessManager.on('refresh', this.refreshParams)__, the load balancer regularly updates the calculation parameters of each process ProcessManager ProcessManager that collects the resource occupancy of each monitored process at regular intervals, and triggers a refresh event with the collected data.
const CONSTS = require("./consts");
const Scheduler = require("./scheduler");
const {
  RANDOM,
  POLLING,
  WEIGHTS,
  SPECIFY,
  WEIGHTS_RANDOM,
  WEIGHTS_POLLING,
  MINIMUM_CONNECTION,
  WEIGHTS_MINIMUM_CONNECTION,
} = CONSTS;
const ProcessManager = require('../ProcessManager');

/* Load Balance Instance */
class LoadBalancer {
  /**
    * @param  {Object} options [ options object ]
    * @param  {Array } options.targets [ targets for load balancing calculation: [{id: 1, weight: 1}, {id: 2, weight: 2}] ]
    * @param  {String} options.algorithm [ strategies for load balancing calculation : RANDOM | POLLING | WEIGHTS | SPECIFY | WEIGHTS_RANDOM | WEIGHTS_POLLING | MINIMUM_CONNECTION | WEIGHTS_MINIMUM_CONNECTION]
    */
  constructor(options) {
    this.targets = options.targets;
    this.algorithm = options.algorithm || POLLING;
    this.params = { // data for algorithm
      currentIndex: 0, // index
      weightIndex: 0, // index for weight alogrithm
      weightTotal: 0, // total weight
      connectionsMap: {}, // connections of each target
      cpuOccupancyMap: {}, // cpu occupancy of each target
      memoryOccupancyMap: {}, // cpu occupancy of each target
    };
    this.scheduler = new Scheduler(this.algorithm);
    this.memoParams = this.memorizedParams();
    this.calculateWeightIndex();
    ProcessManager.on('refresh', this.refreshParams);
  }

  /* params formatter */
  memorizedParams = () => {
    return {
      [RANDOM]: () => [],
      [POLLING]: () => [this.params.currentIndex, this.params],
      [WEIGHTS]: () => [this.params.weightTotal, this.params],
      [SPECIFY]: (id) => [id],
      [WEIGHTS_RANDOM]: () => [this.params.weightTotal],
      [WEIGHTS_POLLING]: () => [this.params.weightIndex, this.params.weightTotal, this.params],
      [MINIMUM_CONNECTION]: () => [this.params.connectionsMap],
      [WEIGHTS_MINIMUM_CONNECTION]: () => [this.params.weightTotal, this.params.connectionsMap, this.params],
    };
  }

  /* refresh params data */
  refreshParams = (pidMap) => { ... }

  /* pick one task from queue */
  pickOne = (...params) => {
    return this.scheduler.calculate(
      this.targets, this.memoParams[this.algorithm](...params)
    );
  }

  /* pick multi task from queue */
  pickMulti = (count = 1, ...params) => {
    return new Array(count).fill().map(
      () => this.pickOne(...params)
    );
  }

  /* calculate weight */
  calculateWeightIndex = () => {
    this.params.weightTotal = this.targets.reduce((total, cur) => total + (cur.weight || 0), 0);
    if (this.params.weightIndex > this.params.weightTotal) {
      this.params.weightIndex = this.params.weightTotal;
    }
  }

  /* calculate index */
  calculateIndex = () => {
    if (this.params.currentIndex >= this.targets.length) {
      this.params.currentIndex = (ths.params.currentIndex - 1 >= 0) ? (this.params.currentIndex - 1) : 0;
    }
  }

  /* clean data of a task or all task */
  clean = (id) => { ... }

  /* add a task */
  add = (task) => {...}

  /* remove target from queue */
  del = (target) => {...}

  /* wipe queue and data */
  wipe = () => {...}

  /* update calculate params */
  updateParams = (object) => {
    Object.entries(object).map(([key, value]) => {
      if (key in this.params) {
        this.params[key] = value;
      }
    });
  }

  /* reset targets */
  setTargets = (targets) => {...}

  /* change algorithm strategy */
  setAlgorithm = (algorithm) => {...}
}

module.exports = Object.assign(LoadBalancer, { ALGORITHM: CONSTS });

➣ Process pool cooperates with LoadBalancer to achieve load balancing

There are a few points to explain:

  1. When we use pool.send('channel', params) getForkedFromPool() function inside the pool will be called. The function selects a process from the process pool to perform the task. If the number of child processes does not reach the maximum set number, a child process will be created first to handle the request.
  2. Need to synchronize updates when a child process create / destroy / exit LoadBalancer in listening targets , or have been destroyed in the process pid may return after performing load balancing strategy calculations.
  3. ForkedProcess is a decorator class that encapsulates the child_process.fork and adds some additional functions to it, such as basic methods such as process sleep, wakeup, binding events, and sending requests.
const _path = require('path');
const EventEmitter = require('events');

const ForkedProcess = require('./ForkedProcess');
const ProcessLifeCycle = require('../ProcessLifeCycle.class');
const ProcessManager = require('../ProcessManager/index');
const { defaultLifecycle } = require('../ProcessLifeCycle.class');
const LoadBalancer = require('../LoadBalancer');
let { inspectStartIndex } = require('../../conf/global.json');
const { getRandomString, removeForkedFromPool, convertForkedToMap, isValidValue } = require('../utils');
const { UPDATE_CONNECTIONS_SIGNAL } = require('../consts');

const defaultStrategy = LoadBalancer.ALGORITHM.POLLING;

class ChildProcessPool extends EventEmitter {
  constructor({
    path, max=6, cwd, env={},
    weights=[], // weights of processes, the length is equal to max
    strategy=defaultStrategy,
    ...
  }) {
    super();
    this.cwd = cwd || _path.dirname(path);
    this.env = {
      ...process.env,
      ...env
    };
    this.callbacks = {};
    this.pidMap = new Map();
    this.callbacksMap = new Map();
    this.connectionsMap={};
    this.forked = [];
    this.connectionsTimer = null;
    this.forkedMap = {};
    this.forkedPath = path;
    this.forkIndex = 0;
    this.maxInstance = max;
    this.weights = new Array(max).fill().map(
      (_, i) => (isValidValue(weights[i]) ? weights[i] : 1)
    );
    this.LB = new LoadBalancer({
      algorithm: strategy,
      targets: [],
    });

    this.initEvents();
  }

  /* -------------- internal -------------- */

  /* init events */
  initEvents = () => {
    // process exit
    this.on('forked_exit', (pid) => {
      this.onForkedDisconnect(pid);
    });
    ...
  }

  /**
    * onForkedCreate [triggered when a process instance created]
    * @param  {[String]} pid [process pid]
    */
  onForkedCreate = (forked) => {
    const pidsValue = this.forked.map(f => f.pid);
    const length = this.forked.length;

    this.LB.add({
      id: forked.pid,
      weight: this.weights[length - 1],
    });
    ProcessManager.listen(pidsValue, 'node', this.forkedPath);
    ...
  }

  /**
    * onForkedDisconnect [triggered when a process instance disconnect]
    * @param  {[String]} pid [process pid]
    */
   onForkedDisconnect = (pid) => {
    const length = this.forked.length;

    removeForkedFromPool(this.forked, pid, this.pidMap);
    this.LB.del({
      id: pid,
      weight: this.weights[length - 1],
    });
    ProcessManager.unlisten([pid]);
    ...
  }

  /* Get a process instance from the pool */
  getForkedFromPool = (id="default") => {
    let forked;
    if (!this.pidMap.get(id)) {
      // create new process and put it into the pool
      if (this.forked.length < this.maxInstance) {
        inspectStartIndex ++;
        forked = new ForkedProcess(
          this,
          this.forkedPath,
          this.env.NODE_ENV === "development" ? [`--inspect=${inspectStartIndex}`] : [],
          { cwd: this.cwd, env: { ...this.env, id }, stdio: 'pipe' }
        );
        this.forked.push(forked);
        this.onForkedCreate(forked);
      } else {
      // get a process from the pool based on load balancing strategy
        forked = this.forkedMap[this.LB.pickOne().id];
      }
      if (id !== 'default') {
        this.pidMap.set(id, forked.pid);
      }
    } else {
      // pick a special process from the pool
      forked = this.forkedMap[this.pidMap.get(id)];
    }

    if (!forked) throw new Error(`Get forked process from pool failed! the process pid: ${this.pidMap.get(id)}.`);

    return forked;
  }

  /* -------------- caller -------------- */

  /**
  * send [Send request to a process]
  * @param  {[String]} taskName [task name - necessary]
  * @param  {[Any]} params [data passed to process - necessary]
  * @param  {[String]} id [the unique id bound to a process instance - not necessary]
  * @return {[Promise]} [return a Promise instance]
  */
  send = (taskName, params, givenId) => {
    if (givenId === 'default') throw new Error('ChildProcessPool: Prohibit the use of this id value: [default] !')

    const id = getRandomString();
    const forked = this.getForkedFromPool(givenId);
    this.lifecycle.refresh([forked.pid]);

    return new Promise(resolve => {
      this.callbacks[id] = resolve;
      forked.send({action: taskName, params, id });
    });
  }
  ...
}

module.exports = ChildProcessPool;

VI. New feature: Intelligent start and stop of child processes


This feature I also call it process lifecycle (lifecycle).

The main function is: when the child process has not been called for a period of time, it will automatically enter the dormant state to reduce CPU usage (it is difficult to reduce memory usage). The time to enter the dormant state can be controlled by the creator, and the default is 10 min. When the child process goes to sleep, if a new request comes and is distributed to the sleeping process, it will automatically wake up the process and continue processing the current request. After a period of inactivity, it will enter the dormant state again.

➣ Various ways to put the process to sleep

1) If you want to pause the process, you can send the SIGSTOP signal to the process, and send the SIGCONT signal to resume the process.

Node.js:

process.kill([pid], "SIGSTOP");
process.kill([pid], "SIGCONT");

Unix System (Windows not tested yet):

kill -STOP [pid]
kill -CONT [pid]

2) The new Atomic.wait API of Node.js can also be controlled by programming. This method will monitor the value under a given subscript of an Int32Array object. If the value has not changed, it will wait (block the event loop) until a timeout occurs (determined by the ms parameter). You can operate this shared data in the main process, and then release the sleep lock for the child process.

const nil = new Int32Array(new SharedArrayBuffer(4));
const array = new Array(100000).fill(0);
setInterval(() => {
console.log(1);
}, 1e3);
Atomics.wait(nil, 0, 0, Number(600e3));

➣ Implementation of LifeCycle

The code is also very simple, there are a few points to explain:

  1. Using the tag removal method, the call time is updated when the child process triggers the request, and the timer cycle is used to calculate the (current time-last call time) difference of each monitored child process. If there are processes exceeding the set time, the sleep signal is sent, and all process pids are carried at the same time.
  2. Each ChildProcessPool process pool instance will have a ProcessLifeCycle instance object to control the sleep/wake-up of the processes in the current process pool. ChildProcessPool listens ProcessLifeCycle object sleep call after the event, to get the needed sleep process pid ForkedProcess of sleep() methods make it sleep. When the next request is dispatched to the process, the process will be automatically awakened.
const EventEmitter = require('events');

const defaultLifecycle = {
  expect: 600e3, // default timeout 10 minutes
  internal: 30e3 // default loop check interval 30 seconds
};

class ProcessLifeCycle extends EventEmitter {
  constructor(options) {
    super();
    const {
      expect=defaultLifecycle.expect,
      internal=defaultLifecycle.internal
    } = options;
    this.timer = null;
    this.internal = internal;
    this.expect = expect;
    this.params = {
      activities: new Map()
    };
  }

  /* task check loop */
  taskLoop = () => {
    if (this.timer) return console.warn('ProcessLifeCycle: the task loop is already running');

    this.timer = setInterval(() => {
      const sleepTasks = [];
      const date = new Date();
      const { activities } = this.params;
      ([...activities.entries()]).map(([key, value]) => {
        if (date - value > this.expect) {
          sleepTasks.push(key);
        }
      });
      if (sleepTasks.length) {
        // this.unwatch(sleepTasks);
        this.emit('sleep', sleepTasks);
      }
    }, this.internal);
  }

  /* watch processes */
  watch = (ids=[]) => {
    ids.forEach(id => {
      this.params.activities.set(id, new Date());
    });
  }

  /* unwatch processes */
  unwatch = (ids=[]) => {
    ids.forEach(id => {
      this.params.activities.delete(id);
    });
  }

  /* stop task check loop */
  stop = () => {
    clearInterval(this.timer);
    this.timer = null;
  }

  /* start task check loop */
  start = () => {
    this.taskLoop();
  }

  /* refresh tasks */
  refresh = (ids=[]) => {
    ids.forEach(id => {
      if (this.params.activities.has(id)) {
        this.params.activities.set(id, new Date());
      } else {
        console.warn(`The task with id ${id} is not being watched.`);
      }
    });
  }
}

module.exports = Object.assign(ProcessLifeCycle, { defaultLifecycle });

➣ The prototype of the process mutex

When I read the article before, I saw Atomic.wait . In addition to implementing process sleep, Atomic can also understand the implementation principle of process mutex based on it. Here is a basic shape can be used as a reference, the relevant documents can be found in the MDN .

The AsyncLock object needs to be introduced in the child process. There is a parameter sab in the constructor of creating AsyncLock that needs attention. This parameter is a SharedArrayBuffer shared data block, this shared data needs to be created in the main process, and then sent to each child process through IPC communication, usually IPC communication will serialize general data such as Object / Array, resulting in message recipients and messages The sender does not get the same object, but the SharedArrayBuffer object sent via IPC will point to the same memory block.

SharedArrayBuffer data in the child process, any modification of the shared data by any child process will cause the SharedArrayBuffer pointing to this memory in other processes to change. This is the basic point for us to use it to achieve process lock.

First, make a brief description Atomic

  • Atomics.compareExchange(typedArray, index, expectedValue, newValue) : Atomics.compareExchange() static method will replace the value on the array with the given replacement value when the value of the array is equal to the expected value, and then return the old value . This atomic operation guarantees that no other write operations will occur before the modified value is written.
  • Atomics.waitAsync(typedArray, index, value[, timeout]) : The static method Atomics.wait() ensures that a value at a given position in the Int32Array array has not changed and the process will Sleep until awakened or timed out. This method returns a string with one of "ok", "not-equal", or "timed-out".
  • Atomics.notify(typedArray, index[, count]) : The static method Atomics.notify() wakes up a specified number of processes sleeping in the waiting queue. If count is not specified, all are wake up by default.

AsyncLock is an asynchronous lock, which will not block the main thread while waiting for the lock to be released. Mainly focus on executeAfterLocked() , call this method and pass in the callback function, the callback function will be executed after the lock is acquired, and automatically release the lock after the execution is completed. The key to one step is the tryGetLock() function, which returns a Promise object, so our logic for waiting for the lock to be released is executed in the microtask queue without blocking the main thread.

/**
  * @name AsyncLock
  * @description
  *   Use it in child processes, mutex lock logic.
  *   First create SharedArrayBuffer in main process and transfer it to all child processes to control the lock.
  */

class AsyncLock {
  static INDEX = 0;
  static UNLOCKED = 0;
  static LOCKED = 1;

  constructor(sab) {
    this.sab = sab; // data like this: const sab = new SharedArrayBuffer(16);
    this.i32a = new Int32Array(sab);
  }

  lock() {
    while (true) {
      const oldValue = Atomics.compareExchange(
        this.i32a, AsyncLock.INDEX,
        AsyncLock.UNLOCKED, // old
        AsyncLock.LOCKED // new
      );
      if (oldValue == AsyncLock.UNLOCKED) { // success
        return;
      }
      Atomics.wait( // wait
        this.i32a,
        AsyncLock.INDEX,
        AsyncLock.LOCKED // expect
      );
    }
  }

  unlock() {
    const oldValue = Atomics.compareExchange(
      this.i32a, AsyncLock.INDEX,
      AsyncLock.LOCKED,
      AsyncLock.UNLOCKED
    );
    if (oldValue != AsyncLock.LOCKED) { // failed
      throw new Error('Tried to unlock while not holding the mutex');
    }
    Atomics.notify(this.i32a, AsyncLock.INDEX, 1);
  }

  /**
    * executeLocked [async function to acquired the lock and execute callback]
    * @param  {Function} callback [callback function]
    */
  executeAfterLocked(callback) {

    const tryGetLock = async () => {
      while (true) {
        const oldValue = Atomics.compareExchange(
          this.i32a,
          AsyncLock.INDEX,
          AsyncLock.UNLOCKED,
          AsyncLock.LOCKED
        );
        if (oldValue == AsyncLock.UNLOCKED) { // success if AsyncLock.UNLOCKED
          callback();
          this.unlock();
          return;
        }
        const result = Atomics.waitAsync( // wait when AsyncLock.LOCKED
          this.i32a,
          AsyncLock.INDEX,
          AsyncLock.LOCKED
        );
        await result.value; // return a Promise, will not block the main thread
      }
    }

    tryGetLock();
  }
}

VII. Known issues


  1. Since Electron’s native remote API is used, electron-re (Service-related) do not support Electron 14 and above (remote has been removed), and we are considering using a third-party remote library for replacement compatibility in the near future.
  2. The fault-tolerant processing is not good enough, and this one will become an important optimization point in the future.
  3. The "call count" method is used to collect the number of active connections in the process pool. This processing method is not very good, and the accuracy is not high enough, but there is no better solution for counting the number of active connections in the child process. I think we still have to solve it from the bottom, such as: macro task and micro task queue, V8 virtual machine, garbage collection, Libuv underlying principle, Node process and thread principle...
  4. I haven't tested the process sleep function on the windows platform for the time being. The win platform itself does not support process signals, but Node provides simulation support, but the specific performance needs to be tested.

VIII. Next To Do


  • [x] Let the Service support code update automatically restart
  • [x] Add ChildProcessPool child process scheduling logic
  • [x] Optimize ChildProcessPool multi-process console output
  • [x] Add a visual process management interface
  • [x] Enhanced ChildProcessPool process pool function
  • [] Enhance the function of ProcessHost transaction center
  • [] The realization of mutual exclusion lock logic between child processes
  • [] Use external remote library to support the latest version of Electron
  • [ ] Kill Bugs 🐛

IX. Several practical examples


  1. electronux -One of my Electron projects uses BrowserService/MessageChannel , and it comes with ChildProcessPool/ProcessHost use the demo.
  2. Shadow Socks-electron -My other Electron cross-platform desktop application project (the link is not provided, you can click the above to view the original text), use electron-re for debugging and development, and in the production environment, you can open the ProcessManager UI for CPU/Memory Resource occupancy monitoring and request log viewing.
  3. file-slice-upload -A demo about the parallel upload of multiple file slices, using ChildProcessPool and ProcessHost , based on Electron@9.3.5 development.
  4. You can also directly view index.test.js and test directories, including some usage examples.
  5. Of course, github- README also has related instructions.

nojsja
112 声望9 粉丝

Stay hungry, Stay foolish.