細說JavaScript單執行緒的一些事
本文由碼農網 – 劉健超原創,轉載請看清文末的轉載要求,歡迎參與我們的付費投稿計劃!
最近被同學問道 JavaScript 單執行緒的一些事,我竟回答不上。好吧,感覺自己的 JavaScript 白學了。下面是我這幾天整理的一些關於 JavaScript 單執行緒的一些事。
首先,說下為什麼 JavaScript 是單執行緒?
總所周知,JavaScript是以單執行緒的方式執行的。說到執行緒就自然聯想到程式。那它們有什麼聯絡呢?
程式和執行緒都是作業系統的概念。程式是應用程式的執行例項,每一個程式都是由私有的虛擬地址空間、程式碼、資料和其它系統資源所組成;程式在執行過程中能夠申請建立和使用系統資源(如獨立的記憶體區域等),這些資源也會隨著程式的終止而被銷燬。而執行緒則是程式內的一個獨立執行單元,在不同的執行緒之間是可以共享程式資源的,所以在多執行緒的情況下,需要特別注意對臨界資源的訪問控制。在系統建立程式之後就開始啟動執行程式的主執行緒,而程式的生命週期和這個主執行緒的生命週期一致,主執行緒的退出也就意味著程式的終止和銷燬。主執行緒是由系統程式所建立的,同時使用者也可以自主建立其它執行緒,這一系列的執行緒都會併發地執行於同一個程式中。
顯然,在多執行緒操作下可以實現應用的並行處理,從而以更高的CPU利用率提高整個應用程式的效能和吞吐量。特別是現在很多語言都支援多核並行處理技術,然而JavaScript卻以單執行緒執行,為什麼呢?
其實這與它的用途有關。作為瀏覽器指令碼語言,JavaScript的主要用途是與使用者互動,以及操作DOM。若以多執行緒的方式操作這些DOM,則可能出現操作的衝突。假設有兩個執行緒同時操作一個DOM元素,執行緒1要求瀏覽器刪除DOM,而執行緒2卻要求修改DOM樣式,這時瀏覽器就無法決定採用哪個執行緒的操作。當然,我們可以為瀏覽器引入“鎖”的機制來解決這些衝突,但這會大大提高複雜性,所以 JavaScript 從誕生開始就選擇了單執行緒執行。
另外,因為 JavaScript 是單執行緒的,在某一時刻內只能執行特定的一個任務,並且會阻塞其它任務執行。那麼對於類似I/O等耗時的任務,就沒必要等待他們執行完後才繼續後面的操作。在這些任務完成前,JavaScript完全可以往下執行其他操作,當這些耗時的任務完成後則以回撥的方式執行相應處理。這些就是JavaScript與生俱來的特性:非同步與回撥。
當然對於不可避免的耗時操作(如:繁重的運算,多重迴圈),HTML5提出了Web Worker,它會在當前JavaScript的執行主執行緒中利用Worker類新開闢一個額外的執行緒來載入和執行特定的JavaScript檔案,這個新的執行緒和JavaScript的主執行緒之間並不會互相影響和阻塞執行,而且在Web Worker中提供了這個新執行緒和JavaScript主執行緒之間資料交換的介面:postMessage和onMessage事件。但在HTML5 Web Worker中是不能操作DOM的,任何需要操作DOM的任務都需要委託給JavaScript主執行緒來執行,所以雖然引入HTML5 Web Worker,但仍然沒有改線JavaScript單執行緒的本質。
併發模式與Event Loop
JavaScript 有個基於“Event Loop”併發的模型。
啊,併發?不是說 JavaScript是單執行緒嗎? 沒錯,的確是單執行緒,但是併發與並行是有區別的。前者是邏輯上的同時發生,而後者是物理上的同時發生。所以,單核處理器也能實現併發。
併發與並行
並行大家都好理解,而所謂“併發”是指兩個或兩個以上的事件在同一時間間隔中發生。如上圖的第一個表,由於計算機系統只有一個CPU,故ABC三個程式從“微觀”上是交替使用CPU,但交替時間很短,使用者察覺不到,形成了“巨集觀”意義上的併發操作。
Runtime 概念
下面的內容解釋一個理論上的模型。現代 JavaScript 引擎已著重實現和優化了以下所描述的幾個概念。
Stack(棧)
這裡放著JavaScript正在執行的任務。每個任務被稱為幀(stack of frames)。
function f(b){ var a = 12; return a+b+35; } function g(x){ var m = 4; return f(m*x); } g(21);
上述程式碼呼叫 g
時,建立棧的第一幀,該幀包含了 g
的引數和區域性變數。當 g
呼叫 f
時,第二幀就會被建立,並且置於第一幀之上,當然,該幀也包含了 f
的引數和區域性變數。當 f
返回時,其對應的幀就會出棧。同理,當 g
返回時,棧就為空了(棧的特定就是後進先出 Last-in first-out (LIFO))。
Heap(堆)
一個用來表示記憶體中一大片非結構化區域的名字,物件都被分配在這。
Queue(佇列)
一個 JavaScript runtime 包含了一個任務佇列,該佇列是由一系列待處理的任務組成。而每個任務都有相對應的函式。當棧為空時,就會從任務佇列中取出一個任務,並處理之。該處理會呼叫與該任務相關聯的一系列函式(因此會建立一個初始棧幀)。當該任務處理完畢後,棧就會再次為空。(Queue的特點是先進先出 First-in First-out (FIFO))。
為了方便描述與理解,作出以下約定:
- Stack棧為主執行緒
- Queue佇列為任務佇列(等待排程到主執行緒執行)
OK,上述知識點幫助我們理清了一個 JavaScript runtime 的相關概念,這有助於接下來的分析。
Event Loop
之所以被稱為Event loop,是因為它以以下類似方式實現:
while(queue.waitForMessage()){ queue.processNextMessage(); }
正如上述所說,“任務佇列”是一個事件的佇列,如果I/O裝置完成任務或使用者觸發事件(該事件指定了回撥函式),那麼相關事件處理函式就會進入“任務佇列”,當主執行緒空閒時,就會排程“任務佇列”裡第一個待處理任務,(FIFO)。當然,對於定時器,當到達其指定時間時,才會把相應任務插到“任務佇列”尾部。
“執行至完成”
每當某個任務執行完後,其它任務才會被執行。也就是說,當一個函式執行時,它不能被取代且會在其它程式碼執行前先完成。
當然,這也是Event Loop的一個缺點:當一個任務完成時間過長,那麼應用就不能及時處理使用者的互動(如點選事件),甚至導致該應用奔潰。一個比較好解決方案是:將任務完成時間縮短,或者儘可能將一個任務分成多個任務執行。
絕不阻塞
JavaScript與其它語言不同,其Event Loop的一個特性是永不阻塞。I/O操作通常是通過事件和回撥函式處理。所以,當應用等待 indexedDB 或 XHR 非同步請求返回時,其仍能處理其它操作(如使用者輸入)。
例外是存在的,如alert或者同步XHR,但避免它們被認為是最佳實踐。注意的是,例外的例外也是存在的(但通常是實現錯誤而非其它原因)。
定時器
定時器的一些概念
上面也提到,在到達指定時間時,定時器就會將相應回撥函式插入“任務佇列”尾部。這就是“定時器(timer)”功能。
定時器包括setTimeout與setInterval兩個方法。它們的第二個引數是指定其回撥函式推遲\每隔多少毫秒數後執行。
對於第二個引數有以下需要注意的地方:
- 當第二個引數預設時,預設為0;
- 當指定的值小於4毫秒,則增加到4ms(4ms是HTML5標準指定的,對於2010年及之前的瀏覽器則是10ms);
如果你理解上述知識,那麼以下程式碼就應該對你沒什麼問題了:
console.log(1); setTimeout(function(){ console.log(2); },10); console.log(3); // 輸出:1 3 2
深入瞭解定時器
零延遲 setTimeout(func, 0)
零延遲並不是意味著回撥函式立刻執行。它取決於主執行緒當前是否空閒與“任務佇列”裡其前面正在等待的任務。
看看以下程式碼:
(function () { console.log('this is the start'); setTimeout(function cb() { console.log('this is a msg from call back'); }); console.log('this is just a message'); setTimeout(function cb1() { console.log('this is a msg from call back1'); }, 0); console.log('this is the end'); })(); // 輸出如下: this is the start this is just a message this is the end undefined // 立即呼叫函式的返回值 this is a msg from callback this is a msg from a callback1
setTimeout(func, 0)的作用
- 讓瀏覽器渲染當前的變化(很多瀏覽器UI render和js執行是放在一個執行緒中,執行緒阻塞會導致介面無法更新渲染)
- 重新評估”scriptis running too long”警告
- 改變執行順序
再看看以下程式碼:
<button id='do'> Do long calc!</button> <div id='status'></div> <div id='result'></div> $('#do').on('click', function(){ $('#status').text('calculating....');// 此處會觸發redraw事件,但會放到佇列裡執行,直到long()執行完。 // 沒設定定時器,使用者將無法看到“calculating...” long();// 執行長時間任務,造成阻塞 // 設定了定時器,使用者就如期看到“calculating...” //setTimeout(long,50);// 大約50ms後,將耗時長的long回撥函式插入“任務佇列”末尾,根據先進先出原則,其將在redraw之後被排程到主執行緒執行 }); function long(){ var result = 0 for (var i = 0; i<1000; i++){ for (var j = 0; j<1000; j++){ for (var k = 0; k<1000; k++){ result = result + i+j+k } } } $('#status').text('calclation done'); // 在本案例中,該語句必須放到這裡,這將使它與回撥函式的行為類似 }
正版與翻版setInterval的區別
大家都可能知道通過setTimeout可以模仿setInterval的效果,下面我們看看以下程式碼的區別:
// 利用setTimeout模仿setInterval setTimeout(function(){ /* 執行一些操作. */ setTimeout(arguments.callee, 10); }, 1000); setInterval(function(){ /* 執行一些操作 */ }, 1000);
可能你認為這沒什麼區別。的確,當回撥函式裡的操作耗時很短時,並不能看出它們有什麼區別。
其實:上面案例中的 setTimeout 總是會在其回撥函式執行後延遲 10ms(或者更多,但不可能少)再次執行回撥函式,從而實現setInterval的效果,而 setInterval 總是 10ms 執行一次,而不管它的回撥函式執行多久。
所以,如果 setInterval 的回撥函式執行時間比你指定的間隔時間相等或者更長,那麼其回撥函式會連在一起執行。
你可以試試執行以下程式碼:
var counter = 0; var initTime = new Date().getTime(); var timer = setInterval(function(){ if(counter===2){ clearInterval(timer); } if(counter === 0){ for(var i = 0; i < 1990000000; i++){ ; } } console.log("第"+counter+"次:" + (new Date().getTime() - initTime) + " ms"); counter++; },1000);
我電腦Chrome瀏覽器的輸入如下:
第0次:2007 ms 第1次:2013 ms 第2次:3008 ms
瀏覽器
瀏覽器不是單執行緒的
上面說了這麼多關於JavaScript是單執行緒的,下面說說其宿主環境——瀏覽器。
瀏覽器的核心是多執行緒的,它們在核心制控下相互配合以保持同步,一個瀏覽器至少實現三個常駐執行緒:
- javascript引擎執行緒 javascript引擎是基於事件驅動單執行緒執行的,JS引擎一直等待著任務佇列中任務的到來,然後加以處理,瀏覽器無論什麼時候都只有一個JS執行緒在執行JS程式。
- GUI渲染執行緒 GUI渲染執行緒負責渲染瀏覽器介面,當介面需要重繪(Repaint)或由於某種操作引發迴流(reflow)時,該執行緒就會執行。但需要注意GUI渲染執行緒與JS引擎是互斥的,當JS引擎執行時GUI執行緒會被掛起,GUI更新會被儲存在一個佇列中等到JS引擎空閒時立即被執行。
- 瀏覽器事件觸發執行緒 事件觸發執行緒,當一個事件被觸發時該執行緒會把事件新增到“任務佇列”的隊尾,等待JS引擎的處理。這些事件可來自JavaScript引擎當前執行的程式碼塊如setTimeOut、也可來自瀏覽器核心的其他執行緒如滑鼠點選、AJAX非同步請求等,但由於JS是單執行緒執行的,所有這些事件都得排隊等待JS引擎處理。
在Chrome瀏覽器中,為了防止因一個標籤頁奔潰而影響整個瀏覽器,其每個標籤頁都是一個程式。當然,對於同一域名下的標籤頁是能夠相互通訊的,具體可看 瀏覽器跨標籤通訊。在Chrome設計中存在很多的程式,並利用程式間通訊來完成它們之間的同步,因此這也是Chrome快速的法寶之一。對於Ajax的請求也需要特殊執行緒來執行,當需要傳送一個Ajax請求時,瀏覽器會開闢一個新的執行緒來執行HTTP的請求,它並不會阻塞JavaScript執行緒的執行,當HTTP請求狀態變更時,相應事件會被作為回撥放入到“任務佇列”中等待被執行。
看看以下程式碼:
document.onclick = function(){ console.log("click") } for(var i = 0; i< 100000000; i++);
解釋一下程式碼:首先向document註冊了一個click事件,然後就執行了一段耗時的for迴圈,在這段for迴圈結束前,你可以嘗試點選頁面。當耗時操作結束後,console控制檯就會輸出之前點選事件的”click”語句。這視乎證明了點選事件(也包括其它各種事件)是由額外單獨的執行緒觸發的,事件觸發後就會將回撥函式放進了“任務佇列”的末尾,等待著JavaScript主執行緒的執行。
總結
- JavaScript是單執行緒的,同一時刻只能執行特定的任務。而瀏覽器是多執行緒的。
- 非同步任務(各種瀏覽器事件、定時器等)都是先新增到“任務佇列”(定時器則到達其指定引數時)。當Stack棧(JS主執行緒)為空時,就會讀取Queue佇列(任務佇列)的第一個任務(隊首),然後執行。
JavaScript為了避免複雜性,而實現單執行緒執行。而今JavaScript卻變得越來越不簡單了,當然這也是JavaScript迷人的地方。
參考資料:
- JavaScript 執行機制詳解:再談Event Loop
- JavaScript單執行緒和瀏覽器事件迴圈簡述
- Javascript是單執行緒的深入分析
- Concurrency model and Event Loop
- 也談setTimeout
- 單執行緒的Javascript
若覺得這篇文章讓您獲益,歡迎您在 Github 給個 Star。
本文連結:http://www.codeceo.com/article/javascript-threaded.html
本文作者:碼農網 – 劉健超
[ 原創作品,轉載必須在正文中標註並保留原文連結和作者等資訊。]
相關文章
- 細說C#多執行緒那些事:執行緒基礎C#執行緒
- JavaScript單執行緒概念JavaScript執行緒
- 細說JUC的執行緒池架構執行緒架構
- 深入理解JavaScript執行(單執行緒的JS)JavaScript執行緒JS
- 以生活例子說明單執行緒與多執行緒執行緒
- 關於多執行緒的一些細節 (轉)執行緒
- Javascript是單執行緒的深入分析JavaScript執行緒
- Thread interrupt() 執行緒中斷的詳細說明thread執行緒
- 執行緒池中你不容錯過的一些細節執行緒
- web前端教程:如何理解JavaScript的單執行緒?Web前端JavaScript執行緒
- 前端效能優化:細說JavaScript的載入與執行前端優化JavaScript
- JAVA執行緒的那些事?Java執行緒
- 執行緒1-單執行緒執行緒
- 執行緒與執行緒池的那些事之執行緒池篇(萬字長文)執行緒
- 單執行緒的 Javascript 為什麼可以非同步執行緒JavaScript非同步
- Java執行緒池的那些事Java執行緒
- java執行緒池趣味事:這不是執行緒池Java執行緒
- JavaScript 單執行緒之非同步程式設計JavaScript執行緒非同步程式設計
- 前端開發技術-剖析JavaScript單執行緒前端JavaScript執行緒
- 淺談Javascript單執行緒和事件迴圈JavaScript執行緒事件
- 深入理解Javascript單執行緒談Event LoopJavaScript執行緒OOP
- 多執行緒Demo學習(執行緒的同步,簡單的執行緒通訊)執行緒
- 細說 Android 下的多執行緒,學會了多執行緒,你就學會了壓榨CPU!Android執行緒
- 面試時說Redis是單執行緒的,被噴慘了!面試Redis執行緒
- javascript執行緒及與執行緒有關的效能優化JavaScript執行緒優化
- 說說Java執行緒間通訊Java執行緒
- 程式、執行緒詳細梳理執行緒
- android 關於關於子執行緒更新UI的一些事Android執行緒UI
- redis 單執行緒Redis執行緒
- 【QT】 Qt多執行緒的“那些事”QT執行緒
- iOS多執行緒的那些事兒iOS執行緒
- 簡單的執行緒池執行緒
- 執行緒和執行緒池的理解與java簡單例子執行緒Java單例
- 直播app開發中,關於執行緒需要了解的一些事APP執行緒
- 瀏覽器多執行緒和js單執行緒瀏覽器執行緒JS
- java多執行緒(超詳細!)Java執行緒
- Java多執行緒事務管理Java執行緒
- 多核和多執行緒那些事執行緒