Nodejs高性能原理(下) --- 事件循環詳解

系列文章

Nodejs高性能原理(上) --- 異步非阻塞事件驅動模型
Nodejs高性能原理(下) --- 事件循環詳解html

前言

終於開始我nodejs的博客生涯了,先從基本的原理講起.之前寫過一篇瀏覽器執行機制的文章,和nodejs的類似之處仍是挺多的,不熟悉能夠去看看先.
Javascript執行機制--單線程,同異步任務,事件循環node

寫下來以後可能仍是有點懞,之後慢慢補充,也歡迎指正,特別是那篇翻譯文章後面已經看不懂了.有人出手科普一下就行了.由於懶得動手作,整篇文章的圖片要麼來源官網,要麼來源百度圖片.
補充: 當前Nodejs版本10.3.0
2019/8/13 修改部分描述內容shell

Nodejs事件循環詳解

基原本自The Node.js Event Loop, Timers, and process.nextTick(),能夠說這部分我就是翻譯功能,部分翻譯太繞口會和諧一下,基本忠於原文.npm

當nodejs開始運行的時候會初始化事件循環,處理所提供的輸入腳本或者放置進REPL(Read Eval Print Loop:交互式解釋器相似 Window 系統的終端或 Unix/Linux shell),可能會進行異步API調用.定時器調度,或者process.nextTick(),而後開始處理事件循環的流程.segmentfault

下面來自官網的炫酷流程代碼示意圖(官網直接用符號拼湊出來,這裏由於編輯器問題衹能截圖)
圖片描述
注意: 每一個框都被稱爲事件循環的一個流程階段.api

每一個階段都有一個FIFO(先進先出)執行回調函數的隊列,然而每一個階段都有其獨特之處.一般當事件循環進入到給定階段會執行特定於該階段的全部操做.而後執行該階段隊列的回調事件直到隊列耗盡或者超過最大執行限度爲止,而後事件循環就會走向下一階段,以此類推.promise

由於這些操做可能會調度更多的操做而且在poll階段中新的處理事件會加入到內核的隊列,即處理輪詢事件時候又加入新的輪詢事件,所以,長時間運行回調事件會讓poll階段運行時間超過定時器的閾值.瀏覽器

階段綜述:

  • timers(定時器): 這階段執行setTimeoutsetInterval調度的回調;
  • pending callbacks(等待回調): 推遲到下一次循環迭代執行I/O回調;
  • idle,prepare(閒置,準備): 只能內部使用;
  • poll(輪詢): 檢索新的I/O事件;執行I/O相關回調(除了close callbacks之外,大多數是定時器調度,和setImmediate()),當運行時候適當條件下nodejs會佔用阻塞;
  • check(檢測): setImmediate()回調就在這執行;
  • close callbacks(關閉回調): 一些關閉回調,例如socket.on('close', ...),

在事件循環的每次運行過程當中,nodejs會檢測是否有任何待處理的異步I/O或者定時器,沒有的話就完全清除關閉.服務器

Timers(定時器)

在定時器設定了一個閾值以後,被提供的回調函數實際執行時間可能不是開發者想要它被執行的時間,定時器回調會在指定閾值過去後儘量早的運行,然而操做系統調度或者其餘回調運行均可能會致使延遲.
注意: 爲了防止輪詢階段持續時間太長,libuv 會根據操做系統的不一樣設置一個輪詢的上限。(這就是爲何上面會說執行該階段隊列的回調事件直到隊列耗盡或者超過最大執行限度爲止)
(下面會單獨詳細講解定時器的東西)dom

pending callbacks(等待回調)

這階段會執行一些系統操做回調像TCP錯誤類型,例如當一個TCP socket想要鏈接的時候接收到ECONNREFUSED,一些*nix系統會等待錯誤報文,這會被排在pending callbacks 階段執行.

poll(輪詢)

這階段有兩個主要功能:

  • 計算它應該阻塞多長的時間和進行輪詢I/O操做;
    (原文: Calculating how long it should block and poll for I/O, then,我看到有些人會翻譯成當 timers 的定時器到期後,執行定時器(setTimeout 和 setInterval)的 callback。不知道版本不對仍是我翻譯不對味)
  • 處理poll隊列事件;

