Goの`sync.Once`の力を解き放つ:単一実行を保証する
Daniel Hayes
Full-Stack Engineer · Leapcell

Goの標準ライブラリは、よく設計された並行処理プリミティブの宝庫であり、特にsync
パッケージは、堅牢でスレッドセーフなアプリケーションを構築するための礎石として際立っています。その様々な提供物の中で、sync.Once
は、特定のコードブロックが、いくつのゴルーチンが同時に呼び出そうとしても、正確に一度だけ実行されることを保証するように設計された、特にエレガントで強力な構造です。
問題:一度だけの初期化
アプリケーションのライフサイクル全体で、グローバルリソースを初期化したり、設定を一度だけロードしたりする必要があるシナリオを考えてみてください。このリソースは、データベース接続プール、作成にコストのかかるオブジェクト、またはHTTPクライアントかもしれません。適切な同期なしに、複数のゴルーチンがこのリソースに同時にアクセスまたは初期化しようとすると、次のような事態につながる可能性があります。
- 冗長な初期化: リソースが複数回初期化され、計算リソースを浪費し、一貫性のない状態につながる可能性があります。
- 競合状態: 初期化が共有状態の変更を伴う場合、同期なしの同時アクセスはデータ破損につながる可能性があります。
単純なアプローチとしては、グローバルなブールフラグとミューテックスを使用することが考えられます。
package main import ( "fmt" "sync" "time" ) var ( initialized bool mu sync.Mutex config string ) func initConfigNaive() { mu.Lock() defer mu.Unlock() if !initialized { fmt.Println("Initializing configuration (naive approach)...") time.Sleep(100 * time.Millisecond) // 高価な初期化をシミュレート config = "Loaded Global Config" initialized = true fmt.Println("Configuration initialized.") } else { fmt.Println("Configuration already initialized, skipping.") } } func main() { var wg sync.WaitGroup for i := 0; i < 5; i++ { wg.Add(1) go func(id int) { defer wg.Done() fmt.Printf("Goroutine %d trying to get config...\n", id) initConfigNaive() fmt.Printf("Goroutine %d got config: %s\n", id, config) }(i) } wg.Wait() fmt.Println("All goroutines finished.") }
この「単純な」アプローチは機能しますが、冗長でエラーが発生しやすいです。フラグ、ミューテックスを宣言し、毎回 if !initialized
チェックを実装することを覚えておく必要があります。これはまさに sync.Once
がエレガントに解決する問題です。
sync.Once
の登場:シンプルさと保証
sync.Once
型は、関数を引数として取るシンプルな Do
メソッドを提供します。sync.Once
の魔法は、その Do
メソッドに渡された関数が、たとえ複数のゴルーチンから同時に Do
が呼び出されたとしても、正確に一度だけ実行されることを保証することです。Do
の後続の呼び出しは何もしませんが、最初の実行が進行中の場合は完了するまで待機します。
sync.Once
変数は、一度使用された後はコピーしてはなりません。通常は構造体に埋め込まれるか、グローバル変数として使用されます。そのゼロ値は使用準備ができています。
sync.Once
を使用して設定の初期化をリファクタリングしてみましょう。
package main import ( "fmt" "sync" "time" ) var ( once sync.Once config string // グローバル設定 ) // initConfigOnce は高価な一度だけの初期化をシミュレートします。 func initConfigOnce() { fmt.Println("Initializing configuration (using sync.Once)...") time.Sleep(100 * time.Millisecond) // 重い処理をシミュレート config = "Secret Application Config" fmt.Println("Configuration initialized.") } // GetConfig は initConfigOnce が一度だけ呼び出されることを保証します。 func GetConfig() string { once.Do(initConfigOnce) // この行が initConfigOnce の実行を一度だけに保証します。 return config } func main() { var wg sync.WaitGroup fmt.Println("Starting concurrent attempts to get config...") // 複数のゴルーチンを起動して GetConfig を同時に呼び出します for i := 0; i < 5; i++ { wg.Add(1) go func(id int) { defer wg.Done() fmt.Printf("Goroutine %d trying to get config...\n", id) c := GetConfig() // すべての呼び出しは once.Do を通過します fmt.Printf("Goroutine %d got config: %s\n", id, c) }(i) } wg.Wait() fmt.Println("\nAll goroutines finished. Config value is:", config) // GetConfig の後続の呼び出しは initConfigOnce を再実行しません fmt.Println("\nCalling GetConfig again (should not re-initialize):") c := GetConfig() fmt.Println("Second call got config:", c) }
このコードを実行すると、GetConfig()
が複数回同時に呼び出されたとしても、「Initializing configuration (using sync.Once)...」と「Configuration initialized.」のメッセージが一度だけ表示されることがわかります。最初の初期化が成功した後、GetConfig()
の後続のすべての呼び出しは、initConfigOnce()
を再実行せずに config
値をすぐに返します。
内部の仕組み:sync.Once
はどのように動作するか(簡略化)
sync.Once
の内部実装はもう少しニュアンスがあり、パフォーマンスのために最適化されています(特にアトミック操作を使用)。概念的には、私たちの initialized
フラグと sync.Mutex
の例と非常によく似ていますが、重要な違いがあります。
- アトミック操作:
sync.Once
は通常、アトミック操作(sync/atomic.LoadUint32
やsync/atomic.CompareAndSwapUint32
など)を利用して、関数が既に実行されたかどうかを確認します。これにより、チェックが非常に高速になり、最初の実行後のすべてのチェックで完全なミューテックスロック/アンロックのオーバーヘッドが回避されます。 - 最初の実行のためのミューテックス: 関数がまだ実行されていないことを検出した最初の呼び出しのために、
sync.Mutex
(または同様の同期プリミティブ)を取得して、1つのゴルーチンだけが実際の初期化を実行することを保証します。 - 状態管理: 内部フィールド(通常は整数またはブール値)が実行状態を追跡します。関数が正常に完了すると、この状態はアトミックに更新され、完了したことを示します。
このパターンにより、sync.Once
は非常に効率的になります。後続の呼び出しは、関数が既に実行されたことを示すために高速なアトミック読み取りのみを含み、オーバーヘッドを最小限に抑えます。
sync.Once
のユースケース
sync.Once
は、単一回の実行が必要なさまざまなシナリオに最適です。
- グローバルリソースの初期化: データベース接続プール、アプリケーション全体の構成のロード、ロギングシステムのセットアップ。
- 遅延初期化: 高価なオブジェクトは、最初に必要になったときにのみ初期化します。
- シングルトンパターンの実装: Goには従来のクラスはありませんが、
sync.Once
は「サービス」や「マネージャー」のインスタンスが一度だけ作成されることを保証するのに最適です。
例:シングルトンデータベース接続
package main import ( "fmt" "sync" "time" ) // DBClient はシミュレートされたデータベースクライアントを表します。 type DBClient struct { Name string } func (db *DBClient) Query(sql string) string { return fmt.Sprintf("Executing query '%s' on %s", sql, db.Name) } var ( dbOnce sync.Once dbConnection *DBClient ) func createDBConnection() { fmt.Println("Establishing database connection...") time.Sleep(500 * time.Millisecond) // 接続設定時間をシミュレート dbConnection = &DBClient{Name: "PostgresDB"} fmt.Println("Database connection established.") } // GetDBClient はシングルトンデータベースクライアントを提供します。 func GetDBClient() *DBClient { dbOnce.Do(createDBConnection) return dbConnection } func main() { var wg sync.WaitGroup fmt.Println("Multiple goroutines attempting to get DB client:") for i := 0; i < 3; i++ { wg.Add(1) go func(id int) { defer wg.Done() fmt.Printf("Goroutine %d requesting DB client...\n", id) client := GetDBClient() fmt.Printf("Goroutine %d received client: %p, query result: %s\n", id, client, client.Query(fmt.Sprintf("SELECT * FROM users WHERE id=%d", id))) }(i) } wg.Wait() fmt.Println("\nAll goroutines finished. Verifying client instance:") client1 := GetDBClient() client2 := GetDBClient() fmt.Printf("Client 1 address: %p\n", client1) fmt.Printf("Client 2 address: %p\n", client2) fmt.Println("Are clients identical?", client1 == client2) // true になるはずです }
この例は、sync.Once
を単一の共有データベース接続を管理するためにどのように使用できるか、冗長な接続試行を防ぎ、アプリケーションのすべての部分が同じインスタンスを使用することを保証するかを明確に示しています。
重要な考慮事項
- パニック処理:
Do
に渡された関数がパニックした場合、sync.Once
は呼び出しが完了したと見なし、後続の呼び出しで関数を再実行しません。これは通常、回復不能な初期化エラーの望ましい動作です。ただし、パニック後に初期化を再試行する必要がある場合は、sync.Once
は適切なツールではありません。より複雑な状態管理システムが必要になります。 - 冪等性:
Do
に渡される関数は、理想的には冪等であるべきです。つまり、(sync.Once
が実際の再実行を防いだとしても)複数回呼び出しても、仮に再実行されたとしても副作用がないことを意味します。これはコードの推論に役立ちます。 - 初期化と継続的なロジック:
sync.Once
は厳密には一度だけの初期化用です。継続的な操作中に共有状態を保護するための汎用的な同期メカニズムではありません。そのためには、sync.Mutex
、sync.RWMutex
、またはチャネルを使用します。
結論
sync.Once
は、Go の哲学であるシンプルかつ強力な並行処理プリミティブを提供する優れた例です。アトミック操作、フラグ管理、同期待機の複雑さを抽象化することにより、開発者はコードブロックが正確に一度だけ実行されることを簡単に保証できます。そのエレガントさと効率性により、Go 開発者のツールキットにおいて、堅牢でパフォーマンスの高い並行アプリケーションを構築するための不可欠なツールとなっています。単一の決定的な実行が必要な場合は sync.Once
を採用してください。これにより、コードはよりクリーンで、安全で、よりイディオマティックになります。