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



