C10K問題は、2000年代初頭のサーバーアーキテクチャに、1万の同時接続を効率よく処理することを求めました。従来の1スレッドごとの接続モデルは、コンテキストスイッチを通じてメモリとCPUを消費しきってしまいました。Goの開発者たちは、数百万のゴルーチンをサポートしつつ、ブロックI/Oコードの明確さを保つことを目指し、ゴルーチンの待機とOSスレッドの消費を切り離すメカニズムが必要でした。
ゴルーチンがネットワークソケットでのread()のようなブロックシステムコールを実行する際、基盤となるOSスレッド(M)を固定化するリスクがあります。介入がなければ、数千の同時接続が数千のスレッドを生じさせ、M:Nスケジューリングの利点を打ち消し、システムリソースを枯渇させてしまいます。
Goランタイムは、スケジューラーに直接統合されたネットワークポーラー(Linuxではepoll、BSDではkqueue、WindowsではIOCPを利用)を使用しています。ゴルーチンがポーラブルなデスクリプタでI/Oを開始すると、ランタイムはそれを_Gwaiting状態に駐車し、ファイルディスクリプタをOS固有のポーラーに登録します。モニタースレッドが準備が整うのを待機し、通知を受けると、ポーラーはゴルーチンを_Grunnableに移行し、使用可能なP(論理プロセッサ)にスケジュールします。これにより、ブロック操作が効率的な駐車イベントに変わり、小さなGOMAXPROCSスレッドプールが膨大な同時接続を処理できるようになります。
// 実際に駐車するイディオマティックなGoコード func handleConn(conn net.Conn) { buf := make([]byte, 1024) n, err := conn.Read(buf) // ゴルーチンを駐車し、スレッドを解放 if err != nil { log.Println(err) return } process(buf[:n]) }
あなたは、20,000の永続的なTCP接続をマーケットデータフィードに維持する高頻度取引ゲートウェイを構築しています。ボラティリティの急増中に、レイテンシは100マイクロ秒を下回る必要があります。最初のテストでは、Java NIOアプローチを使用してスループットを達成しましたが、複雑なコールバック管理に苦しみました。Goに移行したとき、チームはnet.TCPConnを使用して簡単なブロッキングコードを書きました。しかし、50,000の同時接続で負荷テストを行うと、プロセスは10,000以上のOSスレッドを生成し、OOMキルを引き起こし、レイテンシの保証を破壊しました。
解決策A: リアクターパターンを手動で実装する。 標準ライブラリをバイパスし、syscallラッパーを使用して手動のepollイベントループを作成します。 利点: メモリレイアウトとウェイクアップレイテンシを最大限制御できます。 欠点: Goの逐次的なコーディングモデルを犠牲にし、プラットフォーム固有の複雑さを導入し、戦闘テスト済みのランタイムコードを複製し、バグの発生率を高めます。
解決策B: runtime.LockOSThreadでスレッドオーバーヘッドを受け入れる。 各接続を専用のスレッドに強制して、スケジューリングの孤立を保証します。 利点: 予測可能なスレッドの親和性。 欠点: ゴルーチンの基本的な経済的利益を侵害し、メモリ使用量が~8MB per connectionに膨れ上がり、目的の規模には不可能なアプローチになります。
解決策C: 非ポーラブルI/Oを監査し、netpollerを信頼する。 イディオマティックなブロッキングコードを維持しながら、スレッドの生成を強制する偶発的なブロックシステムコール(例:ログ記録やリゾルバ認識なしでのDNSルックアップ)を排除します。 利点: 読みやすい線形フローを維持し、Linux/macOS/Windows全体のランタイム最適化を活用し、メモリを~2KB per connectionに削減します。 欠点: net.Conn操作が駐車する一方で、os.File操作がスレッドをブロックするという深い理解が必要です。
チームは解決策Cを選択し、スレッドの急増がホットパス内でマーケットデータをローカルのext4ファイルに同期的にログ記録することから生じたことを認識しました。通常のファイルI/Oはnetpollerを利用できないため(ファイルはUnixのepollでは常に「準備完了」と見なされます)、各ログ書き込みはOSスレッドをブロックしました。彼らは、ネットワークI/O(ポーラブルなもの)をメインのゴルーチンに留めながら、チャネルバッファを使用して非同期ファイルライターゴルーチンを使用するようリファクタリングしました。
ゲートウェイは、現在16のOSスレッド(GOMAXPROCSに一致)で50,000の接続を維持し、~85µsのP99レイテンシを達成しています。メモリ使用量は40GB(推定スレッドスタック)から~180MBの合計RSSに減少しました。
なぜos.Stdinや通常のファイルからの読み取りが、TCPソケットと同じReadメソッドを使用しているにもかかわらずOSスレッドをブロックするのか、そしてこれがCLIツールの同時性にどのように影響するのか?
TCPソケットは、epollを通じて非同期の準備通知をサポートする一方で、Unixシステム上の通常のファイルやパイプは常にI/Oのために「準備完了」と見なされます; カーネルはファイルデータの可用性に関するノンブロッキングインターフェースを提供しません。このため、ゴルーチンがos.File.Readを呼び出すと、Goランタイムはそれを駐車できず、ブロックシステムコールにリアルなOSスレッドを専念させなければなりません。CLIツールが入力ファイルごとにゴルーチンを生成する場合(例:ログプロセッサ)、これは従来のスレッティングモデルと同様のスレッドリーケを引き起こします。この解決策は、セマフォを使用して同時ファイル操作を制限するか、専用のワーカープールを使ってバッファリングを行います。
ネットポーラーがネットワークパーティションが回復した後に同時に数千のゴルーチンを起こすとき、ランタイムはどのように「大群の雷鳴」を防ぎますか?
netpoller(epoll_waitを介して)が数千の準備完了デスクリプタを返すと、netpoll関数はグローバル実行キューとワークスティーリングアルゴリズムを使用して、すべてのP(論理プロセッサ)にゴルーチンを分配し、すべてを単一のPにエンキューしません。さらに、スケジューラーは公平性のティックを実装しています:10msの実行ごとに、CPUバウンドタスクがそれらを飢えさせないように、実行可能なI/Oゴルーチンを確認します。候補者は、接続ごとにFIFOキューイングを仮定しがちで、スケジューラーがスループットをバランスさせ、ウェイクアップイベントを分散させ、プリエンプションポイントを強制していることを見逃すことがあります。
SetReadDeadlineとアクティブなReadコールの間にどのようなレース条件があり、なぜタイマーウィールの実装にnetpollerとの原子同期が必要ですか?
netpollerは、I/Oの期限を管理するために、各Pごとのタイマーウィールまたはmin-heapを使用します。ゴルーチンAがゴルーチンBがReadでブロックしている間にSetReadDeadlineを呼び出すと、AはBの駐車状態が依存しているタイマーを変更します。原子更新(net.conn内の内部ミューテックスによって保護されている)は、レースを招来する可能性があり、ポーラーが新しい期限が設定される前の古い期限を観測し、ウェイクアップの見逃し(無期限のハング)や偽のタイムアウトを引き起こす可能性があります。原子性は、ハプンズ・ビフォーの一貫性を確保します:更新された期限がepoll待機サイクルによって観測されるか、以前のタイマーが発動しますが、期限の契約を違反する未定義な中間状態は決してありません。