0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Goのsync.Onceを深掘りする

Posted at

表紙

はじめに

ある特定の状況では、リソース(例えばシングルトンオブジェクトや設定情報など)を初期化する必要があります。リソースの初期化にはいくつかの方法があり、パッケージレベルの変数を定義したり、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 つのフィールド donem を持っています。doneuint32 型で、処理が実行済みかどうかを示すフラグです。m はミューテックスで、複数のゴルーチンが同時にアクセスする際に、排他制御を行うために使われます。

sync.Once には DodoSlow の 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.LoadUint32done を確認します。値が 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 の実行中にエラーが返されなければ、done1 に設定して処理済みとします。

この実装のメリットは以下のとおりです:

  • 初期化処理が失敗した場合、次回以降に再実行される可能性を残せる
  • エラーの有無によって初期化の成否を判別できる
  • より柔軟で堅牢な初期化ロジックが組める

このように、カスタム Oncesync.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

Leapcellは、Webホスティング、非同期タスク、Redis向けの次世代サーバーレスプラットフォームです:

複数言語サポート

  • Node.js、Python、Go、Rustで開発できます。

無制限のプロジェクトデプロイ

  • 使用量に応じて料金を支払い、リクエストがなければ料金は発生しません。

比類のないコスト効率

  • 使用量に応じた支払い、アイドル時間は課金されません。
  • 例: $25で6.94Mリクエスト、平均応答時間60ms。

洗練された開発者体験

  • 直感的なUIで簡単に設定できます。
  • 完全自動化されたCI/CDパイプラインとGitOps統合。
  • 実行可能なインサイトのためのリアルタイムのメトリクスとログ。

簡単なスケーラビリティと高パフォーマンス

  • 高い同時実行性を容易に処理するためのオートスケーリング。
  • ゼロ運用オーバーヘッド — 構築に集中できます。

ドキュメントで詳細を確認!

Try Leapcell

Xでフォローする:@LeapcellHQ


ブログでこの記事を読む

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?