首頁  >  文章  >  web前端  >  Node.js 事件循環

Node.js 事件循環

高洛峰
高洛峰原創
2016-11-18 12:50:011284瀏覽

什麼是事件循環(Event Loop)

 

事件循環能讓Node.js 執行非阻塞I/O 操作-- 儘管JavaScript事實上是單線程的-- 透過在可能的情況下把操作交給操作系統核心來實現。

 

由於大多數現代系統核心是多執行緒的,核心可以處理後台執行的多個操作。當其中一個操作完成的時候,核心告訴 Node.js,相應的回調就被加入到輪詢隊列(poll queue)並最終執行。本主題接著會說明更多相關細節。

 

 

事件循環

 

Node.js 開始的時候會初始化事件循環,處理目標腳本,腳本可能會進行異步API、定時任務或process.nextTickcess.nextTickcess.nextTickcess.nextTickcess。

 

下面的表格簡單描述了事件循環的操作順序。

Node.js 事件循環

註:每個方框代表事件循環中的一個階段。

 

每個階段都有一個需要執行的回呼函數的先入先出(FIFO)隊列。同時,每個階段都是特殊的,基本上,當事件循環進行到某個階段時,會執行該階段特有的操作,然後執行該階段隊列中的回調,直到隊列空了或達到了執行次數限制。這時候,事件循環會進入下一個階段,循環往復。

 

由於這些操作可能產生更多的計劃任務操作,並且輪詢階段處理的新事件會被加入到核心的隊列,輪詢事件被處理的時候會有新的輪詢事件加入。於是,長時回調任務會導致輪詢階段的時間超過了定時器的閾值。 詳情請見 定時器(timers)和輪詢(poll)部分。

 

註:Windows 和 Unix/Linux 的實現有輕微的矛盾之處,但不影響剛才的描述。 最重要的部分都有了。其實有七、八個階段,但我們關注的 -- Node.js 實際使用的 -- 就是上面這些。

 

 

階段總覽(Phases Overview)

 

計時器(timers):本階段執行setTimeout() 和setInterval() 計劃的回調;回調, 由定時器和setImmediate()計劃的回調;

空閒,預備(idle,prepare):只內部使用;

輪詢(poll): 獲取新的I/O 事件;nodejs這時會適當進行進行阻塞;

檢查(check): 調用setImmediate() 的回調;

close callbacks: 例如socket.on('close', ... );

 

在事件循環運行之間,Node.js 檢查是否有正在等待的非同步I/O 或定時器,如果沒有就清除並結束。

 

 

階段細部

 

定時器(timers)

 

定時器的用途是讓指定的時間點回準的閾值。定時器的回調會在製定的時間過後儘快得到執行,然而,作業系統的計畫或其他回呼的執行可能會延遲該回呼的執行。

 

註:從技術上來看,輪詢階段控制了定時器的執行時機。

 

例如,你設定了在100ms後執行某個操作,然後腳本開始執行一個需要95ms的文件讀取操作:

var fs = require('fs');

function someAsyncOperation (callback) {
  // Assume this takes 95ms to complete
  fs.readFile('/path/to/file', callback);
}

var timeoutScheduled = Date.now();

setTimeout(function () {

  var delay = Date.now() - timeoutScheduled;

  console.log(delay + "ms have passed since I was scheduled");
}, 100);


// do someAsyncOperation which takes 95 ms to completesomeAsyncOperation(function () {

  var startCallback = Date.now();

  // do something that will take 10ms...
  while (Date.now() - startCallback < 10) {
    ; // do nothing
  }

});

當事件循環進入輪詢階段時,隊列是空的(fs. readFile()還沒完成),因此時間會繼續流逝知道最快的定時器需要執行。過了95ms後,fs.readFile() 讀完檔案了,它的回呼被加入到輪詢隊列,這個回呼需要執行10ms。等到這個回呼執行完,佇列中沒有回調了,這時事件循環看到了最近到時的定時器,然後回到定時器階段(timers phase)來執行先前的定時器回調。

在這個例子中,從定義定時器到回調執行中間過了105ms。

 

註:為了防止輪詢階段持續時間太長,libuv 會根據作業系統的不同設定一個輪詢的上限。

 

 

I/O callbacks

 

這個階段執行一些諸如TCP錯誤之類的系統操作的回調。例如,如果一個TCP socket 在嘗試連線時收到了 ECONNREFUSED錯誤,某些 *nix 系統會等著報告這個錯誤。這個就會被排到本階段的隊列中。

 

 

