[インデックス 17726] ファイルの概要
このコミットは、Goランタイムのファイナライザテストにおけるバグ修正を目的としています。具体的には、src/pkg/runtime/mfinal_test.go
ファイル内のテストコードが修正され、ファイナライザが期待通りに実行されないという問題に対処しています。この問題は、ガベージコレクタ(GC)がスタックをスキャンする方法の変更によって引き起こされた「誤検知」に関連しています。
コミット
commit 5f853d7d9407db1aaa7c7d0dfbf3dbd9d5c19093
Author: Russ Cox <rsc@golang.org>
Date: Wed Oct 2 12:30:49 2013 -0400
runtime: fix finalizer test on amd64
Not scanning the stack by frames means we are reintroducing
a few false positives into the collection. Run the finalizer registration
in its own goroutine so that stack is guaranteed to be out of
consideration in a later collection.
This is working around a regression from yesterday's tip, but
it's not a regression from Go 1.1.
R=golang-dev
TBR=golang-dev
CC=golang-dev
https://golang.org/cl/14290043
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/5f853d7d9407db1aaa7c7d0dfbf3dbd9d5c19093
元コミット内容
このコミットは、Goランタイムのファイナライザテスト、特にamd64
アーキテクチャでの問題を修正します。
コミットメッセージによると、スタックをフレーム単位でスキャンしないという変更が、ガベージコレクション(GC)における「誤検知(false positives)」を再導入してしまったことが原因です。この誤検知とは、本来回収されるべきオブジェクトが、GCによって誤って参照されていると判断され、回収されない状態を指します。
この問題に対処するため、ファイナライザの登録処理を独自のゴルーチンで実行するように変更されました。これにより、ファイナライザが登録されたオブジェクトが、その後のGC実行時にスタックから確実に考慮外となることが保証されます。
この修正は、直近のGoの開発版("yesterday's tip")で発生したリグレッション(退行バグ)への対処ですが、Go 1.1のリリース版からのリグレッションではないと述べられています。
変更の背景
このコミットの背景には、Goのガベージコレクタ(GC)の動作、特にスタックのスキャン方法に関する最近の変更があります。コミットメッセージにある「Not scanning the stack by frames means we are reintroducing a few false positives into the collection」という記述が、問題の核心を示しています。
GoのGCは、到達可能性(reachability)に基づいてオブジェクトを回収します。あるオブジェクトがプログラムから到達可能であると判断される限り、そのオブジェクトは回収されません。到達可能性の判断には、グローバル変数、ヒープ上のオブジェクト間の参照、そしてスタック上の変数からの参照が含まれます。
以前のGC実装では、スタックをフレーム単位で詳細にスキャンし、どのスタック上の変数がヒープ上のオブジェクトを指しているかを正確に追跡していました。しかし、GCのパフォーマンス改善や複雑性軽減のために、スタックのスキャン方法が変更された可能性があります。例えば、スタック全体を保守的にスキャンする(ポインタではない可能性のある値もポインタとして扱う)ようになったり、特定の最適化によってスタック上の情報がGCから見えにくくなったりしたことが考えられます。
このような変更の結果、runtime.SetFinalizer
でファイナライザが設定されたオブジェクトが、実際にはどこからも参照されていないにもかかわらず、GCがスタック上の古い(しかしGCからは有効に見える)参照を誤って検出し、「到達可能」と判断してしまう「誤検知」が発生するようになりました。これにより、ファイナライザが設定されたオブジェクトが期待通りに回収されず、ファイナライザが実行されないというテストの失敗につながっていました。
このコミットは、この「誤検知」の問題を回避するために、ファイナライザの登録処理を別のゴルーチンに分離するというアプローチを取っています。
前提知識の解説
このコミットを理解するためには、以下のGoの概念とガベージコレクションに関する知識が必要です。
-
Goのガベージコレクション (GC):
- Goは自動メモリ管理を採用しており、不要になったメモリ領域をガベージコレクタが自動的に解放します。
- GoのGCは「マーク&スイープ」方式をベースとしており、プログラムが参照しているオブジェクト(到達可能なオブジェクト)をマークし、マークされなかったオブジェクト(到達不可能なオブジェクト)を回収します。
- GCは、プログラムの実行中にバックグラウンドで動作し、メモリの解放を行います。
-
ファイナライザ (
runtime.SetFinalizer
):- Goには、オブジェクトがガベージコレクションによって回収される直前に特定の関数(ファイナライザ)を実行する仕組みがあります。これは
runtime.SetFinalizer
関数を使って設定します。 - ファイナライザは、ファイルハンドルやネットワーク接続などの外部リソースをクリーンアップする際に使用されることがあります。
- ファイナライザが実行されるのは、オブジェクトが「到達不可能」と判断され、GCによって回収されることが決定された後です。
- Goには、オブジェクトがガベージコレクションによって回収される直前に特定の関数(ファイナライザ)を実行する仕組みがあります。これは
-
スタックとヒープ:
- スタック: 関数呼び出しやローカル変数などが格納されるメモリ領域です。LIFO(Last-In, First-Out)の構造を持ち、高速にアクセスできます。Goでは、ゴルーチンごとに独自のスタックを持ちます。
- ヒープ: プログラムの実行中に動的に確保されるメモリ領域です。
new
やmake
などで確保されたオブジェクトはヒープに配置されます。GCの主な対象となります。 - Goのコンパイラは、変数がスタックに割り当てられるかヒープに割り当てられるかをエスケープ解析(escape analysis)によって決定します。
-
ゴルーチン (Goroutines):
- Goにおける軽量な並行実行単位です。OSのスレッドよりもはるかに軽量で、数百万のゴルーチンを同時に実行することも可能です。
- 各ゴルーチンは独自のスタックを持ちます。あるゴルーチンのスタックは、他のゴルーチンのスタックやヒープとは独立しています。
-
ガベージコレクションにおける「誤検知 (False Positives)」:
- GCが、実際には到達不可能であるにもかかわらず、誤って到達可能であると判断してしまう状況を指します。
- これは通常、GCがポインタではない値を誤ってポインタとして解釈したり、スタック上の古い(しかしGCからは有効に見える)値を誤って参照として扱ったりする場合に発生します。
- 誤検知が発生すると、本来回収されるべきメモリが回収されず、メモリリークやメモリ使用量の増加につながる可能性があります。
技術的詳細
このコミットの技術的詳細を理解するためには、Goのガベージコレクタがどのようにスタックをスキャンし、オブジェクトの到達可能性を判断するかに焦点を当てる必要があります。
GoのGCは、プログラムが参照しているすべてのオブジェクトを追跡し、参照されていないオブジェクトを回収します。この参照の追跡には、スタック上の変数も含まれます。もしスタック上の変数がヒープ上のオブジェクトへのポインタを保持している場合、そのオブジェクトは到達可能と見なされ、回収されません。
コミットメッセージにある「Not scanning the stack by frames」という記述は、GoのGCがスタックをスキャンする際の粒度が変更されたことを示唆しています。以前は、GCはスタックフレームごとに、どの領域がポインタを含み、どの領域がポインタを含まないかを正確に識別できた可能性があります。しかし、この変更により、GCがスタック上の特定の領域がポインタであるかどうかを正確に判断できなくなり、保守的に「ポインタである可能性がある」と判断するようになったと考えられます。
この保守的なスキャンが、「誤検知」を引き起こしました。runtime.SetFinalizer
が設定されたオブジェクトは、そのオブジェクトへの参照がすべてなくなった時点でGCの対象となります。しかし、もしファイナライザを設定した関数がまだスタック上にあり、そのスタックフレーム内に、ファイナライザを設定したオブジェクトへの古い参照(たとえその参照がnil
に設定された後であっても、スタック上のメモリが上書きされていない場合)が残っていた場合、GCはその古い参照を誤って有効なポインタと解釈し、オブジェクトがまだ到達可能であると判断してしまう可能性がありました。
この問題を解決するために、コミットではファイナライザの登録処理を新しいゴルーチンで実行するように変更しています。
// 変更前
func() {
v := new(int)
runtime.SetFinalizer(tt.convert(v), tt.finalizer)
v = nil // ここでvへの参照は論理的に切れる
}() // この関数が終了しても、そのスタックフレームはすぐに解放されない可能性がある
// 変更後
go func() {
v := new(int)
runtime.SetFinalizer(tt.convert(v), tt.finalizer)
v = nil // ここでvへの参照は論理的に切れる
}() // 新しいゴルーチンで実行されるため、このゴルーチンが終了すると、そのスタックはGCの考慮から外れる
time.Sleep(1 * time.Second) // 新しいゴルーチンが終了するのを待つ
新しいゴルーチンでファイナライザの登録を行うことで、以下の効果が得られます。
- スタックの独立性: 各ゴルーチンは独自のスタックを持ちます。ファイナライザを登録するゴルーチンが終了すると、そのゴルーチンのスタックはGCの対象から外れます。
- 参照の確実な解放:
v = nil
とした後、そのゴルーチンが終了することで、v
が保持していたオブジェクトへのスタック上の参照が確実にGCの考慮から外れます。これにより、GCが古いスタック上の値を誤ってポインタと解釈する可能性が大幅に減少します。 - GCの正確性向上: ファイナライザが設定されたオブジェクトが、本当に到達不可能になった場合にのみGCの対象となるようになり、テストの信頼性が向上します。
time.Sleep(1 * time.Second)
が追加されているのは、新しいゴルーチンがファイナライザの登録を完了し、そのゴルーチンが終了する(そしてスタックがGCの考慮から外れる)ための十分な時間を与えるためです。これにより、runtime.GC()
が呼び出された時点で、ファイナライザが設定されたオブジェクトが確実に回収対象となる状態になっていることを保証します。
この修正は、GoのGCがスタックをスキャンする際の保守的なアプローチと、ファイナライザの正確な動作を両立させるための重要なステップと言えます。
コアとなるコードの変更箇所
変更は src/pkg/runtime/mfinal_test.go
ファイルに集中しています。
diff --git a/src/pkg/runtime/mfinal_test.go b/src/pkg/runtime/mfinal_test.go
index ae06dd291a..6efef9bb03 100644
--- a/src/pkg/runtime/mfinal_test.go
+++ b/src/pkg/runtime/mfinal_test.go
@@ -46,17 +46,18 @@ func TestFinalizerType(t *testing.T) {
}
for _, tt := range finalizerTests {
- func() {
+ go func() {
v := new(int)
*v = 97531
runtime.SetFinalizer(tt.convert(v), tt.finalizer)
v = nil
}()
+ time.Sleep(1 * time.Second)
runtime.GC()
select {
case <-ch:
case <-time.After(time.Second * 4):
- t.Errorf("Finalizer of type %T didn't run", tt.finalizer)
+ t.Errorf("finalizer for type %T didn't run", tt.finalizer)
}
}
}
@@ -72,25 +73,27 @@ func TestFinalizerInterfaceBig(t *testing.T) {
}
ch := make(chan bool)
- func() {
+ go func() {
v := &bigValue{0xDEADBEEFDEADBEEF, true, "It matters not how strait the gate"}
+ old := *v
runtime.SetFinalizer(v, func(v interface{}) {
i, ok := v.(*bigValue)
if !ok {
- t.Errorf("Expected *bigValue from interface{} in finalizer, got %v", *i)
+ t.Errorf("finalizer called with type %T, want *bigValue", v)
}
- if i.fill != 0xDEADBEEFDEADBEEF && i.it != true && i.up != "It matters not how strait the gate" {
- t.Errorf("*bigValue from interface{} has the wrong value: %v\n", *i)
+ if *i != old {
+ t.Errorf("finalizer called with %+v, want %+v", *i, old)
}
close(ch)
})
v = nil
}()
+ time.Sleep(1 * time.Second)
runtime.GC()
select {
case <-ch:
- case <-time.After(time.Second * 4):
- t.Errorf("Finalizer set by SetFinalizer(*bigValue, func(interface{})) didn't run")
+ case <-time.After(4 * time.Second):
+ t.Errorf("finalizer for type *bigValue didn't run")
}
}
コアとなるコードの解説
このコミットの主要な変更点は、TestFinalizerType
とTestFinalizerInterfaceBig
という2つのテスト関数において、ファイナライザの登録を行う無名関数を新しいゴルーチンで実行するように変更したことです。
-
func() { ... }
からgo func() { ... }
への変更:- 変更前は、ファイナライザを登録するコードブロックは現在のゴルーチン内で同期的に実行されていました。この場合、
v = nil
としてオブジェクトへの参照を論理的に切ったとしても、そのオブジェクトへの古いポインタが現在のゴルーチンのスタック上に残存し、GCがそれを誤って有効な参照と判断する可能性がありました。 - 変更後は、
go
キーワードを追加することで、このコードブロックが新しい独立したゴルーチンとして実行されます。この新しいゴルーチンがファイナライザの登録を完了し、そのゴルーチンが終了すると、そのゴルーチンのスタックはGCの考慮から外れます。これにより、GCが古いスタック上の値を誤ってポインタと解釈する「誤検知」を防ぎ、ファイナライザが設定されたオブジェクトが正しく回収対象となることを保証します。
- 変更前は、ファイナライザを登録するコードブロックは現在のゴルーチン内で同期的に実行されていました。この場合、
-
time.Sleep(1 * time.Second)
の追加:- 新しいゴルーチンは非同期に実行されるため、メインのテストゴルーチンが
runtime.GC()
を呼び出す前に、新しいゴルーチンがファイナライザの登録を完了し、終了していることを保証する必要があります。 time.Sleep(1 * time.Second)
は、新しいゴルーチンが実行を完了し、そのスタックがGCの対象から外れるための十分な時間を与えるために追加されました。これにより、runtime.GC()
が実行される時点で、ファイナライザが設定されたオブジェクトが確実に回収対象となっている状態を作り出します。
- 新しいゴルーチンは非同期に実行されるため、メインのテストゴルーチンが
-
エラーメッセージの変更:
- テストが失敗した場合のエラーメッセージが、より簡潔で明確な表現に修正されています。例えば、
"Finalizer of type %T didn't run"
が"finalizer for type %T didn't run"
に変更されています。
- テストが失敗した場合のエラーメッセージが、より簡潔で明確な表現に修正されています。例えば、
-
TestFinalizerInterfaceBig
におけるファイナライザの検証ロジックの改善:TestFinalizerInterfaceBig
では、ファイナライザに渡されたbigValue
オブジェクトの内容が期待通りであるかを検証しています。- 変更前は、
i.fill
,i.it
,i.up
といった個々のフィールドを直接比較していましたが、これは冗長であり、bigValue
の構造が変更された場合にテストの修正が必要になる可能性があります。 - 変更後は、
old := *v
として元のbigValue
のコピーを保持し、ファイナライザ内で*i != old
という比較を行うことで、オブジェクト全体の内容が期待通りであるかを簡潔に検証できるようになりました。これはテストの堅牢性を高める改善です。
これらの変更により、Goのファイナライザテストは、GCのスタック処理の変更に起因する「誤検知」の問題を回避し、より信頼性の高いものとなりました。
関連リンク
- Goのガベージコレクションに関する公式ドキュメントやブログ記事:
- Go's Garbage Collector: Prioritizing Low Latency and Simplicity (Go 1.5 GCに関する記事ですが、GCの基本的な考え方を理解するのに役立ちます)
- Go Memory Management (Goのメモリ管理に関する古い記事ですが、基本的な概念は共通です)
runtime.SetFinalizer
のドキュメント:- Goのスタックとエスケープ解析に関する情報:
参考にした情報源リンク
- https://github.com/golang/go/commit/5f853d7d9407db1aaa7c7d0dfbf3dbd9d5c19093
- https://golang.org/cl/14290043 (GoのコードレビューシステムGerritのリンク)
- Goのガベージコレクション、ファイナライザ、ゴルーチン、スタックに関する一般的な知識。
- Goのテストコードの慣習。