一、為什麼JavaScript是單線程? JavaScript的特點就是單線程,也就是說同一時間只能做一件事情,前面的任務沒做完,後面的任務只能處於等待狀態,(這就跟生活中的例子:排隊買票一樣,一個一個排隊按順序來)。這就產生了一個問題:為什麼JavaScript不能是多線程的呢?多線程可以提高多核 ...
一、為什麼JavaScript是單線程?
JavaScript的特點就是單線程,也就是說同一時間只能做一件事情,前面的任務沒做完,後面的任務只能處於等待狀態,(這就跟生活中的例子:排隊買票一樣,一個一個排隊按順序來)。這就產生了一個問題:為什麼JavaScript不能是多線程的呢?多線程可以提高多核CPU的利用率,從而提高計算能力啊,這與瀏覽器的用途是息息相關的,也可以說是瀏覽器的用途直接決定了JavaScript只能是單線程。
假如說JavaScript是多線程,我們可以試想一下,如果某一時刻一個線程給某個DOM節點添加內容,另一個線程在刪除這個DOM節點,這個時候瀏覽器聽那一個線程的?這樣會讓程式變得非常複雜,而且完全沒有必要。同時這也說明JavaScript的單線程與它的用途有關,瀏覽器的主要用途是操作DOM、與用戶完成互動、添加一些交互行為,如果是多線程將會產生非常複雜的問題。所以JS從一開始起為了避免複雜的問題產生,JavaScript就是單線程的,單線程已經成為JavaScript的核心,而且今後也不會改變。
在多核CPU出現以來,這給單線程帶來了非常的不便,不能夠充分發揮CPU的作用。為了提高現代多核CPU的計算能力,HTML5提出了Web Worker標準,允許JavaScript腳本創建多個線程,但是,這個本質並沒有改變JavaScript單線程的本質。
什麼是單線程與多線程,這個問題值得我們思考?
單線程:一個進程中只有一個線程在執行,如果把進程比作一間工廠,線程比作一個工人,所謂單線程就是這個工廠中只有一個工人在工作
多線程:一個進程中同時有多個線程在執行,好比這個工廠中有多個工人在一起協同工作
進程:CPU資源分配的最小單位,一個程式要想執行就需要CPU給這個程式分配相應的資源出來,用完之後再收回去。例如:記憶體的占用,CPU給這個程式提供相應的計算能力。
線程:CPU調度的最小單位,一個程式可以理解為有N個任務組成的集合,某一時刻執行那個任務就需要線程的調度
圖解:
註意點:有圖可知:工廠空間是共用的,說明一個進程中可以存在一個或多個線程,工廠資源是共用的,說明一個進程的記憶體空間可以被該進程中所有線程共用,多個進程之間相互獨立,例如:聽音樂的時候,不會影響到敲代碼,歌詞是不會出現在代碼編輯器中的。
多進程:在同一時間里,同一臺電腦系統中允許兩個或兩個以上的進程處於運行狀態,其實現在的電腦開機狀態下就是多個進程在運行,打開任務管理器就可以看到那些進程在運行。多進程帶來的好處是非常明顯的,可以充分利用CPU的資源,而且電腦同時可以乾很多件事還互相不影響。例如:在使用編輯器寫代碼的時候,還可以使用QQ音樂聽歌。編輯器和QQ音樂之間完全不會影響。以Chrome為例:每打開一個tab就就相當於開啟了一個進程,每個tab之間是完全不會影響。
上面提到HTML5的Web Worker可以改善單線程的不便,瞭解Web Worker需要註意以下幾點:
-
同源限制
分配給Worker子線程運行的腳本文件,必須與主線程的腳本文件同源。
-
DOM限制
Worker線程與主線程不一樣,無法讀取主線程所在網頁的DOM對象,無法使用 document 、 window 、 parent 等對象,但是可以使用 Navigator 對象、 location 對象
-
全局對象限制
Worker 的全局對象 WorkerGlobalScope ,不同於網頁的全局對象 Window ,很多介面拿不到,理論上Worker不能使用 console.log
-
通信聯繫
Worker 線程和主線程不在同一個上下文環境,它們不能直接通信,必須通過消息完成。
-
腳本限制
Worker 線程不能執行alert()
方法和confirm()
方法,但可以使用 XMLHttpRequest 對象發出 AJAX 請求。
-
文件限制
Worker 線程無法讀取本地文件,即不能打開本機的文件系統(file://
),它所載入的腳本,必須來自網路。
二、瀏覽器的渲染流程
在理解渲染原理之前,先瞭解瀏覽器內核構成是非常有必要的,具體內容看下麵:
瀏覽器工作方式:瀏覽器內核是通過取得頁面內容、整理信息(應用CSS)、計算和組合最終輸出可視化的圖像結果。
瀏覽器內核是多線程的,在內核控制下各線程相互配合以保持同步,一個瀏覽器通常由以下常駐線程組成:
- GUI渲染線程
- JavaScript引擎線程
- 定時觸發器線程
- 事件觸發線程
- 非同步Http請求線程
GUI渲染線程:
-
- 主要負責頁面的渲染,解析HTML、CSS,構建DOM樹,佈局和繪製等。
- 當界面需要重繪或者由於某種操作引發迴流時,將執行該線程。
- 該線程與JS引擎線程互斥,當執行JS引擎線程時,GUI渲染會被掛起,當任務隊列空閑時,主線程才會去執行GUI渲染,這也是為什麼js解析腳本的時候會阻塞界面的渲染。
JS引擎線程
-
- 該線程當然是主要負責處理 JavaScript腳本,執行代碼。
- 也是主要負責執行準備好待執行的事件(非同步事件),即定時器計數結束,或者非同步請求成功並正確返回時,I/O讀取文件等等將依次進入任務隊列,等待 JS引擎線程的執行。
- 該線程與 GUI渲染線程互斥,當 JS引擎線程執行 JavaScript腳本時間過長,將導致頁面渲染的阻塞。
定時觸發器線程
-
- 負責執行非同步定時器一類函數的線程,如: setTimeout,setInterval。
- 主線程依次執行代碼時,遇到定時器,會將定時器交給該線程處理,當計數完畢後,事件觸發線程會將計數完畢後的事件加入到任務隊列的尾部,等待JS引擎線程執行。
事件觸發線程
-
- 主要負責將準備好的事件交給 JS引擎線程執行。例如:setTimeout定時器計數結束, ajax等非同步請求成功並觸發回調函數,或者用戶觸發點擊事件時,該線程會將相應的事件依次加入到任務隊列的隊尾,等待 JS引擎線程的執行。
非同步http請求線程
-
- 負責執行非同步請求一類函數的線程,如: Promise,axios,ajax等。
- 主線程依次執行代碼時,遇到非同步請求,會將函數交給該線程處理,當監聽到狀態碼變更,如果有回調函數,事件觸發線程會將回調函數加入到任務隊列的尾部,等待JS引擎線程執行。
三、任務隊列
之前初步瞭解了瀏覽器的內核構成,提到了很多非同步事件,那非同步事件如何執行呢?這就跟任務隊列有關了。現在來談談什麼是任務隊列,為什麼需要任務隊列?
單線程也就意味著所有任務都需要排隊,只有在前一個任務結束之後,才會執行後一個任務,否則後面的任務只能處於等待狀態。如果某個任務執行需要很長的時間,後面的任務就都需要等待著,這會造成非常大的交互影響,給用戶有一種頁面載入卡頓的現象,影響用戶體驗!
如果等待是因為CPU忙不過來也可以理解,大多數情況並不是CPU忙不過來的原因,而是文件I/O的讀取,網路請求、滑鼠點擊事件等這些操作需要花費非常長的時間,只有等這些操作返回結果之後才能往下執行。
為瞭解決這個問題,JavaScript的開發者也很快的意識到,腳本文件的執行,完全可以不用管那些非常耗時的I/O設備,非同步請求,完全可以掛起等待中的任務,執行排在後面的位置,等I/O操作返回結果之後,再回過頭執行掛起的任務。
根據上面的理解可以將任務分為兩種:同步任務(synchronize task)、非同步任務(asynchronize task)。
-
- 同步任務:在主線程(JS引擎線程)進行排隊的任務,只有在前一個任務結束之後,才開始執行後一個任務。
- 非同步任務:不進入主線程,進入任務隊列(task queue)的任務,只有在任務隊列通知主線程任務隊列中的某個任務可以進入主線程了,該任務才會進入主線程執行,也可以將非同步任務理解為:有註冊函數和回調兩部分組成,註冊函數負責發起非同步過程,回調函數用來負責處理的結果。
瀏覽器的運行機制
-
- 所有同步任務都在主線程上執行,形成一個執行棧(execution context stack)
- 主線程之外,還存在一個"任務隊列"(task queue)。只要非同步任務有了運行結果,就在"任務隊列"之中放置一個事件。
- 一旦"執行棧"中的所有同步任務執行完畢,系統就會去"任務隊列"找找看,看看裡面有哪些事件,那些對應的非同步任務,已經結束等待狀態,得到的相應的結果,得到結果的任務結束等待狀態立即進入執行棧,開始執行。
- 主線程不斷重覆上面的第三步的操作,也就形成了一個事件環(Event Loop)
四、事件和回調函數
任務隊列是一個事件隊列(也可以理解為:消息隊列),非同步操作如:I/O設備完成一項任務,就在任務隊列中添加一個事件,表示相關的非同步任務可以進入"執行棧"了。主線程讀取"任務隊列",就是讀取裡面的事件。
任務隊列中的事件,除了IO設備的事件以外,還包括一些用戶產生的事件(比如滑鼠點擊、頁面滾動等等)。只要指定過回調函數,這些事件發生時就會進入"任務隊列,等待主線程讀取。
所謂"回調函數"(callback),就是那些會被主線程掛起來的實現該任務的代碼,主線程乾開始運行的時候是不會執行的。非同步任務必須指定回調函數,當主線程開始執行非同步任務,就是執行對應的回調函數代碼。
任務隊列是一個先進先出的數據結構,排在前面的事件,優先被主線程讀取。主線程的讀取過程基本上是自動的,只要執行棧一清空,"任務隊列"上第一位的事件就自動進入主線程。但是,由於存在後文提到的"定時器功能“”,主線程首先要檢查一下執行時間,某些事件只有到了規定的時間,才能返回主線程。
五、瀏覽器中的事件迴圈(Event Loop)
主線程從"任務隊列"中讀取事件,這個過程是迴圈不斷的,所以整個的這種運行機制又稱為Event Loop(事件迴圈)
為了更好的理解事件迴圈,請看下麵的圖:
執行棧中的代碼(同步任務),總是在讀取"任務隊列"(非同步任務)之前執行。
Micro Task 和 Macro Task:
瀏覽器端事件迴圈的非同步隊列有兩種:macro(巨集任務)隊列、micro(微任務)隊列,macro隊列可以有多個,micro隊列只能有一個。
常見的Macro-Task: setTimeout、setInterval、script、I/O操作、UI渲染
常見的Micro-Task: new Promise.then() MutationObserve
現在再拿之前的圖來解釋任務執行的流程,進一步加深理解:
圖解:
執行流程:
- 一開始執行棧為空,執行棧可以理解為“先進後出”的棧結構,micro任務隊列為空,macro任務隊列中只有一個script 腳本,整體的js代碼。
- 全局上下文(script整體代碼)被推入執行棧,代碼開始執行,判斷是同步任務還是非同步任務,如果是非同步任務通過對一些介面的調用可以產生新的macro任務和micro任務,同步代碼執行完畢,script腳本被移除macro任務隊列,這個過程可以理解為macro-task的執行和出隊。
- 上一步我們出隊的是一個 macro-task,這一步我們處理的是 micro-task。但需要註意的是:當 macro-task 出隊時,任務是一個一個執行的;而 micro-task 出隊時,任務是一隊一隊執行的。因此,我們處理 micro 隊列這一步,會逐個執行隊列中的任務並把它出隊,直到隊列被清空。
- 執行渲染操作,更新界面。
- 檢查是否存在 Web worker 任務,如果有,則對其進行處理。
- 上述過程迴圈往複,直到兩個隊列都清空。
總結一下:當某個巨集任務執行完後,會查看是否有微任務隊列。如果有,先執行微任務隊列中的所有任務,如果沒有,會讀取巨集任務隊列中排在最前的任務,執行巨集任務的過程中,遇到微任務,依次加入微任務隊列。棧空後,再次讀取微任務隊列里的任務,依次類推。
代碼演示:
1 <script> 2 Promise.resolve().then(() => { // Promise.then()是屬於micro-task 3 console.log('micro-task1'); 4 setTimeout(() => { // setTimeout是屬於macro-task 5 console.log('macro-task1'); 6 }, 0) 7 }) 8 setTimeout(() => { 9 console.log('macro-task2'); 10 Promise.resolve().then(() => { 11 console.log('micro-task2'); 12 }) 13 console.log('macro-task3'); 14 }) 15 console.log('同步任務'); 16 </script>
運行結果為:同步任務--->micro-task1 --->macro-task2--->macro-task3--->micro-task2--->macro-task1
1.代碼開始執行,判斷是同步任務還是非同步任務,檢測到有同步任務(屬於巨集任務)存在,先輸出“同步任務”
2. 同步任務執行完去查看是否有微任務隊列存在,上面代碼的微任務隊列為:promise.resolve().then(),開始執行微任務,輸出micro-task1
3.執行微任務的過程中發現有巨集任務setTimeout()存在,將其添加到巨集任務隊列,微任務執行完畢開始執行巨集任務,由於macro-task2所在的巨集任務早於macro-task1,因此先執行macro-task2所在的巨集任務,輸出macro-task2
4.輸出macro-task2之後發現存在微任務micro-task2,將其添加到微任務隊列,接著輸出macro-task3
5.巨集任務執行完畢,接著開始執行微任務輸出:micro-task2
6.微任務執行完畢,接著執行macro-task1所在的巨集任務,輸出:macro-task1
7.執行完畢巨集任務,此時的macro-task隊列和micro-task隊列已空,程式停止。
六、Node.js中的事件迴圈
Node.js 不是一門語言也不是框架,它只是基於 Google V8 引擎的 JavaScript 運行時環境,同時結合 Libuv 擴展了 JavaScript 功能,使之支持 io、fs 等只有語言才有的特性,使得 JavaScript 能夠同時具有 DOM 操作(瀏覽器)和 I/O、文件讀寫、操作資料庫(伺服器端)等能力,是目前最簡單的全棧式語言。
目前Node.js在大部分領域都占有一席之地,尤其是 I/O 密集型的,比如 Web 開發,微服務,前端構建等。不少大型網站都是使用 Node.js 作為後臺開發語言的,用的最多的就是使用Node.js做前端渲染和架構優化,比如 淘寶 雙十一、去哪兒網的 PC 端核心業務等。另外,有不少知名的前端庫也是使用 Node.js 開發的,比如,Webpack 是一個強大的打包器,React/Vue 是成熟的前端組件化框架。
Node.js通常被用來開發低延遲的網路應用,也就是那些需要在伺服器端環境和前端實時收集和交換數據的應用(API、即時聊天、微服務)。阿裡巴巴、騰訊、Qunar、百度、PayPal、道瓊斯、沃爾瑪和 LinkedIn 都採用了 Node.js 框架搭建應用。
Node.js 編寫的包管理器 npm 已成為開源包管理了領域最好的生態,直接到2017年10月份,有模塊超過47萬,每周下載量超過32億次,每個月有超過700萬開發者使用npm。
當然了,Node.js 也有一些缺點。Node.js 經常被人們吐槽的一點就是:回調太多難於控制(俗稱回調地獄)。但是,目前非同步流程技術已經取得了非常不錯的進步,從Callback、Promise 到 Async函數,可以輕鬆的滿足所有開發需求。
至於其他的特性這裡附一篇很值得一看的文檔:https://cnodejs.org/topic/5ab3166be7b166bb7b9eccf7
Node中的事件迴圈機制完全和瀏覽器的是不同的,Node.js採用V8作為js的解析引擎,而I/O處理方面使用了自己設計的libuv,libuv是一個基於事件驅動的跨平臺抽象層,封裝了不同操作系統一些底層特性,對外提供統一的API,事件迴圈機制也是它裡面的實現。
Node.js的運行機制如下:
1.V8引擎解析JavaScript腳本
2.解析後的代碼,調用Node API
3.libuv庫負責Node API的執行。它將不同的任務分配給不同的線程,形成一個Event Loop(事件迴圈),以非同步的方式將任務的執行結果返回給V8引擎
4.V8引擎再將結果返回給用戶
EventLoop的六個階段
libuv引擎中的事件迴圈分為 6 個階段,它們會按照順序反覆運行。每當進入某一個階段的時候,都會從對應的回調隊列中取出函數去執行。當隊列為空或者執行的回調函數數量到達系統設定的閾值,就會進入下一階段。
從上面的圖片可以大致看出node的事件迴圈的順序為:外部輸入階段--->輪詢階段(poll)--->檢查階段--->關閉事件回調階段(close callback)--->定時器檢查階段(timer)--->I/O回調階段(I/O callback)--->閑置階段(idle,prepare)--->輪詢階段 ,按照上面的順序迴圈反覆執行。
timer階段:這個階段執行setTimeout或setInterval回調,並且是有poll階段控制的。同樣在Node.js中定時器指定的時間也不是非常準確,只能是儘快執行。
I/O callbacks階段:處理一些上一輪迴圈中少數未執行的I/O回調
idle,prepare階段:進node內部使用
poll階段:獲取新的I/O事件,適當的條件下node將阻塞在這裡
check階段:setImmediate()回調函數的執行
close callbacks階段:執行socket的close事件回調。
註意點:上面的6個階段都是不包括process.nextTick(),在日常的開發中我們使用最多的就是:timer poll check這三個階段,絕大多數的非同步操作都是在這三個階段 完成的。
timer階段:這個階段執行setTimeout或setInterval回調,並且是有poll階段控制的。同樣在Node.js中定時器指定的時間也不是非常準確,只能是儘快執行。
poll階段:
1.這個階段是至關重要的階段,系統會做兩件事情。一是回到timer階段執行回調,二是執行I/O回調
2.如果在進入該階段的時候沒有設置timer,會發生兩件事情:
2.1.如果poll隊列不為空,會遍歷回調隊列並同步執行,直到隊列為空或達到系統限制
2.2.如果poll階段為空,會發生下麵兩件事:
2.2.1:如果有setImmediate()回調需要執行,poll階段會停止,進入到check階段執行回調
2.2.2:如果沒有setImmediate()回調需要執行,會等到回調被加入隊列中並立即執行回調,這裡同樣有個超時限制防止一致等待下去
3.如果設置了timer且poll隊列為空,則會判斷是否有timer超時,如果有的話回到timer階段執行回調。
check階段:setImmediate()的回調會被加入到check隊列中,從event loop的階段圖可以看出,check階段的執行是在poll階段之後的。
microTask和macroTask:
1.常見的micro-task: process.nextTick() Promise.then()
2.常見的macro-task: setTimeout、setIntevaral、setImmeidate、script、I/O操作
3.先分析一段代碼示例:
console.log('start'); setTimeout(() => { console.log('time1'); Promise.resolve().then(() => { console.log('promise1'); }) }, 0) setTimeout(() => { console.log('time2'); Promise.resolve().then(() => { console.log('promsie2'); }) }, 0) Promise.resolve().then(() => { console.log('promise3'); }) console.log('end'); // Node中的列印結果:start--->end--->promise3--->time1--->timer2--->promise1--->promise2 // 瀏覽器中的列印結果:start--->end--->promise3--->time1--->promise1--->time2--->promise2
4.node列印結果分析:
1.先執行同步任務(執行macro-task),列印輸出:start end
2.執行micro-task任務:輸出promise3,這一點跟瀏覽器的機制差不多
3.進入timer階段執行setTimeout(),列印輸入time1,並將promise1放入micro-task隊列中,執行timer2列印time2,並且將promise2放入到micro隊列中,這一點跟瀏覽器的差別比較大,timer階段有幾個setTimeout/setIntever就執行幾個,而不像瀏覽器一樣執行完一個macro-task之後立即執行一個micro-task
5.setTimeout()和setImmediate()非常相似,區別主要在調用的實際不同
5.1:setTimeout()設置在poll階段為空閑時且定時時間到後執行,但它們在timer階段執行
5.2:setImmediate()設置在poll階段完成時執行,即check階段執行
5.3:實例分析:
1 setImmediate(() => { 2 console.log('setImmediate'); 3 }) 4 setTimeout(() => { 5 console.log('setTimeout'); 6 }, 0)
5.4:上面的代碼執行,返回結果是不確定的,有可能先執行setTimeout() ,有可能先執行setImmediate(),首先 setTimeout(fn, 0) === setTimeout(fn, 1),這是由源碼決定的 進入事件迴圈也是需要成本的,如果在準備時候花費了大於 1ms 的時間,那麼在 timer 階段就會直接執行 setTimeout 回調,如果準備時間花費小於 1ms,那麼就是 setImmediate 回調先執行了,可以把setTimeout的第二個參數設置為:1 ,2,3,4....看看運行結果
5.5:當二者寫在I/O讀取操作的回調中時,總是先執行setImmediate(),因為I/O回調是寫在poll階段,當回調執行完畢後隊列為空,發現存在 setImmediate 回調,所以就直接跳轉到 check 階段去執行回調了
1 const fs = require('fs') 2 fs.readFile(__filename, (err, data) => { 3 setTimeout(() => { 4 console.log('setTimeout'); 5 }, 0) 6 setImmediate(() => { 7 console.log('setImmediate'); 8 }) 9 }) 10 console.log(__filename); // 列印當前文件所在的路徑
6.process.nextTick():這個函數其實是獨立於 Event Loop 之外的,它有一個自己的隊列,當每個階段完成後,如果存在 nextTick 隊列,就會清空隊列中的所有回調函數,並且優先於其他 microtask 執行。也就是說它指定的任務隊列,都是在所有非同步任務之前發生
1 console.log('start') 2 setTimeout(() => { 3 console.log('time1'); 4 Promise.resolve().then(() => { 5 console.log('promise1'); 6 }) 7 }) 8 Promise.resolve().then(() => { 9 console.log('promise2'); 10 }) 11 process.nextTick(() => { 12 console.log('nextTick1'); 13 process.nextTick(() => { 14 console.log('nextTick2'); 15 process.nextTick(() => { 16 console.log('nextTick3'); 17 }) 18 }) 19 }) 20 console.log('end'); 21 // 運行結果:start --->end --->nextTick1 --->nextTick2 --->nextTick3 --->promise2 --->time1 --->promise1
七、瀏覽器中Event Loop和Node.js中的差別
重點:瀏覽器環境下,microtask的任務隊列是每個macrotask執行完之後執行。而在Node.js中,microtask會在事件迴圈的各個階段之間執行,也就是一個階段執行完畢,就會去執行microtask隊列的任務。
圖解:
代碼演示:
console.log('start'); setTimeout(() => { console.log('timer1'); Promise.resolve().then(() => { console.log('promise1'); }); }) setTimeout(() => { console.log('timer2'); Promise.resolve().then(() => { console.log('promise2'); }) }, 0) console.log('end'); // 瀏覽器模式下輸出:start ---> end ---> timer1 ---> promise1 ---> timer2 ---> promise2 // node環境下麵:start ---> end ---> timer1 ---> timer2 ---> promsie1 ---> promise2
八、總結
瀏覽器和node環境下、micro-task隊列的執行時機不同:
瀏覽器端,微任務在事件迴圈的各個階段執行。
Node端,微任務是在macro-task執行完畢執行。