[インデックス 18349] ファイルの概要
このコミットは、Go言語の標準ライブラリ sync.Pool
のスケーラビリティを大幅に改善することを目的としています。具体的には、各プロセッサ(P)にローカルな固定サイズのキャッシュを導入し、ローカルキャッシュが溢れるか枯渇した場合にのみ、アイテムのバッチをグローバルなミューテックス保護されたキャッシュとやり取りするメカニズムを導入しています。これにより、高並行環境下での sync.Pool
のパフォーマンスが劇的に向上しました。
コミット
commit f8e0057bb71cded5bb2d0b09c6292b13c59b5748
Author: Dmitriy Vyukov <dvyukov@google.com>
Date: Fri Jan 24 22:29:53 2014 +0400
sync: scalable Pool
Introduce fixed-size P-local caches.
When local caches overflow/underflow a batch of items
is transferred to/from global mutex-protected cache.
benchmark old ns/op new ns/op delta
BenchmarkPool 50554 22423 -55.65%
BenchmarkPool-4 400359 5904 -98.53%
BenchmarkPool-16 403311 1598 -99.60%
BenchmarkPool-32 367310 1526 -99.58%
BenchmarkPoolOverlflow 5214 3633 -30.32%
BenchmarkPoolOverlflow-4 42663 9539 -77.64%
BenchmarkPoolOverlflow-8 46919 11385 -75.73%
BenchmarkPoolOverlflow-16 39454 13048 -66.93%
BenchmarkSprintfEmpty 84 63 -25.68%
BenchmarkSprintfEmpty-2 371 32 -91.13%
BenchmarkSprintfEmpty-4 465 22 -95.25%
BenchmarkSprintfEmpty-8 565 12 -97.77%
BenchmarkSprintfEmpty-16 498 5 -98.87%
BenchmarkSprintfEmpty-32 492 4 -99.04%
BenchmarkSprintfString 259 229 -11.58%
BenchmarkSprintfString-2 574 144 -74.91%
BenchmarkSprintfString-4 651 77 -88.05%
BenchmarkSprintfString-8 868 47 -94.48%
BenchmarkSprintfString-16 825 33 -95.96%
BenchmarkSprintfString-32 825 30 -96.28%
BenchmarkSprintfInt 213 188 -11.74%
BenchmarkSprintfInt-2 448 138 -69.20%
BenchmarkSprintfInt-4 624 52 -91.63%
BenchmarkSprintfInt-8 691 31 -95.43%
BenchmarkSprintfInt-16 724 18 -97.46%
BenchmarkSprintfInt-32 718 16 -97.70%
BenchmarkSprintfIntInt 311 282 -9.32%
BenchmarkSprintfIntInt-2 333 145 -56.46%
BenchmarkSprintfIntInt-4 642 110 -82.87%
BenchmarkSprintfIntInt-8 832 42 -94.90%
BenchmarkSprintfIntInt-16 817 24 -97.00%
BenchmarkSprintfIntInt-32 805 22 -97.17%
BenchmarkSprintfPrefixedInt 309 269 -12.94%
BenchmarkSprintfPrefixedInt-2 245 168 -31.43%
BenchmarkSprintfPrefixedInt-4 598 99 -83.36%
BenchmarkSprintfPrefixedInt-8 770 67 -91.23%
BenchmarkSprintfPrefixedInt-16 829 54 -93.49%
BenchmarkSprintfPrefixedInt-32 824 50 -93.83%
BenchmarkSprintfFloat 418 398 -4.78%
BenchmarkSprintfFloat-2 295 203 -31.19%
BenchmarkSprintfFloat-4 585 128 -78.12%
BenchmarkSprintfFloat-8 873 60 -93.13%
BenchmarkSprintfFloat-16 884 33 -96.24%
BenchmarkSprintfFloat-32 881 29 -96.62%
BenchmarkManyArgs 1097 1069 -2.55%
BenchmarkManyArgs-2 705 567 -19.57%
BenchmarkManyArgs-4 792 319 -59.72%
BenchmarkManyArgs-8 963 172 -82.14%
BenchmarkManyArgs-16 1115 103 -90.76%
BenchmarkManyArgs-32 1133 90 -92.03%
LGTM=rsc
R=golang-codereviews, bradfitz, minux.ma, gobot, rsc
CC=golang-codereviews
https://golang.org/cl/46010043
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/f8e0057bb71cded5bb2d0b09c6292b13c59b5748
元コミット内容
sync: scalable Pool
固定サイズのP-ローカルキャッシュを導入。
ローカルキャッシュがオーバーフロー/アンダーフローした場合、アイテムのバッチがグローバルなミューテックス保護されたキャッシュに転送される。
ベンチマーク結果:
BenchmarkPool
が最大で99%以上の性能向上を示し、BenchmarkPoolOverflow
も大幅に改善。
Sprintf
関連のベンチマークも、特に並行度が高い場合に顕著な性能向上を示している。
変更の背景
Go言語の sync.Pool
は、一時的なオブジェクトを再利用することで、ガベージコレクション(GC)の負荷を軽減し、アプリケーションのパフォーマンスを向上させるためのメカニズムを提供します。しかし、このコミット以前の sync.Pool
の実装は、主に単一のグローバルなミューテックスに依存していました。
高並行環境、つまり多数のGoroutineが同時に sync.Pool
の Put
や Get
メソッドを呼び出すような状況では、この単一のミューテックスがボトルネックとなり、深刻な競合(contention)を引き起こしていました。これにより、sync.Pool
の利用が期待されるパフォーマンス向上をもたらさず、むしろ性能劣化の原因となる可能性がありました。
この問題に対処するため、Dmitriy Vyukov氏はこのコミットで sync.Pool
の内部構造を根本的に見直し、スケーラビリティを向上させる新しいアーキテクチャを提案・実装しました。目標は、ミューテックスの競合を最小限に抑え、より多くのGoroutineが効率的に sync.Pool
を利用できるようにすることでした。
前提知識の解説
sync.Pool
の基本
sync.Pool
は、Go言語で一時的に使用されるオブジェクトを効率的に再利用するためのメカニズムです。例えば、ネットワークリクエストの処理中に一時的に大きなバッファが必要になる場合、そのバッファを毎回アロケートしてGCに回収させるのではなく、sync.Pool
に入れておき、次回必要になったときに再利用することで、アロケーションとGCのオーバーヘッドを削減できます。
sync.Pool
は以下の2つの主要なメソッドを提供します。
Put(x interface{})
: オブジェクトx
をプールに戻します。Get() interface{}
: プールからオブジェクトを取得します。プールが空の場合、Pool
のNew
フィールドに設定された関数が呼び出され、新しいオブジェクトが生成されます。
Goのスケジューラ (Goroutine, M, P)
Goランタイムは、M:Nスケジューラを採用しています。
- G (Goroutine): Go言語の軽量スレッド。数百万個作成することも可能です。
- M (Machine/Thread): オペレーティングシステムのスレッド。Goランタイムは、GをM上で実行します。
- P (Processor): 論理プロセッサ。MがGを実行するために必要なコンテキストを提供します。Pの数は通常、
GOMAXPROCS
環境変数によって制御され、CPUコア数に設定されることが多いです。各Pは、実行可能なGのローカルキューを持っています。
Goスケジューラは、GをMに、MをPに割り当てることで、効率的な並行処理を実現します。GはP間で自由に移動(プリエンプション)することができ、これによりCPUリソースが公平に利用されます。
キャッシュラインとFalse Sharing
現代のCPUは、メモリからデータを「キャッシュライン」という固定サイズのブロック(通常64バイト)単位で読み込みます。複数のCPUコアが同じキャッシュライン内の異なるデータを同時に操作しようとすると、「False Sharing」というパフォーマンス問題が発生する可能性があります。
False Sharingは、異なるCPUコアがそれぞれ異なる変数にアクセスしているにもかかわらず、それらの変数が偶然にも同じキャッシュライン上に存在する場合に起こります。この場合、一方のコアが変数を変更すると、そのキャッシュライン全体が無効化され、他のコアは最新のデータを取得するためにメインメモリから再読み込みを行う必要があります。これは、実際には共有されていないデータに対する不必要なキャッシュコヒーレンシプロトコルのオーバーヘッドを引き起こし、性能を低下させます。
この問題を回避するためには、頻繁にアクセスされる独立した変数を異なるキャッシュラインに配置する「キャッシュラインアライメント」が有効です。これは、構造体のフィールド間にパディング(余分なバイト)を挿入することで実現できます。
ミューテックスと競合
ミューテックス(Mutex: Mutual Exclusion)は、共有リソースへのアクセスを排他的に制御するための同期プリミティブです。複数のGoroutineが同時に共有データにアクセスしようとすると、データ競合が発生し、プログラムの動作が予測不能になる可能性があります。ミューテックスは、一度に一つのGoroutineのみが共有リソースにアクセスできるようにロックをかけることで、この問題を解決します。
しかし、ミューテックスはロックとアンロックの操作にオーバーヘッドを伴います。特に、多数のGoroutineが頻繁に同じミューテックスを奪い合うような状況では、ミューテックスの取得待ち(競合)が発生し、プログラム全体の並行性が低下し、性能ボトルネックとなります。
技術的詳細
このコミットによる sync.Pool
のスケーラビリティ改善は、主に以下の技術的アプローチによって実現されています。
-
P-ローカルキャッシュの導入:
- 各論理プロセッサ(P)は、それぞれ独立した固定サイズのローカルキャッシュ
poolLocal
を持ちます。これにより、ほとんどのPut
およびGet
操作は、グローバルなミューテックスを必要とせず、ローカルなキャッシュに対して行われます。 - ローカルキャッシュへのアクセスは、Goroutineが現在実行されているPに紐付けられるため、ミューテックスによる保護が不要となり、競合が大幅に削減されます。
- 各論理プロセッサ(P)は、それぞれ独立した固定サイズのローカルキャッシュ
-
バッチ転送メカニズム:
- ローカルキャッシュが満杯になった場合(オーバーフロー)、または空になった場合(アンダーフロー)、ローカルキャッシュ内のアイテムの一部(通常は半分)が、グローバルな共有プールにバッチとして転送されます。
- このバッチ転送は、グローバルプールを保護する単一のミューテックス
p.mu
を使用して行われます。しかし、個々のPut
/Get
操作ごとにミューテックスをロックするのではなく、まとめて転送することで、ミューテックスの取得・解放のオーバーヘッドを償却(amortize)し、全体的な競合を低減します。
-
GoroutineのPへの固定 (Pinning):
sync.Pool
のPut
およびGet
メソッドの内部では、runtime_procPin()
とruntime_procUnpin()
というランタイム関数が使用されます。runtime_procPin()
は、現在のGoroutineをそのGoroutineが現在実行されているPに固定し、プリエンプション(他のPへの移動)を一時的に無効にします。これにより、P-ローカルキャッシュへのアクセス中にGoroutineが別のPに移動してしまい、データの一貫性が損なわれることを防ぎます。runtime_procUnpin()
は、固定を解除し、Goroutineが再びプリエンプション可能になります。- この固定は、P-ローカルキャッシュへのアクセスというクリティカルセクションの間のみ行われ、ミューテックスの競合を回避しつつ、P-ローカルデータの安全な利用を保証します。
-
キャッシュラインアライメント:
Pool
構造体にはpad [cacheLineSize]byte
フィールドが追加されています。これは、Pool
構造体のミューテックスmu
とグローバルスライスglobal
が、頻繁に読み書きされるnext
,local
,localSize
,globalOffset
とは異なるキャッシュラインに配置されるようにするためのパディングです。- これにより、
Put
やGet
操作でローカルキャッシュが利用される際に、グローバルミューテックスやグローバルスライスが属するキャッシュラインが不必要に無効化される「False Sharing」を防ぎ、キャッシュ効率を向上させます。
-
ベンチマーク結果の分析:
- コミットメッセージに含まれるベンチマーク結果は、この変更の有効性を明確に示しています。
BenchmarkPool
は、sync.Pool
の基本的なPut
/Get
操作の性能を測定します。特に-4
,-16
,-32
といった並行度が高いテストケースで、旧バージョンと比較して98%以上の劇的な性能向上を達成しています。これは、P-ローカルキャッシュが競合を大幅に削減した結果です。BenchmarkPoolOverflow
は、ローカルキャッシュが頻繁にオーバーフロー/アンダーフローし、グローバルプールとの間でバッチ転送が発生するシナリオをシミュレートします。この場合でも、30%から77%の性能向上が見られ、バッチ転送メカニズムがミューテックスのオーバーヘッドを効果的に償却していることを示しています。BenchmarkSprintf*
のテストは、fmt.Sprintf
の内部でsync.Pool
がどのように利用されているかを示唆しています。これらのテストでも、並行度が高い場合に顕著な性能向上が見られ、sync.Pool
の改善がGo標準ライブラリの他の部分にも良い影響を与えていることがわかります。
これらの変更により、sync.Pool
は高並行環境下でも高いスケーラビリティを発揮し、Goアプリケーション全体のパフォーマンス向上に貢献するようになりました。
コアとなるコードの変更箇所
このコミットの主要な変更は、以下のファイルに集中しています。
src/pkg/sync/pool.go
:sync.Pool
の主要なロジックが実装されているファイル。P-ローカルキャッシュの導入、Put
/Get
メソッドの再実装、Goroutineの固定ロジック、キャッシュラインアライメントのためのパディングなどが含まれます。src/pkg/runtime/proc.c
: Goランタイムのスケジューラ関連のCコード。sync.Pool
から呼び出されるruntime_procPin()
とruntime_procUnpin()
関数が追加されています。これらの関数は、Goroutineを現在のPに固定し、プリエンプションを一時的に無効にする役割を担います。src/pkg/runtime/mgc0.c
: ガベージコレクタ関連のCコード。clearpools
関数がsync.Pool
の新しい内部構造に合わせて更新されています。src/pkg/sync/pool_test.go
:sync.Pool
のベンチマークテスト。新しいスケーラブルな実装の性能を検証するために、テストロジックが更新されています。
コアとなるコードの解説
src/pkg/sync/pool.go
Pool
構造体の変更
type Pool struct {
// The following fields are known to runtime.
next *Pool // for use by runtime
local *poolLocal // local fixed-size per-P pool, actually an array
localSize uintptr // size of the local array
globalOffset uintptr // offset of global
// The rest is not known to runtime.
New func() interface{}
pad [cacheLineSize]byte
// Read-mostly date above this point, mutable data follows.
mu Mutex
global []interface{} // global fallback pool
}
next
: ランタイムが使用する次のプールへのポインタ。local *poolLocal
: 各Pに割り当てられるpoolLocal
の配列へのポインタ。これがP-ローカルキャッシュの実体です。localSize uintptr
:local
配列のサイズ(Pの数に相当)。globalOffset uintptr
:global
フィールドがPool
構造体のどこから始まるかを示すオフセット。ランタイムがこのオフセットを使ってglobal
スライスにアクセスします。pad [cacheLineSize]byte
: キャッシュラインアライメントのためのパディング。これにより、mu
とglobal
が他の頻繁にアクセスされるフィールドとは異なるキャッシュラインに配置され、False Sharingを防ぎます。mu Mutex
: グローバルプールglobal
を保護するためのミューテックス。global []interface{}
: ローカルキャッシュがオーバーフロー/アンダーフローした際に、バッチでアイテムが転送されるグローバルなフォールバックプール。
poolLocal
構造体
type poolLocal struct {
tail int
unused int
buf [poolLocalCap]interface{}
}
tail
:buf
内の次の空きスロットのインデックス。unused
: パディング。buf [poolLocalCap]interface{}
: 固定サイズのローカルキャッシュバッファ。poolLocalCap
はcacheLineSize
に基づいて計算され、通常は1つのキャッシュラインに収まるように設計されています。
Put
メソッドの変更
func (p *Pool) Put(x interface{}) {
if x == nil {
return
}
l := p.pin() // GoroutineをPに固定し、P-ローカルキャッシュを取得
t := l.tail
if t < int(poolLocalCap) { // ローカルキャッシュに空きがある場合
l.buf[t] = x
l.tail = t + 1
runtime_procUnpin() // 固定を解除
return
}
p.putSlow(l, x) // ローカルキャッシュが満杯の場合、putSlowを呼び出す
}
func (p *Pool) putSlow(l *poolLocal, x interface{}) {
// Grab half of items from local pool and put to global pool.
// Can not lock the mutex while pinned.
const N = int(poolLocalCap/2 + 1) // ローカルキャッシュの半分を転送
var buf [N]interface{}
buf[0] = x // 新しいアイテムもバッチに含める
for i := 1; i < N; i++ {
l.tail--
buf[i] = l.buf[l.tail] // ローカルキャッシュからアイテムを移動
}
runtime_procUnpin() // 固定を解除してからミューテックスをロック
p.mu.Lock()
p.global = append(p.global, buf[:]...) // グローバルプールにバッチで追加
p.mu.Unlock()
}
Put
はまず pin()
でP-ローカルキャッシュを取得し、ローカルキャッシュに空きがあれば直接追加します。空きがない場合は putSlow
を呼び出し、ローカルキャッシュの半分と新しいアイテムをグローバルプールにバッチで転送します。この際、runtime_procUnpin()
を呼び出してGoroutineの固定を解除してからグローバルミューテックスをロックすることで、デッドロックを防ぎます。
Get
メソッドの変更
func (p *Pool) Get() interface{} {
l := p.pin() // GoroutineをPに固定し、P-ローカルキャッシュを取得
t := l.tail
if t > 0 { // ローカルキャッシュにアイテムがある場合
t -= 1
x := l.buf[t]
l.tail = t
runtime_procUnpin() // 固定を解除
return x
}
return p.getSlow() // ローカルキャッシュが空の場合、getSlowを呼び出す
}
func (p *Pool) getSlow() (x interface{}) {
// Grab a batch of items from global pool and put to local pool.
// Can not lock the mutex while pinned.
runtime_procUnpin() // 固定を解除してからミューテックスをロック
p.mu.Lock()
pid := runtime_procPin() // ミューテックス内で再度固定
s := p.localSize
l := p.local
if uintptr(pid) < s {
l = indexLocal(l, pid)
// Get the item to return.
last := len(p.global) - 1
if last >= 0 {
x = p.global[last]
p.global = p.global[:last]
}
// Try to refill local pool, we may have been rescheduled to another P.
if last > 0 && l.tail == 0 {
n := int(poolLocalCap / 2)
gl := len(p.global)
if n > gl {
n = gl
}
copy(l.buf[:], p.global[gl-n:]) // グローバルプールから半分をローカルに補充
p.global = p.global[:gl-n]
l.tail = n
}
}
runtime_procUnpin() // 固定を解除
p.mu.Unlock()
if x == nil && p.New != nil {
x = p.New() // プールが空でNew関数が設定されていれば新しいオブジェクトを生成
}
return
}
Get
はまず pin()
でP-ローカルキャッシュを取得し、ローカルキャッシュにアイテムがあれば直接取得します。空の場合は getSlow
を呼び出し、グローバルプールからアイテムを取得し、さらにグローバルプールからアイテムのバッチをローカルキャッシュに補充します。ここでも、グローバルミューテックスをロックする前にGoroutineの固定を解除し、ロック後に再度固定することで安全性を確保しています。
pin
および pinSlow
メソッド
// pin pins current goroutine to P, disables preemption and returns poolLocal pool for the P.
// Caller must call runtime_procUnpin() when done with the pool.
func (p *Pool) pin() *poolLocal {
pid := runtime_procPin() // GoroutineをPに固定し、PのIDを取得
// ... (localSizeとlocalのロードロジック)
if uintptr(pid) < s {
return indexLocal(l, pid) // 既存のP-ローカルキャッシュを返す
}
return p.pinSlow() // P-ローカルキャッシュがまだ割り当てられていない場合
}
func (p *Pool) pinSlow() *poolLocal {
// Retry under the mutex.
runtime_procUnpin() // 一度固定を解除
p.mu.Lock() // グローバルミューテックスをロック
defer p.mu.Unlock()
pid := runtime_procPin() // 再度固定
// ... (P-ローカルキャッシュの初期化/再割り当てロジック)
if p.local == nil {
p.globalOffset = unsafe.Offsetof(p.global)
runtime_registerPool(p) // ランタイムにプールを登録
}
// If GOMAXPROCS changes between GCs, we re-allocate the array and lose the old one.
size := runtime.GOMAXPROCS(0) // 現在のGOMAXPROCSの値を取得
local := make([]poolLocal, size) // Pの数に合わせてpoolLocal配列を生成
atomic.StorePointer((*unsafe.Pointer)(unsafe.Pointer(&p.local)), unsafe.Pointer(&local[0])) // store-release
atomic.StoreUintptr(&p.localSize, uintptr(size)) // store-release
return &local[pid] // 新しく割り当てられたP-ローカルキャッシュを返す
}
pin()
は、Goroutineを現在のPに固定し、そのPに対応する poolLocal
キャッシュを返します。もし poolLocal
キャッシュがまだ初期化されていない場合や、GOMAXPROCS
の変更などで再割り当てが必要な場合は、pinSlow()
が呼び出されます。pinSlow()
はグローバルミューテックスをロックして安全に poolLocal
配列を初期化または再割り当てします。
src/pkg/runtime/proc.c
// func runtime_procPin() int
void
sync·runtime_procPin(intgo p)
{
M *mp;
mp = m;
// Disable preemption.
mp->locks++; // Mのロックカウンタをインクリメントし、プリエンプションを無効化
p = mp->p->id; // 現在のPのIDを取得
FLUSH(&p); // メモリバリア
}
// func runtime_procUnpin()
void
sync·runtime_procUnpin(void)
{
m->locks--; // Mのロックカウンタをデクリメントし、プリエンプションを有効化
}
これらのC関数は、Goランタイムの内部でGoroutineのプリエンプションを制御します。
runtime_procPin()
: 現在のM(OSスレッド)のlocks
カウンタをインクリメントします。locks
カウンタが0より大きい間は、そのM上で実行されているGoroutineはプリエンプションされません。これにより、Goroutineは特定のPに固定され、P-ローカルキャッシュへのアクセス中に他のPに移動することを防ぎます。runtime_procUnpin()
:locks
カウンタをデクリメントします。カウンタが0に戻ると、プリエンプションが再び可能になります。
これらのランタイム関数は、sync.Pool
のP-ローカルキャッシュへのアクセスというクリティカルセクションの整合性を保証するために不可欠です。
関連リンク
- Go言語の
sync.Pool
ドキュメント: https://pkg.go.dev/sync#Pool - Go言語のスケジューラに関する詳細(非公式ですが参考になります): https://go.dev/doc/articles/go_scheduler.html
参考にした情報源リンク
- Go言語のソースコード (特に
src/pkg/sync/pool.go
,src/pkg/runtime/proc.c
) - Go言語のコミット履歴
- Go言語のスケジューラに関する一般的な知識
- CPUキャッシュとFalse Sharingに関する一般的な知識