不要混淆nodejs和瀏覽器中的event loop

yiliwei發表於2018-05-25

1. 什麼是 Event Loop?

"Event Loop是一個程式結構,用於等待和傳送訊息和事件。
(a programming construct that waits for and dispatches events or messages in a program.)"
複製程式碼

舉一個大家都熟知的栗子, 這樣更能客觀的理解。

不要混淆nodejs和瀏覽器中的event loop

大家都知道深夜食堂吧。廚師就一個人(服務端 Server)。最多再來一個服務生( 排程員 Event Loop )。晚上吃飯的客人(客戶端 Client)很多。

1. 客人向服務生點完菜,就幹自己事情,不用一直等著服務生, 服務生把一個人點的選單送到廚師,
   又去服務新的客人...
2. 廚師(服務端)只負責做客人們點的菜。
3. 服務生(排程員)不停的看廚師,一旦廚師做好菜了,按照標號送到相應的 客人(客戶端)座位上
複製程式碼

不要混淆nodejs和瀏覽器中的event loop
假設我們把 廚師 和 服務生 都比作 服務端的執行緒的話, 服務生執行緒 為 主執行緒, 廚師執行緒 為 訊息執行緒。 客人每點一個菜。服務生就向廚師發出一個訊息。並保留該訊息的“標識”(回撥函式)用來接收廚師炒好的菜,並把菜送到相應的客人手中。

2. 同步模式

不管店裡的客人多少,也不管每一份菜需要多久的時間做好。就只有廚師這一個人忙活。廚師一次只能服務一個客人。那這樣的服務模式效率就比較低了。中途等待的時間比較長。 筆者認為 同步模式 就是沒有 “服務生執行緒”, 廚師執行緒升級為 主執行緒

1. 第一個客人點了一份 "讀取檔案" ,  炒好一份 "讀取檔案"  需要花費 1 分鐘
2. 必須等第一個客人的菜炒好後,第二個客人才能點,並且點了一份 "讀取資料庫",
   炒好一份 "讀取資料庫" 需要花費 2 分鐘
3. 第三個客人點了一份 ...
複製程式碼

不要混淆nodejs和瀏覽器中的event loop

從圖中可以看出紅色部份都是等待時間(或者是阻塞時間), 相當浪費資源。

假設我們現在只知道一種程式碼的執行方式 "同步執行", 也就是程式碼從上到下 按順序執行。如果遇到 setTimeout , 也先這樣理解。(實際上setTimeout 本身是立即執行的,只是回撥函式非同步執行)

console.log(1);                         //執行順序1
setTimeout(function(){}, 1000);         //執行順序2
console.log(2);                         //執行順序3
複製程式碼

3. 非同步模式

圖表更能直觀的反應這個概念:

不要混淆nodejs和瀏覽器中的event loop

主執行緒 不停的接收請求 request 和 響應請求 response, 真正處理任務的被 訊息執行緒 event loop 安排其他相應的程式去執行,並接收相應的相應程式返回的訊息。然後 reponse 給客戶端。

1. 主執行緒乾的事情非常簡單,即 接收請求,響應請求, 因此可以能夠處理更多的請求。而不用等待。
2. 訊息執行緒維護請求,並把真正要做的事情交給對應的程式,並接收對應程式的回撥訊息,返回給 主執行緒
複製程式碼

4. 幾種呼叫模式的組合

  • 同步阻塞

你跟你的女神表白,你女神立即回覆你,而你也一直再等女神的回覆

  • 同步不阻塞

你跟你的女神表白, 你表白後,沒有等女神來得及回覆,你去忙你自己的事情了。你的女神立即回覆了你

  • 非同步阻塞

你跟你的女神表白, 你女神沒有立即回覆你,說要考慮考慮,過幾天答覆你,而你也一直再等女神的回覆

  • 非同步不阻塞

你跟你的女神表白,你表白後, 沒有等女神的回覆。你去忙你自己的事情了,女神也說她要考慮考慮,過幾天再回復你

阻塞非阻塞 是指呼叫者(表白的那個人) 同步非同步 是指被呼叫者 (被表白的那個人)

同步非同步取決於被呼叫者,阻塞非阻塞取決於呼叫者

5. 幾個需要知曉的概念

  • 巨集任務 setTimeout , setInterval, setImmediate, I / O 操作

  • 微任務 process.nextTick , 原生Promise (有些實現的Promise將then方法放到了巨集任務中), Mutation Observer

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

不要混淆nodejs和瀏覽器中的event loop

process.nextTick 優先於 promise.then 方法執行

6. 瀏覽器中的Event Loop

  • 瀏覽器中js是單執行緒執行的。筆者稱其為主執行緒, 主執行緒在執行過程中會產生 堆(heap)和 棧(stack), 所有同步任務都是在 棧中執行。
