使用Actor模型管理Web Worker多執行緒

JunpengZ發表於2020-11-21

前端固有的程式設計思維是單執行緒,比如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的具體任務,所以大家就將就看吧。

相關文章