ファイナライザーは、特にcgoを介してCライブラリにブリッジする際に外部リソースを解放するための安全網を提供するために、初期のGoリリースで導入されました。Javaの同様のメカニズムを模倣して、runtime.SetFinalizerは、ガーベジコレクタが参照が存在しないと判断したときに実行される関数をオブジェクトに添付します。ただし、Goチームは、実行タイミングが非決定的であり、ガーベジコレクタのフェーズとの複雑な相互作用があるため、その使用を一貫して控えるように勧めています。
ファイナライザーは、GCがオブジェクトを到達不可能とマークした後にのみ専用のgoroutineで非同期に実行されるため、リソースが必要以上に長く割り当てられてしまうウィンドウが生じます。重要な問題は、ファイナライザーがグローバル変数または生きているオブジェクトに参照を保存することでオブジェクトを復活させる場合に発生し、再び到達可能にします。無限ファイナライゼーションループやリソース枯渇を防ぐために、ランタイムはファイナライザーがすでに実行されたことを追跡し、次のファイナライゼーションが発生する前に強制的な「冷却」期間を設ける必要があります。
Goは、プログラムが早期に終了しない限り、オブジェクトが最初のGCサイクルで到達不可能であると見なされた後にファイナライザーが正確に1回実行されることを保証します。復活が発生すると、ランタイムは内部スウィープバッファからファイナライザーの関連付けを削除し、再登録するにはruntime.SetFinalizerへの明示的な新しい呼び出しが必要です。この設計は、復活したオブジェクトが次のファイナライザーをスケジュールする前に、再び本当に到達不可能であることを証明するために、少なくとも1つの追加の完全なGCサイクルを生き延びる必要があることを保証します。
type Resource struct { ptr unsafe.Pointer // C memory } func NewResource() *Resource { r := &Resource{ptr: C.malloc(1024)} // ファイナライザーはrが到達不可能になると実行されます runtime.SetFinalizer(r, (*Resource).Finalize) return r } func (r *Resource) Finalize() { C.free(r.ptr) // もし我々が: global = rを実行したなら、rを復活させます // ファイナライザーは現在切り離されています; rは再びファイナライズされるために // 別のGCサイクルと新しいSetFinalizerの呼び出しが必要です。 }
リアルタイム分析パイプラインを構築する中で、我々のチームはcgoを使用してハードウェア加速暗号化のためのサードパーティのCライブラリを統合し、Cヒープメモリに敏感なキーのバッファを割り当てました。我々は、ラッパーがガーベジコレクションされるときにCのfree()関数を自動的に呼び出すために、Goのラッパー構造体でruntime.SetFinalizerに依存していました。持続的な負荷テスト中に、我々はGoコードがすでに解放されたCメモリにアクセスしようとする間欠的なセグメンテーションフォルトを観察しましたが、対応するGoオブジェクトはリクエストハンドラーでまだアクティブでした。
根本原因分析により、ファイナライザー内で呼び出されたログ記録フレームワークがエラーコンテキストのためにGoラッパーのポインタをキャプチャしてしまい、意図せずグローバルリングバッファに復活させたことが明らかになりました。Goのファイナライザーはアプリケーションと同時に実行されるため、オブジェクトはそのCメモリが解放された後に復活しましたが、リクエストハンドラーがそれを使用し終える前でした。このレースコンディションにより、復活したオブジェクトがダンジングCポインタを保持し、高い同時処理の下でサービスが予測不可能にクラッシュする使い後の解放シナリオが作成されました。
我々は、明示的なClose()メソッドをio.Closerセマンティクスで実装し、ファイナライザーをリーク検出の安全網として保持することを検討しました。このアプローチは決定的なリソース管理を提供し、リクエストが完了するとすぐにCメモリが解放されることを保証し、ただし、Close()とファイナライザーが同時に実行されると二重解放のリスクを導入し、開発者がClose()を呼び忘れた場合にはクラッシュを防ぐことができません。
別のオプションは、ガーベジコレクションを防ぐことなく未処理の割り当てを追跡するために、uintptrアドレスを使用したカスタムレジストリにファイナライザーを置き換えることでした。この方法ではオブジェクトライフサイクル監視に対する明示的な制御が可能で、復活の副作用を完全に回避します。ただし、複雑な手動同期、古いエントリをスキャンするための定期的なマップスキャン、レジストリ自体が注意深く維持されない場合のメモリリークのリスクが必要であり、かなりの運用オーバーヘッドが追加されます。
我々は、最終的にファイナライザーを生産コードから完全に排除し、すべてのコードパスでdeferステートメントを介して強制された明示的なClose()呼び出しを義務づけました。最後の使用とClose()呼び出しの間の早期ガーベジコレクションを防ぐために、我々はCメモリを使用する重要なセクションの後に**runtime.KeepAlive(obj)**の呼び出しを追加しました。この戦略により、非決定的な動作が排除され、復活のリスクがなくなり、Goの明示的なリソース管理の哲学に合致しましたが、Close()が常に到達可能であることを保証するために、かなりの部分のコードベースをリファクタリングする必要がありました。
移行後、セグメンテーションフォルトは完全になくなり、GPUメモリ使用量はリクエストボリュームと予測可能かつ線形になりました。静的解析リンターを追加して、これらのオブジェクトでのClose()呼び出しを強制し、コンパイル時にリソースリークをキャッチしました。システムは現在、メモリ関連のクラッシュなしで毎秒10万件以上のリクエストを維持し、ミッションクリティカルなGoサービスにおける明示的なライフサイクル管理がファイナライザーに基づくアプローチを上回ることを示しています。
ファイナライズされたオブジェクトがそのファイナライザーがまだ実行中であってもGCによって回収される可能性は何か、そしてruntime.KeepAliveがこれを防ぐ方法は何か?
候補者は、ファイナライザーの存在がターゲットオブジェクトをファイナライザーが完了するまで生かすと仮定することがよくあります。実際には、GCがオブジェクトが到達不可能であると判断すると、即座に回収の対象となり、ファイナライザーは別のgoroutineで実行されるようにスケジュールされます。別の参照が存在しない場合、ファイナライザーが終了する前にオブジェクトが回収される可能性があります。これを防ぐために、**runtime.KeepAlive(obj)**はオブジェクトの最後の使用の後に呼び出され、コンパイラーのレベルでの発生前エッジを作成し、その時点までオブジェクトのライフタイムを延ばし、Cリソースや他の依存関係がファイナライザーの実行中に有効であることを保証します。
単一のGoオブジェクトに対して、runtime.SetFinalizerへの連続呼び出しを介して複数のファイナライザーを登録できますか?もしファイナライザー関数自体がオブジェクトをキャプチャするクロージャーであれば、どうなりますか?
多くの候補者が、一つのオブジェクトに対して複数のファイナライザーがチェーンやキューを形成できると誤解しています。Goは、SetFinalizerが再度呼び出されると、既存のファイナライザーを明示的に上書きし、内部ランタイムのハッシュテーブルに最新の関数ポインタのみを保持します。ファイナライザーがオブジェクトをキャプチャするクロージャーである場合、循環参照が作成され、オブジェクトが永続的にアクセス可能になり、ファイナライザーが決して実行されず、キャプチャされた参照がクロージャーの変数に存在するため、メモリリークが発生します。
GCは、AがBを参照し、両方がファイナライザーを登録しているオブジェクトのグラフに対して、ファイナライザーの実行順序をどう処理しますか?
候補者は子オブジェクトが親オブジェクトの前に実行されるといった決定論的な順序を期待することがよくあります。Goは、GCがすべての到達不可能なオブジェクトのファイナライザーを同時にグローバルキューにキューイングし、複数のバックグラウンドgoroutinesが並行して処理するため、順序保証を提供しません。もしAのファイナライザーがBにアクセスし、Bのファイナライザーがすでに実行されてリソースを解放していた場合、Aのファイナライザーは壊れた状態に遭遇したり、使い後の解放エラーが発生したりします。そのため、ファイナライザーは他のファイナライザーを持つオブジェクトにアクセスしないか、すべてのクリーンアップロジックをルートオブジェクトの単一のファイナライザーに集中させる必要があります。