對 Node.js Promise 進行基準測試
已發表: 2022-03-11我們生活在一個勇敢的新世界。 一個充滿 JavaScript 的世界。 近年來,JavaScript 主導了網絡,席捲了整個行業。 在引入 Node.js 之後,JavaScript 社區能夠利用語言的簡單性和動態性成為唯一的語言,處理服務器端、客戶端,甚至大膽地在機器學習方面佔據一席之地。 但是在過去的幾年裡,JavaScript 作為一種語言發生了巨大的變化。 引入了以前從未有過的新概念,例如箭頭函數和承諾。
啊,承諾。 當我第一次開始學習 Node.js 時,promise 和 callback 的整個概念對我來說並沒有多大意義。 我習慣了執行代碼的程序化方式,但隨著時間的推移,我明白了它為什麼很重要。
這給我們帶來了一個問題,為什麼要引入回調和承諾? 為什麼我們不能在 JavaScript 中編寫順序執行的代碼?
好吧,從技術上講,你可以。 但是你應該嗎?
在本文中,我將簡要介紹 JavaScript 及其運行時,更重要的是,測試 JavaScript 社區普遍認為同步代碼在性能上是低於標準的,從某種意義上說,只是邪惡的,不應該使用。 這個神話是真的嗎?
在開始之前,本文假設您已經熟悉 JavaScript 中的 Promise,但是,如果您不熟悉或需要復習,請參閱JavaScript Promises: A Tutorial with Examples
注意:本文已經在 Node.js 環境中進行了測試,而不是純 JavaScript 環境。 運行 Node.js 版本 10.14.2。 所有基準和語法都將嚴重依賴 Node.js。 測試在配備英特爾 i5 第 8 代四核處理器的 MacBook Pro 2018 上運行,基本時鐘速度為 2.3 GHz。
事件循環
編寫 JavaScript 的問題在於語言本身是單線程的。 這意味著您不能一次執行多個過程,這與其他語言(例如 Go 或 Ruby)不同,它們能夠在內核線程或進程線程上同時生成線程並執行多個過程.
為了執行代碼,JavaScript 依賴於一個稱為事件循環的過程,該過程由多個階段組成。 JavaScript 過程經歷了每個階段,最後又重新開始。 您可以在此處閱讀更多關於 node.js 官方指南的詳細信息。
但是 JavaScript 有一些東西可以解決阻塞問題。 I/O 回調。
大多數需要我們創建線程的實際用例是我們正在請求一些語言不負責的操作,例如,請求從數據庫中獲取一些數據。 在多線程語言中,創建請求的線程只是掛起或等待來自數據庫的響應。 這只是資源的浪費。 它還給開發人員在線程池中選擇正確數量的線程帶來了負擔。 這是為了防止內存洩漏和在應用程序需求量大時分配大量資源。
JavaScript 在處理 I/O 操作方面比其他任何因素都更擅長。 JavaScript 允許您調用 I/O 操作,例如從數據庫請求數據、將文件讀入內存、將文件寫入磁盤、執行 shell 命令等。當操作完成時,您將執行回調。 或者在 Promise 的情況下,您使用結果解決 Promise 或以錯誤拒絕它。
JavaScript 社區總是建議我們在進行 I/O 操作時永遠不要使用同步代碼。 眾所周知的原因是我們不想阻止我們的代碼運行其他任務。 由於是單線程的,如果我們有一段代碼同步讀取一個文件,代碼會阻塞整個過程,直到讀取完成。 相反,如果我們依賴異步代碼,我們可以執行多個 I/O 操作,並在每個操作完成時單獨處理它的響應。 沒有任何阻擋。
但可以肯定的是,在我們根本不關心處理大量進程的環境中,使用同步和異步代碼根本沒有區別,對吧?
基準
我們將要運行的測試旨在為我們提供有關同步和異步代碼運行速度以及性能是否存在差異的基準。
我決定選擇讀取文件作為 I/O 操作進行測試。
首先,我編寫了一個函數,該函數將寫入一個隨機文件,其中填充了由 Node.js Crypto 模塊生成的隨機字節。
const fs = require('fs'); const crypto = require('crypto'); fs.writeFileSync( "./test.txt", crypto.randomBytes(2048).toString('base64') )
該文件將作為我們下一步讀取文件的常量。 這是代碼
const fs = require('fs'); process.on('unhandledRejection', (err)=>{ console.error(err); }) function synchronous() { console.time("sync"); fs.readFileSync("./test.txt") console.timeEnd("sync") } async function asynchronous() { console.time("async"); let p0 = fs.promises.readFile("./test.txt"); await Promise.all([p0]) console.timeEnd("async") } synchronous() asynchronous()
運行前面的代碼會產生以下結果:
跑步 # | 同步 | 異步 | 異步/同步比率 |
---|---|---|---|
1 | 0.278ms | 3.829 毫秒 | 13.773 |
2 | 0.335ms | 3.801ms | 11.346 |
3 | 0.403ms | 4.498ms | 11.161 |
這是出乎意料的。 我最初的期望是他們應該花同樣的時間。 那麼,我們添加另一個文件並讀取 2 個文件而不是 1 個文件怎麼樣?
我複制了生成的文件 test.txt 並將其命名為 test2.txt。 這是更新的代碼:
function synchronous() { console.time("sync"); fs.readFileSync("./test.txt") fs.readFileSync("./test2.txt") console.timeEnd("sync") } async function asynchronous() { console.time("async"); let p0 = fs.promises.readFile("./test.txt"); let p1 = fs.promises.readFile("./test2.txt"); await Promise.all([p0,p1]) console.timeEnd("async") }
我只是為它們中的每一個添加了另一個閱讀,並且在承諾中,我正在等待應該並行運行的閱讀承諾。 結果如下:
跑步 # | 同步 | 異步 | 異步/同步比率 |
---|---|---|---|
1 | 1.659ms | 6.895ms | 4.156 |
2 | 0.323ms | 4.048ms | 12.533 |
3 | 0.324ms | 4.017ms | 12.398 |
4 | 0.333ms | 4.271ms | 12.826 |
第一個具有與隨後的 3 個運行完全不同的值。 我的猜測是它與 JavaScript JIT 編譯器有關,它會在每次運行時優化代碼。

