前端固有的编程思维是单线程,比如JavaScript语言的单线程、浏览器JS线程与UI线程互斥等等,Web Woker是HTML5新增的能力,为前端带来多线程能力。这篇文章简单记录一下搜狗地图WebGL引擎(下文简称WebGL引擎)使用Web Worker的一些实践方案,虽然这个项目最终夭折并且我也从搜狗离职了,但在开发WebGL引擎过程中的一些心得和实践还是值得写一写的。
搜狗地图WebGL引擎使用Actor模型管理worker线程,所以这篇文章就围绕这一点展开,包括以下内容:
我们看到的电子地图实际上是由一个个网格拼合起来,这些网格叫做瓦片。根据瓦片的类型,地图可以分两种,一种是用静态图片配合css拼接,这种称为栅格地图;另一种是由WebGL将数据绘制为图形,这些数据便是真实的地理坐标,这种称为矢量地图。
这么说其实不太严谨,大多数电子地图使用的是墨卡托坐标,经过计算后转换为屏幕坐标,而不是真实的经纬度坐标,这个话题不属于本文的范畴,以后会单独讲
栅格地图是位图拼接的,是非矢量的,缩放会失真,这是缺点;优点是性能好,因为不需要很多计算。而矢量地图恰好相反,需要非常庞大的计算量,而优点便是缩放不会失真,并且可以实现3D效果。
传统的网站大多数用不到Web Worker或者对worker线程的要求比较轻,比如拉个数据啥的。Web Worker最佳的应用场景是计算密集类业务,而WebGL引擎在前端领域内可以说计算最密集的应用,体现在两方面:
比如下面这张图是Level 8的中国局部地图:
每个红色的网格就是一个瓦片,瓦片中的数据其实是一个个坐标点以及POI信息(坐标、文案等),WebGL引擎的工作包括以下几种:
WebGL的渲染管线比较复杂,除了基本的GPU渲染管线以外,在CPU层面也有很繁重的工作,比如数据治理、缓存、创建纹理、矩阵计算等等。后面我会专门写一篇渲染管线的介绍。
看起来很简单,就跟「把大象关进冰箱」一样拢共分三步,但其实里面的逻辑和计算非常复杂,我会在后续的文章里一一剖析,这篇只挑选与worker线程相关的内容讲。Web Worker在其中的主要工作有以下几个:
它最终的尺寸是包括坐标红点图标+坐标文本(实际是canvas纹理)的尺寸。而这类还算比较简单的POI,因为周边几乎没有其他POI,更复杂的还需要根据冲突检测结果动态调整文本与图标的相对位置,比如下图的两个POI,「微电子与纳电子学习」POI文本在图标的下方,『超导量子信息处理实验室』POI的文本就只能置于左侧、右侧或下方,否则会冲突。
最后一步是对视野内的所有POI进行冲突检测,剔除优先级低且位置与高优POI冲突的条目。这类计算在WebGIS业内有种通用的算法,叫做R树算法,JavaScript可用的开源工具推荐rbush。
综合以上的描述,WebGL对于worker线程的需求可以概括为两点:网络请求和计算。这两项工作交给worker线程之后,主线程便可以将资源集中在处理用户交互上,从而提高用户体验。
上面说的都是前提和需求,接下来就讲一讲如何实践的,首先介绍今天另一位主角:Actor模型。
Actor模型是一个为了解决并行计算问题的抽象概念,它并不是一个新词,诞生在40多年之前。大致背景是因为单核CPU无法突破性能瓶颈只能通过多核并行计算提高效率,Actor模型就是为了解决并行计算由共享可变状态引起的race condition、dead lock等问题,更多细节自己去Wiki查。
在前端领域Actor模型并没有被广泛使用,因为在Web Worker出现之前,前端并没有并行计算的条件,Google在2018年的Chrome dev submit中介绍了使用Actor模型搭配Web Worker的一套简易架构,这才有更多前端开发者去关注Actor模型。
Actor模型有以下几个特点:
以上特点可以概括为下图所示的模型:
除了以上特点以外,Actor的操作也有限制,只允许以下三种:
再说说为何Actor模型适合用来管理Web Worker线程。
前端使用Web Worker实现的多线程是一种主从(Master-Slave)模式:
Actor理论模型中并没有规定多线程使用哪种模式,但是Supervisor Actor的存在很适合主从多线程,所以与Web Worker的结合看上去非常合适。
但在实现层面,不一定完全遵从Actor理论模型,往往需要具体场景做一些改造,下面就简单讲一讲WebGL引擎在Actor+Web Worker方面的具体实现方式。
WebGL引擎对于worker线程的管理是一种类似负载均衡的模式,在Actor模型的基础之上增加了一个Dispatcher用于统筹管理所有的Actor,如下图:
每个Actor的工作包括以下几个:
Actor的伪代码如下:
export default class Actor {
private readonly _worker:Worker;
private readonly _id:number;
private _callbacks:KV<Function> = {};
private _counter: number = 0;
private _queue:MessageObject[]=[];
private _busy:boolean=false;
constructor(worker:Worker, id:number) {
this._id=id;
this._worker = worker;
this.receive = this.receive.bind(this);
this._worker.addEventListener('message', this.receive, false);
}
/**
* 占用状态
* @memberof Actor
*/
get busy():boolean{
return this._busy;
}
set busy(status:boolean){
this._busy = status;
// 解除占用状态后如果待执行队列不为空则执行队首任务
if(!status&&this._queue.length){
const {action,data,callback} = this._queue.shift();
this.send(action,data,callback);
}
}
/**
* @memberof Actor
*/
get worker():Worker{
return this._worker;
}
/**
* @private
* @method _postMessage
* @param message
*/
private _postMessage(message) {
this._worker.postMessage(message);
}
private _queueTask(action:WORKER_ACTION, data, callback?:Function){
this._queue.push({action,data,callback});
}
public receive(message:TypePostMessage) {
this.busy = false;
const {id,data} = message.data;
const callback = id?this._callbacks[id]:null;
callback&&callback(data);
delete this._callbacks[id];
}
public send(action:WORKER_ACTION, data, callback?:Function) {
if(this.busy){
this._queueTask(action,data,callback);
return;
}
this.busy = true;
const callbackId = `${this._id}-${action}-cb-${this._counter}`;
if(callback){
this._callbacks[callbackId] = callback;
this._counter++;
}
this._postMessage({
action,
data,
id: callbackId,
});
}
}
Dispatcher的工作比较简单,向上负责接收外层逻辑的调用命令,向下负责管理所有Actor的调度,代码如下:
export default class Dispatcher {
private readonly _actorsCount: number = 1;
private _actors: Actor[]=[];
constructor(count:number) {
this._actorsCount = count;
for (let i = 0; i < count; i++) {
this._actors.push(new Actor(new IWorker(''),i));
}
}
/**
* @public
* @method broadcast 广播指令
* @param {WORKER_ACTION} action 指令名称
* @param {Object} data 数据
*/
public broadcast(action: WORKER_ACTION, data: any) {
for(const actor of this._actors){
actor.send(action, data);
}
}
/**
* @public
* @method send 向单个worker发送动作指令
* @param {WORKER_ACTION} action 指令名称
* @param {Object} data 数据
* @param {Function} [callback] 回调函数
* @param {string} [workerId] 指定worker id
*/
public send(action:WORKER_ACTION, data: any, callback?:Function,workerId?:string) {
const actor = this._actors.filter(a=>!a.busy)[0];
if(actor){
actor.send(action, data, callback);
}else{
const randomId = Math.floor(Math.random()*this._actorsCount);
this._actors[randomId].send(action,data,callback);
}
}
/**
* @public
* @method clear 终止所有worker,清空actors
*/
public clear() {
for(const actor of this._actors){
actor.worker.terminate();
}
this._actors = [];
}
}
Dispatcher需要一个广播API,用来给所有Actor同步信息,比如将瓦片数据中的地理坐标转化为屏幕坐标需要用到屏幕的DPR,可以借助broadcast API将这个信息发送给所有Actor。
另外,Dispatcher并没有接受Actor的message,而是以回调函数的模式为每次任务分配一个handler,Actor执行完任务之后会触发对应的handler。以一个典型的用户交互触发重绘的行为为例,整个流程如下:
tile_pyramid.ts
调用分发器dispatcher.ts
执行加载瓦片的任务;dispatcher.ts
首先会判断所有Actor中是否有被占用的,如果存在空闲Actor则直接将任务分配给它,如果没有空闲Actor则随机选择一个Actor执行任务,此时被选中的Actor会将任务塞入任务队列,排队执行。以上便是WebGL引擎的对于Actor+worker的具体实现模式,加入负载均衡概念之后可以更有效地解决线程被占用时的任务动态分配。因为此WebGL引擎是内部项目,不便将更细节的代码写出来,比如worker的具体任务,所以大家就将就看吧。