Server-side I/O Performance: Node 対 PHP 対 Java 対 Go
アプリケーションの入出力 (I/O) モデルを理解することは、それが受ける負荷に対処するアプリケーションと、現実の使用事例に直面してつぶれるアプリケーションの違いを意味することがあります。 おそらく、アプリケーションが小さく、高負荷を提供しない間は、それははるかに重要ではないかもしれません。 しかし、アプリケーションのトラフィック負荷が増加すると、間違った I/O モデルで作業すると、大変なことになります。
また、複数のアプローチが可能なほとんどの状況と同様に、どちらが優れているかという問題だけでなく、トレードオフを理解することが重要です。 この記事では、Node、Java、Go、および PHP を Apache と比較し、異なる言語が I/O をどのようにモデル化するか、各モデルの利点と欠点を議論し、いくつかの初歩的なベンチマークで締めくくります。 次の Web アプリケーションの I/O パフォーマンスについて懸念しているのであれば、この記事はあなたのためにあります。 A Quick Refresher
I/O に関係する要因を理解するために、まずオペレーティング システム レベルでの概念を見直す必要があります。 これらの概念の多くを直接扱うことはほとんどありませんが、アプリケーションの実行時環境を通じて間接的に常に扱います。
システムコール
まず最初に、以下のように説明できるシステムコールがあります:
- あなたのプログラム (「ユーザーランド」と呼ばれる) は、自分の代わりに I/O 操作を行うようオペレーティング システム カーネルに依頼しなければなりません。 これがどのように実装されるかの詳細は OS によって異なりますが、基本的なコンセプトは同じです。 プログラムからの制御をカーネルに移行させる特定の命令(関数呼び出しのようなものですが、この状況に対処するための特別なソースがあります)があります。 一般に、システムコールはブロッキングされ、プログラムはカーネルが自分のコードに戻るのを待ちます。
- カーネルは、問題の物理デバイス (ディスク、ネットワーク カードなど) で基本的な I/O 操作を行い、システムコールに応答します。 現実世界では、カーネルは、デバイスが準備できるのを待つ、内部状態を更新するなど、要求を満たすために多くのことを行う必要があるかもしれませんが、アプリケーション開発者としては、そんなことは気にする必要はないでしょう。 それはカーネルの仕事です。
ブロッキング vs. ノンブロッキング コール
さて、上でシステムコールはブロッキングだと言いましたが、一般的にはそのとおりです。 これは、カーネルが要求を受け取り、それをどこかのキューまたはバッファに入れ、そして実際の I/O が発生するのを待たずにすぐに戻ることを意味します。
Some examples (of Linux syscalls) might help clarify: – read()
is a blocking call – which file and a buffer of where to deliver the data reads, and the call returns when the data is there.The call receives it has a blocking call, and it has a blocking call, and it has a blocking call, and it has a buffer of the data is there. epoll_create()
、epoll_ctl()
、epoll_wait()
はそれぞれ、 listen するハンドルのグループを作成し、そのグループからハンドラを追加/削除し、 アクティビティがあるまでブロックすることを可能にするコールである。 これにより、1つのスレッドで多数のI/O操作を効率的に制御することができますが、先走りすぎましたね。 これは、機能が必要な場合には素晴らしいことですが、ご覧のように、使用するのがより複雑であることは確かです。
ここで、タイミングの桁違いの差を理解することが重要です。 CPU コアが 3GHz で動作している場合、CPU が実行できる最適化には関与しませんが、1 秒あたり 30 億サイクル (またはナノ秒あたり 3 サイクル) を実行していることになります。 ノンブロッキングのシステムコールが完了するには、数十サイクルのオーダーで、つまり「比較的数ナノ秒」かかるかもしれません。 ネットワーク上で受信する情報のためにブロックする呼び出しは、もっと長い時間、たとえば200ミリ秒(1秒の1/5)かかるかもしれません。 そして、例えば、ノンブロッキングの呼び出しが20ナノ秒かかり、ブロッキングの呼び出しが200,000,000ナノ秒かかったとします。 5555>
カーネルは、ブロッキング 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 パフォーマンスがプロジェクトの成否を分ける場合、これらは知っておくべきことです。
The “Keep It Simple” Approach: PHP
90 年代には、多くの人がコンバースの靴を履いて、Perl で CGI スクリプトを書いていました。 その後、PHP が登場し、一部の人々がそれを非難するのが好きなように、動的な Web ページを作るのがずっと簡単になりました。 いくつかのバリエーションがありますが、平均的なPHPサーバーは次のようなものです:
ユーザーのブラウザからHTTPリクエストが来て、Apacheウェブサーバーに到達します。 Apache は各リクエストに対して個別のプロセスを作成し、その数を最小限にするために再利用するよう最適化します (プロセスの作成は相対的に遅いのです)。 PHP で file_get_contents()
を呼び出すと、その内部で read()
システムコールを行い、結果を待ちます。
そしてもちろん、実際のコードは単にページに埋め込まれ、操作はブロックされます。 I/Oコールはブロックされるだけです。 利点は? それは単純であり、それは動作します。 不利な点は? 同時に 20,000 人のクライアントを処理すると、サーバーは爆発します。 この方法は、大量のI/Oを処理するためにカーネルによって提供されるツール(epollなど)が使用されていないため、うまくスケールしません。 さらに、各リクエストに対して個別のプロセスを実行すると、多くのシステム リソースを使用する傾向があり、特にメモリはこのようなシナリオで最初に不足することがよくあります。
注意: Ruby で使用されるアプローチは PHP のアプローチと非常に似ており、広くて一般的で手探りな方法で、我々の目的には同じとみなすことができる。 Java
Java が登場したのは、ちょうどあなたが最初のドメイン名を購入した頃で、文章の後にランダムに「ドットコム」と言うのがクールだった頃です。 そして、Java にはマルチスレッドが組み込まれており、これは (特に作成された当時としては) かなり素晴らしいものでした。
ほとんどの Java Web サーバーは、入ってくるリクエストごとに新しい実行スレッドを開始し、このスレッドで最終的にアプリケーション開発者であるあなたが書いた関数を呼び出すことで動作します。
Java Servlet で I/O を実行すると、次のようになります。
public void doGet(HttpServletRequest request,HttpServletResponse response) throws ServletException, IOException{// blocking file I/OInputStream fileIs = new FileInputStream("/path/to/file");// blocking network I/OURLConnection urlConnection = (new URL("http://example.com/example-microservice")).openConnection();InputStream netIs = urlConnection.getInputStream();// some more blocking network I/Oout.println("...");}
上記の doGet
メソッドは 1 つの要求に対応し、それ自身のスレッドで実行されるので、それ自身のメモリを必要とする各要求に対して個別のプロセスではなく、個別のスレッドを使用します。 これは、スレッド間で互いのメモリにアクセスできるため、状態やキャッシュされたデータなどを共有できるなどの利点がありますが、スケジュールとの対話方法への影響は、以前の PHP の例で行われたものとほとんど同じです。 各リクエストは新しいスレッドを取得し、様々な I/O 操作はリクエストが完全に処理されるまでそのスレッド内でブロックされます。 スレッドは、それらを作成および破棄するコストを最小限にするためにプールされますが、それでも、何千もの接続は、スケジューラーにとって悪いことである何千ものスレッドを意味します。
重要なマイルストーンは、バージョン 1.4 で Java (と 1.7 で再び大幅なアップグレード) がノンブロッキング I/O 呼び出しを行う能力を獲得したことです。 Web やそれ以外のほとんどのアプリケーションはこれを使用しませんが、少なくとも利用可能です。 しかし、デプロイされた Java アプリケーションの大部分は、まだ上記のように動作しています。
Java は我々に近づき、確かに I/O に関するいくつかの良いアウトオブボックス機能を備えていますが、何千ものブロックするスレッドで地面に叩きつけられる、重く I/O 化されたアプリケーションを持っているとどうなるかといった問題はまだ本当に解決できてはいません。 Node
優れた I/O に関しては、ブロック上の人気のある子供は Node.js です。 Node をほんの少し紹介したことがある人なら誰でも、Node は「ノンブロッキング」であり、I/O を効率的に処理すると言われたことがあるはずです。 そして、これは一般的な意味において真実です。 しかし、悪魔は細部に宿り、この魔術が達成された手段は、パフォーマンスに関して重要です。
本質的に、Node が実装するパラダイムシフトは、「リクエストを処理するコードをここに書きなさい」と本質的に言う代わりに、「リクエストを処理し始めるコードをここに書きなさい」と言うということです。 I/O を伴う何かを行う必要があるたびに、リクエストを作成し、それが完了したときに Node が呼び出すコールバック関数を指定します。
リクエストで I/O 操作を行う典型的な Node コードは次のようになります。 1 つはリクエストが開始されたときに呼び出され、2 つ目はファイル データが利用可能になったときに呼び出されます。
これが行うことは、基本的に Node にこれらのコールバック間の I/O を効率的に処理する機会を提供することです。 データベース呼び出しを開始し、Node にコールバック関数を渡すと、Node はノンブロッキング呼び出しを使って I/O 操作を個別に実行し、要求したデータが利用可能になったときにコールバック関数を呼び出すからです。 このように、I/O呼び出しをキューに入れ、Nodeに処理させ、コールバックを得る仕組みは、”イベントループ “と呼ばれています。 そして、それはかなりうまく機能します。
しかしながら、このモデルにはキャッチがあります。 その理由は、何よりも V8 JavaScript エンジン (Node で使用される Chrome の JS エンジン) がどのように実装されているかに大きく関係しています。 あなたが書くJSコードは、すべてシングルスレッドで実行されます。 ちょっと考えてみてください。 I/Oは効率的なノンブロッキング技術で実行されますが、CPUバウンド処理を行うJS缶はシングルスレッドで実行され、コードの各チャンクは次のチャンクをブロックするということなのです。 よくある例としては、データベースのレコードをループさせて、クライアントに出力する前に何らかの処理をすることが挙げられます。 Node は I/O を効率的に処理しますが、上記の例の for
ループは、1 つだけのメイン スレッドで CPU サイクルを使用しています。 これは、10,000の接続がある場合、そのループがどれくらい長いかによって、アプリケーション全体をクロールさせる可能性があることを意味します。
この概念全体が基づいている前提は、I/O 操作が最も遅い部分であり、したがって、他の処理を直列に行うことを意味しても、それらを効率的に処理することが最も重要である、というものです。 これはいくつかのケースでは正しいですが、すべてではありません。
他のポイントは、これは単なる意見ですが、入れ子のコールバックの束を書くのは非常に疲れることがあり、コードを追うのが著しく難しくなると主張する人もいます。 Node コードの内部で 4、5、あるいはそれ以上のレベルでコールバックがネストされているのは珍しいことではありません。 Node モデルは、主なパフォーマンス問題が I/O である場合にうまく機能します。 しかし、そのアキレス腱は、HTTP リクエストを処理する関数に入り、CPU 集中型のコードを入れて、注意深くなければすべての接続をクロールさせることができるということです。 Go
Go のセクションに入る前に、私が Go ファンボーイであることを明かしておくのが適切でしょう。 多くのプロジェクトで Go を使用しており、その生産性の利点を公然と支持していますし、Go を使用すると、私の仕事にもそれが現れています。 Go 言語の重要な特徴の 1 つは、独自のスケジューラーを含んでいることです。 実行の各スレッドが単一の OS スレッドに対応するのではなく、「ゴルーチン」の概念で動作します。 そしてGoランタイムは、ゴルーチンの処理内容に応じて、ゴルーチンをOSスレッドに割り当てて実行させたり、OSスレッドと関連付けないようにサスペンドさせたりすることができる。 Go の HTTP サーバーから入ってくる各リクエストは、個別のゴルーチンで処理されます。
スケジューラーがどのように動作するかの図は次のようになります:
フードの下では、書き込み/読み取り/接続/などの要求を行うことにより I/O 呼び出しを実装した Go ランタイムのさまざまなポイントによってこれが実装されています。
事実上、Go ランタイムは、コールバック機構が I/O コールの実装に組み込まれており、スケジューラーと自動的に対話することを除いて、Node が行っていることとそれほど変わらないことを行っています。 Goはスケジューラのロジックに基づいて、適切と思われる数のOSスレッドにGoroutineを自動的にマッピングします。 その結果、次のようなコードになります。
func ServeHTTP(w http.ResponseWriter, r *http.Request) {// the underlying network call here is non-blockingrows, 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スケジューラとの間の相互作用が、残りの部分を処理します。 5555>
Go には欠点があるかもしれませんが、一般的に言えば、I/O を処理する方法は欠点に含まれません。
Lies, Damned Lies and Benchmarks
これらのさまざまなモデルで、コンテキスト スイッチングの時間を正確に伝えることは困難です。 また、あまり役に立たないという意見もあります。 そこで、代わりに、これらのサーバー環境の全体的な HTTP サーバー パフォーマンスを比較するいくつかの基本的なベンチマークを紹介します。 これらの環境それぞれについて、ランダムなバイトを含む 64k ファイルを読み込み、それに対して SHA-256 ハッシュを N 回実行し (N は URL のクエリ文字列で指定。例: .../test.php?n=100
) 、結果のハッシュを 16 進数で表示する適切なコードを記述しました。 これを選んだ理由は、いくつかの一貫した I/O と CPU 使用率を増加させる制御された方法で同じベンチマークを実行する非常にシンプルな方法だからです。
使用した環境についてのもう少し詳しい情報は、これらのベンチマーク ノートをご覧ください。 300 の同時リクエストで 2000 回の反復を実行し、リクエストごとに 1 つのハッシュのみ (N=1) を実行すると、次のようになります:
しかし、N を 1000 に増やし、まだ 300 の同時リクエストで、同じ負荷だがハッシュの反復が 100 倍になるとどうなるか (CPU 負荷が大幅に増える):
時間は、すべての同時リクエストでリクエストを完了する平均ミリ秒数である。 各リクエストの CPU 集中処理が互いにブロックしているため、突然、Node のパフォーマンスが大幅に低下します。 そして、興味深いことに、PHP のパフォーマンスは (他のものと比較して) ずっと良くなり、このテストでは Java を打ち負かします。 (PHP では SHA-256 の実装は C 言語で書かれており、現在 1000 回のハッシュ反復を行っているため、実行パスはそのループでより多くの時間を費やしていることは注目に値します。)
では、5000 同時接続 (N=1) – あるいはそれにできるだけ近い- を試してみましょう。 残念ながら、これらの環境のほとんどで、失敗率が小さくないことがわかりました。 このグラフでは、1 秒あたりの総リクエスト数を見ます。 高いほど良い:
そして、画像はかなり違って見える。 推測ですが、高い接続量では、PHP+Apache における新しいプロセスの生成とそれに関連する追加のメモリに関連する接続ごとのオーバーヘッドが支配的な要因になり、PHP のパフォーマンスを悪化させるように見えます。 明らかに、ここでは Go が勝者で、Java、Node、最後に PHP が続きます。
全体的なスループットに関わる要因は多く、また、アプリケーションによって大きく異なりますが、フードの下で何が起こっているのかの本質とトレードオフについて理解すればするほど、より良い結果を得ることができます。
まとめ
以上のことから、言語が進化するにつれて、多くの I/O を行う大規模アプリケーションを扱うためのソリューションも一緒に進化してきたことがよくわかります。 しかし、これらは上記のアプローチほど一般的ではなく、そのようなアプローチを使用してサーバーを維持するための付随する運用上のオーバーヘッドを考慮する必要があります。 通常、「通常の」PHP や Java Web アプリケーションは、このような環境ではかなりの修正なしに実行できません。
比較として、使いやすさと同様にパフォーマンスに影響するいくつかの重要な要素を考慮すると、次のようになります。 プロセス
Threads Available
Node.Core
スレッドは一般的にプロセスよりもはるかにメモリ効率が高くなります。 同じメモリ空間を共有するのに対して、プロセスはそうではないので。 これをノンブロッキングI/Oに関連する要素と組み合わせると、少なくとも上記で考慮した要素では、リストの下に行くほど、I/Oに関連する一般的な設定が改善されることがわかります。 したがって、上記のコンテストで勝者を選ぶとしたら、それは間違いなく Go でしょう。
それでも、実際には、アプリケーションを構築するための環境の選択は、チームがその環境に精通しているかどうか、そして、その環境で達成できる全体的な生産性に密接に結びついています。 ですから、すべてのチームがいきなりNodeやGoでWebアプリケーションやサービスの開発を始めることは意味がないかもしれません。 実際、開発者の確保や社内チームの慣れなどが、異なる言語や環境を使用しない主な理由として挙げられることがよくあります。 5555>
以上、ボンネットの下で起こっていることをより明確に描き、アプリケーションの現実のスケーラビリティに対処する方法についていくつかのアイデアを提供するのに役立つことを願っています。 入力と出力に幸あれ!
。