🚀

【完全解説】Go並行処理の偽共有と性能低下を防ぐ全手法

2025/02/05に公開

1. はじめに

Go言語は、そのシンプルな構文と並行処理を容易に扱える仕組みにより、多くの開発者に支持されています。特に、Goのゴルーチンとチャネルを用いた並行処理は、複雑な処理を直感的に実装できる点が魅力です。しかし、並行処理を実装する際に見落とされがちな問題として、CPUキャッシュの仕組みに起因する「偽共有(False Sharing)」が挙げられます。

偽共有は、プログラムの動作結果に誤りを生むものではなく、パフォーマンス低下という形で現れます。CPUキャッシュのキャッシュライン上に複数の変数が存在する場合、一方の変数が更新されるたびに同じキャッシュライン内の他の変数が無効化される可能性があるためです。この現象は、並行処理を行うプログラムにおいて非常に注意すべき点です。なぜなら、複数のゴルーチンが独立に処理を実行していると見かけても、メモリ上のデータ配置次第で予期せぬオーバーヘッドが発生するからです。

この記事では、CPUキャッシュの基本概念はすでに把握している読者を対象に、偽共有の定義、発生原因、そして実際のGoコードにおける具体例やベンチマーク結果を通して、その影響と対策について詳しく解説します。最終的には、手元で実験することで偽共有の影響を実感し、性能改善に役立つ知見を得られることを目的としています。まずは、偽共有がどのような現象かを正しく理解することから始めましょう。

2. 偽共有とは何か

偽共有(False Sharing)は、異なるスレッドやゴルーチンが、論理的には別々の変数を更新しているにもかかわらず、これらの変数が物理的に同一のキャッシュラインに配置されることによって発生します。CPUはメモリの高速アクセスのためにキャッシュを利用しており、その単位がキャッシュラインです。通常、キャッシュラインのサイズは64バイト前後で、同一ラインに属するデータは同時に読み込まれ、更新時にはキャッシュ全体が無効化される場合があります。

具体例として、構造体に2つの整数型の変数が連続して定義されているケースを考えます。もしゴルーチンAが1つ目の変数を頻繁に更新し、ゴルーチンBが2つ目の変数を更新する状況であれば、両者は論理的には独立して動作しているにもかかわらず、物理的には同じキャッシュラインに乗っているため、一方の更新がもう一方のキャッシュを無効化する事態が生じます。

このキャッシュインバリデーションが頻発すると、CPUは常に最新のデータを取得するためにメモリ間でデータの同期を行わなければならず、その結果、プログラム全体の処理速度が大幅に低下する恐れがあります。つまり、偽共有は結果として性能劣化を招くものの、計算結果そのものには影響を及ぼさないため、見た目には正しく動作しているものの、内部で大きなコストが発生しているという点が特徴です。

この現象は、特に並行処理においてスレッドやゴルーチンが独立して動作していると錯覚させるため、デバッグや性能改善の際に見落とされがちです。そこで、実際のコード例やベンチマークを通して、どのような状況で偽共有が発生し、どの程度のパフォーマンス低下を引き起こすかを明確にすることが重要です。次のセクションでは、具体的なGoコードを例に、偽共有がどのように発生するかを詳しく解説していきます。

3. Goコードで偽共有が起きる仕組み

Go言語では、複数のゴルーチンが共有メモリ上の異なる変数を同時に更新する場合でも、物理的なメモリ配置により意図せず同じキャッシュラインに配置されるケースがあります。これが偽共有(False Sharing)の原因となり、各ゴルーチンが更新を行うたびに、キャッシュライン全体の無効化(キャッシュインバリデーション)が発生してしまいます。結果として、各コア間で頻繁なキャッシュの再読み込みが起こり、パフォーマンスが低下します。

例: 構造体内のフィールド更新による偽共有

以下のコードは、偽共有が発生する典型的な例を示しています。構造体 Counter は連続して定義された2つの int64 型フィールド AB を持ちます。これらのフィールドは、通常1つのキャッシュライン(一般的には64バイト)に収まるため、隣接して配置される可能性が高いです。

package main

import (
	"fmt"
	"runtime"
	"sync"
)

type Counter struct {
	A int64
	B int64
}

func main() {
	// 並列実行するコア数を明示的に2に設定
	runtime.GOMAXPROCS(2)

	var wg sync.WaitGroup
	counter  := &Counter{}
	wg.Add(2)
	// ゴルーチンA: フィールドAを更新
	go func() {
		defer wg.Done()
		for i := 0; i < 10000000; i++ {
			counter .A++
		}
	}()
	// ゴルーチンB: フィールドBを更新
	go func() {
		defer wg.Done()
		for i := 0; i < 10000000; i++ {
			counter .B++
		}
	}()

	wg.Wait()
	fmt.Println("Counter:", counter .A, counter .B)
}

