メインコンテンツへスキップ
  1. Posts/

GOMAXPROCS の謎を解く

·870 文字·5 分·
Go Kubernetes Containers Scheduler Performance
アミット・デイブ
著者
アミット・デイブ
ソフトウェアエンジニアで、しっかりしたシステムを作るのが得意です。難しい問題をわかりやすくすることも得意です。好きなことは、分散システムクリーンアーキテクチャ、そしてオープンソースプロジェクトです。仕事以外では、山を歩くことや日本語の勉強、そしてstderrなどから学ぶことを楽しんでいます。
目次
Go in Containers - この記事は連載の一部です
パート 1: この記事

Go 1.25 から、ランタイムはデフォルトで cgroup を見るようになった。それまでは GOMAXPROCS はホストの論理 CPU 数とプロセスの CPU affinity マスクから決まり、cgroup の CPU quota には完全に無視されていた。64コアの Kubernetes ノード上で CPU limit 500m の Pod を起動すると、runtime.GOMAXPROCS(-1) は 64 を返し、64 個の P が立つ。goroutine は並列に走ろうとするが、100ミリ秒の period 中に使える CPU 時間は 50ミリ秒分だけ。すぐに quota を食い潰して、カーネルが cgroup 全体をスロットルする。P99レイテンシが跳ね上がり、GC がさらに悪化する。これは稀なケースではなく、デフォルトの挙動だった。

Go 1.25 は cgroup v1 / v2 の CPU quota を読み、端数は切り上げ、2 未満には自動設定せず、limit や affinity の変化に応じて値を更新できる。聞こえは Uber の go.uber.org/automaxprocs が標準に取り込まれたようにも見えるが、切り上げ/切り捨て、最小値、明示的な上書きの扱いで両者は異なる。さらに、これまで問題なく動いていたように見える多くの既存コードが、新しい挙動を黙って無効化してしまう。

以下は GOMAXPROCS が実際に何を制御するか、Go 1.25 のデフォルトがどう動作するか、そして runtime.GOMAXPROCS(runtime.NumCPU()) がなぜもはや無害な定型句ではなくなったかをまとめたものだ。

動画で見る: この記事の内容を YouTube で解説しています。

GOMAXPROCS は何を制御するのか
#

GOMAXPROCS は、Go スケジューラが維持する P(論理プロセッサ)の数を決める。P は実行コンテキストであり、M(OS スレッド)が P に紐づいて G(goroutine)を実行する。要するに、同時に実行できる goroutine の数の上限だ。

影響を受けるのは以下のような領域。

  • CPU バウンドな並列処理の上限
  • スケジューラオーバヘッド、P ごとのキャッシュ、ロック競争
  • GC ワーカー数:並行マーク段階で GC は CPU 使用率およそ 25% を目標に、専用ワーカー(おおむね GOMAXPROCS/4)と空いている P 上のアイドルワーカーを使う

制限しないのは以下。

  • システムコールや I/O でブロックしている goroutine(およびそれを乗せた追加の OS スレッド)
  • cgo で作られたネイティブスレッド

だから GOMAXPROCS=2 でも、多数の goroutine が epoll 待ちをしていても問題ない。実際にコードを実行している OS スレッドは 2 本に抑えられるが、待ちスレッドは quota の対象外だ。

Go 1.25 以前のデフォルトはなぜコンテナで壊れたか
#

Go 1.24 までは、環境変数 GOMAXPROCS が設定されていなければ、デフォルトは以下の小さい方だった。

min(ホストの論理 CPU 数, CPU affinity マスクの論理 CPU 数)

cgroup の CPU quota は一切無視された。2 CPU limit のコンテナが 64コアノードに乗っていれば GOMAXPROCS=64 になる。これが引き起こす問題は 4 つある。

1. cgroup スロットル
#

64 個の P を立てると、goroutine は並列に走ろうとする。しかし quota は 2 CPU 分だ。period が 100ミリ秒なら、使える CPU 時間は 200ミリ秒分。それを素早く使い切ると、次の period まで cgroup 内の全スレッドが強制的に休まされ、P99/P99.9 レイテンシに直結する。

2. GC の暴走
#

Go の GC は並行マーク段階でおよそ 25% の CPU 使用率を目標にする。専用ワーカーはおおむね GOMAXPROCS/4 本で、GOMAXPROCS=64 なら専用だけで 16 本。さらに空いている P にはアイドルワーカーも入るため、quota=2 の中でも一瞬にして CPU 時間を食い潰す。ユーザー処理が quota を超えていなくても、GC のためにスロットルされる。

3. スケジューラオーバヘッド
#

