前端固有的編程思維是單線程,比如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的操作也有限制,只允許以下三種:
- 向外傳送message;
- 根據接受到的message分發對應任務。Actor對於message對應的任務並沒有限定為靜態的,而是可以攜帶動態數據甚至函數,這樣就大大地增強了Actor的可定制性;
- 創建其他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的工作包括以下幾個:
- 管理一個worker線程,負責向worker線程發送message和接收message的實質行為;
- 維護一個私有任務隊列,在線程被占用時將后續任務塞入隊列,並且在線程空閑時自動取出隊列中下個任務並執行;
- 維護一個私有狀態-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。以一個典型的用戶交互觸發重繪的行為為例,整個流程如下:
- 用戶操作地圖改變地圖視野(bound)之后會觸發WebGL引擎的重繪行為;
- 第一步是通過當前視野計算可見的瓦片坐標列表,如果需要新的瓦片則觸發加載;
tile_pyramid.ts
調用分發器dispatcher.ts
執行加載瓦片的任務;dispatcher.ts
首先會判斷所有Actor中是否有被占用的,如果存在空閑Actor則直接將任務分配給它,如果沒有空閑Actor則隨機選擇一個Actor執行任務,此時被選中的Actor會將任務塞入任務隊列,排隊執行。
總結
以上便是WebGL引擎的對於Actor+worker的具體實現模式,加入負載均衡概念之后可以更有效地解決線程被占用時的任務動態分配。因為此WebGL引擎是內部項目,不便將更細節的代碼寫出來,比如worker的具體任務,所以大家就將就看吧。