このコードで発生する偽共有の仕組み

  1. キャッシュラインの配置:
    多くのCPUは、メモリの高速アクセスのためにキャッシュを使用しており、その基本単位がキャッシュラインです。一般に、キャッシュラインのサイズは64バイト程度です。上記の例では、 Counter 構造体のフィールド AB が連続して配置されるため、両方が同一のキャッシュラインに収まる可能性があります。

  2. 同時更新とキャッシュインバリデーション:
    ゴルーチンAが counter.A を更新している間、ゴルーチンBが counter.B を更新すると、どちらか一方が書き込みを行うたびに、同じキャッシュライン全体が対象となり、他方のキャッシュが無効化されます。CPUは、最新の値を取得するためにメモリ間でキャッシュラインの同期を行う必要があり、これがパフォーマンス低下の原因となります。

  3. 偽共有の本質:
    重要なのは、各ゴルーチンが論理的には独立した変数を操作しているにもかかわらず、物理的に同じキャッシュラインを共有するために発生するオーバーヘッドです。すなわち、偽共有は「実際のデータ共有」ではなく、メモリ配置上の偶然の重なりが原因となるため、デバッグが難しく、性能改善の妨げとなります。

なぜGoコードで偽共有が問題となるのか

  • パフォーマンス低下:
    各ゴルーチンが頻繁にキャッシュラインを更新するたびに、他のコアで動作するゴルーチンは最新データを再取得しなければならず、そのたびにキャッシュ同期のオーバーヘッドが発生します。

  • 並行処理の効果減少:
    並行処理を行う目的は、複数のタスクを効率的に処理することですが、偽共有が原因でキャッシュの無効化が頻繁に発生すると、実際の処理速度は大幅に低下し、並行処理の恩恵が薄れる可能性があります。

まとめ

このように、Goコードで複数のゴルーチンが同一構造体の隣接するフィールドを同時に更新する場合、物理的なメモリ配置が原因で偽共有が発生するリスクがあります。コード自体は正しく動作していても、裏側でキャッシュインバリデーションによるパフォーマンス低下が隠れたコストとして存在するため、パフォーマンス改善の際にはこの現象に注意を払う必要があります。次のセクションでは、偽共有の影響をベンチマークを通して検証し、具体的な回避方法について解説します。

4. ベンチマークで検証する

偽共有による性能低下は、単にコードを見ただけでは分かりにくいため、実際にベンチマークを行い、数値として確認することが重要です。ここでは、偽共有が発生するケースと、パディングを用いて偽共有を回避したケースの2種類のベンチマークを用意し、性能の差異を検証します。

ベンチマーク用コード例

以下のコードは、 testing パッケージを使ってベンチマークを実施する例です。

  • CounterFalseSharing:
    隣接するフィールド AB が同一キャッシュラインに配置され、偽共有が発生する可能性のある実装です。
  • CounterPadded:
    フィールド間にパディングを挿入することで、キャッシュラインの分離を図り、偽共有を回避した実装です。
package main

import (
	"sync"
	"testing"
)

// 偽共有が発生しやすい実装: 連続してフィールドが定義されている
type CounterFalseSharing struct {
	A int64
	B int64
}

// パディングによりキャッシュラインを分離した実装
type CounterPadded struct {
	A int64
	_ [56]byte // 64バイトのキャッシュラインサイズを考慮し、余剰バイトを補填
	B int64
}

// BenchmarkFalseSharingは、偽共有が発生するケースの性能を計測します。
func BenchmarkFalseSharing(b *testing.B) {
	var wg sync.WaitGroup
	counter := &CounterFalseSharing{}
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		wg.Add(2)
		// ゴルーチンA: フィールドAを更新
		go func() {
			defer wg.Done()
			counter.A++
		}()
		// ゴルーチンB: フィールドBを更新
		go func() {
			defer wg.Done()
			counter.B++
		}()
		wg.Wait()
	}
}

// BenchmarkPaddedは、パディングを入れて偽共有を回避したケースの性能を計測します。
func BenchmarkPadded(b *testing.B) {
	var wg sync.WaitGroup
	counter  := &CounterPadded{}
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		wg.Add(2)
		// ゴルーチンA: フィールドAを更新
		go func() {
			defer wg.Done()
			counter .A++
		}()
		// ゴルーチンB: フィールドBを更新
		go func() {
			defer wg.Done()
			counter .B++
		}()
		wg.Wait()
	}
}

