关于electron:Electron多进程工具开发日记2进程管理UI

8次阅读

共计 11355 个字符,预计需要花费 29 分钟才能阅读完成。

>> 博客原文

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

在 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 目录下的测试样例文件,蕴含了残缺的细节应用。
正文完
 0