The Go Blog

ユニークからクリーンアップ、そしてウィークまで:効率性のための新しい低レベルツール

Michael Knyszek
2025年3月6日

昨年公開した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()

    // Get the file's info; we need its size.
    fi, err := f.Stat()
    if err != nil {
        return nil, err
    }

    // Extract the file descriptor.
    conn, err := f.SyscallConn()
    if err != nil {
        return nil, err
    }
    var data []byte
    connErr := conn.Control(func(fd uintptr) {
        // Create a memory mapping backed by this file.
        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) // ignore error
    }
    runtime.AddCleanup(mf, cleanup, data)
    return mf, nil
}

メモリマップトファイルは、その内容がメモリにマップされます。この場合、バイトスライスの基礎となるデータです。オペレーティングシステムの魔法のおかげで、バイトスライスへの読み書きはファイルの内容に直接アクセスします。このコードを使用すると、*MemoryMappedFileを渡すことができ、それが参照されなくなると、作成したメモリマッピングがクリーンアップされます。

runtime.AddCleanupは、クリーンアップをアタッチする変数のアドレス、クリーンアップ関数自体、およびクリーンアップ関数への引数という3つの引数を取ることに注意してください。この関数とruntime.SetFinalizerの主な違いは、クリーンアップ関数が、クリーンアップをアタッチするオブジェクトとは異なる引数を取ることです。この変更により、ファイナライザの問題がいくつか解決されます。

ファイナライザを正しく使用するのが難しいことは周知の事実です。たとえば、ファイナライザがアタッチされたオブジェクトは、いかなる参照サイクルにも関与してはなりません(自己へのポインタでさえ多すぎます!)。そうでなければ、オブジェクトは決して回収されず、ファイナライザも実行されず、リークの原因となります。ファイナライザはメモリの回収も大幅に遅らせます。ファイナライズされたオブジェクトのメモリを回収するには、最低2回の完全なガベージコレクションサイクルが必要です。1回は到達不能であることを判断するため、もう1回はファイナライザの実行後もまだ到達不能であることを判断するためです。

問題は、ファイナライザがアタッチされたオブジェクトを復活させることです。ファイナライザはオブジェクトが到達不可能になるまで実行されず、その時点でオブジェクトは「死んだ」と見なされます。しかし、ファイナライザはオブジェクトへのポインタで呼び出されるため、ガベージコレクタはそのオブジェクトのメモリの回収を妨げ、代わりにファイナライザのための新しい参照を生成して、再び到達可能、つまり「生きている」状態にする必要があります。その参照は、ファイナライザが戻った後も残る可能性があり、たとえばファイナライザがそれをグローバル変数に書き込んだり、チャネルを介して送信したりする場合です。オブジェクトの復活は問題です。なぜなら、そうでなければガベージとして収集されたはずのオブジェクト、およびそれが指すすべてのもの、それらのオブジェクトが指すすべてのものなどが到達可能であることを意味するからです。

これらの問題は、元のオブジェクトをクリーンアップ関数に渡さないことで両方とも解決されます。まず、オブジェクトが参照する値はガベージコレクタによって特別に到達可能に保つ必要がないため、オブジェクトがサイクルに関与していても回収できます。次に、クリーンアップにオブジェクトが必要ないため、そのメモリはすぐに回収できます。

弱ポインタ

メモリマップトファイルの例に戻りましょう。プログラムが、互いに認識しない異なるゴルーチンから、同じファイルを繰り返しマップしていることに気づいたとします。これはメモリの観点からは問題ありません。これらのすべてのマッピングは物理メモリを共有するためです。しかし、ファイルのマッピングとアンマッピングのために多くの不要なシステムコールが発生します。これは、各ゴルーチンが各ファイルの小さなセクションしか読み取らない場合に特に問題です。

そこで、ファイル名ごとにマッピングを重複排除しましょう。(プログラムはマッピングから読み取るだけで、ファイル自体は作成後に変更または名前変更されないと仮定しましょう。このような仮定は、たとえばシステムフォントファイルでは合理的です。)

ファイル名からメモリマッピングへのマップを維持することもできますが、そのマップからエントリを安全に削除できる時期が不明確になります。マップエントリ自体がメモリマップトファイルオブジェクトを生き残らせてしまうという事実がなければ、クリーンアップをほぼ使用できたでしょう。

