Node.jsの約束のベンチマーク

公開: 2022-03-11

私たちは勇敢な新しい世界に住んでいます。 JavaScriptで満たされた世界。 近年、JavaScriptは業界全体を席巻し、Webを支配してきました。 Node.jsの導入後、JavaScriptコミュニティは、言語のシンプルさと動的性を利用して、サーバー側、クライアント側を処理し、大胆に機械学習の位置を主張することさえできました。 しかし、JavaScriptは過去数年間で言語として劇的に変化しました。 矢印関数やpromiseなど、これまでになかった新しい概念が導入されました。

ああ、約束します。 私が最初にNode.jsを学び始めたとき、promiseとcallbackの概念全体は私にはあまり意味がありませんでした。 私はコードを実行する手続き的な方法に慣れていましたが、やがてそれが重要である理由を理解しました。

これは私たちに質問をもたらします、なぜコールバックとプロミスがとにかく導入されたのですか? 順次実行されるコードをJavaScriptで記述できないのはなぜですか?

まあ、技術的にはできます。 しかし、あなたはすべきですか?

この記事では、JavaScriptとそのランタイムについて簡単に紹介します。さらに重要なことは、同期コードはパフォーマンスが標準以下であり、ある意味では単なる悪であり、決してすべきではないというJavaScriptコミュニティの広範な信念をテストすることです。利用される。 この神話は本当に本当ですか?

始める前に、この記事は、JavaScriptのpromiseに既に精通していることを前提としていますが、復習が必要でない場合、または必要な場合は、 JavaScript Promises:A TutorialwithExamplesを参照してください。

注意:この記事は、純粋なJavaScript環境ではなく、Node.js環境でテストされています。 Node.jsバージョン10.14.2を実行しています。 すべてのベンチマークと構文は、Node.jsに大きく依存します。 テストは、2.3GHzの基本クロック速度を実行するInteli5第8世代クアッドコアプロセッサを搭載したMacBookPro2018で実行されました。

イベントループ

Node.js Promiseのベンチマーク:Node.jsイベントループの図

JavaScriptを書く際の問題は、言語自体がシングルスレッドであるということです。 つまり、カーネルスレッドまたはプロセススレッドのいずれかでスレッドを生成し、同時に複数のプロシージャを実行する機能を持つGoやRubyなどの他の言語とは異なり、一度に複数の単一のプロシージャを実行することはできません。 。

JavaScriptは、コードを実行するために、複数のステージで構成されるイベントループと呼ばれるプロシージャに依存しています。 JavaScriptプロセスは各段階を経て、最後に最初からやり直します。 詳細については、node.jsの公式ガイドをご覧ください。

しかし、JavaScriptには、ブロッキングの問題に対抗するための何かがあります。 I/Oコールバック。

スレッドを作成する必要がある実際のユースケースのほとんどは、言語が責任を負わないアクションを要求しているという事実です。たとえば、データベースからのデータのフェッチを要求します。 マルチスレッド言語では、要求を作成したスレッドは単にハングするか、データベースからの応答を待ちます。 これは単なるリソースの浪費です。 また、スレッドプール内の正しいスレッド数を選択する際に開発者に負担をかけます。 これは、アプリの需要が高いときにメモリリークや大量のリソースの割り当てを防ぐためです。

JavaScriptは、I / O操作を処理するという点で、他のどの要素よりも優れています。 JavaScriptを使用すると、データベースからのデータの要求、メモリへのファイルの読み取り、ディスクへのファイルの書き込み、シェルコマンドの実行などのI / O操作を呼び出すことができます。操作が完了すると、コールバックが実行されます。 または、プロミスの場合は、結果を使用してプロミスを解決するか、エラーを使用して拒否します。

JavaScriptのコミュニティは、I/O操作を行うときに同期コードを使用しないように常にアドバイスしています。 そのよく知られた理由は、コードが他のタスクを実行するのをブロックしたくないということです。 シングルスレッドであるため、ファイルを同期的に読み取るコードがある場合、コードは読み取りが完了するまでプロセス全体をブロックします。 代わりに、非同期コードに依存している場合は、複数のI / O操作を実行し、完了時に各操作の応答を個別に処理できます。 ブロッキングは一切ありません。

しかし、確かに、多くのプロセスを処理することをまったく気にしない環境では、同期コードと非同期コードを使用してもまったく違いはありませんよね?

基準

実行するテストは、同期および非同期コードの実行速度と、パフォーマンスに違いがあるかどうかに関するベンチマークを提供することを目的としています。

テストするI/O操作としてファイルの読み取りを選択することにしました。

まず、Node.jsCryptoモジュールで生成されたランダムバイトで満たされたランダムファイルを書き込む関数を作成しました。

 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.829ms 13.773
2 0.335ms 3.801ms 11.346
3 0.403ms 4.498ms 11.161

これは予想外でした。 私の最初の期待は、彼らが同じ時間をとるべきだということでした。 さて、別のファイルを追加して、1つではなく2つのファイルを読み取るのはどうですか?

ファイルで生成された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つの実行とは完全に異なる値になります。 私の推測では、実行ごとにコードを最適化するJavaScriptJITコンパイラに関連していると思います。

そのため、非同期関数の場合はあまり良くありません。 物事をより動的にし、アプリにもう少しストレスをかけると、別の結果が得られる可能性があります。

したがって、次のテストでは、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.999ms 12.890ms 2.579
2 5.077ms 16.267ms 3.204
3 5.241ms 14.571ms 2.780
4 5.086ms 16.334ms 3.213

これらの結果は、ここで結論を導き始めます。 これは、需要または同時実行性の増加に伴い、オーバーヘッドが意味をなし始めることを約束していることを示しています。 詳細については、サーバーごとに1秒あたり数百または数千のリクエストを実行することになっているWebサーバーを実行している場合、同期を使用してI / O操作を実行すると、その利点がすぐに失われ始めます。

実験のために、それが実際に約束自体の問題なのか、それとも別の問題なのかを見てみましょう。 そのために、まったく何もしない1つのプロミスと、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

面白い。 約束は遅延の主な原因ではないようです。これにより、遅延の原因は実際の読み取りを行うカーネルスレッドであると推測されます。 これには、遅延の背後にある主な理由について決定的な結論を出すために、もう少し実験が必要になる場合があります。

最後の言葉

それで、あなたは約束を使うべきかどうか? 私の意見は次のようになります。

パイプラインまたは単一のユーザーによってトリガーされる特定のフローを使用して単一のマシンで実行されるスクリプトを作成している場合は、同期コードを使用します。 大量のトラフィックとリクエストの処理を担当するWebサーバーを作成している場合、非同期実行から生じるオーバーヘッドは同期コードのパフォーマンスを克服します。

この記事のすべての関数のコードは、リポジトリにあります。

約束からのJavaScript開発者の旅の論理的な次のステップは、async/await構文です。 それについて、そして私たちがここにたどり着いた方法についてもっと知りたい場合は、非同期JavaScript:CallbackHellからAsyncandAwaitまでを参照してください。