你不懂js系列學習筆記-非同步與效能- 05

寇格莫發表於2018-05-22

第五章: 程式效能

原文:You-Dont-Know-JS

這本書至此一直是關於如何更有效地利用非同步模式。但是我們還沒有直接解釋為什麼非同步對於 JS 如此重要。最明顯明確的理由就是 效能

舉個例子,如果你要發起兩個 Ajax 請求,而且他們是相互獨立的,但你在進行下一個任務之前需要等到他們全部完成,你就有兩種選擇來對這種互動建立模型:順序和併發。

你可以發起第一個請求並等到它完成再發起第二個請求。或者,就像我們在 promise 和 generator 中看到的那樣,你可以“並列地”發起兩個請求,並在繼續下一步之前讓一個“門”等待它們全部完成。

顯然,後者要比前者效能更好。而更好的效能一般都會帶來更好的使用者體驗。

非同步(併發穿插)甚至可能僅僅增強高效能的印象,即便整個程式依然要用相同的時間才成完成。使用者對效能的印象意味著一切——如果不能再多的話!——和實際可測量的效能一樣重要。

現在,我們想超越區域性的非同步模式,轉而在程式級別的水平上討論一些巨集觀的效能細節。

注意: 你可能會想知道關於微效能問題,比如a++++a哪個更快。我們會在下一章“基準分析與調優”中討論這類效能細節。

1. Web Workers

如果你有一些處理密集型的任務,但你不想讓它們在主執行緒上執行(那樣會使瀏覽器/UI 變慢),你可能會希望 JavaScript 可以以多執行緒的方式操作。

在第一章中,我們詳細地談到了關於 JavaScript 如何是單執行緒的。那仍然是成立的。但是單執行緒不是組織你程式執行的唯一方法。

想象將你的程式分割成兩塊兒,在 UI 主執行緒上執行其中的一塊兒,而在一個完全分離的執行緒上執行另一塊兒。

這樣的結構會引發什麼我們需要關心的問題?

其一,你會想知道執行在一個分離的執行緒上是否意味著它在並行執行(在多 CPU/核心的系統上),如此在第二個執行緒上長時間執行的處理將 不會 阻塞主程式執行緒。否則,“虛擬執行緒”所帶來的好處,不會比我們已經在非同步併發的 JS 中得到的更多。

而且你會想知道這兩塊兒程式是否訪問共享的作用域/資源。如果是,那麼你就要對付多執行緒語言(Java,C++等等)的所有問題,比如協作式或搶佔式鎖定(互斥,等)。這是很多額外的工作,而且不應當輕易著手。

換一個角度,如果這兩塊兒程式不能共享作用域/資源,你會想知道它們將如何“通訊”。

所有這些我們需要考慮的問題,指引我們探索一個在近 HTML5 時代被加入 web 平臺的特性,稱為“Web Worker”。這是一個瀏覽器(也就是宿主環境)特性,而且幾乎和 JS 語言本身沒有任何關係。也就是說,JavaScript 當前 並沒有任何特性可以支援多執行緒執行。

但是一個像你的瀏覽器那樣的環境可以很容易地提供多個 JavaScript 引擎例項,每個都在自己的執行緒上,並允許你在每個執行緒上執行不同的程式。你的程式中分離的執行緒塊兒中的每一個都稱為一個“(Web)Worker”。這種並行機制叫做“任務並行機制”,它強調將你的程式分割成塊兒來並行執行。

在你的主 JS 程式(或另一個 Worker)中,你可以這樣初始化一個 Worker:

var w1 = new Worker("http://some.url.1/mycoolworker.js");
複製程式碼

這個 URL 應當指向 JS 檔案的位置(不是一個 HTML 網頁!),它將會被載入到一個 Worker。然後瀏覽器會啟動一個分離的執行緒,讓這個檔案在這個執行緒上作為獨立的程式執行。

注意: 這種用這樣的 URL 建立的 Worker 稱為“專用(Dedicated)Wroker”。但與提供一個外部檔案的 URL 不同的是,你也可以通過提供一個 Blob URL(另一個 HTML5 特性)來建立一個“內聯(Inline)Worker”;它實質上是一個儲存在單一(二進位制)值中的內聯檔案。但是,Blob 超出了我們要在這裡討論的範圍。

Worker 不會相互,或者與主程式共享任何作用域或資源——那會將所有的多執行緒程式設計的噩夢帶到我們面前——取而代之的是一種連線它們的基本事件訊息機制。

w1Worker 物件是一個事件監聽器和觸發器,它允許你監聽 Worker 發出的事件也允許你向 Worker 傳送事件。