所以,對於異步函數來說,事情看起來並不那麼好。 也許如果我們讓事情變得更有活力,或者對應用程序施加更多壓力,我們可能會產生不同的結果。
所以我的下一個測試涉及編寫 100 個不同的文件,然後將它們全部讀取。
首先,我在執行測試之前修改了代碼寫了 100 個文件。 每次運行時文件都不同,但大小幾乎相同,因此我們在每次運行前清除舊文件。
這是更新的代碼:
let filePaths = []; function writeFile() { let filePath = `./files/${crypto.randomBytes(6).toString('hex')}.txt` fs.writeFileSync( filePath, crypto.randomBytes(2048).toString('base64') ) filePaths.push(filePath); } function synchronous() { console.time("sync"); /* fs.readFileSync("./test.txt") fs.readFileSync("./test2.txt") */ filePaths.forEach((filePath)=>{ fs.readFileSync(filePath) }) console.timeEnd("sync") } async function asynchronous() { console.time("async"); /* let p0 = fs.promises.readFile("./test.txt"); let p1 = fs.promises.readFile("./test2.txt"); */ // await Promise.all([p0,p1]) let promiseArray = []; filePaths.forEach((filePath)=>{ promiseArray.push(fs.promises.readFile(filePath)) }) await Promise.all(promiseArray) console.timeEnd("async") }
對於清理和執行:
let oldFiles = fs.readdirSync("./files") oldFiles.forEach((file)=>{ fs.unlinkSync("./files/"+file) }) if (!fs.existsSync("./files")){ fs.mkdirSync("./files") } for (let index = 0; index < 100; index++) { writeFile() } synchronous() asynchronous()
讓我們跑吧。
這是結果表:
跑步 # | 同步 | 異步 | 異步/同步比率 |
---|---|---|---|
1 | 4.999 毫秒 | 12.890 毫秒 | 2.579 |
2 | 5.077ms | 16.267 毫秒 | 3.204 |
3 | 5.241ms | 14.571 毫秒 | 2.780 |
4 | 5.086ms | 16.334 毫秒 | 3.213 |
這些結果在這裡開始得出結論。 它表明隨著需求或併發性的增加,promise 開銷開始變得有意義。 更詳細地說,如果我們運行的 Web 服務器應該在每台服務器每秒運行數百甚至數千個請求,那麼使用同步運行 I/O 操作將開始很快失去它的好處。
只是為了實驗,讓我們看看這實際上是 Promise 本身的問題還是其他問題。 為此,我編寫了一個函數來計算解決一個完全不執行任何操作的承諾和另一個解決 100 個空承諾的時間。
這是代碼:
function promiseRun() { console.time("promise run"); return new Promise((resolve)=>resolve()) .then(()=>console.timeEnd("promise run")) } function hunderedPromiseRuns() { let promiseArray = []; console.time("100 promises") for(let i = 0; i < 100; i++) { promiseArray.push(new Promise((resolve)=>resolve())) } return Promise.all(promiseArray).then(()=>console.timeEnd("100 promises")) } promiseRun() hunderedPromiseRuns()
跑步 # | 單一承諾 | 100 個承諾 |
---|---|---|
1 | 1.651ms | 3.293ms |
2 | 0.758ms | 2.575ms |
3 | 0.814ms | 3.127ms |
4 | 0.788ms | 2.623ms |
有趣的。 看起來承諾不是延遲的主要原因,這讓我猜測延遲的來源是內核線程進行實際讀取。 這可能需要更多的實驗才能得出關於延遲背後主要原因的決定性結論。
最後一句話
那麼你應該使用 Promise 還是不使用? 我的意見如下:
如果您正在編寫一個腳本,該腳本將在具有由管道或單個用戶觸發的特定流程的單台機器上運行,那麼請使用同步代碼。 如果您正在編寫一個負責處理大量流量和請求的 Web 服務器,那麼來自異步執行的開銷將超過同步代碼的性能。
您可以在存儲庫中找到本文中所有功能的代碼。
從 Promise 開始,您的 JavaScript 開發者之旅合乎邏輯的下一步是 async/await 語法。 如果您想了解有關它的更多信息以及我們是如何到達這裡的,請參閱異步 JavaScript:從回調地獄到異步和等待。