64 個の P を維持するための per-P キャッシュ、調整、コンテキスト切り替えコストがかかる。実際に並列に走れるスレッドは 2 本なのに、メモリと CPU サイクルを無駄に消費する。

4. ロック保持者のプリエンプション
#

実行可能な goroutine が多く、実際の CPU が少ないと、カーネルはロックを保持中の goroutine を降ろしかねない。ロック待ちの goroutine は全員、その goroutine が再スケジュールされるまで待たされる。

Uber の automaxprocs README にある負荷試験の表は象徴的だ。2 CPU quota 環境で、GOMAXPROCS をクォータに合わせたときが最も高いスループットを出し、尾のレイテンシもそれほど悪化しない。ワークロード次第で数字は変わるが、「quota に合わせるほうが暴走しない」という形は一貫している。

GOMAXPROCS RPS P50 (ms) P99.9 (ms)
1 28,893 1.46 19.70
2 44,715 0.84 26.38
3 44,213 0.66 30.07
8 33,112 0.43 64.32
24 (host default) 22,191 0.45 76.19

この表が、Kubernetes デプロイで automaxprocs が事実上の標準になった理由だ。

Go 1.25 の cgroup 対応デフォルト
#

Go 1.25 から、デフォルト値は以下のようになる。

default = min(
  ホストの論理 CPU 数,
  CPU affinity マスクの論理 CPU 数,
  cgroup CPU quota / period (quota が制限されている場合)
)

default = max(default, 2)   # ただしホストまたは affinity が 1 以下ならその値を尊重

つまり、実際に使える CPU 数の天井を見て P を立てる。ただし、いくつかの挙動には注意が必要だ。

端数は切り上げ
#

250mCPU、500mCPU でも ceil(0.25)=1 だが、max(1, 2)=2 となり最終的に 2 になる。1500mCPU なら ceil(1.5)=2 で最終的に 2。2500mCPU なら 3 になる。Go 1.25 は不足を避ける方向に倒している。

CPU Limit Quota / Period 切り上げ 最終 GOMAXPROCS
250mCPU 25000 / 100000 ceil(0.25) = 1 2
500mCPU 50000 / 100000 ceil(0.5) = 1 2
1500mCPU 150000 / 100000 ceil(1.5) = 2 2
2500mCPU 250000 / 100000 ceil(2.5) = 3 3
8 CPU 800000 / 100000 ceil(8) = 8 8
unlimited max / 100000 制限なし ホスト / affinity

これは Uber の automaxprocs と方針が異なる。automaxprocs はデフォルトで floor(切り捨て)を使い、RoundQuotaFunc オプションで上書きしない限り切り上げない。Go 1.25 は利用率を優先し、不足を避ける。どちらが正しいかはワークロード次第だが、少なくとも「Go 1.25 は automaxprocs と同じではない」という認識が必要だ。

最小値は 2
#

Go 1.25 は GOMAXPROCS=1 を避ける。理由は明確だ。GOMAXPROCS=1 だと GC ワーカーとユーザー goroutine が 1 個の P で直列的に奪い合い、停止が目立つようになる。0.5 CPU 以下の quota は通常バーストするワークロードなので、2 個の P を確保することでランタイム自身もバーストできる余裕を持てる。ただし、ホストまたは CPU affinity が 1 個しかない環境では 1 になる。

cgroup v1 と v2
#

Go ランタイムは /proc/self/cgroup/proc/self/mountinfo を辿って CPU コントローラのマウントポイントを探し、制限を読む。

cgroup v1 cgroup v2
制限ファイル cpu.cfs_quota_us, cpu.cfs_period_us cpu.max
形式 <value_us>-1 = 無制限) <quota> <period>max = 無制限)
マウントタイプ cgroupcpu super-option) cgroup2

混合 v1/v2 構成にも対応しており、v1 の CPU コントローラが存在すればそちらを優先する。なお、cpuset.cpus は cgroup ファイルとして直接読まない。sched_getaffinity(2) を通じて取得する。

CPU request は無視される
#

Kubernetes 用語でいえば、resources.limits.cpu は quota として扱われるが、resources.requests.cpucpu.shares(cgroup v1)/ cpu.weight(cgroup v2)に相当する。これらは相対的な優先度なので、ランタイムは明示的に無視する。limit を設定しない Pod では、GOMAXPROCS はホストの CPU 数のままだ。

定期的な更新
#

GODEBUG=updatemaxprocs=1(言語バージョン ≥1.25 のデフォルト)が有効なら、ランタイムの sysmon が約 1 秒ごとに CPU 数、affinity、cgroup quota の変化を監視し(アイドル時はそれ以下でも)、必要に応じて GOMAXPROCS を更新する。

