本文是我翻譯《JavaScript Concurrency》書籍的第一章 JavaScript併發簡介,該書主要以Promises、Generator、Web workers等技術來講解JavaScript併發編程方面的實踐。 完整書籍翻譯地址: "https://github.com/yzsunl ...
本文是我翻譯《JavaScript Concurrency》書籍的第一章 JavaScript併發簡介,該書主要以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的非同步特性。
同步是很容易理解的
當代碼是同步的,它很容易被理解。將我們在屏幕上看到的指令集映射到頭腦中的有序步驟相當容易; 這樣做,然後那樣做;判斷一下,如果是,則執行此操作,依此類推。這種串列類型的代碼處理很容易理解,因為沒有什麼特別的,假想代碼的運行並不可怕。以下是一大塊同步代碼的示例:
相反的,併發編程並不容易理解。這是因為代碼在編輯器並不能線性的去追蹤。相反,我們不斷跳轉,試圖映射這段代碼相對於那段代碼所做的事情。時間是並行設計的重要因素; 這是違背大腦以自然方式來理解代碼的東西。當我們閱讀代碼時,我們自然會在腦海中假想去執行它。這是我們弄清楚它在做什麼的方式。
當代碼實際執行不符合我們的假想時,這時就會很崩潰。通常情況下,看代碼就像看一本書 - 而看併發代碼就像看一本雖然被編號,但是不按順序編號的書。我們來看一些簡單的偽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引擎會將我們的代碼移動到它的下一組指令。對遠程數據的請求,在繼續執行代碼之前不會等待響應:
頁面元素全部渲染並填充數據後,用戶開始與我們的頁面進行交互。這意味著事件被觸發 - 單擊元素將觸發click事件。發送這些事件的DOM環境是一個沙盒環境。這意味著在瀏覽器中,DOM是一個子系統,與JavaScript解釋器是分離的,後者運行我們的代碼。這種分離使某些JavaScript併發方案很難進行。我們將在下一章深入介紹這些內容。
有了所有這些非同步的來源,毫無疑問,我們的頁面會因特殊的情況處理而變得臃腫,以應對不可避免地出現的特殊情況。非同步思考是不符合邏輯的,因此這種類型的動態修補可能是同步思考的結果。最好採用Web的非同步特性。但是,同步網路可能會導致令用戶無法忍受的體驗。現在,讓我們進一步瞭解我們在JavaScript體繫結構中可能遇到的併發類型。
併發的類型
JavaScript是一種運行直到完成的語言。儘管在運行上存在併發機制,但並沒有解決它。換句話說,我們的JavaScript代碼不會在if語句中間轉而去控制另一個線程。這很重要的原因是我們可以選擇一個有助於我們思考JavaScript併發的抽象層次。讓我們看看在JavaScript代碼中併發操作的兩種類型。
非同步操作
非同步操作的一個特征是它們不會阻止其他後續操作。非同步操作並不一定意味著“一勞永逸”。相反,當那部分我們等待的操作完成時,我們會運行一個回調函數。這個回調函數與我們的其他代碼不同步; 因此,這被稱為非同步。
在Web前端中,經常從遠程伺服器獲取數據。這些請求操作相對較慢,因為它們必須通過網路連接。這些操作是非同步的,因為我們的代碼會等待一些數據返回以便觸發回調函數,這並不意味著用戶必須停下來等待。此外,用戶當前正在查看的任何頁面都不太可能僅依賴於一個遠程資源。因此,串列處理多個遠程數據請求會產生非常糟糕的用戶體驗。
以下是非同步代碼的簡單示例:
var request = fetch('/ foo');
request.addEventListener((response) => {
//現在它已經返回了,可以使用“response”做些事情了
});
//不要等待響應,立即更新DOM
updateUI();
下載示例代碼
您可以從http://www.packtpub.com上的帳戶下載所購買的所有Packt Publishing書籍的示例代碼文件。
如果您在其他地方購買了本書,可以訪問http://www.packtpub.com/support並註冊以直接通過電子郵件發送給您。
我們不僅限於獲取遠程數據,而是將其作為非同步操作的一個案例。當我們發出網路請求時,這些非同步控制流實際上會離開瀏覽器。但是,限制在瀏覽器中的非同步操作呢?以setTimeout()函數為例。它遵循與網路請求使用一樣的回調模式。該函數已通過回調,將在稍後執行。然而,沒有任何東西離開瀏覽器。相反,該操作排在任何的其他操作後面。這是因為非同步操作仍然只是一個控制線程,由一個CPU執行。這意味著隨著我們的應用程式在規模和複雜性方面的增長,我們就會面臨併發擴展問題。但是,也許非同步操作並不意味著只是解決單一CPU問題。
考慮在單個CPU上執行非同步操作的更好方法可能是想象一下雜技師拋球的場景。雜技師的大腦比作CPU,協調他的動作。被拋出的球是我們操作的數據。我們關心的只有兩個基本動作 - 拋球和接球:
由於雜技師只有一個大腦,所以他不可能將自己的精力用於一次執行多項任務。然而,雜技師經驗豐富,並且知道他不需要分出一小部分精力用於投擲或捕捉動作。一旦球到空中,他可以自由地將註意力轉移到即將降落的球上。
別人在看這個雜技師的動作時,以為他全神貫註於所有拋出的六個球,而實際上,他在同一個時間點會忽視其他五個在空中的球。
並行操作
與非同步一樣,並行允許控制流繼續而無需等待操作完成。與非同步不同,並行要取決於硬體。這是因為我們不能在單個CPU上並行運行兩個或更多個控制流程。然而,將並行與非同步區分開來的主要是使用它的合理性方面。這兩種併發方式解決了不同的問題,並且需要不同的設計原則。
有時,我們希望並行執行操作,否則如果同步執行則會耗費時間。想想正在等待完成三項複雜操作的用戶。如果每個操作都需要10秒鐘才能完成,那麼這意味著用戶必須等待30秒。如果我們能夠並行執行這些任務,我們可以使得總等待時間接近10秒。我們以更少的成本獲得更多,從而實現高效的用戶交互體驗。
這些都不是免費的。與非同步操作一樣,並行操作會將回調作為通信機制。通常,設計並行很難,因為除了與worker線程進行通信之外,我們還要擔心手頭的任務,也就是說,我們希望通過使用worker線程來實現什麼?我們如何將問題分解為更小的操作?以下是我們開始引入並行代碼的示例:
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)。但是假設我們周圍的環境在不斷變化。我們期望的雜技動作越來越多,一個雜技師不可能全部完成:
解決方案是為該表演中加入更多的雜技師。通過這種方式,我們可以添加更多的計算能力,在同一時刻執行多次拋擲和捕獲操作。對於單個非同步運行的雜技師來說,這是不可能的。
我們還沒有解決好問題,因為我們不能只讓新添加的雜技師站在一個地方,並按照一個雜技師玩雜技的方式執行他們的動作。觀眾很多,更多樣化,都需要被逗樂。雜技師需要能夠有不同的動作。他們需要在地板上不斷的四處移動以讓每一個觀眾都能感覺開心。他們甚至可能開始互相玩雜技。該由我們來做一個能夠實現這些雜技動作的設計。
JavaScript併發編程原則:併發,同步,保護
既然我們已經瞭解了併發的基礎知識,以及它在前端Web開發中的作用,那麼讓我們看一下JavaScript開發的一些基本併發編程原則。這些原則僅僅是我們在編寫併發JavaScript代碼時為我們的設計選擇提供信息的工具。
當我們應用這些原則時,它們迫使我們退後一步,在我們推進實施之前提出適當的問題。特別的,是關於為什麼和如何做的問題:
- 我們為什麼要實現這種併發設計?
- 我們希望從中獲得什麼,否則我們無法擺脫簡單的同步方法?
- 我們如何在應用程式中以一種不顯眼的方式實現併發功能?
這是每個併發原則的參考示圖,在開發過程中相互依賴。有了這個,我們將把註意力轉向每個原則,以便進一步探究:
併發
併發原則意味著利用現代CPU功能在更短的時間內計算結果。現在可以在任何現代瀏覽器或NodeJS環境中使用。在瀏覽器中,我們可以使用Web workers實現真正的併發。在NodeJS中,我們可以通過生成新進程來實現真正的併發。從瀏覽器的角度來看,下圖這就是CPU的大致樣子:
由於目標是在更短的時間內進行更多的計算,我們現在必須問自己為什麼要這樣做?除了性能本身非常酷的事實之外,還必須對用戶產生一些切實的影響。這個原則讓我們看著我們的並行代碼並想想 - 用戶從中獲得了什麼?答案是我們可以使用較大的數據集作為輸入進行計算,並且很少可能由於JavaScript長時間運行,給用戶帶來無響應的體驗。
重要的是仔細想想併發的實際好處,因為當我們這樣做時,我們會增加代碼的複雜性,否則就沒多大意義了。因此,如果用戶看到相同的結果,得到同樣的體驗,那無論我們做什麼,併發原則可能都不適用。另一方面,如果可擴展性很重要且數據集大小增加的可能性很大,那麼併發的代碼簡單性的折衷可能是值得的。在考慮併發原則時,這裡有一個要遵循的檢查清單:
- 我們的應用程式是否針對大型數據集執行需要很高昂的計算成本?
- 由於我們的數據集大小的增長,是否有能力處理瓶頸,不讓它們對用戶產生負面影響?
- 我們的用戶目前是否在應用程式性能方面遇到瓶頸?
- 考慮到其他限制因素,我們的設計中的併發有多大可行性?有什麼權衡取捨?
- 從用戶感知延遲或代碼可維護性方面來看,併發實現的好處是否超過了開銷成本?
同步
同步原則是有關用於協調併發操作和抽象這些機制的一些方式。回調函數是一個具有深遠根源的JavaScript概念。這是個很不錯的方式選擇,當我們需要運行一些代碼,但我們不希望馬上就運行它。我們希望當一些條件符合時再運行它。往大的方面講,這種方式沒有什麼內在的問題。回調函數在單獨使用時,是一種很簡潔、方便、可讀性強的一種併發模式。但在大量使用回調,並且在回調之間存在有大量的依賴時,就很令人崩潰了。
Promise API
Promise API是ECMAScript 6中引入的核心JavaScript語法,用於解決當前應用程式所面臨的同步問題。這是一個在實際使用回調時更簡單的API(是的,我們正在與嵌套回調做鬥爭)。Promise的目的不是要消除回調,而是要移除不必要的回調。
以下是用於同步兩個網路請求調用的Promise示例:
Promise的關鍵在於它們是一種通用的同步機制。這意味著它們不是專門針對網路請求,Web workers或DOM事件而產生的。我們必須使用promises包裝我們的非同步操作,併在必要時處理它們。這看起來不錯的原因是依賴promise介面的調用者並不關心promise中的內容。顧名思義,Promise是在某個時刻完成的。這可能需要5秒或更快。數據可以來自網路資源或Web用戶。調用者並不關心,因為它假設併發,這意味著我們可以在不破壞應用程式的情況下以任何方式實現它。這是上圖的修改版本,它將為我們提供實現promises的可能性:
當我們學會用它來實現時,併發代碼突然變得更加易於理解了。Promise和類似的機制可用於同步網路請求,或僅僅是Web用戶事件。但它們真正有能力使用它們來編寫併發應用程式,其中預設是併發的。在考慮同步原則時,這裡有一個可以參考的檢查清單:
- 我們的應用程式是否嚴重依賴回調函數作為同步機制?
- 我們是否經常需要同步多個非同步事件,例如網路請求?
- 我們的回調函數是否包含比應用程式代碼更多的同步重覆代碼?
- 我們的代碼對驅動非同步事件的併發機製做了哪些假設?
- 如果我們有一個問題導致併發失敗,我們的應用程式是否仍然能按預期運行?
保護
保護原則是關於節省計算和記憶體資源。這是通過使用惰性計算技術完成的。惰性的名稱源於我們在確定我們確實需要它之前不會實際計算新值的方法。想象一下渲染頁面元素的應用程式組件。我們可以傳遞此組件給它需要渲染的確切數據。這意味著在組件實際需要之前會進行多次計算。它還意味著所使用的數據需要分配到記憶體中,以便我們可以將它傳遞給組件。這種方法並沒有錯。實際上,它是在JavaScript組件中傳遞數據的通用方法。
使用惰性計算的替代方法來實現相同的結果。不是計算要渲染的值,而是在要傳遞的結構中分配它們,我們計算一項,然後渲染它。將此視為一種合作的多任務,其中較大的操作被分解為較小的任務,來回傳遞控制的焦點。
這是一種快速的計算數據方法,並將其傳遞給渲染UI元素的組件:
這種方法有兩個不好的地方。首先,轉換是預先進行的,這可能是一項成本高昂的計算。如果組件發生了什麼問題,無法以任何方式渲染它 - 由於某種限制?然後我們執行了這個計算來轉換不需要的數據。作為必然結果,我們為轉換後的數據分配了一個新的數據結構,以便我們可以將它傳遞給我們的組件。這種瞬時存儲的結構實際上並沒有用於任何目的,因為它會立即被垃圾收集。讓我們來看看惰性方法是什麼樣子的:
使用惰性方法,我們可以刪除預先進行的成本昂貴的轉換計算。相反,我們一次只轉換一項。我們還能夠刪除轉換後的數據結構前期分配的存儲空間。相反,只有轉換後的項將傳遞到組件中。然後,組件可以請求另一項或停止。保護原則是使用併發作為僅計算所需內容,並僅分配所需記憶體的方法。
以下檢查清單將幫助我們在編寫併發代碼時考慮保護原則:
- 我們是否計算了從未使用過的值?
- 我們是否只分配數據結構作為將它們從一個組件傳遞到下一個組件的方法?
- 我們是否將數據轉換操作串在一起?
小結
在本章中,我們介紹了JavaScript中併發的一些目標。雖然同步JavaScript易於維護和理解,但非同步JavaScript代碼在Web上是不可避免的。因此,在編寫JavaScript應用程式時,將併發作為預設的非常重要。
我們感興趣的有兩種主要的併發類型 - 非同步操作和並行操作。非同步是關於操作在時間上排序,這給人一種事情都發生在同一時間的感覺。如果沒有這種類型的併發,對用戶體驗會造成很大的影響,因為它會不斷地等待其他操作完成。並行是另一種類型的併發,解決了另一個不同類型的問題,我們希望通過更快地計算結果來提高性能。
最後,我們研究了JavaScript併發編程中的三種原則。併發原則是利用現代系統中的多核CPU。同步原則是關於創建抽象機制,使我們能夠編寫併發代碼,從我們的功能代碼中隱藏併發機制。保護原則使用惰性計算來僅計算所需內容並避免不必要的記憶體分配。
在下一章中,我們將把註意力轉向JavaScript執行環境。為了有效地使用JavaScript併發,我們需要對代碼運行時實際發生的事情有充分的理解。