nodejs?快速入門之事件循環(huán)
瀏覽器中的事件循環(huán)
請在瀏覽器中運行這段代碼:
console.log('1'); setTimeout(() => { console.log('2'); Promise.resolve().then(() => console.log('3')); Promise.resolve().then(() => console.log('4')); }, 100); setTimeout(() => { console.log('5'); Promise.resolve().then(() => console.log('6')); }, 150); Promise.resolve().then(() => console.log('7')); setTimeout(() => console.log('8'), 200); console.log('9'); /* 結果: 1 9 7 2 3 4 5 6 8 */
分析這段代碼的事件循環(huán)的詳細過程之前,有幾點需要說一下:
- 在一次事件循環(huán)中,只會執(zhí)行
一個宏任務
和所有的微任務
,而且宏任務和微任務的處理順序是固定的:每次執(zhí)行完一個宏任務后,首先會立即處理所有的微任務,然后才會執(zhí)行下一個宏任務。如果在執(zhí)行微任務時又產生了新的微任務,那么這些新的微任務也會被添加到隊列中,直到全部微任務都執(zhí)行完成,才會執(zhí)行宏任務。 - 宏任務執(zhí)行期間產生的微任務都會在當前宏任務執(zhí)行完畢之后立即執(zhí)行,不會延遲到下一個宏任務或事件循環(huán)中執(zhí)行
- 當一個宏任務執(zhí)行的過程中產生了微任務,那么這些微任務會被推入微任務隊列中等待處理。而只有當當前宏任務執(zhí)行結束之后,主線程才會去處理微任務隊列中的所有微任務。因此,所有的微任務都會在下一個宏任務執(zhí)行之前被處理完畢。
- 在瀏覽器中,主線程使用
輪詢
方式來實現事件循環(huán)機制。在執(zhí)行完當前的任務之后,如果宏任務隊列為空,主線程會等待一段時間,這個時間間隔是由瀏覽器廠商自行決定的,然后再次查詢宏任務隊列是否有任務需要執(zhí)行。 - setTimeout 是宏任務,比如執(zhí)行
setTimeout(() => console.log('8'), 200)
,瀏覽器會創(chuàng)建一個定時器(200ms),并將回調函數和指定的時間保存在一個任務中。當指定的時間到達時,定時器才會將這個任務推入宏任務隊列中等待處理
這段代碼大概有四次
事件循環(huán),執(zhí)行過程如下:
- 第一次事件循環(huán):
首先將 console.log('1') 加入執(zhí)行棧中,輸出 1,然后將其從執(zhí)行棧中彈出。 第一個 setTimeout 函數被調用時,瀏覽器會創(chuàng)建一個定時器(100ms),并將回調函數和指定的時間保存在一個任務中。當指定的時間到達時,定時器會將這個任務推入宏任務隊列中等待處理 第二個 setTimeout 與第一 setTimeout 類似,等待 150ms 后會被放入宏任務隊列中 Promise.resolve().then(() => console.log('7')) 放入微任務隊列 第三個 setTimeout 與第一 setTimeout 類似,等待 200ms 后會被放入宏任務隊列中 執(zhí)行 console.log('9') 取出微任務隊列中的所有任務,輸出 7
- 第二次事件循環(huán):
執(zhí)行棧為空,主線程輪詢查看宏任務隊列(微任務隊列剛才已經清空了),此時宏任務隊列為空 100ms后,第一個setTimeout 宏任務推入宏任務隊列中,取出這個宏任務放入執(zhí)行棧中 輸出 2 執(zhí)行 `Promise.resolve().then(() => console.log('3'));`、`Promise.resolve().then(() => console.log('4'));`,放入微任務隊列 這個宏任務執(zhí)行完畢之后,主線程會轉而執(zhí)行當前微任務隊列中的所有任務,輸出 3 和 4
- 第三次事件循環(huán):
執(zhí)行棧為空,主線程輪詢宏任務隊列發(fā)現其為空 150ms后,第二個setTimeout 宏任務推入宏任務隊列中,取出這個宏任務放入執(zhí)行棧中 輸出 5 執(zhí)行 `Promise.resolve().then(() => console.log('6'));` 放入微任務隊列 這個宏任務執(zhí)行完畢之后,主線程會轉而執(zhí)行當前微任務隊列中的所有任務,輸出 6
- 第四次事件循環(huán):
執(zhí)行棧為空,主線程輪詢宏任務隊列發(fā)現其為空 200ms后,第三個setTimeout 宏任務推入宏任務隊列中,取出這個宏任務放入執(zhí)行棧中 輸出 8
宏任務優(yōu)先級
宏任務之間其實存在優(yōu)先級
。比如 click > requestAnimationFrame > setTimeout
。
- 用戶交互相關的任務具有最高的優(yōu)先級。在用戶交互(例如點擊)后,會將與該事件相關的任務添加到宏任務隊列中并標記為
緊急
,從而使它們具有比其他任務更高的優(yōu)先級。這確保了與用戶直接交互相關的操作具有更快的響應時間。 - requestAnimationFrame函數,這個函數也有較高的優(yōu)先級,因為它需要在下一次屏幕刷新之前進行處理以提供平滑的動畫效果
- setTimeout 或 setInterval 添加的回調函數。通常情況下,先添加到隊列中的回調函數會優(yōu)先得到處理。它們只能保證
至少
在指定的時間后才開始執(zhí)行
請看示例:
function log(message) { const now = new Date(); console.log(`[${now.getSeconds()}:${now.getMilliseconds()}] ${message}`); } setTimeout(() => { log('setTimeout callback'); }, 0); requestAnimationFrame(() => { log('requestAnimationFrame callback'); }); document.addEventListener('click', () => { log('click event'); }); // 手動觸發(fā) click 事件 const event = new Event('click'); document.dispatchEvent(event); /* [46:280] click event [46:299] setTimeout callback [5:646] requestAnimationFrame callback */
無論測試多少次,click 總是最先輸出。但是 requestAnimationFrame 就不一定先 setTimeout 輸出,因為 requestAnimationFrame 有自己的節(jié)奏,只要不影響平滑的動畫效果,即使在 setTimeout 后面也可能。
核心特性
Node.js 核心的特性是事件驅動
(Event-driven)和非阻塞 I/O
(Non-blocking I/O):
事件驅動
- nodejs 中的異步操作基于事件,也就是說,當某個操作完成時,Node.js 會發(fā)出一個事件來通知你,然后你就可以通過注冊事件的方式來執(zhí)行回調函數。非阻塞 I/O
- nodejs 執(zhí)行一個 I/O 操作時,它不會像傳統(tǒng)的同步阻塞 I/O 一樣等待操作完成,而是會在操作的同時繼續(xù)處理其他請求。這種方式可以避免 I/O 導致的阻塞,提高系統(tǒng)的吞吐量和響應能力。
Tip:兩個特性有關系,但不是一個概念。比如可以說:基于事件驅動的非阻塞 I/O
Node.js 中的事件驅動和非阻塞 I/O 是基于事件循環(huán)實現的。
在 node 中,事件循環(huán)是一個持續(xù)不斷的循環(huán)過程,不斷地從事件隊列
中取出事件并處理,直到事件隊列為空。具體來說,當 Node.js 遇到一個需要異步處理的 I/O 操作時,它不會等待操作完成后再執(zhí)行下一步操作,而是將該操作放到事件隊列中,并繼續(xù)執(zhí)行下一步。當操作完成后,Node.js 會將相應的回調函數也放到事件隊列中,等待事件循環(huán)來處理。這樣一來,Node.js 就可以同時處理多個請求,而且不會因為某一個操作的阻塞而影響整個應用程序的性能。
除了 I/O 操作之外,事件循環(huán)還可以用于處理定時器
、HTTP 請求
、數據庫訪問
等各種類型的事件
Tip: 事件隊列不僅包含宏任務隊列
和微任務隊列
,還有維護著幾個其他的隊列,這些隊列通過事件循環(huán)機制來實現異步非阻塞。其他隊列有:
- check 隊列。check 隊列用于存放 setImmediate() 的回調函數
- I/O 觀察器隊列(watcher queue)
- 關閉事件隊列(close queue)
高并發(fā)和高性能
在 Node.js 中,高并發(fā)
指的是系統(tǒng)能夠處理高并發(fā)請求的能力。不會因為一個請求的處理而阻塞其他請求的執(zhí)行,系統(tǒng)能夠同時處理眾多請求。高性能
通常指的是它在處理大量并發(fā)請求時表現出的優(yōu)異性能。
事件循環(huán)是 Node.js 實現高并發(fā)和高性能的核心機制
之一。通過將計算密集型任務和 I/O 任務分離并采用異步執(zhí)行,Node.js 能夠充分利用 CPU 和內存資源,從而實現高性能和高并發(fā)。
沒有事件循環(huán)
,Node.js 就無法實現異步 I/O 和非阻塞式編程模型。在傳統(tǒng)的阻塞式 I/O 模型中,一個 I/O 操作會一直等待數據返回,導致應用程序被阻塞,無法進行其他操作。而通過事件循環(huán)機制,Node.js 實現了異步 I/O,當一個 I/O 操作被觸發(fā)后,Node.js 將其放入事件循環(huán)隊列中,然后立即執(zhí)行下一個任務,不必等待當前的 I/O 操作結束。當 I/O 操作完成時,Node.js 會將相應的回調函數添加到事件隊列中等待執(zhí)行。
node 中的事件循環(huán)vs 瀏覽器中的事件循環(huán)
相同點:單個主線程、單個執(zhí)行棧、有宏任務隊列和微任務隊列
不同點:
實現
不同。Node.js 是一款服務端運行時,而瀏覽器則用于頁面和交互等,場景不同,所以實現方式不同。Node.js 中的事件循環(huán)機制是通過 libuv 庫來實現,因為它具有跨平臺性、高效性、多功能性(除了事件循環(huán)機制外,libuv 還提供了很多其他的系統(tǒng)功能和服務,能夠滿足 Node.js 在服務器端編程上的需要)等。一次事件循環(huán)
不同。瀏覽器中的一次事件循環(huán)包括一個宏任務和相關所有微任務。在 node 中,一次事件循環(huán)包含6個階段(下文會詳細介紹)
雖然兩者有不同,但它們有相同的設計目標
:高效而可靠的方式處理異步任務
(或者說:解決 JavaScript 異步編程問題)。
原理
一次事件循環(huán)包含以下 6 個階段:
+--------------------------+ | | | timers | 計時器階段:處理 setTimeout() 和 setInterval() 定時器的回調函數。 | | +--------------------------+ | | | pending callbacks | 待定回調階段:用于處理系統(tǒng)級別的錯誤信息,例如 TCP 錯誤或者 DNS 解析異常。 | | +--------------------------+ | | | idle, prepare | 僅在內部使用,可以忽略不計。 | | +--------------------------+ | | | poll | 輪詢階段:等待 I/O 事件(如網絡請求或文件 I/O 等)的發(fā)生,然后執(zhí)行對應的回調函數,并且會處理定時器相關的回調函數。 | | 如果沒有任何 I/O 事件發(fā)生,此階段可能會使事件循環(huán)阻塞。 +--------------------------+ | | | check | 檢查階段:處理 setImmediate() 的回調函數。check 的回調優(yōu)先級比 setTimeout 高,比微任務要低 | | +--------------------------+ | | | close callbacks | 關閉回調階段:處理一些關閉的回調函數,比如 socket.on('close')。 | | +--------------------------+
這 6 個階段執(zhí)行順序:
- 事件循環(huán)首先會進入
timers
階段,執(zhí)行所有超時時間到達的定時器相關的回調函數。 - 當 Node.js 執(zhí)行完 timers 階段后,就會進入到
pending callbacks
階段。在這個階段, Node.js 會執(zhí)行一些系統(tǒng)級別的回調函數,這些回調函數一般都是由 Node.js 的內部模塊觸發(fā)的,而不是由 JavaScript 代碼直接觸發(fā)的。 - 然后進入
poll
階段,等待 I/O 事件的發(fā)生,處理相關的回調函數。如果在此階段確定沒有任何 I/O 事件需要處理,那么事件循環(huán)會等待一定的時間,以防止 CPU 空轉,這個時間會由系統(tǒng)自動設置或者手動在代碼中指定。如果有定時器在此階段需要處理,那么事件循環(huán)會回到 timers 階段繼續(xù)執(zhí)行相應的回調函數。 - 接著進入
check
階段,處理 setImmediate() 注冊的回調函數。setImmediate() 的優(yōu)先級比 timers 階段要高。當事件循環(huán)進入 check 階段時,如果發(fā)現事件隊列中存在 setImmediate() 的回調函數,則會立即執(zhí)行該回調函數而不是繼續(xù)等待 timers 階段的到來。 - 最后進入
close callbacks
階段,處理一些關閉的回調函數。
事件循環(huán)的每個階段都有對應的宏任務隊列
和微任務隊列
。當一個階段中的所有宏任務都執(zhí)行完之后,事件循環(huán)會進入下一個階段。在該階段結束時,如果存在微任務,事件循環(huán)將會在開始下一個階段之前執(zhí)行所有的微任務。這樣一來,無論在何時添加微任務,都能確保先執(zhí)行所有的微任務,避免了某些任務的并發(fā)問題。如果我們在某個階段中添加了多個微任務,那么它們會在該階段結束時依次執(zhí)行,直到所有微任務都被處理完成,才會進入下一個階段的宏任務隊列。
一次事件循環(huán)周期
以清空6個階段的宏任務隊列和微任務隊列來結束。
一次事件循環(huán)周期內,每個階段是否可以執(zhí)行多次
。例如此時在 poll 階段,這時 timers 階段任務隊列中有了回調函數,由于 timers 的優(yōu)先級高于 poll,所以又回到 timers 階段,執(zhí)行完該階段的宏任務和微任務后,在回到 poll 階段。
總之,這 6 個階段構成了 Node.js 的事件循環(huán)機制,確保了所有被注冊的回調函數都能得到及時、準確的執(zhí)行
Tip:當調用 setTimeout 方法時,如果超時時間還沒到,則生成的定時器宏任務也不會立刻
放入宏任務隊列中,而是會被放入計時器隊列中。計時器隊列和延遲隊列類似,都是由定時器宏任務組成的小根堆結構,每個定時器宏任務也對應著其到期時間以及對應的回調函數。當超時時間到達后,Node.js 會將該定時器宏任務從計時器隊列中取出并放入宏任務隊列中,等待事件循環(huán)去執(zhí)行。
盡管事件循環(huán)的機制比較明確,但由于各種因素的影響,具體的執(zhí)行順序仍然難以精確預測
。其順序取決于當前事件隊列中各個回調函數的執(zhí)行情況、耗時以及系統(tǒng)各種資源的利用情況等多種因素。每次事件循環(huán)的順序都不一定相同:
- 例如,在事件循環(huán)的 poll 階段中,如果存在大量耗時較長的 I/O 回調函數,則事件循環(huán)可能會在 poll 階段中花費較長的時間。此時,即使定時器的超時時間到達了,事件循環(huán)也不會立即進入 timers 階段,而是要先處理 poll 階段中還未完成的任務。
Tip: setTimeout 在node 中最小是1ms
,在瀏覽器中是4ms
。
示例
console.log("start"); setTimeout(() => { console.log("first timeout callback"); }, 1); setImmediate(() => { console.log("immediate callback"); }); process.nextTick(() => { console.log("next tick callback"); }); console.log("end");
運行10次node 輸出如下:
start end next tick callback first timeout callback immediate callback
執(zhí)行分析:
- 先執(zhí)行同步代碼,輸出
start
、end
- setTimeout和setImmediate屬于宏任務
- process.nextTick 是微任務,輸出
next tick callback
現在的難點是 setImmediate 和 setTimeout 的回調哪個先執(zhí)行!
注:在某些特殊情況下,timers 階段和 check 階段的任務可能會交錯執(zhí)行。這通常發(fā)生在以下兩種情況下:
- 當 timers 階段中存在長時間運行的回調函數時(如一個耗時很長的 for 循環(huán)),會導致該階段阻塞,影響事件循環(huán)的正常執(zhí)行。在這種情況下,如果 check 階段中有一些較短的回調函數需要執(zhí)行,Node.js 可能會在 timers 階段中間中斷執(zhí)行,并立即進入 check 階段處理已經準備好的回調函數,然后再返回 timers 階段繼續(xù)執(zhí)行剩余的回調函數。
- 當注冊了 setImmediate() 和 setTimeout() 回調函數并且它們被分別安排到不同的事件循環(huán)周期中執(zhí)行時,這時候 setImmediate() 的回調函數可能會在 timers 階段的回調函數之前被執(zhí)行。這是因為 check 階段的任務隊列優(yōu)先級比 timers 階段的任務隊列要高,所以在下一個循環(huán)周期的 check 階段中,setImmediate() 的回調函數會被優(yōu)先處理。
根據結果,我們推測
:setImmediate 和 setTimeout 都進入了下一個循環(huán)周期,先執(zhí)行 timers 階段,在執(zhí)行 check 階段的回調。
Tip: 盡管 setImmediate 被稱為 "immediate",但它并不保證會立刻執(zhí)行。在 Node.js 的事件循環(huán)中,setImmediate() 的回調函數會被加入到 check 階段的任務隊列中,等到輪到 check 階段時才會執(zhí)行。
CPU 密集型場景
Node.js 不適合CPU 密集型場景
。比如大量數學計算,可能會阻塞 Node.js 主線程。
比如一個 1 到 10億求和
的請求:
const http = require('http'); http.createServer((req, res) => { console.log('start'); let sum = 0; for (let i = 1; i <= 1000000000; i++) { sum += i; } console.log('end'); res.writeHead(200, {'Content-Type': 'text/plain'}); res.end(sum.toString()); }).listen(3000); console.log('server running at http://localhost:3000/');
通過curl 檢測訪問 http://localhost:3000/
的時間,分別是 1.754s
、1.072s
、2.821s
Administrator@ MINGW64 /e/ (master) $ time curl http://localhost:3000/ % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 100 18 0 18 0 0 15 0 --:--:-- 0:00:01 --:--:-- 15500000000067109000 real 0m1.754s user 0m0.000s sys 0m0.078s Administrator@ MINGW64 /e/ (master) $ time curl http://localhost:3000/ % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 100 18 0 18 0 0 20 0 --:--:-- --:--:-- --:--:-- 21500000000067109000 real 0m1.072s user 0m0.015s sys 0m0.093s Administrator@ MINGW64 /e/ (master) $ time curl http://localhost:3000/ % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 100 18 0 18 0 0 6 0 --:--:-- 0:00:02 --:--:-- 6500000000067109000 real 0m2.821s user 0m0.031s sys 0m0.077s
接著用node 內置的 cluster
模塊將計算工作分配到4個子進程中,訪問速度大幅度提升。
const http = require('http'); const cluster = require('cluster'); if (cluster.isMaster) { // 計算工作分配到4個子進程中 const numCPUs = require('os').cpus().length; const range = 1000000000; const rangePerCore = Math.ceil(range / numCPUs); let endIndex = 0; let sum = 0; for (let i = 0; i < numCPUs; i++) { const worker = cluster.fork(); worker.on('message', function({ endIndex, result }) { sum += result; if (endIndex === range) { console.log(sum); // 啟動 Web 服務器,在主進程中處理請求 http.createServer((req, res) => { res.statusCode = 200; res.setHeader('Content-Type', 'text/plain'); res.end(`The sum is ${sum}\n`); }).listen(3000, () => { console.log(`Server running at http://localhost:3000/`); }); } }); worker.send({ startIndex: endIndex + 1, endIndex: endIndex + rangePerCore }); endIndex += rangePerCore; } } else { process.on('message', function({ startIndex, endIndex }) { let sum = 0; for (let i = startIndex; i <= endIndex; i++) { sum += i; } process.send({ endIndex, result: sum }); }); }
訪問時長分別是:0.230s
、0.216s
、0.205s
:
Administrator@ MINGW64 /e/ (master) $ time curl http://localhost:3000/ % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 100 30 100 30 0 0 2354 0 --:--:-- --:--:-- --:--:-- 4285The sum is 500000000098792260 real 0m0.230s user 0m0.000s sys 0m0.109s Administrator@ MINGW64 /e/ (master) $ time curl http://localhost:3000/ % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 100 30 100 30 0 0 2212 0 --:--:-- --:--:-- --:--:-- 3750The sum is 500000000098792260 real 0m0.216s user 0m0.000s sys 0m0.078s Administrator@ MINGW64 /e/ (master) $ time curl http://localhost:3000/ % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 100 30 100 30 0 0 2545 0 --:--:-- --:--:-- --:--:-- 6000The sum is 500000000098792260 real 0m0.205s user 0m0.000s sys 0m0.078s
其他
pm2 的一個局限性
假如一個請求得花費2秒(1 到 10億之和
),使用 pm2 也不能減小請求時間。
pm2能做的是:比如一個 node 應用單核(1個cpu內核)可以支持一千個并發(fā)請求,現在并發(fā)四千個請求,由于超出能力,請求響應會變慢?,F在通過 Pm2 在四核服務器中啟動4個node應用,之前還存在負載均衡,這樣就可以支持四千個并發(fā)請求。
Tip:pm2的介紹請看這里
單線程
Node.js 是單線程的,這意味著所有事件循環(huán)(Event Loop)和 I/O 操作都在一個主線程
中運行。所以說,Node.js 中只存在一個事件循環(huán)和一個執(zhí)行上下文棧。
不過,Node.js 的實現并不簡單粗暴。它通過使用非阻塞 I/O、異步編程以及事件驅動機制,讓單線程可以支持高并發(fā)處理大量的 I/O 操作。Node.js 底層采用的是 libuv 庫來實現異步 I/O 模型,該庫在底層會使用 libev 和 libeio 等多種事件驅動框架來實現對底層 I/O 系統(tǒng)調用的封裝,從而讓單線程可以同時處理多個 I/O 任務,避免了線程切換的開銷,提高了應用程序的性能。
此外,在 Node.js 版本 10.5.0 之后,Node.js 引入了 worker_threads 模塊,支持通過創(chuàng)建子線程的方式來實現多線程。worker_threads 模塊提供了一套 API,使得開發(fā)者可以方便地創(chuàng)建和管理多個子線程,并利用多線程來加速處理計算密集型任務等場景。
總之,Node.js 是單線程的,但同時也通過采用異步 I/O 模型、事件驅動機制和多線程等技術手段,來支持高并發(fā)、高性能的應用程序開發(fā)。
到此這篇關于nodejs 快速入門之事件循環(huán)的文章就介紹到這了,更多相關nodejs 事件循環(huán)內容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關文章希望大家以后多多支持腳本之家!
相關文章
nodejs 使用nodejs-websocket模塊實現點對點實時通訊
這篇文章主要介紹了nodejs 使用nodejs-websocket模塊實現點對點實時通訊的實例代碼,代碼簡單易懂,非常不錯,具有一定的參考借鑒價值,需要的朋友可以參考下2018-11-11Node.js、Socket.IO和GPT-4構建AI聊天機器人的項目實踐
本文主要介紹了Node.js、Socket.IO和GPT-4構建AI聊天機器人的項目實踐,文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友們下面隨著小編來一起學習學習吧2023-05-05