コンテンツにスキップ

AI Session Notes - 2026-03-28

goroutine の内部実装:GMP モデル

学んだこと

  • Go のランタイムには独自のスケジューラがあり、GMP モデルで goroutine を管理している
  • G (Goroutine): 実行したい処理。go func() で作られる
  • M (Machine): カーネルスレッド。実際に CPU で命令を実行する
  • P (Processor): 論理プロセッサ。G のローカルキューを持ち、どの G を M で実行するか決める
  • P はプログラムとして実行されるものではなく、Go ランタイム内のデータ構造(キュー+スケジューリング情報)。作業員(M)が参照するクリップボードのようなもの
  • P の数は GOMAXPROCS で決まり、デフォルトは CPU の論理コア数

G・P・M の関係はすべて動的

  • G ↔ P: Go ランタイムが頻繁に切り替える
  • P ↔ M: I/O ブロック時などに付け替わる。P は M に固定されない
  • M ↔ CPU コア: OS が負荷に応じて自由に移動させる。固定されない
  • 固定なのは P の数だけ

二段階スケジューリング

  • Go ランタイム: 「どの G を、どの P を通じて、どの M で動かすか」を決める
  • OS: 「どの M を、どの CPU コアで動かすか」を決める
  • 2つのレイヤーは互いに干渉しない。Go は M がどのコアで動くか知らないし、OS は M 内で goroutine がどう切り替わっているか知らない

カーネルスレッドとユーザー空間スレッド

学んだこと

  • 「スレッド」には誰が管理するかで2種類ある
  • カーネルスレッド(= OS スレッド): OS カーネルが作成・管理・スケジューリング。C, Java, Ruby 等で使われる一般的なスレッド
  • ユーザー空間スレッド: OS は関知せず、アプリケーション自身が管理。goroutine はこちら
  • 昨日学んだ「プロセスの中の作業員でメモリを共有する」はスレッドの役割・性質の話。「OS スレッド」は管理主体の話。同じものを別の角度から説明している

goroutine が軽い理由:スタックの動的拡張

学んだこと

  • カーネルスレッドは最初から 1〜8MB の固定サイズのスタックを確保する
  • goroutine は 2KB で開始し、必要に応じて自動拡張(最大 1GB)、不要になれば縮小
  • これが「1 万個作っても数十 MB」で済む理由

goroutine の切り替えタイミング

学んだこと

  • Go 1.14 より前(協調的スケジューリング): goroutine が自発的に CPU を譲るタイミング(チャネル操作、I/O、time.Sleep() 等)でのみ切り替わっていた。for {} のような無限ループで停止できない問題があった
  • Go 1.14 以降(非同期プリエンプション): OS のシグナルを使い、約 10ms ごとに強制切り替えが可能になった

Work Stealing

学んだこと

  • P のローカルキューが偏った場合、暇な P が他の P のキューから半分を奪い取る仕組み
  • 手順: 自分のキューが空 → グローバルキュー確認 → 他の P から盗む
  • これにより全 CPU コアが均等に稼働し続ける

goroutine のコンテキストスイッチ(セーブ&ロード)

学んだこと

  • goroutine の切り替え時、状態(スタック、プログラムカウンタ、レジスタ値)は RAM に保存される
  • スタックはもともと RAM 上にあるのでそのまま。CPU レジスタの値だけ RAM の G 構造体に退避する
  • これは Go ランタイム(ソフトウェア) が行う処理で、ハードウェアの特殊機能ではない
  • OS のカーネルスレッド切り替えも原理は同じ(レジスタ ↔ RAM の退避/復元)。違いは「誰がやるか」だけ

CPU コア数と goroutine の関係

学んだこと

  • 1 つの CPU コアが同時に実行できるのは 1 スレッドだけ。4 コアなら物理的に同時実行は最大 4 つ
  • スレッドがコア数を超えると、OS が高速に切り替えて疑似的に同時実行する
  • ハイパースレッディング(SMT): 1 コア内の遊んでいる部品を別スレッドに使わせる技術。物理コアと論理コアが異なる数になる(例: 4 コア 8 スレッド)。ただし性能は 2 倍ではなく 1.2〜1.3 倍程度
  • Apple Silicon(M3 等) は SMT を採用しておらず、物理コア = 論理コア。代わりに性能コア(高速)と効率コア(省電力)の 2 種類を持つ
  • GOMAXPROCS のデフォルトは論理コア数

WebSocket × goroutine が強い理由

学んだこと

  • チャットの WebSocket 接続中、ユーザーの goroutine は 99.9% の時間「メッセージ待ち(I/O 待ち)」
  • I/O 待ちの goroutine は M から外されて RAM 上で寝ているだけ。M を占有しない
  • 1 万接続あっても、同時に M を使うのはメッセージ処理中の数個だけ
  • カーネルスレッド(Rails 等)は I/O 待ちでもスレッドが占有されたまま。OS はスレッド内部の状態を知らないので「こいつは待ってるだけだから外す」という判断ができない
  • Go ランタイムは自分で goroutine を管理しているからこそ、待ち状態の G を M から外す最適化ができる

Go がコンテナ(Docker / Kubernetes)と相性が良い理由

学んだこと

  • 相性の良さの主な理由は goroutine ではなく、コンパイル言語としての特性
  • シングルバイナリ: 依存ランタイム不要。scratch(空のイメージ)にバイナリ1つ置くだけで動く → イメージサイズ 10〜20MB 程度(Node.js 等は数百 MB〜1GB)
  • 起動が速い: 数十 ms で起動し即座にリクエストを受けられる。JVM のようなウォームアップが不要 → Kubernetes のスケールアウト時に有利
  • メモリ消費が少ない: 同じマシンにより多くのコンテナを詰められる

goroutine の関係は間接的

  • goroutine のおかげで 1 コンテナあたりの処理能力が高い → 必要なコンテナ数自体を減らせる → 運用コストが下がる
  • ただし「Docker/Kubernetes と相性が良い」と言われるときのメインの理由ではない

補足: Docker・Kubernetes 自体が Go 製

  • Docker も Kubernetes も Go で書かれており、エコシステムとツール連携の面でも親和性が高い

Ruby の GIL(Global Interpreter Lock)

学んだこと

  • CRuby には GIL があり、1 プロセス内で Ruby コードを同時実行できるスレッドは 1 つだけ
  • I/O 待ち中はロックが解放されるため、その間は他のスレッドが動ける
  • GIL の制約を回避するために Puma は複数ワーカー(プロセス)を立てる。ただしプロセスごとにメモリを確保するので重い

メタ情報

  • ツール: Claude Code
  • 関連技術: Go, goroutine, GMP モデル, スケジューラ, カーネルスレッド, ユーザー空間スレッド, CPU コア, ハイパースレッディング, Apple Silicon, WebSocket, Ruby, GIL, Puma, Docker, Kubernetes