コード解説

  1. 構造体の定義:

    • CounterFalseSharing は、2つの int64 型フィールド AB を連続して持ちます。これにより、両フィールドが同一キャッシュラインに配置される可能性が高く、ゴルーチンが同時に更新を行うとキャッシュインバリデーションが発生しやすくなります。
    • CounterPadded は、フィールド AB の間に56バイトのパディングフィールド _ を挿入しています。キャッシュラインサイズが64バイトの場合、 int64 型のサイズ(8バイト)を考慮すると、これにより AB が異なるキャッシュラインに配置されるようになります。
  2. ベンチマークの流れ:

    • 各ベンチマーク関数は、 b.N 回のループ内で2つのゴルーチンを起動し、それぞれが対応するフィールドを1回ずつインクリメントします。
    • 並行処理の終了を待つために sync.WaitGroup を利用し、各ループごとにゴルーチン間の同期を行います。
    • b.ResetTimer() を呼び出すことで、初期化処理の時間を計測対象から除外し、純粋なループ処理の性能のみを評価します。
  3. 偽共有の影響:

    • BenchmarkFalseSharing では、同一キャッシュライン上のフィールドが頻繁に更新されるため、キャッシュラインの無効化が発生し、結果として更新処理にかかるオーバーヘッドが大きくなります。
    • 対して BenchmarkPadded では、パディングによってフィールドが分離されるため、各ゴルーチンが独立してキャッシュを利用でき、性能が向上することが期待されます。

ベンチマークの実行方法

ターミナルで以下のコマンドを実行して、ベンチマークを実施します。

% go test -bench .

結果と考察

% go test -bench .
goos: linux
goarch: amd64
pkg: mainTest
cpu: AMD Ryzen 5 3500 6-Core Processor              
BenchmarkFalseSharing-6          1316686               980.1 ns/op
BenchmarkPadded-6                1292317               936.8 ns/op
PASS
ok      mainTest        4.371s
% go test -bench .
goos: linux
goarch: amd64
pkg: mainTest
cpu: AMD Ryzen 5 3500 6-Core Processor              
BenchmarkFalseSharing-6          1289992               945.1 ns/op
BenchmarkPadded-6                1245096               909.2 ns/op
PASS
ok      mainTest        4.273s
% go test -bench .
goos: linux
goarch: amd64
pkg: mainTest
cpu: AMD Ryzen 5 3500 6-Core Processor              
BenchmarkFalseSharing-6          1282280               937.8 ns/op
BenchmarkPadded-6                1279338               922.9 ns/op
PASS
ok      mainTest        4.288s
  1. パフォーマンス向上の傾向

各実行結果を見ると、偽共有が発生する CounterFalseSharing の場合と、パディングによってキャッシュラインを分離した CounterPadded の場合で、1回あたりの処理時間 (ns/op) において一貫して CounterPadded のほうが低い値を示しています。全体としてパディングによる回避策が効果を発揮していることが確認できます。

  1. 偽共有によるオーバーヘッドの実感
    偽共有が発生すると、同一キャッシュライン上のフィールド更新時にキャッシュインバリデーションが頻繁に発生し、ゴルーチン間で余計な同期が行われます。今回のマイクロベンチマークでは、単純なインクリメント処理であっても、更新毎に発生するこのオーバーヘッドが ns 単位で現れるため、パディングによりその影響が軽減されることが数値に表れています。

  2. 実環境での適用可能性
    本ベンチマークはあくまでマイクロベンチマークであり、2つのフィールドに対してインクリメントを行う単純なケースですが、実際のアプリケーションにおいては、ゴルーチンの数が多かったり、更新頻度が高い場合、偽共有による性能低下はより顕著になる可能性があります。したがって、データ構造の設計時にキャッシュラインの配置を意識し、必要に応じてパディングなどで偽共有を回避する対策は、性能改善の上で非常に有用です。

  3. 結果の信頼性と注意点
    ベンチマーク結果には多少のばらつきが見られますが、全体として CounterPadded のほうが一貫して低い ns/op を記録している点から、偽共有回避の効果は実証できると考えられます。また、マイクロベンチマークはシステム環境や実行タイミングにより若干の変動があるため、実運用環境での影響を評価する際は、より複雑なシナリオでの検証も併せて行うことが望ましいです。

5. まとめ

今回の記事では、Go言語における偽共有(False Sharing)の問題について、以下の点を中心に解説しました。

  • 偽共有の定義と原因:
    論理的には独立した変数が物理的に同一のキャッシュラインに配置されることで、片方の更新時にもう一方のキャッシュが無効化され、不要な同期処理が発生する現象であることを説明しました。

  • 具体的なGoコードによる検証:
    複数のゴルーチンが連続した構造体フィールドを同時に更新する場合に偽共有が発生する仕組みを、シンプルなコード例を通して明示しました。

  • ベンチマーク実験:
    偽共有が発生するケースと、パディングによってキャッシュラインを分離したケースの性能差を数値で検証し、パディング対策が数パーセントの性能改善に寄与することを示しました。

これらの知見は、並行処理を効率的に実装する上で、データ構造の配置や更新頻度の見直しが重要であることを示しています。手元でサンプルコードやベンチマークを実行し、偽共有の影響を体感することで、実際のアプリケーション設計における性能改善のヒントが得られるでしょう。

今後も、より複雑なシナリオでの検証や専用ライブラリの活用事例を通して、さらなるパフォーマンス最適化手法を紹介していく予定です。ぜひ引き続きご注目ください!

Discussion