首頁 > web前端 > js教程 > 主體

深入聊聊Node 非同步和事件循環的底層實作和執行機制

青灯夜游
發布: 2022-07-20 20:23:14
轉載
2128 人瀏覽過

深入聊聊Node 非同步和事件循環的底層實作和執行機制

Node 最初是為打造高效能的Web 伺服器而生,作為JavaScript 的服務端運行時,具有事件驅動、非同步I/O、單線程等特性。基於事件循環的非同步程式設計模型讓Node 具備處理高並發的能力,大幅提升伺服器的效能,同時,由於維持了JavaScript 單執行緒的特點,Node 不需要處理多執行緒下狀態同步、死鎖等問題,也沒有線程上下文切換所帶來的效能上的開銷。基於這些特性,讓 Node  具備高效能、高併發的先天優勢,可基於它建構各種高速、可伸縮網路應用平台。

本文將深入 Node 非同步和事件循環的底層實作和執行機制,希望對你有幫助。

為什麼要非同步?

Node 為什麼要使用非同步來作為核心程式設計模型呢?

前面說過,Node 最初是為打造高效能的Web 伺服器而生,假設業務場景中有幾組互不相關的任務要完成,現代主流的解決方式有以下兩種:

  • 單執行緒串列依序執行。

  • 多執行緒並行完成。

單執行緒串列依序執行,是一種同步的程式設計模型,雖然它比較符合程式設計師依序思考的思考方式,易寫出更順手的程式碼,但由於是同步執行I/O,同一時刻只能處理單一請求,會導致伺服器回應速度較慢,無法在高並發的應用程式場景下適用,且由於是阻塞I/O,CPU 會一直等待I/O 完成,無法做其他事情,使CPU 的處理能力無法充分利用,最終導致效率的低下,

而多執行緒的程式設計模型也會因為程式設計中的狀態同步、死鎖等問題讓開發人員頭痛。儘管多執行緒在多核心 CPU 上能夠有效提升 CPU 的使用率。

雖然單執行緒串列依序執行和多執行緒並行完成的程式設計模型有其自身的優勢,但是在效能、開發難度等方面也有不足之處。

除此之外,從回應客戶端請求的速度出發,如果客戶端同時取得兩個資源,同步方式的回應速度會是兩個資源的回應速度總和,而非同步方式的回應速度會是兩者中最大的一個,效能優勢相比同步十分明顯。隨著應用程式複雜度的增加,該場景會演變成同時回應 n 個請求,非同步相比於同步的優勢將會凸顯出來。

綜上所述,Node 給出了它的答案:利用單線程,遠離多線程死鎖、狀態同步等問題;利用非同步I/O,讓單線程遠離阻塞,以便更好地使用CPU。這就是 Node 使用非同步作為核心程式設計模型的原因。

此外,為了彌補單執行緒無法利用多核心 CPU 的缺點,Node 也提供了類似瀏覽器中 Web Workers 的子進程,該子進程可以透過工作進程有效地利用 CPU。

如何實現非同步?

聊完了為什麼要使用非同步,那又該如何實現非同步呢?

我們通常所說的非同步操作總共有兩類:一是像檔案I/O、網路I/O 這類與I/O 有關的操作;二是像setTimeOutsetInterval 這類與I/O 無關的操作。很明顯我們所討論的非同步是指與 I/O 有關的操作,即非同步 I/O。

非同步 I/O 的提出是期望 I/O 的呼叫不會阻塞後續程序的執行,將原有等待 I/O 完成的這段時間分配給其餘需要的業務去執行。要達到這個目的,就需要用到非阻塞 I/O。

阻塞 I/O 是 CPU 在啟動 I/O 呼叫後,會一直阻塞,等待 I/O 完成。知道了阻塞 I/O,非阻塞 I/O 就很好理解了,CPU 在發起 I/O 呼叫後會立即返回,而不是阻塞等待,在 I/O 完成之前,CPU 可以處理其他交易。顯然,相較於阻塞 I/O,非阻塞 I/O 多於效能的提升是很明顯的。

那麼,既然使用了非阻塞 I/O,CPU 在發起 I/O 呼叫後可以立即返回,那它是如何知道 I/O 完成的呢?答案是輪詢。

