>>博客原文

文中实现的局部工具办法正处于晚期/测试阶段,仍在继续优化中,仅供参考...

在Ubuntu20.04上进行开发/测试,测试版本:Electron@8.2.0 / 9.3.5

Contents


├── Contents (you are here!)│├── I.前言│├── II.electron-re 能够用来做什么?│   ├── 1) 用于Electron利用│   └── 2) 用于Electron/Nodejs利用│├── III.UI性能介绍│   ├── 主界面│   ├── 性能1:Kill过程│   ├── 性能2:一键开启DevTools│   ├── 性能3:查看过程日志│   └── 性能4:查看过程CPU/Memory占用趋势│├── IV.应用&原理│   ├── 引入│   ├── 怎么捕捉过程资源占用?│   ├── 怎么在主过程和UI之间共享数据?│   └── 怎么在UI窗口中绘制折线图?│├── V. 存在的已知问题│├── VI. Next To Do│├── VII. 几个理论应用实例│   ├── 1)Service/MessageChannel示例│   ├── 2)ChildProcessPool/ProcessHost示例│   └── 3)test测试目录示例

I. 前言


最近在做一个多文件分片并行上传模块的时候(基于Electron和React),遇到了一些性能问题,次要体现在:前端同时增加大量文件(1000-10000)并行上传时(文件同时上传数默认为6),在不做懒加载优化的状况下,引起了整个利用窗口的卡顿。所以针对Electron/Nodejs多过程这方面做了一些学习,尝试应用多过程架构对上传流程进行优化。

同时也编写了一个不便进行Electron/Node多过程治理和调用的工具electron-re,曾经公布为npm组件,能够间接装置:

>> github地址

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

前文《Electron/Node多过程工具开发日记》形容了electron-re的开发背景、针对的问题场景以及具体的应用办法,这篇文章不会对它的根底应用做过多阐明。次要介绍新个性多过程治理UI的开发相干。UI界面基于electron-re已有的BrowserService/MessageChannelChildProcessPool/ProcessHost基础架构驱动,应用React17 / Babel7开发,主界面:

II. electron-re 能够用来做什么?


1. 用于Electron利用

  • BrowserService
  • MessageChannel

在Electron的一些“最佳实际”中,倡议将占用cpu的代码放到渲染过程中而不是间接放在主过程中,这里先看下chromium的架构图:

每个渲染过程都有一个全局对象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 } = 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);  });

III. UI性能介绍


II 形容了electron-re的次要性能,基于这些性能来实现多过程监控UI面板

主界面

UI参考electron-process-manager设计

预览图:

次要性能如下:

  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过程

性能2:一键开启DevTools

性能3:查看过程日志

性能3:查看过程CPU/Memory占用趋势

IV.应用&原理


引入

  1. 在Electron主过程入口文件中引入:
const {  MessageChannel, // must required in main.js even if you don't use it  ProcessManager} = require('electron-re');
  1. 开启过程治理窗口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渲染窗口发送日志数据

1秒之内采集到的所有过程的console数据会被长期缓存到数组中,默认每秒钟向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))为整个纵轴的长度。

V. 存在的已知问题


  1. 生产环境下ChildProcessPool未按预期工作

Electron生产环境下,如果app被装置到系统目录,那么ChildProcessPool不能依照预期工作,解决办法有:将app装置到用户目录或者把过程池用于创立子过程的脚本(通过path参数指定)独自放到Electron用户数据目录下(Ubuntu20.04上是~/.config/[appname])。

VI. Next To Do


  • ☑ 让Service反对代码更新后主动重启
  • ☐ 增加ChildProcessPool子过程调度逻辑
  • ☑ 优化ChildProcessPool多过程console输入
  • ☑ 增加可视化过程治理界面
  • ☐ 加强ChildProcessPool过程池性能
  • ☐ 加强ProcessHost事务核心性能

VII. 一些理论应用示例


  1. electronux - 我的一个Electron我的项目,应用了 BrowserService/MessageChannel,并且附带了ChildProcessPool/ProcessHost应用demo。
  2. file-slice-upload - 一个对于多文件分片并行上传的demo,应用了 ChildProcessPool and ProcessHost,基于 Electron@9.3.5开发。
  3. 也查看 test 目录下的测试样例文件,蕴含了残缺的细节应用。