您好,登錄后才能下訂單哦!
這期內容當中小編將會給大家帶來有關Nodejs中如何理解異步I/O和事件循環,文章內容豐富且以專業的角度為大家分析和敘述,閱讀完這篇文章希望大家可以有所收獲。
處理器訪問任何寄存器和 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
。
異常處理 :如上操作有一個致命點就是,如果出現了異常,(比如在同級目錄下沒有 file.js 文件),就會讓整個程序報錯,接下來的代碼講不會執行。通常需要 try catch來捕獲錯誤邊界。代碼如下:
/* 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'] ,找不到文件。 })
回調 callback 被異步執行,返回的第一個參數是錯誤信息,如果沒有錯誤,那么返回 null
,第二個參數為 fs.readFile
執行得到的真正內容。
這種異步的形式可以會優雅的捕獲到執行 I/O 中出現的錯誤,比如說如上當讀取 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 等,這些事件都有對應的觀察者,我這里枚舉出一些重要的觀察者。
文件 I/O 操作 —— I/O 觀察者;
網絡 I/O 操作 —— 網絡 I/O 觀察者;
process.nextTick —— idle 觀察者
setImmediate —— check 觀察者
setTimeout/setInterval —— 延時器觀察者
...
在 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 // 退出進程 } }
Nodejs 啟動后,就像創建一個 while 循環一樣,queue
里面放著待處理的事件,每一次循環過程中,如果還有事件,那么取出事件,執行事件,如果存在事件關聯的回調函數,那么執行回調函數,然后開始下一次循環。
如果循環體中沒有事件,那么將退出進程。
我總結了流程圖如下所示:
那么如何事件循環是如何處理這些任務的呢?我們列出 Nodejs 中一些常用的事件任務:
setTimeout
或 setInterval
延時器計時器。
異步 I/O 任務:文件任務 ,網絡請求等。
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 的回調函數。
nextTick 隊列 : 存放 nextTick 的回調函數。這個是在 nodejs 中特有的。
Microtasks 微隊列 Promise : 存放 promise 的回調函數。
中間隊列的執行特點:
首先要明白兩個中間隊列并非在 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 執行
被打印。 整個流程完畢。
無論是 nextTick 的任務,還是 promise 中的任務, 兩個任務中的代碼會阻塞事件循環的有序進行,導致 I/O 餓死的情況發生,所以需要謹慎處理兩個任務中的邏輯。比如如下:
/* 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);
效果:
三秒鐘, 事件循環中的 timer 任務和 I/O 任務,才被有序執行。也就是說 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); } }
如上 handle timeout 可以理解成過期時間,也就是計時器回到函數的執行時間。
當 timeout 大于當前事件循環的開始時間時,即表示還沒有到執行時機,回調函數還不應該被執行。那么根據二叉最小堆的性質,父節點始終比子節點小,那么根節點的時間節點都不滿足執行時機的話,其他的 timer 也不滿足執行時間。此時,退出 timer 階段的回調函數執行,直接進入事件循環下一階段。
當過期時間小于當前事件循環 tick 的開始時間時,表示至少存在一個過期的計時器,那么循環迭代計時器最小堆的根節點,并調用該計時器所對應的回調函數。每次循環迭代時都會更新最小堆的根節點為最近時間節點的計時器。
如上是 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; }
setTimeout: 邏輯很簡單,就是創建一個 timer 時間觀察者,然后放入計時器堆中。
那么 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'); }
在 nodejs 中無論 setTimeout 還是 setInterval 本質上都是 Timeout 類。超出最大時間閥 2 ** 31 - 1
或者 setTimeout(callback, 0)
,_idleTimeout 會被設置成 1 ,轉換為 setTimeout(callback, 1) 來執行。
用一副流程圖描述一下,我們創建一個 timer ,再到 timer 在事件循環里面執行的流程。
這里有兩點需要注意:
執行機制 :延時器計時器觀察者,每一次都會執行一個,執行一個之后會清空 nextTick 和 Promise, 過期時間是決定兩者是否執行的重要因素,還有一點 poll 會計算阻塞 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; }
如果存放 I/O 回調的任務的 pending_queue
是空的,那么直接返回 0。
如果 pending_queue 有 I/O 回調任務,那么執行回調任務。
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);
初始化超時時間 timeout = 0 ,通過 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 主要做的事情是:
當前事件循環停止時,不阻塞。
當前事件循環 loop 不活躍的時候 ,不阻塞。
當 idle 隊列 ( setImmediate ) 不為空時,返回 0,不阻塞。
i/o pending 隊列不為空的時候,不阻塞。
有關閉回調函數的時候,不阻塞。
如果上述均不滿足,那么通過 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
做的事情如下:
找到時間閥值最小的 timer (最優先執行的),如何沒有 timer,那么返回 -1 。poll 階段將無限制阻塞。這樣的好處是一旦有 I/O 執行完畢 ,I/O 回調函數會直接加入到 poll ,接下來就會執行對應的回調函數。
如果有 timer ,但是 timeout <= loop.time
證明已經過期了,那么返回 0,poll 階段不阻塞,優先執行過期任務。
如果沒有過期,返回當前最小閥值的 timer 與 當前事件循環的事件相減得值,即是可以證明 poll 可以停留多長時間。當停留完畢,證明有過期 timer ,那么進入到下一個 tick。
執行io_poll
接下來就是 uv__io_poll
真正的執行,里面有一個 epoll_wait
方法,根據 timeout ,來輪詢有沒有 I/O 完成,有得話那么執行 I/O 回調。這也是 unix 下異步I/O 實現的重要環節。
poll階段本質
接下來總結一下 poll 階段的本質:
poll 階段就是通過 timeout 來判斷,是否阻塞事件循環。poll 也是一種輪詢,輪詢的是 i/o 任務,事件循環傾向于 poll 階段的持續進行,其目的就是更快的執行 I/O 任務。如果沒有其他任務,那么將一直處于 poll 階段。
如果有其他階段更緊急待執行的任務,比如 timer ,close ,那么 poll 階段將不阻塞,會進行下一個 tick 階段。
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); /* 添加 */ } }
Immediate 類會初始化一些參數,然后將當前 Immediate 類,插入到 immediateQueue
鏈表中。
immediateQueue 本質上是一個鏈表,存放每一個 Immediate。
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)
區別。
setTimeout 是 用于在設定閥值的最小誤差內,執行回調函數,setTimeout 存在精度問題,創建 setTimeout 和 poll 階段都可能影響到 setTimeout 回調函數的執行。
setImmediate 在 poll 階段之后,會馬上進入 check 階段,會執行 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 執行,至于為什么,來一起分析一下原因。
首先分析一下異步任務——主進程中有一個異步 I/O 任務,I/O 回調中有一個 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 可以會發蒙,不過上述講到了整個事件循環,再來看這個問題就很輕松了,下面來分析一下整體流程:
第一階段: 首先開始啟動 js 文件,那么進入第一次事件循環,那么先會執行同步任務:
最先打印:
打印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 隊列,和 promise 隊列,順序是 nextTick 隊列大于 Promise 隊列。
清空 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 里面的任務。執行第一個 setTimeout。
執行第一個 timer:
console.log(8);
此時發現一個 Promise 。在正常的執行上下文中:
console.log(8+'promise');
然后將 Promise.then 加入到 nextTick 隊列中。接下里會馬上清空 nextTick 隊列。
console.log(8+'promise+then');
執行第二個 timer:
console.log(9)
接下來到了 check 階段,執行 check 隊列里面的內容:
執行第一個 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)
到此為止,執行整個事件循環。那么整體打印內容如下:
上述就是小編為大家分享的Nodejs中如何理解異步I/O和事件循環了,如果剛好有類似的疑惑,不妨參照上述分析進行理解。如果想知道更多相關知識,歡迎關注億速云行業資訊頻道。
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。