役に立つシーンは 3 つある。

  • Kubernetes の In-Place Vertical Scaling(1.33 で beta)。Pod 実行中に CPU limit が変更されても、プロセスを再起動せずに GOMAXPROCS が追従する。
  • 実行中の CPU ピン留め / 解除。
  • コンテナランタイムによる実行時の limit 変更。

ただし、環境変数 GOMAXPROCS を設定するか、runtime.GOMAXPROCS(n) を呼び出すと、この自動更新は無効化される。再度有効化したい場合は runtime.SetDefaultGOMAXPROCS() を呼ぶ。これは $GOMAXPROCS 環境変数を無視して、現在のホスト / affinity / cgroup 状態からデフォルトを再計算する。ただし、あとから runtime.GOMAXPROCS(n) を呼べば、やはりその明示的な値が優先される。

優先順位:何が勝つのか
#

以下の順で、強い方から適用される。

ソース 勝つ条件 備考
$GOMAXPROCS 環境変数 正の整数で設定されていれば常に 自動更新を永続的に無効化
runtime.GOMAXPROCS(n) n > 0 で呼ばれた場合 自動更新を無効化。前の値を返す
runtime.SetDefaultGOMAXPROCS() 呼ばれた場合 環境変数を無視してデフォルトを再計算。自動更新を再有効化(あとの runtime.GOMAXPROCS(n) は優先)
デフォルト計算 上記が何もない場合 ホスト、affinity、cgroup limit、端数切り上げ、min 2

最重要の移行ポイントmain() やライブラリで runtime.GOMAXPROCS(runtime.NumCPU()) と書いていると、Go 1.25 でも新しい自動検知が無効になる。五年前の「ベストプラクティス」が今ではリグレッション源になっている。フレームワークやテストランナーが暗黙的に GOMAXPROCS を上書きしている場合も同様だ。知らないうちに新機能を無効化している可能性がある。

GODEBUG でも挙動を反転できる。

  • GODEBUG=containermaxprocs=0:cgroup limit を無視。Go ≤1.24 と同じ挙動。
  • GODEBUG=containermaxprocs=1:cgroup limit を考慮。言語バージョン ≥1.25 のデフォルト。
  • GODEBUG=updatemaxprocs=0:定期更新をしない。
  • GODEBUG=updatemaxprocs=1:定期更新をする。言語バージョン ≥1.25 のデフォルト。

重要なのは、「どの Go ツールチェーンを使ったか」ではなく、go.modgo ディレクティブが基準になる点だ。たとえ Go 1.25 のツールチェーンでビルドしても、go 1.24 が書かれていれば、明示的な GODEBUG 設定がない限り旧挙動になる。

端数 CPU とスケジューラのトレードオフ
#

cgroup quota は期間内の平均スループット制限であり、瞬間的な並列度のキャップではない。2 CPU quota / 100ms period なら、200ミリ秒分の CPU 時間を 100ミリ秒の実時間内で使える。使い方としては、2 スレッドを 100% 使い続けるか、4 スレッドを 50% の間だけフルで回して残り 50ms を idle にするか、どちらも許される。

GOMAXPROCS は瞬間的な並列数の天井だ。これを quota の平均値に合わせると:

  • period 内での突発的なバーストが失われる。
  • カーネルスロットルは減る。
  • P99 レイテンシは改善するかもしれないが、バースト性のあるワークロードでは逆に中央値(median)が悪化する可能性もある。

つまり Go 1.25 の「切り上げ」は、リソース利用率を優先する判断だ。automaxprocs の「切り捨て」は、quota 超過を抑える保守的な判断だ。これはどちらが普遍的に優れているかではなく、サービスの特性による。ただし、ベンチマークなしに決めるべきではない。

いつ手動で上書きすべきか
#

そのままにしておくべき場合
#

  • ベアメタルまたは VM で、プロセスが CPU を占有している。
  • コンテナで CPU limit を設定していて、Go 1.25+ である。
  • CPU バウンドな処理で、利用可能な並列度をそのまま使いたい。

明示的に設定すべき場合
#

  • period 内の遊休 CPU 時間を意図的に使いたいバースト型ワークロードで、偶発的スロットルを許容できる。
  • 500mCPU など小さな quota で動くレイテンシ重視 RPC で、計測した結果 GOMAXPROCS=1 の方が実際に勝つケース。
  • どうしても Go 1.24 以前を使う場合。go.uber.org/automaxprocs または /sys/fs/cgroup/... から手動で計算する。
  • サイドカー用に CPU 余裕を残したい場合(同一 Pod のコンテナは同じ cgroup/同じ quota を共有する点に留意)。
  • 共有 CI ランナーでベンチマークする際に決定論的な並列度を固定したい。

