Electron多進程工具開發日記2:進程管理UI

>>博客原文javascript

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

在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佔用趨勢
│
├── V. 使用&原理
│   ├── 引入
│   ├── 怎樣捕獲進程資源佔用?
│   ├── 怎樣在主進程和UI之間共享數據?
│   └── 怎樣在UI窗口中繪製折線圖?
│
├── VI. 存在的已知問題
│
├── VII. Next To Do
│
├── VIII. 幾個實際使用示例
│   ├── 1) Service/MessageChannel示例
│   ├── 2) ChildProcessPool/ProcessHost示例
│   └── 3) test測試目錄示例

I. 前言


最近在作一個多文件分片並行上傳模塊的時候(基於Electron和React),遇到了一些性能問題,主要體如今:前端同時添加大量文件(1000-10000)並行上傳時(文件同時上傳數默認爲6),在不作懶加載優化的狀況下,引發了整個應用窗口的卡頓。因此針對Electron/Nodejs多進程這方面作了一些學習,嘗試使用多進程架構對上傳流程進行優化。java

同時也編寫了一個方便進行Electron/Node多進程管理和調用的工具electron-re,已經發布爲npm組件,能夠直接安裝:node

>> github地址git

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

前文《Electron/Node多進程工具開發日記》描述了electron-re的開發背景、針對的問題場景以及詳細的使用方法,這篇文章不會對它的基礎使用作過多說明,主要介紹新特性多進程管理UI的開發相關。UI界面基於electron-re已有的BrowserService/MessageChannelChildProcessPool/ProcessHost基礎架構驅動,使用React17 / Babel7開發,主界面:github

process-manager.main.png

II. electron-re架構圖


electron-re.png

III. electron-re 能夠用來作什麼?


1. 用於Electron應用

  • BrowserService
  • MessageChannel

在Electron的一些「最佳實踐」中,建議將佔用cpu的代碼放到渲染過程當中而不是直接放在主過程當中,這裏先看下chromium的架構圖:web

chromium.jpg

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

所以electron-re在Electron已有的Main Process主進程和Renderer Process渲染進程邏輯的基礎上獨立出一個單獨的Service概念。Service即不須要顯示界面的後臺進程,它不參與UI交互,單獨爲主進程或其它渲染進程提供服務,它的底層實現爲一個容許node注入remote調用的渲染窗口進程。shell

這樣就能夠將代碼中耗費cpu的操做(好比文件上傳中維護一個數千個上傳任務的隊列)編寫成一個單獨的js文件,而後使用BrowserService構造函數以這個js文件的地址path爲參數構造一個Service實例,從而將他們從主進程中分離。若是你說那這部分耗費cpu的操做直接放到渲染窗口進程能夠嘛?這其實取決於項目自身的架構設計,以及對進程之間數據傳輸性能損耗和傳輸時間等各方面的權衡,建立一個Service的簡單示例:npm

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 } = require('electron-re');
global.ipcUploadProcess = new ChildProcessPool({
  path: path.join(app.getAppPath(), 'app/services/child/upload.js'), max: 6
});

通常狀況下,在咱們的子進程執行文件中(建立子進程時path參數指定的腳本),如要想在主進程和子進程之間同步數據,可使用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實例以便獲取回調數據,簡單示例以下:

/* --- 主進程中 --- */
...
global.ipcUploadProcess
  .send('task1', params)
  .then(rsp => console.log(rsp));

/* --- 子進程中 --- */
const { ProcessHost } = require('electron-re');
ProcessHost
  .registry('task1', (params) => {
    return { value: 'task-value' };
  })
  .registry('init-works', (params) => {
    return fetch(url);
  });

IV. UI功能介紹


II 描述了electron-re的主要功能,基於這些功能來實現多進程監控UI面板

主界面

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/內存佔用趨勢,若是此進程是渲染進程的話還能夠經過DevTools按鈕一鍵打開內置調試工具。
  4. ChildProcessPool建立的子進程暫不支持直接打開DevTools進行調試,不過因爲建立子進程時添加了--inspect參數,可使用chrome的chrome://inspect進行遠程調試。

功能1:Kill進程