弱ポインタがこの問題を解決します。弱ポインタは、オブジェクトが到達可能かどうかを判断する際にガベージコレクタが無視する特殊な種類のポインタです。Go 1.24の新しい弱ポインタ型、weak.Pointerには、オブジェクトがまだ到達可能であれば実際のポインタを返し、到達不可能であればnilを返すValueメソッドがあります。

代わりに、メモリマップトファイルを弱くしか指さないマップを維持すれば、誰も使用しなくなったときにマップエントリをクリーンアップできます!これがどのように見えるか見てみましょう。

var cache sync.Map // map[string]weak.Pointer[MemoryMappedFile]

func NewCachedMemoryMappedFile(filename string) (*MemoryMappedFile, error) {
    var newFile *MemoryMappedFile
    for {
        // Try to load an existing value out of the cache.
        value, ok := cache.Load(filename)
        if !ok {
            // No value found. Create a new mapped file if needed.
            if newFile == nil {
                var err error
                newFile, err = NewMemoryMappedFile(filename)
                if err != nil {
                    return nil, err
                }
            }

            // Try to install the new mapped file.
            wp := weak.Make(newFile)
            var loaded bool
            value, loaded = cache.LoadOrStore(filename, wp)
            if !loaded {
                runtime.AddCleanup(newFile, func(filename string) {
                    // Only delete if the weak pointer is equal. If it's not, someone
                    // else already deleted the entry and installed a new mapped file.
                    cache.CompareAndDelete(filename, wp)
                }, filename)
                return newFile, nil
            }
            // Someone got to installing the file before us.
            //
            // If it's still there when we check in a moment, we'll discard newFile
            // and it'll get cleaned up by garbage collector.
        }

        // See if our cache entry is valid.
        if mf := value.(weak.Pointer[MemoryMappedFile]).Value(); mf != nil {
            return mf, nil
        }

        // Discovered a nil entry awaiting cleanup. Eagerly delete it.
        cache.CompareAndDelete(filename, value)
    }
}

この例は少し複雑ですが、要点は単純です。まず、作成したすべてのマップ済みファイルのグローバルな並行マップから始めます。NewCachedMemoryMappedFileは、既存のマップ済みファイルをこのマップで参照し、それが失敗した場合は、新しいマップ済みファイルを作成して挿入しようとします。もちろん、他の挿入と競合しているため、これも失敗する可能性があります。そのため、それにも注意し、再試行する必要があります。(この設計には、競合中に同じファイルを複数回無駄にマップしてしまう可能性があるという欠陥があり、NewMemoryMappedFileによって追加されたクリーンアップを介してそれを破棄する必要があります。これはほとんどの場合、大きな問題ではないでしょう。修正は読者の演習として残されています。)

このコードで活用されている、弱ポインタとクリーンアップのいくつかの有用なプロパティを見てみましょう。

まず、弱ポインタは比較可能であることに注意してください。それだけでなく、弱ポインタは安定した独立した識別子を持ち、それが指すオブジェクトがなくなってからも残ります。これが、クリーンアップ関数がsync.MapCompareAndDeleteを呼び出し、weak.Pointerを比較するのが安全である理由であり、このコードが機能する重要な理由です。

次に、単一のMemoryMappedFileオブジェクトに複数の独立したクリーンアップを追加できることに注目してください。これにより、クリーンアップを構成可能な方法で使用し、汎用データ構造を構築するために使用できます。この特定の例では、NewCachedMemoryMappedFileNewMemoryMappedFileを組み合わせて、クリーンアップを共有する方が効率的かもしれません。ただし、上記のコードの利点は、汎用的な方法で書き直せることです!

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 {
        // Try to load an existing value out of the cache.
        value, ok := cache.Load(key)
        if !ok {
            // No value found. Create a new mapped file if needed.
            if newValue == nil {
                var err error
                newValue, err = c.create(key)
                if err != nil {
                    return nil, err
                }
            }

            // Try to install the new mapped file.
            wp := weak.Make(newValue)
            var loaded bool
            value, loaded = cache.LoadOrStore(key, wp)
            if !loaded {
                runtime.AddCleanup(newValue, func(key K) {
                    // Only delete if the weak pointer is equal. If it's not, someone
                    // else already deleted the entry and installed a new mapped file.
                    cache.CompareAndDelete(key, wp)
                }, key)
                return newValue, nil
            }
        }

        // See if our cache entry is valid.
        if mf := value.(weak.Pointer[V]).Value(); mf != nil {
            return mf, nil
        }

        // Discovered a nil entry awaiting cleanup. Eagerly delete it.
        cache.CompareAndDelete(key, value)
    }
}

