使用Actor模型管理Web Worker多线程

前端固有的编程思维是单线程,比如JavaScript语言的单线程、浏览器JS线程与UI线程互斥等等,Web Woker是HTML5新增的能力,为前端带来多线程能力。这篇文章简单记录一下搜狗地图WebGL引擎(下文简称WebGL引擎)使用Web Worker的一些实践方案,虽然这个项目最终夭折并且我也从搜狗离职了,但在开发WebGL引擎过程中的一些心得和实践还是值得写一写的。

搜狗地图WebGL引擎使用Actor模型管理worker线程,所以这篇文章就围绕这一点展开,包括以下内容:

  • WebGL引擎为何要使用Web Worker以及对worker线程的需求定位
  • Actor模型是什么以及为何它适用于Web Worker
  • WebGL引擎的Actor模型+Web Worker的实践方案

WebGL引擎对Web Worker的需求定位

我们看到的电子地图实际上是由一个个网格拼合起来,这些网格叫做瓦片。根据瓦片的类型,地图可以分两种,一种是用静态图片配合css拼接,这种称为栅格地图;另一种是由WebGL将数据绘制为图形,这些数据便是真实的地理坐标,这种称为矢量地图。

这么说其实不太严谨,大多数电子地图使用的是墨卡托坐标,经过计算后转换为屏幕坐标,而不是真实的经纬度坐标,这个话题不属于本文的范畴,以后会单独讲

栅格地图是位图拼接的,是非矢量的,缩放会失真,这是缺点;优点是性能好,因为不需要很多计算。而矢量地图恰好相反,需要非常庞大的计算量,而优点便是缩放不会失真,并且可以实现3D效果。

传统的网站大多数用不到Web Worker或者对worker线程的要求比较轻,比如拉个数据啥的。Web Worker最佳的应用场景是计算密集类业务,而WebGL引擎在前端领域内可以说计算最密集的应用,体现在两方面:

  • 数据量庞大
  • 计算复杂且密集

比如下面这张图是Level 8的中国局部地图:

每个红色的网格就是一个瓦片,瓦片中的数据其实是一个个坐标点以及POI信息(坐标、文案等),WebGL引擎的工作包括以下几种:

  • 根据当前视野计算瓦片坐标;
  • 从后台接口获取瓦片数据;
  • 渲染。

WebGL的渲染管线比较复杂,除了基本的GPU渲染管线以外,在CPU层面也有很繁重的工作,比如数据治理、缓存、创建纹理、矩阵计算等等。后面我会专门写一篇渲染管线的介绍。

看起来很简单,就跟「把大象关进冰箱」一样拢共分三步,但其实里面的逻辑和计算非常复杂,我会在后续的文章里一一剖析,这篇只挑选与worker线程相关的内容讲。Web Worker在其中的主要工作有以下几个:

  • 从接口获取瓦片数据。这个比较简单,没啥好说的,说白了就是网络请求,稍微特殊的就是地图瓦片的数据比较大,请求耗时相对会长一点;

  • 将瓦片数据解析为绘制可用的数据。瓦片数据可以简单理解为地理坐标+规则,WebGL引擎需要将地理坐标转化为屏幕坐标,然后按照规则将其进一步转化为最终可绘制的数据。这些规则包括样式(颜色/线宽等)、图形类别(Polygon/Line/Point等)、权重等,其中权重是比较特殊的一种规则,代表图形的绘制优先级,高优先级的后绘制,这是因为WebGL的绘制过程中,后绘制的图形会遮盖同位置已有的图形。

  • 对POI进行定位计算。这个整个地图引擎中最复杂的一套计算流程。瓦片中的POI原始数据仅仅是一个点的地理坐标和文本,其中文本需要对应创建一个2D canvas作为WebGL的纹理。WebGL引擎首先需要从style文件中获取到POI的图标,然后将文本换算为canvas的尺寸,计算出整个POI图形的尺寸。比如天津的POI图形是这样的:

    它最终的尺寸是包括坐标红点图标+坐标文本(实际是canvas纹理)的尺寸。而这类还算比较简单的POI,因为周边几乎没有其他POI,更复杂的还需要根据冲突检测结果动态调整文本与图标的相对位置,比如下图的两个POI,「微电子与纳电子学习」POI文本在图标的下方,『超导量子信息处理实验室』POI的文本就只能置于左侧、右侧或下方,否则会冲突。

    最后一步是对视野内的所有POI进行冲突检测,剔除优先级低且位置与高优POI冲突的条目。这类计算在WebGIS业内有种通用的算法,叫做R树算法,JavaScript可用的开源工具推荐rbush

  • 对文本进行定位计算,比如道路的名称需要沿着道路线条布局如下图,这项工作量也比较复杂,后面我会单独写一篇。

