Electron 程式管理工具開發日記3:程式池負載均衡、智慧啟停

nojsja發表於2021-12-24

>> 原文連結

文中實現的部分工具方法正處於早期/測試階段,仍在持續優化中,僅供參考...

在 Ubuntu20.04 上進行開發/測試,可用於 Electron 專案,測試版本: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. 前言


之前在做 Electron 應用開發的時候,寫了個 Electron 程式管理工具 electron-re,支援 Electron/Node 多程式管理、service 模擬、程式實時監控(UI功能)、Node.js 程式池等特性。已經發布為npm元件,可以直接安裝(最新特性還沒釋出到線上,需要再進行測試):

>> github地址

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

本主題前面兩篇文章:

  1. 《Electron/Node多程式工具開發日記》 描述了electron-re的開發背景、針對的問題場景以及詳細的使用方法。
  2. 《Electron多程式工具開發日記2》 介紹了新特性 "多程式管理 UI" 的開發和使用相關。UI 介面基於 electron-re 已有的 BrowserService/MessageChannelChildProcessPool/ProcessHost 基礎架構驅動,使用 React17 / Babel7 開發。

這篇文章主要是描述最近支援的程式池模組新特性 - "程式池負載均衡" 和 "子程式智慧啟停",以及相關的基本實現原理。同時提出自己遇到的一些問題,以及對這些問題的思考、解決方案,對之後版本迭代的一些想法等等。

II. electron-re 架構圖


archtecture

  • Electron Core :Electron 應用的一系列核心功能,包含了應用的主程式、渲染程式、視窗等等(Electron 自帶)。
  • BrowserWindow :渲染視窗程式,一般用於UI渲染 (Electron 自帶)。
  • ProcessManager :程式管理器,負責程式佔用資源採集、非同步重新整理UI、響應和發出各種程式管理訊號,作為一個觀察者物件給其它模組和UI提供服務 (electron-re 引入)。
  • MessageChannel :適用於主程式、渲染程式、Service 程式的訊息傳送工具,基於原生 IPC 封裝,主要服務於 BrowserService,也可替代原生的 IPC 通訊方法 (electron-re 引入)。
  • ChildProcess :由 child_process.fork 方法生成的子程式,不過以裝飾器的方式為其新增了簡單的程式休眠和喚醒邏輯 (electron-re 引入)。
  • ProcessHost :配合程式池使用的工具,我稱它為 "程式事務中心",封裝了 process.send / process.on 基本邏輯,提供了 Promise 的呼叫方式讓 主程式/子程式 之間 IPC 訊息通訊更簡單 (electron-re 引入)。
  • LoadBalancer :服務於程式池的負載均衡器 (electron-re 引入)。
  • LifeCycle :服務於程式池的生命週期 (electron-re 引入)。
  • ChildProcessPool :基於 Node.js - child_process.fork 方法實現的程式池,內部管理多個 ChildProcess 例項物件,支援自定義負載均衡策略、子程式智慧啟停、子程式異常退出後自動重啟等特性 (electron-re 引入)。
  • BrowserService :基於 BrowserWindow 實現的 Service 程式,可以看成是一個執行在後臺的隱藏渲染視窗程式,允許 Node 注入,不過僅支援 CommonJs 規範 (electron-re 引入)。

III. electron-re 可以用來做什麼?


1. 用於 Electron 應用

  • BrowserService
  • MessageChannel

在 Electron 的一些“最佳實踐”中,建議將佔用cpu的程式碼放到渲染過程中而不是直接放在主過程中,這裡先看下 chromium 的架構圖:

archtecture

每個渲染程式都有一個全域性物件 RenderProcess,用來管理與父瀏覽器程式的通訊,同時維護著一份全域性狀態。瀏覽器程式為每個渲染程式維護一個 RenderProcessHost 物件,用來管理瀏覽器狀態和與渲染程式的通訊。瀏覽器程式和渲染程式使用 Chromium 的 IPC 系統進行通訊。在 chromium 中,頁面渲染時,UI程式需要和 main process 不斷的進行 IPC 同步,若此時 main process 忙,則 UIprocess 就會在 IPC 時阻塞。所以如果主程式持續進行消耗 CPU 時間的任務或阻塞同步 IO 的任務的話,就會在一定程度上阻塞,從而影響主程式和各個渲染程式之間的 IPC 通訊,IPC 通訊有延遲或是受阻,渲染程式視窗就會卡頓掉幀,嚴重的話甚至會卡住不動。

