非同期プログラミング、コールバック、およびprocess.nextTick()の使用

Node.jsを勉強する人のほとんどはある程度JavaScriptを知っており、ブラウザのコンテキストでJavaScriptを使用した経験がありますが、実際の問題を議論する一方で、多くのネストされたコードを含むコードの非同期実行を保証するメカニズムと標準ライブラリの動作を理解するのは困難ですコールバック。 Node.jsのイベントループの動作を簡単に説明し、高品質の非同期コードを作成する際に注意すべき点を説明します。 この記事は、ブラウザ用の生産的なフレームワークの作成に携わっている人にとって役立つと思います。







叙情的な余談:Node.jsの基礎となるイベントのサイクル





何度も書かれているように、Node.jsはlibevライブラリによって実装されたイベントループに基づいています。 ループの各ターンで、次のことが発生します。まず、process.nextTick()を使用して、ループの前のループにインストールされた関数が実行されます。 次は、libevイベント、特にタイマーイベントの処理です。 最後になりましたが、 libeioは I / Oを完了し、それらに設定されたコールバックを完了するためにポーリングされます。 ループの通過中に、process.nextTick()を使用して機能がインストールされていないことが判明した場合、libevおよびlibeioに単一のタイマーおよびリクエストキューがなく、ノードが終了します。 イベントループの詳細については、プレゼンテーションwww.slideshare.net/jacekbecela/introduction-to-nodejsにアクセスしてください



したがって、サーバーのロジックがほとんどなく、主に低コストで着信データを処理する場合、サイクルは非常に頻繁に実行されます。 ただし、データ処理に時間がかかる場合は、このプロセスを個別の部分に分割し、新しい接続のサービスを開始したり、ディスクから非同期に読み取られたデータを処理したりできるように、イベントループに制御を戻すことをお勧めします。 HTTPサーバーの例を考えてみましょう。アクセスすると、クエリ文字列に対応する名前で現在のフォルダーからファイルを読み取り、そのコンテンツから特定のハッシュサムを返します。



テストサーバーの同期バージョン





// readFileSync.js var http = require('http'), fs = require('fs'); function func1(str) { var res = ''; for (var i = 0, l = str.length; i < l; i++) { res += str.charCodeAt(i); } return res; } function func2(str) { var res = 0; for (var i = 0, l = str.length; i < l; i++) { res += Math.sin(str.charCodeAt(i)); } return '' + res; } http.createServer(function (req, res) { // Very simple and dangerous check var filename = req.url.replace(/\?.*/, '').replace(/(\.\.|\/)/, ''); // Read file from disk try { var filecontent = fs.readFileSync(filename, 'utf8'); } catch (e) { res.writeHead(404, {'Content-Type': 'text/plain'}); res.end('File ' + filename + ' doesn\'t exist'); return; } // Calculate checksum var hash = func2(func1(filecontent)); // Write response res.writeHead(200, {'Content-Type': 'text/plain'}); res.end(hash); }).listen(8124, "127.0.0.1");
      
      







サンプルサーバーには、ディスクからのファイルの同期読み取りが含まれており、読み取りが完了するまで実行をブロックし、その後に大きなファイルサイズで長時間実行できる2つの関数の値を計算します。 さらに、読み取りにT read秒かかり、T calc秒の合計を計算する場合、そのようなブロッキングサーバーは1秒あたり1 /(T read + T calc )未満のリクエストを処理できます。 より多くの接続を処理できるようにすることで、サーバーをどのように改善できますか? まず、非ブロッキングファイル読み取りを使用します。



