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