js-關於非同步原理的理解和總結
我們經常說JS是單執行緒的,比如Node.js研討會上大家都說JS的特色之一是單執行緒的,這樣使JS更簡單明瞭,可是大家真的理解所謂JS的單執行緒機制嗎?單執行緒時,基於事件的非同步機制又該當如何,這些知識在《JavaScript權威指南》並沒有介紹,我也一直困惑了,直到看到一篇外文,才有了些眉目,這裡與大家分享下。翻譯的過程中,發現已有人翻譯了這篇文章,於是乎,在某些語句上,借鑑了下。文章網址:連結。後來發現《JavaScript高階程式設計》高階定時器和迴圈定時器介紹過,不過覺得沒我翻譯這篇原文介紹得更透徹,覺得我寫的不好的,可以檢視原外文。
1 先看下兩個例子
1.1. 簡單的settimeout
setTimeout(function () { while (true) { } }, 1000); setTimeout(function () { alert('end 2'); }, 2000); setTimeout(function () { alert('end 1'); }, 100); alert('end'); |
執行的結果是彈出’end’、’end 1’,然後瀏覽器假死,就是不彈出‘end 2’。也就是說第一個settimeout裡執行的時候是一個死迴圈,這個直接導致了理論上比它晚一秒執行的第二個settimeout裡的函式被阻塞,這個和我們平時所理解的非同步函式多執行緒互不干擾是不符的。
附計時器使用方法
--初始化一個簡單的js的計時器,一段時間後,才觸發並執行回撥函式。 setTimeout 返回一個唯一id,可用這個id來取消這個計時器。 var id = setTimeout(fn,delay); --類似於setTimeout,不一樣的是,每隔一段時間,會持續呼叫回撥fn,直到被取消 var id = setInterval(fn,delay); --傳入一個計時器的id,取消計時器。 clearInterval(id); clearTimeout(id); |
1.2. ajax請求回撥
接著我們來測試一下通過xmlhttprequest實現ajax非同步請求呼叫,主要程式碼如下:
var xmlReq = createXMLHTTP();//建立一個xmlhttprequest物件 function testAsynRequest() { var url = "/AsyncHandler.ashx?action=ajax"; xmlReq.open("post", url, true); xmlReq.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); xmlReq.onreadystatechange = function () { if (xmlReq.readyState == 4) { if (xmlReq.status == 200) { var jsonData = eval('(' + xmlReq.responseText + ')'); alert(jsonData.message); } else if (xmlReq.status == 404) { alert("Requested URL is not found."); } else if (xmlReq.status == 403) { alert("Access denied."); } else { alert("status is " + xmlReq.status); } } }; xmlReq.send(null); } testAsynRequest();//1秒後呼叫回撥函式 while (true) { } |
在服務端實現簡單的輸出:
private void ProcessAjaxRequest(HttpContext context) { string action = context.Request["ajax"]; Thread.Sleep(1000);//等1秒 string jsonObject = "{\"message\":\"" + action + "\"}"; context.Response.Write(jsonObject); } |
理論上,如果ajax非同步請求,它的非同步回撥函式是在單獨一個執行緒中,那麼回撥函式必然不被其他執行緒”阻撓“而順利執行,也就是1秒後,它回撥執行彈出‘ajax’,可是實際情況並非如此,回撥函式無法執行,因為瀏覽器再次因為死迴圈假死。
據上面兩個例子,總結如下:
① JavaScript引擎是單執行緒執行的,瀏覽器無論在什麼時候都只且只有一個執行緒在執行JavaScript程式. ② JavaScript引擎用單執行緒執行也是有意義的,單執行緒不必理會執行緒同步這些複雜的問題,問題得到簡化。 |
2. JavaScript引擎
可JS內部究竟如何實現,我們在接下來探討。
在瞭解計時器內部運作前,我們必須清楚一點,觸發和執行並不是同一概念,計時器的回撥函式一定會在指定delay的時間後被觸發,但並不一定立即執行,可能需要等待。所有JavaScript程式碼是在一個執行緒裡執行的,像滑鼠點選和計時器之類的事件只有在JS單執行緒空閒時才執行。
JS 是單執行緒的,但是卻能執行非同步任務,這主要是因為 JS 中存在事件迴圈(Event Loop)和任務佇列(Task Queue)。
事件迴圈:JS 會建立一個類似於 while (true) 的迴圈,每執行一次迴圈體的過程稱之為 Tick。每次 Tick 的過程就是檢視是否有待處理事件,如果有則取出相關事件及回撥函式放入執行棧中由主執行緒執行。待處理的事件會儲存在一個任務佇列中,也就是每次 Tick 會檢視任務佇列中是否有需要執行的任務。
任務佇列:非同步操作會將相關回調新增到任務佇列中。而不同的非同步操作新增到任務佇列的時機也不同,如 onclick, setTimeout, ajax 處理的方式都不同,這些非同步操作是由瀏覽器核心的 webcore 來執行的,webcore 包含上圖中的3種 webAPI,分別是 DOM Binding、network、timer模組。
onclick 由瀏覽器核心的 DOM Binding 模組來處理,當事件觸發的時候,回撥函式會立即新增到任務佇列中。
setTimeout 會由瀏覽器核心的 timer 模組來進行延時處理,當時間到達的時候,才會將回調函式新增到任務佇列中。
ajax 則會由瀏覽器核心的 network 模組來處理,在網路請求完成返回之後,才將回撥新增到任務佇列中。
主執行緒:JS 只有一個執行緒,稱之為主執行緒。而事件迴圈是主執行緒中執行棧裡的程式碼執行完畢之後,才開始執行的。所以,主執行緒中要執行的程式碼時間過長,會阻塞事件迴圈的執行,也就會阻塞非同步操作的執行。只有當主執行緒中執行棧為空的時候(即同步程式碼執行完後),才會進行事件迴圈來觀察要執行的事件回撥,當事件迴圈檢測到任務佇列中有事件就取出相關回調放入執行棧中由主執行緒執行。
Update:
《你不知道的 JavaScript》一書中,重新講解了 ES6 新增的任務佇列,和上面的任務佇列略有不同,上面的任務佇列書中稱為事件佇列。
上面提到的任務(事件)佇列是在事件迴圈中的,事件迴圈每一次 tick 便執行上面所述的任務(事件)佇列中的一個任務。而任務(事件)佇列是隻能往尾部新增任務。
而 ES6 中新增的任務佇列是在事件迴圈之上的,事件迴圈每次 tick 後會檢視 ES6 的任務佇列中是否有任務要執行,也就是 ES6 的任務佇列比事件迴圈中的任務(事件)佇列優先順序更高。
如 Promise 就使用了 ES6 的任務佇列特性。
3. JavaScript引擎執行緒和其它偵聽執行緒
在瀏覽器中,JavaScript引擎是基於事件驅動的,這裡的事件可看作是瀏覽器派給它的各種任務,這些任務可能源自當前執行的程式碼塊,如呼叫setTimeout(),也可能來自瀏覽器核心,如onload()、onclick()、onmouseover()、setTimeOut()、setInterval()、Ajax等。如果從程式碼的角度來看,所謂的任務實體就是各種回撥函式,由於“單執行緒”的原因,這些任務會進行排隊,一個接著一個等待著被引擎處理。
上圖中,定時器和事件都按時觸發了,這表明JavaScript引擎的執行緒和計時器觸發執行緒、事件觸發執行緒是三個單獨的執行緒,即使JavaScript引擎的執行緒被阻塞,其它兩個觸發執行緒都在執行。
瀏覽器核心實現允許多個執行緒非同步執行,這些執行緒在核心制控下相互配合以保持同步。假如某一瀏覽器核心的實現至少有三個常駐執行緒: JavaScript引擎執行緒,事件觸發執行緒,Http請求執行緒,下面通過一個圖來闡明單執行緒的JavaScript引擎與另外那些執行緒是怎樣互動通訊的。雖然每個瀏覽器核心實現細節不同,但這其中的呼叫原理都是大同小異。
執行緒間通訊:JavaScript引擎執行當前的程式碼塊,其它諸如setTimeout給JS引擎新增一個任務,也可來自瀏覽器核心的其它執行緒,如介面元素滑鼠點選事件,定時觸發器時間到達通知,非同步請求狀態變更通知等.從程式碼角度看來任務實體就是各種回撥函式,JavaScript引擎一直等待著任務佇列中任務的到來.由於單執行緒關係,這些任務得進行排隊,一個接著一個被引擎處理.
GUI渲染也是在引擎執行緒中執行的,指令碼中執行對介面進行更新操作,如新增結點,刪除結點或改變結點的外觀等更新並不會立即體現出來,這些操作將儲存在一個佇列中,待JavaScript引擎空閒時才有機會渲染出來。來看例子(這塊內容還有待驗證,個人覺得當Dom渲染時,才可阻止渲染)
<div id="test">test</div> <script type="text/javascript" language="javascript"> var i=0; while(1) { document.getElementById("test").innerHTML+=i++ + "<br />"; } </script> |
這段程式碼的本意是從0開始順序顯示數字,它們將一個接一個出現,現在我們來仔細研究一下程式碼,while(1)建立了一個無休止的迴圈,但是對於單執行緒的JavaScript引擎而言,在實際情況中就會造成瀏覽器暫停響應並處於假死狀態。
alert()會停止JS引擎的執行,直到按確認鍵,在JS除錯的時候,檢視當前實時頁面的內容。
4. setTimeout和 setInterval
回到文章開頭,我們來看下setTimeout和setsetInterval的區別。
setTimeout(function(){ /* Some long block of code ... */ setTimout(arguments.callee,10); },10); setInterval(function(){ /* Some long block of code ... */ },10); |
這兩個程式段第一眼看上去是一樣的,但並不是這樣。setTimeout程式碼至少每隔10ms以上才執行一次;然而setInterval固定每隔10ms將嘗試執行,不管它的回撥函式的執行狀態。
我們來總結下:
l JavaScript引擎只有一個執行緒,強制非同步事件排隊等待執行。 l setTimeout和setInterval在非同步執行時,有著根本性不同。 l 如果一個計時器被阻塞執行,它將會延遲,直到下一個可執行點(這可能比期望的時間更長) l setInterval的回撥可能被不停的執行,中間沒間隔(如果回撥執行的時間超過預定等待的值) |
《JavaScript高階程式設計》中,針對setInterval說法如下:
當使用setInterval()時,僅當沒有該定時器的任何其他程式碼例項時,才將定時器程式碼新增到佇列中。還要注意兩問題:
① 某些間隔會被跳過(拋棄); ② 多個定時器的程式碼執行之間的間隔可能會比預期小。此時可採取 setTimeout和setsetInterval的區別 的例子方法。 |
5. Ajax非同步
很多同學朋友搞不清楚,既然說JavaScript是單執行緒執行的,那麼XMLHttpRequest在連線後是否真的非同步?其實請求確實是非同步的,不過這請求是由瀏覽器新開一個執行緒請求(參見上圖),當請求的狀態變更時,如果先前已設定回撥,這非同步執行緒就產生狀態變更事件放到JavaScript引擎的處理佇列中等待處理,當任務被處理時,JavaScript引擎始終是單執行緒執行回撥函式,具體點即還是單執行緒執行onreadystatechange所設定的函式。
Tip:理解JavaScript引擎運作非常重要,特別是在大量非同步事件(連續)發生時,可以提升程式程式碼的效率。