概要
Go1のGOMAXPROCS
は、同時に実行できる最大CPU数を設定し、設定前の設定値を返すruntime
パッケージの関数です。デフォルトでは、Goのランタイムがプロセス起動時にruntime.NumCPU
を使用して現在のプロセスが利用可能な論理コア数を取得し、その値が自動で設定されます2。なので、GOMAXPROCS
の設定値は論理コア数と等しくなります。
GOMAXPROCS
は、Go1.5以前はデフォルトで1
が設定されていたのですが、1.5以降はruntime.NumCPU
の値が設定されるようになりました3。異なるスレッド間で2つのゴルーチンを切り替えるコストが改善し、ハードウェアのコア数が増え続ける中で性能の向上を狙ったためです4。
デフォルトの設定がそうなっているように、CPUのコア数を最大限活用するという観点からも、GOMAXPROCS
の設定値は論理コア数と同程度が望ましいです。GOMAXPROCS
の値が論理コア数より多いと、OSスレッド間のコンテキストスイッチが増加しスイッチングコストが掛かってしまいます5。
しかし、Dockerなどのコンテナ環境ではCPU帯域幅に制限がかかっている場合があり、必ずしもGOMAXPROCS
の値をruntime.NumCPU
で取得される論理コア数と合わせることが最適とは限りません。
本記事では、この問題がなぜ発生するのか、そしてGOMAXPROCS
の適切な設定方法について解説します。
なお、ここでいうコンテナとは、Linux上で動くLinuxコンテナと、それを動かす技術Dockerのことを指します。
動作環境
動作確認は、すべてVM上のゲストOS(Ubuntu)で行っています。
- ホストマシン: macOS Sequoia 15.5、Apple M2 Pro チップ(10コア)搭載
- Linux VM: OrbStack 1.11.3
- ゲストOS: Ubuntu 24.04.2 LTS
- Docker: 28.2.2 (build e6534b4)
- Golang: 1.24.4-alpine3.22
- gcc: 13
GOMAXPROCSはDockerのCPU帯域幅制限を考慮しないことの確認
--cpus=0.5
6オプションを指定して、CPU 帯域幅を50%に制限した状態でコンテナを起動し、その中でGOMAXPROCS
の値を確認してみます。
func main() {
fmt.Printf("GOMAXPROCS: %d\n", runtime.GOMAXPROCS(0))
}
FROM golang:1.24.4-alpine3.22
COPY . .
RUN go build -o app
CMD ["./app"]
$ docker build -t test001 . && docker run --rm --cpus=0.5 test001
GOMAXPROCS: 10
上記のように、CPU帯域幅制限を50%にしているにも関わらず、GOMAXPROCS
の設定値は10となっています。
ちなみに概要でも述べた通り、GOMAXPROCS
の設定値が実際に利用可能なCPUリソースを上回ると、過剰なスレッド生成やコンテキストスイッチの増加により、かえってパフォーマンスが低下する可能性があります。
GOMAXPROCSに設定される値はどう決まるのか
概要で述べた通り、GOMAXPROCS
にはruntime.NumCPU
で取得した論理コア数が設定されます。NumCPU
は現在のプロセスが利用可能な論理CPUの数を返します7。そのruntime.NumCPU
はgetproccount
関数の戻り値です。それが具体的にどのような処理を行っているのかを見ていきます。
Goのruntime.NumCPU
の実コード8を抜粋すると、以下のようになっています。
// NumCPU returns the number of logical CPUs usable by the current process.
//
// The set of available CPUs is checked by querying the operating system
// at process startup. Changes to operating system CPU allocation after
// process startup are not reflected.
func NumCPU() int {
return int(ncpu)
}
関数の戻り値として、ncpu
を返していると思います。
ncpu
はsrc/runtime/runtime2.go
でグローバル変数として定義されています9。
ncpu
を何処で設定しているかというと、osinit
関数内でgetproccount
の結果を設定しています10。
func osinit() {
ncpu = getproccount()
// 以下省略
getproccount
の実コード11は以下です。
func getproccount() int32 {
const maxCPUs = 64 * 1024
var buf [maxCPUs / 8]byte
r := sched_getaffinity(0, unsafe.Sizeof(buf), &buf[0])
// 以下省略
sched_getaffinity
システムコールが呼ばれています。sched_getaffinity
は、スレッドのCPU アフィニティマスクを取得するシステムコールです。CPUアフィニティマスクとは、スレッドが実行可能なCPUの集合を決めるものです12。
CPUアフィニティマスクから算出できるのは、利用可能なCPU論理コアの数である
こちら13を参考にして、sched_getaffinity
システムコールを使用して自分自身のスレッドのCPUアフィニティマスクを取得し、実行可能なCPUの数を数えて出力するコードを書いてみました。
#define _GNU_SOURCE
#include <sched.h>
#include <stdio.h>
#include <unistd.h>
int main() {
cpu_set_t set;
CPU_ZERO(&set);
if (sched_getaffinity(0, sizeof(set), &set) != 0) {
return 1;
}
printf("number of cpus: %d\n",CPU_COUNT(&set));
return 0;
}
FROM gcc:13
COPY . .
RUN gcc -o cpu_count cpu_count.c
CMD ["./cpu_count"]
以下は、ホストマシンに論理CPUコアが10個ある環境において、VM上で実行した場合の結果です。
VMのCPUリミットを無制限に設定した場合
$ docker build -t cpu_count . && docker run --rm cpu_count
number of cpus: 10
VMのCPUリミットを無制限にした場合、結果が10になりました。これは、ホストマシンの論理コア数が10であり、VMにも同等のCPUリソースが割り当てられていると考えられます。
VMのCPUリミットを200%に設定した場合
$ docker build -t cpu_count . && docker run --rm cpu_count
number of cpus: 2
VMのCPUリミットを200%にした場合、結果が2になりました。これは、VMに2論理コア分のCPUリソースが割り当てられていると考えられます。
以上により、CPUアフィニティマスクを通じて、現在のスレッドが利用可能な論理コアの数を取得できることが確認できました。
Goのgetproccount
は、システムコールsched_getaffinity
で取得したスレッドのアフィニティマスクに基づいて、利用可能なCPU論理コア数を返していると思われます。(※参考: 古いGoのバージョンに関する記事ですが、こちら14も参考になります)
概要で述べたように、GOMAXPROCS
の設定値は論理コア数と同程度が望ましいため、getproccount
で取得した利用可能な論理コアの数(runtime.NumCPU
の結果)をGOMAXPROCS
に設定するのは理にかなっています。
ただし、コンテナ環境ではCPU帯域幅に制限がかけられている場合があり、それも考慮する必要があります。
uber-go/automaxprocsを使うことで、CPU帯域幅制限を考慮したGOMAXPROCSの値を設定できる
CPU帯域幅制限を考慮してGOMAXPROCS
を設定してくれるライブラリとして、uber-go/automaxprocs
15が存在します。
使い方は、_ "go.uber.org/automaxprocs"
でimportして、init
を呼ぶだけです。
import (
_ "go.uber.org/automaxprocs"
)
func main() {
fmt.Printf("GOMAXPROCS: %d\n", runtime.GOMAXPROCS(0))
}
以下は、automaxprocs
を使用した上記のコードを実行した場合の、GOMAXPROCS
の設定結果です。
コンテナのCPU帯域幅制限を50%(=0.5コア)にした場合
このとき、automaxprocs
によりGOMAXPROCS
は1に設定されます(GOMAXPROCS
は1未満にはなりません)。
$ docker build -t test002 . && docker run --rm --cpus=0.5 test002
GOMAXPROCS: 1
2025/06/24 11:32:02 maxprocs: Updating GOMAXPROCS=1: using minimum allowed GOMAXPROCS
コンテナのCPU帯域幅制限を200%(=2コア)にした場合
この場合、automaxprocs
によりGOMAXPROCS
は2に設定されます。
$ docker build -t test003 . && docker run --rm --cpus=2.0 test003
GOMAXPROCS: 2
2025/06/24 11:33:10 maxprocs: Updating GOMAXPROCS=2: determined from CPU quota
なぜautomaxprocsがCPUの帯域幅制限を考慮したGOMAXPROCS
を設定できるのか
Dockerの公式ドキュメント6にも記載があるように、DockerコンテナをCPU帯域幅制限付きで実行することは、Linuxカーネルの機能のひとつであるcgroup
を用いて、--cpu-period
と--cpu-quota
を設定してコンテナ内プロセスのCPU帯域幅を制限することと等しいです。
This is the equivalent of setting --cpu-period="100000" and --cpu-quota="150000".
automaxprocs
がCPUの帯域幅制限を考慮したGOMAXPROCS
を設定できる理由は、実行時にcgroup
の情報を参照して値を決定しているためです。
automaxprocs
内部のCPUQuotaToGOMAXPROCS
関数16を追っていくことで、automaxprocs
がcgroup
の情報をもとにGOMAXPROCS
を算出していることが確認できます。
cgroupとは
前提として、コンテナとは簡単にいうとホスト側や他コンテナのリソースへのアクセスが制限されたプロセスです。
アクセス制限はLinuxカーネルが持つケーパビリティ
、Namespaces
、cgroup
などの隔離技術を使用します。これらを使用して、プロセスとホストとの分離レベルを高めています。
cgroup
はプロセスやスレッドをグループ化し、そのグループごとにCPUやメモリ、I/O帯域などのリソース使用を制御・制限する仕組みです。これにより、たとえば特定のプロセスグループに割り当てるCPUコア数やメモリ容量を制限できます17。
cgroup
はcgroupfs
と呼ばれるファイルシステムを通して操作をします。多くの場合/sys/fs/cgroups
にマウントされていますが、cgroup
v1とv2では階層構造や設定方法が異なります。
ここではv2を前提に説明します。
cgroup
では管理するリソースのことをサブシステムと呼び、cgroup.controllers
というファイルに利用できるサブシステムが書かれています。
$ cat /sys/fs/cgroup/cgroup.controllers
cpuset cpu io memory pids
こちら18を参考にして、試しにプロセスのCPU帯域幅制限を20%に設定して、プロセスにCPU制限がかかっていることを確認しましょう。
$ sudo mkdir /sys/fs/cgroup/test
# 50ms(期間)ごとに最大10ms(CPU時間)だけ使用できる=20%を設定
$ echo "10000 50000" | sudo tee /sys/fs/cgroup/test/cpu.max
10000 50000
$ yes > /dev/null &
[1] 87343
# 直前にバックグラウンドで起動したプロセスのPIDをcgroup.procsに書き込む
$ echo $! | sudo tee /sys/fs/cgroup/test/cgroup.procs
87343
$ top
top - 19:57:23 up 17:01, 0 user, load average: 0.04, 0.03, 0.01
Tasks: 22 total, 2 running, 20 sleeping, 0 stopped, 0 zombie
%Cpu(s): 0.0 us, 1.4 sy, 0.7 ni, 97.9 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
MiB Mem : 3977.7 total, 3481.8 free, 510.9 used, 122.2 buff/cache
MiB Swap: 5001.7 total, 4886.7 free, 115.0 used. 3466.8 avail Mem
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
87343 ryosh 25 5 8288 1528 1528 R 20.3 0.0 0:34.04 yes
PID=87343のプロセスのCPU利用率が20%に制限されていることが確認できます。
他には、2コア分に相当する帯域幅制限を与えたい場合は、こちら19を参考に以下のように設定します。
# 500ms(期間)ごとに最大1000ms(CPU時間)だけ使用できる=2コア分のCPU時間を使える
$ echo "1000000 500000" | sudo tee /sys/fs/cgroup/test/cpu.max
まとめ
-
GOMAXPROCS
は、同時に実行できる最大CPU数を決めるパラメータであり、デフォルトでは実行環境の論理コア数と同じ数になっている - Dockerコンテナ環境においては、CPU帯域幅制限がかかっている場合があり、
GOMAXPROCS
はそれを考慮しない。この場合、GOMAXPROCS
の設定値が実際に利用可能なCPUリソースを上回り、過剰なスレッド生成やコンテキストスイッチの増加により、かえってパフォーマンスが低下する可能性がある - CPU帯域幅制限を考慮して
GOMAXPROCS
を設定してくれるライブラリとして、uber-go/automaxprocs
がある - DockerコンテナをCPU帯域幅制限付きで実行することは、Linuxカーネルの機能のひとつである
cgroup
を用いて、コンテナ内プロセスのCPU帯域幅を制限することに等しい -
uber-go/automaxprocs
は、cgroup
の情報を参照してGOMAXPROCS
を設定している
-
https://docs.google.com/document/d/1At2Ls5_fhJQ59kDK2DFVhFu3g5mATSXqqV5QrxinasI/edit?tab=t.0 ↩
-
https://christina04.hatenablog.com/entry/why-goroutine-is-good ↩
-
https://docs.docker.com/engine/containers/resource_constraints/#configure-the-default-cfs-scheduler ↩ ↩2
-
https://cs.opensource.google/go/go/+/refs/tags/go1.24.4:src/runtime/debug.go;l=42 ↩
-
https://cs.opensource.google/go/go/+/refs/tags/go1.24.4:src/runtime/runtime2.go;l=1196 ↩
-
https://cs.opensource.google/go/go/+/refs/tags/go1.24.4:src/runtime/os_linux.go;l=355-356 ↩
-
https://cs.opensource.google/go/go/+/refs/tags/go1.24.4:src/runtime/os_linux.go;l=103 ↩
-
https://manpages.ubuntu.com/manpages/noble/en/man2/sched_getaffinity.2.html ↩
-
https://qiita.com/kubo39/items/dec96d7c93a50a310d7e#golang ↩
-
https://github.com/uber-go/automaxprocs/blob/1ea14c35ce47a73089b824e504d1c92eeb61a5a6/internal/runtime/cpu_quota_linux.go#L35-L54 ↩
-
森田 浩平. 基礎から学ぶコンテナセキュリティ――Dockerを通して理解するコンテナの攻撃例と対策. 技術評論社, 2023, 26p, 2.3章「コンテナとLinuxカーネルの機能」 ↩