這是如何監聽事件(實際上,是固定的"message"事件):

w1.addEventListener("message", function(evt) {
  // evt.data
});
複製程式碼

而且你可以傳送"message"事件給 Worker:

w1.postMessage("something cool to say");
複製程式碼

在 Worker 內部,訊息是完全對稱的:

// "mycoolworker.js"

addEventListener("message", function(evt) {
  // evt.data
});

postMessage("a really cool reply");
複製程式碼

要注意的是,一個專用 Worker 與它建立的程式是一對一的關係。也就是,"message"事件不需要消除任何歧義,因為我們可以確定它只可能來自於這種一對一關係——不是從 Wroker 來的,就是從主頁面來的。

通常主頁面的程式會建立 Worker,但是一個 Worker 可以根據需要初始化它自己的子 Worker——稱為 subworker。有時將這樣的細節委託給一個“主”Worker 十分有用,它可以生成其他 Worker 來處理任務的一部分。不幸的是,在本書寫作的時候,Chrome 還沒有支援 subworker,然而 Firefox 支援。

要從建立一個 Worker 的程式中立即殺死它,可以在 Worker 物件(就像前一個程式碼段中的w1)上呼叫terminate()。突然終結一個 Worker 執行緒不會給它任何機會結束它的工作,或清理任何資源。這和你關閉瀏覽器的標籤頁來殺死一個頁面相似。

如果你在瀏覽器中有兩個或多個頁面(或者開啟同一個頁面的多個標籤頁!),試著從同一個檔案 URL 中建立 Worker,實際上最終結果是完全分離的 Worker。待一會兒我們就會討論“共享”Worker 的方法。

注意: 看起來一個惡意的或者是呆頭呆腦的 JS 程式可以很容易地通過在系統上生成數百個 Worker 來發起拒絕服務攻擊(Dos 攻擊),看起來每個 Worker 都在自己的執行緒上。雖然一個 Worker 將會在存在於一個分離的執行緒上是有某種保證的,但這種保證不是沒有限制的。系統可以自由決定有多少實際的執行緒/CPU/核心要去建立。沒有辦法預測或保證你能訪問多少,雖然很多人假定它至少和可用的 CPU/核心數一樣多。我認為最安全的臆測是,除了主 UI 執行緒外至少有一個執行緒,僅此而已。

Worker 環境

在 Worker 內部,你不能訪問主程式的任何資源。這意味著你不能訪問它的任何全域性變數,你也不能訪問頁面的 DOM 或其他資源。記住:它是一個完全分離的執行緒。

然而,你可以實施網路操作(Ajax,WebSocket)和設定定時器。另外,Worker 可以訪問它自己的幾個重要全域性變數/特性的拷貝,包括navigatorlocationJSON,和applicationCache

你還可以使用importScripts(..)載入額外的 JS 指令碼到你的 Worker 中:

// 在Worker內部
importScripts("foo.js", "bar.js");
複製程式碼

這些指令碼會被同步地載入,這意味著在檔案完成載入和執行之前,importScripts(..)呼叫會阻塞 Worker 的執行。

注意: 還有一些關於暴露<canvas>API 給 Worker 的討論,其中包括使 canvas 成為 Transferable 的(見“資料傳送”一節),這將允許 Worker 來實施一些精細的脫執行緒圖形處理,在高效能的遊戲(WebGL)和其他類似應用中可能很有用。雖然這在任何瀏覽器中都還不存在,但是很有可能在近未來發生。

Web Worker 的常見用途是什麼?

  • 處理密集型的數學計算
  • 大資料集合的排序
  • 資料操作(壓縮,音訊分析,影象畫素操作等等)
  • 高流量網路通訊

資料傳送

你可能注意到了這些用途中的大多數的一個共同性質,就是它們要求使用事件機制穿越執行緒間的壁壘來傳遞大量的資訊,也許是雙向的。

在 Worker 的早期,將所有資料序列化為字串是唯一的選擇。除了在兩個方向上進行序列化時速度上變慢了,另外一個主要缺點是,資料是被拷貝的,這意味著記憶體用量翻了一倍(以及在後續垃圾回收上的流失)。

謝天謝地,現在我們有了幾個更好的選擇。

如果你傳遞一個物件,在另一端一個所謂的“結構化克隆演算法(Structured Cloning Algorithm)”(developer.mozilla.org/en-US/docs/… )會用於拷貝/複製這個物件。這個演算法相當精巧,甚至可以處理帶有迴圈引用的物件複製。to-string/from-string 的效能劣化沒有了,但用這種方式我們依然面對著記憶體用量的翻倍。IE10 以上版本,和其他主流瀏覽器都對此有支援。

