深入web workers (上)

enne5w4發表於2020-11-07

前段時間,為了優化某個有點複雜的功能,我採用了shared workers + indexDB,構建了一個高效能的多頁面共享的服務。由於是第一次真正意義上的運用workers,比以前單純的學習有更多體會,所以這裡就分享出來!

各種worker概要

有三種worker:普通的worker、shared worker、service worker。(有極少的文件說有四種,多了一個 audio worker,但其實所謂的audio worker 就是 audio context,用於構建強大的音/視訊處理系統)

  • 普通worker,也叫專用worker,僅能被生成它的指令碼所使用,全域性物件this是DedicatedWorkerGlobalScope物件
  • 共享worker,即sharedworker,能被不同的window頁面,iframe,以及worker訪問(當然要遵循同源限制),全域性物件this是 SharedWorkerGlobalScope 物件。
  • serviceWorker,專為PWA應用而生的worker,構建一個PWA必須要基於https,且所使用的金鑰簽名必須是經過CA認證的,否則你的瀏覽器都將認為不安全,而不會載入你的service worker。由於這個特殊性,我並沒有深入瞭解service worker!

serviceWorker 一般作為web應用程式、瀏覽器和網路(如果可用)之前的代理伺服器。它們旨在(除開其他方面)建立有效的離線體驗,攔截網路請求,以及根據網路是否可用採取合適的行動並更新駐留在伺服器上的資源。他們還將允許訪問推送通知和後臺同步API。

作為官方標準,3種worker當前的瀏覽器支援性都非常良好,可以放心使用! 呃,等一下,shared worker的支援性好像不太好喲:

 

不用緊張,不支援的主要是應用場景不多的移動端(移動端應用誰會開啟多視窗?)和ios了,總體可以忽略(如果必須考慮ios的web端,那就要考慮回退方案了)。

如果你要實現的功能中,使用者多視窗操作是很正常的;有資料庫(如indexDB)、socket等連結;大量相同的可共用的變數……毫無疑問你應該使用shared worker!
我所要優化的功能就有這些特點,這就是採用shared worker的原因。

worker與主執行緒的互動

這裡只講專用worker 和 sharedWorker兩種(service worker沒有深入瞭解)。專用worker和sharedWorker差別很小,所以接下來先詳細的把專用worker講解清楚,再講解sharedWorker的不同點。

專用worker和主執行緒的互動

示例:

// 主執行緒:
const worker = new Worker('./worker.js')
worker.onmessage = (e) => {
    console.log('[main receive]:',e.data )   
}
worker.postMessage('Hello ,this is main thread')


// worker.js:
addEventListener('message', function (e) {
    console.log('[worker receive]:', e.data )
    postMessage('Hi,this is worker thread')
});
  1. 主執行緒和worker 都是通過 postMessage 方法向對方傳送訊息。
  2. 雙方也都是通過監聽 message 事件來接收訊息(上面分別有兩種監聽方法: addEventListener 和 onmessage ,就是個DOM Event )。
  3. 事件控制程式碼的data欄位的值就是傳送訊息時傳遞的內容。

執行結果:

 postMessage傳送 + 監聽message事件接收——互動原理就這麼簡單,這也是唯一的互動方式!

深入訊息的資料傳遞

資料絕對不會以引用的方式“共享”過去,要麼被複制,要麼被轉移

拷貝

普通的資料傳遞,是通過拷貝來進行的。也就是發過去的是一份拷貝而非引用,如果是個物件,那麼修改物件屬性是互不影響的——資料能獨立變化,互不影響。

和indexDB一樣,拷貝是採用結構化克隆的規範的,經過測試它至少有以下副作用:

  • 物件裡不能含有方法,也不能拷貝方法
  • 物件裡不能含有symbol,也不能拷貝symbol,鍵為symbol的屬性會被忽略
  • 大多數物件的類資訊會丟失。如:傳遞一個 obj=new Person() 收到的資料將沒有 Person這個類資訊。
    但是如果是一個內建物件,如Number,Boolean這樣的物件,則不會丟失!(注意:這一點和mdn描述的不一樣)
  • 不可列舉的屬性(enumerable為false)將會被忽略。
  • 屬性的可寫性配置(writable配置)將丟失。
  • 經過測試,所有通過 Object.defineProperties 新增的(注意 是新增的!)屬性都將被忽略。

轉移

拷貝在某些情況下會存在效能問題,比如拷貝一個500M的檔案,肯定會花較多時間。除了拷貝還提供通過轉移的方式來傳遞資料。

目前只有4種物件支援轉移:ArrayBuffer, MessagePort, ImageBitmap OffscreenCanvas

ArrayBuffer是原始的二進位制緩衝區,檔案File,Blob,各種 TypedArray ,都是基於arrayBuffer的。接下來以ArrayBuffer來舉例說明轉移傳遞資料:

