Node中的事件循環
如果對前端瀏覽器的時間循環不太清楚,請看這篇文章。那麼node中的事件循環是什麼樣子呢?其實官方文檔有很清楚的解釋,本文先從node執行一個單文件說起,再講事件循環。
node的內部模塊
任何高級語言的存在都有一定的執行環境,比如瀏覽器的代碼是在瀏覽器引擎中,那麼在node環境中也有一定的執行環境。我們先來看一下官網的依賴包有哪些?
- V8
- libuv
- http-parser
- c-cares
- OpenSSL
- zlib
上面就是nodejs中依賴的模塊。那麼這些模塊之間是如何工作的呢?模塊之間的工作關係如下圖所示:
主要過程如下:
- step1: 用戶的代碼通過v8引擎解釋器,解析爲兩部分:"立即執行"和"異步執行"。
立即執行:可以理解爲,需要v8引擎去處理的代碼;
異步執行:並不是真正的異步,可以理解爲,不需要v8引擎處理的和需要異步處理的。
- step2: “異步執行”的部分,通過v8引擎和底層之間建立的綁定關係,去執行對應的操作
- step3: 在“異步執行”部分,通過libuv內部的事件循環機制,無阻塞調用。libuv在執行的時候,主要通過handles和request實現對應的操作,handles和requests具備不同的數據結構。官網解釋,handles是長期存在的對象,request是短期存在的對象,猜測來講,requests和handles有不同的垃圾回收機制。
libuv的事件循環
一個線程有唯一的一個事件循環(event loop)。線程非安全。
這裏需要理解兩點:
- 線程
這可能和我們理解的不太一樣,Javascript代碼是單線程的,但是libuv不是單線程的,他可以開啓多個線程,libuv 提供了一個調度的線程池,線程池中的線程數目,默認是4個,最多1024個(爲什麼?因爲每一個線程都會佔用資源,而內存是有限的),關於線程池的可以看官方文檔。
- 線程安全
對數據的操作無非就是讀和寫,線程安全,簡單來說,就是一個線程對這一份數據具有獨佔性,只有當該線程操作完成,其他線程纔可以進行操作,當然線程安全的概念遠不止這些,詳細可以看維基百科,這裏就簡單理解一下就行了。
libuv中的事件循環
事件循環圖,如下所示:
主要分爲下面幾步:
- step1: 線程啓動時,初始化一個時間:now,爲了計算後面的timer的回調函數什麼時候執行
- step2: 判斷事件循環是否存活,如果不存活,立即退出,否則進行下一步。判斷是否存活的依據:索引是否存在。索引就是指否還有需要執行的事件,是否還有請求,關閉事件循環的請求等等。(用白話來講,就是看還有沒有沒處理的事情)
- step3: 執行所有的定時器(timers)在事件循環之前
- step4: 執行待執行(pending)的回調,一般的IO輪詢都會在輪詢後,立即執行,但是有的也會延遲(defer)執行,延遲執行的,就會在這個階段執行
- step4: 執行空閒(idle)函數,每個階段都會執行的,一般情況下是執行一些必要的操作,程序內置的
- step5: 執行準備好的回調函數,具體內部使用的
-
step6: IO輪詢執行,直到超時,在阻塞執行之前,會計算超時時間,也就是停止輪詢的時間:
- 如果隊列爲空、或者是即將關閉,或者有將要關閉的handles,timeout爲0
- 如果沒有上面的情況,超時時間就取最近的timer時間,否則就是無窮大
(用白話來理解,就是看有沒有要關閉的,有的話,就直接往下走,沒有的話,看看有哪個事件比較急,到了點就去執行)
- step7: 執行IO
- step8: 檢查接下來要執行哪些handle,保證正確執行
- step9: 是否存在關閉的回調,如果有就執行,關閉循環,否則繼續循環
通常情況下來講,文件的I/O會調用線程池,但是網絡請求的I/O總是用同一個線程。
Node中的事件循環
阻塞和非阻塞
node中所有的代碼幾乎都提供了同步(阻塞)和異步(非阻塞)的方式,你可以選擇使用哪一種方式,但是不要混合使用。
node中的事件循環,就是一個簡版的libuv事件循環機制圖
NodeJs中的定時器
NodeJs中的定時器主要有三種:
- setTimeout
- setInterval
- setImmediate
三個定時器都有對應的取消函數:
- clearTimeout
- clearInterval
- clearImmediate
setTimeout && setInterval
setTimeout和setInterval行爲和在瀏覽器環境中的行爲類似,但是setTimeout和setImmediate有一點不同。在libuv中可以看到,判斷循環是否結束的時候,是需要判斷是否還有待執行的函數,如果只剩下一個setTimeout或者setInterval函數,那麼整個循環還會繼續存在,node提供了一個函數,可以讓循環暫時休眠。
- unref
- ref
unref是可以讓setTimeout暫時休眠,ref可以再次喚醒
setImmediate
setImmediate是指定在事件循環結束執行的。主要發生在poll階段之後
如果poll隊列沒空,則一直執行,直到對列空位置如果poll隊列空了,有setImmediate事件,則會跳到check階段
如果poll隊列空了,沒有setImmediate事件,就會查看哪一個timer事件快要到期了,轉到timers階段
依據上面的解釋,就有了setTimeout和setImmediate執行先後順序的問題:
setTimeout(() => {
console.log('timeout');
})
setImmediate(() => {
console.log('immediate);
});
先說答案:
可能會有兩種情況:
timeout
immediate
或者
immediate
timeout
爲什麼?
主要是setTimeout在前或者後的問題,依賴於線程的執行速度。
主要是兩個階段:
- 1、v8引擎執行環境掃描代碼,啓動事件循環,當走到setTimeout的時候,會將timeout丟進libuv事件隊列中
-
2、v8引擎繼續執行,走到setImmediate
- 此時,上面的libuv事件隊列可能執行第一次,剛走到poll階段,那麼接下來就會打印immediate,
- 也可能libuv事件隊列,已經第二次循環,經過了poll階段,然後判斷timeout到時間了,去執行timeout了,這樣就會先打印timeout然後再打印immediate
所以根本原因是在於事件循環執行了一次還是兩次。
那我們接下來看看事件循環的邏輯
nextTick
Node添加了這樣一個API,這個並不在事件循環的機制內,但是和時間循環機制相關。先來看一下定義:
nextTick的定義是在事件循環的下一個階段之前執行對應的回調。
雖然nextTick是這樣定義的,但是它並不是爲了在事件循環的每個階段去執行的。
主要有下面兩種應用場景:
- 作爲下一個執行階段的鉤子,去清理不需要的資源,或者再次請求
- 等運行環境準備好之後,再去執行回調
案例一:
let bar;
function someAsyncApiCall(callback) {
callback()
process.nextTick(callback);
}
someAsyncApiCall(() => {
console.log('bar', bar); // 1
});
bar = 1;
// 輸出
undefined
1
輸出undefine的情況是,因爲執行函數的時候,bar並沒有被賦值,而process.nextTick則能保證整個執行環境都準備好了再去執行
案例二:
const server = net.createServer();
server.on('connection', (conn) => { });
server.listen(8080);
server.on('listening', () => { });
當v8引擎執行完代碼後,listen的回調會直接命中poll階段,那麼server的connect事件就不會執行
案例三:
想要在構造函數中,去發送對應的事件,因爲此時v8引擎還沒有掃描到,而構造函數的代碼會立即執行,就需要nextTick
const EventEmitter = require('events');
const util = require('util');
function MyEmitter() {
EventEmitter.call(this);
// 這樣操作無效
this.emit('event');
// 應該這樣
// process.nextTick(() => {
this.emit('event');
});
}
util.inherits(MyEmitter, EventEmitter);
const myEmitter = new MyEmitter();
myEmitter.on('event', () => {
console.log('an event occurred!');
});
總結
上面三個案例,重點在於v8引擎是單線程立即執行,而libuv則是異步執行,想要在異步循環之前執行一些操作就需要process.nextTick