🔍 Go 並行処理の本質:sync.Once ファミリー完全ガイド
Go の並行プログラミングにおいて、ある操作が一度だけ実行されることを保証することは一般的な要件です。標準ライブラリの軽量な同期プリミティブである sync.Once は、極めてシンプルな設計でこの問題を解決します。この記事では、この強力なツールの使い方と原理を詳しく理解していきましょう。
🎯 sync.Once とは?
sync.Once は Go 言語の sync パッケージに含まれる同期プリミティブです。その核心的な機能は、プログラムのライフサイクル中に特定の操作が一度だけ実行されることを保証することであり、いくつのゴルーチンが同時に呼び出しても同じ結果が得られます。
公式の定義は簡潔かつ強力です:
Once は特定の操作が一度だけ実行されることを保証するオブジェクトです。
Once オブジェクトが初めて使用された後は、コピーしてはなりません。
f 関数の戻り値は、once.Do(f) の呼び出しの戻り値「より前に同期」されます。
最後の点は、f の実行が完了した後、その結果は once.Do(f) を呼び出すすべてのゴルーチンから可視になり、メモリの一貫性が保証されることを意味します。
💡 典型的な使用シナリオ
- シングルトンパターン:データベース接続プール、設定ロードなどが一度だけ初期化されることを保証
- 遅延ローディング:必要なときにだけリソースをロードし、かつ一度だけ
- 並行安全な初期化:マルチゴルーチン環境での安全な初期化
🚀 クイックスタート
sync.Once の使い方は極めて簡単で、コアとなるのは Do メソッドだけです:
package main
import (
"fmt"
"sync"
)
func main() {
var once sync.Once
onceBody := func() {
fmt.Println("Only once")
}
// 10 個のゴルーチンを起動して並行呼び出し
done := make(chan bool)
for i := 0; i < 10; i++ {
go func() {
once.Do(onceBody)
done <- true
}()
}
// すべてのゴルーチンが完了するのを待つ
for i := 0; i < 10; i++ {
<-done
}
}
実行結果は常に:
Only once
単一のゴルーチン内で複数回呼び出しても同じ結果で、関数は一度だけ実行されます。
🔍 ソースコード詳細分析
sync.Once のソースコードは非常に簡潔で(コメントを含めてわずか78行)、しかし精巧な設計が含まれています:
type Once struct {
done atomic.Uint32 // 操作が実行されたかどうかを識別
m Mutex // ミューテックスロック
}
func (o *Once) Do(f func()) {
if o.done.Load() == 0 {
o.doSlow(f) // スローパス、ファストパスのインライン化を許可
}
}
func (o *Once) doSlow(f func()) {
o.m.Lock()
defer o.m.Unlock()
if o.done.Load() == 0 {
defer o.done.Store(1)
f()
}
}
設計のハイライト:
-
ダブルチェックロッキング:
- 最初のチェック(ロックなし):すでに実行されたかどうかをすばやく判断
- 2回目のチェック(ロック後):並行安全性を保証
-
性能最適化:
- done フィールドを構造体の先頭に配置し、ポインタオフセット計算を減らす
- ファストパスとスローパスを分離することで、ファストパスのインライン化最適化を可能に
- 初回実行時にだけロックが必要で、その後の呼び出しはオーバーヘッドゼロ
-
なぜ CAS で実装しないのか?:
コメントに明確に説明があります:単純な CAS では、f の実行完了後にだけ結果が返されることを保証できず、他のゴルーチンが未完成の結果を取得する可能性があります。
⚠️ 注意事項
-
コピー不可:Once には noCopy フィールドが含まれており、初回使用後にコピーすると未定義の動作を引き起こす
// 誤った例 var once sync.Once once2 := once // コンパイルはエラーを報告しませんが、実行時に問題が発生する可能性があります
-
再帰呼び出しを避ける:f の中で再度 once.Do(f) を呼び出すと、デッドロックを引き起こします
-
パニック処理:f の中でパニックが発生した場合、実行済みと見なされ、その後の呼び出しでは f は実行されなくなります
✨ Go 1.21 の新機能
Go 1.21 では sync.Once ファミリーに3つの実用的な関数が追加され、その機能が拡張されました:
1. OnceFunc:パニック処理付きの単一実行関数
func OnceFunc(f func()) func()
特徴:
- f を一度だけ実行する関数を返す
- f がパニックを起こした場合、返された関数は各呼び出しで同じ値でパニックを起こす
- 並行安全
例:
package main
import (
"fmt"
"sync"
)
func main() {
// 一度だけ実行される関数を作成
initialize := sync.OnceFunc(func() {
fmt.Println("Initialization completed")
})
// 並行呼び出し
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
initialize()
}()
}
wg.Wait()
}
ネイティブの once.Do と比較:f がパニックを起こした場合、OnceFunc は各呼び出しで同じ値でパニックを起こしますが、ネイティブの Do は初回のみパニックを起こします。
2. OnceValue:単一計算と戻り値を持つ関数
func OnceValue[T any](f func() T) func() T
結果の計算とキャッシュが必要なシナリオに適しています:
package main
import (
"fmt"
"sync"
)
func main() {
// 一度だけ計算される関数を作成
calculate := sync.OnceValue(func() int {
fmt.Println("Start complex calculation")
sum := 0
for i := 0; i < 1000000; i++ {
sum += i
}
return sum
})
// 複数回呼び出し、初回だけ計算
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("Result:", calculate())
}()
}
wg.Wait()
}
3. OnceValues:2つの値を返すことをサポート
func OnceValues[T1, T2 any](f func() (T1, T2)) func() (T1, T2)
Go 関数の(値、エラー)を返す慣習に完全に適合しています:
package main
import (
"fmt"
"os"
"sync"
)
func main() {
// ファイルを一度だけ読み込む
readFile := sync.OnceValues(func() ([]byte, error) {
fmt.Println("Reading file")
return os.ReadFile("config.json")
})
// 並行読み込み
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func() {
defer wg.Done()
data, err := readFile()
if err != nil {
fmt.Println("Error:", err)
return
}
fmt.Println("File length:", len(data))
}()
}
wg.Wait()
}
🆚 機能比較
関数 | 特徴 | 適用シナリオ |
---|---|---|
Once.Do | 基本バージョン、戻り値なし | 簡単な初期化 |
OnceFunc | パニック処理付き | エラー処理が必要な初期化 |
OnceValue | 単一値の返却をサポート | 結果の計算とキャッシュ |
OnceValues | 2つの値の返却をサポート | エラー返却のある操作 |
新しい関数を優先的に使用することを推奨します。これらはより良いエラー処理と直感的なインターフェースを提供します。
🎬 実践的な応用事例
1. シングルトンパターンの実装
type Database struct {
// データベース接続情報
}
var (
dbInstance *Database
dbOnce sync.Once
)
func GetDB() *Database {
dbOnce.Do(func() {
// データベース接続を初期化
dbInstance = &Database{
// 設定情報
}
})
return dbInstance
}
2. 設定の遅延ローディング
type Config struct {
// 設定項目
}
var loadConfig = sync.OnceValue(func() *Config {
// ファイルまたは環境変数から設定をロード
data, _ := os.ReadFile("config.yaml")
var cfg Config
_ = yaml.Unmarshal(data, &cfg)
return &cfg
})
// 使用方法
func main() {
cfg := loadConfig()
// 設定を使用...
}
3. リソースプールの初期化
var initPool = sync.OnceFunc(func() {
// 接続プールを初期化
pool = NewPool(
WithMaxConnections(10),
WithTimeout(30*time.Second),
)
})
func GetResource() (*Resource, error) {
initPool() // プールが初期化されていることを保証
return pool.Get()
}
🚀 性能に関する考慮事項
sync.Once は優れた性能を持っています。初回呼び出しのオーバーヘッドは主にミューテックスロックに由来し、その後の呼び出しはほぼオーバーヘッドゼロです:
- 初回呼び出し:約50-100ns(ロック競合による差異あり)
- その後の呼び出し:約1-2ns(原子的なロード操作のみ)
高並行シナリオでは、他の同期方法(ミューテックスロックなど)と比較して、性能損失を大幅に削減できます。
📚 まとめ
sync.Once は極めて簡単な設計で並行環境における単一実行の問題を解決し、その核心的なアイデアは学ぶ価値があります:
- 最小限のオーバーヘッドでスレッドセーフを実現
- ファストパスとスローパスを分離して性能を最適化
- 明確なメモリモデル保証
Go 1.21 で追加された3つの新機能はその実用性をさらに向上させ、単一実行ロジックをより簡潔かつ堅牢にしています。
sync.Once ファミリーをマスターすることで、並行初期化やシングルトンパターンなどのシナリオを容易に処理でき、よりエレガントで効率的な Go コードを記述することができます。
Leapcell: 最高のサーバーレスWebホスティング
最後に、Go サービスをデプロイするための最高のプラットフォームを推奨します:Leapcell
🚀 お気に入りの言語で構築
JavaScript、Python、Go、Rust を使って簡単に開発。
🌍 無料で無制限のプロジェクトをデプロイ
使用分だけ支払う—リクエストなしでは料金なし。
⚡ 従量課金制、隠れたコストなし
アイドル料金はなく、シームレスにスケーラビリティを提供。
🔹 Twitter でフォロー:@LeapcellHQ