ファイルを非同期で読み取り、コールバックを使用しようとしています





  // readFile.js var http = require('http'), fs = require('fs'); function func1(str) { var res = ''; for (var i = 0, l = str.length; i < l; i++) { res += str.charCodeAt(i); } return res; } function func2(str) { var res = 0; for (var i = 0, l = str.length; i < l; i++) { res += Math.sin(str.charCodeAt(i)); } return '' + res; } http.createServer(function (req, res) { // Very simple and dangerous check var filename = req.url.replace(/\?.*/, '').replace(/(\.\.|\/)/, ''); // Read file from disk fs.readFile(filename, 'utf8', function (err, filecontent) { if (err) { res.writeHead(404, {'Content-Type': 'text/plain'}); res.end('File ' + filename + ' doesn\'t exist'); return; } // Calculate checksum var hash = func2(func1(filecontent)); // Write response res.writeHead(200, {'Content-Type': 'text/plain'}); res.end(hash); }); }).listen(8124, "127.0.0.1");
      
      







>非同期読み取りの使用により、バックグラウンドでの計算中に別の要求のためにファイルが読み取られるため、各要求の処理を高速化できます。 <strikeしたがって、同期サーバーの場合のように、処理時間はmin(T read 、T calc )であり、(T read + T calc )ではありません。 したがって、2つの要求が同期の場合に到着すると、それらのサービス時間はT read1 + T calc1 + T read2 + T calc2になり、非同期読み取りの場合、T read1 + T read2 + min(T calc1 、T calc2 )に達する可能性があります。



これはすでに良いことです。 しかし、ファイルの処理時間がファイルの読み取り時間よりもはるかに長く、また大幅に変動する場合はどうでしょうか? この場合、1つのファイルの量の計算中に、他のいくつかの小さなファイルを時間内に読み取ることができ、その後すぐに処理されます。 さらに、上記の例では、クライアントはサーバーへのリクエストが送信されたのとほぼ同じ順序で結果を受け取ります。 ただし、小さなファイルを要求する顧客や、処理時間を短縮する必要のある顧客に、結果をより早く返すことは論理的です。 これを行うには、ネストされた処理関数の長いチェーンを使用する場合、何らかの方法でfunc1()を計算した後、メインスレッドに制御を戻し、サイクルの次のターンでfunc2()を計算してクライアントに結果を返す必要があります。 このため、1つの要求に対するfunc1()とfunc2()の計算の間隔で、新しい接続が受け入れられ、別のファイルを読み取るタスクが作成されるか、すでに読み取られている小さなファイルが処理される場合があります。



それでは、Node.jsの初心者は何をしますか(実際、一般的なJavaScript VMでの言語の使用に関係するため、JavaScriptの初心者と呼ばれるべきです)。 標準ライブラリの非同期I / O関数は、呼び出しの直後にメインスレッドに実行を返すため、多くの人々は、コールバックを受け入れる関数を記述するだけで十分であり、呼び出しのメインスレッドにギャップが生じると考えています。



  // readFile-and-sync-chain.js var http = require('http'), fs = require('fs'); function func1(str) { var res = ''; for (var i = 0, l = str.length; i < l; i++) { res += str.charCodeAt(i); } return res; } function func2(str) { var res = 0; for (var i = 0, l = str.length; i < l; i++) { res += Math.sin(str.charCodeAt(i)); } return '' + res; } function func1_cb(str, cb) { var res = func1(str); cb(res); } function func2_cb(str, cb) { var res = func2(str); cb(res); } http.createServer(function (req, res) { // Very simple and dangerous check var filename = req.url.replace(/\?.*/, '').replace(/(\.\.|\/)/, ''); // Read file from disk fs.readFile(filename, 'utf8', function (err, filecontent) { if (err) { res.writeHead(404, {'Content-Type': 'text/plain'}); res.end('File ' + filename + ' doesn\'t exist'); return; } // Calculate checksum func1_cb(filecontent, function (str) { func2_cb(str, function (hash) { // Write response res.writeHead(200, {'Content-Type': 'text/plain'}); res.end(hash); }); }); }); }).listen(8124, "127.0.0.1");
      
      







実際に何が起こるでしょうか? もちろん、魔法はありません。 唯一の違いは、純粋に命令型の合計計算コードを2つのネストされたコールバック関数を含むコードに置き換えた点です。これらの関数は、順次呼び出し、不要な関数呼び出しにより計算時間をわずかに増やし、最終的にはサーバーのパフォーマンスを低下させるだけです。



