ホームページ >バックエンド開発 >PHPチュートリアル >PHP を使用して高性能 TCP/UDP サーバーを構築する
Web サーバーがデータベースに直接接続されている場合、Web サーバーが侵害されると、ハッカーがコード内でデータベースのユーザー名とパスワードを見つけて、データベースに引き込まれる危険性があります。また、db には接続数の上限があり、複数の CGI が db に接続する必要がある場合、db 接続数が上限に達するとサービスが拒否される可能性があります。したがって、Webサーバーとデータベースの間に中間層を追加する必要があり、中間層とデータベースは長時間の接続を維持します。データ要求があると、Web サーバーと中間層サーバーはプライベート プロトコル (非 SQL) を使用して対話し、セキュリティとパフォーマンスを向上させます。これは中間層サーバーのプロトタイプです。
Web サービスの継続的な多様化に伴い、中間層サーバーの役割は単に DB データを転送するだけではなく、完全な TCP および UDP サービスを提供するようになりました。以下にアーキテクチャを紹介しましょう。
ほとんどのサーバーのアーキテクチャと同様に、TCP サーバー全体はマスター プロセス、リスナー プロセス、ワーカー プロセスで構成されます。マスター プロセスは、シグナルとリスナー/ワーカー プロセスの健全性を監視し、予期せず終了した場合にプロセスを再起動する責任があります。リスナー プロセスはクライアントの接続を保持する責任を負い、ワーカー プロセスは実際のビジネス ロジックを実行します。リスナーは単にルーティングと下請けを担当し、呼び出しのブロックを含まないため、ブロックされることはありません。 unix ドメイン ソケットは、リスナーとワーカー間の通信メカニズムとして使用されます。通信は関連するプロセスに限定されるため、この問題を完了するために、名前のない unix ドメイン ソケットの実装であるソケットペアを選択しました。
一般に、リスナーの数はワーカーの数よりも少ないです。描画と説明を容易にするために、次の例では、リスナーの数が 2、ワーカーの数が 5 であると仮定します。
1.1 マスタープロセス
サービスが開始されると、まず現在のプロセス (マスタープロセス)
次は fork() といくつかの詳細に対処する必要があります
すべてのソケットは静的変数、つまりマスタープロセスのデータセグメントに配置されるため、 fork()、子プロセス内 これらのソケットはプロセス中にもアクセスできます。これらのソケットが複数のプロセス間でペアになっているからこそ、リスナーとワーカーは通信できるのです。
Unix ドメイン ソケットは、同じマシン上で実行されているプロセス間の通信に使用されますが、INET ドメイン ソケットと同じインターフェイスにカプセル化されていますが、内部実装は完全に異なります。データをコピーするだけで、ネットワーク ヘッダーの追加と削除、チェックサムの計算、シーケンス番号の生成、確認メッセージの送信などを行う必要がないため、より効率的です。 Unix ドメイン ソケットには TCP と UDP の 2 つのインターフェイスがあります。どちらを選択すればよいでしょうか。もちろん UDP なので、コネクションレス型プロトコルなので接続状態を保持する必要がなく、純粋に非同期で行うことができます。しかし、問題は、UDP プロトコルによってパケット損失が発生するかどうかです。順番は保証されないのでしょうか?答えは「ノー」です。その理由は明白です。Unix ドメイン ソケットはパイプに基づいて実装されているため、信頼性が高く、メッセージが失われたりエラーが発生したりすることはありません。
これでプロセス構造は次のようになります
マスタープロセスは初期化作業を完了し、信号の監視と信号の処理のメインループに入ります。その主な機能は、すべてのサブプロセスの健全性ステータスを監視し、それに応じて処理し、管理者のリロード、再起動、停止、および実行ステータス要件のレポートを容易にするシステム信号を受信することです。同時に、マスターは動的に構成できます。個々のサブプロセスの番号(TODO)。
子プロセスの状態が変化したことが判明した場合(SIGCHLD)、再度プルアップされます。システムの終了信号であればフラグビットがセットされ、スムーズに終了します。他の信号が処理された後に実行されます。
1.2 リスナーとワーカーのプロセス
すべてのリスナーとワーカーはマスター プロセスの子プロセスであり、マスターによって作成された 1+5+2 のソックスを持っているため、最初に行うことは、リスナー プロセスとワーカー プロセスが開始されるときです。これは、考慮する必要があるソックスについてカーネルに伝えること、つまり、それを epoll に置くことです。
この写真は、直前に撮影した写真とは異なり、何かが欠けているように感じられることがわかります。理由は、この写真の靴下がepollに置かれている靴下であるため、リスナーやワーカーごとに気になる靴下が異なるからです。たとえば、ワーカー番号 n は、リスナーから送信されたデータを受信するために、socketpair_n を epoll (下図の同じ色のペア) に入れるだけで済みます。
すべてのソックスを epoll に配置した後は、メイン ループで epoll_wait を呼び出して、処理する必要のあるイベントを取得するだけで、純粋な非同期が実現されます。
現時点では、すべてのリスナー プロセスが同じポートでリッスンしており、ユーザーが接続リクエストを開始すると、それを正常に受け入れることができるリスナーは 1 つだけです。
受け入れが成功したら、ユーザー データを受信するために新しいソックス (下の図の赤い四角) を epoll に配置する必要があります。
ユーザーデータが到着すると、リスナーはラウンドローリングメソッドを通じてソケットペアを選択し、それを特定のワーカーに委託します。
epoll は 2 つのイベント トリガー メカニズムを提供します。1 つは ET (エッジ トリガー)、もう 1 つは LT (ハイ レベル トリガー) です。 2 つの違いは、ET モードでは、データが初めてバッファーに現れたときに、その時点でデータが時間内に読み取られなかった場合、カーネルはソケット読み取り可能イベントを通知します。将来的に私たちに知らせてください。 LT モードでは、バッファーにデータがある限り、ソケット読み取り可能イベントがトリガーされます。カーネル レベルから見ると、NGINX はこのモードの epoll を一度だけ通知する必要があるため、ET モードの方が効率的です。したがって、読み取り可能なイベントがトリガーされるたびに、nginx ワーカーはデータを一度にバッファする必要があります。エリア内が読み取られました。しかし、実装では、epoll は LT モードを使用するため、コーディングがより便利になる一方で、もう 1 つの重要な理由は、libevent の PHP 拡張機能が LT モードのみをサポートしていることです。したがって、ソック読み取り可能なイベントが発生したことをカーネルが通知するたびに、リスナーは 8k のデータを読み取り、それを転送できます。
TCP パケットはボーダレスですが、下請けには UDP プロトコルを使用するため、いくつかの問題が関係します:
私たちの解決策は、元のデータに基づいて各パケットにヘッダーを追加することです。ヘッダーの内容はユーザーのラベルです (特定の実装では ip+port でマークされています)。図では色 (黄色、紫、オレンジ) でマークされています。同時に、リスナーは 2 つの接続プールを維持する必要があります。最初の接続プールはラベルを使用してクライアントとリスナーの間のソケットを見つけ、2 番目の接続プールはラベルを使用してリスナーとワーカーの間のソケットを見つけます。同時に、ワーカーがパケットを返すために正しいソケットを選択できるように、リスナーはワーカーにパケットを送信するときにその ID を示す必要があります。したがって、リスナーがワーカーにパケットを送信するとき、ヘッダーには次のものが含まれます。ユーザーの IP、ポート、およびリスナーの一意の ID (番号)。
このようなシンプルな非同期TCPサーバーが構築されました。業務で利用する場合はワーカーの処理メソッドを実装するだけで簡単に行えます。
UDP はコネクションレスであり、個々のパケットに意味があるため、設計が容易です。リスナーとワーカー間の通信媒体として msgqueue を選択します。この msgqueue には名前が付けられ、msgqueue の一意のキーに従ってすべてのリスナーとワーカーが使用できます。
同様に、クライアントから送信されたパケットを受け入れるネットワーク ソケットを作成し、リスナーとワーカー間の通信用のメッセージ キューの記述子を取得します。リスナーといくつかのワーカーをフォークアウトした後、マスター プロセスはメッセージをリッスンするメイン ループに入ります。
Lisener が起動すると、ユーザーが接続したソックスが epoll に配置されます。ユーザー要求があると、カーネルはステータスの変更を通知します。 TCP サーバーとは異なり、今回は epoll にはリスナーのソックスのみが存在します。 IPC メッセージ キューは純粋にメモリで維持され、一般的なファイル システムには対応するマッピングがないため、epoll はサポートされていません。したがって、msg_queue データの読み取りは、アイドル状態のワーカーによってアクティブに行われます。
各ワーカーは、msg_queue の読み取り -> 処理データのループに入ります。
データを処理した後、ワーカーはマスターのソックスを再利用し、各パケットのタグ (IP、ポート) に従ってパケットをクライアントに直接返します
したがって、システムが輻輳している場合、最初に行うことは次のとおりです。オーバーフローするのはメッセージキューです。
構造を見るだけの場合は、ここで終了して、実装の詳細が続きます
3.1 プロセスの ID を変更します
マスター プロセスには多くの特権システム コールが含まれるため、Run です。ルートとして。 fork() の後、ワーカーとリスナーは親プロセスの ID を継承する、つまり root 権限を持っていることがわかります。これは明らかに最小特権の原則に準拠していません (つまり、プログラムは以下の機能のみを持つ必要があります)。タスクに必要な最小限の権限を完了します。これにより、セキュリティが侵害される可能性が低くなります)。したがって、フォーク後に子プロセスの ID を変更する必要があります。
システム API を見ると、setuid() と seteuid() の 2 つのメソッドがあることがわかります。どちらを使用する必要がありますか?
プロセスがファイルにアクセスしようとすると、カーネルはプロセスの ID とファイルの許可ビットに基づいて、対応する操作を実行できるかどうかを判断することがわかっています。プロセスに対して、カーネルは次の 3 つの ID を維持します。
このうち実効ユーザー ID は実際に権限の検証に使用されます。したがって、直感的に言えば、マスター プロセスは seteuid() を呼び出して、子プロセスの実効 ID を変更する必要があります。しかし、これでは問題は解決されません。カーネルがプロセスの 3 セットの ID を維持する理由は、プロセスの実行中に他のユーザーのアクセス許可を使用する必要があるためです。したがって、複数の ID セットを設定する必要があります。プロセスの実行を支援するための一時的な権限の昇格。
プロセスの実行中、プロセスは実際の ID または保存された ID を有効な ID にコピーして、実際の ID または保存された ID のアクセス許可を取得することを選択できます。したがって、リスナーとワーカーの実効 ID を設定するだけで、root 権限を取得できます。
したがって、ここでは、子プロセスの 3 つの ID を none に変更する必要があることがわかります。
setuid() をもう一度見てみましょう
ここまで述べてきましたが、現在の状況はちょうど最初の状況に該当することがわかりました。そのため、マスター プロセスでは、setuid() および setgid() 操作がすべての子プロセスに対して実行されます。
すべてが予想どおりであることがわかります。次に、ps aux によって表示される最初の列 USER に表示されるプロセスの ID は何なのかという疑問が生じます。
3.2 マスターとリスナー/ワーカー間の通信メカニズム
上記からわかるように、TCP サーバーではリスナーとワーカー間の通信は Unix ソケットを通じて実装されますが、UDP サーバーではメッセージキューによって完了します。しかし、マスタープロセスとその子プロセス (リスナーとワーカー) の間の通信メカニズムについてはまったく言及されていません。
まず、通信要件を見てみましょう
(1) 管理者が停止、リロード、再起動する必要がある場合、マスターは子プロセスに通知する必要があります
(2) リスナーとワーカーのステータスが変更された場合変更 (予期しない終了など) がある場合は、マスター プロセスに通知する必要があります
最初の点に関して、マスターはこれらの操作を子プロセスに通知するための小さなパッケージのみを必要とします。このパッケージは整数のみを含む程度の小さなものにすることができます。私たちは自然に信号について考えます。この問題は、カーネルによって提供される USER1 および USER2 信号を使用することで解決できます。 NGINX には多くの状態が含まれるため、最初の要件を完了するためにソケットペアを使用します。
2 番目の点に関しては、リスナーとワーカーは両方ともマスターの子プロセスであるため、カーネルはすでにこれを完了しています。子プロセスの状態が変化すると、カーネルは親プロセスに SIGCHLD シグナルを送信します。したがって、解決策はSIGCHLDのハンドラ関数を登録する方法と、マスタプロセスのwaitまたはwaitpidでイベントを取得する方法の2つしかありません。
3.3 CPU アフィニティ
マルチプロセッサ マシンでは、カーネルは通常、cpu0 の負荷が上限に達しようとしているときに cpu1 を有効にすることによって CPU をスケジュールし、この順序で続行します。プロセス間でメモリをコピーすると、リソースが無駄に消費されます。したがって、効率的なサービスを実現するには、すべてのプロセスが同じ CPU を使用してカーネル スケジューリングで実行されるのではなく、すべてのワーカー プロセスとリスナー プロセスが同時に実行できることを望んでいます。
幸いなことに、Linux は sched_setaffinity を通じてカーネルの CPU スケジューリング部分を公開しており、プロセスを CPU のグループにバインドすることが可能です。
したがって、実装では、CPU 番号の係数を取得するインデックスを使用してリスナーとワーカーを特定の CPU にバインドし、パフォーマンスを向上させました。
ソーラーサーバーは成長しており、私たちは優れたアーキテクチャを学習してきましたが、ソーラーが実用化される過程で、実際には開発および改善が必要な側面がたくさんあることがわかりました。
4.1 負荷分散
リスナー内のワーカーを選択する方法は、単純な RR アルゴリズムを通じて、つまりワーカー番号 0 から開始して実装されていることがわかります。
つまり、リクエスト パケットが各ワーカーに均等に送信される場合、ワーカーがすべてのリクエストを同時に処理する場合、このアルゴリズムには問題はありません。ただし、ワーカーがリクエストを処理するのにかかる時間は制御できないため、このような構造では一部のワーカー間で負荷が完全に不均衡になる可能性があります。
ワーカーの処理メソッドはビジネス側によって実装されるため、リスナーが現在のワーカーのより低いワーカーを選択できるように、ワーカーの忙しさをリスナーに報告するためのリスナーとワーカー間の通信メカニズムが必要です。負荷。
NGINX が負荷分散の問題をどのように解決するかを見てみましょう。 nginx の構造は比較的単純で、マスター プロセスとワーカー プロセスのみがあり、マスター プロセスは Solar のマスターと同様の機能を持ち、シグナルの受信とワーカー プロセスの健全性状態の管理のみを行います。すべての接続と作業はワーカー プロセスによって処理されます。
実際、NGINX の負荷分散を解決する方法は非常に大雑把で、現在のワーカー接続数 (統計接続プール内の使用されている接続数) が最大接続数 (構成) の 7/8 を超えているかどうかによって決まります。 )。このしきい値より大きい場合、新しい接続は受け入れられません。
4.2 雷鳴グループ問題
雷鳴グループ問題とは何ですか?つまり、広場にパンを投げると、すべてのハトがそれを掴みに来ますが、最終的にパンを掴むことができたハトは 1 羽だけであり、そのエネルギーは無駄に浪費されたことになります。
Linux サーバーの場合、複数のプロセスが同時にポートをリッスンします。新しい接続リクエストがこのポートに送信されると、カーネルはこのポートを開くすべてのプロセスに通知しますが、最終的に正常に受け入れることができるのは 1 つのプロセスだけです。これにより、システム リソースが無駄に消費されます。
問題は、複数のプロセスが同じポートをリッスンするようにするにはどうすればよいでしょうか?バインドは失敗しないでしょうか?
2 つの独立したプロセスが同じポートをバインドしようとした場合、2 つのプロセスのソケットはファイル システム ファイル内で 2 つの独立したものとなり、同じネットワーク カードにバインドしようとすると、間違いなく競合が発生します。したがって、バインド時に直接エラーが返されます。ただし、最初にバインドしてリッスンしてからフォークすると、ファイル システムには各プロセスのソケットのイメージが 1 つしか存在しないため、競合は発生しませんが、前述の雷鳴の群れの問題が発生します。 (写真を提供してくれた gexiaabaoHelloWorld に感謝します)
雷鳴の群れの問題を解決するためのより古典的な方法は、ロックを取得したプロセスのみが同時にリッスンできるようにすることです。このプロセスは受け入れることができます。
NGINX はロックを使用して、1 つのプロセスだけがソケットをリッスンしていることを保証します。 NGINX はスピン ロック メカニズムを使用します。これは、各ワーカーが CPU にバインドされているため、システムのパフォーマンスを最大限に活用するために、各ワーカー プロセスを可能な限りブロック状態に保つ必要があるためです。 NGINX は、さまざまな CPU アーキテクチャに基づいて独自のスピン ロックを実装し、ワーカーがロックを取得できない場合は準備完了状態になり、ブロッキング状態にならないようにすることで、不必要なコンテキストの切り替えを削減します。ただし、スピン ロックを使用するための基本的な基準は、プロセスがロックを占有する時間が短くなければならないということです。そうでないと、待機ロック プロセスが大量のシステム リソースを占有することになります。したがって、いかに早くロックを解除するかが問題となる。 NGINX の解決策は、プロセスがロックの取得に成功した後、epoll 内のすべてのイベントを処理するのではなく、イベントを 2 つのタイプに分割し、1 つは新しい接続イベント、もう 1 つは通常のイベントを維持することです。メモリ内の 2 つのリンクされたリスト。ループでは、ワーカーは最初に新しい接続イベントを処理し、ロックを解放してから、通常のイベントを処理します。このメカニズムにより、ロックが時間内に確実に解除されるようになります。
現在、中間層サーバーは引き続き開発中であり、スケジュールされたイベントのサポート、ワーカーとバックエンド サーバー間のさらなる非同期対話、データベース、タイムアウトなど、多くの機能が開発中です。 . 保護など。