综合以上的描述,WebGL对于worker线程的需求可以概括为两点:网络请求计算。这两项工作交给worker线程之后,主线程便可以将资源集中在处理用户交互上,从而提高用户体验。

上面说的都是前提和需求,接下来就讲一讲如何实践的,首先介绍今天另一位主角:Actor模型。

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的state都是private,不存在共享状态。理想情况下,每个Actor都运行在不同的线程,也不存在共享内存;
  • 借助message通信:每个Actor通过接收message分发任务,可以理解为每个message都会触发一个任务,因此可能产生任务排队,每个Actor维护一个private task queue,每个task执行结束后通过message向外传递信息。

以上特点可以概括为下图所示的模型:

Actor Model

除了以上特点以外,Actor的操作也有限制,只允许以下三种:

  1. 向外传送message;
  2. 根据接受到的message分发对应任务。Actor对于message对应的任务并没有限定为静态的,而是可以携带动态数据甚至函数,这样就大大地增强了Actor的可定制性;
  3. 创建其他Actor。一个Actor对于它创建的其他Actor有管理员权限,可以定制其他Actor的某些行为。比如Actor A创建了Actor B,对于Actor B来说,Actor A就是Supervisor Actor。Actor A可以限制Actor B的行为,比如当Actor B崩溃以后发送一个message通知Actor A,这样Actor A就可以在接收到这个message时重启Actor B。这种机制跟PM2的重启机制很相似。通过这个特性也能看出来,Actor模型不仅适用于处理并行计算问题,同样适合分布式系统。

再说说为何Actor模型适合用来管理Web Worker线程。

前端使用Web Worker实现的多线程是一种主从(Master-Slave)模式:

  • worker线程只具备有限的权限,不能操作DOM,从这个角度上来说,worker线程对于浏览器来说是线程安全的;
  • worker线程与master线程(即JS主线程)之间通过postMessage通信;
  • master线程通过发送message指定worker执行哪些行为,worker线程通过message返回结果。

Actor理论模型中并没有规定多线程使用哪种模式,但是Supervisor Actor的存在很适合主从多线程,所以与Web Worker的结合看上去非常合适。

但在实现层面,不一定完全遵从Actor理论模型,往往需要具体场景做一些改造,下面就简单讲一讲WebGL引擎在Actor+Web Worker方面的具体实现方式。

Actor模型在WebGL引擎渲染的实践应用

WebGL引擎对于worker线程的管理是一种类似负载均衡的模式,在Actor模型的基础之上增加了一个Dispatcher用于统筹管理所有的Actor,如下图:

每个Actor的工作包括以下几个:

  1. 管理一个worker线程,负责向worker线程发送message和接收message的实质行为;
  2. 维护一个私有任务队列,在线程被占用时将后续任务塞入队列,并且在线程空闲时自动取出队列中下个任务并执行;
  3. 维护一个私有状态-private busy,代表线程是否被占用,同时向外部提供访问入口public busy,Dispacher可以通过busy状态在所有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。以一个典型的用户交互触发重绘的行为为例,整个流程如下:

  1. 用户操作地图改变地图视野(bound)之后会触发WebGL引擎的重绘行为;
  2. 第一步是通过当前视野计算可见的瓦片坐标列表,如果需要新的瓦片则触发加载;
  3. tile_pyramid.ts调用分发器dispatcher.ts执行加载瓦片的任务;
  4. dispatcher.ts首先会判断所有Actor中是否有被占用的,如果存在空闲Actor则直接将任务分配给它,如果没有空闲Actor则随机选择一个Actor执行任务,此时被选中的Actor会将任务塞入任务队列,排队执行。

总结

以上便是WebGL引擎的对于Actor+worker的具体实现模式,加入负载均衡概念之后可以更有效地解决线程被占用时的任务动态分配。因为此WebGL引擎是内部项目,不便将更细节的代码写出来,比如worker的具体任务,所以大家就将就看吧。