サーバー側のI/Oパフォーマンス:ノード対PHP対Java対Go
公開: 2022-03-11アプリケーションの入出力(I / O)モデルを理解することは、受ける負荷を処理するアプリケーションと、実際のユースケースに直面してしわくちゃになるアプリケーションとの違いを意味する可能性があります。 おそらく、アプリケーションが小さく、高負荷に対応していない場合でも、問題ははるかに少ない可能性があります。 ただし、アプリケーションのトラフィック負荷が増加すると、間違ったI / Oモデルを使用すると、問題が発生する可能性があります。
また、複数のアプローチが可能なほとんどの状況と同様に、どちらが優れているかだけでなく、トレードオフを理解することも重要です。 I / Oの風景を散歩して、何をスパイできるか見てみましょう。
この記事では、Node、Java、Go、PHPをApacheと比較し、さまざまな言語がI / Oをモデル化する方法、各モデルの長所と短所について説明し、いくつかの基本的なベンチマークで締めくくります。 次のWebアプリケーションのI/Oパフォーマンスが心配な場合は、この記事が役に立ちます。
I / Oの基本:簡単な復習
I / Oに関連する要因を理解するには、最初にオペレーティングシステムレベルで概念を確認する必要があります。 これらの概念の多くを直接処理する必要はほとんどありませんが、アプリケーションのランタイム環境を介して間接的に処理します。 そして、詳細が重要です。
システムコール
まず、システムコールがあります。これは次のように説明できます。
- プログラム(「ユーザーランド」では、彼らが言うように)は、オペレーティングシステムカーネルに、その代わりにI/O操作を実行するように要求する必要があります。
- 「システムコール」は、プログラムがカーネルに何かをするように要求する手段です。 これを実装する方法の詳細はOSによって異なりますが、基本的な概念は同じです。 プログラムからカーネルに制御を移す特定の命令があります(関数呼び出しのようですが、この状況に対処するための特別なソースがあります)。 一般的に、システムコールはブロックされています。つまり、プログラムはカーネルがコードに戻るのを待ちます。
- カーネルは、問題の物理デバイス(ディスク、ネットワークカードなど)で基盤となるI / O操作を実行し、syscallに応答します。 現実の世界では、カーネルは、デバイスの準備が整うのを待つ、内部状態を更新するなど、要求を満たすために多くのことを行わなければならない場合がありますが、アプリケーション開発者はそれを気にしません。 それがカーネルの仕事です。
ブロッキングコールと非ブロッキングコール
さて、先ほど、システムコールがブロックされていると言いましたが、それは一般的な意味で真実です。 ただし、一部の呼び出しは「非ブロッキング」として分類されます。つまり、カーネルは要求を受け取り、それをキューまたはバッファのどこかに置き、実際のI/Oが発生するのを待たずにすぐに戻ります。 そのため、リクエストをキューに入れるのに十分な長さの非常に短い期間だけ「ブロック」します。
(Linux syscallsの)いくつかの例は明確にするのに役立つかもしれません:-read read()
はブロッキング呼び出しです-どのファイルとそれが読み取るデータを配信する場所のバッファーを示すハンドルを渡し、データがそこにあるときに呼び出しが返されます。 これには、素晴らしくシンプルであるという利点があることに注意してください。 epoll_create()
、 epoll_ctl()
、 epoll_wait()
はそれぞれ呼び出しであり、リッスンするハンドルのグループを作成し、そのグループからハンドラーを追加/削除して、アクティビティが発生するまでブロックします。 これにより、1つのスレッドで多数のI / O操作を効率的に制御できますが、私は自分より進んでいます。 これは、機能が必要な場合に最適ですが、ご覧のとおり、使用するのは確かに複雑です。
ここでは、タイミングの違いの大きさの順序を理解することが重要です。 CPUコアが3GHzで実行されている場合、CPUが実行できる最適化を行わずに、1秒あたり30億サイクル(または1ナノ秒あたり3サイクル)を実行しています。 非ブロッキングシステムコールは、完了するまでに数十サイクルのオーダー、つまり「比較的数ナノ秒」かかる場合があります。 ネットワークを介して受信される情報をブロックする呼び出しは、はるかに長い時間がかかる場合があります。たとえば、200ミリ秒(1/5秒)などです。 たとえば、非ブロッキング呼び出しに20ナノ秒かかり、ブロッキング呼び出しに2億ナノ秒かかったとします。 プロセスは、ブロッキング呼び出しを1,000万倍長く待機しました。
カーネルは、ブロッキングI / O(「このネットワーク接続から読み取ってデータを取得する」)と非ブロッキングI / O(「これらのネットワーク接続のいずれかに新しいデータがある場合に通知する」)の両方を実行する手段を提供します。 また、どのメカニズムを使用すると、呼び出しプロセスが大幅に異なる時間ブロックされます。
スケジューリング
従うことが重要な3番目のことは、ブロックを開始するスレッドまたはプロセスが多数ある場合に何が起こるかです。
私たちの目的では、スレッドとプロセスの間に大きな違いはありません。 実生活では、パフォーマンスに関連する最も顕著な違いは、スレッドが同じメモリを共有し、プロセスがそれぞれ独自のメモリスペースを持っているため、別々のプロセスを作成すると、より多くのメモリを消費する傾向があることです。 しかし、スケジューリングについて話しているとき、それが実際に要約するのは、利用可能なCPUコアで実行時間のスライスを取得するためにそれぞれが必要とするもの(スレッドとプロセスの両方)のリストです。 300のスレッドを実行し、それらを実行する8つのコアがある場合は、各コアが短時間実行されてから次のスレッドに移動するように、それぞれが共有されるように時間を分割する必要があります。 これは「コンテキストスイッチ」を介して行われ、CPUが1つのスレッド/プロセスの実行から次のスレッド/プロセスに切り替わるようにします。
これらのコンテキストスイッチにはコストがかかります。時間がかかります。 場合によっては、100ナノ秒未満になることもありますが、実装の詳細、プロセッサの速度/アーキテクチャ、CPUキャッシュなどによっては、1000ナノ秒以上かかることも珍しくありません。
また、スレッド(またはプロセス)が多いほど、コンテキストの切り替えも多くなります。 数千のスレッド、およびそれぞれの数百ナノ秒について話していると、処理が非常に遅くなる可能性があります。
ただし、本質的に非ブロッキング呼び出しは、カーネルに「これらの接続のいずれかに新しいデータまたはイベントがある場合にのみ電話をかけてください」と伝えます。 これらの非ブロッキング呼び出しは、大きなI / O負荷を効率的に処理し、コンテキスト切り替えを減らすように設計されています。
これまで私と一緒に? ここで楽しい部分があります。いくつかの人気のある言語がこれらのツールで何をするかを見て、使いやすさとパフォーマンスの間のトレードオフについていくつかの結論を導きましょう…そして他の興味深い一口。
注意として、この記事に示されている例は些細なものですが(そして部分的であり、関連するビットのみが示されています)。 データベースアクセス、外部キャッシングシステム(memcacheなど)、およびI / Oを必要とするものはすべて、内部で何らかのI / O呼び出しを実行することになります。これは、示されている単純な例と同じ効果があります。 また、I / Oが「ブロッキング」(PHP、Java)として記述されているシナリオでは、HTTP要求と応答の読み取りと書き込みは、それ自体が呼び出しをブロックしています。繰り返しになりますが、パフォーマンスの問題を伴うシステムに隠されたI/Oが増えます。考慮に入れる。
プロジェクトのプログラミング言語の選択には多くの要因があります。 パフォーマンスだけを考えると、多くの要因があります。 ただし、プログラムが主にI / Oによって制約されることが懸念される場合、I / Oパフォーマンスがプロジェクトの成功または失敗である場合、これらは知っておく必要のあることです。
「シンプルに保つ」アプローチ:PHP
90年代には、多くの人がコンバースの靴を履いて、PerlでCGIスクリプトを書いていました。 その後、PHPが登場し、一部の人々がそれを利用するのを好むのと同じように、動的Webページをはるかに簡単にしました。
PHPが使用するモデルはかなり単純です。 いくつかのバリエーションがありますが、平均的なPHPサーバーは次のようになります。
HTTPリクエストはユーザーのブラウザから着信し、ApacheWebサーバーに到達します。 Apacheは、リクエストごとに個別のプロセスを作成し、必要な数を最小限に抑えるためにそれらを再利用するためのいくつかの最適化を行います(プロセスの作成は比較的遅いです)。 ApacheはPHPを呼び出し、ディスク上で適切な.php
ファイルを実行するように指示します。 PHPコードが実行され、I/O呼び出しがブロックされます。 PHPでfile_get_contents()
を呼び出すと、内部でread()
システムコールが作成され、結果が待機します。
そしてもちろん、実際のコードはページに埋め込まれているだけで、操作はブロックされています。
<?php // blocking file I/O $file_data = file_get_contents('/path/to/file.dat'); // blocking network I/O $curl = curl_init('http://example.com/example-microservice'); $result = curl_exec($curl); // some more blocking network I/O $result = $db->query('SELECT id, data FROM examples ORDER BY id DESC limit 100'); ?>
これがシステムとどのように統合されるかという点では、次のようになります。
非常に単純です。リクエストごとに1つのプロセス。 I/O呼び出しはブロックするだけです。 アドバンテージ? シンプルで機能します。 不利益? 同時に20,000のクライアントでそれを打つと、サーバーは炎上します。 大量のI/O(epollなど)を処理するためにカーネルが提供するツールが使用されていないため、このアプローチは適切に拡張できません。 また、怪我に侮辱を加えるために、リクエストごとに個別のプロセスを実行すると、多くのシステムリソース、特にメモリを使用する傾向があります。これは、このようなシナリオで最初に不足することがよくあります。
注:Rubyに使用されるアプローチは、PHPのアプローチと非常によく似ており、広く、一般的に、手で波打つ方法で、私たちの目的では同じと見なすことができます。
マルチスレッドアプローチ:Java
そのため、最初のドメイン名を購入したちょうどその頃にJavaが登場し、文の後に「ドットコム」とランダムに言うのはクールでした。 また、Javaにはマルチスレッドが言語に組み込まれています。これは(特に作成時の場合)非常に優れています。
ほとんどのJavaWebサーバーは、着信する要求ごとに新しい実行スレッドを開始し、このスレッドで最終的に、アプリケーション開発者として作成した関数を呼び出すことで機能します。
JavaサーブレットでI/Oを実行すると、次のようになります。
public void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // blocking file I/O InputStream fileIs = new FileInputStream("/path/to/file"); // blocking network I/O URLConnection urlConnection = (new URL("http://example.com/example-microservice")).openConnection(); InputStream netIs = urlConnection.getInputStream(); // some more blocking network I/O out.println("..."); }
上記のdoGet
メソッドは1つのリクエストに対応し、独自のスレッドで実行されるため、独自のメモリを必要とするリクエストごとに個別のプロセスではなく、個別のスレッドがあります。 これには、スレッド間で状態やキャッシュされたデータなどを共有できるなど、いくつかの優れた特典があります。スレッドは相互のメモリにアクセスできるためですが、スケジュールとの相互作用への影響は、PHPで行われていることとほぼ同じです。前の例。 各リクエストは新しいスレッドを取得し、リクエストが完全に処理されるまで、そのスレッド内でさまざまなI/O操作がブロックされます。 スレッドは、スレッドの作成と破棄のコストを最小限に抑えるためにプールされますが、それでも、数千の接続は数千のスレッドを意味し、スケジューラーにとっては悪いことです。
重要なマイルストーンは、バージョン1.4でJava(および1.7で再び大幅なアップグレード)がノンブロッキングI/O呼び出しを実行できるようになったことです。 Webやその他のほとんどのアプリケーションはそれを使用しませんが、少なくともそれは利用可能です。 一部のJavaWebサーバーは、これをさまざまな方法で利用しようとします。 ただし、デプロイされたJavaアプリケーションの大部分は、上記のように機能します。
Javaは私たちを近づけ、確かにI / O用の優れたすぐに使える機能をいくつか備えていますが、それでも、I/Oバウンドの多いアプリケーションが大量に使用されている場合に何が起こるかという問題を実際には解決しません。何千ものブロッキングスレッドがある地面。
第一級市民としてのノンブロッキングI/O:ノード
より良いI/Oに関しては、ブロックで人気のある子供はNode.jsです。 Nodeを簡単に紹介したことのある人なら誰でも、Nodeは「ノンブロッキング」であり、I/Oを効率的に処理すると言われています。 そして、これは一般的な意味で真実です。 しかし、悪魔は詳細にあり、この魔術が達成された手段は、パフォーマンスに関しては重要です。
基本的に、Nodeが実装するパラダイムシフトは、「リクエストを処理するためにここにコードを書く」と言う代わりに、「リクエストの処理を開始するためにここにコードを書く」と言うことです。 I / Oを伴う何かを行う必要があるたびに、要求を行い、それが完了したときにノードが呼び出すコールバック関数を提供します。

