深刻研究 Node.js 的回調隊列


// 每日前端夜話 第365篇
// 正文共:3000 字
// 預計閱讀時間:10 分鐘

隊列是 Node.js 中用於有效處理異步操做的一項重要技術。在本文中,咱們將深刻研究 Node.js 中的隊列:它們是什麼,它們如何工做(經過事件循環)以及它們的類型。javascript

Node.js 中的隊列是什麼?

隊列是 Node.js 中用於組織異步操做的數據結構。這些操做以不一樣的形式存在,包括HTTP請求、讀取或寫入文件操做、流等。html

在 Node.js 中處理異步操做很是具備挑戰性。前端

HTTP 請求期間可能會出現不可預測的延遲(或者更糟糕的可能性是沒有結果),具體取決於網絡質量。嘗試用 Node.js 讀寫文件時也有可能會產生延遲,具體取決於文件的大小。java

相似於計時器和其餘的許多操做,異步操做完成的時間也有多是不肯定的。node

在這些不一樣的延遲狀況之下,Node.js 須要可以有效地處理全部這些操做。web

Node.js 沒法處理基於 first-start-first-handle (先開始先處理)或 first-finish-first-handle (先結束先處理)的操做。面試

之因此不能這樣作的一個緣由是,在一個異步操做中可能還會包含另外一個異步操做。json

爲第一個異步過程留出空間意味着必須先要完成內部異步過程,而後才能考慮隊列中的其餘異步操做。api

有許多狀況須要考慮,所以最好的選擇是制定規則。這個規則影響了事件循環和隊列在 Node.js 中的工做方式。promise

讓咱們簡要地看一下 Node.js 是怎樣處理異步操做的。

調用棧,事件循環和回調隊列

調用棧被用於跟蹤當前正在執行的函數以及從何處開始運行。當一個函數將要執行時,它會被添加到調用堆棧中。這有助於 JavaScript 在執行函數後從新跟蹤其處理步驟。

回調隊列是在後臺操做完成時把回調函數保存爲異步操做的隊列。它們以先進先出(FIFO)的方式工做。咱們將會在本文後面介紹不一樣類型的回調隊列。

請注意,Node.js 負責全部異步活動,由於 JavaScript 能夠利用其單線程性質來阻止產生新的線程。

在完成後臺操做後,它還負責向回調隊列添加函數。JavaScript 自己與回調隊列無關。同時事件循環會連續檢查調用棧是否爲空,以即可以從回調隊列中提取一個函數並添加到調用棧中。事件循環僅在執行全部同步操做以後才檢查隊列。

那麼,事件循環是按照什麼樣的順序從隊列中選擇回調函數的呢?

首先,讓咱們看一下回調隊列的五種主要類型。

回調隊列的類型

IO 隊列(IO queue)

IO操做是指涉及外部設備(如計算機的硬盤、網卡等)的操做。常見的操做包括讀寫文件操做、網絡操做等。這些操做應該是異步的,由於它們留給 Node.js 處理。

JavaScript 沒法訪問計算機的內部設備。當執行此類操做時,JavaScript 會將其傳輸到 Node.js 以在後臺處理。

完成後,它們將會被轉移到 IO 回調隊列中,來進行事件循環,以轉移到調用棧中執行。

計時器隊列(Timer queue)

每一個涉及 Node.js 計時器功能[1]的操做(如 setTimeout()setInterval())都是要被添加到計時器隊列的。

請注意,JavaScript 語言自己沒有計時器功能[2]。它使用 Node.js 提供的計時器 API(包括 setTimeout )執行與時間相關的操做。因此計時器操做是異步的。不管是 2 秒仍是 0 秒,JavaScript 都會把與時間相關的操做移交給 Node.js,而後將其完成並添加到計時器隊列中。

例如:

