uniqueからcleanupsとweakへ:効率化のための新しい低レベルツール
From unique to cleanups and weak: new low-level tools for efficiency by Michael Knyszek
昨年のuniqueパッケージについてのブログ記事では、当時プロポーザルのレビュー中だったいくつかの新機能について触れました。そして今回、Go 1.24でそれらの機能がすべてのGo開発者に向けて利用可能になったことをお知らせできることを嬉しく思います。この新機能とは、オブジェクトが到達不可能になったときに実行される関数をキューに入れるruntime.AddCleanup関数と、ガベージコレクションを妨げることなく安全にオブジェクトを指し示すweak.Pointer型です。この2つの機能を組み合わせれば、自分自身の手でuniqueパッケージを構築できるほど強力です!これらの機能が何に役立つのか、そしてどう使うべきかを詳しく見ていきましょう。
注意: この新機能はガベージコレクタの高度な機能です。ガベージコレクションの基本的な概念にまだ馴染みがない場合は、ガベージコレクタガイドの導入部分を読むことを強くお勧めします。
クリーンアップ
ファイナライザを使ったことがあるなら、クリーンアップの概念にはすぐ馴染めるでしょう。ファイナライザとは、runtime.SetFinalizerを呼び出すことで割り当て済みのオブジェクトに関連付けられる関数で、オブジェクトが到達不可能になってからしばらく経った後にガベージコレクタによって呼び出されます。大まかに言えば、クリーンアップも同じように動作します。
メモリマップドファイルを利用するアプリケーションを例に、クリーンアップがどのように役立つかを見てみましょう。
//go:build unix
type MemoryMappedFile struct {
data []byte
}
func NewMemoryMappedFile(filename string) (*MemoryMappedFile, error) {
f, err := os.Open(filename)
if err != nil {
return nil, err
}
defer f.Close()
// ファイルの情報を取得する。サイズが必要。
fi, err := f.Stat()
if err != nil {
return nil, err
}
// ファイルディスクリプタを取り出す。
conn, err := f.SyscallConn()
if err != nil {
return nil, err
}
var data []byte
connErr := conn.Control(func(fd uintptr) {
// このファイルを裏付けとするメモリマッピングを作成する。
data, err = syscall.Mmap(int(fd), 0, int(fi.Size()), syscall.PROT_READ, syscall.MAP_SHARED)
})
if connErr != nil {
return nil, connErr
}
if err != nil {
return nil, err
}
mf := &MemoryMappedFile{data: data}
cleanup := func(data []byte) {
syscall.Munmap(data) // エラーは無視する
}
runtime.AddCleanup(mf, cleanup, data)
return mf, nil
}
メモリマップドファイルは、その内容がメモリ上に、この場合はバイトスライスの裏側にあるデータとしてマッピングされます。OSの機能のおかげで、このバイトスライスへの読み書きはファイルの内容に直接アクセスすることになります。このコードによって*MemoryMappedFileを自由に受け渡しでき、それが参照されなくなったときに、作成したメモリマッピングがクリーンアップされます。
runtime.AddCleanupは3つの引数を取ることに注目してください。クリーンアップを紐付ける変数のアドレス、クリーンアップ関数そのもの、そしてクリーンアップ関数への引数です。この関数とruntime.SetFinalizerとの重要な違いは、クリーンアップ関数がクリーンアップを紐付けたオブジェクトとは異なる引数を取るという点です。この変更によって、ファイナライザが抱えていたいくつかの問題が解決されています。
ファイナライザを正しく使うのが難しいことは周知の事実です。たとえば、ファイナライザが付与されたオブジェクトは、どのような参照サイクルにも関わってはいけません(自分自身へのポインタでさえ問題になります!)。そうしないと、オブジェクトが決して回収されず、ファイナライザも決して実行されず、リークが発生してしまいます。ファイナライザはまた、メモリの回収を大きく遅らせます。ファイナライズ対象のオブジェクトのメモリを回収するには、最低でもガベージコレクションが2サイクル完全に回る必要があります。1回目でそのオブジェクトが到達不可能であると判定し、2回目でファイナライザの実行後もなお到達不可能であることを判定するのです。
問題は、ファイナライザが自分の紐付いたオブジェクトを復活させてしまうという点です。ファイナライザはオブジェクトが到達不可能になるまで実行されず、その時点でオブジェクトは「死んでいる」とみなされています。しかしファイナライザはそのオブジェクトへのポインタとともに呼び出されるため、ガベージコレクタはそのオブジェクトのメモリの回収を防ぎ、代わりにファイナライザのために新しい参照を生成して、オブジェクトを再び到達可能、つまり「生きている」状態にしなければなりません。この参照は、たとえばファイナライザがそれをグローバル変数に書き込んだり、チャネル経由で送信したりすると、ファイナライザの実行が終わった後も残ってしまうことがあります。オブジェクトの復活が問題となるのは、そのオブジェクトと、それが指し示すすべてのもの、さらにそれらが指し示すすべてのもの、というように、本来ならガベージとして回収されていたはずのものまで到達可能になってしまうからです。
私たちはクリーンアップ関数に元のオブジェクトを渡さないようにすることで、この両方の問題を解決しました。まず、そのオブジェクトが参照する値をガベージコレクタが特別に到達可能な状態に保っておく必要がなくなるため、サイクルに関わっていてもオブジェクトを回収できます。次に、クリーンアップにオブジェクト自体が必要ないため、そのメモリを直ちに回収できます。
弱いポインタ
メモリマップドファイルの例に戻りましょう。プログラムが、互いの存在を知らない別々のゴルーチンから、同じファイルを何度も繰り返しマッピングしていることに気づいたとします。これらのマッピングはすべて物理メモリを共有するため、メモリの観点では問題ありませんが、ファイルのマップとアンマップのために不要なシステムコールが大量に発生してしまいます。各ゴルーチンがそれぞれのファイルのごく一部しか読まない場合には、これは特に厄介です。
そこで、ファイル名によってマッピングを重複排除しましょう。(ここでは、プログラムはマッピングから読み取るだけで、ファイル自体は作成後に変更されたりリネームされたりしないと仮定します。こうした仮定は、たとえばシステムのフォントファイルであれば妥当なものです。)
ファイル名からメモリマッピングへのマップを保持することもできますが、そうするとそのマップからいつエントリを取り除いても安全なのかがわからなくなってしまいます。マップのエントリ自体がメモリマップドファイルのオブジェクトを生かし続けてしまうという事実さえなければ、クリーンアップをほぼそのまま使えたはずです。
弱いポインタ(weak pointer)はこの問題を解決します。弱いポインタとは、オブジェクトが到達可能かどうかを判定する際にガベージコレクタが無視する特別な種類のポインタです。Go 1.24の新しい弱いポインタ型であるweak.PointerにはValueメソッドがあり、オブジェクトがまだ到達可能であれば実際のポインタを、そうでなければnilを返します。
代わりに、メモリマップドファイルを弱くしか指し示さないマップを保持すれば、誰もそれを使わなくなったときにマップのエントリをクリーンアップできます!実際にどうなるか見てみましょう。
var cache sync.Map // map[string]weak.Pointer[MemoryMappedFile]
func NewCachedMemoryMappedFile(filename string) (*MemoryMappedFile, error) {
var newFile *MemoryMappedFile
for {
// キャッシュから既存の値の読み込みを試みる。
value, ok := cache.Load(filename)
if !ok {
// 値が見つからなかった。必要であれば新しいマップドファイルを作成する。
if newFile == nil {
var err error
newFile, err = NewMemoryMappedFile(filename)
if err != nil {
return nil, err
}
}
// 新しいマップドファイルの登録を試みる。
wp := weak.Make(newFile)
var loaded bool
value, loaded = cache.LoadOrStore(filename, wp)
if !loaded {
runtime.AddCleanup(newFile, func(filename string) {
// 弱いポインタが等しい場合のみ削除する。等しくなければ、他の誰かが
// すでにエントリを削除して新しいマップドファイルを登録している。
cache.CompareAndDelete(filename, wp)
}, filename)
return newFile, nil
}
// 誰かが私たちより先にファイルの登録を済ませていた。
//
// 少し後で確認したときにまだそこにあれば、newFileは破棄し、
// ガベージコレクタによってクリーンアップされる。
}
// キャッシュのエントリが有効かどうか確認する。
if mf := value.(weak.Pointer[MemoryMappedFile]).Value(); mf != nil {
return mf, nil
}
// クリーンアップ待ちのnilエントリを発見した。積極的に削除する。
cache.CompareAndDelete(filename, value)
}
}
この例は少し複雑ですが、要点はシンプルです。まず、これまでに作成したすべてのマップドファイルのグローバルな並行マップを用意します。NewCachedMemoryMappedFileはこのマップに既存のマップドファイルがないか問い合わせ、なければ新しいマップドファイルを作成して挿入を試みます。これは他の挿入とのレースにより当然失敗することがあるので、そこにも注意を払い、リトライする必要があります。(この設計には、レースの結果、同じファイルを無駄に何度もマッピングしてしまい、NewMemoryMappedFileによって追加されたクリーンアップを通じて破棄しなければならないという欠点があります。ほとんどの場合、これは大きな問題にはならないでしょう。修正は読者への課題として残しておきます。)
このコードが活用している、弱いポインタとクリーンアップの便利な性質をいくつか見ていきましょう。
まず、弱いポインタは比較可能であることに注目してください。それだけでなく、弱いポインタは安定した独立の同一性を持っており、それが指し示すオブジェクトがとうに消え去った後でもその同一性は残ります。これが、クリーンアップ関数の中でweak.Pointerを比較するsync.MapのCompareAndDeleteを安全に呼び出せる理由であり、このコードがそもそも成立している決定的な理由でもあります。
次に、1つのMemoryMappedFileオブジェクトに対して複数の独立したクリーンアップを追加できることに注目してください。これによりクリーンアップを組み合わせ可能な形で使え、汎用的なデータ構造を構築するのにも使えます。この例に限って言えば、NewCachedMemoryMappedFileとNewMemoryMappedFileを統合してクリーンアップを共有させたほうが効率的かもしれません。しかし、上で書いたコードの利点は、それをジェネリックな形に書き換えられるという点にあります!
type Cache[K comparable, V any] struct {
create func(K) (*V, error)
m sync.Map
}
func NewCache[K comparable, V any](create func(K) (*V, error)) *Cache[K, V] {
return &Cache[K, V]{create: create}
}
func (c *Cache[K, V]) Get(key K) (*V, error) {
var newValue *V
for {
// キャッシュから既存の値の読み込みを試みる。
value, ok := cache.Load(key)
if !ok {
// 値が見つからなかった。必要であれば新しいマップドファイルを作成する。
if newValue == nil {
var err error
newValue, err = c.create(key)
if err != nil {
return nil, err
}
}
// 新しいマップドファイルの登録を試みる。
wp := weak.Make(newValue)
var loaded bool
value, loaded = cache.LoadOrStore(key, wp)
if !loaded {
runtime.AddCleanup(newValue, func(key K) {
// 弱いポインタが等しい場合のみ削除する。等しくなければ、他の誰かが
// すでにエントリを削除して新しいマップドファイルを登録している。
cache.CompareAndDelete(key, wp)
}, key)
return newValue, nil
}
}
// キャッシュのエントリが有効かどうか確認する。
if mf := value.(weak.Pointer[V]).Value(); mf != nil {
return mf, nil
}
// クリーンアップ待ちのnilエントリを発見した。積極的に削除する。
cache.CompareAndDelete(key, value)
}
}
注意点と今後の課題
最善を尽くしたつもりですが、それでもクリーンアップと弱いポインタはエラーの原因になりがちです。ファイナライザ、クリーンアップ、弱いポインタの利用を検討している人たちの助けになるよう、私たちは最近ガベージコレクタガイドを更新し、これらの機能を使う際のアドバイスを追加しました。次にこれらの機能に手を伸ばすときにはぜひ目を通してほしいのですが、そもそもそれらを使う必要があるのかどうかも慎重に検討してください。これらは繊細な意味論を持つ高度なツールであり、ガイドにもあるとおり、ほとんどのGoコードはこれらの機能を直接使うのではなく、間接的な形で恩恵を受けています。これらの機能が輝くユースケースに絞って使うようにすれば、大丈夫でしょう。
ここでは、遭遇する可能性が比較的高い問題をいくつか挙げておきます。
まず、クリーンアップが紐付けられたオブジェクトは、クリーンアップ関数から(キャプチャされた変数として)も、クリーンアップ関数への引数からも、到達可能であってはいけません。どちらの状況でも、クリーンアップは決して実行されなくなります。(クリーンアップの引数がruntime.AddCleanupに渡したポインタそのものであるという特殊なケースでは、runtime.AddCleanupはパニックを起こします。これは呼び出し元に対して、クリーンアップをファイナライザと同じような使い方をすべきではないと伝えるシグナルです。)
次に、弱いポインタをマップのキーとして使う場合、弱く参照されているオブジェクトは対応するマップの値から到達可能であってはいけません。そうでなければ、そのオブジェクトは生き続けてしまいます。弱いポインタについてのブログ記事を読み込んでいる最中であれば当たり前に思えるかもしれませんが、見落としやすい微妙な点です。この問題こそが、それを解決するためのエフェメロンという概念全体のきっかけとなっており、これは将来の方向性の1つとなり得ます。
3つ目に、クリーンアップにおいてよくあるパターンとして、ここでのMemoryMappedFileの例のようにラッパーオブジェクトが必要になる点があります。この特定のケースでは、ガベージコレクタがマップされたメモリ領域を直接追跡し、内部の[]byteをそのまま受け渡せるようにすることも考えられます。そのような機能は今後の作業として実現可能であり、そのためのAPIが最近提案されました。
最後に、弱いポインタとクリーンアップはどちらも原理的に非決定的であり、その挙動はガベージコレクタの設計と動作に密接に依存しています。クリーンアップのドキュメントでは、ガベージコレクタがクリーンアップを一切実行しないことすら許容されています。これらを使うコードをうまくテストするのは一筋縄ではいきませんが、不可能ではありません。
なぜ今なのか
弱いポインタは、Goがほぼ誕生した頃からその機能として話題に上がっていましたが、長年Goチームによって優先度高く扱われることはありませんでした。その理由の1つは、弱いポインタが繊細な存在であり、その設計空間が、使い方をさらに難しくしかねない決定に満ちた地雷原のようなものだからです。もう1つの理由は、弱いポインタが言語に複雑さを加える一方で、ニッチなツールでもあるという点です。私たちはすでにSetFinalizerの使いにくさを痛感していました。しかし、弱いポインタなしには表現できない有用なプログラムも存在し、uniqueパッケージとその存在理由が、そのことをまさに浮き彫りにしました。
ジェネリクス、ファイナライザから得た教訓、そしてC#やJavaなど他言語のチームによってこれまでになされてきたすばらしい仕事から得た知見によって、弱いポインタとクリーンアップの設計は素早くまとまりました。弱いポインタをファイナライザと組み合わせて使いたいという要望からさらなる疑問が生まれ、そのおかげでruntime.AddCleanupの設計も同様に素早くまとまりました。
謝辞
プロポーザルのイシューにフィードバックを寄せ、機能が利用可能になってからバグを報告してくれたコミュニティの皆さんに感謝します。また、弱いポインタの意味論について私と一緒に徹底的に考え抜いてくれたDavid Chase、そしてruntime.AddCleanupの設計を手伝ってくれた彼とRuss Cox、Austin Clementsにも感謝します。runtime.AddCleanupをGo 1.24向けに実装し、磨き上げ、取り込むまでの作業をしてくれたCarlos Amedeeに感謝します。そして最後に、Go 1.25に向けて標準ライブラリ全体でruntime.SetFinalizerをruntime.AddCleanupに置き換える作業をしてくれたCarlos AmedeeとIan Lance Taylorに感謝します。
By Michael Knyszek