為了及時取得 I/O 呼叫的狀態,CPU 會不斷重複呼叫 I/O 操作來確認 I/O 是否已經完成,這種重複呼叫判斷操作是否完成的技術就稱為輪詢。

顯然,輪詢會讓 CPU 不斷重複地執行狀態判斷,是對 CPU 資源的浪費。而且,輪詢的間隔很難控制,如果間隔太長,I/O 操作的完成得不到及時的回應,間接降低應用程式的回應速度;如果間隔太短,難免會讓CPU 花在輪詢的耗時變長,降低CPU 資源的使用率。

因此,輪詢雖然滿足了非阻塞I/O 不會阻塞後續程式的執行的要求,但是對於應用程式而言,它仍然只能算是一種同步,因為應用程式仍然需要等待I/O 完全返回,依舊花了很多時間等待。

我們所期望的完美的非同步I/O,應該是應用程式發起非阻塞調用,無須透過輪詢的方式不斷查詢I/O 調用的狀態,而是可以直接處理下一個任務,在I/ O 完成後透過信號量或回調將資料傳遞給應用程式即可。

如何實現這種非同步 I/O 呢?答案是線程池。

雖然本文一直提到,Node 是單執行緒執行的,但此處的單執行緒是指JavaScript 程式碼是執行在單執行緒上的,對於I/O 操作這類與主業務邏輯無關的部分,透過運行在其他執行緒的方式實現,並不會影響或阻塞主執行緒的運行,反而可以提高主執行緒的執行效率,實現非同步I/O。

透過線程池,讓主執行緒僅進行I/O 的調用,讓其他多個執行緒進行阻塞I/O 或非阻塞I/O 加輪詢技術完成資料獲取,再透過執行緒之間的通信將I/O 得到的資料進行傳遞,這就輕鬆實現了異步I/O:

深入聊聊Node 非同步和事件循環的底層實作和執行機制

主線程進行I/O 調用,而線程池進行I /O 操作,完成資料的獲取,然後透過線程之間的通訊將資料傳遞給主線程,即可完成一次I/O 的調用,主線程再利用回調函數,將資料暴露給用戶,用戶再利用這些資料來完成業務邏輯層面的操作,這就是Node 中一次完整的非同步I/O 流程。而對於使用者來說,不必在意底層這些繁瑣的實作細節,只需要呼叫Node 封裝好的非同步API,並傳入處理業務邏輯的回呼函數即可,如下所示:

const fs = require("fs");

fs.readFile('example.js', (data) => {
  // 进行业务逻辑的处理
});
登入後複製

Nodejs 的非同步底層實作機制在不同平台下有所不同:Windows 下主要透過IOCP 來向系統核心發送I/O 呼叫和從核心獲取已完成的I/O 操作,配以事件循環,以此完成非同步I/O 的過程;Linux 下透過epoll 實現這個過程;FreeBSD下透過kqueue 實現,Solaris 下透過Event ports 實現。執行緒池在 Windows 下由核心(IOCP)直接提供,*nix 系列則由 libuv 自行實作。

由於Windows 平台和*nix 平台的差異,Node 提供了libuv 作為抽象封裝層,使得所有平台相容性的判斷都由這一層來完成,保證上層的Node與下層的自訂執行緒池及IOCP 之間各自獨立。 Node 在編譯期間會判斷平台條件,選擇性編譯 unix 目錄或是 win 目錄下的源檔到目標程式中:

深入聊聊Node 非同步和事件循環的底層實作和執行機制

##以上就是 Node 對非同步的實作。

(執行緒池的大小可以透過環境變數 

UV_THREADPOOL_SIZE 設置,預設值為4,使用者可結合實際情況來調整這個值的大小。)

那麼問題來了,在得到執行緒池傳遞過來的資料後,主執行緒是如何、何時呼叫回呼函數的呢?答案是事件循環。

基於事件循環的非同步程式設計模型

既然使用回呼函數來進行對 I/O 資料的處理,就必然涉及到何時、如何呼叫回呼函數的問題。在實際開發中,往往會涉及多個、多類異步I/O 調用的場景,如何合理安排這些異步I/O 回調的調用,確保異步回調的有序進行是一個難題,而且,除了異步I /O 之外,還存在定時器這類非I/O 的非同步調用,這類API 即時性強,優先權相應地更高,如何實現不同優先權回調地調度呢?