因此 electron-re 在 Electron 已有的 Main Process 主程式 和 Renderer Process 渲染程式邏輯的基礎上獨立出一個單獨的 Service 概念。Service即不需要顯示介面的後臺程式,它不參與 UI 互動,單獨為主程式或其它渲染程式提供服務,它的底層實現為一個允許 node注入remote呼叫 的 __隱藏渲染視窗程式__。

這樣就可以將程式碼中耗費 cpu 的操作(比如檔案上傳中維護一個數千個上傳任務的佇列)編寫成一個單獨的js檔案,然後使用 BrowserService 建構函式以這個 js 檔案的地址 path 為引數構造一個 Service 例項,從而將他們從主程式中分離。如果你說那這部分耗費 cpu 的操作直接放到渲染視窗程式可以嘛?這其實取決於專案自身的架構設計,以及對程式之間資料傳輸效能損耗和傳輸時間等各方面的權衡,建立一個 Service 的簡單示例:

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

如果使用了 BrowserService 的話,要想在主程式、渲染程式、service 程式之間相互傳送訊息就要使用 electron-re 提供的 MessageChannel 通訊工具,它的介面設計跟 Electron 內建的IPC基本一致,底層也是基於原生的 IPC 非同步通訊原理來實現的,簡單示例如下:

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

2. 用於 Electron/Nodejs 應用

  • ChildProcessPool
  • ProcessHost

此外,如果要建立一些不依賴於 Electron 執行時的子程式(相關參考nodejs child_process),可以使用 electron-re 提供的專門為 nodejs 執行時編寫的程式池 ChildProcessPool 。因為建立程式本身所需的開銷很大,使用程式池來重複利用已經建立了的子程式,將多程式架構帶來的效能效益最大化,簡單示例如下:

/* --- 主程式中 --- */
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));

一般情況下,在我們的子程式執行檔案中,為了在主程式和子程式之間同步資料,可以使用 process.send('channel', params)process.on('channel', function) 的方式實現(前提是程式以以 fork 方式建立或者手動開啟了 IPC 通訊)。但是這樣在處理業務邏輯的同時也強迫我們去關注程式之間的通訊,你需要知道子程式什麼時候能處理完畢,然後再使用process.send再將資料返回主程式,使用方式繁瑣。

electron-re 引入了 ProcessHost 的概念,我稱之為"程式事務中心"。實際使用時在子程式執行檔案中只需要將各個任務函式通過 ProcessHost.registry('task-name', function) 註冊成多個被監聽的事務,然後配合程式池的 ChildProcessPool.send('task-name', params) 來觸發子程式事務邏輯的呼叫即可,ChildProcessPool.send() 同時會返回一個 Promise 例項以便獲取回撥資料,簡單示例如下:

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

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

IV. UI 功能介紹


UI 功能基於 electron-re 基礎架構開發,它通過非同步 IPC 和主程式的 ProcessManager 進行通訊,實時重新整理程式狀態。操作者可以通過 UI 手動 Kill 程式、檢視程式 console 資料、檢視程式數 CPU/Memory 佔用趨勢以及檢視 MessageChannel 工具的請求傳送記錄。

主介面

UI參考 electron-process-manager 設計

預覽圖:

process-manager.main.png

主要功能如下:

  1. 展示 Electron 應用中所有開啟的程式,包括主程式、普通的渲染程式、Service 程式(electron-re 引入)、ChildProcessPool 建立的子程式(electron-re 引入)。
  2. 程式列表中顯示各個程式程式號、程式標識、父程式號、記憶體佔用大小、CPU 佔用百分比等,所有程式標識分為:main(主程式)、service(服務程式)、renderer(渲染程式)、node(程式池子程式),點選表格頭可以針對對某項進行遞增/遞減排序。
  3. 選中某個程式後可以 Kill 此程式、檢視程式控制檯 Console 資料、檢視1分鐘內程式 CPU/Memory 佔用趨勢,如果此程式是渲染程式的話還可以通過 DevTools 按鈕一鍵開啟內建除錯工具。
  4. ChildProcessPool 建立的子程式暫不支援直接開啟 DevTools 進行除錯,不過由於建立子程式時新增了 --inspect 引數,可以使用 chrome 的 chrome://inspect 進行遠端除錯。
  5. 點選 Signals 按鈕可以檢視 MessageChannel 工具的請求傳送日誌,包括簡單的請求引數、請求名、請求返回資料等。

功能:Kill 程式

