本文講詳細講解 nodejs 中兩個比較難以理解的部分非同步I/O和事件迴圈,對 nodejs 核心知識點,做梳理和補充。【推薦學習:《》】
送人玫瑰,手有餘香,希望閱讀後感覺不錯的同學,可以給點個贊,鼓勵我繼續創作前端硬文。
老規矩我們帶上疑問開始今天的分析:
處理器存取任何暫存器和 Cache 等封裝以外的資料資源都可以當成 I/O 操作,包括記憶體,磁碟,顯示卡等外部裝置。在 Nodejs 中像開發者呼叫 fs
讀取本地檔案或網路請求等操作都屬於I/O操作。(最普遍抽象 I/O 是檔案操作和 TCP/UDP 網路操作)
Nodejs 為單執行緒的,在單執行緒模式下,任務都是順序執行的,但是前面的任務如果用時過長,那麼勢必會影響到後續任務的進行,通常 I/O 與 cpu 之間的計算是可以並行進行的,但是同步的模式下,I/O的進行會導致後續任務的等待,這樣阻塞了任務的執行,也造成了資源不能很好的利用。
為了解決如上的問題,Nodejs 選擇了非同步I/O的模式,讓單執行緒不再阻塞,更合理的使用資源。
前端開發者可能更清晰瀏覽器環境下的 JS 的非同步任務,比如發起一次 ajax
請求,正如 ajax 是瀏覽器提供給 js 執行環境下可以呼叫的 api 一樣 ,在 Nodejs 中提供了 http 模組可以讓 js 做相同的事。比如監聽|傳送 http 請求,除了 http 之外,nodejs 還有操作本地檔案的 fs 檔案系統等。
如上 fs http 這些任務在 nodejs 中叫做 I/O 任務。理解了 I/O 任務之後,來分析一下在 Nodejs 中,I/O 任務的兩種形態——阻塞和非阻塞。
nodejs 對於大部分的 I/O 操作都提供了阻塞和非阻塞兩種用法。阻塞指的是執行 I/O 操作的時候必須等待結果,才往下執行 js 程式碼。如下一下阻塞程式碼
同步I/O模式
/* TODO: 阻塞 */ const fs = require('fs'); const data = fs.readFileSync('./file.js'); console.log(data)
file.js
檔案,結果 data
為 buffer
結構,這樣當讀取過程中,會阻塞程式碼的執行,所以 console.log(data)
將被阻塞,只有當結果返回的時候,才能正常列印 data
。/* TODO: 阻塞 - 捕獲異常 */ try{ const fs = require('fs'); const data = fs.readFileSync('./file1.js'); console.log(data) }catch(e){ console.log('發生錯誤:',e) } console.log('正常執行')
同步 I/O 模式造成程式碼執行等待 I/O 結果,浪費等待時間,CPU 的處理能力得不到充分利用,I/O 失敗還會讓整整個執行緒退出。阻塞 I / O 在整個呼叫棧上示意圖如下:
非同步I/O模式
這就是剛剛介紹的非同步I/O。首先看一下非同步模式下的 I/O 操作:
/* TODO: 非阻塞 - 非同步 I/O */ const fs = require('fs') fs.readFile('./file.js',(err,data)=>{ console.log(err,data) // null <Buffer 63 6f 6e 73 6f 6c 65 2e 6c 6f 67 28 27 68 65 6c 6c 6f 2c 77 6f 72 6c 64 27 29> }) console.log(111) // 111 先被列印~ fs.readFile('./file1.js',(err,data)=>{ console.log(err,data) // 儲存 [ no such file or directory, open './file1.js'] ,找不到檔案。 })
null
,第二個引數為 fs.readFile
執行得到的真正內容。file1.js
檔案時候,出現了找不到對應檔案的異常行為,會直接通過第一個引數形式傳遞到 callback 中。比如如上的 callback ,作為一個非同步回撥函數,就像 setTimeout(fn) 的 fn 一樣,不會阻塞程式碼執行。會在得到結果後觸發,對於 Nodejs 非同步執行 I/O 回撥的細節,接下來會慢慢剖析。
對於非同步 I/O 的處理, Nodejs 內部使用了執行緒池來處理非同步 I/O 任務,執行緒池中會有多個 I/O 執行緒來同時處理非同步的 I/O 操作,比如如上的的例子中,在整個 I/O 模型中會這樣。
接下來將一起探索一下非同步 I/O 執行過程。
和瀏覽器一樣,Nodejs 也有自身的執行模型——事件迴圈( eventLoop ),事件迴圈的執行模型受到宿主環境的影響,它不屬於 javascript 執行引擎( 例如 v8 )的一部分,這就導致了不同宿主環境下事件迴圈模式和機制可能不同,直觀的體現就是 Nodejs 和瀏覽器環境下對微任務( microtask )和宏任務( macrotask )處理存在差異。對於 Nodejs 的事件迴圈及其每一個階段,接下來會詳細探討。
Nodejs 的事件迴圈有多個階段,其中有一個專門處理 I/O 回撥的階段,每一個執行階段我們可以稱之為 Tick
, 每一個 Tick
都會查詢是否還有事件以及關聯的回撥函數 ,如上非同步 I/O 的回撥函數,會在 I/O 處理階段檢查當前 I/O 是否完成,如果完成,那麼執行對應的 I/O 回撥函數,那麼這個檢查 I/O 是否完成的觀察者我們稱之為 I/O 觀察者。
如上提到了 I/O 觀察者的概念,也講了 Nodejs 中會有多個階段,事實上每一個階段都有一個或者多個對應的觀察者,它們的工作很明確就是在每一次對應的 Tick 過程中,對應的觀察者查詢有沒有對應的事件執行,如果有,那麼取出來執行。
瀏覽器的事件來源於使用者的互動和一些網路請求比如 ajax
等, Nodejs
中,事件來源於網路請求 http
,檔案 I/O 等,這些事件都有對應的觀察者,我這裡列舉出一些重要的觀察者。
在 Nodejs 中,對應觀察者接收對應型別的事件,事件迴圈過程中,會向這些觀察者詢問有沒有該執行的任務,如果有,那麼觀察者會取出任務,交給事件迴圈去執行。
從 JavaScript
呼叫到計算機系統執行完 I/O 回撥,請求物件充當著很重要的作用,我們還是以一次非同步 I/O 操作為例
請求物件: 比如之前呼叫 fs.readFile
,本質上呼叫 libuv
上的方法建立一個請求物件。這個請求物件上保留著此次 I/O 請求的資訊,包括此次 I/O 的主體和回撥函數等。然後非同步呼叫的第一階段就完成了,JavaScript 會繼續往下執行執行棧上的程式碼邏輯,當前的 I/O 操作將以請求物件的形式放入到執行緒池中,等待執行。達到了非同步 I/O 的目的。
執行緒池: Nodejs 的執行緒池在 Windows 下有核心( IOCP )提供,在 Unix 系統中由 libuv
自行實現, 執行緒池用來執行部分的 I/O (系統檔案的操作),執行緒池大小預設為 4 ,多個檔案系統操作的請求可能阻塞到一個執行緒中。那麼執行緒池裡面的 I/O 操作是怎麼執行的呢? 上一步說到,一次非同步 I/O 會把請求物件放線上程池中,首先會判斷當前執行緒池是否有可用的執行緒,如果執行緒可用,那麼會執行請求物件的 I/O 操作,並把執行後的結果返回給請求物件。在事件迴圈中的 I/O 處理階段,I/O 觀察者會獲取到已經完成的 I/O 物件,然後取出回撥函數和結果呼叫執行。I/O 回撥函數就這樣執行,而且在回撥函數的引數重獲取到結果。
上述講了整個非同步 I/O 的執行流程,從一個非同步 I/O 的觸發,到 I/O 回撥到執行。事件迴圈 ,觀察者 ,請求物件 ,執行緒池 構成了整個非同步 I/O 執行模型。
用一幅圖表示四者的關係:
總結上述過程:
第一階段:每一次非同步 I/O 的呼叫,首先在 nodejs 底層設定請求引數和回撥函 callback,形成請求物件。
第二階段:形成的請求物件,會被放入執行緒池,如果執行緒池有空閒的 I/O 執行緒,會執行此次 I/O 任務,得到結果。
第三階段:事件迴圈中 I/O 觀察者,會從請求物件中找到已經得到結果的 I/O 請求物件,取出結果和回撥函數,將回撥函數放入事件迴圈中,執行回撥,完成整個非同步 I/O 任務。
對於如何感知非同步 I/O 任務執行完畢的?以及如何獲取完成的任務的呢? libuv 作為中間層, 在不同平臺上,採用手段不同,在 unix 下通過 epoll 輪詢,在 Windows 下通過核心( IOCP )來實現 ,FreeBSD 下通過 kqueue 實現。
事件迴圈機制由宿主環境實現
上述中已經提及了事件迴圈不是 JavaScript 引擎的一部分 ,事件迴圈機制由宿主環境實現,所以不同宿主環境下事件迴圈不同 ,不同宿主環境指的是瀏覽器環境還是 nodejs 環境 ,但在不同作業系統中,nodejs 的宿主環境也是不同的,接下來用一幅圖描述一下 Nodejs 中的事件迴圈和 javascript 引擎之間的關係。
以 libuv 下 nodejs 的事件迴圈為參考,關係如下:
以瀏覽器下 javaScript 的事件迴圈為參考,關係如下:
事件迴圈本質上就像一個 while 迴圈,如下所示,我來用一段程式碼模擬事件迴圈的執行流程。
const queue = [ ... ] // queue 裡面放著待處理事件 while(true){ //開始迴圈 //執行 queue 中的任務 //.... if(queue.length ===0){ return // 退出程序 } }
queue
裡面放著待處理的事件,每一次迴圈過程中,如果還有事件,那麼取出事件,執行事件,如果存在事件關聯的回撥函數,那麼執行回撥函數,然後開始下一次迴圈。我總結了流程圖如下所示:
那麼如何事件迴圈是如何處理這些任務的呢?我們列出 Nodejs 中一些常用的事件任務:
setTimeout
或 setInterval
延時器計時器。setImmediate
任務。process.nextTick
任務。Promise
微任務。接下來會一一講到 ,這些任務的原理以及 nodejs 是如何處理這些任務的。
對於不同的事件任務,會在不同的事件迴圈階段執行。根據 nodejs 官方檔案,在通常情況下,nodejs 中的事件迴圈根據不同的作業系統可能存在特殊的階段,但總體是可以分為以下 6 個階段 (程式碼塊的六個階段) :
/* ┌───────────────────────────┐ ┌─>│ timers │ -> 定時器,延時器的執行 │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ │ │ pending callbacks │ -> i/o │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ │ │ idle, prepare │ │ └─────────────┬─────────────┘ ┌───────────────┐ │ ┌─────────────┴─────────────┐ │ incoming: │ │ │ poll │<─────┤ connections, │ │ └─────────────┬─────────────┘ │ data, etc. │ │ ┌─────────────┴─────────────┐ └───────────────┘ │ │ check │ │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ └──┤ close callbacks │ └───────────────────────────┘ */
第一階段: timer
,timer 階段主要做的事是,執行 setTimeout
或 setInterval
註冊的回撥函數。
第二階段:pending callback ,大部分 I/O 回撥任務都是在 poll 階段執行的,但是也會存在一些上一次事件迴圈遺留的被延時的 I/O 回撥函數,那麼此階段就是為了呼叫之前事件迴圈延遲執行的 I/O 回撥函數。
第三階段:idle prepare 階段,僅用於 nodejs 內部模組的使用。
第四階段:poll 輪詢階段,這個階段主要做兩件事,一這個階段會執行非同步 I/O 的回撥函數; 二 計算當前輪詢階段阻塞後續階段的時間。
第五階段:check階段,當 poll 階段回撥函數佇列為空的時候,開始進入 check 階段,主要執行 setImmediate
回撥函數。
第六階段:close階段,執行註冊 close
事件的回撥函數。
對於每一個階段的執行特點和對應的事件任務,我接下來會詳細剖析。我們看一下六個階段在底層原始碼中是怎麼樣體現的。
我們看一下 libuv
下 nodejs 的事件迴圈的原始碼(在 unix
和 win
有點差別,不過不影響流程,這裡以 unix 為例子。):
libuv/src/unix/core.c
int uv_run(uv_loop_t* loop, uv_run_mode mode) { // 省去之前的流程。 while (r != 0 && loop->stop_flag == 0) { /* 更新事件迴圈的時間 */ uv__update_time(loop); /*第一階段: timer 階段執行 */ uv__run_timers(loop); /*第二階段: pending 階段 */ ran_pending = uv__run_pending(loop); /*第三階段: idle prepare 階段 */ uv__run_idle(loop); uv__run_prepare(loop); timeout = 0; if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT) /* 計算 timeout 時間 */ timeout = uv_backend_timeout(loop); /* 第四階段:poll 階段 */ uv__io_poll(loop, timeout); /* 第五階段:check 階段 */ uv__run_check(loop); /* 第六階段: close 階段 */ uv__run_closing_handles(loop); /* 判斷當前執行緒還有任務 */ r = uv__loop_alive(loop); /* 省去之後的流程 */ } return r; }
uv__loop_alive
判斷當前事件迴圈沒有任務,那麼退出執行緒。在整個事件迴圈過程中,有四個佇列(實際的資料結構不是佇列)是在 libuv 的事件迴圈中進行的,還有兩個佇列是在 nodejs 中執行的分別是 promise 佇列 和 nextTick 佇列。
在 NodeJS 中不止一個佇列,不同型別的事件在它們自己的佇列中入隊。在處理完一個階段後,移向下一個階段之前,事件迴圈將會處理兩個中間佇列,直到兩個中間佇列為空。
事件迴圈的每一個階段,都會執行對應任務佇列裡面的內容。
timer 佇列( PriorityQueue ):本質上的資料結構是二叉最小堆,二叉最小堆的根節點獲取最近的時間線上的 timer 對應的回撥函數。
I/O 事件佇列:存放 I/O 任務。
Immediate 佇列( ImmediateList ):多個 Immediate ,node 層用連結串列資料結構儲存。
關閉回撥事件佇列:放置待 close 的回撥函數。
中間佇列的執行特點:
首先要明白兩個中間佇列並非在 libuv 中被執行,它們都是在 nodejs 層執行的,在 libuv 層處理每一個階段的任務之後,會和 node 層進行通訊,那麼會優先處理兩個佇列中的任務。
nextTick 任務的優先順序要大於 Microtasks 任務中的 Promise 回撥。也就是說 node 會首先清空 nextTick 中的任務,然後才是 Promise 中的任務。為了驗證這個結論,例舉一個列印結果的題目如下:
/* TODO: 列印順序 */ setTimeout(()=>{ console.log('setTimeout 執行') },0) const p = new Promise((resolve)=>{ console.log('Promise執行') resolve() }) p.then(()=>{ console.log('Promise 回撥執行') }) process.nextTick(()=>{ console.log('nextTick 執行') }) console.log('程式碼執行完畢')
如上程式碼塊中的 nodejs 中的執行順序是什麼?
效果:
列印結果:Promise執行 -> 程式碼執行完畢 -> nextTick 執行 -> Promise 回撥執行 -> setTimeout 執行
解釋:很好理解為什麼這麼列印,在主程式碼事件迴圈中, Promise執行
和 程式碼執行完畢
最先被列印,nextTick 被放入 nextTick 佇列中,Promise 回撥放入 Microtasks 佇列中,setTimeout 被放入 timer 堆中。接下來主迴圈完成,開始清空兩個佇列中的內容,首先清空 nextTick 佇列,nextTick 執行
被列印,接下來清空 Microtasks 佇列,Promise 回撥執行
被列印,最後再判斷事件迴圈 loop 中還有 timer 任務,那麼開啟新的事件迴圈 ,首先執行,timer 任務,setTimeout 執行
被列印。 整個流程完畢。
/* TODO: 阻塞 I/O 情況 */ process.nextTick(()=>{ const now = +new Date() /* 阻塞程式碼三秒鐘 */ while( +new Date() < now + 3000 ){} }) fs.readFile('./file.js',()=>{ console.log('I/O: file ') }) setTimeout(() => { console.log('setTimeout: ') }, 0);
效果:
nextTick
中的程式碼,阻塞了事件迴圈的有序進行。接下來用流程圖,表示事件迴圈的六大階段的執行順序,以及兩個優先佇列的執行邏輯。
延時器計時器觀察者(Expired timers and intervals):延時器計時器觀察者用來檢查通過 setTimeout
或 setInterval
建立的非同步任務,內部原理和非同步 I/O 相似,不過定期器/延時器內部實現沒有用執行緒池。通過setTimeout
或 setInterval
定時器物件會被插入到延時器計時器觀察者內部的二叉最小堆中,每次事件迴圈過程中,會從二叉最小堆頂部取出計時器物件,判斷 timer/interval 是否過期,如果有,然後呼叫它,出隊。再檢查當前佇列的第一個,直到沒有過期的,移到下一個階段。
首先一起看一下 libuv 層是如何處理的 timer
libuv/src/timer.c
void uv__run_timers(uv_loop_t* loop) { struct heap_node* heap_node; uv_timer_t* handle; for (;;) { /* 找到 loop 中 timer_heap 中的根節點 ( 值最小 ) */ heap_node = heap_min((struct heap*) &loop->timer_heap); /* */ if (heap_node == NULL) break; handle = container_of(heap_node, uv_timer_t, heap_node); if (handle->timeout > loop->time) /* 執行時間大於事件迴圈事件,那麼不需要在此次 loop 中執行 */ break; uv_timer_stop(handle); uv_timer_again(handle); handle->timer_cb(handle); } }
如上是 timer 階段在 libuv 中執行特點。接下里分析一下 node 中是如何處理定時器延時器的。
在 Nodejs 中 setTimeout
和 setInterval
是 nodejs 自己實現的,來一起看一下實現細節:
node/lib/timers.js
function setTimeout(callback,after){ //... /* 判斷引數邏輯 */ //.. /* 建立一個 timer 觀察者 */ const timeout = new Timeout(callback, after, args, false, true); /* 將 timer 觀察者插入到 timer 堆中 */ insert(timeout, timeout._idleTimeout); return timeout; }
那麼 Timeout 做了些什麼呢?
node/lib/internal/timers.js
function Timeout(callback, after, args, isRepeat, isRefed) { after *= 1 if (!(after >= 1 && after <= 2 ** 31 - 1)) { after = 1 // 如果延時器 timeout 為 0 ,或者是大於 2 ** 31 - 1 ,那麼設定成 1 } this._idleTimeout = after; // 延時時間 this._idlePrev = this; this._idleNext = this; this._idleStart = null; this._onTimeout = null; this._onTimeout = callback; // 回撥函數 this._timerArgs = args; this._repeat = isRepeat ? after : null; this._destroyed = false; initAsyncResource(this, 'Timeout'); }
2 ** 31 - 1
或者 setTimeout(callback, 0)
,_idleTimeout 會被設定成 1 ,轉換為 setTimeout(callback, 1) 來執行。用一副流程圖描述一下,我們建立一個 timer ,再到 timer 在事件迴圈裡面執行的流程。
這裡有兩點需要注意:
驗證結論一次執行一個 timer 任務 ,先來看一段程式碼片段:
setTimeout(()=>{ console.log('setTimeout1:') process.nextTick(()=>{ console.log('nextTick') }) },0) setTimeout(()=>{ console.log('setTimeout2:') },0)
列印結果:
nextTick 佇列是在事件迴圈的每一階段結束執行的,兩個延時器的閥值都是 0 ,如果在 timer 階段一次性執行完,過期任務的話,那麼列印 setTimeout1 -> setTimeout2 -> nextTick ,實際上先執行一個 timer 任務,然後執行 nextTick 任務,最後再執行下一個 timer 任務。
精度問題 :關於 setTimeout 的計數器問題,計時器並非精確的,儘管在 nodejs 的事件迴圈非常的快,但是從延時器 timeout 類的建立,會佔用一些事件,再到上下文執行, I/O 的執行,nextTick 佇列執行,Microtasks 執行,都會阻塞延時器的執行。甚至在檢查 timer 過期的時候,也會消耗一些 cpu 時間。
效能問題 :如果想用 setTimeout(fn,0) 來執行一些非立即呼叫的任務,那麼效能上不如 process.nextTick
實在,首先 setTimeout 精度不夠,還有一點就是裡面有定時器物件,並需要在 libuv 底層執行,佔用一定效能,所以可以用 process.nextTick
解決這種場景。
pending 階段用來處理此次事件迴圈之前延時的 I/O 回撥函數。首先看一下在 libuv 中執行時機。
libuv/src/unix/core.c
static int uv__run_pending(uv_loop_t* loop) { QUEUE* q; QUEUE pq; uv__io_t* w /* pending_queue 為空,清空佇列 ,返回 0 */ if (QUEUE_EMPTY(&loop->pending_queue)) return 0; QUEUE_MOVE(&loop->pending_queue, &pq); while (!QUEUE_EMPTY(&pq)) { /* pending_queue 不為空的情況,清空 I/O 回撥。返回 1 */ q = QUEUE_HEAD(&pq); QUEUE_REMOVE(q); QUEUE_INIT(q); w = QUEUE_DATA(q, uv__io_t, pending_queue); w->cb(loop, w, POLLOUT); } return 1; }
pending_queue
是空的,那麼直接返回 0。idle
做一些 libuv 一些內部操作, prepare
為接下來的 I/O 輪詢做一些準備工作。接下來一起解析一下比較重要 poll
階段。
在正式講解 poll 階段做哪些事情之前,首先看一下,在 libuv 中,輪詢階段的執行邏輯:
timeout = 0; if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT) /* 計算 timeout */ timeout = uv_backend_timeout(loop); /* 進入 I/O 輪詢 */ uv__io_poll(loop, timeout);
uv_backend_timeout
計算本次 poll
階段的超時時間。超時時間會影響到非同步 I/O 和後續事件迴圈的執行。timeout代表什麼
首先要明白不同 timeout ,在 I/O 輪詢中代表什麼意思。
timeout = 0
的時候,說明 poll 階段不會阻塞事件迴圈的進行,那麼說明有更迫切執行的任務。那麼當前的 poll 階段不會發生阻塞,會盡快進入下一階段,儘快結束當前 tick,進入下一次事件迴圈,那麼這些緊急任務將被執行。timeout = -1
時,說明會一直阻塞事件迴圈,那麼此時就可以停留在非同步 I/O 的 poll 階段,等待新的 I/O 任務完成。timeout
等於常數的情況,說明此時 io poll 迴圈階段能夠停留的時間,那麼什麼時候會存在 timeout 為常數呢,將馬上揭曉。獲取timeout
timeout 的獲取是通過 uv_backend_timeout 那麼如何獲得的呢?
int uv_backend_timeout(const uv_loop_t* loop) { /* 當前事件迴圈任務停止 ,不阻塞 */ if (loop->stop_flag != 0) return 0; /* 當前事件迴圈 loop 不活躍的時候 ,不阻塞 */ if (!uv__has_active_handles(loop) && !uv__has_active_reqs(loop)) return 0; /* 當 idle 控制程式碼佇列不為空時,返回 0,即不阻塞。 */ if (!QUEUE_EMPTY(&loop->idle_handles)) return 0; /* i/o pending 佇列不為空的時候。 */ if (!QUEUE_EMPTY(&loop->pending_queue)) return 0; /* 有關閉回撥 */ if (loop->closing_handles) return 0; /* 計算有沒有延時最小的延時器 | 定時器 */ return uv__next_timeout(loop); }
uv_backend_timeout 主要做的事情是:
uv__next_timeout
計算有沒有延時閥值最小的定時器 | 延時器( 最急迫執行 ),返回延時時間。接下來看一下 uv__next_timeout
邏輯。
int uv__next_timeout(const uv_loop_t* loop) { const struct heap_node* heap_node; const uv_timer_t* handle; uint64_t diff; /* 找到延時時間最小的 timer */ heap_node = heap_min((const struct heap*) &loop->timer_heap); if (heap_node == NULL) /* 如何沒有 timer,那麼返回 -1 ,一直進入 poll 狀態 */ return -1; handle = container_of(heap_node, uv_timer_t, heap_node); /* 有過期的 timer 任務,那麼返回 0,poll 階段不阻塞 */ if (handle->timeout <= loop->time) return 0; /* 返回當前最小閥值的 timer 與 當前事件迴圈的事件相減,得出來的時間,可以證明 poll 可以停留多長時間 */ diff = handle->timeout - loop->time; return (int) diff; }
uv__next_timeout
做的事情如下:
timeout <= loop.time
證明已經過期了,那麼返回 0,poll 階段不阻塞,優先執行過期任務。執行io_poll
接下來就是 uv__io_poll
真正的執行,裡面有一個 epoll_wait
方法,根據 timeout ,來輪詢有沒有 I/O 完成,有得話那麼執行 I/O 回撥。這也是 unix 下非同步I/O 實現的重要環節。
poll階段本質
接下來總結一下 poll 階段的本質:
poll 階段流程圖
我把整個 poll 階段做的事用流程圖表示,省去了一些細枝末節。
如果 poll 階段進入 idle 狀態並且 setImmediate 函數存在回撥函數時,那麼 poll 階段將打破無限制的等待狀態,並進入 check 階段執行 check 階段的回撥函數。
check 做的事就是處理 setImmediate 回撥。,先來看一下 Nodejs 中是怎麼定義的 setImmediate
。
setImmediate定義
node/lib/timer.js
function setImmediate(callback, arg1, arg2, arg3) { validateCallback(callback); /* 校驗一下回撥函數 */ /* 建立一個 Immediate 類 */ return new Immediate(callback, args); }
setImmediate
本質上呼叫 nodejs 中的 setImmediate 方法,首先校驗回撥函數,然後建立一個 Immediate
類。接下來看一下 Immediate 類。node/lib/internal/timers.js
class Immediate{ constructor(callback, args) { this._idleNext = null; this._idlePrev = null; /* 初始化引數 */ this._onImmediate = callback; this._argv = args; this._destroyed = false; this[kRefed] = false; initAsyncResource(this, 'Immediate'); this.ref(); immediateInfo[kCount]++; immediateQueue.append(this); /* 新增 */ } }
immediateQueue
連結串列中。setImmediate執行
poll 階段之後,會馬上到 check 階段,執行 immediateQueue 裡面的 Immediate。 在每一次事件迴圈中,會先執行一個setImmediate 回撥,然後清空 nextTick 和 Promise 佇列的內容。為了驗證這個結論,同樣和 setTimeout 一樣,看一下如下程式碼塊:
setImmediate(()=>{ console.log('setImmediate1') process.nextTick(()=>{ console.log('nextTick') }) }) setImmediate(()=>{ console.log('setImmediate2') })
列印 setImmediate1 -> nextTick -> setImmediate2 ,在每一次事件迴圈中,執行一個 setImmediate ,然後執行清空 nextTick 佇列,在下一次事件迴圈中,執行另外一個 setImmediate2 。
setImmediate執行流程圖
接下來對比一下 setTimeout 和 setImmediate,如果開發者期望延時執行的非同步任務,那麼接下來對比一下 setTimeout(fn,0)
和 setImmediate(fn)
區別。
setImmediate
回撥。如果 setTimeout 和 setImmediate 在一起,那麼誰先執行呢?
首先寫一個 demo:
setTimeout(()=>{ console.log('setTimeout') },0) setImmediate(()=>{ console.log( 'setImmediate' ) })
猜測
先猜測一下,setTimeout 發生 timer
階段,setImmediate 發生在 check
階段,timer 階段早於 check 階段,那麼 setTimeout 優先於 setImmediate 列印。但事實是這樣嗎?
實際列印結果
從以上列印結果上看, setTimeout
和 setImmediate
執行時機是不確定的,為什麼會造成這種情況,上文中講到即使 setTimeout 第二個引數為 0,在 nodejs 中也會被處理 setTimeout(fn,1)
。當主程序的同步程式碼執行之後,會進入到事件迴圈階段,第一次進入 timer 中,此時 settimeout 對應的 timer 的時間閥值為 1,若在前文 uv__run_timer(loop) 中,系統時間呼叫和時間比較的過程總耗時沒有超過 1ms 的話,在 timer 階段會發現沒有過期的計時器,那麼當前 timer 就不會執行,接下來到 check 階段,就會執行 setImmediate 回撥,此時的執行順序是: setImmediate -> setTimeout。
但是如果總耗時超過一毫秒的話,執行順序就會發生變化,在 timer 階段,取出過期的 setTimeout 任務執行,然後到 check 階段,再執行 setImmediate ,此時 setTimeout -> setImmediate。
造成這種情況發生的原因是:timer 的時間檢查距當前事件迴圈 tick 的間隔可能小於 1ms 也可能大於 1ms 的閾值,所以決定了 setTimeout 在第一次事件迴圈執行與否。
接下來我用程式碼阻塞的情況,會大概率造成 setTimeout 一直優先於 setImmediate 執行。
/* TODO: setTimeout & setImmediate */ setImmediate(()=>{ console.log( 'setImmediate' ) }) setTimeout(()=>{ console.log('setTimeout') },0) /* 用 100000 迴圈阻塞程式碼,促使 setTimeout 過期 */ for(let i=0;i<100000;i++){ }
效果:
100000
迴圈阻塞程式碼,這樣會讓 setTimeout 超過時間閥值執行,這樣就保證了每次先執行 setTimeout -> setImmediate 。
特殊情況:確定順序一致性。我們看一下特殊的情況。
const fs = require('fs') fs.readFile('./file.js',()=>{ setImmediate(()=>{ console.log( 'setImmediate' ) }) setTimeout(()=>{ console.log('setTimeout') },0) })
如上情況就會造成,setImmediate 一直優先於 setTimeout 執行,至於為什麼,來一起分析一下原因。
poll
階段會執行 I/O 回撥。然後處理一個 setImmediate萬變不離其宗,只要掌握瞭如上各個階段的特性,那麼對於不同情況的執行情況,就可以清晰的分辨出來。
close 階段用於執行一些關閉的回撥函數。執行所有的 close 事件。接下來看一下 close 事件 libuv
的實現。
libuv/src/unix/core.c
static void uv__run_closing_handles(uv_loop_t* loop) { uv_handle_t* p; uv_handle_t* q; p = loop->closing_handles; loop->closing_handles = NULL; while (p) { q = p->next_closing; uv__finish_close(p); p = q; } }
uv__run_closing_handles
這個方法迴圈執行 close 佇列裡面的回撥函數。接下來總結一下 Nodejs 事件迴圈。
Nodejs 的事件迴圈分為 6 大階段。分別為 timer 階段,pending 階段,prepare 階段,poll 階段, check 階段,close 階段。
nextTick 佇列和 Microtasks 佇列執行特點,在每一階段完成後執行, nextTick 優先順序大於 Microtasks ( Promise )。
poll 階段主要處理 I/O,如果沒有其他任務,會處於輪詢阻塞階段。
timer 階段主要處理定時器/延時器,它們並非準確的,而且建立需要額外的效能浪費,它們的執行還收到 poll 階段的影響。
pending 階段處理 I/O 過期的回撥任務。
check 階段處理 setImmediate。 setImmediate 和 setTimeout 執行時機和區別。
接下來為了更清楚事件迴圈流程,這裡出兩道事件迴圈的問題。作為實踐:
process.nextTick(function(){ console.log('1'); }); process.nextTick(function(){ console.log('2'); setImmediate(function(){ console.log('3'); }); process.nextTick(function(){ console.log('4'); }); }); setImmediate(function(){ console.log('5'); process.nextTick(function(){ console.log('6'); }); setImmediate(function(){ console.log('7'); }); }); setTimeout(e=>{ console.log(8); new Promise((resolve,reject)=>{ console.log(8+'promise'); resolve(); }).then(e=>{ console.log(8+'promise+then'); }) },0) setTimeout(e=>{ console.log(9); },0) setImmediate(function(){ console.log('10'); process.nextTick(function(){ console.log('11'); }); process.nextTick(function(){ console.log('12'); }); setImmediate(function(){ console.log('13'); }); }); console.log('14'); new Promise((resolve,reject)=>{ console.log(15); resolve(); }).then(e=>{ console.log(16); })
如果剛看這個 demo 可以會發蒙,不過上述講到了整個事件迴圈,再來看這個問題就很輕鬆了,下面來分析一下整體流程:
最先列印:
列印console.log('14');
列印console.log(15);
nextTick 佇列:
nextTick -> console.log(1) nextTick -> console.log(2) -> setImmediate(3) -> nextTick(4)
Promise佇列
Promise.then(16)
check佇列
setImmediate(5) -> nextTick(6) -> setImmediate(7) setImmediate(10) -> nextTick(11) -> nextTick(12) -> setImmediate(13)
timer佇列
setTimeout(8) -> promise(8+'promise') -> promise.then(8+'promise+then') setTimeout(9)
清空 nextTick ,列印:
console.log('1');
console.log('2');
執行第二個 nextTick 的時候,又有一個 nextTick ,所以會把這個 nextTick 也加入到佇列中。接下來馬上執行。
console.log('4')
接下來清空Microtasks
console.log(16);
此時的 check 佇列加入了新的 setImmediate。
check佇列setImmediate(5) -> nextTick(6) -> setImmediate(7) setImmediate(10) -> nextTick(11) -> nextTick(12) -> setImmediate(13) setImmediate(3)
執行第一個 timer:
console.log(8);
此時發現一個 Promise 。在正常的執行上下文中:
console.log(8+'promise');
然後將 Promise.then 加入到 nextTick 佇列中。接下里會馬上清空 nextTick 佇列。
console.log(8+'promise+then');
執行第二個 timer:
console.log(9)
執行第一個 check:
console.log(5);
此時發現一個 nextTick ,然後還有一個 setImmediate 將 setImmediate 加入到 check 佇列中。然後執行 nextTick 。
console.log(6)
執行第二個 check
console.log(10)
此時發現兩個 nextTick 和一個 setImmediate 。接下來清空 nextTick 佇列。將 setImmediate 新增到佇列中。
console.log(11)
console.log(12)
此時的 check 佇列是這樣的:
setImmediate(3) setImmediate(7) setImmediate(13)
接下來按順序清空 check 佇列。列印
console.log(3)
console.log(7)
console.log(13)
到此為止,執行整個事件迴圈。那麼整體列印內容如下:
本文主要講的內容如下:
原文地址:https://juejin.cn/post/7002106372200333319
作者:我不是外星人
更多程式設計相關知識,請存取:!!
以上就是Nodejs進階學習:深入瞭解非同步I/O和事件迴圈的詳細內容,更多請關注TW511.COM其它相關文章!