Goのnet/httpサーバーは、接続ごとのゴルーチンモデルに、ランタイムのM:Nスケジューリング戦略を組み合わせています。サーバーがTCP接続を受け入れると、すぐにその接続のライフサイクル全体を処理する軽量のゴルーチンが生成され、メインの受け入れループが返されて次の接続を直ちに受け入れることができます。これらのゴルーチンは、Goスケジューラによって限られた数のOSスレッドにマルチプレックスされ、ブロッキングI/Oを行っているゴルーチンは駐車され、実行可能なものは利用可能なスレッドに再スケジュールされます。このアーキテクチャにより、サーバーは数百のカーネルスレッドしか使用せずに数十万の同時接続を維持することができ、従来の接続ごとのスレッドサーバーのメモリオーバーヘッドを回避しています。
私たちは、50,000のIoTデバイスから同時にデータを取り込むことができるリアルタイムテレメトリーゲートウェイを構築する必要がありました。
問題の説明: 初期のプロトタイプでは、PythonとTwistedを使用して必要な並行性を提供しましたが、複雑なコールバックチェーンと深くネストされたエラーハンドリングのためにすぐに保守不可能になりました。コードを簡素化するためにJavaの接続ごとのスレッドアプローチを試みたところ、約32,000の接続でオペレーティングシステムのスレッド制限に遭遇し、OutOfMemoryError: unable to create new native threadというエラーによりJVMがクラッシュしました。なぜなら、各スレッドが1MB以上の仮想メモリを消費していたからです。
検討された異なるソリューション:
明示的状態マシンを使用したAsyncio: 私たちは、Pythonのasyncioに移行し、コルーチンを使用して単一のイベントループを使用することを評価しました。これは、スレッドと比較してメモリフットプリントを大幅に削減しますが、すべてのプロトコル解析ロジックをasync/await構文に書き直す必要があり、CPU集中的な操作で誤ってイベントループをブロックするリスクがありました。非同期境界を越えたデバッグスタックトレースも、開発チームにとって非常に困難でした。
JVMインスタンスの水平シャーディング: 私たちは、ロードバランサーの背後で10の小さなJavaインスタンスを実行し、それぞれのインスタンスが5,000スレッドを処理することを検討しました。このアプローチは、プロセスごとのスレッド制限を解決しましたが、大幅な運用複雑性をもたらし、追加のハードウェアリソースを必要とし、クラスタ全体での共有状態と接続の粘着性の管理を複雑にしました。このマイクロクラスターを維持するための運用オーバーヘッドは、Javaに留まる利点を上回りました。
Goの接続ごとのゴルーチンモデル: 私たちは、Goでゲートウェイを再実装することを選び、標準ライブラリのnet/httpとnetパッケージを活用しました。サーバーのServeメソッドは、受け入れたTCP接続ごとに自動的に軽量のゴルーチンを生成し、Goランタイムのスケジューラはこれを透明に限られた数のOSスレッドにマルチプレックスします。これにより、手動の状態マシン管理なしに数十万の接続にスケールできる単純で同期的に見えるI/Oコードを書くことができました。
選んだソリューションとその理由: 私たちは、イベント駆動型システムのスケーラビリティとスレッドプログラミングのシンプルさを兼ね備えたGo実装を選択しました。ランタイムは、スケジューリングと非ブロッキングI/Oの複雑さを自動的に処理し、開発者が並行性のプリミティブではなくビジネスロジックに集中できるようにします。さらに、ゴルーチンの初期スタックサイズが2KBであるため、理論的にはメモリ予算内で何百万もの接続を処理できる可能性がありました。
結果: 生産システムは、単一の8コアサーバーで75,000の同時持続接続を正常に管理し、4GB未満のRAMを消費しました。CPU使用率は35-40%で安定しており、スケジューラがI/O遅延を効率的に隠していたため、シャーディングされたJavaインスタンスのクラスターを管理する運用の負担を排除できました。
どうやってGoのスケジューラは、数千のゴルーチンが同じチャネルの受信でブロックしているときにハンダリング問題を防ぐのか?
Goのスケジューラは、チャネルに対して先入れ先出し(FIFO)の待機キューを使用しており、セマフォスタイルのすべてを起こすのではありません。送信者がチャネルに書き込むと、スケジューラは受信キューからちょうど1つの待機しているゴルーチンを起こします(待機時間が最も長いもの)。これにより、1つのゴルーチンだけが値を消費し、複数のゴルーチンが起きてロックを競い合い、1つを除いたすべてが再び眠りに戻るハンダリング問題を防ぎます。候補者はしばしば、チャネルの操作が条件変数のようにすべての待機者にブロードキャストされると誤解しています。
なぜGOMAXPROCSを物理CPUコアの数を超えて増やすと、I/OバウンドのGo** HTTPサーバーのパフォーマンスが低下するのか?**
Goのスケジューラは、バージョン1.14以降は事前割り込みが行われますが、コア数よりも多くのOSスレッド(M)がある場合は、カーネルレベルのコンテキストスイッチオーバーヘッドが増加します。I/Oバウンドのサーバーの場合、過剰なスレッドは、スケジューラが実ユーザーコードを実行するよりもランキューの管理やスレッドの引き渡しに多くの時間を費やす原因になることがあります。さらに、各OSスレッドはカーネルリソース(スレッドローカルストレージやカーネルスタックのためのメモリ)を消費し、必要以上の並列性を超えてスケールする際にオペレーティングシステムに圧力をかける可能性があります。
Goのnet/httpサーバーは、ゴルーチンの受け入れ率が接続の到着率に対して一時的に遅れた場合に、TCPのSO_BACKLOGキューをどのように処理するのか?**
サーバーは、カーネルのリッスンバックログキュー(net.ListenConfigのBacklogまたはシステムのデフォルトによって制御されます)に依存しています。ゴルーチンが生成されるのが遅い場合やハンドラーがリスナーからの接続を受け入れるのが遅い場合、カーネルは待機中のSYNをバックログにキューします。一度バックログが満杯になると、カーネルは新しい接続をTCP RSTを介して拒否します。GoのAccept()ループは自身のゴルーチンで動作し、理想的にはハンドラーゴルーチンを迅速に生成するべきです。しかし、ハンドラーの生成が遅れた場合(例えば、GCの一時停止やミドルウェアでのミューテックス競合が原因)、接続がドロップします。候補者は、Goがユーザースペースの接続キューイングを実装していないことを見逃しがちで、カーネルのバックログに完全に依存しており、SOMAXCONNやListenConfig.Backlogの調整がバーストの吸収にとって重要です。