基本上,JavaScript實現並行的方式是基於事件循環,而非執行緒切換,這是因為JavaScript只使用一個執行緒;HTML5的Web Worker提供多執行緒的功能,然而它不僅可用於實現並行,更可用於平行程式設計,善用多核心CPU的運算能力。

單執行緒實現並行?

若開發者是從一門原生就提供執行緒的語言來到JavaScript,在尋找sleep之類功能時,就會產生困惑,因為JavaScript沒有這種東西;另一方面,想要「同時」執行多個任務,只能透過setTimeout、setInterval或Promise.all之類的方式;在進行IO操作時(例如非同步網路請求),可以採取非同步模式,不會因為IO操作而阻斷流程,然而文件上都告訴我們,JavaScript運行時只有一個執行緒?

在電腦科學領域,多個任務可以「同時」處理,這件事並沒有嚴謹定義,但並非實體上(Physical)的「同時」,只是可以管理多個任務,從使用者觀點來看,像是同時執行的設計,因此,歸類在並行(Concurrency)處理的範疇。

JavaScript是單執行緒,會在一個事件迴圈(Event loop)中不斷地檢查事件佇列(Event queue),有些API是基於事件,執行API時,任務若是委託給JavaScript的宿主環境(例如瀏覽器進行非同步請求),程式流程就繼續下一步了,這是這類API的非同步特性。當宿主環境任務完成時,會建立事件,並將事件排入佇列,於是,JavaScript引擎在事件迴圈的下一輪檢查時,才將佇列中事件對應的任務依序執行完成。

說來就像JavaScript引擎本身作弊了,有些任務其實是外包,對宿主環境來說,就是一種「任務就交給你了,完成後再通知我」的感覺;不過,事件發生後的對應處理,還是得回到引擎自身,只要各個事件的處理時間夠短,就使用者來看,就會像是在同時處理,這就是setInterval、Promise.all之類API,可用來實現並行的原因。

然而,對於事件佇列中被排定的任務,就像在櫃台前排隊等候處理,如果隊伍前面有一大堆人,或者處理某人任務時花費的時間特別多,後面的人就會等上許久的時間。這就是為什麼沒有、也無法實作sleep這類功能的原因,想想看,你故意叫等候隊伍中某個人發呆個10秒鐘,什麼事都不做,隊伍後頭的人會怎樣?

而且,這也是setInterval不可靠的原因,雖然時間間隔來臨之際,任務確實被排入事件佇列了,不過,若是佇列前面的任務一大堆,等到真正執行到setInterval的指定任務時,很有可能又過去了一段時光了。

Web Worker API

檔案讀寫(以Node.js為宿主時)或非同步網路請求(以瀏覽器為宿主時)這類任務,都涉及IO之類的阻斷,適合委託給宿主,就宿主來說,也確實是同時執行了任務;然而,若任務必須自行使用程式碼來定義,過去沒辦法委託給宿主,若是計算密集任務,直接使用JavaScript定義,就意謂著使用引擎唯一的執行緒來執行計算密集任務,若程式具有使用者介面,這時就會發生介面凍結的問題。

若是Web應用程式想避免這類問題,方式之一是在伺服端實現計算密集任務,瀏覽器發出請求時提供計算時必要的資料;有些開發者也試著透過asm.js、WebAssembly加速指令的執行,甚至是嘗試透過WebGL,利用GPU的高速運算特性,以縮減執行時秘須耗費的時間;HTML5則提供了Web Worker API,可以要求瀏覽器建立背景執行緒,以執行交付的任務。

就API來說,Web Worker使用上並不困難,當一個.js檔案定義了一個任務,也就是定義了一個Worker,Worker中可以產生子Worker,而瀏覽器會建立執行緒來執行Worker的任務,Worker與JavaScript執行緒間會基於訊息與事件溝通,JavaScript執行緒對Worker發送計算時必要的資料,Worker任務完成後,再將結果發回。

現在,複雜的計算任務可以定義在Worker,而不是排在事件佇列中,這意謂著JavaScript本身的執行緒,可以只負責處理時間夠短的任務就好,如此一來,也就可以避免使用者介面因計算密集任務而被凍結的問題。

天下沒有白吃的午餐,Worker也不是什麼都做得來,談到執行緒,就會想到存取共同資料的競速問題(Race condition),為了避免這類問題在瀏覽器上重演,只要是有引發競速疑慮的資料,就不能發送給Worker,全域的window物件肯定不行了,DOM元素、Storage物件等也不可以,發送這類物件會引發DOMException,並表示無法複製物件,而在Worker中,也不可以直接使用window、document等名稱。

為避免競速問題,預計情況下,發送給Worker的資料也會進行複製,不過,複製二進位資料(例如ArrayBuffer)會有效能疑慮,然而,此時,我們可以將資料所有權轉移給Worker執行緒,等到轉移之後,JavaScript執行緒就會失去資料的所有權。

既有任務轉移至Worker

哪些任務可以轉移至Worker?既然瀏覽器會建立執行緒,自然就是那些過去想用執行緒解決卻沒得用的任務吧?這麼想的話,開發者往往會更困惑,因為Worker執行緒的運用方式,顯然與原生就具備執行緒支援的語言不同。

關於哪些任務可轉移至Worker這類問題,我們可以從JavaScript在哪些地方用到了非同步來思考。例如,setTimeout、setInterval是將計時任務委由宿主來處理;在後端fs模組的readFile是將讀取檔案的工作交由Node.js來處理;在瀏覽器上發出非同步請求時,是將網路相關的處理任務交給了瀏覽器。

也就是說,在過去可以交付的任務,受限於宿主提供了哪些API,並不能以JavaScript自行定義任務;而現在有了Worker API,簡單來說,就是提供了管道,能用JavaScript定義任務,要求宿主建立執行緒來處理任務,某些程度上,可以比擬為建立了本地的伺服端,而執行緒間的訊息發送,就像是請求與回應。

不過,執行緒這個名詞容易令開發者誤會。對其他原生支援執行緒的語言來說,執行緒是運行在同一個執行環境;然而瀏覽器開設的執行緒,各個執行緒發送是各自獨立的執行環境,JavaScript執行緒與各個Worker執行緒之間,實際上彼此是平行的。

也就是,Worker不單可用來實現並行,也能在實體上同時進行多個流程,進行平行(Parallel)程式設計,在HTML5規範的Web workers章節也明確寫到,在多核CPU普及的現代,將複雜計算的任務分配到多個Worker上,可以取得更好的執行效能。

不只實現並行演算

將Worker拿來實現一些簡單的並行演算,其實是可行的,然而若要發揮更大的效用,應該留意平行程式設計的可能性,程式碼需要重構,也需要些觀念與技巧,這部份可參考先前專欄〈關注平行程式設計〉

實際上,也有一些開發者把Worker API作了封裝,提供了Parallel.js程式庫;至於透過Worker是否真能增加效能,還是要實際測量;除了演算法本身之外,建立Worker、訊息的傳遞等開銷也是要考量的問題,關於這部份,我們可以參考〈How fast are web workers?〉(https://mzl.la/1JE2Rot)。

專欄作者

熱門新聞

Advertisement