🎲

【Go】Goランタイムについて

に公開

Go スケジューラ

Go ランタイムの一部で、goroutine の実行を管理する仕組み。
Go スケジューラの全体を図にすると以下のようになります。
Goランタイムの全体図
Go ランタイムの全体図
(引用:https://zenn.dev/hsaki/books/golang-concurrency/viewer/gointernal)

  • G
    Goroutine のこと。Go ランタイムが管理する軽量スレッドで、ユーザーレベルで生成され、M(OS スレッド)上で実行される。
  • M
    Machine の略。OS カーネルが提供するスレッドを表し、実際に CPU コア上で動く。Go ランタイムは G を直接 CPU に割り当てず、M を介して実行する。
  • P
    Processor の略。Go ランタイムが導入している論理的な概念で、G をスケジューリングするためのリソースを持つ。各 P はローカルランキューを持ち、M は必ず 1 つの P を保持していなければ G を実行できない。GOMAXPROCS の値で P の数が決まる。
  • sysmon
    ランタイム内部で動作する特別な M(監視用スレッド)。定期的にランタイム全体を監視し、以下を行う。
    • プリエンプションの強制(長時間動作する G の停止)
    • ブロッキングした M から P を切り離して再利用
    • タイマーイベントの処理
    • Work Stealing の補助
  • G0
    各 M が持っている特別な goroutine。ユーザープログラムの処理を行うわけではなく、スタック切り替えやガーベジコレクションなど、ランタイム内部の処理を行うためのもの。
  • sched
    スケジューラ全体の状態を保持する構造体。グローバルキューやアイドル状態の M や P を管理し、全体の調整役を担う。
    • sched.runq
      グローバルキューのこと。新規作成された goroutine などが一時的にここに入り、後に各 P のローカルランキューへ分配される。
    • sched.midle
      アイドル状態の M を保持するリスト。今は使われていないが、必要に応じて再利用できる M がここに入る。
    • sched.pidle
      アイドル状態の P を保持するリスト。まだ M と組み合わされていない P がここに待機し、新しい M が動作可能になったときに渡される。

シンプルな図は以下です。
シンプル図
(引用:https://hiramekun.hatenablog.com/entry/2023/12/29/002550)

  • グローバルキュー(global queue)
    全体で共有されるゴルーチン待ち行列(全スレッド共通)。どの CPU core でも取り出せる。
  • ローカルキュー(local queue)
    各 CPU core(正確には P: Processor)に紐づくローカルキュー。ここにゴルーチン(G)がたまる。
  • カーネルスレッド(kernel thread)(黄色い丸)
    実際の OS スレッド(Go ランタイムでは M: Machine)。CPU core に対応して実行される。
  • CPU コア 1 と 2
    実際に物理 CPU で動作するコア。M がここで動く。

G/M/P の協調

Go ランタイムは G・M・P の責務を分離することで、軽量コンテキストスイッチと資源管理を両立しています。

  • G は goroutine とそのスタックを表し、P が所有する実行コンテキストにキューイングされる。
  • P はローカルランキューやタイマーキューを保持し、アトミック操作で G の取得・解放を行う。
  • M は OS スレッドであり、利用可能な P を獲得するたびに G を取り出して実行する。
  • P が不足すると M は新規 goroutine を実行できず、逆に M が余るとブロッキング I/O やシステムコールに備えて休眠する。

Work Stealing とは

Go ランタイムでは、各 P(Processor) が自分専用のローカルランキューを持ちます。通常は、M(Machine = OS スレッド)がこの P のローカルランキューから G(goroutine)を取り出して実行します。
しかし、もしローカルランキューが空になった場合、M は他の P のランキューからタスクを「盗む」動作を行います。これが Work Stealing です。

Work Stealing によって、あるコアだけがアイドルになるのを避け、システム全体で負荷を平準化できます。さらに、グローバルキューも存在し、新規に大量の goroutine が生成されたときのバッファとして機能します。

プリエンプションとは

プリエンプションとは、長時間 CPU を独占している goroutine を強制的に中断させ、他の goroutine に実行機会を与える仕組みです。
Go 1.13 までは協調的プリエンプションが中心で、関数呼び出しなど安全地点でのみ切り替えが可能でした。これでは CPU を使い続ける goroutine が存在すると、他の goroutine が実行できない問題がありました。

Go 1.14 からは 非同期プリエンプション が導入され、シグナルを使って動作中の goroutine に割り込みをかけられるようになりました。これにより、無限ループや重い計算を行っている goroutine でも強制的に切り替えられるため、公平性と応答性が大幅に改善しました。

この監視と制御は、ランタイム内部の sysmon スレッド が担っており、定期的に M や P の状態をチェックし、プリエンプション・タイマー処理・Work Stealing の補助などを行います。

HandOff とは

HandOff は、実行中の M がブロッキング操作(例: システムコール、ネットワーク I/O)で動けなくなったときに発生する仕組みです。M がブロックされても、その M に紐づいていた P はアイドルにせず、別の M へ「手渡し(HandOff)」します。

具体的には、ブロックした M は P を切り離し、ランタイムは待機中の別の M(もしくは新規に生成された M)へ P を再割り当てします。これにより、P に残っているローカルキュー上の goroutine や、今後スケジュールされるタスクを途切れなく処理できるようになります。

HandOff によって、OS スレッドのブロッキングがそのまま CPU コアの遊休につながるのを防ぎ、Go ランタイムの高い並行性が維持されます。

まとめ

  • G・M・P の三層構造により、goroutine のスケジューリングとリソース管理が疎結合に保たれている。
  • P はローカルランキューとグローバルキューを使い分け、Work Stealing で負荷分散を実現している。
  • sysmon とプリエンプション機構が長時間実行タスクを監視し、スループットと応答性を維持している。

参考文献

Discussion