WebWorker 方案讨论
xiaoiver opened this issue · comments
问题背景
在 graphpology 中只有 noverlap / force / forceatlas2 支持在 WebWorker 中运行。
但我们希望所有布局算法都可以,无论是否是 iterative,至少可以做到不阻塞主线程运行。
此外我们希望在工程、使用体验上作出以下改进:
- 算法主体部分不出现 WebWorker 相关代码,保持纯粹
- 提供易用的 WebWorker 相关控制逻辑,例如启动、停止等
- 开发调试友好,不需要发线上 UMD
设计思路
提供一个统一的监视器(这个名字也是从 graphology 中看来的),主线程和 WebWorker 线程协作过程如下:
- 主线程 创建 Supervisor,接收一个图模型和算法作为参数
- 主线程 创建 WebWorker 并与之通信。在 payload 中带上当前执行的算法、参数,图节点/边数据(转换成 ArrayBuffer,通过 Transferables 共享控制权),通过 postMessage 向 WebWorker 发送消息
- WebWorker 接收到事件上携带的 payload 后,同步创建一个对应的算法并执行,返回计算结果(可以是 Transferables)
- 主线程 接收计算结果,触发对应生命周期事件(例如 tick layoutend 等)
API
创建一个“监视器”,接收一个图模型和算法作为参数:
const graph = new Graph();
const layout = new CircularLayout();
const supervisor = new Supervisor(graph, layout, { auto: true });
流程控制:
supervisor.start();
supervisor.stop();
supervisor.kill();
事件。既然是一个异步的计算过程,就需要通知主线程当前的计算状态,例如单次迭代完成、全部计算完成等:
supervisor.on('tick', (positions) => {
});
supervisor.on('layoutend', (positions) => {
});
实现
之前创建 Worker 是在 G6 代码中完成的,会存在有一些局限性:
https://github.com/antvis/G6/blob/master/packages/pc/src/layout/worker/work.ts#L5
- 如果想单独使用
@antv/layout
,需要重复实现一遍 WebWorker 相关的逻辑 - 目前在 Worker 代码中使用
importScripts
直接引用线上的 layout UMD 版本,无网络环境下就无法使用了,也不便于调试(需要把新版本发到线上)
目前使用 Webpack + workerize-loader 的方案,比照最初设想的 template 替换可以说是好处多多:
- 可以将代码内联到 WebWorker 中
- 走 Webpack 编译,因此可以使用正常的高级语法,开发调试时也可以被 watch
- workerize-loader 封装了 Worker 的创建与通信代码,还可以通过
@naoak/workerize-transferable
使用 Transferables
import worker from "workerize-loader?inline!./worker";
主线程和 WebWorker 通信时,需要告知所需的信息,便于在 Worker 中同步创建 Graph 和 Layout 算法并执行,目前携带的数据如下:
layout
包含算法名称、参数。Worker 据此可以同步创建对应的 Layout 对象nodes/edges
目前是直接把 Graph 上的原始节点边数据带过去,是这么获取的nodes: this.graph.getAllNodes()
。但考虑到 Transferables,需要将数据转换成线性存储,例如:const arraybufferWithNodesEdges = graphToByteArrays(this.graph); // Float32Array
export interface Payload {
layout: {
id: string;
options: any;
};
nodes: Node<any>[];
edges: Edge<any>[];
}
目前的实现:
目前存在的问题
体积问题
相比之前运行时通过 importScripts
的方式加载 Layout UMD,目前由于是内联的,体积会变大,可以近似认为是原来的两倍。
自定义布局
目前 Worker 代码是构建时产生的,所以运行时的自定义布局即使可以“注册”,也无法添加到已经生成好的 Worker 代码里,也就无法使用 Supervisor 的功能了。
Transferables
我看到 graphology 的三个 Worker 实现中都使用了这个特性,简单来说就是主线程和 Worker 共享同一块线性内存的控制权:
https://github.com/graphology/graphology/blob/master/src/layout-forceatlas2/webworker.tpl.js#L29-L34
NODES = new Float32Array(data.nodes);
self.postMessage(
{
nodes: NODES.buffer
},
[NODES.buffer]
);
在我们目前的方案中要使用这个特性也很简单,借助 @naoak/workerize-transferable 就可以完成,例如在 Worker 侧的代码如下:
import { setupTransferableMethodsOnWorker } from "@naoak/workerize-transferable";
setupTransferableMethodsOnWorker({
// The name of function which use some transferables.
calculateLayout: {
// Specify an instance of the function
fn: calculateLayout,
// Pick a transferable object from the result which is an instance of Float32Array
pickTransferablesFromResult: (result) => [result[1].buffer],
},
});
剩下的问题就是如何设计线性内存的结构,合理存储节点和边的数据。
在 graphology 中不同的算法有不同的结构,例如 forceatlas2:
https://github.com/graphology/graphology/blob/master/src/layout-forceatlas2/helpers.js#L117
var NodeMatrix = new Float32Array(order * PPN);
var EdgeMatrix = new Float32Array(size * PPE);
graph.forEachNode(function (node, attr) {
// Node index
index[node] = j;
// Populating byte array
NodeMatrix[j] = attr.x;
NodeMatrix[j + 1] = attr.y;
NodeMatrix[j + 2] = 0; // dx
NodeMatrix[j + 3] = 0; // dy
NodeMatrix[j + 4] = 0; // old_dx
NodeMatrix[j + 5] = 0; // old_dy
NodeMatrix[j + 6] = 1; // mass
NodeMatrix[j + 7] = 1; // convergence
NodeMatrix[j + 8] = attr.size || 1;
NodeMatrix[j + 9] = attr.fixed ? 1 : 0;
j += PPN;
});
因此每个 Layout 实现需要实现自己的 graphToByteArray
方法。
ESM
目前用 Webpack 生成的是 UMD + 类型文件,那 ESM 应该咋生成呢?如果是用 tsc 的话,肯定不认识这样带 webpack loader 的语法:
import worker from "workerize-loader?inline!./worker";
因为webworker需要提供一个URL,ESM 版本能用的话,要么使用提前打包好的内联代码,要么使用在线url文件。除非用户也要安装类似workerize-loader的插件...
使用 Webpack ESM bundle 的问题
因为webworker需要提供一个URL,ESM 版本能用的话,要么使用提前打包好的内联代码,要么使用在线url文件。除非用户也要安装类似workerize-loader的插件...
我验证了一下之前的 ESM 产物,由于是用 webpack + workerize-loader 生成的 bundle,无法被 treeshaking:
import { ForceAtlas2Layout } from "@antv/layout";
export function useForceAtlas2Layout() {
return new ForceAtlas2Layout();
}
可以看出只引用了一个布局,但是整个 bundle 都被打进来了:
![截屏2023-05-23 上午10 29 48](https://private-user-images.githubusercontent.com/3608471/240103214-44cda20d-7516-452b-83b6-3401e7e503f0.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3MTUxNjMxNzEsIm5iZiI6MTcxNTE2Mjg3MSwicGF0aCI6Ii8zNjA4NDcxLzI0MDEwMzIxNC00NGNkYTIwZC03NTE2LTQ1MmItODNiNi0zNDAxZTdlNTAzZjAucG5nP1gtQW16LUFsZ29yaXRobT1BV1M0LUhNQUMtU0hBMjU2JlgtQW16LUNyZWRlbnRpYWw9QUtJQVZDT0RZTFNBNTNQUUs0WkElMkYyMDI0MDUwOCUyRnVzLWVhc3QtMSUyRnMzJTJGYXdzNF9yZXF1ZXN0JlgtQW16LURhdGU9MjAyNDA1MDhUMTAwNzUxWiZYLUFtei1FeHBpcmVzPTMwMCZYLUFtei1TaWduYXR1cmU9OWI1OWRiOGM5OTc2NTQ4Y2IyOTQ0YTg3YjI2NmM0MjRjNWU4OTExZmViMDNhOWY5N2YwNjdkM2FhNmYyODA4OSZYLUFtei1TaWduZWRIZWFkZXJzPWhvc3QmYWN0b3JfaWQ9MCZrZXlfaWQ9MCZyZXBvX2lkPTAifQ.TxqdrrHVXspF__nUW1pkyhMsyROEuwiHZKQwVvr4jlk)
因此 ESM 使用 tsc 构建,放弃 webpack loader 语法。
WebWorker 交给用户的构建工具处理
移除了对于 Webpack workerize-loader 的依赖,在 ESM 产物中保留如下代码:
this.worker = Comlink.wrap(
// 创建 Worker
new Worker(new URL("./worker.js", import.meta.url), {
type: "module",
})
);
这也是 vite 推荐的 WebWorker 使用方式:https://vitejs.dev/guide/features.html#web-workers
webpack 5 同样推荐这种方式,而不是 worker-loader:https://webpack.js.org/guides/web-workers/
这样用户使用 ESM 时,可以根据自己的构建工具例如 vite、webpack 配置:
- Vite 例子:https://stackblitz.com/edit/vite-t1euu4?file=vite.config.js
- Webpack5 例子:https://stackblitz.com/edit/github-wpncwj-u8ghot?file=src%2Findex.js
UMD 使用方式
除了 ESM,如果希望用户通过 UMD 方式使用,最好的方式就是把 WebWorker 代码通过 Blob 内联进来,因此我们依然保留了一个 bundle-entry.ts
,其中依然包含了 workerize-loader 语法供 webpack 消费。webpack/webpack#14198
我写了一个 Codepen 例子,在 WebWorker 中使用同心圆布局:
https://codepen.io/xiaoiver/pen/LYgqEbN
兼容性问题
不是所有浏览器都支持 type: 'module'
:
new Worker(new URL("./worker.js", import.meta.url), {
type: "module",
})
因此一种兼容方式是通过 Blob 创建 URL:
const workerBlob = new Blob([workerString]);
const workerUrl = URL.createObjectURL(workerBlob);
const worker = new Worker(workerUrl);