Go の Once で初期化処理を一度だけ実行する

アプリケーションの初期化処理は一度だけ実行したいものです。データベース接続の確立、設定ファイルの読み込み、シングルトンの生成など、複数回実行すると問題になる処理があります。sync.Once はそのような処理を goroutine-safe に一度だけ実行することを保証します。

sync.Once の基本

sync.OnceDo メソッドを持ち、渡された関数を最初の 1 回だけ実行します。

package main

import (
    "fmt"
    "sync"
)

func main() {
    var once sync.Once

    for i := 0; i < 5; i++ {
        once.Do(func() {
            fmt.Println("初期化処理")
        })
        fmt.Println("ループ:", i)
    }
}

出力は以下のようになります。「初期化処理」は 1 回しか表示されません。

初期化処理
ループ: 0
ループ: 1
ループ: 2
ループ: 3
ループ: 4

複数の goroutine からの呼び出し

sync.Once の真価は複数の goroutine から同時に呼び出されても安全な点です。

func main() {
    var once sync.Once
    var wg sync.WaitGroup

    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            once.Do(func() {
                fmt.Println("初期化実行者:", id)
            })
            fmt.Println("goroutine:", id)
        }(i)
    }

    wg.Wait()
}

10 個の goroutine が同時に once.Do を呼び出しても、初期化処理は 1 回だけ実行されます。どの goroutine が実行者になるかは不定ですが、他の goroutine は初期化が完了するまで待機します。

シングルトンパターン

sync.Once はシングルトンの実装によく使われます。

type Database struct {
    connection string
}

var (
    dbInstance *Database
    dbOnce     sync.Once
)

func GetDatabase() *Database {
    dbOnce.Do(func() {
        fmt.Println("データベース接続を確立")
        dbInstance = &Database{
            connection: "host=localhost port=5432",
        }
    })
    return dbInstance
}

func main() {
    var wg sync.WaitGroup

    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            db := GetDatabase()
            fmt.Println("取得:", db.connection)
        }()
    }

    wg.Wait()
}

何度 GetDatabase() を呼んでも、データベース接続は 1 回だけ確立されます。

設定ファイルの遅延読み込み

起動時ではなく、必要になったときに設定を読み込む「遅延初期化」にも使えます。

type Config struct {
    APIKey   string
    Endpoint string
}

var (
    config     *Config
    configOnce sync.Once
)

func GetConfig() *Config {
    configOnce.Do(func() {
        fmt.Println("設定ファイルを読み込み中...")
        // 実際にはファイルから読み込む
        config = &Config{
            APIKey:   "secret-key",
            Endpoint: "https://api.example.com",
        }
    })
    return config
}

設定が必要になるまで読み込みを遅延させることで、使われない設定の読み込みコストを削減できます。

注意点

sync.Once にはいくつかの注意点があります。

Do に渡す関数は引数を取れない

クロージャを使って外部の変数をキャプチャする必要があります。

パニックしても「実行済み」扱い

Do 内でパニックが発生しても、2 回目以降は実行されません。初期化のエラーハンドリングには別の仕組みが必要です。

パニック時の挙動は要注意です。初期化に失敗した場合でも再試行されないため、エラー処理を慎重に設計する必要があります。

エラーを扱うパターン

初期化時のエラーを扱うには、結果を変数に保存するパターンが使えます。

var (
    client    *http.Client
    clientErr error
    clientOnce sync.Once
)

func GetClient() (*http.Client, error) {
    clientOnce.Do(func() {
        // 初期化処理(エラーが発生する可能性あり)
        client = &http.Client{Timeout: 10 * time.Second}
        // clientErr = errors.New("初期化失敗") // エラー時
    })
    return client, clientErr
}

このパターンでは、初期化が成功したか失敗したかを呼び出し元で判断できます。ただし、失敗時の再試行はできません。再試行が必要な場合は、sync.Once 以外の仕組み(Mutex + フラグなど)を検討してください。