一個更好的選擇,特別是對大的資料集合而言,是“Transferable 物件”(updates.html5rocks.com/2011/12/Tra… )。它使物件的“所有權”被傳送,而物件本身沒動。一旦你傳送一個物件給 Worker,它在原來的位置就空了出來或者不可訪問——這消除了共享作用域的多執行緒程式設計中的災難。當然,所有權的傳送可以雙向進行。

選擇使用 Transferable 物件不需要你做太多;任何實現了 Transferable 介面(developer.mozilla.org/en-US/docs/… )的資料結構都將自動地以這種方式傳遞(Firefox 和 Chrome 支援此特性)。

舉個例子,有型別的陣列如Uint8Array(見本系列的 ES6 與未來)是一個“Transferables”。這是你如何用postMessage(..)來傳送一個 Transferable 物件:

// `foo` 是一個 `Uint8Array`

postMessage(foo.buffer, [foo.buffer]);
複製程式碼

第一個引數是未經加工的緩衝,而第二個引數是要傳送的內容的列表。

不支援 Transferable 物件的瀏覽器簡單地降級到結構化克隆,這意味著效能上的降低,而不是徹底的特性失靈。

2. SIMD

一個指令,多個資料(SIMD)是一種“資料並行機制”形式,與 Web Worker 的“任務並行機制”相對應,因為他強調的不是程式邏輯的塊兒被並行化,而是多個位元組的資料被並行地處理。

使用 SIMD,執行緒不提供並行機制。相反,現代 CPU 用數字的“向量”提供 SIMD 能力——想想:指定型別的陣列——還有可以在所有這些數字上並行操作的指令;這些是利用底層操作的指令級別的並行機制。

使 SIMD 能力包含在 JavaScript 中的努力主要是由 Intel 帶頭的(01.org/node/1495 ),名義上是 Mohammad Haghighat(在本書寫作的時候),與 Firefox 和 Chrome 團隊合作。SIMD 處於早期標準化階段,而且很有可能被加入未來版本的 JavaScript 中,很可能在 ES7 的時間框架內。

SIMD JavaScript 提議向 JS 程式碼暴露短向量型別與 API,它們在 SIMD 可用的系統中將操作直接對映為 CPU 指令的等價物,同時在非 SIMD 系統中退回到非並行化操作的“shim”。

對於資料密集型的應用程式(訊號分析,對圖形的矩陣操作等等)來說,這種並行數學處理在效能上的優勢是十分明顯的!

在本書寫作時,SIMD API 的早期提案形式看起來像這樣:

var v1 = SIMD.float32x4(3.14159, 21.0, 32.3, 55.55);
var v2 = SIMD.float32x4(2.1, 3.2, 4.3, 5.4);

var v3 = SIMD.int32x4(10, 101, 1001, 10001);
var v4 = SIMD.int32x4(10, 20, 30, 40);

SIMD.float32x4.mul(v1, v2); // [ 6.597339, 67.2, 138.89, 299.97 ]
SIMD.int32x4.add(v3, v4); // [ 20, 121, 1031, 10041 ]
複製程式碼

這裡展示了兩種不同的向量資料型別,32 位浮點數和 32 位整數。你可以看到這些向量正好被設定為 4 個 32 位元素,這與大多數 CPU 中可用的 SIMD 向量的大小(128 位)相匹配。在未來我們看到一個x8(或更大!)版本的這些 API 也是可能的。

除了mul()add(),許多其他操作也很可能被加入,比如sub()div()abs()neg()sqrt()reciprocal()reciprocalSqrt() (算數運算),shuffle()(重拍向量元素),and()or()xor()not()(邏輯運算),equal()greaterThan()lessThan() (比較運算),shiftLeft()shiftRightLogical()shiftRightArithmetic()(輪換),fromFloat32x4(),和fromInt32x4()(變換)。

注意: 這裡有一個 SIMD 功能的官方“填補”(很有希望,預期的,著眼未來的填補)(github.com/johnmccutch… ),它描述了許多比我們在這一節中沒有講到的許多計劃中的 SIMD 功能。

3. asm.js

“asm.js”(asmjs.org/ )是可以被高度優化的 JavaScript 語言子集的標誌。通過小心地迴避那些特定的很難優化的(垃圾回收,強制轉換,等等)機制和模式,asm.js 風格的程式碼可以被 JS 引擎識別,而且用主動地底層優化進行特殊的處理。

與本章中討論的其他效能優化機制不同的是,asm.js 沒必須要是必須被 JS 語言規範所採納的東西。確實有一個 asm.js 規範(asmjs.org/spec/latest… ),但它主要是追蹤一組關於優化的候選物件的推論,而不是 JS 引擎的需求。