可以轉移的資料,也可以通過拷貝來傳遞:

 1 // 主執行緒:
 2 const worker = new Worker('./worker.js')
 3 const u8 = new Uint8Array(new ArrayBuffer(1))  // 建立一個長度為1的TypedArray u8
 4 u8[0] = 1
 5 worker.onmessage = (e) => {
 6     const receive = e.data
 7     console.log('[main receive]:', receive, 'orginal:', u8)
 8 }
 9 worker.postMessage(u8)  // 通過普通的拷貝,將u8傳給worker
10 
11 
12 // worker.js :
13 addEventListener('message', function (e) {
14     const receive = e.data  
15     receive[0] = 9   // worker 收到u8後,改變裡面的內容
16     console.log('[worker change]:',receive)    
17     postMessage(receive)       
18 });

console列印結果:

這個例子僅僅表明,可以轉移的bufferArray也可以通過拷貝傳遞。注意看第二條列印:和預想中的一樣,主執行緒和worker執行緒的資料會獨立變化。

轉移傳遞示例:

轉移很簡單,僅僅是在postMessage時,額外傳入第二引數,表明要轉移的物件,將上面例子稍加改造:

 1 // 主執行緒:
 2 const worker = new Worker('./worker.js')
 3 const u8 = new Uint8Array(new ArrayBuffer(1))
 4 u8[0] = 1
 5 worker.onmessage = (e) => {
 6     const receive = e.data
 7     console.log('[main receive]:', receive, 'orginal:', u8)
 8     worker.postMessage('finish')
 9 }
10 worker.postMessage(u8 , [u8.buffer])  // 第二個參數列示要轉移的物件:注意這必須是一個陣列;注意轉移的是typedArray的buffer,而不是typedArray!
11 
12 
13 
14 // worker.js :
15 let receive
16 addEventListener('message', function (e) {
17     if(e.data==='finish'){
18         console.log('[worker after transfer]',receive)
19         return;
20     }
21     receive = e.data  
22     receive[0] = 9
23     console.log('[worker change]:',receive)    
24     postMessage(receive,[receive.buffer])   // 轉移typedArray的buffer,typedArray長度將變成0!
25     
26 }, false);

console的列印結果(注意理解兩個空的typedArray,為什麼是空的陣列,因為buffer的“使用權”被轉移了!):

把二進位制資料直接轉移給子執行緒,一旦轉移,主執行緒就無法再使用這些二進位制資料了!

sharedWorker與專用worker的差異

訊息互動的差異:

sharedWorker與主執行緒互動和專用worker基本一樣,只是多了一個port:

 1 // 主執行緒:
 2 const worker = new SharedWorker('worker.js', { name: '公共服務' })
 3 // 建立worker時,除了檔案路徑,還可以傳入一些額外的配置:如name。 
 4 // worker的name有id的功能,不同頁面要想共享sharedWorker,名稱相同是必要條件!
 5 const key = Math.random().toString(16).substring(2)
 6 worker.port.postMessage(key)        // 通過worker.port傳送訊息
 7 worker.port.onmessage = e => {        // 通過worker.port接收訊息
 8     console.log(e.data)
 9 }
10 
11 
12 // worker.js:
13 const buf = []
14 onconnect = function (evt) {        // 當其他執行緒建立sharedWorker其實是向sharedWorker發了一個連結,worker會收到一個connect事件
15     const port = evt.ports[0]        // connect事件的控制程式碼中evt.ports[0]是非常重要的物件port,用來向對應執行緒傳送訊息和接收對應執行緒的訊息
16     port.onmessage = (m) => {
17         buf.push(m.data)
18         console.log(buf)        // 這個列印沒看到?請看除錯差異小節!
19         port.postMessage('worker receive:' + m.data)
20     }
21 }

注意看上面的註釋,資訊互動都是通過port進行!通常一個sharedWorker可以對應多個主執行緒,所以sharedWorker多了一個connect事件,通過這個事件獲取各自的port與各自的主執行緒通訊!

需要注意的是,在sharedWorker中,如果不是通過onmessage 而是通過addEventListener監聽message來接收訊息,必須顯式呼叫start開啟連線,否則將無法收到訊息,只能傳送訊息。示例:

// sharedWorker內部:
port.start()
port.addEventListener('message',e=>{       
    // ... 
})

// 主執行緒內部:
worker.port.start()
worker.port.addEventListener('message',e=>{
    // ...
})

除錯的差異:

在上方的例子有兩處列印,第8行 主執行緒列印worker傳過來的訊息,第18行worker內部列印快取下來的[主執行緒傳過來的]訊息。奇怪的是,當你開啟開發者工具,在Console中並沒有看到第18行的列印資訊!

要想看到第18行列印的資訊對sharedWorker進行除錯,需要進行下面兩步:

啟動一個新的標籤頁,網址輸入:chrome://inspect/#workers 介面如下:

點選 inspect(千萬不要點選terminate,這個是結束worker的),你會看到瀏覽器會開啟一個新視窗,新視窗的介面就是開發者工具介面(做過web移動端開發的應該很熟悉這個介面):

切換到Sources頁面,就可以對SharedWorker程式碼進行除錯了! 

全域性物件差異:

在主執行緒中,一切都很好理解,我們通過建立的worker來監聽或傳送訊息,但在worker內部,則會發現直接呼叫 postMessage、onmessage等方法。