避けるべきこと
#

  • main()runtime.GOMAXPROCS(runtime.NumCPU()) と書くこと。Go 1.25 ではこれはリグレッションであり、新しい自動検知を潰す。
  • 自分が把握していないライブラリに GOMAXPROCS を設定させること。
  • 全サービスに関係なくベースコンテナイメージで GOMAXPROCS を決め打ちすること。

実際に何を選んだかを確認する
#

現在値を確認する。

fmt.Println(runtime.GOMAXPROCS(-1))

cgroup ファイルを直接読むには、以下のコマンドを使う。これらのパスは典型的な Docker/containerd 環境での例だ。実際の cgroup 階層はランタイムによって異なる。正確なパスは /proc/self/cgroup/proc/self/mountinfo から辿る。

# cgroup v2
cat /sys/fs/cgroup/cpu.max

# cgroup v1
cat /sys/fs/cgroup/cpu/cpu.cfs_quota_us
cat /sys/fs/cgroup/cpu/cpu.cfs_period_us

新しい動作を確実に試すには、実行時に以下を設定する。

GODEBUG=containermaxprocs=1,updatemaxprocs=1 ./myapp

既に runtime.GOMAXPROCS を呼んで自動更新が止まっていて、再開したい場合は runtime.SetDefaultGOMAXPROCS() を呼ぶ。

package main

import (
	"fmt"
	"runtime"
)

func main() {
	fmt.Println("before:", runtime.GOMAXPROCS(-1))
	runtime.SetDefaultGOMAXPROCS()
	fmt.Println("after:", runtime.GOMAXPROCS(-1))
}

Kubernetes での留意点
#

  • resources.limits.cpu = cgroup quota。GOMAXPROCS に影響する。
  • resources.requests.cpu = shares/weights。優先度であり、GOMAXPROCS には影響しない。
  • CPU Manager Static ポリシーで cpuset.cpus が設定されている場合、affinity を通じて反映される。
  • limit を設定しないワークロードは、Go 1.25 でも以前と同じくホストの CPU 数が使われる。
  • In-Place Vertical Scaling と組み合わせると、updatemaxprocs の自動更新が真価を発揮する。

意見としてのまとめ
#

Go 1.25 以降で、コンテナ内に CPU limit を設定しているなら、まず何もするな。automaxprocs は、切り捨て(round-down)や Min オプション、ログ出力が必要な場合以外は外してもいい(実際のオプションは LoggerMinRoundQuotaFunc の 3 つだけだ)。むしろ心配すべきは、自分や依存ライブラリが runtime.GOMAXPROCS(runtime.NumCPU()) を呼んでいるかどうかだ。それが一番のリグレッション源だ。

私の推奨は以下の通り。

  • Go 1.25+、コンテナ、CPU limit あり:何も設定しない。go.modgo ディレクティブを 1.25 以上にする。
  • Go 1.24 以前:automaxprocs を入れる。ただし、それが他のコードや環境変数によって上書きされていないか監査する。
  • バースト型レイテンシ重視サービス:ベンチマークしてから手動で調整する。理論より計測。
  • automaxprocs を残す理由:切り捨てや Min オプション、ログ出力が必要な場合。単に「デフォルトを合わせる」だけなら標準ランタイムで十分になった。

この変更は「壊れない範囲でうまく動いてた」状態を、「正しく動く」状態に置き換えるものだ。移行初期には驚くことがあるだろう。しかし 500mCPU の Pod が 64 個の P を持って暴走するよりは、はるかに健全だ。


次回は、同じシリーズの圧力下でのガベージコレクション — メモリ limit と OOMKilled の話だ。お楽しみに。


「コンテナの中の Go」シリーズ: イントロ — サイレント・クライシス · 1. GOMAXPROCS の謎を解く · 2. 圧力下でのガベージコレクション · 3. GC ポーズとレイテンシ · 4. コンテナ・デバッグの実践ワークフロー

Go in Containers - この記事は連載の一部です
パート 1: この記事

関連記事

そのプライマリキーがボトルネックだ(スケーリングじゃ直らない)
·701 文字·4 分
Postgresql Performance B-Tree Uuid Database-Design
AIエージェントのタスク管理、GitHub Issuesでやってみた
·678 文字·4 分
Ai Go Automation Github Devops
配管地獄を止める:`bs` を作っている話
·80 文字·1 分
Go Devops Build-Systems Bs Engineering
Goでモジュラーモノリスを設計する:構成、境界、実践パターン
·529 文字·3 分
Go Architecture Golang Modular-Monolith Architecture
Goプロジェクトの構成方法(実用的な慣習、ルールじゃない)
·443 文字·3 分
Go Architecture Golang Project-Structure Monolith Best-Practices