目前還沒有新的語法被提案。取而代之的是,ams.js 建議了一些方法,用來識別那些符合 ams.js 規則的既存標準 JS 語法,並且讓引擎相應地實現它們自己的優化功能。

關於 ams.js 應當如何在程式中活動的問題,在瀏覽器生產商之間存在一些爭議。早期版本的 asm.js 實驗中,要求一個"use asm";編譯附註(與 strict 模式的"use strict";類似)來幫助 JS 引擎來尋找 asm.js 優化的機會和提示。另一些人則斷言 asm.js 應當只是一組啟發式演算法,讓引擎自動地識別而不用作者做任何額外的事情,這意味著理論上既存的程式可以在不用做任何特殊的事情的情況下從 asm.js 優化中獲益。

如何使用 asm.js 進行優化

關於 asm.js 需要理解的第一件事情是型別和強制轉換。如果 JS 引擎不得不在變數的操作期間一直追蹤一個變數內的值的型別,以便於在必要時它可以處理強制轉換,那麼就會有許多額外的工作使程式處於次優化狀態。

注意: 為了說明的目的,我們將在這裡使用 ams.js 風格的程式碼,但要意識到的是你手寫這些程式碼的情況不是很常見。asm.js 的本意更多的是作為其他工具的編譯目標,比如 Emscripten(github.com/kripken/ems… )。當然你寫自己的 asm.js 程式碼也是可能的,但是這通常不是一個好主意,因為那樣的程式碼非常底層,而這意味著它會非常耗時而且易錯。儘管如此,也會有情況使你想要為了 ams.js 優化的目的手動調整程式碼。

這裡有一些“技巧”,你可以使用它們來提示支援 asm.js 的 JS 引擎變數/操作預期的型別是什麼,以便於它可以跳過那些強制轉換追蹤的步驟。

舉個例子:

var a = 42;

// ..

var b = a;
複製程式碼

在這個程式中,賦值b = a在變數中留下了型別分歧的問題。然而,它可以寫成這樣:

var a = 42;

// ..

var b = a | 0;
複製程式碼

這裡,我們與值0一起使用了|(“二進位制或”),雖然它對值沒有任何影響,但它確保這個值是一個 32 位整數。這段程式碼在普通的 JS 引擎中可以工作,但是當它執行在支援 asm.js 的 JS 引擎上時,它 可以 表示b應當總是被作為 32 位整數來對待,所以強制轉換追蹤可以被跳過。

類似地,兩個變數之間的加法操作可以被限定為效能更好的整數加法(而不是浮點數):

(a + b) | 0;
複製程式碼

再一次,支援 asm.js 的 JS 引擎可以看到這個提示,並推斷+操作應當是一個 32 位整數加法,因為不論怎樣整個表示式的最終結果都將自動是 32 位整數。

複習

本書的前四章基於這樣的前提:非同步編碼模式給了你編寫更高效程式碼的能力,這通常是一個非常重要的改進。但是非同步行為也就能幫你這麼多,因為它在基礎上仍然使用一個單獨的事件輪詢執行緒。

所以在這一章我們涵蓋了幾種程式級別的機制來進一步提升效能。

Web Worker 讓你在一個分離的執行緒上執行一個 JS 檔案(也就是程式),使用非同步事件線上程之間傳遞訊息。對於將長時間執行或資源密集型任務掛載到一個不同執行緒,從而讓主 UI 執行緒保持相應來說,它們非常棒。

SIMD 提議將 CPU 級別的並行數學操作對映到 JavaScript API 上來提供高效能資料並行操作,比如在大資料集合上進行數字處理。

最後,asm.js 描述了一個 JavaScript 的小的子集,它迴避了 JS 中不易優化的部分(比如垃圾回收與強制轉換)並讓 JS 引擎通過主動優化識別並執行這樣的程式碼。asm.js 可以手動編寫,但是極其麻煩且易錯,就像手動編寫組合語言。相反,asm.js 的主要意圖是作為一個從其他高度優化的程式語言交叉編譯來的目標——例如,Emscripten(github.com/kripken/ems… )可以將 C/C++轉譯為 JavaScript。

雖然在本章沒有明確地提及,在很早以前的有關 JavaScript 的討論中存在著更激進的想法,包括近似地直接多執行緒功能(不僅僅是隱藏在資料結構 API 後面)。無論這是否會明確地發生,還是我們將看到更多並行機制偷偷潛入 JS,但是在 JS 中發生更多程式級別優化的未來是可以確定的。

相關文章