因此,必須存在一個調度機制,對不同優先順序、不同類型的非同步任務進行協調,確保這些任務在主執行緒上有條不紊地運作。與瀏覽器一樣,Node 選擇了事件循環來承擔這項重任。

Node 根據任務的種類和優先權將它們分為七類:Timers、Pending、Idle、Prepare、Poll、Check、Close。對於每類任務,都存在一個先進先出的任務佇列來存放任務及其回呼(Timers 是用小頂堆存放)。基於這七個類型,Node 將事件循環的執行分為以下七個階段:

timers

這個階段的執行優先權是最高的。

事件循環在這個階段會檢查存放定時器的資料結構(最小堆),對其中的定時器進行遍歷,逐個比較當前時間和過期時間,判斷該定時器是否過期,如果過期的話,就將該定時器的回呼函數取出並執行。

pending

該階段會執行網路、IO 等異常時的回呼。一些

*nix 回報的錯誤,在這個階段會被處理。另外,一些應該在上輪循環的 poll 階段執行的 I/O 回調會被推遲到這個階段執行。

idle、prepare

這兩個階段僅在事件循環內部使用。

poll

检索新的 I/O 事件;执行与 I/O 相关的回调(除了关闭回调、定时器调度的回调和 之外几乎所有回调setImmediate());节点会在适当的时候阻塞在这里。

poll,即轮询阶段是事件循环最重要的阶段,网络 I/O、文件 I/O 的回调都主要在这个阶段被处理。该阶段有两个主要功能:

  • 计算该阶段应该阻塞和轮询 I/O 的时间。

  • 处理 I/O 队列中的回调。

当事件循环进入 poll 阶段并且没有设置定时器时:

  • 如果轮询队列不为空,则事件循环将遍历该队列,同步地执行它们,直到队列为空或达到可执行的最大数量。

  • 如果轮询队列为空,则会发生另外两种情况之一:

    • 如果有 setImmediate() 回调需要执行,则立即结束 poll 阶段,并进入 check 阶段以执行回调。

    • 如果没有 setImmediate() 回调需要执行,事件循环将停留在该阶段以等待回调被添加到队列中,然后立即执行它们。在超时时间到达前,事件循环会一直停留等待。之所以选择停留在这里是因为 Node 主要是处理 IO 的,这样可以更及时地响应 IO。

一旦轮询队列为空,事件循环将检查已达到时间阈值的定时器。如果有一个或多个定时器达到时间阈值,事件循环将回到 timers 阶段以执行这些定时器的回调。

check

该阶段会依次执行 setImmediate() 的回调。

close

该阶段会执行一些关闭资源的回调,如 socket.on('close', ...)。该阶段晚点执行也影响不大,优先级最低。

当 Node 进程启动时,它会初始化事件循环,执行用户的输入代码,进行相应异步 API 的调用、计时器的调度等等,然后开始进入事件循环:

   ┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<p>事件循环的每一轮循环(通常被称为 tick),会按照如上给定的优先级顺序进入七个阶段的执行,每个阶段会执行一定数量的队列中的回调,之所以只执行一定数量而不全部执行完,是为了防止当前阶段执行时间过长,避免下一个阶段得不到执行。</p><p>OK,以上就是事件循环的基本执行流程。现在让我们来看另外一个问题。</p><p>对于以下这个场景:</p><pre class="brush:php;toolbar:false">const server = net.createServer(() => {}).listen(8080);

server.on('listening', () => {});
登入後複製

当服务成功绑定到 8000 端口,即 listen() 成功调用时,此时 listening 事件的回调还没有绑定,因此端口成功绑定后,我们所传入的 listening 事件的回调并不会执行。

再思考另外一个问题,我们在开发中可能会有一些需求,如处理错误、清理不需要的资源等等优先级不是那么高的任务,如果以同步的方式执行这些逻辑,就会影响当前任务的执行效率;如果以异步的方式,比如以回调的形式传入 setImmediate() 又无法保证它们的执行时机,实时性不高。那么要如何处理这些逻辑呢?

