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

[インデックス 17723] ファイルの概要

このコミットは、Goランタイムのガベージコレクション(GC)におけるスタックスキャン方法の変更に関するものです。具体的には、スタックをフレーム単位で走査する(ScanStackByFrames)機能を一時的に無効化し、Go 1.1 と同じ挙動に戻すことで、GCのパフォーマンスを大幅に改善することを目的としています。この変更は、フレーム単位のスタック走査が期待された精度をもたらさず、かつパフォーマンスコストが非常に高かったため、保守的な選択として行われました。将来的な再評価のために、関連するベンチマークも追加されています。

コミット

commit c3dadca9776b6f1c32ee088698e8584636bba2fb
Author: Russ Cox <rsc@golang.org>
Date:   Wed Oct 2 11:59:53 2013 -0400

    runtime: do not scan stack by frames during garbage collection
    
    Walking the stack by frames is ~3x more expensive
    than not, and since it didn't end up being precise,
    there is not enough benefit to outweigh the cost.
    
    This is the conservative choice: this CL makes the
    stack scanning behavior the same as it was in Go 1.1.
    
    Add benchmarks to package runtime so that we have
    them when we re-enable this feature during the
    Go 1.3 development.
    
    benchmark                     old ns/op    new ns/op    delta
    BenchmarkGoroutineSelect        3194909      1272092  -60.18%
    BenchmarkGoroutineBlocking      3120282       866366  -72.23%
    BenchmarkGoroutineForRange      3256179       939902  -71.13%
    BenchmarkGoroutineIdle          2005571       482982  -75.92%
    
    The Go 1 benchmarks, just to add more data.
    As far as I can tell the changes are mainly noise.
    
    benchmark                         old ns/op    new ns/op    delta
    BenchmarkBinaryTree17            4409403046   4414734932   +0.12%
    BenchmarkFannkuch11              3407708965   3378306120   -0.86%
    BenchmarkFmtFprintfEmpty                100           99   -0.60%
    BenchmarkFmtFprintfString               242          239   -1.24%
    BenchmarkFmtFprintfInt                  204          206   +0.98%
    BenchmarkFmtFprintfIntInt               320          316   -1.25%
    BenchmarkFmtFprintfPrefixedInt          295          299   +1.36%
    BenchmarkFmtFprintfFloat                442          435   -1.58%
    BenchmarkFmtManyArgs                   1246         1216   -2.41%
    BenchmarkGobDecode                 10186951     10051210   -1.33%
    BenchmarkGobEncode                 16504381     16445650   -0.36%
    BenchmarkGzip                     447030885    447056865   +0.01%
    BenchmarkGunzip                   111056154    111696305   +0.58%
    BenchmarkHTTPClientServer             89973        93040   +3.41%
    BenchmarkJSONEncode                28174182     27933893   -0.85%
    BenchmarkJSONDecode               106353777    110443817   +3.85%
    BenchmarkMandelbrot200              4822289      4806083   -0.34%
    BenchmarkGoParse                    6102436      6142734   +0.66%
    BenchmarkRegexpMatchEasy0_32            133          132   -0.75%
    BenchmarkRegexpMatchEasy0_1K            372          373   +0.27%
    BenchmarkRegexpMatchEasy1_32            113          111   -1.77%
    BenchmarkRegexpMatchEasy1_1K            964          940   -2.49%
    BenchmarkRegexpMatchMedium_32           202          205   +1.49%
    BenchmarkRegexpMatchMedium_1K         68862        68858   -0.01%
    BenchmarkRegexpMatchHard_32            3480         3407   -2.10%
    BenchmarkRegexpMatchHard_1K          108255       112614   +4.03%
    BenchmarkRevcomp                  751393035    743929976   -0.99%
    BenchmarkTemplate                 139637041    135402220   -3.03%
    BenchmarkTimeParse                      479          475   -0.84%
    BenchmarkTimeFormat                     460          466   +1.30%
    
    benchmark                          old MB/s     new MB/s  speedup
    BenchmarkGobDecode                    75.34        76.36    1.01x
    BenchmarkGobEncode                    46.50        46.67    1.00x
    BenchmarkGzip                         43.41        43.41    1.00x
    BenchmarkGunzip                      174.73       173.73    0.99x
    BenchmarkJSONEncode                   68.87        69.47    1.01x
    BenchmarkJSONDecode                   18.25        17.57    0.96x
    BenchmarkGoParse                       9.49         9.43    0.99x
    BenchmarkRegexpMatchEasy0_32         239.58       241.74    1.01x
    BenchmarkRegexpMatchEasy0_1K        2749.74      2738.00    1.00x
    BenchmarkRegexpMatchEasy1_32         282.49       286.32    1.01x
    BenchmarkRegexpMatchEasy1_1K        1062.00      1088.96    1.03x
    BenchmarkRegexpMatchMedium_32          4.93         4.86    0.99x
    BenchmarkRegexpMatchMedium_1K         14.87        14.87    1.00x
    BenchmarkRegexpMatchHard_32            9.19         9.39    1.02x
    BenchmarkRegexpMatchHard_1K            9.46         9.09    0.96x
    BenchmarkRevcomp                     338.26       341.65    1.01x
    BenchmarkTemplate                     13.90        14.33    1.03x
    
    Fixes #6482.
    
    R=golang-dev, dave, r
    CC=golang-dev
    https://golang.org/cl/14257043

GitHub上でのコミットページへのリンク

https://github.com/golang/go/commit/c3dadca9776b6f1c32ee088698e8584636bba2fb

元コミット内容

上記の「コミット」セクションに記載されている内容と同一です。

変更の背景

このコミットの背景には、Goランタイムのガベージコレクション(GC)におけるスタックスキャン戦略の進化と、それに伴うパフォーマンス上の課題がありました。

Go 1.1では、GCはスタックを保守的にスキャンしていました。これは、スタック上の値がポインタであるか否かを厳密に区別せず、ポインタのように見える値はすべてポインタとして扱う方式です。この方法は安全ですが、実際にはポインタではない値をポインタと誤認することで、到達可能なオブジェクトを過剰に保持し、メモリリークを引き起こす可能性があります(ただし、GoのGCは非常に効率的であるため、実用上大きな問題となることは稀です)。

Go 1.2の開発過程で、より「正確な」スタックスキャンを目指す試みが行われました。これは、スタックフレームの情報を利用して、どのスタック上の値が実際にポインタであるかを正確に識別しようとするものでした。このアプローチの目的は、GCの精度を高め、より多くの不要なメモリを解放できるようにすることでした。

しかし、このコミットのメッセージが示すように、スタックをフレーム単位で走査するアプローチは、以下の2つの主要な問題に直面しました。

  1. 高いパフォーマンスコスト: フレーム単位でのスタック走査は、従来の保守的なスキャンと比較して約3倍のコストがかかることが判明しました。これは、GCの実行時間を大幅に増加させ、アプリケーション全体のパフォーマンスに悪影響を及ぼすことを意味します。
  2. 期待された精度の欠如: 最も重要な問題は、このフレーム単位の走査が「正確ではなかった」という点です。つまり、開発者が期待したほどポインタの識別精度が向上せず、保守的なスキャンと比較してGCの効率が劇的に改善されるわけではなかったのです。Goのスタックは動的にサイズが変更され、フレームのレイアウトも複雑であるため、実行時に正確なポインタ情報を常に把握することは非常に困難でした。