注意事項と今後の作業

私たちの努力にもかかわらず、クリーンアップと弱ポインタは依然としてエラーを起こしやすい可能性があります。ファイナライザ、クリーンアップ、弱ポインタの使用を検討している方々をガイドするために、最近ガベージコレクタガイドを更新し、これらの機能の使用に関するいくつかのアドバイスを追加しました。次回それらを使用するときは確認してください。ただし、そもそもそれらを使用する必要があるかどうかを慎重に検討してください。これらは微妙なセマンティクスを持つ高度なツールであり、ガイドに記載されているように、ほとんどのGoコードはこれらの機能を直接使用するのではなく、間接的に利用しています。これらの機能が輝くユースケースにこだわり、問題なく利用できるでしょう。

今のところ、発生しやすい問題のいくつかを取り上げます。

まず、クリーンアップがアタッチされているオブジェクトは、クリーンアップ関数(キャプチャされた変数として)からも、クリーンアップ関数への引数からも到達可能であってはなりません。これらの状況のいずれも、クリーンアップが実行されない結果となります。(クリーンアップ引数がruntime.AddCleanupに渡されたポインタと完全に同じである特殊なケースでは、runtime.AddCleanupはパニックを起こします。これは、呼び出し元がファイナライザと同じ方法でクリーンアップを使用すべきではないというシグナルです。)

次に、弱ポインタがマップキーとして使用される場合、弱参照されているオブジェクトは対応するマップ値から到達可能であってはなりません。そうでなければ、オブジェクトは生き残り続けます。弱ポインタに関するブログ記事の奥深くでは自明に思えるかもしれませんが、見落としやすい微妙な点です。この問題は、それを解決するためのエフェメロンという概念全体を発想させました。これは将来の方向性となる可能性があります。

第三に、クリーンアップの一般的なパターンは、私たちのMemoryMappedFileの例のように、ラッパーオブジェクトが必要となることです。この特定のケースでは、ガベージコレクタがマップされたメモリ領域を直接追跡し、内部の[]byteを渡すことを想像できます。このような機能は将来の作業となる可能性があり、そのAPIは最近提案されました

最後に、弱ポインタとクリーンアップの両方は本質的に非決定論的であり、その動作はガベージコレクタの設計とダイナミクスに密接に依存します。クリーンアップのドキュメントでは、ガベージコレクタがクリーンアップをまったく実行しないことも許可されています。それらを使用するコードを効果的にテストするのは難しい場合がありますが、可能です

なぜ今なのか?

弱ポインタはGoの最初期から機能として提起されてきましたが、何年もの間、Goチームによって優先されていませんでした。その理由の1つは、それらが微妙であり、弱ポインタの設計空間が、使用をさらに困難にする可能性のある決定の地雷原であることです。もう1つは、弱ポインタはニッチなツールであると同時に、言語に複雑さを加えることです。私たちはすでにSetFinalizerがいかに使用が難しいかという経験がありました。しかし、それなしでは表現できない有用なプログラムがいくつかあり、uniqueパッケージとその存在理由はそれを本当に強調していました。

ジェネリクス、ファイナライザの教訓、C#やJavaのような他の言語のチームによって行われたすべての素晴らしい作業からの洞察により、弱ポインタとクリーンアップの設計は迅速にまとまりました。ファイナライザで弱ポインタを使用したいという要望はさらなる疑問を提起し、そのためruntime.AddCleanupの設計も迅速にまとまりました。

謝辞

提案の課題にフィードバックを提供し、機能が利用可能になったときにバグを報告してくださったコミュニティの皆様に感謝いたします。デビッド・チェイスには、弱ポインタのセマンティクスを私と一緒に徹底的に検討してくださったことに感謝し、彼、ラス・コックス、オースティン・クレメンツには、runtime.AddCleanupの設計にご協力いただいたことに感謝いたします。カルロス・アメディーには、runtime.AddCleanupの実装、洗練、Go 1.24への導入にご尽力いただいたことに感謝いたします。そして最後に、カルロス・アメディーとイアン・ランス・テイラーには、Go 1.25の標準ライブラリ全体でruntime.SetFinalizerruntime.AddCleanupに置き換える作業にご尽力いただいたことに感謝いたします。

次の記事: トラバーサル耐性のあるファイルAPI
前の記事: スイステーブルによるGoマップの高速化
ブログインデックス