function one() {
  let a = 1;
  two();
  function two() {
    console.log(a);
    let b = 2;
    function three() {
      //debugger;
      console.log(b);
    }
    three();
  }
}
one();
複製程式碼

毫無疑問的是,上面這段程式碼執行的結果為:

1
2
複製程式碼

在棧中都是以同步任務的方式存在:

再來看下面這段程式碼:

console.log(1);
setTimeout(function(){
  console.log(2);
})
console.log(3);
複製程式碼

執行結果為:

1
3
2
複製程式碼

那到底是怎樣執行的呢?

不要混淆nodejs和瀏覽器中的event loop
順便提一句:文章最開始就說 setTimeout函式本身的執行時機和其回撥函式執行的時機是不一樣的。

//巨集任務
setTimeout(function(){
  console.log(2);
})

//微任務
let p = new Promise((resolve, reject) => {
  resolve(3);
});
p.then((data) => {
  console.log(data);
}, (err)=>{

})

console.log(4);
複製程式碼

執行結果為:

1
4
3
2
複製程式碼

從這個可以看到。微任務訊息佇列的執行的優先於巨集任務的訊息佇列.

console.log(1);

//巨集任務
setTimeout(function(){
  console.log(2);
})

//微任務
let p = new Promise((resolve, reject) => {
  resolve(4);
});
p.then((data) => {
  console.log(data);
}, (err)=>{

})

setTimeout(function(){
  console.log(3);
})


console.log(5);

複製程式碼

執行結果為:

1
5
4
2
3
複製程式碼

每一次事件迴圈機制過程中,會將當前巨集任務 或者 微任務訊息佇列中的任務都執行完成。然後再之前其他佇列。

  • 對於不能進入主執行緒執行的程式碼,筆者稱其為非同步任務, 這部分任務會進去訊息佇列(callback queue), 通過 事件迴圈機制 (event loop) 不停呼叫,進入 棧中進行執行。前提是棧中當前的所有任務(同步任務)都已經執行完成。

不要混淆nodejs和瀏覽器中的event loop

  • 從圖中,還可以得出這樣的結論: 非同步任務是通過 WebAPIs 的方式存入 訊息佇列。
  • 上述過程總是在迴圈執行。

7. Node中的Event Loop

我們先來看看node是怎樣執行的:

不要混淆nodejs和瀏覽器中的event loop

  • js原始碼首先交給node 中的v8引擎進行編譯
  • 編譯好的js程式碼通過node api 交給 libuv庫 處理
  • libuv庫通過阻塞I/O和非同步的方式,為每一個js任務(檔案讀取等等)建立一個單獨的執行緒,形成多執行緒
  • 通過Event Loop的方式非同步的返回每一個任務執行的結果,然後返回給V8引擎,並反饋給使用者

Event Loop 在整個Node 執行機制中佔據著舉足輕重的地位。是其核心。

不要混淆nodejs和瀏覽器中的event loop
(Event Loop 不同階段)

每個階段都有一個執行回撥的FIFO佇列。 雖然每個階段都有其特定的方式,但通常情況下,當事件迴圈進入給定階段時,它將執行特定於該階段的任何操作, 然後在該階段的佇列中執行回撥,直到佇列耗盡或回撥的最大數量 已執行。 當佇列耗盡或達到回撥限制時,事件迴圈將移至下一個階段,依此類推。

timers:此階段執行由setTimeout()和setInterval()排程的回撥。
pending callbacks:執行I / O回撥,推遲到下一個迴圈迭代。
idle,prepare:只在內部使用。
poll:檢索新的I / O事件; 執行I / O相關的回撥函式;  適當時節點將在此處阻塞。
check:setImmediate()回撥在這裡被呼叫。
close backbacks:一些關閉回撥,例如 socket.on('close',...)。
複製程式碼

timers階段

需要注意的是:

const fs = require('fs');

function someAsyncOperation(callback) {
  //假設需要95ms需要執行完成
  fs.readFile('/path/to/file', callback);
}

const timeoutScheduled = Date.now();

//定義100ms後執行
setTimeout(() => {
  const delay = Date.now() - timeoutScheduled;
  console.log(`${delay}ms have passed since I was scheduled`);
}, 100);


// 執行someAsyncOperation需要消耗95ms執行
someAsyncOperation(() => {
  const startCallback = Date.now();

  // do something that will take 10ms...
  while (Date.now() - startCallback < 10) {
    // do nothing
  }
});
複製程式碼

分析上述程式碼:

  • someAsyncOperation方法時同步程式碼,先在棧中執行
  • someAsyncOperation 中包含非同步I/O, 需要花費95ms執行,加上 while的10ms, 因此需要105ms
  • setTimeout 雖然定義的是在100ms後執行, 但由於 第一次輪詢是到了 poll 階段, 所以 setTimeout 需要等到第二輪事件輪詢是執行。因此是在 105ms後執行

pending callbacks階段