這是因為在worker內部,有一個全域性物件 self,相當於globalThis(如果支援的話),相當於全域性作用域下的this,直接呼叫相當於 self. 呼叫:

// 專用worker示例:
globalThis.addEventListener('message', function (e) {})
self.postMessage(msgObj)

// serviceWorker 示例:
// 頂級作用域:
this.onconnect = function(evt){}

上面的globalThis,self,this 均可以省略,類似於主執行緒的window!

正像前面提到過的:專用worker全域性物件this是DedicatedWorkerGlobalScope物件,sharedWorker則是SharedWorkerGlobalScope 物件,這兩者都是WorkerGlobalScope的派生類,所以可以這樣判斷:

console.log(this instanceof DedicatedWorkerGlobalScope) // 專用worker 中 true,  sharedWorker和主執行緒中報異常錯誤
console.log(this instanceof SharedWorkerGlobalScope)    // sharedWorker 中 true,  專用worker和主執行緒中報異常錯誤
console.log(this instanceof WorkerGlobalScope)          // 專用worker和sharedWorker中都是true,  主執行緒中異常錯誤

執行緒生命週期差異:

專用worker很好理解:每開啟一個頁面就建立一個worker執行緒,關閉頁面worker就銷燬,重新整理一次頁面worker就經歷了一次銷燬和建立的過程,不同頁面互不干擾。

你也可以像下面這樣主動銷燬一個worker:

// 專用worker內部
self.close() // 主動關閉worker連線,後續傳送訊息將靜默失敗 

// 外部主執行緒:
worker.terminate() // 或者外部這樣關閉連線,注意:一旦關閉worker,worker將會被銷燬,worker內的所有進行中的任務(如定時任務)都將直接銷燬

一個sharedWorker可以對應多個主執行緒,所以:開啟頁面時,如果沒有sharedWorker時才建立,否則就共用已經存在的sharedWorker;當只有當前頁面和sharedWorker連線時,關閉當前頁面sharedWorker才會被銷燬,重新整理當前頁面sharedWorker才會先銷燬後建立。

sharedWorker的連線也可以主動斷開,但僅僅是斷開連結,並不會銷燬sharedWorker,即便是唯一使用sharedWorker的頁面斷開了連結。worker內部進行中的任務會正常進行,只是不能正常與主執行緒通訊了!

// 主執行緒:
worker.port.close()    // 僅僅關閉連線

// worker內部(拿到port後):
port.close()           // 僅僅關閉連線

很多人喜歡像下面這樣寫程式碼,但請注意註釋中的說明,:

const clients = new Set()    // 用於記錄所有與worker連線的執行緒
this.onconnect = function (c) {
    let port = c.ports[0]     
    clients.add(port)      // 沒有任何方法知道 port 已經斷開連結了(如頁面關閉),所以clients只能無限新增port。這會引起記憶體洩露
    // 在你不得不這麼做,以實現諸如“向所有頁面傳送訊息”的需求時,注意控制記憶體洩露的幅度:
    // 所有port使用同一個onmessageHandler例項和onmessageerrorHandler例項,是個不錯的選擇!
    
    port.onmessage = onmessageHandler
    port.onmessageerror = onmessageerrorHandler    
}

function onmessageHandler(evt){}
function onmessageerrorHandler(evt){}

事件和異常的互動

在面多異常和事件相關的問題時,你必須明白:worker 和 主執行緒是兩個執行緒!那麼就很好理解:
worker中的事件,主執行緒是沒法監聽到的,反之亦然;worker中的異常,主執行緒是無法感知的,反之亦然!再次強調,二者唯一的互動方式就是 postMessage和監聽message事件。

// worker.js內部:

// ... other code
throw new Error('test error')  
// 這個錯誤無法被主執行緒獲取,相反 你會在worker的console中看到“錯誤未捕獲提示”的錯誤提示,而不是主執行緒的console!

 

主執行緒中可以監聽worker的error事件,但請注意這到底是什麼error:

worker.onerror = e=>{
    // 請注意 這裡主執行緒監聽的是建立worker時的異常,而非worker建立成功後內部執行的異常
    // 建立時異常:如下載worker指令碼錯誤,路徑錯誤,worker指令碼解析錯誤等
}

兩邊都能監聽 messageerror 事件,但是經過測試一直都沒法觸發這個事件,按官方的解釋是:當接收到一個訊息,但是訊息的資料無法成功解析時,會觸發這個事件。請注意,這裡是“接收”!我嘗試傳送一個無法拷貝的物件(如含有function欄位),但是在傳送時就失敗了。

可以看到  onerror 和 onmessageerror事件都是和對方無關的事件!

結語

本文深入講解了 worker 和 sharedWorker  與 主執行緒的互動。

現在你已經能用兩種worker做一些簡單的工作了,但是在面臨較複雜的工作,以及在面臨webpack這樣的工程中,使用worker(或sharedWorker)會面臨新的問題。敬請期待:深入web workers (下),我將與你詳細探討workers在工程化中的最佳實踐。

 

相關文章