kill.gif

功能2:一鍵開啓DevTools

devtools.gif

功能3:查看進程日誌

console.gif

功能3:查看進程CPU/Memory佔用趨勢

trends.gif

trends2.gif

V. 使用&原理


引入

1. 在Electron主進程入口文件中引入:

const {
  MessageChannel, // must required in main.js even if you don't use it
  ProcessManager
} = require('electron-re');

2. 開啓進程管理窗口UI

ProcessManager.openWindow();

怎樣捕獲進程資源佔用?

1.使用ProcessManager監聽多個進程號

  • 1)在Electron窗口建立事件中將窗口進程id放入ProcessManager監聽列表
/* --- src/index.js --- */
...
app.on('web-contents-created', (event, webContents) => {
  webContents.once('did-finish-load', () => {
    const pid = webContents.getOSProcessId();
    if (
      exports.ProcessManager.processWindow &&
      exports.ProcessManager.processWindow.webContents.getOSProcessId() === pid
    ) { return; }

    exports.ProcessManager.listen(pid, 'renderer');

    webContents.once('closed', function(e) {
      exports.ProcessManager.unlisten(this.pid);
    }.bind({ pid }));
      ...
  })
});
  • 2)在進程池fork子進程時將進程id放入監聽列表
/* --- src/libs/ChildProcessPool.class.js --- */
...
const { fork } = require('child_process');

class ChildProcessPool {
  constructor({ path, max=6, cwd, env }) {
    ...
    this.event = new EventEmitter();
    this.event.on('fork', (pids) => {
      ProcessManager.listen(pids, 'node');
    });
    this.event.on('unfork', (pids) => {
      ProcessManager.unlisten(pids);
    });
  }

  /* Get a process instance from the pool */
  getForkedFromPool(id="default") {
    let forked;
    ...
    forked = fork(this.forkedPath, ...);
    this.event.emit('fork', this.forked.map(fork => fork.pid));
    ...
    return forked;
  }
  ...
}
  • 3)在Service進程註冊時監聽進程id

BrowserService進程建立時會向主進程MessageChannel發送registry請求來全局註冊一個Service服務,此時將進程id放入監聽列表便可:

/* --- src/index.js --- */
...
exports.MessageChannel.event.on('registry', ({pid}) => {
  exports.ProcessManager.listen(pid, 'service');
});
...
exports.MessageChannel.event.on('unregistry', ({pid}) => {
  exports.ProcessManager.unlisten(pid)
});

2.使用兼容多平臺的pidusage庫每秒採集一次進程的負載數據:

/* --- src/libs/ProcessManager.class.js --- */
...
const pidusage = require('pidusage');

class ProcessManager {
  constructor() {
    this.pidList = [process.pid];
    this.typeMap = {
      [process.pid]: 'main',
    };
    ...
  }

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

  /* 設置外部庫採集併發送到UI進程 */
  refreshList = () => {
    return new Promise((resolve, reject) => {
      if (this.pidList.length) {
        pidusage(this.pidList, (err, records) => {
          if (err) {
            console.log(`ProcessManager: refreshList -> ${err}`);
          } else {
            this.processWindow.webContents.send('process:update-list', { records, types: this.typeMap });
          }
          resolve();
        });
      } else {
        resolve([]);
      }
    });
  }

  /* 設置定時器進行採集 */
  setTimer() {
    if (this.status === 'started') return console.warn('ProcessManager: the timer is already started!');

    const interval = async () => {
      setTimeout(async () => {
        await this.refreshList()
        interval(this.time)
      }, this.time)
    }

    this.status = 'started';
    interval()
  }
  ...

3.監聽進程輸出來採集進程日誌

進程池建立的子進程能夠經過監聽 stdout標準輸出流來進行日誌採集;Electron渲染窗口進程則能夠經過監聽 ipc通訊事件 console-message來進行採集;
/* --- src/libs/ProcessManager.class.js --- */

class ProcessManager {
  constructor() {
    ...
  }

