使用 Web Worker 實現簡單的非阻塞異步

以前的文章提到了 JavaScript 中的異步編程,然而不管早就存在的 setTimeout 仍是 ES6 中的 Promise,它們都是 阻塞 異步,執行函數的時候,會阻塞線程。setTimeout 只會把一個函數延後執行,但仍是在主線程中執行,執行函數的時候會阻塞線程。換句話說,setTimeout 只實現了過程間併發(concurrent)而未實現並行(parallel)。javascript

ES 規範並無定義多線程,Node.js 至今也沒有原生的多線程實現。然而在 HTML5 中卻定義了 Web Worker 用於實現瀏覽器中的多線程。java

Web Worker

引用 MDN 原文:git

Web Workers 使得一個Web應用程序能夠在與主執行線程分離的後臺線程中運行一個腳本操做。這樣作的好處是能夠在一個單獨的線程中執行費時的處理任務,從而容許主(一般是UI)線程運行而不被阻塞/放慢。

與樸素(原始)的多線程編程方式不一樣,Web Worker 一般不容許線程間共享數據,因此沒有線程同步、數據競爭等問題,更沒有沒有鎖(Mutex)和條件變量(Condition variable)等概念(注 1)。它們使用 postMessage 相互通訊,能夠認爲是 JS 中的參與者模式實現。各個 Worker 間數據獨立,不共享內存:postMessage 始終經過結構化克隆的方式深拷貝傳值。github

使用 Web Worker 也很是簡單,只須要預先在 Worker 中註冊 message 事件,在主線程中 postMessage 給 Worker 處理就行了。處理完後能夠再經過 postMessage 傳結果給主線程。編程

須要注意的是,Web Worker 中不能夠操做 DOM,一切與 DOM 操做相關的函數、類都不能使用(建立一個 DOM 元素髮回給主線程 appendChild 也不行),因此可使用的方法很是有限,只適用於處理數據(注 2)。segmentfault

使用 Web Worker 實現非阻塞的 Promise

前面提到 Promise 是阻塞異步,那是否能夠把要處理的數據轉發給某個 Worker 處理並返回一個 Promise,在處理完後將其 resolve 掉呢?數組

答案固然是能夠的,並且實現並不複雜。瀏覽器

建立 Web Worker

首先固然是 new 一個 Worker 出來。須要注意的是 Worker 的構造函數 接受的是一個 JavaScript 腳本的 URL,能否接受 data-uri 看瀏覽器,實測 Chrome、Firefox 能夠,Safari、Edge 不行(會拋 SECURITY_ERR 異常)。多線程

簡單起見,這裏仍是採起 data-uri 的形式。考慮可移植性的話能夠先指定一個靜態文件,而後使用 postMessage 把函數體傳過去。併發

this._worker = new Worker('data:text/javascript,' + encodeURIComponent(`'use strict';
const __fn = ${fn};
onmessage = e => postMessage(__fn(...e.data));`));

Worker 中作了兩件事:

  1. 定義一個函數變量 __fn,其值 fn 是須要執行的函數。若是 fn 自己是一個函數對象,這裏將其轉換爲字符串,至關於把函數的源代碼拼到了字符串裏。
  2. 綁定 message 事件。將傳入的值做爲參數列表調用 __fn,而後將 __fn 的返回值經過 postMessage 傳給主函數。

當接受請求時,派發事件給建立的 Worker

function dispatch(...args) {
  return new Promise((resolve, reject) => {
    this._queue.push({ resolve, reject });
    this._worker.postMessage(args);
  });
}

返回一個 Promise。注意這裏不能只是簡單的 postMessage。由於若是使用者屢次調用 dispatch 函數一次建立了多個 Promise,以後很難肯定是哪一個 Promise 完成了。這裏經過一個隊列記憶建立的 Promise 順序,而後依次 resolve(單個 Worker 處理 message 事件仍是順序執行的)。固然你也能夠多傳一個標記值給 Worker 用於標記被 resolve 的 Promise。

JavaScript 裏的隊列就是數組:

this._queue = [];

接收 Worker 處理完返回的值

this._worker.onmessage = e => this._queue.shift().resolve(e.data);
this._worker.onerror = e => this._queue.shift().reject(e.error);

onmessage 表示正常返回;onerror 表示出現了異常。對應的 Promise 的 resolve 和 reject 直接從隊列裏取出來。

完整代碼

class Dispatcher {
  constructor(fn) {
    this._queue = [];
    this._worker = new Worker('data:text/javascript,' + encodeURIComponent(`'use strict';
const __fn = ${fn};
onmessage = e => postMessage(__fn(...e.data));`));
    this._worker.onmessage = e => this._queue.shift().resolve(e.data);
    this._worker.onerror = e => this._queue.shift().reject(e.error);
  }

  dispatch(...args) {
    return new Promise((resolve, reject) => {
      this._queue.push({ resolve, reject });
      this._worker.postMessage(args);
    });
  }
}

這就是完整代碼了,總共不到 20 行。使用的話也很簡單:

const dispatcher = new Dispatcher(arr => { // 建立對象,把入口函數傳入
  for (let i=0; i<1000; ++i) arr.sort(); // 耗費些時間
  return arr;  // 返回處理後的結果
});

const arr = Array.from({ length: 8192 }, () => Math.random() * 10000); // 須要處理的數據
dispatcher.dispatch(arr)  // 派發給 Worker
  .then(res => console.log(res));  // 處理完畢後輸出

在瀏覽器中測試,會生成這樣一段代碼:

clipboard.png

排序大數組 1000 次的同時 UI 響應仍然不受影響。

這裏還有一個線程池的版本,能夠建立多個 Worker 同時並行執行多個任務:https://github.com/CarterLi/T...

由於要區分到底是哪一個 Worker 完成運行,處理 Worker 返回值的邏輯複雜了一些,有什麼建議歡迎提出。

  • 注 1:ES2017 中加入 SharedArrayBuffer 後已經能夠在主線程和各 Web Worker 間共享數據,使用 Atomics.wait()Atomics.wake() 還能夠實現傳統意義上的鎖和條件變量。但因爲其出現較晚且並不是使用 Web Worker 的主流方式,這裏不展開討論。
  • 注 2:還有一個多是在 Worker 中畫圖,見 OffscreenCanvas。一旦實現,對遊戲編程是個不小的幫助。
相關文章
相關標籤/搜索