これらの問題が明らかになったため、Goチームは、パフォーマンスの低下に見合うだけのメリットがないと判断しました。そのため、このコミットでは、Go 1.1のスタックスキャン挙動に戻すという「保守的な選択」がなされました。これは、安定性とパフォーマンスを優先し、将来的にこの機能を再検討するための準備として、関連するベンチマークを追加するという方針を示しています。

この変更は、GoのGCが常にパフォーマンスと正確性のバランスを追求していることを示しており、理論的な改善が必ずしも実用的なメリットに繋がるとは限らないという現実的な判断に基づいています。

前提知識の解説

このコミットを理解するためには、以下のGoランタイムとガベージコレクションに関する基本的な概念を理解しておく必要があります。

Goランタイム (Go Runtime)

Goランタイムは、Goプログラムの実行を管理するシステムです。これには、スケジューラ(Goroutineの管理)、メモリ管理(ヒープアロケーションとガベージコレクション)、チャネル操作、システムコールインターフェースなどが含まれます。Goプログラムは、オペレーティングシステム上で直接実行されるのではなく、Goランタイム上で実行されます。

ガベージコレクション (Garbage Collection, GC)

ガベージコレクションは、プログラムが動的に確保したメモリのうち、もはや使用されていない(到達不可能になった)メモリ領域を自動的に解放するプロセスです。これにより、開発者は手動でのメモリ管理から解放され、メモリリークやダングリングポインタといった一般的なメモリ関連のバグを防ぐことができます。

GoのGCは、並行(Concurrent)、**三色マーク&スイープ(Tri-color Mark-and-Sweep)**方式を採用しています。

  • 並行: GCの大部分のフェーズが、通常のGoプログラムの実行と並行して行われます。これにより、GCによるアプリケーションの一時停止(ストップ・ザ・ワールド、STW)時間を最小限に抑え、低レイテンシを実現します。
  • 三色マーク&スイープ:
    • マークフェーズ: GCは、プログラムのルート(グローバル変数、スタック上の変数など)から到達可能なすべてのオブジェクトを「生きている(live)」とマークします。このプロセスでは、オブジェクトを白、灰色、黒のいずれかの色に分類します。
      • : まだGCによって訪問されていないオブジェクト。最終的に白のままのオブジェクトは、到達不可能と見なされ、回収の対象となります。
      • 灰色: GCによって訪問されたが、その参照先がまだ訪問されていないオブジェクト。
      • : GCによって訪問され、その参照先もすべて訪問されたオブジェクト。
    • スイープフェーズ: マークフェーズが完了した後、GCはヒープ全体を走査し、白のままのオブジェクト(到達不可能なオブジェクト)を解放して、そのメモリを再利用可能にします。

スタックスキャン (Stack Scanning)

GCのマークフェーズにおいて、GCはプログラムのルートから到達可能なオブジェクトを特定する必要があります。このルートの一つが、現在実行中のGoroutineのスタックです。スタックスキャンとは、GCが各Goroutineのスタックを走査し、スタック上に存在するポインタ(ヒープ上のオブジェクトを指すアドレス)を識別するプロセスです。これらのポインタが指すオブジェクトは「生きている」とマークされ、さらにそのオブジェクトが指す他のオブジェクトも再帰的にマークされます。

正確なGC (Precise GC) と保守的なGC (Conservative GC)

スタックスキャンにおいて、GCがスタック上の値をどのように解釈するかによって、GCの「正確性」が異なります。

  • 正確なGC (Precise GC): GCがスタック上のどの値がポインタであり、どの値がポインタではないかを正確に識別できる場合を指します。例えば、コンパイラが生成するメタデータや、ランタイムが管理する型情報に基づいて、スタック上の特定の位置にある値がポインタ型であると確実に判断できる場合です。正確なGCは、不要なメモリを最大限に解放できるため、メモリ効率が最も高くなります。
  • 保守的なGC (Conservative GC): GCがスタック上の値がポインタであるか否かを確実に判断できない場合、その値が有効なヒープアドレスを指しているように見える場合、それをポインタとして扱う方式です。つまり、「ポインタかもしれない」という疑いがあるものはすべてポインタとして扱います。この方法は、誤って生きているオブジェクトを回収してしまうリスクを回避できるため安全ですが、実際にはポインタではない値をポインタと誤認することで、不要なメモリを解放できない(メモリリークを引き起こす)可能性があります。GoのGCは、ヒープ上のオブジェクトに対しては正確なGCを行いますが、スタックに対しては歴史的に保守的な要素を含んでいました。

このコミットで言及されている「スタックをフレーム単位で走査する」試みは、スタックに対するより正確なGCを実現しようとするものでした。

Goroutineとスタックフレーム

  • Goroutine: Goにおける軽量な並行実行単位です。OSのスレッドよりもはるかに軽量で、数百万のGoroutineを同時に実行することも可能です。各Goroutineは独自のスタックを持っています。
  • スタックフレーム (Stack Frame): 関数が呼び出されるたびに、その関数のローカル変数、引数、戻りアドレスなどを格納するためにスタック上に割り当てられるメモリ領域です。関数が終了すると、そのスタックフレームはスタックからポップされます。スタックは、これらのフレームが積み重なって構成されています。GCがスタックを走査する際、これらのスタックフレームの構造を理解することが、正確なポインタ識別には不可欠となります。

技術的詳細

このコミットの技術的詳細を掘り下げると、GoランタイムのGCにおけるスタックスキャン戦略の複雑さと、パフォーマンスと正確性のトレードオフが浮き彫りになります。

ScanStackByFrames フラグの役割

src/pkg/runtime/mgc0.c ファイル内の ScanStackByFrames は、GoランタイムのGCがスタックをどのようにスキャンするかを制御する内部フラグです。

  • ScanStackByFrames = 1 (変更前): この設定は、GCがGoroutineのスタックを個々のスタックフレームの境界を認識しながら走査しようとすることを意味します。理論的には、これによりGCは各フレーム内の変数の型情報(コンパイラによって生成される)を利用して、どのスタック上の値が実際にヒープ上のオブジェクトへのポインタであるかをより正確に識別できるはずでした。これにより、保守的なスキャンで発生する可能性のある「偽陽性」(ポインタではない値をポインタと誤認すること)を減らし、より多くのメモリを解放できると期待されていました。
  • ScanStackByFrames = 0 (変更後): この設定は、GCがスタックをフレーム構造を意識せずに、より単純な方法で走査することを意味します。具体的には、スタック全体を単なるバイトの塊として扱い、その中に有効なヒープアドレスに似たパターンがないかを検索します。これは、Go 1.1で採用されていた保守的なスキャンに近い挙動です。この方法は、ポインタの識別精度は劣る可能性がありますが、走査のオーバーヘッドがはるかに低いという利点があります。

フレーム単位走査のパフォーマンスオーバーヘッド

コミットメッセージにある「Walking the stack by frames is ~3x more expensive than not」という記述は、フレーム単位のスタック走査がなぜこれほど高コストであったかを示唆しています。

  1. 複雑なスタックレイアウト: Goのスタックは、関数の呼び出し規約、インライン化、エスケープ解析の結果などによって、非常に動的で複雑なレイアウトを持つことがあります。特に、可変長引数やクロージャの使用は、スタックフレームの構造をさらに複雑にします。
  2. メタデータの取得と解析: 各スタックフレームの正確なポインタ情報を得るためには、コンパイラが生成した型メタデータ(スタックマップなど)をランタイムが参照し、解析する必要があります。このメタデータの読み込み、解析、そしてそれに基づいてスタック上の値を検査するプロセスは、単純なバイト列スキャンに比べて計算コストが高くなります。
  3. ポインタの動的な性質: Goのポインタは、ガベージコレクタが移動する可能性があるため、ポインタの値を追跡し、必要に応じて更新するメカニズムが必要です。フレーム単位でこれを正確に行うことは、より多くの同期やチェックを必要とし、オーバーヘッドを増加させます。