基于这几个问题,Node 参考了浏览器,也实现了一套微任务的机制。在 Node 中,除了调用 new Promise().then() 所传入的回调函数会被封装成微任务外,process.nextTick() 的回调也会被封装成微任务,并且后者的执行优先级比前者高。

有了微任务后,事件循环的执行流程又是怎么样的呢?换句话说,微任务的执行时机在什么时候?

  • 在 node 11 及 11 之后的版本,一旦执行完一个阶段里的一个任务就立刻执行微任务队列,清空该队列。

  • 在 node11 之前执行完一个阶段后才开始执行微任务。

因此,有了微任务后,事件循环的每一轮循环,会先执行 timers 阶段的一个任务,然后按照先后顺序清空 process.nextTick()new Promise().then() 的微任务队列,接着继续执行 timers 阶段的下一个任务或者下一个阶段,即 pending 阶段的一个任务,按照这样的顺序以此类推。

利用 process.nextTick(),Node 就可以解决上面的端口绑定问题:在 listen() 方法内部,listening 事件的发出会被封装成回调传入 process.nextTick() 中,如下伪代码所示:

function listen() {
    // 进行监听端口的操作
    ...
    // 将 `listening` 事件的发出封装成回调传入 `process.nextTick()` 中
    process.nextTick(() => {
        emit('listening');
    });
};
登入後複製

在当前代码执行完毕后便会开始执行微任务,从而发出 listening 事件,触发该事件回调的调用。

一些注意事项

由于异步本身的不可预知性和复杂性,在使用 Node 提供的异步 API 的过程中,尽管我们已经掌握了事件循环的执行原理,但是仍可能会有一些不符合直觉或预期的现象产生。

比如定时器(setTimeoutsetImmediate)的执行顺序会因为调用它们的上下文而有所不同。如果两者都是从顶层上下文中调用的,那么它们的执行时间取决于进程或机器的性能。

我们来看以下这个例子:

setTimeout(() => {
  console.log('timeout');
}, 0);

setImmediate(() => {
  console.log('immediate');
});
登入後複製

以上代码的执行结果是什么呢?按照我们刚才对事件循环的描述,你可能会有这样的答案:由于 timers 阶段会比 check 阶段先执行,因此 setTimeout() 的回调会先执行,然后再执行 setImmediate() 的回调。

实际上,这段代码的输出结果是不确定的,可能先输出 timeout,也可能先输出 immediate。这是因为这两个定时器都是在全局上下文中调用的,当事件循环开始运行并执行到 timers 阶段时,当前时间可能大于 1 ms,也可能不足 1 ms,具体取决于机器的执行性能,因此 setTimeout() 在第一个 timers 阶段是否会被执行实际上是不确定的,因此才会出现不同的输出结果。

(当 delaysetTimeout 的第二个参数)的值大于 2147483647 或小于 1 时, delay 会被设置为 1。)

我们接着看下面这段代码:

const fs = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('timeout');
  }, 0);
  setImmediate(() => {
    console.log('immediate');
  });
});
登入後複製

可以看到,在这段代码中两个定时器都被封装成回调函数传入 readFile 中,很明显当该回调被调用时当前时间肯定大于 1 ms 了,所以 setTimeout 的回调会比 setImmediate 的回调先得到调用,因此打印结果为:timeout immediate

以上是在使用 Node 时需要注意的与定时器相关的事项。除此之外,还需注意 process.nextTick()new Promise().then() 还有 setImmediate() 的执行顺序,由于这部分比较简单,前面已经提到过,就不再赘述了。

总结

文章开篇从为什么要异步、如何实现异步两个角度出发,较详细地阐述了 Node 事件循环的实现原理,并提到一些需要注意的相关事项,希望对你有所帮助。

更多node相关知识,请访问:nodejs 教程

以上是深入聊聊Node 非同步和事件循環的底層實作和執行機制的詳細內容。更多資訊請關注PHP中文網其他相關文章!

相關標籤:
來源:juejin.cn
本網站聲明
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn
熱門教學
更多>
最新下載
更多>
網站特效
網站源碼
網站素材
前端模板
關於我們 免責聲明 Sitemap
PHP中文網:公益線上PHP培訓,幫助PHP學習者快速成長!