JavaScript執行機制:event-loop

奮鬥的小小鳥發表於2018-08-08

JavaScript執行機制:event-loop

一、前言

JavaScript語言的特點是單執行緒,單執行緒只是指主執行緒,但不管是瀏覽器執行環境還是node執行環境,除了主執行緒還有其他的執行緒,如:網路執行緒,定時器觸發執行緒,事件觸發執行緒等等,這些執行緒是如何與主執行緒協同工作的呢?

二、任務佇列

這裡不得不提一個任務佇列的概念,js程式碼中所有程式碼分兩種:同步任務、非同步任務。

  • 所有同步任務都在主執行緒上執行,形成一個執行棧;

  • 主執行緒之外,還存在一個任務佇列,只要非同步任務有了執行結果,就在任務佇列中放置一個事件;

  • 一旦執行棧中所有同步任務執行完畢,系統就會讀取任務佇列,那些對應的非同步任務,於是結束等待狀態,進入執行棧,開始執行。

  • 主執行緒不斷重複上一步。

JavaScript執行機制:event-loop

三、巨集任務和微任務

瀏覽器和node中巨集任務和微任務是不同的,後面詳細說明。下面先來了解巨集任務和微任務的概念,巨集任務和微任務都是任務佇列裡面的,可以想象成任務佇列中其實有兩列,巨集任務是一列,微任務是一列。

1、巨集過任務

首先我們把任務佇列裡面的任務稱為task,瀏覽器為了能夠使得JS內部task與DOM任務能夠有序的執行,會在一個task執行結束後,在下一個 task 執行開始前,對頁面進行重新渲染 (task->渲染->task->...),巨集任務就是上述的 任務佇列裡的任務,嚴格按照時間順序壓棧和執行。如 setTimeOut、setInverter等,下圖為瀏覽器與node中的巨集任務。

JavaScript執行機制:event-loop

2、微任務

微任務通常來說就是需要在當前 task 執行結束後立即執行的任務,比如對一系列動作做出反饋,或或者是需要非同步的執行任務而又不需要分配一個新的 task,這樣便可以減小一點效能的開銷。只要執行棧中沒有其他的js程式碼正在執行且每個巨集任務執行完,微任務佇列會立即執行。如果在微任務執行期間微任務佇列加入了新的微任務,會將新的微任務加入佇列尾部,之後也會被執行。下圖為瀏覽器與node中的微任務。

JavaScript執行機制:event-loop

四、事件環(Event Loop)

主執行緒從任務佇列中讀取事件,這個過程是迴圈不斷的,這個執行機制被稱為Event Loop(事件環)

五、瀏覽器的事件環及對巨集任務微任務的執行機制

JavaScript執行機制:event-loop

主執行緒執行的時候,產生堆和棧,heap就是堆,堆裡面是存的是各種物件和函式,stack是棧,var a=1就儲存在棧內;dom事件,ajax請求,定時器等非同步操作的回撥會被放到任務佇列callback queue中,這個佇列時先進先出的順序,主執行緒執行完畢之後會依次執行callback queue中的任務,對應的非同步任務就會結束等待狀態,進入主執行緒被執行。

1、瀏覽器的巨集任務和微任務

當stack執行棧空的時候,立即執行microtask checkpoint ,microtask checkpoint 會檢查整個微任務佇列。所以就會執行微任務佇列中所有的任務,才會去執行第一個巨集任務,執行完第一個巨集任務後,又會去清空微任務佇列。

具體支援分類如下: macro-task: setTimeout, setInterval, setImmediate, I/O, UI rendering,mesageChannel micro-task: Promises(這裡指瀏覽器實現的原生 Promise),Object.observe, MutationObserver

我們用下面一段程式碼來檢驗一下是否理解瀏覽器事件環:

setTimeout(function(){
    console.log('setTimeout1')
    Promise.resolve().then(()=>{
        console.log('then1');

    })
},0)

Promise.resolve().then(()=>{
    console.log('then2');
    Promise.resolve().then(()=>{
        console.log('then3');
    })
    setTimeout(function(){
        console.log('setTimeout2')
    },0)
})
複製程式碼

執行結果是then2 then3 setTimeout1 then1 setTimeout2

首先程式碼裡面的setTimeout和Promise都是非同步任務,js從上到下執行程式碼,分別將這兩個非同步任務放到了巨集任務佇列和微任務佇列,執行棧此時為空先清空微任務佇列,所以先輸出了then2,然後在微任務佇列中有新增一個then3的promise任務,在巨集任務中新增了一個setTimeout2的定時器任務,所以接著執行下一個微任務,所以輸出了then3,開始執行第一個巨集任務,輸出setTimeout1,並且在微任務佇列又新增then1的promise任務,所以轉去執行微任務,輸出then1,再去執行一個巨集任務,就是之前放進去的setTimeout2.