輪詢(poll)

 

輪詢階段有兩個主要功能:

1,執行已經到時的定時器腳本,然後

隊列中

2,處理

 

當事件循環進入到輪詢階段卻沒有發現定時器時:

如果輪詢隊列非空,事件循環會迭代回調隊列並同步執行回調,直到隊列空了或者達到了上限(前文說過的根據作業系統的不同而設定的上限)。

如果轮询队列是空的:


如果有setImmediate()定义了回调,那么事件循环会终止轮询阶段并进入检查阶段去执行定时器回调;

如果没有setImmediate(),事件回调会等待回调被加入队列并立即执行。

 

一旦轮询队列空了,事件循环会查找已经到时的定时器。如果找到了,事件循环就回到定时器阶段去执行回调。

 

 

检查(check)

 

这个阶段允许回调函数在轮询阶段完成后立即执行。如果轮询阶段空闲了,并且有回调已经被 setImmediate() 加入队列,事件循环会进入检查阶段而不是在轮询阶段等待。

 

setImmediate() 是个特殊的定时器,在事件循环中一个单独的阶段运行。它使用libuv的API 来使得回调函数在轮询阶段完成后执行。

 

基本上,随着代码的执行,事件循环会最终进入到等待状态的轮询阶段,可能是等待一个连接、请求等。然而,如果有一个setImmediate() 设置了一个回调并且轮询阶段空闲了,那么事件循环会进入到检查阶段而不是等待轮询事件。   ---- 这车轱辘话说来说去的

 

 

关闭事件的回调(close callbacks)

 

如果一个 socket 或句柄(handle)被突然关闭(is closed abruptly),例如 socket.destroy(), 'close' 事件会被发出到这个阶段。否则这种事件会通过 process.nextTick() 被发出。

 

 

setImmediate() vs setTimeout()

 

这两个很相似,但调用时机会的不同会导致它们不同的表现。

 

setImmediate() 被设计成一旦轮询阶段完成就执行回调函数;

setTimeout() 规划了在某个时间值过后执行回调函数;

 

这两个执行的顺序会因为它们被调用时的上下文而有所不同。如果都是在主模块调用,那么它们会受到进程性能的影响(运行在本机的其他程序会影响它们)。

 

例如,如果我们在非 I/O 循环中运行下面的脚本(即在主模块中),他俩的顺序是不固定的,因为会受到进程性能的影响:

// timeout_vs_immediate.jssetTimeout(function timeout () {
  console.log(&#39;timeout&#39;);
},0);

setImmediate(function immediate () {
  console.log(&#39;immediate&#39;);
});

$ node timeout_vs_immediate.js

timeout

immediate


$ node timeout_vs_immediate.js

immediate

timeout

但是如果把它们放进 I/O 循环中,setImmediate() 的回调总是先执行:

// timeout_vs_immediate.jsvar fs = require(&#39;fs&#39;)

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log(&#39;timeout&#39;)
  }, 0)
  setImmediate(() => {
    console.log(&#39;immediate&#39;)
  })
})

$ node timeout_vs_immediate.js

immediate

timeout


$ node timeout_vs_immediate.js

immediate

timeout

 

setImmediate() 比 setTimeout() 优势的地方是 setImmediate() 在 I/O 循环中总是先于任何定时器,不管已经定义了多少定时器。

 

 

process.nextTick()

 

理解 process.nextTick()

 

你可能已经注意到了 process.nextTick() 没有在上面那个表格里出现,虽然它确实是一个异步API。这是因为它技术上不属于事件循环。然而,nextTickQueue 会在当前操作结束后被处理,不管是在事件循环的哪个阶段。

 

回头看看之前那个表格,你在某个阶段的任何时候调用它,它的所有回调函数都会在事件循环继续进行之前得到处理。有时候这会导致比较糟糕的情况,因为它允许你用递归调用的方式去“阻塞” I/O,这会让事件循环无法进入到轮询阶段。

 

为什么要允许这样

 

部分是因为 Node.js 的设计哲学:API 应该总是异步的,即使本不需要是异步。

 

blablabla,后面几段看的我有点尴尬+晕。既尴尬又晕是觉得这几段说的有点啰嗦,而且举的例子不合适。例子要么是同步的,不是异步的。要么是例子里的写法完全可以避免,比如应该先添加 'connect' 事件监听再进行 .connect() 操作;又或者变量声明最好放在变量使用之前,可以避免变量的提前声明和当时赋值的麻烦。

 

