[書籍翻譯] 《JavaScript併發程式設計》第一章 JavaScript併發簡介
阿新 • • 發佈:2019-10-10
> 本文是我翻譯《JavaScript Concurrency》書籍的第一章,該書主要以Promises、Generator、Web workers等技術來講解JavaScript併發程式設計方面的實踐。完整書籍翻譯地址:https://github.com/yzsunlei/javascript_concurrency_translation 。由於能力有限,肯定存在翻譯不清楚甚至翻譯錯誤的地方,歡迎朋友們提issue指出,感謝。
JavaScript並不是一門與併發有關聯的語言。事實上,它的特性還與併發應用是完全不相符的。近幾年來,它已經改變了很多,特別是ES2015新的語言特性。Promises已經在JavaScript中運用了好幾年了; 只是現在,它成為JavaScript語言的
一種原生型別。
Generators是JavaScript的另一個特性,它改變了我們對JavaScript語言中併發的思考方式。Web workers也已經被瀏覽器支援好幾年了,然而,我們卻運用的並不多。也許,是因為它與併發關係不大,而且更多的原因是它在我們的應用程式中關於併發扮演的角色的理解。
本章的目標是探討一些通用的併發思想,從併發是什麼開始講起。如果您在工作或學習中沒有任何的併發程式設計經驗,那很好,本章對您來說是一個很不錯的起點。如果您以前使用JavaScript或其他語言完成過併發程式設計相關的專案,那可以將本章作為複習,並使用JavaScript來回顧下。
我們將以一些重要的併發原則來貫穿本章。這些原則是有價值的程式設計工具,我們應該在編寫併發程式碼時牢記在腦海中。一旦我們學會應用這些原則,它們會告訴我們我們的併發設計是否正確,或者需要退後一步,問問自己真正想要實現的目標。這些原則採用自上而下的方法來設計我們的應用程式。這意味著它們從一開始就是適用的,甚至在我們開始編寫程式碼之前。在整本書中,我們將引用這些原則,因此如果您只閱讀本章的一節,那最好是併發原則那部分。
### 同步JavaScript
在我們開始構建大規模併發JavaScript體系結構之前,讓我們先將注意力轉移到我們熟悉的、老舊的同步JavaScript程式碼上。這些JavaScript程式碼塊,它們作為單擊事件的回撥結果,或者作為載入網頁的執行結果。一旦它們開始執行,它們就不會停止。也就是說,它們是一直執行到完成的。在接下來的章節中,我們將進一步深入研究它們。
> 我們在整個章節中偶爾會看到術語“同步”和“序列”,它們可互換使用。它們都是指代一個接一個地執行程式碼語句,直到沒有其他程式碼可以執行。
儘管JavaScript被設計為單執行緒,執行直到完成的,但Web的特性不得不使其複雜化。想想Web瀏覽器及其所有可應用的模組。有用於渲染使用者介面的文件物件模型(DOM),有用於獲取遠端資料的XMLHttpRequest(XHR)物件。現在讓我們先來看看JavaScript的同步特性和Web的非同步特性。
#### 同步是很容易理解的
當代碼是同步的,它很容易被理解。將我們在螢幕上看到的指令集對映到頭腦中的有序步驟相當容易; 這樣做,然後那樣做;判斷一下,如果是,則執行此操作,依此類推。這種序列型別的程式碼處理很容易理解,因為沒有什麼特別的,假想程式碼的執行並不可怕。以下是一大塊同步程式碼的示例:
![image031.gif](https://img2018.cnblogs.com/blog/1815214/201910/1815214-20191010005802309-2106337094.gif)
相反的,併發程式設計並不容易理解。這是因為程式碼在編輯器並不能線性的去追蹤。相反,我們不斷跳轉,試圖對映這段程式碼相對於那段程式碼所做的事情。時間是並行設計的重要因素; 這是違背大腦以自然方式來理解程式碼的東西。當我們閱讀程式碼時,我們自然會在腦海中假想去執行它。這是我們弄清楚它在做什麼的方式。
當代碼實際執行不符合我們的假想時,這時就會很崩潰。通常情況下,看程式碼就像看一本書 - 而看併發程式碼就像看一本雖然被編號,但是不按順序編號的書。我們來看一些簡單的偽JavaScript程式碼:
```javascript
var collection = ['a', 'b', 'c', 'd'];
var results = [];
for (let item of collection) {
results.push(String.fromCharCode(item.charCodeAt(0)));
}
// ['b','c','d','e' ]
```
在傳統的多執行緒程式設計環境中,執行緒與執行緒之間是非同步執行。我們使用多執行緒來充分利用當今大多數系統中的多核CPU,從而獲得更好的效能。但是,這需要付出一些代價的,因為它迫使我們去重新思考程式碼在執行時的執行方式。它不再是通常的一步一步的去執行。一段程式碼可以與另一個CPU中的其他程式碼一起執行,也可以在同一CPU上與其他執行緒一起執行。
當我們將併發引入同步程式碼時,很多簡易性就消失了 - 它就會是很燒腦的程式碼。這就是我們編寫併發程式碼的原因:提出併發的前期假設的程式碼。隨著本書的進展,我們將詳細闡述這一概念。使用JavaScript,併發設計很重要,因為這就是Web的工作方式。
#### 非同步是不可避免的
JavaScript中的併發是一個很重要的方法,原因是不管是從非常高的層次,還是實現細節水平上來說,Web是一個併發的東西。換句話說,網路是併發的,因為在任何一個時間點,都有大量的資料流過數英里的光纖,這些光纖包圍著全球。它與部署到Web瀏覽器的應用程式本身以及後端伺服器如何處理一連串的資料請求有關。
##### 非同步瀏覽器
讓我們仔細看看瀏覽器以及在那裡發生的各種非同步操作。當用戶載入網頁時,頁面執行的第一個操作就是下載和執行頁面JavaScript程式碼。這本身就是一個非同步操作,因為我們的程式碼在下載時,瀏覽器會繼續執行其他操作,例如渲染頁面元素。
通過網路傳輸的非同步資料是應用程式資料本身。載入頁面並開始執行JavaScript程式碼後,我們需要為使用者展示資料。這實際上是我們的程式碼將要做的第一件事,以便使用者可以儘快看到。同樣,當我們等待這些資料返回時,JavaScript引擎會將我們的程式碼移動到它的下一組指令。對遠端資料的請求,在繼續執行程式碼之前不會等待響應:
![image032.gif](https://img2018.cnblogs.com/blog/1815214/201910/1815214-20191010005803774-1792050697.gif)
頁面元素全部渲染並填充資料後,使用者開始與我們的頁面進行互動。這意味著事件被觸發 - 單擊元素將觸發click事件。傳送這些事件的DOM環境是一個沙盒環境。這意味著在瀏覽器中,DOM是一個子系統,與JavaScript直譯器是分離的,後者執行我們的程式碼。這種分離使某些JavaScript併發方案很難進行。我們將在下一章深入介紹這些內容。
有了所有這些非同步的來源,毫無疑問,我們的頁面會因特殊的情況處理而變得臃腫,以應對不可避免地出現的特殊情況。非同步思考是不符合邏輯的,因此這種型別的動態修補可能是同步思考的結果。最好採用Web的非同步特性。但是,同步網路可能會導致令使用者無法忍受的體驗。現在,讓我們進一步瞭解我們在JavaScript體系結構中可能遇到的併發型別。
### 併發的型別
JavaScript是一種執行直到完成的語言。儘管在執行上存在併發機制,但並沒有解決它。換句話說,我們的JavaScript程式碼不會在if語句中間轉而去控制另一個執行緒。這很重要的原因是我們可以選擇一個有助於我們思考JavaScript併發的抽象層次。讓我們看看在JavaScript程式碼中併發操作的兩種型別。
#### 非同步操作
非同步操作的一個特徵是它們不會阻止其他後續操作。非同步操作並不一定意味著“一勞永逸”。相反,當那部分我們等待的操作完成時,我們會執行一個回撥函式。這個回撥函式與我們的其他程式碼不同步; 因此,這被稱為非同步。
在Web前端中,經常從遠端伺服器獲取資料。這些請求操作相對較慢,因為它們必須通過網路連線。這些操作是非同步的,因為我們的程式碼會等待一些資料返回以便觸發回撥函式,這並不意味著使用者必須停下來等待。此外,使用者當前正在檢視的任何頁面都不太可能僅依賴於一個遠端資源。因此,序列處理多個遠端資料請求會產生非常糟糕的使用者體驗。
以下是非同步程式碼的簡單示例:
```javascript
var request = fetch('/ foo');
request.addEventListener((response) => {
//現在它已經返回了,可以使用“response”做些事情了
});
//不要等待響應,立即更新DOM
updateUI();
```
> 下載示例程式碼
> 您可以從[http://www.packtpub.com](http://www.packtpub.com)上的帳戶下載所購買的所有Packt Publishing書籍的示例程式碼檔案。
> 如果您在其他地方購買了本書,可以訪問[http://www.packtpub.com/support](http://www.packtpub.com/support)並註冊以直接通過電子郵件傳送給您。
我們不僅限於獲取遠端資料,而是將其作為非同步操作的一個案例。當我們發出網路請求時,這些非同步控制流實際上會離開瀏覽器。但是,限制在瀏覽器中的非同步操作呢?以setTimeout()函式為例。它遵循與網路請求使用一樣的回撥模式。該函式已通過回撥,將在稍後執行。然而,沒有任何東西離開瀏覽器。相反,該操作排在任何的其他操作後面。這是因為非同步操作仍然只是一個控制執行緒,由一個CPU執行。這意味著隨著我們的應用程式在規模和複雜性方面的增長,我們就會面臨併發擴充套件問題。但是,也許非同步操作並不意味著只是解決單一CPU問題。
考慮在單個CPU上執行非同步操作的更好方法可能是想象一下雜技師拋球的場景。雜技師的大腦比作CPU,協調他的動作。被丟擲的球是我們操作的資料。我們關心的只有兩個基本動作 - 拋球和接球:
![image036.gif](https://img2018.cnblogs.com/blog/1815214/201910/1815214-20191010005805478-353253083.gif)
由於雜技師只有一個大腦,所以他不可能將自己的精力用於一次執行多項任務。然而,雜技師經驗豐富,並且知道他不需要分出一小部分精力用於投擲或捕捉動作。一旦球到空中,他可以自由地將注意力轉移到即將降落的球上。
別人在看這個雜技師的動作時,以為他全神貫注於所有丟擲的六個球,而實際上,他在同一個時間點會忽視其他五個在空中的球。
#### 並行操作
與非同步一樣,並行允許控制流繼續而無需等待操作完成。與非同步不同,並行要取決於硬體。這是因為我們不能在單個CPU上並行執行兩個或更多個控制流程。然而,將並行與非同步區分開來的主要是使用它的合理性方面。這兩種併發方式解決了不同的問題,並且需要不同的設計原則。
有時,我們希望並行執行操作,否則如果同步執行則會耗費時間。想想正在等待完成三項複雜操作的使用者。如果每個操作都需要10秒鐘才能完成,那麼這意味著使用者必須等待30秒。如果我們能夠並行執行這些任務,我們可以使得總等待時間接近10秒。我們以更少的成本獲得更多,從而實現高效的使用者互動體驗。
這些都不是免費的。與非同步操作一樣,並行操作會將回調作為通訊機制。通常,設計並行很難,因為除了與worker執行緒進行通訊之外,我們還要擔心手頭的任務,也就是說,我們希望通過使用worker執行緒來實現什麼?我們如何將問題分解為更小的操作?以下是我們開始引入並行程式碼的示例:
```javascript
var worker = new Worker('worker.js');
var myElement = document.getElementById('myElement');
worker.addEventListener('message', (e) => {
myElement.textContent = 'Done working!';
});
myElement.addEventListener('click', (e) => {
worker.postMessage('work');
});
```
不要擔心這段程式碼執行時的機制,因為它們將在後面深入討論。需要注意的是,當我們將一些執行緒放入工作環境時,我們會向已經混亂的環境新增更多回調。這就是為什麼在我們的程式碼中需要併發設計,這是本書的主要話題,從“第5章,使用Web workers”開始。
讓我們考慮下前一節中雜技師的比方。拋擲和捕獲動作由雜技師非同步執行; 也就是說,他只有一個腦(CPU)。但是假設我們周圍的環境在不斷變化。我們期望的雜技動作越來越多,一個雜技師不可能全部完成:
![image037.gif](https://img2018.cnblogs.com/blog/1815214/201910/1815214-20191010005807063-440638958.gif)
解決方案是為該表演中加入更多的雜技師。通過這種方式,我們可以新增更多的計算能力,在同一時刻執行多次拋擲和捕獲操作。對於單個非同步執行的雜技師來說,這是不可能的。
我們還沒有解決好問題,因為我們不能只讓新新增的雜技師站在一個地方,並按照一個雜技師玩雜技的方式執行他們的動作。觀眾很多,更多樣化,都需要被逗樂。雜技師需要能夠有不同的動作。他們需要在地板上不斷的四處移動以讓每一個觀眾都能感覺開心。他們甚至可能開始互相玩雜技。該由我們來做一個能夠實現這些雜技動作的設計。
### JavaScript併發程式設計原則:併發,同步,保護
既然我們已經瞭解了併發的基礎知識,以及它在前端Web開發中的作用,那麼讓我們看一下JavaScript開發的一些基本併發程式設計原則。這些原則僅僅是我們在編寫併發JavaScript程式碼時為我們的設計選擇提供資訊的工具。
當我們應用這些原則時,它們迫使我們退後一步,在我們推進實施之前提出適當的問題。特別的,是關於為什麼和如何做的問題:
- 我們為什麼要實現這種併發設計?
- 我們希望從中獲得什麼,否則我們無法擺脫簡單的同步方法?
- 我們如何在應用程式中以一種不顯眼的方式實現併發功能?
這是每個併發原則的參考示圖,在開發過程中相互依賴。有了這個,我們將把注意力轉向每個原則,以便進一步探究:
![image038.gif](https://img2018.cnblogs.com/blog/1815214/201910/1815214-20191010005808619-1760291927.gif)
#### 併發
併發原則意味著利用現代CPU功能在更短的時間內計算結果。現在可以在任何現代瀏覽器或NodeJS環境中使用。在瀏覽器中,我們可以使用Web workers實現真正的併發。在NodeJS中,我們可以通過生成新程序來實現真正的併發。從瀏覽器的角度來看,下圖這就是CPU的大致樣子:
![image039.gif](https://img2018.cnblogs.com/blog/1815214/201910/1815214-20191010005810516-97064793.gif)
由於目標是在更短的時間內進行更多的計算,我們現在必須問自己為什麼要這樣做?除了效能本身非常酷的事實之外,還必須對使用者產生一些切實的影響。這個原則讓我們看著我們的並行程式碼並想想 - 使用者從中獲得了什麼?答案是我們可以使用較大的資料集作為輸入進行計算,並且很少可能由於JavaScript長時間執行,給使用者帶來無響應的體驗。
重要的是仔細想想併發的實際好處,因為當我們這樣做時,我們會增加程式碼的複雜性,否則就沒多大意義了。因此,如果使用者看到相同的結果,得到同樣的體驗,那無論我們做什麼,併發原則可能都不適用。另一方面,如果可擴充套件性很重要且資料集大小增加的可能性很大,那麼併發的程式碼簡單性的折衷可能是值得的。在考慮併發原則時,這裡有一個要遵循的檢查清單:
- 我們的應用程式是否針對大型資料集執行需要很高昂的計算成本?
- 由於我們的資料集大小的增長,是否有能力處理瓶頸,不讓它們對使用者產生負面影響?
- 我們的使用者目前是否在應用程式效能方面遇到瓶頸?
- 考慮到其他限制因素,我們的設計中的併發有多大可行性?有什麼權衡取捨?
- 從使用者感知延遲或程式碼可維護性方面來看,併發實現的好處是否超過了開銷成本?
#### 同步
同步原則是有關用於協調併發操作和抽象這些機制的一些方式。回撥函式是一個具有深遠根源的JavaScript概念。這是個很不錯的方式選擇,當我們需要執行一些程式碼,但我們不希望馬上就執行它。我們希望當一些條件符合時再執行它。往大的方面講,這種方式沒有什麼內在的問題。回撥函式在單獨使用時,是一種很簡潔、方便、可讀性強的一種併發模式。但在大量使用回撥,並且在回撥之間存在有大量的依賴時,就很令人崩潰了。
##### Promise API
Promise API是ECMAScript 6中引入的核心JavaScript語法,用於解決當前應用程式所面臨的同步問題。這是一個在實際使用回撥時更簡單的API(是的,我們正在與巢狀回撥做鬥爭)。Promise的目的不是要消除回撥,而是要移除不必要的回撥。
以下是用於同步兩個網路請求呼叫的Promise示例:
![image040.gif](https://img2018.cnblogs.com/blog/1815214/201910/1815214-20191010005812315-749118289.gif)
Promise的關鍵在於它們是一種通用的同步機制。這意味著它們不是專門針對網路請求,Web workers或DOM事件而產生的。我們必須使用promises包裝我們的非同步操作,並在必要時處理它們。這看起來不錯的原因是依賴promise介面的呼叫者並不關心promise中的內容。顧名思義,Promise是在某個時刻完成的。這可能需要5秒或更快。資料可以來自網路資源或Web使用者。呼叫者並不關心,因為它假設併發,這意味著我們可以在不破壞應用程式的情況下以任何方式實現它。這是上圖的修改版本,它將為我們提供實現promises的可能性:
![image041.gif](https://img2018.cnblogs.com/blog/1815214/201910/1815214-20191010005814251-61182292.gif)
當我們學會用它來實現時,併發程式碼突然變得更加易於理解了。Promise和類似的機制可用於同步網路請求,或僅僅是Web使用者事件。但它們真正有能力使用它們來編寫併發應用程式,其中預設是併發的。在考慮同步原則時,這裡有一個可以參考的檢查清單:
- 我們的應用程式是否嚴重依賴回撥函式作為同步機制?
- 我們是否經常需要同步多個非同步事件,例如網路請求?
- 我們的回撥函式是否包含比應用程式程式碼更多的同步重複程式碼?
- 我們的程式碼對驅動非同步事件的併發機制做了哪些假設?
- 如果我們有一個問題導致併發失敗,我們的應用程式是否仍然能按預期執行?
#### 保護
保護原則是關於節省計算和記憶體資源。這是通過使用惰性計算技術完成的。惰性的名稱源於我們在確定我們確實需要它之前不會實際計算新值的方法。想象一下渲染頁面元素的應用程式元件。我們可以傳遞此元件給它需要渲染的確切資料。這意味著在元件實際需要之前會進行多次計算。它還意味著所使用的資料需要分配到記憶體中,以便我們可以將它傳遞給元件。這種方法並沒有錯。實際上,它是在JavaScript元件中傳遞資料的通用方法。
使用惰性計算的替代方法來實現相同的結果。不是計算要渲染的值,而是在要傳遞的結構中分配它們,我們計算一項,然後渲染它。將此視為一種合作的多工,其中較大的操作被分解為較小的任務,來回傳遞控制的焦點。
這是一種快速的計算資料方法,並將其傳遞給渲染UI元素的元件:
![image042.gif](https://img2018.cnblogs.com/blog/1815214/201910/1815214-20191010005816836-2016918276.gif)
這種方法有兩個不好的地方。首先,轉換是預先進行的,這可能是一項成本高昂的計算。如果元件發生了什麼問題,無法以任何方式渲染它 - 由於某種限制?然後我們執行了這個計算來轉換不需要的資料。作為必然結果,我們為轉換後的資料分配了一個新的資料結構,以便我們可以將它傳遞給我們的元件。這種瞬時儲存的結構實際上並沒有用於任何目的,因為它會立即被垃圾收集。讓我們來看看惰性方法是什麼樣子的:
![image043.gif](https://img2018.cnblogs.com/blog/1815214/201910/1815214-20191010005821910-1908193782.gif)
使用惰性方法,我們可以刪除預先進行的成本昂貴的轉換計算。相反,我們一次只轉換一項。我們還能夠刪除轉換後的資料結構前期分配的儲存空間。相反,只有轉換後的項將傳遞到元件中。然後,元件可以請求另一項或停止。保護原則是使用併發作為僅計算所需內容,並僅分配所需記憶體的方法。
以下檢查清單將幫助我們在編寫併發程式碼時考慮保護原則:
- 我們是否計算了從未使用過的值?
- 我們是否只分配資料結構作為將它們從一個元件傳遞到下一個元件的方法?
- 我們是否將資料轉換操作串在一起?
### 小結
在本章中,我們介紹了JavaScript中併發的一些目標。雖然同步JavaScript易於維護和理解,但非同步JavaScript程式碼在Web上是不可避免的。因此,在編寫JavaScript應用程式時,將併發作為預設的非常重要。
我們感興趣的有兩種主要的併發型別 - 非同步操作和並行操作。非同步是關於操作在時間上排序,這給人一種事情都發生在同一時間的感覺。如果沒有這種型別的併發,對使用者體驗會造成很大的影響,因為它會不斷地等待其他操作完成。並行是另一種型別的併發,解決了另一個不同型別的問題,我們希望通過更快地計算結果來提高效能。
最後,我們研究了JavaScript併發程式設計中的三種原則。併發原則是利用現代系統中的多核CPU。同步原則是關於建立抽象機制,使我們能夠編寫併發程式碼,從我們的功能程式碼中隱藏併發機制。保護原則使用惰性計算來僅計算所需內容並避免不必要的記憶體分配。
在下一章中,我們將把注意力轉向JavaScript執行環境。為了有效地使用JavaScript併發,我們需要對程式碼執行時實際發生的事情有充分的