はじめに
ある特定の状況では、リソース(例えばシングルトンオブジェクトや設定情報など)を初期化する必要があります。リソースの初期化にはいくつかの方法があり、パッケージレベルの変数を定義したり、init
関数で初期化したり、main
関数内で初期化を行ったりすることが可能です。これらの方法はいずれも並行処理に対して安全であり、プログラムの起動時にリソースを初期化することができます。
しかしながら、実際にリソースが必要になるまで初期化を遅らせたいケースもあります。このような遅延初期化では並行安全性を確保する必要があり、そのような場合に Go 言語の sync.Once
は優雅かつ並行安全な解決策を提供してくれます。本記事では sync.Once
について紹介します。
sync.Once の基本概念
sync.Once とは
sync.Once
は Go 言語における同期プリミティブの一つで、ある操作や関数を並行環境下で一度だけ実行させるために使用されます。sync.Once
にはエクスポートされたメソッドが一つだけあり、それが Do
メソッドです。Do
メソッドは関数を引数として受け取り、その関数を一度だけ実行します。複数のゴルーチンが同時に Do
を呼び出した場合でも、その関数が実行されるのは一度だけです。
sync.Once の使用場面
sync.Once
は主に以下のような場面で利用されます:
- シングルトンパターン:グローバルに一つだけのインスタンスを保証し、リソースの重複生成を避ける。
-
遅延初期化:プログラムの実行中にリソースが必要になった時点で、
sync.Once
を用いて動的に初期化する。 - 一度だけ実行する操作:設定の読み込みやデータのクリアなど、一度だけ行えばよい処理。
sync.Once の使用例
シングルトンパターン
シングルトンパターンでは、ある構造体が一度だけ初期化されることを保証する必要があります。sync.Once
を使えばこの目的を簡単に達成できます。
package main
import (
"fmt"
"sync"
)
type Singleton struct{}
var (
instance *Singleton
once sync.Once
)
func GetInstance() *Singleton {
once.Do(func() {
instance = &Singleton{}
})
return instance
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
s := GetInstance()
fmt.Printf("Singleton instance address: %p\n", s)
}()
}
wg.Wait()
}
上記のコードでは、GetInstance
関数が once.Do()
を使って instance
を一度だけ初期化しています。並行環境で複数のゴルーチンが同時に GetInstance
を呼び出しても、instance = &Singleton{}
の処理は一度しか実行されず、全てのゴルーチンが同じインスタンス s
を取得します。
遅延初期化
あるリソースを必要になったときだけ初期化したい場合があります。sync.Once
を使うことでこのような遅延初期化が可能になります。
package main
import (
"fmt"
"sync"
)
type Config struct {
config map[string]string
}
var (
config *Config
once sync.Once
)
func GetConfig() *Config {
once.Do(func() {
fmt.Println("init config...")
config = &Config{
config: map[string]string{
"c1": "v1",
"c2": "v2",
},
}
})
return config
}
func main() {
// 初めて設定情報を取得する必要がある時、config を初期化
cfg := GetConfig()
fmt.Println("c1: ", cfg.config["c1"])
// 2回目以降は config がすでに初期化されているため再初期化は行われない
cfg2 := GetConfig()
fmt.Println("c2: ", cfg2.config["c2"])
}
この例では、設定情報を保持する Config
構造体を定義しています。GetConfig
関数内で sync.Once
を使用し、初回呼び出し時にのみ Config
を初期化します。これにより、実際に必要になるまで初期化を遅らせ、不要なオーバーヘッドを回避できます。
sync.Once の実装原理
type Once struct {
// 処理がすでに実行されたかどうかを示す
done uint32
// 複数のゴルーチンがアクセスする際に排他制御を行うためのミューテックス
m Mutex
}
func (o *Once) Do(f func()) {
// done の値をチェック。0であれば f はまだ実行されていない
if atomic.LoadUint32(&o.done) == 0 {
// Do 関数の高速パス(fast-path)をインライン化可能にするため、遅いパス(slow-path)を分離
o.doSlow(f)
}
}
func (o *Once) doSlow(f func()) {
// ロックを取得
o.m.Lock()
defer o.m.Unlock()
// 再度チェックして、f がすでに実行されたかを確認
if o.done == 0 {
// done を変更する
defer atomic.StoreUint32(&o.done, 1)
// 関数 f を実行
f()
}
}
sync.Once
構造体は、2 つのフィールド done
と m
を持っています。done
は uint32
型で、処理が実行済みかどうかを示すフラグです。m
はミューテックスで、複数のゴルーチンが同時にアクセスする際に、排他制御を行うために使われます。
sync.Once
には Do
と doSlow
の 2 つのメソッドがあります。Do
はメインとなるメソッドで、関数 f
を引数に取り、まず atomic.LoadUint32
(原子操作)を使って done
を確認します。done
が 0 の場合に限り、doSlow
を実行します。
doSlow
ではまずロックを取得し、その後もう一度 done
を確認します。done
がまだ 0 の場合、関数 f
を実行し、最後に atomic.StoreUint32
により done
を 1 に設定します。これにより、関数 f
は一度だけ安全に実行されます。
なぜ doSlow
関数を分離しているのか?
doSlow
関数を分離している主な理由はパフォーマンス最適化のためです。遅い処理(スローパス)を別の関数に分離することで、Do
メソッドの高速パス(fast-path)が**インライン展開(inlining)**されやすくなり、パフォーマンスが向上します。
なぜ「ダブルチェック(二重チェック)」があるのか?
ソースコードからわかるように、done
の値のチェックは 2 回行われます:
-
1 回目のチェック:ロックを取得する前に
atomic.LoadUint32
でdone
を確認します。値が 1 であれば処理はすでに行われているため、すぐに戻ります。これにより不要なロック取得を避けられます。 -
2 回目のチェック:ロックを取得した後に再度
done
を確認します。これは、現在のゴルーチンがロックを取得するまでの間に、他のゴルーチンが処理をすでに実行している可能性があるためです。
このダブルチェックにより、競合の回避とパフォーマンスの両立が可能になります。
拡張された sync.Once(強化版)
sync.Once
が提供する Do
メソッドは戻り値がありません。つまり、渡した関数でエラーが発生して初期化に失敗した場合でも、以降の Do
呼び出しでは再実行されません。この制限を回避するために、sync.Once
に似たカスタムの並行プリミティブを実装することができます。
package main
import (
"sync"
"sync/atomic"
)
type Once struct {
done uint32
m sync.Mutex
}
func (o *Once) Do(f func() error) error {
if atomic.LoadUint32(&o.done) == 0 {
return o.doSlow(f)
}
return nil
}
func (o *Once) doSlow(f func() error) error {
o.m.Lock()
defer o.m.Unlock()
var err error
if o.done == 0 {
err = f()
// エラーが発生しなかった場合のみ done を更新する
if err == nil {
atomic.StoreUint32(&o.done, 1)
}
}
return err
}
上記のコードでは、標準の sync.Once
とは異なり、Do
メソッドが error
を返すように拡張されています。関数 f
の実行中にエラーが返されなければ、done
を 1
に設定して処理済みとします。
この実装のメリットは以下のとおりです:
- 初期化処理が失敗した場合、次回以降に再実行される可能性を残せる
- エラーの有無によって初期化の成否を判別できる
- より柔軟で堅牢な初期化ロジックが組める
このように、カスタム Once
は sync.Once
の弱点を補い、初期化失敗時のリカバリを可能にします。
sync.Once の注意点
デッドロック
sync.Once
の実装を見ると、m
という名前のミューテックスフィールドが含まれています。Do
メソッドの中で別の Do
を呼び出すと、同じロックを二重に獲得しようとするため、ミューテックスは再入不可(non-reentrant)なのでデッドロックが発生します。
func main() {
once := sync.Once{}
once.Do(func() {
once.Do(func() {
fmt.Println("init...")
})
})
}
上記のコードでは、内側の once.Do
呼び出しが同じロックを獲得しようとしてブロックし、プログラムは永久に停止してしまいます。こうしたネストした Do
の呼び出しは避けるべきです。
初期化失敗
ここで言う初期化失敗とは、Do
メソッドで渡した関数 f
が何らかのエラーを返し、そのために初期化が完了しなかった状態を指します。標準の sync.Once
では、Do
の関数が失敗してもそのことを知る方法がなく、以降の呼び出しでも再実行されません。
この問題を解決するために、前述のように戻り値付きのカスタム Once
を実装し、初期化失敗時に再度初期化を試みられるようにする方法があります。
まとめ
本記事では、Go 言語の sync.Once
について詳しく解説しました。基本的な定義、使用場面、具体的な利用例、そしてソースコードの解析までを網羅しています。
実際の開発において、sync.Once
はシングルトンパターンや遅延初期化といったシナリオで頻繁に活用されます。
シンプルかつ高効率な一方で、誤った使い方をすると意図しない動作やデッドロックを引き起こす可能性もあるため注意が必要です。
まとめると、sync.Once
は Go 言語における非常に有用な並行制御プリミティブであり、1 回だけの初期化処理を安全に実装する際に強力な味方となります。
初期化を一度だけ行いたい場面があれば、ぜひ sync.Once
を活用してみてください。
私たちはLeapcell、Goプロジェクトのホスティングの最適解です。
Leapcellは、Webホスティング、非同期タスク、Redis向けの次世代サーバーレスプラットフォームです:
複数言語サポート
- Node.js、Python、Go、Rustで開発できます。
無制限のプロジェクトデプロイ
- 使用量に応じて料金を支払い、リクエストがなければ料金は発生しません。
比類のないコスト効率
- 使用量に応じた支払い、アイドル時間は課金されません。
- 例: $25で6.94Mリクエスト、平均応答時間60ms。
洗練された開発者体験
- 直感的なUIで簡単に設定できます。
- 完全自動化されたCI/CDパイプラインとGitOps統合。
- 実行可能なインサイトのためのリアルタイムのメトリクスとログ。
簡単なスケーラビリティと高パフォーマンス
- 高い同時実行性を容易に処理するためのオートスケーリング。
- ゼロ運用オーバーヘッド — 構築に集中できます。
Xでフォローする:@LeapcellHQ