これらの要因が複合的に作用し、フレーム単位のスタック走査は、期待された精度向上に見合うだけのパフォーマンスメリットをもたらさなかったのです。

「正確ではなかった」という記述の深掘り

「since it didn't end up being precise」という記述は、フレーム単位の走査が期待された「正確性」を達成できなかったことを示しています。これは、以下の理由が考えられます。

  • 部分的な情報: コンパイラが生成するスタックマップは、必ずしもスタック上のすべてのポインタを完全に網羅しているわけではない可能性があります。特に、レジスタに一時的に格納されたポインタや、最適化によってスタック上のポインタが一時的に非ポインタ値として扱われるケースなど、ランタイムが完全に追跡できないエッジケースが存在したかもしれません。
  • 動的なスタック成長: Goのスタックは、必要に応じて動的に拡大・縮小します。この動的な性質が、フレーム境界の正確な特定や、GC実行中のスタック変更への対応を複雑にした可能性があります。
  • ポインタの曖昧さ: Goは、Cのような言語とは異なり、ポインタと非ポインタの区別を厳密に行いますが、それでもスタック上の特定のバイト列がポインタであるか否かを実行時に完全に区別することは困難な場合があります。例えば、整数値が偶然にも有効なヒープアドレスと一致するようなケースです。フレーム情報を使っても、このような曖昧さを完全に解消することは難しかったのかもしれません。

結果として、フレーム単位の走査は、その高いコストに見合うだけのGC効率の改善をもたらさなかったため、一時的に無効化されることになりました。

Go 1.1のスタックスキャン挙動

Go 1.1では、スタックは基本的に保守的にスキャンされていました。これは、スタック上のすべてのワード(通常は8バイト)を検査し、それが有効なヒープアドレスを指しているように見える場合、それをポインタとして扱うというアプローチです。この方法は、実装が比較的単純で高速ですが、前述の通り「偽陽性」の可能性をはらんでいます。このコミットは、この実績のある、より高速な挙動に戻すことを選択しました。

ベンチマークの追加の意図

コミットメッセージには、「Add benchmarks to package runtime so that we have them when we re-enable this feature during the Go 1.3 development」とあります。これは、この機能が将来的にGo 1.3の開発中に再検討される可能性があることを示唆しています。

追加されたベンチマーク(BenchmarkGoroutineSelectBenchmarkGoroutineBlockingBenchmarkGoroutineForRangeBenchmarkGoroutineIdle)は、特に多数のGoroutineが関与するシナリオでのGCのパフォーマンスを測定するために設計されています。これらのベンチマークは、スタックスキャン戦略の変更がGoroutineのライフサイクルや相互作用に与える影響を定量的に評価するための重要なツールとなります。将来的にフレーム単位のスタックスキャンを再導入する際には、これらのベンチマークを用いて、そのパフォーマンス上のメリットがコストを上回るかどうかを慎重に評価できるようになります。

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

このコミットによる主要なコード変更は以下の2つのファイルに集中しています。

  1. src/pkg/runtime/mgc0.c: ガベージコレクションのコアロジックが含まれるC言語のファイルです。ここでスタックスキャン挙動を制御するフラグが変更されています。
  2. src/pkg/runtime/malloc_test.go: ランタイムのメモリ割り当てとGCのパフォーマンスをテストするためのGo言語のファイルです。新しいベンチマークが追加されています。

src/pkg/runtime/mgc0.c の変更

