[インデックス 16377] ファイルの概要
このコミットは、Goランタイムにおけるガベージコレクション(GC)とファイナライザゴルーチン間の同期メカニズムの改善に関するものです。特に、プリエンプティブスケジューラが導入された環境下で、これらの重要なランタイムコンポーネントが適切に協調動作することを保証するための変更が含まれています。
コミット
commit 72c4ee1a9daba7b952c9440851d3b9ebbaa58458
Author: Dmitriy Vyukov <dvyukov@google.com>
Date: Wed May 22 23:04:46 2013 +0400
runtime: properly synchronize GC and finalizer goroutine
This is needed for preemptive scheduler, because the goroutine
can be preempted at surprising points.
R=golang-dev, iant
CC=golang-dev
https://golang.org/cl/9376043
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/72c4ee1a9daba7b952c9440851d3b9ebbaa58458
元コミット内容
runtime: properly synchronize GC and finalizer goroutine
This is needed for preemptive scheduler, because the goroutine
can be preempted at surprising points.
変更の背景
このコミットの主な背景には、Goランタイムにおけるプリエンプティブスケジューラの導入があります。従来のGoスケジューラは協調的(cooperative)であり、ゴルーチンは自身が安全なポイントで明示的にスケジューラに制御を返す必要がありました。しかし、プリエンプティブスケジューラが導入されると、ゴルーチンは任意の時点で中断(プリエンプト)される可能性が生じます。
ガベージコレクション(GC)とファイナライザは、Goランタイムの非常に重要な部分であり、メモリ管理とリソースのクリーンアップを担当します。これらの処理は、データ構造の一貫性を保つために厳密な同期を必要とします。プリエンプティブスケジューラによって、GCやファイナライザ関連のゴルーチンが予期せぬタイミングで中断されると、共有データ構造が破壊されたり、デッドロックが発生したりするリスクがありました。
特に、ファイナライザキュー(finq
)の操作や、ファイナライザを実行するゴルーチン(fing
)の状態管理は、GCと密接に関連しており、これらの操作がアトミックに行われることが不可欠です。このコミットは、プリエンプションによって引き起こされる可能性のある競合状態を解消し、GCとファイナライザの処理が常に安全かつ正確に行われるようにするためのものです。
前提知識の解説
- ガベージコレクション (GC): GoのGCは、プログラムが動的に割り当てたメモリのうち、もはや到達不可能になった(参照されなくなった)メモリ領域を自動的に解放するプロセスです。GoのGCは並行(concurrent)かつ停止世界(stop-the-world: STW)フェーズを持つハイブリッドな方式を採用しています。STWフェーズでは、すべてのアプリケーションゴルーチンが一時停止され、GCが安全にメモリをスキャン・マーク・スイープできるようになります。
- ファイナライザ (Finalizer): Goのファイナライザは、オブジェクトがGCによって回収される直前に実行される関数です。主に、ファイルハンドルやネットワーク接続などのOSリソースを解放するために使用されます。
runtime.SetFinalizer
関数で設定されます。ファイナライザは専用のゴルーチン(ファイナライザゴルーチン)によって実行されます。 - ゴルーチン (Goroutine): Goにおける軽量な実行スレッドです。OSスレッドよりもはるかに軽量で、数百万個のゴルーチンを同時に実行できます。Goランタイムのスケジューラによって管理されます。
- プリエンプティブスケジューラ (Preemptive Scheduler): 実行中のゴルーチンが、自身の意思とは関係なく、スケジューラによって強制的に中断され、別のゴルーチンにCPUが割り当てられる方式です。これにより、長時間実行されるゴルーチンが他のゴルーチンの実行をブロックするのを防ぎ、システムの応答性を向上させます。Go 1.2以降で段階的に導入されました。
runtime.lock
とruntime.unlock
: Goランタイム内部で使用される低レベルのロックプリミティブです。ミューテックス(相互排他ロック)として機能し、複数のゴルーチンが共有データに同時にアクセスするのを防ぎ、競合状態を回避するために使用されます。runtime.newproc1
: 新しいゴルーチンを作成し、実行可能状態にするランタイム内部関数です。runtime.ready
: 停止しているゴルーチンを実行可能状態に戻すランタイム内部関数です。runtime.park
: 現在のゴルーチンを停止させ、スケジューラから外すランタイム内部関数です。特定の条件が満たされるまで待機するために使用されます。runtime.park(unlockf, lock, reason)
の形式で、ロックを解放してからパークし、アンパーク後にロックを再取得するパターンでよく使われます。
技術的詳細
このコミットは、src/pkg/runtime/mgc0.c
ファイル、特にGCのメインロジックとファイナライザの管理に関連する部分に変更を加えています。
変更の核心は、GCがファイナライザゴルーチンを起動または再開する際、およびファイナライザゴルーチン自身がファイナライザキューを処理する際に、finlock
というミューテックス(ロック)を使用して同期を取るようにした点です。
変更前:
GC関数(gc
)内では、ファイナライザキュー(finq
)が存在する場合、ファイナライザゴルーチン(fing
)を起動または再開していました。この際、m->locks
をインクリメント/デクリメントすることでGCを一時的に無効化していましたが、これはプリエンプションに対して十分な保護を提供していませんでした。特に、runfinq
関数内では「このセクションではロックは不要」というコメントがあり、GCとの競合はGCが停止している間にのみ発生するという前提がありました。これは協調的スケジューリングの仮定に基づいています。
変更後:
gc
関数における変更:finq != nil
のブロック全体がruntime·lock(&finlock)
とruntime·unlock(&finlock)
で囲まれるようになりました。これにより、GCがファイナライザゴルーチンの状態(fing
,fingwait
)やファイナライザキュー(finq
)を操作する際に、他のゴルーチン(特にファイナライザゴルーチン自身)との競合が防止されます。runtime·gosched()
の呼び出しはロックの外に移動されました。これは、スケジューリングポイントでロックを保持しないようにするためです。
runfinq
関数における変更:- ファイナライザキュー(
finq
)の取得とクリア、およびfingwait
の設定の前にruntime·lock(&finlock)
が追加されました。 finq
がnil
でない場合、つまりファイナライザが処理されるべきデータがある場合は、runtime·unlock(&finlock)
が追加され、ロックを解放してからファイナライザの処理ループに入ります。これにより、ファイナライザの実行中にロックを保持し続けることを避け、他のゴルーチンがfinlock
を取得できるようになります。runtime·park
の呼び出しがruntime·park(runtime·unlock, &finlock, "finalizer wait")
に変更されました。これは、ゴルーチンがパークする直前にfinlock
を安全に解放し、アンパーク後に自動的に再取得するイディオムです。これにより、ファイナライザゴルーチンが待機状態に入る際にデッドロックを回避し、他のゴルーチンがfinlock
を取得してfinq
を更新したり、ファイナライザゴルーチンを再開したりできるようになります。
- ファイナライザキュー(
これらの変更により、プリエンプティブスケジューラが導入されても、GCとファイナライザゴルーチンが共有するデータ(finq
, fing
, fingwait
など)へのアクセスが常にロックによって保護されるようになり、データの一貫性が保証されます。
コアとなるコードの変更箇所
src/pkg/runtime/mgc0.c
ファイルにおいて、以下の部分が変更されています。
変更前:
// gc関数内
- if(finq != nil) {
- m->locks++; // disable gc during the mallocs in newproc
- // kick off or wake up goroutine to run queued finalizers
- if(fing == nil)
- fing = runtime·newproc1(&runfinqv, nil, 0, 0, runtime·gc);
- else if(fingwait) {
- fingwait = 0;
- runtime·ready(fing);
- }
- m->locks--;
- }
// runfinq関数内
- // There's no need for a lock in this section
- // because it only conflicts with the garbage
- // collector, and the garbage collector only
- // runs when everyone else is stopped, and
- // runfinq only stops at the gosched() or
- // during the calls in the for loop.
fb = finq;
finq = nil;
if(fb == nil) {
fingwait = 1;
- runtime·park(nil, nil, "finalizer wait");
continue;
}
変更後:
// gc関数内
+ if(finq != nil) {
+ runtime·lock(&finlock);
+ // kick off or wake up goroutine to run queued finalizers
+ if(fing == nil)
+ fing = runtime·newproc1(&runfinqv, nil, 0, 0, runtime·gc);
+ else if(fingwait) {
+ fingwait = 0;
+ runtime·ready(fing);
+ }
+ runtime·unlock(&finlock);
+ // give the queued finalizers, if any, a chance to run
+ runtime·gosched();
+ }
// runfinq関数内
+ runtime·lock(&finlock);
fb = finq;
finq = nil;
if(fb == nil) {
fingwait = 1;
+ runtime·park(runtime·unlock, &finlock, "finalizer wait");
continue;
}
+ runtime·unlock(&finlock);
コアとなるコードの解説
このコミットの核となる変更は、finlock
というミューテックス(ロック)を導入し、GCとファイナライザゴルーチンが共有する状態(ファイナライザキュー finq
、ファイナライザゴルーチンのポインタ fing
、待機状態フラグ fingwait
)へのアクセスを保護することです。
-
gc
関数におけるロックの追加:- GCがファイナライザゴルーチンを起動または再開するロジック全体が
runtime·lock(&finlock)
とruntime·unlock(&finlock)
で囲まれました。これにより、GCがこれらの共有変数を読み書きする際に、ファイナライザゴルーチンが同時にこれらの変数にアクセスして競合状態を引き起こすことを防ぎます。 - 以前は
m->locks
を使用していましたが、これはGCの停止を制御するものであり、プリエンプティブスケジューラ下でのゴルーチン間の同期には不十分でした。finlock
の導入により、より粒度の細かい、かつプリエンプション耐性のある同期が実現されました。 runtime·gosched()
はロックの外に移動されました。これは、gosched
がスケジューリングポイントであり、その際にロックを保持しているとデッドロックや性能問題を引き起こす可能性があるためです。
- GCがファイナライザゴルーチンを起動または再開するロジック全体が
-
runfinq
関数におけるロックの追加とpark
の変更:runfinq
はファイナライザを実行する専用のゴルーチンです。このゴルーチンがfinq
からファイナライザを取得し、finq
をクリアする前にruntime·lock(&finlock)
を取得します。これにより、GCが同時にfinq
を更新しようとするのを防ぎます。- ファイナライザが処理されるべきデータがある場合(
fb != nil
)、runtime·unlock(&finlock)
が呼び出され、ロックを解放してからファイナライザの処理ループに入ります。これは、ファイナライザの実行自体は時間がかかる可能性があり、その間ずっとロックを保持しているとGCがファイナライザキューを更新できなくなるためです。ファイナライザの実行は独立して行われ、次のイテレーションで再度ロックを取得します。 - 最も重要な変更の一つは、ファイナライザゴルーチンが待機状態に入る際の
runtime·park
の呼び出しです。- 変更前は
runtime·park(nil, nil, "finalizer wait")
でした。これは、ロックを解放せずにパークするため、もしGCがfinlock
を取得してファイナライザゴルーチンを再開しようとした場合、デッドロックが発生する可能性がありました。 - 変更後は
runtime·park(runtime·unlock, &finlock, "finalizer wait")
となりました。これは、runtime.park
がゴルーチンを停止させる直前にfinlock
を安全に解放し、ゴルーチンが再開された後に自動的にfinlock
を再取得するというパターンです。これにより、ファイナライザゴルーチンが待機している間もGCがfinlock
を取得してfinq
を更新したり、ファイナライザゴルーチンをruntime.ready
で再開したりすることが可能になり、デッドロックが回避されます。
- 変更前は
これらの変更により、Goランタイムはプリエンプティブスケジューラが導入された環境下でも、GCとファイナライザの間の同期を堅牢に行えるようになりました。
関連リンク
- Go言語のガベージコレクション: https://go.dev/doc/gc-guide
- Goのファイナライザに関するドキュメント: https://pkg.go.dev/runtime#SetFinalizer
- Goのプリエンプティブスケジューラに関する議論(古いものも含む):
- Go 1.2におけるプリエンプションの導入: https://go.dev/doc/go1.2#runtime
- Go 1.14における非協調的プリエンプション: https://go.dev/blog/go1.14-preemption
参考にした情報源リンク
- Goソースコード (runtime/mgc0.c): https://github.com/golang/go/blob/master/src/runtime/mgc0.go (現在のGoバージョンでは
mgc0.c
はmgc.go
などに統合されていますが、当時のCコードのロジックはGoコードに引き継がれています。) - Goのランタイム内部に関するブログ記事やドキュメント(一般的な情報源)
- Goのコミット履歴と関連するコードレビュー(CL): https://golang.org/cl/9376043 (このコミットのChange List)
- Goのスケジューラに関する解説記事 (例: "Go's work-stealing scheduler"): https://rakyll.org/scheduler/
- GoのGCに関する詳細な解説記事 (例: "Go's garbage collector: how it works and why it's fast"): https://blog.golang.org/go15gc
- Goの
runtime.park
の挙動に関する議論やドキュメント