Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

[インデックス 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からはアクセスできないため、以下のような問題が発生していました。

  1. メモリの浪費: もしsync.Poolが大きなオブジェクト(例: 2MB)をキャッシュし、かつGOMAXPROCSが大きな値(例: 32)に設定されている場合、理論上は15要素/P * 2MB/要素 * 32P = 960MBものメモリが、他のPからは利用できない状態でキャッシュされ、実質的に浪費される可能性がありました。これは、特にメモリ使用量がクリティカルなアプリケーションにおいて大きな問題となります。
  2. 競合下での非効率性: ある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で共有されるオブジェクトを保持します。PutGetでこのスライスにアクセスする際には、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のローカルキャッシュ(privateshared)からオブジェクトを取得できなかった場合に呼び出されます。このメソッドは、他の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内のすべてのキャッシュされたオブジェクト(privatesharedの両方)をクリアする責任を負います。この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での特定のシナリオでわずかな性能低下があるものの、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といったフィールドが削除または変更されました。新しいlocallocalSizeフィールドは、unsafe.Pointeruintptr型になりました。
    • poolLocal構造体が大幅に再定義されました。tail, unused, bufフィールドが削除され、代わりにprivate interface{}, shared []interface{}, Mutex, pad [128]byteが追加されました。
    • Putメソッドのロジックが完全に書き換えられ、privateキャッシュとsharedキャッシュを優先的に利用するようになりました。
    • Getメソッドのロジックも同様に書き換えられ、privateshared、そして他のPのsharedキャッシュからの取得を試みるようになりました。
    • putSlow関数が削除されました。
    • getSlow関数が大幅に書き換えられ、他のPのsharedキャッシュから要素を「盗む」ロジックが実装されました。
    • pinSlow関数も変更され、allPoolsグローバルスライスへの登録と、poolLocal配列の再割り当てロジックが調整されました。
    • poolCleanup関数が新しく追加され、GC開始時にすべてのsync.Poolのキャッシュをクリアする責任を負います。
    • allPoolsMuallPoolsというグローバル変数が追加され、すべての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フィールドは、偽共有を防ぐための巧妙な最適化であり、マルチコア環境でのパフォーマンス低下を回避します。
    • PutGetのロジック: これらのメソッドは、まず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の動作、特にPutGetの順序に関する挙動が変更されたことを反映しています。これは、実装の変更が意図した通りに機能していることを確認するためのものです。

これらの変更は、Goランタイムの設計思想である「シンプルさと効率性」を体現しており、特に並行処理とメモリ管理の分野で、より堅牢で高性能なシステムを構築するための基盤を提供しています。

関連リンク

参考にした情報源リンク

(注:上記の参考にした情報源リンクは、一般的なGo言語の概念やsync.Poolに関する情報源の例であり、このコミットに直接言及しているとは限りません。解説作成時にこれらのトピックについてWeb検索を行い、得られた情報に基づいて記述しています。)