當事件循環進入poll階段,而且沒有timers調度,會發生其中一種狀況:

  • 若是poll隊列不爲空,事件循環會迭代回調隊列同步執行它們直到隊列耗盡或者到達系統限制;
  • 若是poll隊列爲空,一件或者多件狀況會發生:

    • 若是setImmediate()腳本已經被調度,事件循環的poll階段完成而後繼續到check階段去執行那裏的調度腳本;
    • 若是setImmediate()腳本還沒被調度,事件循環會等待回調被添加到隊列,而後當即執行.

一旦poll隊列清空了事件循環會檢測有沒有定時器閾值是否到達,若是一個或多個定時器已經準備好,事件循環會繞回到timers階段去執行它們的定時器回調函數.

check(檢測)

這階段容許開發者在poll階段完成以後當即執行回調函數,若是poll階段在閒置中而且腳本已經被setImmediate()加入隊列,事件循環會跳到check階段而不是等待.

setImmediate()其實是一個特殊的定時器,它會在事件循環的單獨階段運行.經過libuv API在poll階段完成以後調度回調去執行.

通常來講,當代碼執行完,事件循環最終會到達poll階段去等待即將到來的鏈接,請求等等.然而,若是一個回調函數被setImmediate()調度而且poll階段是閒置狀態,它會結束而且跳到check階段而不是在等待輪詢事件.

close callbacks(關閉回調)

若是一個sockethandle忽然被關閉(例如socket.destroy()),'close'事件會在這階段被觸發,不然會經過process.nextTick()被觸發.

非異步API(強勢插樓)

事件循環階段部分已經講完了,剩下的是定時器之間區別部分,在那以前我想在這裏補充一下定時器知識!

Node.js 中的計時器函數實現使用了一個與瀏覽器相似但不一樣的內部實現,它是基於 Node.js 事件循環構建的。

瀏覽器定時器

setTimeout(callback,delay,lang) :

在指定的毫秒數後調用函數或計算表達式,返回一個用於 clearTimeout() 的Timeout或窗口被關閉。

參數 描述
callback 必需。要調用的函數後要執行的 JavaScript 代碼串。
delay 必需。在執行代碼前需等待的毫秒數, W3C標準規定時間間隔低於4ms被算爲4ms,具體看瀏覽器
lang 可選。腳本語言能夠是:JScript VBScript JavaScript

setInterval(callback,delay,lang) :

按照指定的週期(以毫秒計)來調用函數或計算表達式。方法會不停地調用函數,返回一個用於 clearInterval() 的Timeout或窗口被關閉。
參數請看上面setTimeout.

nodejs定時器

setTimeout(callback, delay[, ...args])

在指定的毫秒數後調用函數或計算表達式,返回一個用於 clearTimeout() 的Timeout或窗口被關閉。

參數 描述
callback 必需。要調用的函數後要執行的 JavaScript 代碼串。
delay 必需。在執行代碼前需等待的毫秒數。當 delay 大於 2147483647 或小於 1 時,delay 會被設爲 1。
...args 可選, 當調用 callback 時要傳入的可選參數。

此外還增長一些方法timeout.ref(),timeout.unref()等,請自行查看.Timeout 類

setInterval(callback, delay[, ...args])

按照指定的週期(以毫秒計)來調用函數或計算表達式。方法會不停地調用函數,返回一個用於 clearInterval() 的Timeout或窗口被關閉。
參數請看上面setTimeout.

setImmediate(callback[, ...args])

預約當即執行的 callback,它是在 I/O 事件的回調以後被觸發。 返回一個用於 clearImmediate() 的 Immediate。
當屢次調用 setImmediate() 時,callback 函數會按照它們被建立的順序依次執行。 每次事件循環迭代都會處理整個回調隊列。 若是一個即時定時器是被一個正在執行的回調排入隊列的,則該定時器直到下一次事件循環迭代纔會被觸發。

參數 描述
callback 在 Node.js 事件循環的當前回合結束時要調用的函數。
...args 可選, 當調用 callback 時要傳入的可選參數。

對應的清除方法clearImmediate(),此外還增長一些方法setImmediate.ref(),setImmediate.unref()等,請自行查看.Immediate 類

promise寫法(題外話)

可用util.promisify()提供的promises經常使用變體

const util = require('util');
const setTimeoutPromise = util.promisify(setTimeout),
  setImmediatePromise = util.promisify(setImmediate);

setTimeoutPromise(40, 'foobar').then(value => {
  // value === 'foobar' (passing values is optional)
  // This is executed after about 40 milliseconds.
});