リクエストでI/O操作を実行するための一般的なノードコードは、次のようになります。
http.createServer(function(request, response) { fs.readFile('/path/to/file', 'utf8', function(err, data) { response.end(data); }); });
ご覧のとおり、ここには2つのコールバック関数があります。 1つ目はリクエストの開始時に呼び出され、2つ目はファイルデータが利用可能になったときに呼び出されます。
これは基本的に、これらのコールバック間のI/Oを効率的に処理する機会をノードに与えることです。 さらに関連性の高いシナリオは、Nodeでデータベース呼び出しを行う場合ですが、これはまったく同じ原則であるため、例については気にしません。データベース呼び出しを開始し、Nodeにコールバック関数を指定します。非ブロッキング呼び出しを使用して個別にI/O操作を実行し、要求したデータが利用可能になったときにコールバック関数を呼び出します。 I / O呼び出しをキューに入れ、Nodeに処理させてからコールバックを取得するこのメカニズムは、「イベントループ」と呼ばれます。 そして、それはかなりうまく機能します。
ただし、このモデルには問題があります。 内部的には、その理由は、V8 JavaScriptエンジン(Nodeで使用されるChromeのJSエンジン)がどのように実装されているかと関係があります1 。 作成するJSコードはすべて、単一のスレッドで実行されます。 ちょっと考えてみてください。 つまり、I / Oは効率的な非ブロッキング手法を使用して実行されますが、CPUバウンド操作を実行しているJS缶は単一のスレッドで実行され、コードの各チャンクが次のスレッドをブロックします。 これが発生する可能性のある一般的な例は、データベースレコードをループして、クライアントに出力する前に何らかの方法でそれらを処理することです。 これがどのように機能するかを示す例です。
var handler = function(request, response) { connection.query('SELECT ...', function (err, rows) { if (err) { throw err }; for (var i = 0; i < rows.length; i++) { // do processing on each row } response.end(...); // write out the results }) };
NodeはI/Oを効率的に処理しますが、上記の例のfor
ループは、唯一のメインスレッド内でCPUサイクルを使用しています。 つまり、接続数が10,000の場合、所要時間によっては、そのループによってアプリケーション全体がクロールされる可能性があります。 各リクエストは、メインスレッドで一度に1つずつ時間のスライスを共有する必要があります。
この概念全体の前提は、I / O操作が最も遅い部分であるため、他の処理を連続して行うことを意味する場合でも、それらを効率的に処理することが最も重要です。 これは場合によっては当てはまりますが、すべてではありません。
もう1つのポイントは、これは単なる意見ですが、ネストされたコールバックの束を作成するのは非常に面倒であり、コードを追跡するのが非常に困難になると主張する人もいます。 コールバックがノードコードの奥深くに4、5、またはそれ以上のレベルでネストされているのを見るのは珍しいことではありません。
トレードオフに戻ります。 主なパフォーマンスの問題がI/Oである場合、ノードモデルは適切に機能します。 ただし、そのアキレス腱は、HTTPリクエストを処理する関数に入り、CPUを集中的に使用するコードを挿入し、注意しないとすべての接続をクロールに持ち込むことができるということです。
当然ノンブロッキング:Go
Goのセクションに入る前に、私がGoのファンであることを開示するのが適切です。 私はこれを多くのプロジェクトで使用してきましたが、その生産性の利点を公然と支持しており、使用すると仕事でそれらを目にします。
そうは言っても、それがI/Oをどのように扱うかを見てみましょう。 Go言語の重要な機能の1つは、独自のスケジューラーが含まれていることです。 単一のOSスレッドに対応する実行の各スレッドの代わりに、「ゴルーチン」の概念で動作します。 また、Goランタイムは、そのゴルーチンの実行内容に基づいて、ゴルーチンをOSスレッドに割り当てて実行するか、一時停止してOSスレッドに関連付けないようにすることができます。 GoのHTTPサーバーから着信する各リクエストは、個別のGoroutineで処理されます。
スケジューラーの動作の図は次のようになります。
内部的には、これは、書き込み/読み取り/接続などの要求を行うことによってI / O呼び出しを実装し、現在のゴルーチンをスリープ状態にし、ゴルーチンをウェイクアップするための情報を含むGoランタイムのさまざまなポイントによって実装されます。さらにアクションをとることができるときにアップします。
事実上、Goランタイムは、コールバックメカニズムがI / O呼び出しの実装に組み込まれ、スケジューラーと自動的に対話することを除いて、Nodeが実行していることとそれほど異ならないことを実行しています。 また、すべてのハンドラーコードを同じスレッドで実行する必要があるという制限もありません。Goは、スケジューラーのロジックに基づいて、適切と見なされる数のOSスレッドにGoroutinesを自動的にマップします。 結果は次のようなコードになります。
func ServeHTTP(w http.ResponseWriter, r *http.Request) { // the underlying network call here is non-blocking rows, err := db.Query("SELECT ...") for _, row := range rows { // do something with the rows, // each request in its own goroutine } w.Write(...) // write the response, also non-blocking }
上記のように、私たちが行っている基本的なコード構造は、より単純なアプローチのコード構造に似ていますが、内部でノンブロッキングI/Oを実現しています。
ほとんどの場合、これは「両方の長所」になります。 ノンブロッキングI/Oはすべての重要なことに使用されますが、コードはブロッキングしているように見えるため、理解と保守が簡単になる傾向があります。 GoスケジューラーとOSスケジューラーの間の相互作用が残りを処理します。 これは完全な魔法ではありません。大規模なシステムを構築する場合は、その仕組みについて詳しく理解するために時間をかける価値があります。 しかし同時に、「すぐに使える」環境が機能し、拡張性が非常に高くなります。
Goには欠点があるかもしれませんが、一般的に言って、I/Oを処理する方法はその中にはありません。
嘘、大嘘、ベンチマーク
これらのさまざまなモデルに関連するコンテキスト切り替えの正確なタイミングを示すことは困難です。 また、それはあなたにとってあまり役に立たないと主張することもできます。 代わりに、これらのサーバー環境の全体的なHTTPサーバーのパフォーマンスを比較するいくつかの基本的なベンチマークを示します。 エンドツーエンドのHTTP要求/応答パス全体のパフォーマンスには多くの要因が関係していることを覚えておいてください。ここに示されている数値は、基本的な比較のためにまとめたサンプルのほんの一部です。
これらの環境のそれぞれについて、ランダムなバイトを含む64kファイルを読み取る適切なコードを記述し、SHA-256ハッシュをN回実行しました(NはURLのクエリ文字列で指定されています。例: .../test.php?n=100
)、結果のハッシュを16進数で出力します。 これを選択したのは、一貫したI / Oを使用して同じベンチマークを実行する非常に簡単な方法であり、CPU使用率を上げるための制御された方法だからです。
使用される環境の詳細については、これらのベンチマークノートを参照してください。
まず、同時実行性の低い例をいくつか見てみましょう。 300の同時リクエストとリクエストごとに1つのハッシュ(N = 1)で2000回の反復を実行すると、次のようになります。
この1つのグラフだけから結論を出すのは難しいですが、これは私には、この接続と計算の量で、言語自体の一般的な実行に関係することが多くなっているように見えます。 I/O。 「スクリプト言語」と見なされる言語(緩い型付け、動的解釈)のパフォーマンスが最も遅いことに注意してください。
しかし、Nを1000に増やしても、300の同時リクエストがある場合はどうなりますか?同じ負荷ですが、ハッシュの反復が100倍多くなります(CPUの負荷が大幅に増えます)。
各リクエストのCPUを集中的に使用する操作が相互にブロックしているため、突然、ノードのパフォーマンスが大幅に低下します。 そして興味深いことに、このテストでは、PHPのパフォーマンスが(他の製品と比較して)はるかに向上し、Javaよりも優れています。 (PHPではSHA-256の実装はCで記述されており、現在1000回のハッシュ反復を行っているため、実行パスはそのループでより多くの時間を費やしていることに注意してください)。
それでは、5000の同時接続(N = 1)を試してみましょう-または私が来ることができる限りそれに近いものです。 残念ながら、これらの環境のほとんどでは、故障率は重要ではありませんでした。 このグラフでは、1秒あたりのリクエストの総数を確認します。 高いほど良い:
そして、絵はかなり異なって見えます。 推測ですが、接続量が多い場合、新しいプロセスの生成に伴う接続ごとのオーバーヘッドと、PHP + Apacheでのそれに関連する追加のメモリが支配的な要因になり、PHPのパフォーマンスが低下するようです。 明らかに、ここではGoが勝者であり、Java、Node、そして最後にPHPが続きます。
全体的なスループットに関係する要因は多く、アプリケーションごとに大きく異なりますが、内部で何が起こっているのか、および関連するトレードオフについて理解すればするほど、より良い結果が得られます。
要約すれば
上記のすべてで、言語が進化するにつれて、多くのI/Oを実行する大規模なアプリケーションを処理するためのソリューションが進化したことは明らかです。
公平を期すために、この記事の説明にもかかわらず、PHPとJavaの両方に、Webアプリケーションで使用できる非ブロッキングI/Oの実装があります。 ただし、これらは上記のアプローチほど一般的ではなく、そのようなアプローチを使用してサーバーを保守するための付随する運用上のオーバーヘッドを考慮する必要があります。 言うまでもなく、コードはそのような環境で機能するように構造化する必要があります。 「通常の」PHPまたはJavaWebアプリケーションは、通常、このような環境で大幅な変更を加えないと実行されません。
比較として、パフォーマンスと使いやすさに影響を与えるいくつかの重要な要因を考慮すると、次のようになります。
言語 | スレッドとプロセス | ノンブロッキングI/O | 使いやすさ |
---|---|---|---|
PHP | プロセス | 番号 | |
Java | スレッド | 利用可能 | コールバックが必要 |
Node.js | スレッド | はい | コールバックが必要 |
行け | スレッド(Goroutines) | はい | コールバックは必要ありません |
スレッドは、プロセスが共有しないのに対して同じメモリスペースを共有するため、通常、プロセスよりもはるかにメモリ効率が高くなります。 これを非ブロッキングI/Oに関連する要因と組み合わせると、少なくとも上記の要因で、リストを下に移動すると、I/Oに関連する一般的な設定が改善されることがわかります。 したがって、上記のコンテストで勝者を選ぶ必要がある場合、それは確かにGoになります。
それでも、実際には、アプリケーションを構築するための環境を選択することは、チームがその環境に精通していること、およびそれを使用して達成できる全体的な生産性と密接に関連しています。 そのため、すべてのチームがNodeまたはGoでWebアプリケーションとサービスの開発を開始するだけでは意味がない場合があります。 実際、開発者を見つけることや社内チームに精通していることが、別の言語や環境を使用しない主な理由としてよく挙げられます。 とはいえ、過去15年ほどで時代は大きく変わりました。
うまくいけば、上記が内部で何が起こっているのかをより明確に描くのに役立ち、アプリケーションの実際のスケーラビリティに対処する方法のいくつかのアイデアを提供します。 ハッピー入力と出力!