六、node Event Loop

Node.js也是單執行緒的Event Loop,但是它的執行機制不同於瀏覽器環境。

JavaScript執行機制:event-loop
node的程式碼雖然也是執行在V8引擎上的,但是他還有一個libuv庫,專門處理非同步i/o操作的,libuv庫底層是靠多執行緒加阻塞I/O模擬實現的非同步i/o實現的。 根據上圖,Node.js的執行機制如下:

  • 1、我們寫的js程式碼會交給v8引擎進行解析;
  • 2、程式碼中可能會呼叫node api,node會交給libuv庫處理
  • 3、libuv通過阻塞i/o和多執行緒實現了非同步i/o
  • 4、通過事件驅動的方式,將結果放到事件佇列中,最終交給我們的應用。

Node在程式啟動時,便會建立一個類似於while(true)的迴圈,每執行一次迴圈體的過程被稱為tick,中文翻譯應該意為“滴答”,就像時鐘一樣,每滴答一下,就表示過去了1s。這個tick也有點這個意思,每迴圈一次,都表示本次tick結束,下次tick開始。每個tick開始之初,都會檢查是否有事件需要處理,如果有,就取出事件及關聯的callbak函式,如果存在有關聯的callback函式,就把事件的結果作為引數呼叫這個callback函式執行。如果不在有事件處理,就退出程式。

那麼在每個tick的過程中,如何判斷是否有事件需要處理,先要引入一個概念,叫做“觀察者”(watcher)。每一個事件迴圈都有一個或者多個觀察者,判斷是否有事件要處理的過程就是向這些觀察者詢問是否有需要處理的事件

Node的觀察者有這樣幾種:

  • 定時器觀察者:setTimeout,setInterval

  • idle觀察者:顧名思義,就是早已等在那裡的觀察者,以後會說到的process.nextTick就屬於這類

  • I/O觀察者:顧名思義,就是I/O相關觀察者,也就是I/O的回撥事件,如網路,檔案,資料庫I/O等

  • check觀察者:顧名思義,就是需要檢查的觀察者,後面會說到的setImmediate就屬於這類

事件迴圈是一個典型的生產者/消費者模型。非同步I/O,網路請求,setTimeout等都是典型的事件生產者,源源不斷的為Node提供不同型別的事件,這些事件被傳到對應的觀察者那裡,事件迴圈在每次tick時則從觀察者那裡取出事件並處理。

我們現在知道,JavaScript的非同步I/O呼叫過程中,回撥函式並不由我們開發者呼叫,事實上,在JavaScript發起呼叫到核心執行完I/O操作的過程中,存在一種中間產物,它叫做請求物件。這個請求物件會重新封裝回撥函式及引數,並做一些其他的處理。這個請求物件,會在非同步事件完成時被呼叫,取出回撥函式和引數,並傳入執行結果進行回撥。

組裝好請求物件,送入I/O執行緒池等待執行,實際上只是完成了非同步I/O的第一步;第二步則是非同步I/O被執行緒池處理結束後的回撥,也就是執行回撥。

JavaScript執行機制:event-loop
應該說,事件迴圈、觀察者、請求物件、I/O執行緒池,這四者共同組成了Node非同步I/O模型的基本要素。
JavaScript執行機制:event-loop

不同型別的觀察者,處理的優先順序不同,idle觀察者最先,I/O觀察者其次,check觀察者最後。

setTimeout()和setInterval()分別用於單次和多次執行任務,其建立的定時器會被插入到定時器觀察者內部的一個紅黑樹中。每次Tick執行時,會從該紅黑樹中迭代取出定時器物件,檢查是否超過定時時間,若超過則形成一個事件,其回撥函式馬上執行。

1、Node中巨集任務和微任務

執行機制:

1、初始化Event loop;

2、執行主程式碼,遇到非同步處理,就分配給對應的佇列,直到主程式碼執行完畢;

3、主程式碼中遇到所有的微任務,先去執行所有的nextTick(),然後執行其他的微任務,就是nextTick()在微任務裡面等級最高;

4、開始Event loop,就是上面的各個觀察者按順序檢查;

5、每次執行完畢一個觀察者佇列,轉下一個觀察者之前,會清空微任務佇列;

6、timer階段的定時器是不準的,在超過規定時間後,一旦得到執行機會就立即執行。

promise的then是微任務,process.nextTick()也是微任務,執行順序是nextTick大於then

