本文講什麼?
伴隨著JavaScript這種web瀏覽器指令碼語言的普及,對它的事件驅動互動模型,以及它與Ruby、Python和Java中常見的請求-響應模型的區別有一個基本瞭解,對您是有益的。在這篇文章中,我將解釋一些JavaScript併發模型的核心概念,包括其事件迴圈和訊息佇列,希望能夠提升你對一種語言的理解,這種語言你可能已經在使用但也許並不完全理解。
這篇文章是寫給誰的?
這篇文章是針對在客戶端或伺服器端使用或計劃使用JavaScript的web開發人員的。如果你已經精通事件迴圈,那麼這篇文章的大部分對你來說會很熟悉。對於那些還不是很精通的人,我希望能給你提供一個基本的瞭解,這樣可以更好地幫助你閱讀和編寫日常程式碼。
非阻塞I / O
在JavaScript中,幾乎所有的I/O都是非阻塞的。這包括HTTP請求,資料庫操作和磁碟讀寫,單執行緒執行要求在執行期執行一個操作時,提供一個回撥函式,然後繼續做其它的事情。當操作已經完成時,訊息和已提供的回撥函式一起插入到佇列。在將來的某個時候,訊息從佇列移除,回撥函式觸發。
雖然這種互動模型可能對已經習慣使用使用者介面的開發人員很熟悉,比如“mousedown,”和“click”事件在某一時刻被觸發。這與通常在伺服器端應用程式進行的同步式請求-響應模型是不同的。
讓我們來比較一下兩小塊程式碼,發出HTTP請求到www.google.com和輸出響應到控制檯。首先看看Ruby,配合使用Faraday(一個Ruby 的HTTP 客戶端開發庫):
1 2 3 |
response = Faraday.get 'http://www.google.com' puts response puts 'Done!' |
執行路徑很容易跟蹤:
- 執行get方法,執行的執行緒等待,直到收到響應
- 從谷歌收到響應並返回給呼叫者,它儲存在一個變數中
- 變數的值(在本例中,就是我們的響應)輸出到控制檯
- 值“Done!“輸出到控制檯
讓我們使用Node.js和Request庫在JavaScript做同樣的事情:
1 2 3 4 5 |
request('http://www.google.com', function(error, response, body) { console.log(body); }); console.log('Done!'); |
表面上看略有不同,實際行為截然不同:
- 執行請求函式,傳遞一個匿名函式作為回撥,當響應在將來某個時候可用時執行回撥。
- “Done!“立即輸出到控制檯
- 在將來的某個時候,響應返回和回撥執行時,輸出它的內容到控制檯
事件迴圈
將呼叫者和響應解耦,使得JavaScript在執行期等待非同步操作完成和回撥觸發時可以做其他事情。但是這些回撥在記憶體中是如何組織的,按什麼順序執行?什麼導致他們被呼叫?
JavaScript執行時包含一個訊息佇列,它儲存了需要處理的訊息的列表和相關的回撥函式。這些訊息是以佇列的形式來響應回撥函式所涉及的外部事件(如滑鼠單擊或收到HTTP請求的響應)的。例如,如果使用者單擊一個按鈕,但沒有提供回撥函式,那麼也沒有訊息會被加入佇列。
在一次迴圈,佇列提取下一條訊息(每次提取稱為一次“tick”),當事件發生,該訊息的回撥執行。
回撥函式的呼叫在呼叫棧作為初始化frame(片段),由於JavaScript是單執行緒的,未來的訊息提取和處理因為等待棧的所有呼叫返回而被停止。後續(同步)函式呼叫會新增新的呼叫frame到棧(例如,函式init呼叫函式changeColor)。
1 2 3 4 5 6 7 8 9 |
function init() { var link = document.getElementById("foo"); link.addEventListener("click", function changeColor() { this.style.color = "burlywood"; }); } init(); |
在這個例子中,當使用者單擊“foo”元素時,一條訊息(及其回撥函式changeColor)會被插入到佇列,並觸發“onclick“事件。當訊息離開佇列時,其回撥函式changeColor被呼叫。當changeColor返回(或者是丟擲一個錯誤),事件迴圈仍在繼續。只要函式changeColor存在,並指定為“foo”元素的onclick方法的回撥,那麼在該元素上單擊會導致更多的訊息(和相關的回撥changeColor)插入佇列。
佇列附加訊息
如果一個函式在程式碼中按非同步呼叫(比如setTimeout),提供的回撥將最終作為一個不同的訊息佇列的一部分被執行,它將發生在事件迴圈的某個未來的動作上。例如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
function f() { console.log("foo"); setTimeout(g, 0); console.log("baz"); h(); } function g() { console.log("bar"); } function h() { console.log("blix"); } f(); |
由於setTimeout的非阻塞特性,它的回撥將在至少0毫秒後觸發,而不是作為訊息的一部分被處理。在這個示例中,setTimeout被呼叫, 傳入了一個回撥函式g且延時0毫秒後執行。當我們指定時間到達(當前情況是,幾乎立即執行),一個單獨的訊息將被加入佇列(g作為回撥函式)。控制檯列印的結果會是像這樣:“foo”,“baz”,“blix”,然後是事件迴圈的下一個動作:“bar”。如果在同一個呼叫片段中,兩個呼叫都設定為setTimeout -傳遞給第二個引數的值也相同-則它們的回撥將按照呼叫順序插入佇列。
Web Workers
使用Web Workers允許您能夠將一項費時的操作在一個單獨的執行緒中執行,從而可以釋放主執行緒去做別的事情。worker(工作執行緒)包括一個獨立的訊息佇列,事件循 環,記憶體空間獨立於例項化它的原始執行緒。worker和主執行緒之間的通訊通過訊息傳遞,看起來很像我們往常常見的傳統事件程式碼示例。
首先,我們的worker:
1 2 3 4 5 6 7 |
// our worker, which does some CPU-intensive operation var reportResult = function(e) { pi = SomeLib.computePiToSpecifiedDecimals(e.data); postMessage(pi); }; onmessage = reportResult; |
然後,主要的程式碼塊在我們的HTML中以script-標籤存在:
1 2 3 4 5 6 7 8 |
// our main code, in a <script>-tag in our HTML page var piWorker = new Worker("pi_calculator.js"); var logResult = function(e) { console.log("PI: " + e.data); }; piWorker.addEventListener("message", logResult, false); piWorker.postMessage(100000); |
在這個例子中,主執行緒建立一個worker,同時註冊logResult回撥函式到其“訊息”事件。在worker裡,reportResult函式註冊到自己的“訊息”事件中。當worker執行緒接收到主執行緒的訊息,worker入隊一條訊息同時帶上reportResult回撥函式。訊息出隊時,一條新訊息傳送回主執行緒,新訊息入隊主執行緒佇列(帶上logResult回撥函式)。這樣,開發人員可以將cpu密集型操作委託給一個單獨的執行緒,使主執行緒解放出來繼續處理訊息和事件。
關於閉包的
JavaScript對閉包的支援,允許你這樣註冊回撥函式,當回撥函式執行時,保持了對他們被建立的環境的訪問(即使回撥的執行時建立了一個全新的呼叫棧)。理解我們的回撥作為一個不同的訊息的一部分被執行,而不是建立它的那個會很有意思。看看下面的例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
function changeHeaderDeferred() { var header = document.getElementById("header"); setTimeout(function changeHeader() { header.style.color = "red"; return false; }, 100); return false; } changeHeaderDeferred(); |
在這個例子中,changeHeaderDeferred函式被執行時包含了變數header。函式 setTimeout被呼叫,導致訊息(帶上changeHeader回撥)被新增到訊息佇列,在大約100毫秒後執行。然後 changeHeaderDeferred函式返回false,結束第一個訊息的處理,但header變數仍然可以通過閉包被引用,而不是被垃圾回收。當 第二個訊息被處理(changeHeader函式),它保持了對在外部函式作用域中宣告的header變數的訪問。一旦第二個訊息 (changeHeader函式)執行結束,header變數可以被垃圾回收。
提醒
JavaScript 事件驅動的互動模型不同於許多程式設計師習慣的請求-響應模型,但如你所見,它並不複雜。使用簡單的訊息佇列和事件迴圈,JavaScript使得開發人員在構建他們的系統時使用大量asynchronously-fired(非同步-觸發)回撥函式,讓執行時環境能在等待外部事件觸發的同時處理併發操作。然 而,這不過是併發的一種方法。在本文的第二部分中,我將對JavaScript的併發模型與MRI Ruby(執行緒和GIL),EventMachine(Ruby),Java(執行緒)進行比較。
更多的閱讀
- Check out this presentation I did recently, titled “The JavaScript Event Loop: Concurrency in the Language of the Web”
- Concurrency model and Event Loop @ MDN
- An intro to the Node.js platform, by Aaron Stannard