非同期ファイル読み取りと適切な非同期処理





実行のメインスレッドに制御を移し、同時にfunc1()を計算した後の量をさらに処理するという将来のタスクを実行するには、JavaScriptで使用可能な古い実績のあるツールsetTimeout(fn、0)を使用できます。 ブラウザ用にプログラムした場合は、この関数を使用する必要があります。 しかし、上記で書いたように、Node.jsにはprocess.nextTick(fn)関数があり、これはより効率的で、渡される関数はタイマーを使用して設定された関数、またはソケットまたはファイルシステムからのイベントハンドラーよりも早く実行されることが保証されます。 したがって、readFile-and-sync-chain.jsサーバーコードは次のように書き換えることができます。



  // readFile-and-nextTick.js var http = require('http'), fs = require('fs'); function func1(str) { var res = ''; for (var i = 0, l = str.length; i < l; i++) { res += str.charCodeAt(i); } return res; } function func2(str) { var res = 0; for (var i = 0, l = str.length; i < l; i++) { res += Math.sin(str.charCodeAt(i)); } return '' + res; } function func1_cb(str, cb) { var res = func1(str); process.nextTick(function () { cb(res); }); } function func2_cb(str, cb) { var res = func2(str); process.nextTick(function () { cb(res); }); } http.createServer(function (req, res) { // Very simple and dangerous check var filename = req.url.replace(/\?.*/, '').replace(/(\.\.|\/)/, ''); // Read file from disk fs.readFile(filename, 'utf8', function (err, filecontent) { if (err) { res.writeHead(404, {'Content-Type': 'text/plain'}); res.end('File ' + filename + ' doesn\'t exist'); return; } // Calculate checksum func1_cb(filecontent, function (str) { func2_cb(str, function (hash) { // Write response res.writeHead(200, {'Content-Type': 'text/plain'}); res.end(hash); }); }); }); }).listen(8124, "127.0.0.1");
      
      







この結果は、サーバーの最終バージョンであり、すべての操作を非同期に実行し、イベントのサイクルをブロックする最小限のコードセクションを含みます。



考慮されるオプションのパフォーマンスの比較





上記のすべてのことは、ほとんどの場合、正しいアーキテクチャに関する議論です。 実際、特定のオプションのパフォーマンスは、読み込まれたファイルのサイズとその処理時間、ファイルサイズに対する処理時間の非線形依存性、およびサーバーが処理する異なるリクエストの数に依存する場合があります。 それにもかかわらず、テストでは、最悪の場合でも、アーキテクチャの観点からより適切なソリューションを使用しても、サーバーの速度が10%以上低下しないことが示されました。



比較のために、128バイトから1 MBのサイズのファイルが使用され、Apache Benchを使用してサーバーがロードされました。



 ab2 -n 1000 -c 100 http://127.0.0.1:8124/filename
      
      







結果はグラフに表示されます。





ご覧のとおり、非同期読み取りを使用すると、ファイルサイズが大きい場合にサーバーのパフォーマンスが向上します。 これは、小さなファイルを読み取るときに、特にFSキャッシュから読み取られるため、非同期読み取り用のさまざまな構造を作成するオーバーヘッドがファイルの読み取り時間を超えるためです。 予想どおり、通常のネストされた関数を使用してもパフォーマンスは向上しません。 しかし、段階的なファイル処理に非同期コールバックを使用すると、小さなファイルサイズで明確に見える結果がもたらされました。



ただし、テスト結果は、サーバーが受信するさまざまな要求だけでなく、要求されたファイルのサイズにも大きく左右されます。 いくつかのファイルと、それらに対する異なる数のリクエストを使用した拡張テストの時間があることを願っています。 また、一部のオペレーティングシステムでの非同期I / Oの不完全な実装に関連する問題、およびそのようなシステムの非同期操作をエミュレートするためにlibeioが使用するスレッド数の制限を意図的に考慮しません。



PSこの問題を提起してくれたforum.nodejs.ruのnewsのnodejsに感謝します。



All Articles