Goにおけるsync.Onceの理解
Takashi Yamamoto
Infrastructure Engineer · Leapcell

はじめに
特定の場合において、シングルトンオブジェクトや設定などのリソースを初期化する必要があります。リソースの初期化を実装する方法は複数あり、パッケージレベルの変数を定義したり、init
関数、またはmain
関数で初期化したりする方法があります。これら3つのアプローチはすべて、プログラムの起動時に同時実行の安全性とリソースの完全な初期化を保証できます。
ただし、リソースが本当に必要な場合にのみ初期化される遅延初期化を使用したい場合があります。これには同時実行の安全性が求められますが、そのような場合、Goのsync.Once
はエレガントでスレッドセーフなソリューションを提供します。この記事では、sync.Once
を紹介します。
sync.Onceの基本的な概念
sync.Onceとは
sync.Once
は、Goにおける同期プリミティブであり、特定の操作または関数が同時実行環境で一度だけ実行されることを保証します。エクスポートされたメソッドはDo
のみで、関数をパラメータとして受け取ります。Do
メソッドを呼び出すと、提供された関数が実行され、複数のゴルーチンが同時にそれを呼び出したとしても一度だけ実行されます。
sync.Onceの応用シナリオ
sync.Once
は、主に次のシナリオで使用されます。
- シングルトンパターン: グローバルインスタンスオブジェクトが1つだけ存在することを保証し、リソースの重複作成を防ぎます。
- 遅延初期化: プログラムの実行中、必要なときに
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
を呼び出すと、1つのゴルーチンだけが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
構造体を初期化します。このようにして、Config
は本当に必要なときにのみ初期化され、不要なオーバーヘッドを回避します。
sync.Onceの実装原理
type Once struct { // 操作が実行されたかどうかを示します done uint32 // 操作を実行するのが1つのゴルーチンだけであることを保証するMutex m Mutex } func (o *Once) Do(f func()) { // doneが0かどうかを確認します。つまり、fはまだ実行されていません if atomic.LoadUint32(&o.done) == 0 { // Doでの高速パスのインライン化を可能にするために、低速パスを呼び出します 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() } }
sync.Once
構造体には、done
とm
の2つのフィールドが含まれています。
done
は、操作が既に実行されたかどうかを示すために使用されるuint32
変数です。m
は、同時アクセス時に操作を実行するのが1つのゴルーチンだけであることを保証するために使用されるmutexです。
sync.Once
は、Do
とdoSlow
の2つのメソッドを提供します。Do
メソッドがコアです。関数f
を受け入れます。最初にアトミック操作atomic.LoadUint32
を使用してdone
の値をチェックします(同時実行の安全性を保証します)。done
が0
の場合、関数f
はまだ実行されていないことを意味し、次にdoSlow
が呼び出されます。
doSlow
メソッド内では、最初にmutexロックm
を取得して、一度に1つのゴルーチンだけがf
を実行できるようにします。次に、done
変数を再度チェックします。done
がまだ0
の場合、関数f
が実行され、アトミックストア操作atomic.StoreUint32
を使用してdone
が1
に設定されます。
別途doSlow
メソッドがあるのはなぜですか?
doSlow
メソッドは、主にパフォーマンスの最適化のために存在します。低速パスロジックをDo
メソッドから分離することにより、Do
の高速パスをコンパイラーによってインライン化でき、パフォーマンスが向上します。
二重チェックを使用するのはなぜですか?
ソースコードからわかるように、done
の値は2回チェックされます。
- 最初のチェック: ロックを取得する前に、
atomic.LoadUint32
を使用してdone
をチェックします。値が1
の場合、操作は既に実行されていることを意味するため、doSlow
はスキップされ、不要なロック競合が回避されます。 - 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 }
上記のコードは、拡張されたOnce
構造体を実装しています。標準のsync.Once
とは異なり、このバージョンでは、Do
メソッドに渡される関数がエラーを返すことを許可しています。関数がエラーを返さない場合、関数が正常に実行されたことを示すためにdone
が設定されます。その後の呼び出しでは、以前に正常に完了した場合にのみ関数がスキップされ、未処理の初期化の失敗が回避されます。
sync.Onceの注意点
デッドロック
sync.Once
ソースコードの分析から、mutexフィールドm
が含まれていることがわかります。別のDo
呼び出し内でDo
を再帰的に呼び出すと、同じロックを複数回取得しようとすることになります。mutexは再入可能ではないため、これによりデッドロックが発生します。
func main() { once := sync.Once{} once.Do(func() { once.Do(func() { fmt.Println("init...") }) }) }
初期化の失敗
初期化の失敗とは、ここでDo
に渡された関数の実行中にエラーが発生することを指します。標準のsync.Once
は、そのような失敗を検出する方法を提供していません。この問題を解決するには、エラー処理と条件付き再試行をサポートする、前述の拡張されたOnce
を使用できます。
結論
この記事では、Goプログラミング言語のsync.Once
について、基本的な定義、使用シナリオ、応用例、およびソースコード分析を含む詳細な紹介を提供しました。
実際の開発では、sync.Once
はシングルトンパターンと遅延初期化を実装するためによく使用されます。
sync.Once
はシンプルで効率的ですが、誤った使用法は予期しない問題につながる可能性があるため、注意して使用する必要があります。
要約すると、sync.Once
はGoの非常に便利な同時実行プリミティブであり、開発者がさまざまな同時実行シナリオでスレッドセーフな操作を実行するのに役立ちます。操作を一度だけ初期化する必要がある状況に遭遇した場合は常に、sync.Once
が優れた選択肢です。
Goプロジェクトのホスティングに最適なLeapcellをご利用ください。
Leapcellは、Webホスティング、非同期タスク、およびRedis向けの次世代サーバーレスプラットフォームです。
多言語サポート
- Node.js、Python、Go、または Rustで開発します。
無制限のプロジェクトを無料でデプロイ
- 使用量に対してのみ支払い — リクエストも料金もかかりません。
比類のない費用対効果
- アイドル料金なしの従量課金。
- 例:$25で、平均応答時間60msで694万リクエストをサポートします。
合理化された開発者エクスペリエンス
- 簡単なセットアップのための直感的なUI。
- 完全に自動化されたCI/CDパイプラインとGitOps統合。
- 実用的な洞察のためのリアルタイムのメトリックとロギング。
簡単なスケーラビリティとハイパフォーマンス
- 高い同時実行を容易に処理するための自動スケーリング。
- 運用上のオーバーヘッドはゼロ — 構築に集中するだけです。
ドキュメントで詳細をご覧ください。
Xでフォローしてください:@LeapcellHQ