kill.gif

功能:一鍵開啟 DevTools

devtools.gif

功能:檢視程式日誌

console.gif

功能:檢視程式 CPU/Memory 佔用趨勢

trends.gif

功能:檢視 MessageChannel 請求傳送日誌

console.gif

V. 新特性:程式池負載均衡


簡化的初版實現

>> 程式碼地址

➣ 關於負載均衡

“ 負載均衡,英文名稱為 Load Balance,其含義就是指將負載(工作任務)進行平衡、分攤到多個操作單元上進行執行,例如 FTP 伺服器、Web伺服器、企業核心應用伺服器和其它主要任務伺服器等,從而協同完成工作任務。
負載均衡構建在原有網路結構之上,它提供了一種透明且廉價有效的方法擴充套件伺服器和網路裝置的頻寬、加強網路資料處理能力、增加吞吐量、提高網路的可用性和靈活性。” -- 《百度百科》

➣ 負載均衡策略說明

之前的實現中,程式池建立好後,當使用 pool 傳送請求時,採用兩種方式處理請求傳送策略:

  1. 預設使用輪詢策略選擇一個子程式處理請求,只能保證基本的請求平均分配。
  2. 另一種使用情況是通過手動指定傳送請求時的額外引數 id:pool.send(channel, params, id),這樣子讓 id 相同的請求傳送到同一個子程式上。一個適用情景就是:第一次我們向某個子程式傳送請求,該子程式處理請求後在其執行時記憶體空間中儲存了一些處理結果,之後某個情況下需要將之前那次請求產生的處理結果再次拿回主程式,這時候就需要使用 id 來區分請求。

新版本引入了一些負載均衡策略,包括:

  • POLLING - 輪詢:子程式輪流處理請求
  • WEIGHTS - 權重:子程式根據設定的權重來處理請求
  • RANDOM - 隨機:子程式隨機處理請求
  • SPECIFY - 指定:子程式根據指定的程式 id 處理請求
  • WEIGHTS_POLLING - 權重輪詢:權重輪詢策略與輪詢策略類似,但是權重輪詢策略會根據權重來計運算元程式的輪詢次數,從而穩定每個子程式的平均處理請求數量。
  • WEIGHTS_RANDOM - 權重隨機:權重隨機策略與隨機策略類似,但是權重隨機策略會根據權重來計運算元程式的隨機次數,從而穩定每個子程式的平均處理請求數量。
  • MINIMUM_CONNECTION - 最小連線數:選擇子程式上具有最小連線活動數量的子程式處理請求。
  • WEIGHTS_MINIMUM_CONNECTION - 權重最小連線數:權重最小連線數策略與最小連線數策略類似,不過各個子程式被選中的概率由連線數和權重共同決定。

➣ 負載均衡策略的簡易實現

引數說明:

  • tasks:任務陣列,一個示例:[{id: 11101, weight: 2}, {id: 11102, weight: 1}]
  • currentIndex: 目前所處的任務索引,預設為 0,每次呼叫時會自動加 1,超出任務陣列長度時會自動取模。
  • context:主程式引數上下文,用於動態更新當前任務索引和權重索引。
  • weightIndex:權重索引,用於權重策略,預設為 0,每次呼叫時會自動加 1,超出權重總和時會自動取模。
  • weightTotal:權重總和,用於權重策略相關計算。
  • connectionsMap:各個程式活動連線數的對映,用於最小連線數策略相關計算。
1. 輪詢策略(POLLING)
原理:索引值遞增,每次呼叫時會自動加 1,超出任務陣列長度時會自動取模,保證平均呼叫。
時間複雜度 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. 權重策略(WEIGHTS)
原理:每個程式根據 (權重值 + (權重總和 * 隨機因子)) 生成最終計算值,最終計算值中的最大值被命中。
時間複雜度 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)
原理:隨機函式在 [0, length) 中任意選取一個索引即可
時間複雜度 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. 權重輪詢策略(WEIGHTS_POLLING)
原理:類似輪詢策略,不過輪詢的區間為:[最小權重值, 權重總和],根據各項權重累加值進行命中區間計算。每次呼叫時權重索引會自動加 1,超出權重總和時會自動取模。
時間複雜度 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. 權重隨機策略(WEIGHTS_RANDOM)
原理:由 (權重總和 * 隨機因子) 產生計算值,將各項權重值與其相減,第一個不大於零的最終值即被命中。
時間複雜度 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. 最小連線數策略(MINIMUM_CONNECTION)
原理:直接選擇當前連線數最小的項即可。
時間複雜度 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. 權重最小連線數(WEIGHTS_MINIMUM_CONNECTION)
原理:權重 + ( 隨機因子 權重總和 ) + ( 連線數佔比 權重總和 ) 三個因子,計算出最終值,根據最終值的大小進行比較,最小值所代表項即被命中。
時間複雜度 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];
};