  /* pipe to process.stdout */
  pipe(pinstance) {
    if (pinstance.stdout) {
      pinstance.stdout.on(
        'data',
        (trunk) => {
          this.stdout(pinstance.pid, trunk);
        }
      );
    }
  }
  ...
}

/* --- src/index.js --- */

app.on('web-contents-created', (event, webContents) => {
    webContents.once('did-finish-load', () => {
      const pid = webContents.getOSProcessId();
      ...
      webContents.on('console-message', (e, level, msg, line, sourceid) => {
        exports.ProcessManager.stdout(pid, msg);
      });
      ...
    })
  });

怎樣在主進程和UI之間共享數據?

基於Electron原生 ipc異步通訊

1.使用ProcessManager向UI渲染窗口發送日誌數據

每秒採集到的全部進程的console數據會被臨時緩存到數組中,默認1秒鐘向UI進程發送一次數據,而後清空臨時數組。

在這裏須要注意的是ChildProcessPool中的子進程是經過Node.js的child_process.fork()方法建立的,此方法會衍生shell,且建立子進程時參數stdio會被指定爲'pipe',指明在子進程和父進程之間建立一個管道,從而讓父進程中能夠直接監聽子進程對象上的 stdout.on('data')事件來拿到子進程的標準輸出流。

/* --- src/libs/ProcessManager.class.js --- */

class ProcessManager {
  constructor() {
    ...
  }

  /* pipe to process.stdout */
  pipe(pinstance) {
    if (pinstance.stdout) {
      pinstance.stdout.on(
        'data',
        (trunk) => {
          this.stdout(pinstance.pid, trunk);
        }
      );
    }
  }

  /* send stdout to ui-processor */
  stdout(pid, data) {
    if (this.processWindow) {
      if (!this.callSymbol) {
        this.callSymbol = true;
        setTimeout(() => {
          this.processWindow.webContents.send('process:stdout', this.logs);
          this.logs = [];
          this.callSymbol = false;
        }, this.time);
      } else {
        this.logs.push({ pid: pid, data: String.prototype.trim.call(data) });
      }
    }
  }
  ...

}

2.使用ProcessManager向UI渲染窗口發送進程負載信息

/* --- src/libs/ProcessManager.class.js --- */

class ProcessManager {
  constructor() {
    ...
  }

  /* 設置外部庫採集併發送到UI進程 */
  refreshList = () => {
    return new Promise((resolve, reject) => {
      if (this.pidList.length) {
        pidusage(this.pidList, (err, records) => {
          if (err) {
            console.log(`ProcessManager: refreshList -> ${err}`);
          } else {
            this.processWindow.webContents.send('process:update-list', { records, types: this.typeMap });
          }
          resolve();
        });
      } else {
        resolve([]);
      }
    });
  }
  ...

}

3.UI窗口拿到數據後處理並臨時存儲

import { ipcRenderer, remote } from 'electron';
  ...

    ipcRenderer.on('process:update-list', (event, { records, types }) => {
      console.log('update:list');
      const { history } = this.state;
      for (let pid in records) {
        history[pid] = history[pid] || { memory: [], cpu: [] };
        if (!records[pid]) continue;
        history[pid].memory.push(records[pid].memory);
        history[pid].cpu.push(records[pid].cpu);
        // 存儲最近的60條進程負載數據
        history[pid].memory = history[pid].memory.slice(-60); 
        history[pid].cpu = history[pid].cpu.slice(-60);
      }
      this.setState({
        processes: records,
        history,
        types
      });
    });

    ipcRenderer.on('process:stdout', (event, dataArray) => {
      console.log('process:stdout');
      const { logs } = this.state;
      dataArray.forEach(({ pid, data })=> {
        logs[pid] = logs[pid] || [];
        logs[pid].unshift(`[${new Date().toLocaleTimeString()}]: ${data}`);
      });
      // 存儲最近的1000個日誌輸出
      Object.keys(logs).forEach(pid => {
        logs[pid].slice(0, 1000);
      });
      this.setState({ logs });
    });

怎樣在UI窗口中繪製折線圖?

1.注意使用React.PureComponent,會自動在屬性更新進行淺比較,以減小沒必要要的渲染

/* *************** ProcessTrends *************** */
export class ProcessTrends extends React.PureComponent {
  componentDidMount() {
    ...
  }

