GoランタイムとNUMA:CPUの「距離」を味方につけて、パフォーマンスを極限まで引き出す
こんにちは。現場で「なぜか本番環境でだけ遅い」という現象に頭を抱えたことはありませんか?その原因、実はOSやランタイムのせいではなく、ハードウェアの物理的な配置にあるかもしれません。
今日は、Go言語のランタイムがマルチソケットサーバー上でどう振る舞うのか、そして「NUMA(Non-Uniform Memory Access)」という物理レイヤーの制約をどう攻略し、超高速なアプリケーションを構築するか、その極意を伝授します。
—
1. なぜ「NUMA」を知る必要があるのか?
現代の高性能サーバー(特にマルチソケット構成)では、CPUごとに「自分の担当するメモリ」が物理的に近くに配置されています。これをNUMAといいます。
- ローカルアクセス: CPUが自分の近くのメモリにアクセスする(爆速)
- リモートアクセス: 別のCPUソケットのメモリへアクセスする(バスを跨ぐため、レイテンシが発生し、帯域も制限される)
Goのランタイムは非常に優秀ですが、デフォルトでは「どのメモリがどのCPUに近いか」を意識したスケジューリングを完璧に行うわけではありません。スレッド(M)がOSによって別のソケットへ移動させられると、キャッシュミスやメモリバスの競合が発生し、パフォーマンスがガタ落ちします。これを「NUMA不整合」と呼びます。
—
2. NUMAを意識したGoのセットアップ術
まずは、現在の環境がNUMA構成かどうかを確認しましょう。Linuxであれば以下のコマンドで一発です。
NUMAのトポロジーを確認する
lscpu | grep NUMA
出力例:
NUMA node(s): 2 <-- 2つのCPUノードがあることを示す
NUMA node0 CPU(s): 0-15
NUMA node1 CPU(s): 16-31
ここで「ノードが複数ある」場合は、アプリケーションを特定のノードに固定(CPU Affinityの設定)することで、メモリバスのオーバーヘッドを劇的に減らせます。
---
3. 実践:runtime.LockOSThread で「住所」を固定する
Goの並行処理の最小単位はGoroutine(G)ですが、これが実行されるのはOSスレッド(M)の上です。デフォルトでは、GoランタイムはMを自由に移動させます。
これを防ぎ、特定のCPUコアで処理を完結させるためのアーキテクチャ・コードがこれです。
package main
import (
“fmt”
“runtime”
“sync”
“syscall”
)
func main() {
// 1. OSスレッドを現在のGoroutineにロックする
// これにより、この関数を実行しているGは他のスレッドへ移動しなくなる
runtime.LockOSThread()
defer runtime.UnlockOSThread()
// 2. 指定したCPUコアにこのスレッドをピン留めする (Linux限定)
// CPU 0番に固定する例
cpuSet := syscall.CPUSet{}
cpuSet.Set(0)
// 現在のスレッド(0)の親プロセス/スレッドのCPU親和性を設定
if err := syscall.SchedSetaffinity(0, &cpuSet); err != nil {
panic(err)
}
fmt.Println(“このGoroutineはCPU 0番に縛り付けられました。NUMAノード内での爆速処理が可能です。”)
}
なぜこれを行うのか?
メモリの割り当て(アロケーション)は、そのスレッドが実行されているNUMAノードから行われます。スレッドを固定することで、「常に同じノードのメモリを使い続ける」ことが保証され、キャッシュヒット率が驚くほど向上します。
—
4. GOMAXPROCS の魔法
Goの `GOMAXPROCS` は、並行処理を行う論理プロセッサ数を制御します。
- 注意点: 全コアを使い切ろうとして `GOMAXPROCS` を最大値に設定すると、かえってNUMAノードを跨ぐスレッドが増え、パフォーマンスが低下します。
- 最適解: NUMAノードごとにプロセスを分離するか、ノードのCPU数に合わせて `GOMAXPROCS` を調整するのが現場の鉄則です。
ノード1つ分(例えば16コア)に最適化して実行する例
GOMAXPROCS=16 ./my-high-performance-app
—
5. 動作確認:これが真の最適化だ
実際に効果があるかを測るには、`perf` コマンドを使ってCPUキャッシュミスを確認するのがプロのやり方です。
プロセスIDを指定して、キャッシュミスを監視する
perf stat -e cache-misses -p
設定前後の数値を見比べてみてください。`LockOSThread` と `GOMAXPROCS` の最適化を行うだけで、キャッシュミス率が10%以上改善することも珍しくありません。
先輩エンジニアからのアドバイス
「最適化は計測から」といいますが、NUMAのような低レイヤを意識する際は、「ハードウェアとソフトウェアの境界線」を想像することが不可欠です。
初心者の方は、まずは `GOMAXPROCS` をCPUの物理コア数に合わせて固定することから始めてください。それだけで、あなたの書くGoアプリケーションは、これまでとは別次元の安定性とレスポンスを見せてくれるはずです。
この知識を武器に、誰よりも速いシステムを構築しましょう。次のコードレビューで、誰よりも説得力のある指摘ができるようになるはずですよ。頑張ってください!