--- a/src/pkg/runtime/mgc0.c
+++ b/src/pkg/runtime/mgc0.c
@@ -19,7 +19,7 @@ enum {
 	Debug = 0,
 	DebugMark = 0,  // run second pass to check mark
 	CollectStats = 0,
-	ScanStackByFrames = 1,
+	ScanStackByFrames = 0,
 	IgnorePreciseGC = 0,
 
 	// Four bits per word (see #defines below).\
@@ -1474,6 +1474,7 @@ addstackroots(G *gp)\
 	\t\tUSED(guard);\
 	\t\truntime·gentraceback(pc, sp, lr, gp, 0, nil, 0x7fffffff, addframeroots, nil, false);\
 	\t} else {\
+\t\tUSED(lr);\
 	\t\tUSED(pc);\
 	\t\tn = 0;\
 	\t\twhile(stk) {\
  • ScanStackByFrames の値を 1 から 0 に変更しています。これが、スタックをフレーム単位で走査する機能を無効化する主要な変更です。
  • addstackroots 関数内で、ScanStackByFrames0 の場合のコードパスに USED(lr);USED(pc); が追加されています。これは、これらの変数が使用されていないというコンパイラの警告を抑制するためのものです。

src/pkg/runtime/malloc_test.go の変更

--- a/src/pkg/runtime/malloc_test.go
+++ b/src/pkg/runtime/malloc_test.go
@@ -5,8 +5,10 @@
 package runtime_test
 
 import (
+\t"flag"\
 	. "runtime"\
 	"testing"\
+\t"time"\
 	"unsafe"\
 )
 
@@ -65,3 +67,90 @@ func BenchmarkMallocTypeInfo16(b *testing.B) {\
 	}\
 	mallocSink = x\
 }\
+\n+var n = flag.Int(\"n\", 1000, \"number of goroutines\")\
+\n+func BenchmarkGoroutineSelect(b *testing.B) {\
+\tquit := make(chan struct{})\
+\tread := func(ch chan struct{}) {\
+\t\tfor {\
+\t\t\tselect {\n+\t\t\tcase _, ok := <-ch:\
+\t\t\t\tif !ok {\
+\t\t\t\t\treturn\
+\t\t\t\t}\
+\t\t\tcase <-quit:\
+\t\t\t\treturn\
+\t\t\t}\
+\t\t}\
+\t}\
+\tbenchHelper(b, *n, read)\
+}\
+\n+func BenchmarkGoroutineBlocking(b *testing.B) {\
+\tread := func(ch chan struct{}) {\
+\t\tfor {\
+\t\t\tif _, ok := <-ch; !ok {\
+\t\t\t\treturn\
+\t\t\t}\
+\t\t}\
+\t}\
+\tbenchHelper(b, *n, read)\
+}\
+\n+func BenchmarkGoroutineForRange(b *testing.B) {\
+\tread := func(ch chan struct{}) {\
+\t\tfor _ = range ch {\
+\t\t}\
+\t}\
+\tbenchHelper(b, *n, read)\
+}\
+\n+func benchHelper(b *testing.B, n int, read func(ch chan struct{})) {\
+\tm := make([]chan struct{}, n)\
+\tfor i := range m {\
+\t\tm[i] = make(chan struct{}, 1)\
+\t\tgo read(m[i])\
+\t}\
+\tb.StopTimer()\
+\tb.ResetTimer()\
+\tGC()\
+\n+\tfor i := 0; i < b.N; i++ {\
+\t\tfor _, ch := range m {\
+\t\t\tif ch != nil {\
+\t\t\t\tch <- struct{}{}\
+\t\t\t}\
+\t\t}\
+\t\ttime.Sleep(10 * time.Millisecond)\
+\t\tb.StartTimer()\
+\t\tGC()\
+\t\tb.StopTimer()\
+\t}\
+\n+\tfor _, ch := range m {\
+\t\tclose(ch)\
+\t}\
+\ttime.Sleep(10 * time.Millisecond)\
+}\
+\n+func BenchmarkGoroutineIdle(b *testing.B) {\
+\tquit := make(chan struct{})\
+\tfn := func() {\
+\t\t<-quit\
+\t}\
+\tfor i := 0; i < *n; i++ {\
+\t\tgo fn()\
+\t}\
+\n+\tGC()\
+\tb.ResetTimer()\
+\n+\tfor i := 0; i < b.N; i++ {\
+\t\tGC()\
+\t}\
+\n+\tb.StopTimer()\
+\tclose(quit)\
+\ttime.Sleep(10 * time.Millisecond)\
+}\
  • flagtime パッケージがインポートされています。
  • n というコマンドラインフラグが追加され、ベンチマークで作成するGoroutineの数を制御できるようになっています。
  • 以下の新しいベンチマーク関数が追加されています。
    • BenchmarkGoroutineSelect: select ステートメントを使用するGoroutineのパフォーマンスを測定します。
    • BenchmarkGoroutineBlocking: ブロッキングチャネル操作を使用するGoroutineのパフォーマンスを測定します。
    • BenchmarkGoroutineForRange: for range ループでチャネルを読み取るGoroutineのパフォーマンスを測定します。
    • benchHelper: 上記3つのGoroutineベンチマークで共通して使用されるヘルパー関数です。多数のGoroutineを作成し、チャネルを介して通信させ、GCを強制的に実行しながらベンチマークを行います。
    • BenchmarkGoroutineIdle: アイドル状態のGoroutineが多数存在する場合のGCパフォーマンスを測定します。

コアとなるコードの解説

ScanStackByFrames = 0 の意味

src/pkg/runtime/mgc0.c における ScanStackByFrames = 0 の変更は、Goランタイムのガベージコレクタがスタックをスキャンする際の挙動を根本的に変更します。

  • 変更前 (ScanStackByFrames = 1): この設定では、GCはスタックを個々のスタックフレームの境界を認識しながら走査しようとしました。これは、各関数呼び出しによって作成されるスタックフレームの構造(ローカル変数、引数、戻りアドレスなど)を解析し、フレーム内のどの位置にポインタが存在するかを特定しようとするアプローチです。この目的は、より「正確な」GCを実現し、スタック上のポインタではない値を誤ってポインタと解釈する「偽陽性」を減らすことでした。しかし、Goの動的なスタックレイアウトや最適化の複雑さにより、このフレーム単位の解析は非常に高コストであり、期待された精度向上も得られませんでした。
  • 変更後 (ScanStackByFrames = 0): この設定により、GCはスタックをフレーム構造を意識せずに、より単純な方法で走査します。具体的には、スタック全体を単なる連続したメモリ領域として扱い、その中をワード単位で走査し、有効なヒープアドレスを指しているように見えるパターンがないかを検索します。これは、Go 1.1で採用されていた保守的なスキャンに近い挙動です。この方法は、ポインタの識別精度は劣る可能性がありますが、走査のオーバーヘッドがはるかに低く、GCの実行時間を大幅に短縮できます。コミットメッセージのベンチマーク結果が示すように、特にGoroutine関連のシナリオで劇的なパフォーマンス改善が見られます。

addstackroots 関数における USED(lr); USED(pc); の追加

addstackroots 関数は、GCのマークフェーズ中にGoroutineのスタックを走査し、ルートとなるポインタを識別するために使用されます。

	} else {
		USED(lr);
		USED(pc);
		n = 0;
		while(stk) {

このコードブロックは、ScanStackByFrames0 の場合のパスに相当します。USED(lr);USED(pc); は、それぞれリンクレジスタ(lr)とプログラムカウンタ(pc)がこのコードパスでは直接使用されていないことをコンパイラに伝えるためのマクロです。これにより、未使用変数に関するコンパイラの警告を抑制します。これは機能的な変更ではなく、コンパイル時の警告を回避するためのものです。

重要なのは、この else ブロックに入った場合、runtime·gentraceback 関数(スタックフレームをトレースしてポインタを識別する関数)が呼び出されないことです。代わりに、より単純なスタック走査ロジックが実行され、スタックをフレーム単位で解析するのではなく、メモリ領域として直接スキャンします。

追加されたベンチマークコードの解説

src/pkg/runtime/malloc_test.go に追加されたベンチマークは、多数のGoroutineが存在する状況でのGCのパフォーマンスを評価するために設計されています。

  • BenchmarkGoroutineSelectBenchmarkGoroutineBlockingBenchmarkGoroutineForRange: これらのベンチマークは、それぞれ異なるチャネル操作パターン(select、ブロッキング読み取り、for range)を持つ多数のGoroutineを作成します。

    • benchHelper 関数がこれらのベンチマークの共通ロジックを提供します。
    • m := make([]chan struct{}, n): n 個のチャネルの配列を作成します。n はコマンドラインフラグで指定できます(デフォルト1000)。
    • go read(m[i]): 各チャネルに対してGoroutineを起動し、read 関数(各ベンチマーク固有のチャネル読み取りロジック)を実行させます。
    • b.StopTimer(), b.ResetTimer(), GC(), b.StartTimer(): ベンチマークの測定範囲を正確に制御し、GCを強制的に実行して、GCがパフォーマンスに与える影響を測定します。特に、GC() の呼び出しは、スタックスキャンを含むGCサイクルをトリガーします。
    • ch <- struct{}{}: 各Goroutineにシグナルを送信し、チャネル操作をトリガーします。
    • time.Sleep(10 * time.Millisecond): Goroutineがチャネル操作を完了し、GCが実行されるための時間を与えます。 これらのベンチマークは、多数のGoroutineがアクティブに動作し、スタックが頻繁に変化するようなシナリオで、GCのスタックスキャン効率がどのように影響するかを測定します。コミットメッセージのベンチマーク結果が示すように、これらのテストケースで大幅なパフォーマンス改善が見られました。これは、フレーム単位のスタックスキャンがGoroutineの多いアプリケーションで特に大きなオーバーヘッドを引き起こしていたことを裏付けています。
  • BenchmarkGoroutineIdle: このベンチマークは、多数のアイドル状態のGoroutineが存在する場合のGCパフォーマンスを測定します。

    • for i := 0; i < *n; i++ { go fn() }: n 個のGoroutineを起動しますが、これらのGoroutineは <-quit でブロックされ、ほとんど何も処理しません。
    • GC(): アイドル状態のGoroutineが多数存在する状況でGCを強制的に実行します。 このベンチマークは、アクティブでないGoroutineのスタックをスキャンする際のGCの効率を評価します。ここでも大幅なパフォーマンス改善が見られたことは、たとえGoroutineがアイドル状態であっても、フレーム単位のスタックスキャンが不必要なオーバーヘッドを発生させていたことを示唆しています。

これらのベンチマークは、Go 1.3の開発中にフレーム単位のスタックスキャン機能を再評価する際の重要な基準となります。

関連リンク

参考にした情報源リンク

  • Goのガベージコレクションに関する公式ドキュメントやブログ記事 (GoのバージョンによってGCの実装は進化しているため、当時の情報に焦点を当てる必要がありますが、一般的な概念は共通です)
  • Goのスタック管理に関する技術記事
  • Goのベンチマークに関する公式ドキュメント
  • Goのソースコード(特に src/runtime ディレクトリ)

[インデックス 17723] ファイルの概要

このコミットは、Goランタイムのガベージコレクション(GC)におけるスタックスキャン方法の変更に関するものです。具体的には、スタックをフレーム単位で走査する(ScanStackByFrames)機能を一時的に無効化し、Go 1.1 と同じ挙動に戻すことで、GCのパフォーマンスを大幅に改善することを目的としています。この変更は、フレーム単位のスタック走査が期待された精度をもたらさず、かつパフォーマンスコストが非常に高かったため、保守的な選択として行われました。将来的な再評価のために、関連するベンチマークも追加されています。

コミット

commit c3dadca9776b6f1c32ee088698e8584636bba2fb
Author: Russ Cox <rsc@golang.org>
Date:   Wed Oct 2 11:59:53 2013 -0400

    runtime: do not scan stack by frames during garbage collection
    
    Walking the stack by frames is ~3x more expensive
    than not, and since it didn't end up being precise,
    there is not enough benefit to outweigh the cost.
    
    This is the conservative choice: this CL makes the
    stack scanning behavior the same as it was in Go 1.1.
    
    Add benchmarks to package runtime so that we have
    them when we re-enable this feature during the
    Go 1.3 development.
    
    benchmark                     old ns/op    new ns/op    delta
    BenchmarkGoroutineSelect        3194909      1272092  -60.18%
    BenchmarkGoroutineBlocking      3120282       866366  -72.23%
    BenchmarkGoroutineForRange      3256179       939902  -71.13%
    BenchmarkGoroutineIdle          2005571       482982  -75.92%
    
    The Go 1 benchmarks, just to add more data.
    As far as I can tell the changes are mainly noise.
    
    benchmark                         old ns/op    new ns/op    delta
    BenchmarkBinaryTree17            4409403046   4414734932   +0.12%
    BenchmarkFannkuch11              3407708965   3378306120   -0.86%
    BenchmarkFmtFprintfEmpty                100           99   -0.60%
    BenchmarkFmtFprintfString               242          239   -1.24%
    BenchmarkFmtFprintfInt                  204          206   +0.98%
    BenchmarkFmtFprintfIntInt               320          316   -1.25%
    BenchmarkFmtFprintfPrefixedInt          295          299   +1.36%
    BenchmarkFmtFprintfFloat                442          435   -1.58%
    BenchmarkFmtManyArgs                   1246         1216   -2.41%
    BenchmarkGobDecode                 10186951     10051210   -1.33%
    BenchmarkGobEncode                 16504381     16445650   -0.36%
    BenchmarkGzip                     447030885    447056865   +0.01%
    BenchmarkGunzip                   111056154    111696305   +0.58%
    BenchmarkHTTPClientServer             89973        93040   +3.41%
    BenchmarkJSONEncode                28174182     27933893   -0.85%
    BenchmarkJSONDecode               106353777    110443817   +3.85%
    BenchmarkMandelbrot200              4822289      4806083   -0.34%
    BenchmarkGoParse                    6102436      6142734   +0.66%
    BenchmarkRegexpMatchEasy0_32            133          132   -0.75%
    BenchmarkRegexpMatchEasy0_1K            372          373   +0.27%
    BenchmarkRegexpMatchEasy1_32            113          111   -1.77%
    BenchmarkRegexpMatchEasy1_1K            964          940   -2.49%
    BenchmarkRegexpMatchMedium_32           202          205   +1.49%
    BenchmarkRegexpMatchMedium_1K         68862        68858   -0.01%
    BenchmarkRegexpMatchHard_32            3480         3407   -2.10%
    BenchmarkRegexpMatchHard_1K          108255       112614   +4.03%
    BenchmarkRevcomp                  751393035    743929976   -0.99%
    BenchmarkTemplate                 139637041    135402220   -3.03%
    BenchmarkTimeParse                      479          475   -0.84%
    BenchmarkTimeFormat                     460          466   +1.30%
    
    benchmark                          old MB/s     new MB/s  speedup
    BenchmarkGobDecode                    75.34        76.36    1.01x
    BenchmarkGobEncode                    46.50        46.67    1.00x
    BenchmarkGzip                         43.41        43.41    1.00x
    BenchmarkGunzip                      174.73       173.73    0.99x
    BenchmarkJSONEncode                   68.87        69.47    1.01x
    BenchmarkJSONDecode                   18.25        17.57    0.96x
    BenchmarkGoParse                       9.49         9.43    0.99x
    BenchmarkRegexpMatchEasy0_32         239.58       241.74    1.01x
    BenchmarkRegexpMatchEasy0_1K        2749.74      2738.00    1.00x
    BenchmarkRegexpMatchEasy1_32         282.49       286.32    1.01x
    BenchmarkRegexpMatchEasy1_1K        1062.00      1088.96    1.03x
    BenchmarkRegexpMatchMedium_32          4.93         4.86    0.99x
    BenchmarkRegexpMatchMedium_1K         14.87        14.87    1.00x
    BenchmarkRegexpMatchHard_32            9.19         9.39    1.02x
    BenchmarkRegexpMatchHard_1K            9.46         9.09    0.96x
    BenchmarkRevcomp                     338.26       341.65    1.01x
    BenchmarkTemplate                     13.90        14.33    1.03x
    
    Fixes #6482.
    
    R=golang-dev, dave, r
    CC=golang-dev
    https://golang.org/cl/14257043

GitHub上でのコミットページへのリンク

https://github.com/golang/go/commit/c3dadca9776b6f1c32ee088698e8584636bba2fb

元コミット内容

上記の「コミット」セクションに記載されている内容と同一です。

変更の背景

このコミットの背景には、Goランタイムのガベージコレクション(GC)におけるスタックスキャン戦略の進化と、それに伴うパフォーマンス上の課題がありました。

Go 1.1では、GCはスタックを保守的にスキャンしていました。これは、スタック上の値がポインタであるか否かを厳密に区別せず、ポインタのように見える値はすべてポインタとして扱う方式です。この方法は安全ですが、実際にはポインタではない値をポインタと誤認することで、到達可能なオブジェクトを過剰に保持し、メモリリークを引き起こす可能性があります(ただし、GoのGCは非常に効率的であるため、実用上大きな問題となることは稀です)。

Go 1.2の開発過程で、より「正確な」スタックスキャンを目指す試みが行われました。これは、スタックフレームの情報を利用して、どのスタック上の値が実際にポインタであるかを正確に識別しようとするものでした。このアプローチの目的は、GCの精度を高め、より多くの不要なメモリを解放できるようにすることでした。

しかし、このコミットのメッセージが示すように、スタックをフレーム単位で走査するアプローチは、以下の2つの主要な問題に直面しました。

  1. 高いパフォーマンスコスト: フレーム単位でのスタック走査は、従来の保守的なスキャンと比較して約3倍のコストがかかることが判明しました。これは、GCの実行時間を大幅に増加させ、アプリケーション全体のパフォーマンスに悪影響を及ぼすことを意味します。
  2. 期待された精度の欠如: 最も重要な問題は、このフレーム単位の走査が「正確ではなかった」という点です。つまり、開発者が期待したほどポインタの識別精度が向上せず、保守的なスキャンと比較してGCの効率が劇的に改善されるわけではなかったのです。Goのスタックは動的にサイズが変更され、フレームのレイアウトも複雑であるため、実行時に正確なポインタ情報を常に把握することは非常に困難でした。

これらの問題が明らかになったため、Goチームは、パフォーマンスの低下に見合うだけのメリットがないと判断しました。そのため、このコミットでは、Go 1.1のスタックスキャン挙動に戻すという「保守的な選択」がなされました。これは、安定性とパフォーマンスを優先し、将来的にこの機能を再検討するための準備として、関連するベンチマークを追加するという方針を示しています。

この変更は、GoのGCが常にパフォーマンスと正確性のバランスを追求していることを示しており、理論的な改善が必ずしも実用的なメリットに繋がるとは限らないという現実的な判断に基づいています。

前提知識の解説

このコミットを理解するためには、以下のGoランタイムとガベージコレクションに関する基本的な概念を理解しておく必要があります。

Goランタイム (Go Runtime)

Goランタイムは、Goプログラムの実行を管理するシステムです。これには、スケジューラ(Goroutineの管理)、メモリ管理(ヒープアロケーションとガベージコレクション)、チャネル操作、システムコールインターフェースなどが含まれます。Goプログラムは、オペレーティングシステム上で直接実行されるのではなく、Goランタイム上で実行されます。

ガベージコレクション (Garbage Collection, GC)

ガベージコレクションは、プログラムが動的に確保したメモリのうち、もはや使用されていない(到達不可能になった)メモリ領域を自動的に解放するプロセスです。これにより、開発者は手動でのメモリ管理から解放され、メモリリークやダングリングポインタといった一般的なメモリ関連のバグを防ぐことができます。

GoのGCは、並行(Concurrent)、**三色マーク&スイープ(Tri-color Mark-and-Sweep)**方式を採用しています。

  • 並行: GCの大部分のフェーズが、通常のGoプログラムの実行と並行して行われます。これにより、GCによるアプリケーションの一時停止(ストップ・ザ・ワールド、STW)時間を最小限に抑え、低レイテンシを実現します。
  • 三色マーク&スイープ:
    • マークフェーズ: GCは、プログラムのルート(グローバル変数、スタック上の変数など)から到達可能なすべてのオブジェクトを「生きている(live)」とマークします。このプロセスでは、オブジェクトを白、灰色、黒のいずれかの色に分類します。
      • : まだGCによって訪問されていないオブジェクト。最終的に白のままのオブジェクトは、到達不可能と見なされ、回収の対象となります。
      • 灰色: GCによって訪問されたが、その参照先がまだ訪問されていないオブジェクト。
      • : GCによって訪問され、その参照先もすべて訪問されたオブジェクト。
    • スイープフェーズ: マークフェーズが完了した後、GCはヒープ全体を走査し、白のままのオブジェクト(到達不可能なオブジェクト)を解放して、そのメモリを再利用可能にします。

スタックスキャン (Stack Scanning)

GCのマークフェーズにおいて、GCはプログラムのルートから到達可能なオブジェクトを特定する必要があります。このルートの一つが、現在実行中のGoroutineのスタックです。スタックスキャンとは、GCが各Goroutineのスタックを走査し、スタック上に存在するポインタ(ヒープ上のオブジェクトを指すアドレス)を識別するプロセスです。これらのポインタが指すオブジェクトは「生きている」とマークされ、さらにそのオブジェクトが指す他のオブジェクトも再帰的にマークされます。

正確なGC (Precise GC) と保守的なGC (Conservative GC)

スタックスキャンにおいて、GCがスタック上の値をどのように解釈するかによって、GCの「正確性」が異なります。

  • 正確なGC (Precise GC): GCがスタック上のどの値がポインタであり、どの値がポインタではないかを正確に識別できる場合を指します。例えば、コンパイラが生成するメタデータや、ランタイムが管理する型情報に基づいて、スタック上の特定の位置にある値がポインタ型であると確実に判断できる場合です。正確なGCは、不要なメモリを最大限に解放できるため、メモリ効率が最も高くなります。
  • 保守的なGC (Conservative GC): GCがスタック上の値がポインタであるか否かを確実に判断できない場合、その値が有効なヒープアドレスを指しているように見える場合、それをポインタとして扱う方式です。つまり、「ポインタかもしれない」という疑いがあるものはすべてポインタとして扱います。この方法は、誤って生きているオブジェクトを回収してしまうリスクを回避できるため安全ですが、実際にはポインタではない値をポインタと誤認することで、不要なメモリを解放できない(メモリリークを引き起こす)可能性があります。GoのGCは、ヒープ上のオブジェクトに対しては正確なGCを行いますが、スタックに対しては歴史的に保守的な要素を含んでいました。

このコミットで言及されている「スタックをフレーム単位で走査する」試みは、スタックに対するより正確なGCを実現しようとするものでした。

Goroutineとスタックフレーム

  • Goroutine: Goにおける軽量な並行実行単位です。OSのスレッドよりもはるかに軽量で、数百万のGoroutineを同時に実行することも可能です。各Goroutineは独自のスタックを持っています。
  • スタックフレーム (Stack Frame): 関数が呼び出されるたびに、その関数のローカル変数、引数、戻りアドレスなどを格納するためにスタック上に割り当てられるメモリ領域です。関数が終了すると、そのスタックフレームはスタックからポップされます。スタックは、これらのフレームが積み重なって構成されています。GCがスタックを走査する際、これらのスタックフレームの構造を理解することが、正確なポインタ識別には不可欠となります。

技術的詳細

このコミットの技術的詳細を掘り下げると、GoランタイムのGCにおけるスタックスキャン戦略の複雑さと、パフォーマンスと正確性のトレードオフが浮き彫りになります。

ScanStackByFrames フラグの役割

src/pkg/runtime/mgc0.c ファイル内の ScanStackByFrames は、GoランタイムのGCがスタックをどのようにスキャンするかを制御する内部フラグです。

  • ScanStackByFrames = 1 (変更前): この設定は、GCがGoroutineのスタックを個々のスタックフレームの境界を認識しながら走査しようとすることを意味します。理論的には、これによりGCは各フレーム内の変数の型情報(コンパイラによって生成される)を利用して、どのスタック上の値が実際にヒープ上のオブジェクトへのポインタであるかをより正確に識別できるはずでした。これにより、保守的なスキャンで発生する可能性のある「偽陽性」(ポインタではない値をポインタと誤認すること)を減らし、より多くのメモリを解放できると期待されていました。
  • ScanStackByFrames = 0 (変更後): この設定は、GCがスタックをフレーム構造を意識せずに、より単純な方法で走査することを意味します。具体的には、スタック全体を単なるバイトの塊として扱い、その中に有効なヒープアドレスに似たパターンがないかを検索します。これは、Go 1.1で採用されていた保守的なスキャンに近い挙動です。この方法は、ポインタの識別精度は劣る可能性がありますが、走査のオーバーヘッドがはるかに低いという利点があります。

フレーム単位走査のパフォーマンスオーバーヘッド

コミットメッセージにある「Walking the stack by frames is ~3x more expensive than not」という記述は、フレーム単位のスタック走査がなぜこれほど高コストであったかを示唆しています。

  1. 複雑なスタックレイアウト: Goのスタックは、関数の呼び出し規約、インライン化、エスケープ解析の結果などによって、非常に動的で複雑なレイアウトを持つことがあります。特に、可変長引数やクロージャの使用は、スタックフレームの構造をさらに複雑にします。
  2. メタデータの取得と解析: 各スタックフレームの正確なポインタ情報を得るためには、コンパイラが生成した型メタデータ(スタックマップなど)をランタイムが参照し、解析する必要があります。このメタデータの読み込み、解析、そしてそれに基づいてスタック上の値を検査するプロセスは、単純なバイト列スキャンに比べて計算コストが高くなります。
  3. ポインタの動的な性質: Goのポインタは、ガベージコレクタが移動する可能性があるため、ポインタの値を追跡し、必要に応じて更新するメカニズムが必要です。フレーム単位でこれを正確に行うことは、より多くの同期やチェックを必要とし、オーバーヘッドを増加させます。

これらの要因が複合的に作用し、フレーム単位のスタック走査は、期待された精度向上に見合うだけのパフォーマンスメリットをもたらさなかったのです。

「正確ではなかった」という記述の深掘り

「since it didn't end up being precise」という記述は、フレーム単位の走査が期待された「正確性」を達成できなかったことを示しています。これは、以下の理由が考えられます。

  • 部分的な情報: コンパイラが生成するスタックマップは、必ずしもスタック上のすべてのポインタを完全に網羅しているわけではない可能性があります。特に、レジスタに一時的に格納されたポインタや、最適化によってスタック上のポインタが一時的に非ポインタ値として扱われるケースなど、ランタイムが完全に追跡できないエッジケースが存在したかもしれません。
  • 動的なスタック成長: Goのスタックは、必要に応じて動的に拡大・縮小します。この動的な性質が、フレーム境界の正確な特定や、GC実行中のスタック変更への対応を複雑にした可能性があります。
  • ポインタの曖昧さ: Goは、Cのような言語とは異なり、ポインタと非ポインタの区別を厳密に行いますが、それでもスタック上の特定のバイト列がポインタであるか否かを実行時に完全に区別することは困難な場合があります。例えば、整数値が偶然にも有効なヒープアドレスと一致するようなケースです。フレーム情報を使っても、このような曖昧さを完全に解消することは難しかったのかもしれません。

結果として、フレーム単位の走査は、その高いコストに見合うだけのGC効率の改善をもたらさなかったため、一時的に無効化されることになりました。

Go 1.1のスタックスキャン挙動

Go 1.1では、スタックは基本的に保守的にスキャンされていました。これは、スタック上のすべてのワード(通常は8バイト)を検査し、それが有効なヒープアドレスを指しているように見える場合、それをポインタとして扱うというアプローチです。この方法は、実装が比較的単純で高速ですが、前述の通り「偽陽性」の可能性をはらんでいます。このコミットは、この実績のある、より高速な挙動に戻すことを選択しました。

ベンチマークの追加の意図

コミットメッセージには、「Add benchmarks to package runtime so that we have them when we re-enable this feature during the Go 1.3 development」とあります。これは、この機能が将来的にGo 1.3の開発中に再検討される可能性があることを示唆しています。

追加されたベンチマーク(BenchmarkGoroutineSelectBenchmarkGoroutineBlockingBenchmarkGoroutineForRangeBenchmarkGoroutineIdle)は、特に多数のGoroutineが関与するシナリオでのGCのパフォーマンスを測定するために設計されています。これらのベンチマークは、スタックスキャン戦略の変更がGoroutineのライフサイクルや相互作用に与える影響を定量的に評価するための重要なツールとなります。将来的にフレーム単位のスタックスキャンを再導入する際には、これらのベンチマークを用いて、そのパフォーマンス上のメリットがコストを上回るかどうかを慎重に評価できるようになります。

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

このコミットによる主要なコード変更は以下の2つのファイルに集中しています。

  1. src/pkg/runtime/mgc0.c: ガベージコレクションのコアロジックが含まれるC言語のファイルです。ここでスタックスキャン挙動を制御するフラグが変更されています。
  2. src/pkg/runtime/malloc_test.go: ランタイムのメモリ割り当てとGCのパフォーマンスをテストするためのGo言語のファイルです。新しいベンチマークが追加されています。

src/pkg/runtime/mgc0.c の変更

--- a/src/pkg/runtime/mgc0.c
+++ b/src/pkg/runtime/mgc0.c
@@ -19,7 +19,7 @@ enum {
 	Debug = 0,
 	DebugMark = 0,  // run second pass to check mark
 	CollectStats = 0,
-	ScanStackByFrames = 1,
+	ScanStackByFrames = 0,
 	IgnorePreciseGC = 0,
 
 	// Four bits per word (see #defines below).\
@@ -1474,6 +1474,7 @@ addstackroots(G *gp)\
 	\t\tUSED(guard);\
 	\t\truntime·gentraceback(pc, sp, lr, gp, 0, nil, 0x7fffffff, addframeroots, nil, false);\
 	\t} else {\
+\t\tUSED(lr);\
 	\t\tUSED(pc);\
 	\t\tn = 0;\
 	\t\twhile(stk) {\
  • ScanStackByFrames の値を 1 から 0 に変更しています。これが、スタックをフレーム単位で走査する機能を無効化する主要な変更です。
  • addstackroots 関数内で、ScanStackByFrames0 の場合のコードパスに USED(lr);USED(pc); が追加されています。これは、これらの変数が使用されていないというコンパイラの警告を抑制するためのものです。

src/pkg/runtime/malloc_test.go の変更

--- a/src/pkg/runtime/malloc_test.go
+++ b/src/pkg/runtime/malloc_test.go
@@ -5,8 +5,10 @@
 package runtime_test
 
 import (
+\t"flag"\
 	. "runtime"\
 	"testing"\
+\t"time"\
 	"unsafe"\
 )
 
@@ -65,3 +67,90 @@ func BenchmarkMallocTypeInfo16(b *testing.B) {\
 	}\
 	mallocSink = x\
 }\
+\n+var n = flag.Int(\"n\", 1000, \"number of goroutines\")\
+\n+func BenchmarkGoroutineSelect(b *testing.B) {\
+\tquit := make(chan struct{})\
+\tread := func(ch chan struct{}) {\
+\t\tfor {\
+\t\t\tselect {\n+\t\t\tcase _, ok := <-ch:\
+\t\t\t\tif !ok {\
+\t\t\t\t\treturn\
+\t\t\t\t}\
+\t\t\tcase <-quit:\
+\t\t\t\treturn\n+\t\t\t}\
+\t\t}\
+\t}\
+\tbenchHelper(b, *n, read)\
+}\
+\n+func BenchmarkGoroutineBlocking(b *testing.B) {\
+\tread := func(ch chan struct{}) {\
+\t\tfor {\
+\t\t\tif _, ok := <-ch; !ok {\
+\t\t\t\treturn\
+\t\t\t}\
+\t\t}\
+\t}\
+\tbenchHelper(b, *n, read)\
+}\
+\n+func BenchmarkGoroutineForRange(b *testing.B) {\
+\tread := func(ch chan struct{}) {\
+\t\tfor _ = range ch {\
+\t\t}\
+\t}\
+\tbenchHelper(b, *n, read)\
+}\
+\n+func benchHelper(b *testing.B, n int, read func(ch chan struct{})) {\
+\tm := make([]chan struct{}, n)\
+\tfor i := range m {\
+\t\tm[i] = make(chan struct{}, 1)\
+\t\tgo read(m[i])\
+\t}\
+\tb.StopTimer()\
+\tb.ResetTimer()\
+\tGC()\
+\n+\tfor i := 0; i < b.N; i++ {\
+\t\tfor _, ch := range m {\
+\t\t\tif ch != nil {\
+\t\t\t\tch <- struct{}{}\
+\t\t\t}\
+\t\t}\
+\t\ttime.Sleep(10 * time.Millisecond)\
+\t\tb.StartTimer()\
+\t\tGC()\
+\t\tb.StopTimer()\
+\t}\
+\n+\tfor _, ch := range m {\
+\t\tclose(ch)\
+\t}\
+\ttime.Sleep(10 * time.Millisecond)\
+}\
+\n+func BenchmarkGoroutineIdle(b *testing.B) {\
+\tquit := make(chan struct{})\
+\tfn := func() {\
+\t\t<-quit\
+\t}\
+\tfor i := 0; i < *n; i++ {\
+\t\tgo fn()\
+\t}\
+\n+\tGC()\
+\tb.ResetTimer()\
+\n+\tfor i := 0; i < b.N; i++ {\
+\t\tGC()\
+\t}\
+\n+\tb.StopTimer()\
+\tclose(quit)\
+\ttime.Sleep(10 * time.Millisecond)\
+}\
  • flagtime パッケージがインポートされています。
  • n というコマンドラインフラグが追加され、ベンチマークで作成するGoroutineの数を制御できるようになっています。
  • 以下の新しいベンチマーク関数が追加されています。
    • BenchmarkGoroutineSelect: select ステートメントを使用するGoroutineのパフォーマンスを測定します。
    • BenchmarkGoroutineBlocking: ブロッキングチャネル操作を使用するGoroutineのパフォーマンスを測定します。
    • BenchmarkGoroutineForRange: for range ループでチャネルを読み取るGoroutineのパフォーマンスを測定します。
    • benchHelper: 上記3つのGoroutineベンチマークで共通して使用されるヘルパー関数です。多数のGoroutineを作成し、チャネルを介して通信させ、GCを強制的に実行しながらベンチマークを行います。
    • BenchmarkGoroutineIdle: アイドル状態のGoroutineが多数存在する場合のGCパフォーマンスを測定します。

コアとなるコードの解説

ScanStackByFrames = 0 の意味

src/pkg/runtime/mgc0.c における ScanStackByFrames = 0 の変更は、Goランタイムのガベージコレクタがスタックをスキャンする際の挙動を根本的に変更します。

  • 変更前 (ScanStackByFrames = 1): この設定では、GCはスタックを個々のスタックフレームの境界を認識しながら走査しようとしました。これは、各関数呼び出しによって作成されるスタックフレームの構造(ローカル変数、引数、戻りアドレスなど)を解析し、フレーム内のどの位置にポインタが存在するかを特定しようとするアプローチです。この目的は、より「正確な」GCを実現し、スタック上のポインタではない値を誤ってポインタと解釈する「偽陽性」を減らすことでした。しかし、Goの動的なスタックレイアウトや最適化の複雑さにより、このフレーム単位の解析は非常に高コストであり、期待された精度向上も得られませんでした。
  • 変更後 (ScanStackByFrames = 0): この設定により、GCはスタックをフレーム構造を意識せずに、より単純な方法で走査します。具体的には、スタック全体を単なる連続したメモリ領域として扱い、その中をワード単位で走査し、有効なヒープアドレスを指しているように見えるパターンがないかを検索します。これは、Go 1.1で採用されていた保守的なスキャンに近い挙動です。この方法は、ポインタの識別精度は劣る可能性がありますが、走査のオーバーヘッドがはるかに低く、GCの実行時間を大幅に短縮できます。コミットメッセージのベンチマーク結果が示すように、特にGoroutine関連のシナリオで劇的なパフォーマンス改善が見られます。これは、フレーム単位のスタックスキャンがGoroutineの多いアプリケーションで特に大きなオーバーヘッドを引き起こしていたことを裏付けています。

addstackroots 関数における USED(lr); USED(pc); の追加

addstackroots 関数は、GCのマークフェーズ中にGoroutineのスタックを走査し、ルートとなるポインタを識別するために使用されます。

	} else {
		USED(lr);
		USED(pc);
		n = 0;
		while(stk) {

このコードブロックは、ScanStackByFrames0 の場合のパスに相当します。USED(lr);USED(pc); は、それぞれリンクレジスタ(lr)とプログラムカウンタ(pc)がこのコードパスでは直接使用されていないことをコンパイラに伝えるためのマクロです。これにより、未使用変数に関するコンパイラの警告を抑制します。これは機能的な変更ではなく、コンパイル時の警告を回避するためのものです。

重要なのは、この else ブロックに入った場合、runtime·gentraceback 関数(スタックフレームをトレースしてポインタを識別する関数)が呼び出されないことです。代わりに、より単純なスタック走査ロジックが実行され、スタックをフレーム単位で解析するのではなく、メモリ領域として直接スキャンします。

追加されたベンチマークコードの解説

src/pkg/runtime/malloc_test.go に追加されたベンチマークは、多数のGoroutineが存在する状況でのGCのパフォーマンスを評価するために設計されています。

  • BenchmarkGoroutineSelectBenchmarkGoroutineBlockingBenchmarkGoroutineForRange: これらのベンチマークは、それぞれ異なるチャネル操作パターン(select、ブロッキング読み取り、for range)を持つ多数のGoroutineを作成します。

    • benchHelper 関数がこれらのベンチマークの共通ロジックを提供します。
    • m := make([]chan struct{}, n): n 個のチャネルの配列を作成します。n はコマンドラインフラグで指定できます(デフォルト1000)。
    • go read(m[i]): 各チャネルに対してGoroutineを起動し、read 関数(各ベンチマーク固有のチャネル読み取りロジック)を実行させます。
    • b.StopTimer(), b.ResetTimer(), GC(), b.StartTimer(): ベンチマークの測定範囲を正確に制御し、GCを強制的に実行して、GCがパフォーマンスに与える影響を測定します。特に、GC() の呼び出しは、スタックスキャンを含むGCサイクルをトリガーします。
    • ch <- struct{}{}: 各Goroutineにシグナルを送信し、チャネル操作をトリガーします。
    • time.Sleep(10 * time.Millisecond): Goroutineがチャネル操作を完了し、GCが実行されるための時間を与えます。 これらのベンチマークは、多数のGoroutineがアクティブに動作し、スタックが頻繁に変化するようなシナリオで、GCのスタックスキャン効率がどのように影響するかを測定します。コミットメッセージのベンチマーク結果が示すように、これらのテストケースで大幅なパフォーマンス改善が見られました。これは、フレーム単位のスタックスキャンがGoroutineの多いアプリケーションで特に大きなオーバーヘッドを引き起こしていたことを裏付けています。
  • BenchmarkGoroutineIdle: このベンチマークは、多数のアイドル状態のGoroutineが存在する場合のGCパフォーマンスを測定します。

    • for i := 0; i < *n; i++ { go fn() }: n 個のGoroutineを起動しますが、これらのGoroutineは <-quit でブロックされ、ほとんど何も処理しません。
    • GC(): アイドル状態のGoroutineが多数存在する状況でGCを強制的に実行します。 このベンチマークは、アクティブでないGoroutineのスタックをスキャンする際のGCの効率を評価します。ここでも大幅なパフォーマンス改善が見られたことは、たとえGoroutineがアイドル状態であっても、フレーム単位のスタックスキャンが不必要なオーバーヘッドを発生させていたことを示唆しています。

これらのベンチマークは、Go 1.3の開発中にフレーム単位のスタックスキャン機能を再評価する際の重要な基準となります。

関連リンク

参考にした情報源リンク