歴史: 初期のGoのバージョンでは、ブロッキングシステムコールが実行中のOSスレッドを直接ブロックし、他のgoroutineを実行できなくしました。これにより、高い並行性の中でスレッドが急増し、メモリの枯渇やスケジューラのフラッシングが発生し、ランタイムが進行を維持するために無限のスレッドを生成しました。
問題: goroutineがブロッキング操作(例:ファイルI/O)を呼び出すと、基盤のOSスレッドはカーネル空間に入り、システムコールが完了するまで他のgoroutineを実行できません。干渉がなければ、スケジューラは並行性を維持するために新しいスレッドを生成する必要があり、Goの軽量な並行性モデルに違反し、コンテキストスイッチのオーバーヘッドやメモリ圧力によってパフォーマンスが低下します。
解決策: Goランタイムは引き渡しメカニズムを採用しています。goroutineがブロッキングシステムコールに入ると、runtime.entersyscallがそのProcessor(P)を切り離し、スレッドを譲渡します。Pはすぐに別のgoroutineをスケジュールし、飢餓を防ぎます。元のスレッドはシステムコールを実行します。完了すると、runtime.exitsyscallは元のPを再取得しようとします;利用できない場合、goroutineはグローバル実行キューに入るか、別のPを奪って、スレッドの効率的な再利用を保証し、無制限の成長を回避します。
// このファイル操作は透明にシステムコール引き渡しメカニズムをトリガーします func ProcessLogFile(path string) error { // この時点で、runtime.entersyscallが呼び出されます // このスレッドがブロックしている間に、Pが他のgoroutineに引き渡されます data, err := os.ReadFile(path) if err != nil { return err } // 戻り時に、runtime.exitsyscallが実行されます // Goroutineは利用可能なPに再スケジュールされます processData(data) return nil }
私たちは毎秒何百万ものイベントを処理する高スループットのログ集約サービスを運営しました。各goroutineはCPU集中的なパースを行った後、os.WriteFileを通じて原子的なディスク書き込みを行いました。負荷の下で、サービスは低いヒープ使用率と効率的なガーベジコレクションにもかかわらず、OOMクラッシュを示しました。
問題分析: pprofおよびランタイムメトリクスは、プロセスが50,000以上のOSスレッドを生成し、各スレッドがディスクI/Oでブロックされていることを明らかにしました。デフォルトのスレッド制限(10000)を超えており、goroutineの飢餓とマイクロサービスメッシュ全体でのタイムアウトのカスケードが発生しました。
解決策 A: セマフォ制御されたワーカープールを用いたバッファ付きI/O: 私たちは、同時に百の操作にディスクアクセスを制限する固定ワーカープールを実装することを考えました。このアプローチは予測可能なリソース使用率とバックプレッシャーを提供しましたが、複雑なフローロジック、シャットダウン中の潜在的なデッドロックを引き起こし、ランタイムが処理すべき手動のセマフォ管理を追加することによりGoの自然な並行性モデルを実質的に破壊しました。
解決策 B: 生のepollを介した非同期I/O: 私たちは、非ブロッキングファイル記述子を持つsyscall.RawSyscallを利用し、ネットポーラーに統合することを評価しました。ソケットには効率的ですが、Linuxはすべてのファイルシステムで真の非同期ファイルI/Oをepollを通じて均一にサポートしておらず、ディスク操作に対して複雑なスレッドプール管理が必要です。これは、実質的にランタイムのシステムコール戦略を再実装することを意味し、オーバーヘッドと信頼性の低下を引き起こします。
解決策 C: アーキテクチャチューニングにランタイムを信頼する: 私たちは、I/Oパターンを最適化しながらGoの既存のシステムコール処理を活用することを選びました。安全バルブとして一時的にdebug.SetMaxThreadsを増加させ、バッファリングによってシステムコールの頻度を減少させるためにbufio.Writerに切り替え、リトライロジックのために指数バックオフを実装しました。これにより、ブロッキングコールのレートを減少させることによってランタイムのentersyscall / exitsyscallメカニズムが適切に機能し、スレッドの爆発を回避できました。
結果: ピーク負荷中のスレッド数は1,000未満に安定し、OOMエラーは完全に停止し、コンテキストスイッチのオーバーヘッドが減少したためスループットは40%増加しました。このサービスは、I/O待機中にスケジューラが利用可能なスレッドプール間でgoroutinesを多重化できるようにすることによって、トラフィックのスパイクを優雅に処理します。まさにGoランタイムが動作するように設計されたものです。
1. チャネルでのブロッキングがなぜOSスレッドを消費せず、ファイル読み取りで消費するのはなぜですか、そしてランタイムはこれらの状態をどのように区別しますか?
チャネルでのブロッキングは、完全にユーザースペース内の管理されたgoroutineの状態変化です。ランタイムはgoroutineを駐車(待機中としてマーク)し、すぐにそのPの実行キューから別のgoroutineを実行するためにOSスレッドを再スケジュールします。そのスレッドはカーネル空間に入ることはありません。対照的に、ファイルの読み取りはシステムコールを通じてカーネル空間に入ります。ランタイムはruntime.entersyscallを呼び出し、このスレッドが不定期間利用不可能になることをスケジューラに伝え、CPUの飢餓を防ぐために即座にPの引き渡しを促します。その区別は、ユーザースペースの駐車(チャネル)とカーネルスペースの委任(システムコール)にあります。
2. runtime.LockOSThread()がブロッキングシステムコールの前に呼び出されるとどのような壊滅的な失敗モードが発生し、なぜこれが多重化メカニズムをバイパスするのですか?
runtime.LockOSThread()は、ロックの期間中にgoroutineを現在のOSスレッドにバインドします。もしロックされたgoroutineがブロッキングシステムコールを実行すると、スレッドはその特定のスレッドがこの特定のgoroutineを実行する必要があるため、Pを切り離すことができません。Pは、システムコールが完了するまでスケジューラのプールから効果的に除外されます。多くのロックされたgoroutinesが同時にブロックすると、アプリケーションは完全に並列性を失い、ブロックされている操作が他のgoroutinesに依存している場合、デッドロックが発生する可能性があります。
3. CGO実行はentersyscallメカニズムとどのように相互作用し、過度のCGO呼び出しパターンがどのようにブロッキングシステムコールと同様のスレッド枯渇を引き起こすのですか?
CGO呼び出しは、ランタイムによってブロッキング操作として扱われます。GoがCコードを呼び出すと、runtime.entersyscallが呼び出され、飢餓を防ぐためにPが解放されます。しかし、CGOは別々のシステムスタック上で実行され、OSスレッドがC実行コンテキストに移行する必要があります。Cコードがブロッキング操作を行ったり、長時間実行されると、OSスレッドは占有されたままとなります。純粋なGoのシステムコールとは異なり、CGO呼び出しは、goroutineがキューに入れられることなく同じスレッドで続行できる「ファーストパス」の再入をサポートしていません。過度のCGO呼び出しは、各呼び出しがスレッドとスタックの組み合わせを占有するため、スレッドプールを枯渇させる可能性があり、スケジューラが他のgoroutinesにサービスを提供するために新しいスレッドを生成し、未処理のブロッキングシステムコールと同様のスレッドの爆発につながります。