setImmediatePromise('foobar').then(value => {
  // value === 'foobar' (passing values is optional)
  // This is executed after all I/O callbacks.
});

// or with async function
async function timerExample() {
  console.log('Before I/O callbacks');
  await setImmediatePromise();
  console.log('After I/O callbacks');
}
timerExample();

process.nextTick(callback[, ...args])

將 callback 添加到next tick 隊列。 一旦當前事件輪詢隊列的任務所有完成,在next tick隊列中的全部callbacks會被依次調用。可是不一樣於上面的定時器.在內部的處理機制不一樣,nextTick擁有比延時更多的特性.
注意:這不是定時器,並且遞歸調用nextTick callbacks 會阻塞任何I/O操做,就像一個while(true)循環同樣

參數 描述
callback 一旦當前事件輪詢隊列的任務所有完成,在next tick隊列中要調用的函數
...args 可選, 當調用 callback 時要傳入的可選參數。

事件輪詢隨後的ticks 調用,會在任何I/O事件(包括定時器)以前運行。

console.log('start');
process.nextTick(() => {
  console.log('nextTick callback');
});
console.log('scheduled');
// Output:
// start
// scheduled
// nextTick callback

在對象構造好但尚未任何I/O發生以前,想給用戶機會來指定某些事件處理器。

function MyThing(options) {
  this.setupOptions(options);

  process.nextTick(() => {
    this.startDoingStuff();
  });
}

const thing = new MyThing();
thing.getReadyForStuff();

// thing.startDoingStuff() gets called now, not before.

每次事件輪詢後,在額外的I/O執行前,next tick隊列都會優先執行。

//使用場景
const maybeTrue = Math.random() > 0.5;

maybeSync(maybeTrue, () => {
  foo();
});

bar();

//危險寫法,由於不清楚foo() 或 bar() 哪一個會被先調用
function maybeSync(arg, cb) {
  if (arg) {
    cb();
    return;
  }

  fs.stat('file', cb);
}

//優化寫法,每次事件輪詢後,在額外的I/O執行前,next tick隊列都會優先執行
function definitelyAsync(arg, cb) {
  if (arg) {
    process.nextTick(cb);
    return;
  }

  fs.stat('file', cb);
}

繼續回到文章

setImmediate() vs setTimeout()

setImmediate() vs setTimeout()很類似,可是行爲方式的不一樣取決於他們調用時機.

  • setImmediate()被設計爲在當前poll階段完成以後執行腳本.
  • setTimeout()會在消耗一段時間閾值以後調度一段腳本去運行.

定時器被執行時候的順序變化取決於它們被調用時候的上下文,若是都是在主模塊內部被調用會受到進程性能的約束(可能被本機其餘應用運行影響);

例如,若是咱們不在I/O循環運行下面的腳本(也就是在主模塊中),兩個定時器的執行順序是不肯定的,由於它們受到進程性能的約束.

// timeout_vs_immediate.js
setTimeout(function timeout () {
  console.log('timeout');
},0);

setImmediate(function immediate () {
  console.log('immediate');
});
$ node timeout_vs_immediate.js
timeout
immediate

$ node timeout_vs_immediate.js
immediate
timeout

但是若是你把兩個代碼放進I/O循環內部,immediate()回調函數老是先執行;

// timeout_vs_immediate.js
const fs = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('timeout');
  }, 0);
  setImmediate(() => {
    console.log('immediate');
  });
});
$ node timeout_vs_immediate.js
immediate
timeout

$ node timeout_vs_immediate.js
immediate
timeout

使用setImmediate()而不是setTimeout()的主要優點是若是在I/O循環內部調用,setImmediate()總會在全部定時器以前執行,與你定義多少個定時器無關.

理解process.nextTick()

你可能已經注意到process.nextTick()並無出如今圖表,雖然它是異步API的一部分,那是由於process.nextTick()技術上不是事件循環部分.相反,process.nextTick()會在當前操做完成以後被處理,無論事件循環的當前階段如何.

回顧咱們的圖表,在給定階段的任什麼時候候你調用process.nextTick(),傳遞給process.nextTick()的回調函數都會在事件循環繼續以前被解決,這會形成一些糟糕狀況由於它容許你經過執行遞歸process.nextTick()調用去'餓死'(starve)你的I/O,從而阻止事件循環到達poll階段.

爲何會被容許?

爲何一些像這樣的內容會被包含在Nodejs?這部分是由於它是一種設計哲學,API應該老是異步即便它並不須要,看這段代碼片斷例子