setTimeout(function({
        console.log('setTimeout');
    }, 0)
    console.log('yeah')


# 返回
yeah
setTimeout

在處理異步操做時,JavaScript 會繼續執行其餘操做。只有在全部同步操做都已被處理完畢後,事件循環纔會進入回調隊列。

微任務隊列(Microtask queue)

該隊列分爲兩個隊列:

  • 第一個隊列包含因 process.nextTick 函數而延遲的函數。

事件循環執行的每一個迭代稱爲一個 tick(時間刻度)。

process.nextTick 是一個函數,它在下一個 tick (即事件循環的下一個迭代)執行一個函數。微任務隊列須要存儲此類函數,以即可以在下一個 tick 執行它們。

這意味着事件循環必須繼續檢查微任務隊列中的此類函數,而後再進入其餘隊列。

  • 第二個隊列包含因 promises 而延遲的函數。

如你所見,在 IO 和計時器隊列中,全部與異步操做有關的內容都被移交給了異步函數。

可是 promise 不一樣。在 promise 中,初始變量存儲在 JavaScript 內存中(你可能已經注意到了<Pending>)。

異步操做完成後,Node.js 會將函數(附加到 Promise)放在微任務隊列中。同時它用獲得的結果來更新 JavaScript 內存中的變量,以使該函數不與 <Pending> 一塊兒運行。

如下代碼說明了 promise 是如何工做的:

let prom = new Promise(function (resolve, reject{
        // 延遲執行
        setTimeout(function ({
            return resolve("hello");
        }, 2000);
    });
    console.log(prom);
    // Promise { <pending> }
    
    prom.then(function (response{
        console.log(response);
    });
    // 在 2000ms 以後,輸出
    // hello

關於微任務隊列,須要注意一個重要功能,事件循環在進入其餘隊列以前要反覆檢查並執行微任務隊列中的函數。例如,當微任務隊列完成時,或者說計時器操做執行了 Promise 操做,事件循環將會在繼續進入計時器隊列中的其餘函數以前參與該 Promise 操做。

所以,微任務隊列比其餘隊列具備最高的優先級。

檢查隊列(Check queue)

檢查隊列也稱爲即時隊列(immediate queue)。IO 隊列中的全部回調函數均已執行完畢後,當即執行此隊列中的回調函數。setImmediate 用於向該隊列添加函數。

例如:

const fs = require('fs');
setImmediate(function({
    console.log('setImmediate');
})
// 假設此操做須要 1ms
fs.readFile('path-to-file'function({
    console.log('readFile')
})
// 假設此操做須要 3ms
do...while...

執行該程序時,Node.js 把 setImmediate 回調函數添加到檢查隊列。因爲整個程序還沒有準備完畢,所以事件循環不會檢查任何隊列。

由於 readFile 操做是異步的,因此會移交給 Node.js,以後程序將會繼續執行。

do while  操做持續 3ms。在這段時間內,readFile 操做完成並被推送到 IO 隊列。完成此操做後,事件循環將會開始檢查隊列。

儘管首先填充了檢查隊列,但只有在 IO 隊列爲空以後才考慮使用它。因此在 setImmediate 以前,將 readFile 輸出到控制檯。

關閉隊列(Close queue)

此隊列存儲與關閉事件操做關聯的函數。

包括如下內容:

  • 流關閉事件 [3],在關閉流時發出。它表示再也不發出任何事件。
  • http關閉事件 [4],在服務器關閉時發出。

這些隊列被認爲是優先級最低的,由於此處的操做會在之後發生。

你肯sing不但願在處理 promise 函數以前在 close 事件中執行回調函數。當服務器已經關閉時,promise 函數會作些什麼呢?

隊列順序

微任務隊列具備最高優先級,其次是計時器隊列,I/O隊列,檢查隊列,最後是關閉隊列。

回調隊列的例子

讓咱們經過一個更復雜的例子來講明隊列的類型和順序:

const fs = require("fs");

// 假設此操做須要 2ms
fs.writeFile('./new-file.json''...'function({
    console.log('writeFile')
})

// 假設這須要 10ms 才能完成 
fs.readFile("./file.json"function(err, data{
    console.log("readFile");
});

// 不須要假設,這實際上須要 1ms
setTimeout(function({
    console.log("setTimeout");
}, 1000);

// 假設此操做須要 3ms
while(...) {
    ...
}

setImmediate(function({
    console.log("setImmediate");
});

// 解決 promise 須要 4 ms
let promise = new Promise(function (resolve, reject{
    setTimeout(function ({
        return resolve("promise");
    }, 4000);
});
promise.then(function(response{
    console.log(response)
})

console.log("last line");

程序流程以下:

  • 在 0 毫秒時,程序開始。

  • 在 Node.js 將回調函數添加到 IO 隊列以前,fs.writeFile 在後臺花費 2 毫秒。

fs.readFile takes 10ms at the background before Node.js adds the callback function to the IO queue.

  • 在 Node.js 將回調函數添加到 IO 隊列以前,fs.readFile 在後臺花費 10 毫秒。

  • 在 Node.js 將回調函數添加到計時器隊列以前,setTimeout 在後臺花費 1ms。

  • 如今,while 操做(同步)須要 3ms。在此期間,線程被阻止(請記住 JavaScript 是單線程的)。

  • 一樣在這段時間內,setTimeoutfs.writeFile 操做完成,並將它們的回調函數分別添加到計時器和 IO 隊列中。

如今的隊列是:

// queues
Timer = [
    function ({
        console.log("setTimeout");
    },
];
IO = [
    function ({
        console.log("writeFile");
    },
];

setImmediate 將回調函數添加到 Check 隊列中:

js
// 隊列
Timer...
IO...
Check = [
    function({console.log("setImmediate")}
]

在將 promise 操做添加到微任務隊列以前,須要花費 4ms 的時間在後臺進行解析。

最後一行是同步的,所以將會當即執行:

# 返回
"last line"

由於全部同步活動都已完成,因此事件循環開始檢查隊列。因爲微任務隊列爲空,所以它從計時器隊列開始:

// 隊列
Timer = [] // 如今是空的
IO...
Check...


# 返回
"last line"
"setTimeout"

當事件循環繼續執行隊列中的回調函數時,promise 操做完成並被添加到微任務隊列中:

// 隊列
    Timer = [];
    Microtask = [
        function (response{
            console.log(response);
        },
    ];
    IO = []; // 當前是空的
    Check = []; // 當前是在 IO 的後面,爲空


    # results
    "last line"
    "setTimeout"
    "writeFile"
    "setImmediate"

幾秒鐘後,readFile 操做完成,並添加到 IO 隊列中:

// 隊列
    Timer = [];
    Microtask = []; // 當前是空的
    IO = [
        function ({
            console.log("readFile");
        },
    ];
    Check = [];


    # results
    "last line"
    "setTimeout"
    "writeFile"
    "setImmediate"
    "promise"

最後,執行全部回調函數:

// 隊列
    Timer = []
    Microtask = []
    IO = [] // 如今又是空的
    Check = [];


    # results
    "last line"
    "setTimeout"
    "writeFile"
    "setImmediate"
    "promise"
    "readFile"

這裏要注意的三點:

  • 異步操做取決於添加到隊列以前的延遲時間。並不取決於它們在程序中的存放順序。
  • 事件循環在每次迭代之繼續檢查其餘任務以前,會連續檢查微任務隊列。
  • 即便在後臺有另外一個 IO 操做( readFile),事件循環也會執行檢查隊列中的函數。這樣作的緣由是此時 IO 隊列爲空。請記住,在執行 IO 隊列中的全部的函數以後,將會當即運行檢查隊列回調。

總結

JavaScript 是單線程的。每一個異步函數都由依賴操做系統內部函數工做的 Node.js 去處理。

Node.js 負責將回調函數(經過 JavaScript 附加到異步操做)添加到回調隊列中。事件循環會肯定將要在每次迭代中接下來要執行的回調函數。

瞭解隊列如何在 Node.js 中工做,使你對其有了更好的瞭解,由於隊列是環境的核心功能之一。Node.js 最受歡迎的定義是 non-blocking(非阻塞),這意味着異步操做能夠被正確的處理。都是由於有了事件循環和回調隊列才能使此功能生效。


做者:Dillion Megida
翻譯:瘋狂的技術宅
原文:https://blog.logrocket.com/a-deep-dive-into-queues-in-node-js/

Reference

[1]

Node.js 計時器功能: https://nodejs.org/en/docs/guides/timers-in-node/

[2]

JavaScript 語言自己沒有計時器功能: https://dillionmegida.com/p/browser-apis-and-javascript/#javascript-on-nodejs

[3]

流關閉事件: https://nodejs.org/api/stream.html#stream_event_close

[4]

http關閉事件: https://nodejs.org/api/http.html#http_event_close




前端面試神器助你一臂之力


精彩文章回顧,點擊直達

.


轉了嗎

讚了嗎

在看嗎


本文分享自微信公衆號 - 前端先鋒(jingchengyideng)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。

相關文章
相關標籤/搜索