[インデックス 19127] ファイルの概要
このコミットは、Go言語の標準ライブラリであるsync.Pool
の内部キャッシュメカニズムを改善し、特に高GOMAXPROCS環境下でのメモリ効率とスケーラビリティを向上させるものです。P(論理プロセッサ)ごとのローカルキャッシュをより控えめにし、共有キャッシュの利用を促進することで、大規模なオブジェクトを扱う際のメモリ浪費を削減し、競合下でのパフォーマンスを改善しています。
コミット
commit 8fc6ed4c8901d13fe1a5aa176b0ba808e2855af5
Author: Dmitriy Vyukov <dvyukov@google.com>
Date: Mon Apr 14 21:13:32 2014 +0400
sync: less agressive local caching in Pool
Currently Pool can cache up to 15 elements per P, and these elements are not accesible to other Ps.
If a Pool caches large objects, say 2MB, and GOMAXPROCS is set to a large value, say 32,
then the Pool can waste up to 960MB.
The new caching policy caches at most 1 per-P element, the rest is shared between Ps.
Get/Put performance is unchanged. Nested Get/Put performance is 57% worse.
However, overall scalability of nested Get/Put is significantly improved,
so the new policy starts winning under contention.
benchmark old ns/op new ns/op delta
BenchmarkPool 27.4 26.7 -2.55%
BenchmarkPool-4 6.63 6.59 -0.60%
BenchmarkPool-16 1.98 1.87 -5.56%
BenchmarkPool-64 1.93 1.86 -3.63%
BenchmarkPoolOverlflow 3970 6235 +57.05%
BenchmarkPoolOverlflow-4 10935 1668 -84.75%
BenchmarkPoolOverlflow-16 13419 520 -96.12%
BenchmarkPoolOverlflow-64 10295 380 -96.31%
LGTM=rsc
R=rsc
CC=golang-codereviews, khr
https://golang.org/cl/86020043
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/8fc6ed4c8901d13fe1a5aa176b0ba808e2855af5
元コミット内容
上記の「コミット」セクションに記載されている内容が、このコミットの元のメッセージです。
変更の背景
このコミットの主な背景は、Go言語のsync.Pool
が抱えていたメモリ効率の問題と、高負荷環境下でのスケーラビリティの課題を解決することにありました。
従来のsync.Pool
の実装では、各P(論理プロセッサ)が最大15個の要素をローカルにキャッシュしていました。このローカルキャッシュは他のPからはアクセスできないため、以下のような問題が発生していました。
- メモリの浪費: もし
sync.Pool
が大きなオブジェクト(例: 2MB)をキャッシュし、かつGOMAXPROCS
が大きな値(例: 32)に設定されている場合、理論上は15要素/P * 2MB/要素 * 32P = 960MB
ものメモリが、他のPからは利用できない状態でキャッシュされ、実質的に浪費される可能性がありました。これは、特にメモリ使用量がクリティカルなアプリケーションにおいて大きな問題となります。 - 競合下での非効率性: あるPがローカルにキャッシュしているオブジェクトは、他のPが
Get
操作をしても利用できませんでした。これにより、オブジェクトの再利用効率が低下し、特に複数のPが同時にsync.Pool
にアクセスするような競合の激しいシナリオでは、不必要なオブジェクトの生成やGCの発生につながる可能性がありました。
このコミットは、これらの問題を解決するために、sync.Pool
のキャッシュポリシーを根本的に見直し、Pローカルなキャッシュをより控えめにし、共有キャッシュの利用を促進することで、メモリ効率の向上と競合下でのスケーラビリティの改善を目指しました。
前提知識の解説
このコミットの変更内容を理解するためには、以下のGo言語のランタイムおよび並行処理に関する基本的な概念を理解しておく必要があります。
-
sync.Pool
:sync.Pool
はGo言語の標準ライブラリsync
パッケージに含まれる型で、一時的なオブジェクトの再利用を目的としています。これにより、ガベージコレクション(GC)の負荷を軽減し、オブジェクトのアロケーション(メモリ確保)を減らすことで、アプリケーションのパフォーマンスを向上させることができます。Put
メソッドでオブジェクトをプールに戻し、Get
メソッドでプールからオブジェクトを取得します。プール内のオブジェクトはGCの対象外となるため、明示的にプールから取り出されない限り、GCによって回収されることはありません。ただし、GCのサイクル中にプールが自動的にクリアされる可能性があるため、プールに格納されたオブジェクトが永続的に利用可能であると仮定すべきではありません。 -
P (Processor): GoランタイムにおけるPは「論理プロセッサ」を指します。これは、Goルーチンを実行するためのコンテキストを提供する抽象的な概念です。
GOMAXPROCS
環境変数やruntime.GOMAXPROCS()
関数によって設定されるPの数は、同時に実行可能なGoルーチンの最大数を決定します。各Pは、Goルーチンをスケジュールし、実行するためのローカルキューやリソースを管理します。 -
GC (Garbage Collection): Go言語は自動メモリ管理(ガベージコレクション)を採用しています。GCは、プログラムがもはや参照しないメモリ領域を自動的に識別し、解放するプロセスです。これにより、開発者は手動でのメモリ管理から解放されますが、GCの実行は一時的にプログラムの実行を停止させる(Stop-The-World)ことがあり、これがパフォーマンスに影響を与える場合があります。
sync.Pool
は、頻繁に生成・破棄されるオブジェクトを再利用することで、GCの頻度や停止時間を減らすのに役立ちます。 -
キャッシュラインと偽共有 (False Sharing):
- キャッシュライン: CPUはメモリからデータを読み込む際に、一度に少量のデータ(通常64バイト)をまとめてキャッシュに読み込みます。この最小単位がキャッシュラインです。
- 偽共有 (False Sharing): 複数のCPUコアがそれぞれ異なる変数にアクセスしているにもかかわらず、それらの変数が偶然にも同じキャッシュライン上に存在する場合に発生するパフォーマンス問題です。あるコアがそのキャッシュライン上の変数を変更すると、キャッシュコヒーレンシプロトコルにより、他のコアの同じキャッシュラインが無効化されます。これにより、他のコアは最新のデータを取得するためにメインメモリから再読み込みを行う必要が生じ、不要なキャッシュの無効化とメモリトラフィックが発生し、パフォーマンスが低下します。この問題を避けるために、関連性のないデータが同じキャッシュラインに乗らないように、構造体にパディング(余分なバイトを追加)することがあります。
-
runtime_procPin()
/runtime_procUnpin()
: これらはGoランタイムの内部関数で、Goルーチンを特定のPに「固定(pin)」し、プリエンプション(Goルーチンの実行を一時停止して他のGoルーチンに切り替えること)を防ぐために使用されます。これにより、GoルーチンがPローカルなデータ構造に安全にアクセスできるようになります。操作が完了したら、runtime_procUnpin()
を呼び出して固定を解除します。 -
アトミック操作 (
atomic.LoadUintptr
,atomic.StorePointer
など): アトミック操作は、複数のGoルーチンから同時にアクセスされてもデータ競合が発生しないように、不可分(分割不可能)な操作を提供します。これにより、共有データへの安全なアクセスが保証され、並行処理におけるバグ(例: 競合状態)を防ぐことができます。
技術的詳細
このコミットは、sync.Pool
の内部実装を大幅に変更し、特にpoolLocal
構造体とPut
/Get
メソッドのロジックを刷新することで、前述のメモリ効率とスケーラビリティの問題に対処しています。
1. 新しいキャッシュポリシー
- Pローカルキャッシュの削減: 従来のPごとに最大15個の要素をキャッシュする代わりに、新しいポリシーでは各Pが最大1つのプライベート要素(
poolLocal.private
)をキャッシュします。 - 共有キャッシュの導入: 残りの要素は、各Pの
poolLocal
構造体内のshared
スライスに格納されます。このshared
スライスは、ミューテックス(poolLocal.Mutex
)によって保護されており、他のPからもアクセス可能です。これにより、P間でオブジェクトを効率的に共有できるようになります。
2. poolLocal
構造体の変更
type poolLocal struct {
private interface{} // Can be used only by the respective P.
shared []interface{} // Can be used by any P.
Mutex // Protects shared.
pad [128]byte // Prevents false sharing.
}
private interface{}
: このフィールドは、現在のPのみが排他的に利用できる単一のオブジェクトを保持します。ロックなしでアクセスできるため、最も高速なパスを提供します。shared []interface{}
: このスライスは、複数のPで共有されるオブジェクトを保持します。Put
やGet
でこのスライスにアクセスする際には、Mutex
によるロックが必要です。Mutex
:shared
スライスへの並行アクセスを保護するためのミューテックスです。pad [128]byte
: 偽共有(False Sharing)を防ぐためのパディングです。poolLocal
のインスタンスが配列として配置される際、隣接するインスタンスが異なるキャッシュラインに確実に配置されるように、128バイトのパディングが追加されています。これにより、あるPが自身のpoolLocal
を更新しても、他のPのpoolLocal
が不必要にキャッシュ無効化されることを防ぎ、パフォーマンスの低下を抑制します。
3. Put
メソッドの変更
func (p *Pool) Put(x interface{}) {
if x == nil {
return
}
l := p.pin() // 現在のPにGoルーチンを固定し、対応するpoolLocalを取得
if l.private == nil {
l.private = x // まずprivateに格納を試みる
x = nil
}
runtime_procUnpin() // 固定を解除
if x == nil {
return
}
l.Lock() // sharedスライスへのアクセスをロック
l.shared = append(l.shared, x) // sharedスライスに追加
l.Unlock() // ロックを解除
}
Put
操作では、まず現在のPのpoolLocal.private
フィールドにオブジェクトを格納しようとします。private
が空であれば、そこに格納し、高速に処理を完了します。もしprivate
が既に埋まっている場合、オブジェクトはpoolLocal.shared
スライスに追加されます。このshared
スライスへのアクセスは、Mutex
によって保護されるため、複数のPからの同時アクセスでも安全性が保たれます。
4. Get
メソッドの変更
func (p *Pool) Get() interface{} {
l := p.pin() // 現在のPにGoルーチンを固定し、対応するpoolLocalを取得
x := l.private // まずprivateから取得を試みる
l.private = nil // 取得したらprivateをクリア
runtime_procUnpin() // 固定を解除
if x != nil {
return x // privateから取得できたらそれを返す
}
l.Lock() // sharedスライスへのアクセスをロック
last := len(l.shared) - 1
if last >= 0 {
x = l.shared[last] // sharedスライスの末尾から取得
l.shared = l.shared[:last] // 取得した要素をスライスから削除
}
l.Unlock() // ロックを解除
if x != nil {
return x // sharedから取得できたらそれを返す
}
return p.getSlow() // どちらからも取得できない場合、getSlowを呼び出す
}
Get
操作では、まず現在のPのpoolLocal.private
フィールドからオブジェクトを取得しようとします。private
が空の場合、次にpoolLocal.shared
スライスからオブジェクトを取得しようとします。shared
スライスへのアクセスはMutex
によって保護されます。それでもオブジェクトが取得できない場合、getSlow
メソッドが呼び出されます。
5. getSlow
メソッドの変更
getSlow
メソッドは、現在のPのローカルキャッシュ(private
とshared
)からオブジェクトを取得できなかった場合に呼び出されます。このメソッドは、他のPのshared
プールからオブジェクトを「盗む」ロジックを含んでいます。
func (p *Pool) getSlow() (x interface{}) {
// ... (pin/unpin, atomic loads for size/local) ...
pid := runtime_procPin() // 現在のPのIDを取得
runtime_procUnpin()
// 他のPのsharedプールから要素を盗む試み
for i := 0; i < int(size); i++ {
l := indexLocal(local, (pid+i+1)%int(size)) // 他のPのpoolLocalを取得
l.Lock() // そのPのsharedスライスをロック
last := len(l.shared) - 1
if last >= 0 {
x = l.shared[last] // 要素を取得
l.shared = l.shared[:last]
l.Unlock() // ロックを解除
break // 取得できたらループを抜ける
}
l.Unlock() // ロックを解除
}
if x == nil && p.New != nil {
x = p.New() // プールが空でNew関数が設定されていれば、新しいオブジェクトを生成
}
return x
}
この変更により、グローバルな競合が減少し、複数のPが同時にオブジェクトを要求するシナリオでのスケーラビリティが大幅に向上します。
6. GC時のクリーンアップメカニズムの変更
このコミットでは、sync.Pool
がガベージコレクション(GC)の開始時に自動的にクリアされるメカニズムも変更されています。
-
src/pkg/runtime/mgc0.c
の変更:sync.Pool
のクリーンアップ処理をランタイムが直接管理するのではなく、sync.Pool
自身が登録したクリーンアップ関数を呼び出すように変更されました。具体的には、sync·runtime_registerPool
が削除され、sync·runtime_registerPoolCleanup
が導入されました。clearpools
関数は、登録されたpoolcleanup
関数ポインタを呼び出すようになりました。 -
src/pkg/sync/pool.go
の変更:poolCleanup
という関数が追加され、この関数がGC開始時にsync.Pool
内のすべてのキャッシュされたオブジェクト(private
とshared
の両方)をクリアする責任を負います。このpoolCleanup
関数は、init()
関数内でruntime_registerPoolCleanup
を介してランタイムに登録されます。これにより、sync.Pool
の内部構造がランタイムから分離され、よりモジュール化された設計になっています。GC時にプールがクリアされることで、プールに一時的に格納されたオブジェクトがGCの対象となり、メモリリークの懸念が解消されます。
ベンチマーク結果の分析
コミットメッセージに含まれるベンチマーク結果は、この変更の意図と効果を明確に示しています。
-
BenchmarkPool
(基本的なGet/Put): 単一Pおよび複数P(-4, -16, -64)での基本的なGet
/Put
操作のパフォーマンスは、わずかに改善またはほぼ変化なし(-0.60%から-5.56%)。これは、新しいポリシーが基本的な操作のオーバーヘッドを大きく増やしていないことを示しています。 -
BenchmarkPoolOverlflow
(ネストされたGet/Put):- 単一P (
BenchmarkPoolOverlflow
): 57.05%の性能悪化。これは、単一P環境ではprivate
キャッシュが1つに制限され、shared
キャッシュへのアクセスにミューテックスのオーバーヘッドが加わるためと考えられます。 - 複数P (
BenchmarkPoolOverlflow-4
,-16
,-64
): 驚異的な性能改善(-84.75%から-96.31%)。これは、新しいポリシーが競合下でのスケーラビリティを劇的に向上させることを明確に示しています。他のPのshared
プールから効率的にオブジェクトを「盗む」メカニズムが、競合によるボトルネックを大幅に緩和しているためです。
- 単一P (
結論として、このコミットは、単一Pでの特定のシナリオでわずかな性能低下があるものの、Goアプリケーションが複数のCPUコアを利用する現代の環境において、sync.Pool
のメモリ効率と並行処理性能を大幅に向上させる重要な改善であると言えます。
コアとなるコードの変更箇所
このコミットによる主要なコード変更は以下のファイルに集中しています。
-
src/pkg/runtime/mgc0.c
:pools
構造体(sync.Pool
のグローバルリストを管理していた)が削除されました。sync·runtime_registerPool
関数が削除され、代わりにsync·runtime_registerPoolCleanup
関数が導入されました。これは、GC開始時に呼び出されるクリーンアップ関数を登録するためのものです。clearpools
関数が、新しいpoolcleanup
関数ポインタを呼び出すように変更されました。
-
src/pkg/sync/pool.go
:Pool
構造体の定義が変更され、next
,local
,localSize
,globalOffset
,mu
,global
といったフィールドが削除または変更されました。新しいlocal
とlocalSize
フィールドは、unsafe.Pointer
とuintptr
型になりました。poolLocal
構造体が大幅に再定義されました。tail
,unused
,buf
フィールドが削除され、代わりにprivate interface{}
,shared []interface{}
,Mutex
,pad [128]byte
が追加されました。Put
メソッドのロジックが完全に書き換えられ、private
キャッシュとshared
キャッシュを優先的に利用するようになりました。Get
メソッドのロジックも同様に書き換えられ、private
、shared
、そして他のPのshared
キャッシュからの取得を試みるようになりました。putSlow
関数が削除されました。getSlow
関数が大幅に書き換えられ、他のPのshared
キャッシュから要素を「盗む」ロジックが実装されました。pinSlow
関数も変更され、allPools
グローバルスライスへの登録と、poolLocal
配列の再割り当てロジックが調整されました。poolCleanup
関数が新しく追加され、GC開始時にすべてのsync.Pool
のキャッシュをクリアする責任を負います。allPoolsMu
とallPools
というグローバル変数が追加され、すべてのsync.Pool
インスタンスを追跡するために使用されます。init()
関数内でruntime_registerPoolCleanup(poolCleanup)
が呼び出され、poolCleanup
関数がランタイムに登録されるようになりました。indexLocal
関数の実装が簡素化されました。runtime_registerPool
の宣言がruntime_registerPoolCleanup
に変更されました。
-
src/pkg/sync/pool_test.go
:TestPool
内のGet
操作の期待値が変更されました。これは、Put
された要素がGet
される順序が、新しいキャッシュポリシーによって変わる可能性があるためです。
コアとなるコードの解説
-
src/pkg/runtime/mgc0.c
の変更: このC言語のファイルはGoランタイムのガベージコレクタの一部であり、sync.Pool
のクリーンアップ処理との連携を担っています。変更の核心は、ランタイムがsync.Pool
の内部構造に直接触れるのをやめ、代わりにsync.Pool
自身が提供するコールバック関数(poolcleanup
)を呼び出すようにした点です。これにより、ランタイムとsync.Pool
間の結合度が低下し、sync.Pool
の実装変更がランタイムに与える影響が少なくなりました。これは、よりクリーンで保守性の高い設計への移行を示しています。 -
src/pkg/sync/pool.go
の変更: このファイルはsync.Pool
のGo言語での実装そのものです。poolLocal
構造体の再設計: 最も重要な変更点です。private
フィールドは、ロックなしでアクセスできる超高速なキャッシュパスを提供します。これは、Goルーチンが自身のPでオブジェクトを頻繁に再利用するシナリオで非常に効果的です。shared
スライスは、private
が埋まっている場合や、他のPがオブジェクトを必要とする場合に利用されます。Mutex
による保護は、shared
スライスへの並行アクセスを安全にします。pad
フィールドは、偽共有を防ぐための巧妙な最適化であり、マルチコア環境でのパフォーマンス低下を回避します。Put
とGet
のロジック: これらのメソッドは、まずprivate
キャッシュを優先し、次にshared
キャッシュを利用するという新しい階層的なキャッシュ戦略を実装しています。これにより、ほとんどの操作は高速なprivate
パスで処理され、競合が発生した場合にのみロックを伴うshared
パスや、さらにコストのかかるgetSlow
パスにフォールバックします。getSlow
の改善: 他のPのshared
プールからオブジェクトを「盗む」ロジックは、システム全体でのオブジェクトの再利用率を高め、特に高競合環境下でのスループットを向上させます。これにより、メモリの浪費が減り、GCの頻度も抑制されます。- GCクリーンアップの統合:
poolCleanup
関数とallPools
グローバル変数の導入は、sync.Pool
のメモリ管理をより堅牢にします。GC開始時にプールがクリアされることで、一時的にプールに格納されたオブジェクトがGCの対象となり、メモリリークの可能性が排除されます。これは、sync.Pool
の利用における重要な保証を提供します。
-
src/pkg/sync/pool_test.go
の変更: テストコードの変更は、新しいsync.Pool
の動作、特にPut
とGet
の順序に関する挙動が変更されたことを反映しています。これは、実装の変更が意図した通りに機能していることを確認するためのものです。
これらの変更は、Goランタイムの設計思想である「シンプルさと効率性」を体現しており、特に並行処理とメモリ管理の分野で、より堅牢で高性能なシステムを構築するための基盤を提供しています。
関連リンク
- https://github.com/golang/go/commit/8fc6ed4c8901d13fe1a5aa176b0ba808e2855af5
- Go CL 86020043 (コミットメッセージに記載されているGo Code Reviewのリンク)
参考にした情報源リンク
- Go言語の
sync.Pool
に関する公式ドキュメントやブログ記事 - Goランタイムのスケジューラ(GPMモデル)に関する解説
- CPUキャッシュと偽共有(False Sharing)に関する一般的な情報
- Go言語のアトミック操作に関するドキュメント
- Go言語のガベージコレクションに関する解説
- Go の sync.Pool の実装を読んでみた (日本語の解説記事、類似のトピックを扱っている可能性)
- Go の sync.Pool の内部実装について (日本語の解説記事、類似のトピックを扱っている可能性)
- Go の sync.Pool の使い方と注意点 (日本語の解説記事、一般的な使い方と注意点)
- Go の sync.Pool の実装を理解する (日本語の解説記事、実装に関する深い洞察)
- Go の sync.Pool の内部構造 (英語の解説記事、内部構造に関する詳細)
- Go の sync.Pool のパフォーマンス (英語の解説記事、パフォーマンスに関する考察)
- Go の sync.Pool と GC (Go公式ブログ、sync.PoolとGCに関する情報)
- Go の GOMAXPROCS (Go公式ドキュメント、GOMAXPROCSに関する情報)
- Go の False Sharing (英語の解説記事、Goにおける偽共有)
- Go の Atomic Operations (Go公式ドキュメント、アトミック操作)
- Go の GC (Go公式ドキュメント、GCに関するガイド)
- Go の Runtime Scheduler (Go公式ブログ、ランタイムスケジューラに関する情報)
(注:上記の参考にした情報源リンクは、一般的なGo言語の概念やsync.Pool
に関する情報源の例であり、このコミットに直接言及しているとは限りません。解説作成時にこれらのトピックについてWeb検索を行い、得られた情報に基づいて記述しています。)