function apiCall(arg, callback) {
  if (typeof arg !== 'string')
    return process.nextTick(callback, new TypeError('argument should be string'));
}

這片斷會檢查入參,若是不正確會傳遞錯誤到回調函數,最近更新的API容許傳遞入參到process.nextTick(),容許他在回調後取傳遞的任何參數做爲回調的入參,這樣就沒必要嵌套函數了.

這句又長又繞口,附上部分原文:
The API updated fairly recently to allow passing arguments to process.nextTick() allowing it to take any arguments passed after the callback to be propagated as the arguments to the callback so you don't have to nest functions.

咱們要作的是傳遞一個錯誤給開發者但僅僅是咱們已經容許開發者其他的代碼執行以後.經過使用process.nextTick()咱們保證apiCall()總會在開發者其他代碼執行以後事件循環容許執行以前運行它的回調函數,爲了實現這一步,JS調用堆棧容許展開當即執行所提供的回調函數,容許開發者執行遞歸調用process.nextTick()而不會達到引用錯誤: Maximum call stack size exceeded from v8.

這句又長又繞口,附上原文:
What we're doing is passing an error back to the user but only after we have allowed the rest of the user's code to execute. By using process.nextTick() we guarantee that apiCall() always runs its callback after the rest of the user's code and before the event loop is allowed to proceed. To achieve this, the JS call stack is allowed to unwind then immediately execute the provided callback which allows a person to make recursive calls to process.nextTick() without reaching a RangeError: Maximum call stack size exceeded from v8.

這種哲學會致使一些潛在的有問題的狀況,看看這段片斷例子

let bar;

// this has an asynchronous signature, but calls callback synchronously
function someAsyncApiCall(callback) { callback(); }

// the callback is called before `someAsyncApiCall` completes.
someAsyncApiCall(() => {
  // since someAsyncApiCall has completed, bar hasn't been assigned any value
  console.log('bar', bar); // undefined
});

bar = 1;

開發者定義someAsyncApiCall()有一個異步簽名(signature??),實際上倒是同步操做,當它調用時候提供給someAsyncApiCall()的回調函數會在事件循環的相同階段被調用由於someAsyncApiCall()實際上並無作任何異步事情,結果回調函數試著去引用bar即便它可能還沒在做用域裏,由於代碼不可能運行完成.

可是若是把它放進process.nextTick(),代碼依舊有能力跑完,容許全部變量,函數等等在回調函數被調用以前優先初始化完,它具備不讓事件循環繼續的優勢,在容許事件循環繼續以前,提醒用戶注意錯誤多是有用的。

這句又長又繞口,附上原文:
The user defines someAsyncApiCall() to have an asynchronous signature, but it actually operates synchronously. When it is called, the callback provided to someAsyncApiCall() is called in the same phase of the event loop because someAsyncApiCall() doesn't actually do anything asynchronously. As a result, the callback tries to reference bar even though it may not have that variable in scope yet, because the script has not been able to run to completion.

By placing the callback in a process.nextTick(), the script still has the ability to run to completion, allowing all the variables, functions, etc., to be initialized prior to the callback being called. It also has the advantage of not allowing the event loop to continue. It may be useful for the user to be alerted to an error before the event loop is allowed to continue. Here is the previous example using process.nextTick():

這是上面使用process.nextTick()的例子

let bar;

function someAsyncApiCall(callback) {
  process.nextTick(callback);
}

someAsyncApiCall(() => {
  console.log('bar', bar); // 1
});

bar = 1;

這是另外一個現實世界的例子:

const server = net.createServer(() => {}).listen(8080);

server.on('listening', () => {});

(這句又長又繞口,不想翻了:)
When only a port is passed, the port is bound immediately. So, the 'listening' callback could be called immediately. The problem is that the .on('listening') callback will not have been set by that time.

想要避開這問題,'listening'事件會加入nextTick()隊列以允許腳本運行完,這容許開發者設置任何他們想要的任何事件處理器.

process.nextTick() vs setImmediate()

就用戶而言,咱們有兩個相似的調用,不過他們的名字使人困惑.
process.nextTick() 在同一階段馬上觸發(原文fires: 點燃;解僱;開除;使發光;燒製;激動;放槍???)
setImmediate() 在事件循環的後續迭代或「tick」中觸發(原文fires)