此階段為某些系統操作(如TCP錯誤型別)執行回撥。例如,
如果嘗試連線時TCP套接字收到ECONNREFUSED,則某些* nix系統要等待報告錯誤。這將排隊等候在待處理的回撥階段執行。
複製程式碼

poll階段

1.計算應該阻塞和輪詢I / O的時間
2.處理輪詢佇列中的事件。
複製程式碼

當事件迴圈進入poll階段並且沒有計時器時,會發生以下兩件事之一:

1. 如果輪詢佇列不為空,則事件迴圈將遍歷其回撥佇列,同步執行它們,直到佇列耗盡或達到系統相關硬限制。
2. 如果輪詢佇列為空,則會發生以下兩件事之一:
   2.1 如果指令碼已通過setImmediate()進行排程,則事件迴圈將結束輪詢階段並繼續執行(check階段)檢查階段以執行這些預定指令碼。
   2.2 如果指令碼沒有通過setImmediate()進行排程,則事件迴圈將等待將回撥新增到佇列中,然後立即執行它們。
複製程式碼

一旦輪詢佇列為空,事件迴圈將檢查已達到時間閾值的定時器。如果一個或多個定時器準備就緒,則事件迴圈將回退到定時器階段以執行這些定時器的回撥。

// poll的下一個階段時check
// 有check階段就會走到check中
let fs = require('fs');
fs.readFile('./1.txt',function () {  //輪詢佇列已經執行完成,為空,即2.1中描述的
  setTimeout(() => {
    console.log('setTimeout')
  }, 0);
  setImmediate(() => {
    console.log('setImmediate')
  });
});
複製程式碼

上面這段程式碼執行的過程階段為:

不要混淆nodejs和瀏覽器中的event loop

check階段

setImmediate()實際上是一個特殊的定時器,它在事件迴圈的一個單獨的階段中執行。它使用libuv API來排程回撥,以在輪詢(poll)階段完成後執行。

close callback階段

如果套接字socks或控制程式碼突然關閉(例如socket.destroy()),則在此階段將發出'close'事件。 否則它將通過process.nextTick()觸發事件。
複製程式碼

8. setImmediate 與 setTimeout

setImmediate()用於在當前輪詢階段完成後執行指令碼。
setTimeout()計劃指令碼在經過最小閾值(以毫秒為單位)後執行。
複製程式碼

定時器執行的順序取決於它們被呼叫的上下文。 如果兩者都是在主模組內呼叫的,那麼時序將受到程式效能的限制(可能會受到計算機上執行的其他應用程式的影響)。

簡言之: setTimediate 和 setTimeout 的執行順序不確定。

// setTimeout和setImmediate順序是不固定,看node準備時間
 setTimeout(function () {
   console.log('setTimeout')
 },0);

 setImmediate(function () {
   console.log('setImmediate')
 });

複製程式碼

輸出的結果可能是這樣

setTimeout
setImmediate
複製程式碼

也有可能是這樣

setImmediate
setTimeout
複製程式碼

But, 如果在I / O週期內移動這兩個呼叫,則立即回撥總是首先執行, 可以爬樓參考 poll階段的介紹。

使用setImmediate()的主要優點是,如果在I / O週期內進行排程,將始終在任何計時器之前執行setImmediate(),而不管有多少個計時器。

9. process.nextTick

為什麼要用process.nextTick

允許使用者處理錯誤,清理任何不需要的資源,或者可能在事件迴圈繼續之前再次嘗試請求。 有時需要在呼叫堆疊解除之後但事件迴圈繼續之前允許回撥執行。

process.nextTick()沒有顯示在圖中,即使它是非同步API的一部分。 這是因為process.nextTick()在技術上並不是事件迴圈的一部分。 相反,nextTickQueue將在當前操作完成後處理,而不管事件迴圈的當前階段如何。

回顧一下事件迴圈機制,只要你在給定的階段呼叫process.nextTick(),所有傳遞給process.nextTick()的回撥都將在事件迴圈繼續之前被解析。

// nextTick是佇列切換時執行的,timer->check佇列 timer1->timer2不叫且
setImmediate(() => {
  console.log('setImmediate1')
  setTimeout(() => {
    console.log('setTimeout1')
  }, 0);
})
setTimeout(()=>{
  process.nextTick(()=>console.log('nextTick'))
  console.log('setTimeout2')
  setImmediate(()=>{
    console.log('setImmediate2')
  })
},0);
複製程式碼

在討論事件迴圈(Event Loop)的時候,要時刻知道 巨集任務,微任務,process.nextTick等概念。 上面程式碼執行的結果可能為:

setTimeout2
nextTick
setImmediate1
setImmediate2
setTimeout1
複製程式碼

或者

setImmediate1
setTimeout2
setTimeout1
nextTick
setImmediate2
複製程式碼

為什麼呢? 這個就留給各位看官的一個思考題吧。歡迎留言討論~

相關文章