Promise.resolve().then(()=>{
    console.log('then');
})
process.nextTick(()=>{
    console.log('nextTick');
})
複製程式碼

上面程式碼先輸出nextTick,後輸出then 我們可以利用process.nextTick是非同步任務,並且執行快的特點實現一些巧妙的解決辦法。

class A{
    constructor(){
        this.arr=[];
        process.nextTick(()=>{   
            console.log(this.arr);
        })
    }
    add(val){
        this.arr.push(val);
    }
}
let a=new A();
a.add('123');
a.add('456');
複製程式碼

假如我們這裡沒有加process.nextTick的時候,這裡列印出來的空陣列,因為new例項的時候,就執行了constructor了,但是加了這個process.nextTick後,裡面的程式碼會等同步程式碼先執行完畢後再執行,這是就已經拿到了資料。列印出['123','456']。

setTimeout(()=>{
    console.log('timeout1');
    process.nextTick(()=>{
        console.log('nextTick');
    })
},1000)
setTimeout(()=>{
    console.log('timeout2')
},1000)

複製程式碼

輸出:timeout1 timeout2 nextTick 先清空時間佇列,去執行下一個佇列之前,先去清空微任務佇列,也就是idle佇列,所以順序是這樣的

setTimeout(()=>{
    console.log('timeout1');
    process.nextTick(()=>{
        console.log('nextTick1');
    })
},1000)
process.nextTick(()=>{
    setTimeout(()=>{
        console.log('timeout2')
    },1000)
    console.log('nextTick2');
})
複製程式碼

上面程式碼的執行順序是不固定的,有時候

nextTick2 timeout1 nextTick1 timeout2

nextTick2 timeout1 timeout2 nextTick1

timer階段的定時器是不準的,他是在超過規定時間後,一旦得到執行機會就立即執行。

上面程式碼,先走idle佇列,先輸出nextTick2是固定的,這時候定時器佇列中放了兩個定時器了。肯定是限制性timeout1,因為他是先放進去的,但是第一個定時器執行完畢後,第二個定時器不一定到結束時間,所以就會去執行idle佇列,輸出nextTick1,之後再執行timeout2。

第一個定時器是1000毫秒,但是第二個定時器的結束時間可能是1000.8ms,因為process。nextTick也需要執行時間。第一個定時器執行完之後,可能還沒到1000.8ms,所以他就去清空了idle任務佇列,如果第一個定時器執行完畢後,已經到了1000.8ms,那麼肯定先執行第二個定時器。

所以定時器的時間在底層實現的時候是不一樣的。

又一個例子

setImmediate(()=>{
    console.log('setImmediate');
})
setTimeout(()=>{
    console.log('setTimeout');
},0);  //規範是4ms,這裡規定的時間0,在底層實現的時候不是0ms
複製程式碼

輸出:誰都可能先輸出

我們知道setImmediate是check檢查佇列中的,node執行棧執行時間如果是5ms,那麼走到時間佇列的時候,定時器時間就已經到了,所以先執行setTimeout,再執行setImmediate,但是也有可能node執行棧中程式碼執行了2ms,沒到4ms,就會先走setImmediate,再走時間佇列。

let fs=require('fs');
fs.readFile('./1.txt',function(){
    setImmediate(()=>{
        console.log('setImmediate');
    })
    setTimeout(()=>{
        console.log('setTimeout');
    },0);
})
複製程式碼

檔案讀取會走poll輪詢階段,得到回撥資訊後,下一階段是check階段,所以setImmediate永遠先走。執行結果順序永遠一樣

最後一個小測試

let fs=require('fs');
setImmediate(()=>{
    Promise.resolve().then(()=>{
        console.log('then1');
    })
},0)
Promise.resolve().then(()=>{
    console.log('then2');
})
fs.readFile('./1.txt',function(){
    process.nextTick(()=>{
        console.log('nextTick');
    })
    setImmediate(()=>{
        console.log('setImmediate');
    })
})
複製程式碼

答案在下面哦~

then2 then1 nextTick setImmediate

第一次肯定是執行微任務輸出then2,然後走poll階段檔案讀取,檔案讀取不是立刻執行回撥函式的,因為非同步任務需要時間等待讀取結果,執行棧也不是在等著他執行完畢的,直接執行check階段,執行setImmediate的回撥函式,裡面遇到了微任務,現在微任務佇列被新增進去一個,在執行fs的回撥之前,清空微任務佇列,所以輸出then1,接著執行fs的回撥,新增進去nextTick微任務,check階段的setImmediate,走完poll階段,肯定要去清空微任務佇列,輸出nextTick,再走check階段,輸出setImmediate。

相關文章