➣ 負載均衡器的實現

程式碼都不復雜,有幾點需要說明:

  1. params 物件儲存了用於各種策略計算的一些引數,比如權重索引、權重總和、連線數、CPU/Memory佔用等等。
  2. scheduler 物件用於呼叫各種策略進行計算,scheduler.calculate() 會返回一個命中的程式 id。
  3. targets 即所有用於計算的目標程式,不過其中僅存放了目標程式 pid 和 其權重 weight:[{id: [pid], weight: [number]}, ...]
  4. algorithm 為特定的負載均衡策略,預設值為輪詢策略。
  5. __ProcessManager.on('refresh', this.refreshParams)__,負載均衡器通過監聽 ProcessManager 的 refresh 事件來定時更新各個程式的計算引數。ProcessManager 中有一個定時器,每隔一段時間就會採集一次各個被監聽的程式的資源佔用情況,並攜帶採集資料觸發一次 refresh 事件。
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 });

➣ 程式池配合 LoadBalancer 來實現負載均衡

有幾點需要說明:

  1. 當我們使用 pool.send('channel', params) 時,pool 內部 getForkedFromPool() 函式會被呼叫,函式從程式池中選擇一個程式來執行任務,如果子程式數未達到最大設定數,則優先建立一個子程式來處理請求。
  2. 子程式 建立/銷燬/退出 時需要同步更新 LoadBalancer 中監聽的 targets,否則已被銷燬的程式 pid 可能會在執行負載均衡策略計算後被返回。
  3. ForkedProcess 是一個裝飾器類,封裝了 child_process.fork 邏輯,為其增加了一些額外功能,如:程式睡眠、喚醒、繫結事件、傳送請求等基本方法。
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. 新特性:子程式智慧啟停


這個特性我也將其稱為 程式生命週期 (lifecycle)。

主要作用是:當子程式一段時間未被呼叫,則自動進入休眠狀態,減少 CPU 佔用 (減少記憶體佔用很難)。進入休眠狀態的時間可以和由建立者控制,預設為 10 min。當子程式進入休眠後,如果有新的請求到來並分發到該休眠的程式上,則會自動喚醒該程式並繼續處理當前請求。一段時間閒置後,將會再次進入休眠狀態。

➣ 使程式休眠的各種方式

1)如果是讓程式暫停的話,可以向程式傳送 SIGSTOP 訊號,傳送 SIGCONT 訊號可以恢復程式。

Node.js:

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

Unix System (Windows 暫未測試):

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

2)Node.js 新的 Atomic.wait API 也可以做到程式設計控制。該方法會監聽一個 Int32Array 物件的給定下標下的值,若值未發生改變,則一直等待(阻塞 event loop),直到發生超時(由 ms 引數決定)。可以在主程式中操作這塊共享資料,然後為子程式解除休眠鎖定。

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

➣ 生命週期 LifeCycle 的實現

程式碼同樣很簡單,有幾點需要說明:

  1. 採用了 標記清除法,子程式觸發請求時更新呼叫時間,同時使用定時器迴圈計算各個被監聽子程式的 ( 當前時間 - 上次呼叫時間) 差值。如果有超過設定的時間的程式則傳送 sleep 訊號,同時攜帶所有程式 pid。
  2. 每個 ChildProcessPool 程式池例項都會擁有一個 ProcessLifeCycle 例項物件用於控制當前程式池中的程式的 休眠/喚醒。ChildProcessPool 會監聽 ProcessLifeCycle 物件的 sleep 事件,拿到需要 sleep 的程式 pid 後呼叫 ForkedProcesssleep() 方法使其睡眠。下個請求分發到該程式時,會自動喚醒該程式。
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 });

➣ 程式互斥鎖的雛形

之前看文章時看到關於 API - Atomic.wait 的一篇文章,Atomic 除了用於實現程式睡眠,也能基於它來理解程式互斥鎖的實現原理。這裡有個基本雛形可以作為參考,相關文件可以參閱 MDN