  ...

  render() {
    const { visible, memory, cpu } = this.props;
    if (visible) {
      this.uiDrawer.draw();
      this.dataDrawer.draw(cpu, memory);
    };

    return (
      <div className={`process-trends-container ${!visible ? 'hidden' : 'progressive-show' }`}>
        <header>
          <span className="text-button small" onClick={this.handleCloseTrends}>X</span>
        </header>
        <div className="trends-drawer">
          <canvas
            width={document.body.clientWidth * window.devicePixelRatio}
            height={document.body.clientHeight * window.devicePixelRatio}
            id="trendsUI"
          />
          <canvas
            width={document.body.clientWidth * window.devicePixelRatio}
            height={document.body.clientHeight * window.devicePixelRatio}
            id="trendsData"
          />
        </div>
      </div>
    )
  }
}

2.使用兩個Canvas畫布分別繪製座標軸和折線線段

設置兩個畫布相互重疊以儘量保證靜態的座標軸不會被重複繪製,咱們須要在組件掛載後初始化一個座標軸繪製對象 uiDrawer和一個數據折線繪製對象 dataDrawer
...
  componentDidMount() {
    this.uiDrawer = new UI_Drawer('#trendsUI', {
      xPoints: 60,
      yPoints: 100
    });
    this.dataDrawer = new Data_Drawer('#trendsData');
    window.addEventListener('resize', this.resizeDebouncer);
  }
...

如下是Canvas相關的基礎繪製命令:

this.canvas = document.querySelector(selector);
this.ctx =  this.canvas.getContext('2d');
this.ctx.strokeStyle = lineColor; // 設置線段顏色
this.ctx.beginPath(); // 建立一個新的路徑
this.ctx.moveTo(x, y); // 移動到初始座標點(不進行繪製)
this.ctx.lineTo(Math.floor(x), Math.floor(y)); // 描述從上一個座標點到(x, y)的一條直線
this.ctx.stroke(); // 開始繪製

繪製類的源代碼能夠查看這裏Drawer,大概原理是:設置Canvas畫布寬度width和高度height鋪滿窗口,設定橫縱座標軸到邊緣的padding值爲30,Canvas座標原點[0,0]爲繪製區域左上角頂點。這裏以繪製折線圖縱軸座標爲例,縱軸表示CPU佔用0%-100%或內存佔用0-1GB,咱們能夠將縱軸劃分爲100個基礎單位,可是縱軸座標點不用爲100個,能夠設置爲10個方便查看,因此每一個座標點就能夠表示爲[0, (height-padding) - ((height-(2*padding)) / index) * 100 ],index依次等於0,10,20,30...90,其中(height-padding)爲最下面那個座標點位置,(height-(2*padding))爲整個縱軸的長度。

VI. 存在的已知問題


1.生產環境下ChildProcessPool未按預期工做

Electron生產環境下,若是app被安裝到系統目錄,那麼ChildProcessPool不能按照預期工做,解決辦法有:將app安裝到用戶目錄或者把進程池用於建立子進程的腳本(經過path參數指定)單獨放到Electron用戶數據目錄下(Ubuntu20.04上是~/.config/[appname])。

2.UI界面未監聽主進程Console數據

主進程暫未支持此功能,正在尋找解決方案。

VII. Next To Do


  • ☑ 讓Service支持代碼更新後自動重啓
  • ☐ 添加ChildProcessPool子進程調度邏輯
  • ☑ 優化ChildProcessPool多進程console輸出
  • ☑ 添加可視化進程管理界面
  • ☐ 加強ChildProcessPool進程池功能
  • ☐ 加強ProcessHost事務中心功能

VIII. 幾個實際使用示例


  1. electronux - 個人一個Electron項目,使用了 BrowserService/MessageChannel,而且附帶了ChildProcessPool/ProcessHost使用demo。
  2. file-slice-upload - 一個關於多文件分片並行上傳的demo,使用了 ChildProcessPool and ProcessHost,基於 Electron@9.3.5開發。
  3. 也查看 test 目錄下的測試樣例文件,包含了完整的細節使用。
相關文章
相關標籤/搜索