本質上,名字應該調換,process.nextTick()比setImmediate()更加容易觸發,但這是一種不可變得的過去的產物,這種轉換會在npm中破壞大量的包,天天都有不少新包被添加,意味着咱們每等待一天就有更多潛在的破壞發生,即便它們多困惑也不能更改它們的名字.

咱們建議開發者們在任何狀況使用setImmediate()由於它容易推出(reason about??)(它會讓代碼兼容更普遍的環境變量,像browser JS)

爲何使用process.nextTick()?(翻譯文章最後內容)

兩個緣由:
1, 容許開發者們處理錯誤,清除任何不須要的資源,或者嘗試在事件循環繼續以前再次發起請求.
2, 在須要的時候容許調用棧釋放(unwound??)以後但事件循環繼續以前運行一個回調函數.

一個符合開發者們指望的簡單例子

const server = net.createServer();
server.on('connection', (conn) => { });

server.listen(8080);
server.on('listening', () => { });

假設listen()在事件循環開始的時候運行,可是監聽回調被放置在setImmediate()。除非傳遞主機名當即綁定端口,想讓事件循環繼續進行必須進入poll階段,意味着有機會(a non-zero chance??)已經接收到一個鏈接,容許在監聽事件以前觸發鏈接事件。

(有段名詞不懂怎麼翻譯:)
which means there is a non-zero chance that a connection could have been received allowing the connection event to be fired before the listening event

另外一個例子是運行構造函數,從EventEmitter繼承而且想要在構造函數內部調用一個事件。

const EventEmitter = require('events');
const util = require('util');

function MyEmitter() {
  EventEmitter.call(this);
  this.emit('event');
}
util.inherits(MyEmitter, EventEmitter);

const myEmitter = new MyEmitter();
myEmitter.on('event', () => {
  console.log('an event occurred!');
});

咱們不能在構造函數馬上發出事件是由於腳本可能還沒處理到開發者設置觸發事件回調函數的位置,因此在構造函數內部自己你能使用process.nextTick()設置觸發事件回調函數以在構造函數已經完成以後提供指望結果。

const EventEmitter = require('events');
const util = require('util');

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

  // use nextTick to emit the event once a handler is assigned
  process.nextTick(() => {
    this.emit('event');
  });
}
util.inherits(MyEmitter, EventEmitter);

const myEmitter = new MyEmitter();
myEmitter.on('event', () => {
  console.log('an event occurred!');
});

輸出例子

大家試試看這個輸出順序符不符合大家預期

const fs = require('fs');

console.log('start');
setTimeout(function timeout() {
  console.log('模塊外部timeout');
}, 1000);

setImmediate(function immediate() {
  console.log('模塊外部immediate');
});

process.nextTick(() => {
  console.log('模塊外部nextTick callback');
});

fs.readFile(__filename, () => {
  setTimeout(function timeout() {
    console.log('I/O內部timeout');
  }, 0);

  setImmediate(function immediate() {
    console.log('I/O內部immediate');
  });

  process.nextTick(() => {
    console.log('I/O內部nextTick callback');
  });
});

console.log('end');
// start
// end
// 模塊外部nextTick callback
// 模塊外部immediate
// I/O內部nextTick callback
// I/O內部immediate
// I/O內部timeout
// 模塊外部timeout

Nodejs劣勢

總的來講單線程的鍋.
1, 異常拋出終止
咱們都知道Javascript是一門單線程語言,在發生各類錯誤以後,JavaScript引擎一般會中止,並拋出一個錯誤.
Nodejs具體錯誤直接看Error (錯誤).
暫時還沒研究到,可是確定能夠經過一些方法解決的,後補.

2, 不適合CPU密集型
儘管咱們上面已經提出了事件驅動異步IO非阻塞模型的各類優勢,可是裏面有個關鍵詞叫"I/O",若是是非I/O的處理例如CPU計算仍是沒改進的,若是有長時間運行的計算,將會致使CPU時間片不能釋放,使得後續I/O沒法發起.
能夠經過把密集運算拆分紅多個小任務,減輕CPU壓力.

3, 不能用到CPU的多核
如今的服務器操做系統基本都是支持多CPU/核了,單線程言語註定只能佔用一個資源,不能充分利用.

解決單線程痛點方案
能夠新開進程去玩,還沒研究到不說.
process - 進程

參考資源

Node.js 中文網 API
The Node.js Event Loop, Timers, and process.nextTick()

相關文章
相關標籤/搜索