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

[インデックス 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.PoolPutGet メソッドを呼び出すような状況では、この単一のミューテックスがボトルネックとなり、深刻な競合(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{}: プールからオブジェクトを取得します。プールが空の場合、PoolNew フィールドに設定された関数が呼び出され、新しいオブジェクトが生成されます。

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 のスケーラビリティ改善は、主に以下の技術的アプローチによって実現されています。

  1. P-ローカルキャッシュの導入:

    • 各論理プロセッサ(P)は、それぞれ独立した固定サイズのローカルキャッシュ poolLocal を持ちます。これにより、ほとんどの Put および Get 操作は、グローバルなミューテックスを必要とせず、ローカルなキャッシュに対して行われます。
    • ローカルキャッシュへのアクセスは、Goroutineが現在実行されているPに紐付けられるため、ミューテックスによる保護が不要となり、競合が大幅に削減されます。
  2. バッチ転送メカニズム:

    • ローカルキャッシュが満杯になった場合(オーバーフロー)、または空になった場合(アンダーフロー)、ローカルキャッシュ内のアイテムの一部(通常は半分)が、グローバルな共有プールにバッチとして転送されます。
    • このバッチ転送は、グローバルプールを保護する単一のミューテックス p.mu を使用して行われます。しかし、個々の Put/Get 操作ごとにミューテックスをロックするのではなく、まとめて転送することで、ミューテックスの取得・解放のオーバーヘッドを償却(amortize)し、全体的な競合を低減します。
  3. GoroutineのPへの固定 (Pinning):

    • sync.PoolPut および Get メソッドの内部では、runtime_procPin()runtime_procUnpin() というランタイム関数が使用されます。
    • runtime_procPin() は、現在のGoroutineをそのGoroutineが現在実行されているPに固定し、プリエンプション(他のPへの移動)を一時的に無効にします。これにより、P-ローカルキャッシュへのアクセス中にGoroutineが別のPに移動してしまい、データの一貫性が損なわれることを防ぎます。
    • runtime_procUnpin() は、固定を解除し、Goroutineが再びプリエンプション可能になります。
    • この固定は、P-ローカルキャッシュへのアクセスというクリティカルセクションの間のみ行われ、ミューテックスの競合を回避しつつ、P-ローカルデータの安全な利用を保証します。
  4. キャッシュラインアライメント:

    • Pool 構造体には pad [cacheLineSize]byte フィールドが追加されています。これは、Pool 構造体のミューテックス mu とグローバルスライス global が、頻繁に読み書きされる next, local, localSize, globalOffset とは異なるキャッシュラインに配置されるようにするためのパディングです。
    • これにより、PutGet 操作でローカルキャッシュが利用される際に、グローバルミューテックスやグローバルスライスが属するキャッシュラインが不必要に無効化される「False Sharing」を防ぎ、キャッシュ効率を向上させます。
  5. ベンチマーク結果の分析:

    • コミットメッセージに含まれるベンチマーク結果は、この変更の有効性を明確に示しています。
    • BenchmarkPool は、sync.Pool の基本的な Put/Get 操作の性能を測定します。特に -4, -16, -32 といった並行度が高いテストケースで、旧バージョンと比較して98%以上の劇的な性能向上を達成しています。これは、P-ローカルキャッシュが競合を大幅に削減した結果です。
    • BenchmarkPoolOverflow は、ローカルキャッシュが頻繁にオーバーフロー/アンダーフローし、グローバルプールとの間でバッチ転送が発生するシナリオをシミュレートします。この場合でも、30%から77%の性能向上が見られ、バッチ転送メカニズムがミューテックスのオーバーヘッドを効果的に償却していることを示しています。
    • BenchmarkSprintf* のテストは、fmt.Sprintf の内部で sync.Pool がどのように利用されているかを示唆しています。これらのテストでも、並行度が高い場合に顕著な性能向上が見られ、sync.Pool の改善がGo標準ライブラリの他の部分にも良い影響を与えていることがわかります。

これらの変更により、sync.Pool は高並行環境下でも高いスケーラビリティを発揮し、Goアプリケーション全体のパフォーマンス向上に貢献するようになりました。

コアとなるコードの変更箇所

このコミットの主要な変更は、以下のファイルに集中しています。

  1. src/pkg/sync/pool.go: sync.Pool の主要なロジックが実装されているファイル。P-ローカルキャッシュの導入、Put/Get メソッドの再実装、Goroutineの固定ロジック、キャッシュラインアライメントのためのパディングなどが含まれます。
  2. src/pkg/runtime/proc.c: Goランタイムのスケジューラ関連のCコード。sync.Pool から呼び出される runtime_procPin()runtime_procUnpin() 関数が追加されています。これらの関数は、Goroutineを現在のPに固定し、プリエンプションを一時的に無効にする役割を担います。
  3. src/pkg/runtime/mgc0.c: ガベージコレクタ関連のCコード。clearpools 関数が sync.Pool の新しい内部構造に合わせて更新されています。
  4. 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: キャッシュラインアライメントのためのパディング。これにより、muglobal が他の頻繁にアクセスされるフィールドとは異なるキャッシュラインに配置され、False Sharingを防ぎます。
  • mu Mutex: グローバルプール global を保護するためのミューテックス。
  • global []interface{}: ローカルキャッシュがオーバーフロー/アンダーフローした際に、バッチでアイテムが転送されるグローバルなフォールバックプール。

poolLocal 構造体

type poolLocal struct {
	tail   int
	unused int
	buf    [poolLocalCap]interface{}
}
  • tail: buf 内の次の空きスロットのインデックス。
  • unused: パディング。
  • buf [poolLocalCap]interface{}: 固定サイズのローカルキャッシュバッファ。poolLocalCapcacheLineSize に基づいて計算され、通常は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言語のソースコード (特に src/pkg/sync/pool.go, src/pkg/runtime/proc.c)
  • Go言語のコミット履歴
  • Go言語のスケジューラに関する一般的な知識
  • CPUキャッシュとFalse Sharingに関する一般的な知識