AsyncLock 物件需要在子程式中引入,建立 AsyncLock 的建構函式中有一個引數 sab 需要注意。這個引數是一個 SharedArrayBuffer 共享資料塊,這個共享資料快需要在主程式建立,然後通過 IPC 通訊傳送到各個子程式,通常 IPC 通訊會序列化一般的諸如 Object / Array 等資料,導致訊息接受者和訊息傳送者拿到的不是同一個物件,但是經由 IPC 傳送的 SharedArrayBuffer 物件卻會指向同一個記憶體塊。

在子程式中使用 SharedArrayBuffer 資料建立 AsyncLock 例項後,任意一個子程式對共享資料的修改都會導致其它程式內指向這塊記憶體的 SharedArrayBuffer 資料內容變化,這就是我們使用它實現程式鎖的基本要點。

先對 Atomic API 做個簡單說明:

  • Atomics.compareExchange(typedArray, index, expectedValue, newValue) :Atomics.compareExchange() 靜態方法會在陣列的值與期望值相等的時候,將給定的替換值替換掉陣列上的值,然後返回舊值。此原子操作保證在寫上修改的值之前不會發生其他寫操作。
  • Atomics.waitAsync(typedArray, index, value[, timeout]) :靜態方法 Atomics.wait() 確保了一個在 Int32Array 陣列中給定位置的值沒有發生變化且仍然是給定的值時程式將會睡眠,直到被喚醒或超時。該方法返回一個字串,值為"ok", "not-equal", 或 "timed-out" 之一。
  • Atomics.notify(typedArray, index[, count]) :靜態方法 Atomics.notify() 喚醒指定數量的在等待佇列中休眠的程式,不指定 count 時預設喚醒所有。

AsyncLock 即非同步鎖,等待鎖釋放的時候不會阻塞主執行緒。主要關注 executeAfterLocked() 這個方法,呼叫該方法並傳入回撥函式,該回撥函式會在鎖被獲取後執行,並且在執行完畢後自動釋放鎖。其中一步的關鍵就是 tryGetLock() 函式,它返回了一個 Promise 物件,因此我們等待鎖釋放的邏輯在微任務佇列中執行而並不阻塞主執行緒。

/**
  * @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. 存在的已知問題


  1. 由於使用了 Electron 原生的 remote API,因此 electron-re 部分特性(Service 相關)不支援 Electron 14 以及以上版本(已經移除 remote),正考慮近期使用第三方 remote 庫進行替代相容。
  2. 容錯處理做的不夠好,這一塊會成為之後的重要優化點。
  3. 採集程式池中活動連線數時採用了"呼叫計數"的方式。這個處理方法不太好,準確性也不夠高,但是目前還未想到更好的解決方法用於統計子程式中活躍的連線數。我覺得還是要從底層進行解決,比如:巨集任務和微任務佇列、V8 虛擬機器、垃圾回收、Libuv 底層原理、Node 程式和執行緒原理...
  4. 暫時沒在 windows 平臺測試程式休眠功能,win 平臺本身不支援程式訊號,但是 Node 提供了模擬支援,但是具體表現還需測試。

VIII. Next To Do


  • [x] 讓 Service 支援程式碼更新後自動重啟
  • [x] 新增 ChildProcessPool 子程式排程邏輯
  • [x] 優化 ChildProcessPool 多程式console輸出
  • [x] 新增視覺化程式管理介面
  • [x] 增強 ChildProcessPool 程式池功能
  • [ ] 增強 ProcessHost 事務中心功能
  • [ ] 子程式之間互斥鎖邏輯的實現
  • [ ] 使用外部 remote 庫以支援最新版本的 Electron
  • [ ] Kill Bugs ?

IX. 幾個實際使用示例


  1. electronux - 我的一個Electron專案,使用了 BrowserService/MessageChannel,並且附帶了ChildProcessPool/ProcessHost使用demo。
  2. 暗影襪子-electron - 我的另一個Electron 跨平臺桌面應用專案(不提供連結,可以點選上面的檢視原文),使用 electron-re 進行除錯開發,並且在生產環境下可以開啟 ProcessManager UI 用於 CPU/Memory 資源佔用監控和請求日誌檢視。
  3. file-slice-upload - 一個關於多檔案分片並行上傳的demo,使用了 ChildProcessPool and ProcessHost,基於 Electron@9.3.5開發。
  4. 也可直接檢視 index.test.jstest 目錄下的測試樣例檔案,包含了一些使用示例。
  5. 當然 github - README 也有相關說明項。

相關文章