难道是我没理解里面的秘辛?

 

 

process.nextTick() vs setTimeout()

 

这两个函数有些相似但是名字让人困惑:

process.netxtTick() 在事件循环的当前阶段立即生效;

setImmediate() 生效是在接下来的迭代或者事件循环的下一次tick;

 

本质上,它们的名字应该互换一下。process.nextTick() 比 setImmediate() 更“立刻”执行,但这是个历史问题没法改变。如果改了,npm上大堆的包就要挂了。

 

我们推荐开发者在所有情况下都使用 setImmediate() 因为它更显而易见(reason about),另外兼容性也更广,例如浏览器端。

 

为什么使用 process.nextTick() 

 

有两大原因:

 

允许用户处理错误,清理不需要的资源,或许在事件循环结束前再次尝试发送请求;

必须让回调函数在调用栈已经清除(unwound)后并且事件循环继续下去之前执行;

 

下面的两个例子都是类似的,即在 line1 派发事件,却在 line2 才添加监听,因此监听的回调是不可能被执行到的。

于是可以用 process.nextTick() 使得当前调用栈先执行完毕,也即先执行 line2 注册事件监听,然后在 nextTick 派发事件。

const EventEmitter = require(&#39;events&#39;);
const util = require(&#39;util&#39;);

function MyEmitter() {
  EventEmitter.call(this);

  // use nextTick to emit the event once a handler is assigned
  process.nextTick(function () {
    this.emit(&#39;event&#39;);
  }.bind(this));
}
util.inherits(MyEmitter, EventEmitter);

const myEmitter = new MyEmitter();
myEmitter.on(&#39;event&#39;, function() {
  console.log(&#39;an event occurred!&#39;);
});

翻译总结:

 

这篇文章写的不太简练,也可能为了有更多的受众吧,我感觉车轱辘话比较多,一个意思要说好几遍。

 

从编程应用的角度简单来说:

 

Node.js 中的事件循环大概有七八个阶段,每个阶段都有自己的队列(queue),需要等本阶段的队列处理完成后才进入其他阶段。阶段之间会互相转换,循环顺序并不是完全固定的 ,因为很多阶段是由外部的事件触发的。

 

其中比较重要的是三个:

 

定时器阶段 timers:
定时器阶段执行定时器任务(setTimeOut(), setInterval())。

轮询阶段 poll:

          轮询阶段由 I/O 事件触发,例如 'connect','data' 等。这是比较重/重要的阶段,因为大部分程序功能就是为了 I/O 数据。

          本阶段会处理定时器任务和 poll 队列中的任务,具体逻辑:


如果有 setImmediate(),终止轮询阶段并进入检查阶段去执行;

如果没有 setImmediate(),那么就查看有没有到期的定时器,有的话就回到定时器阶段执行回调函数;

处理到期的定时器任务,然后

处理队列任务,直到队列空了或者达到上限

如果队列任务没了:


检查阶段 check:

          当轮询阶段空闲并且已经有 setImmediate() 的时候,会进入检查阶段并执行。

 

比较次要但也列在表格中的两个:

 

I/O 阶段:

          本阶段处理 I/O 异常错误;

'close'事件回调:

          本阶段处理各种 'close' 事件回调;

 

关于 setTimeout(), setImmediate(), process.nextTick():

 

setTimeout()           在某个时间值过后尽快执行回调函数;

setImmediate()       一旦轮询阶段完成就执行回调函数;

process.nextTick()   在当前调用栈结束后就立即处理,这时也必然是“事件循环继续进行之前” ;

 

优先级顺序从高到低: process.nextTick() > setImmediate() > setTimeout()

注:这里只是多数情况下,即轮询阶段(I/O 回调中)。比如之前比较 setImmediate() 和 setTimeout() 的时候就区分了所处阶段/上下文。

 

 

另:

 

关于调用栈,事件循环还可以参考这篇文章:

https://blog.risingstack.com/node-js-at-scale-understanding-node-js-event-loop/

 

这篇文章里对事件任务区分了大任务(macro task) 、小任务(micro task),每个事件循环只处理一个大任务 ,但会处理完所有小任务。

这一点和前面的文章说的不同。

examples of microtasks:

process.nextTick

promises

Object.observe

examples of macrotasks:

setTimeout

setInterval

setImmediate

I/O


陳述:
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn