Goのsync.Onceで初期化失敗が固定化される実装をどうレビューするか
Goのsync.Onceで初期化失敗が固定化される実装をどうレビューするか
Goの sync.Once は、処理を1回だけ実行したいときに便利だ。
設定読み込み、メトリクス登録、グローバルなクライアント初期化などでよく使われる。
ただし、失敗する可能性がある初期化処理を sync.Once に閉じ込めた実装は、レビューで慎重に止めたい。
Once は「成功するまで一度」ではなく、「関数呼び出しを一度」実行する道具だからだ。
この記事では sync.Once を否定するのではなく、
初期化失敗時の復旧責務が読めるかというレビュー観点で整理する。
まず止めたい実装
var (
paymentOnce sync.Once
paymentClient *PaymentClient
paymentErr error
)
func GetPaymentClient(ctx context.Context) (*PaymentClient, error) {
paymentOnce.Do(func() {
paymentClient, paymentErr = NewPaymentClient(ctx)
})
@Reviewer`sync.Once` は初回実行が失敗しても再実行されません。NewPaymentClientが一時的なネットワークエラーで失敗した場合、プロセス再起動まで復旧できない構造です。
if paymentErr != nil {
return nil, paymentErr
}
return paymentClient, nil
}このコードは一見よくある遅延初期化に見える。
しかし NewPaymentClient(ctx) が失敗した場合、paymentErr は固定され、次回呼び出しでも初期化は再試行されない。
レビューで見るべきなのは「二重初期化を防げているか」だけではない。
初期化に失敗したあと、どう復旧する設計なのかである。
なぜ危ないのか
初期化失敗が固定化されると、障害の性質が変わる。
- 一時的なDNS失敗が永続障害になる
- 設定配信の遅延がプロセス再起動まで回復しない
- 起動直後の依存サービス遅延で全リクエストが失敗し続ける
- エラーがグローバル変数に残り、テスト間で状態が汚染される
sync.Once は強い道具だ。
だからこそレビューでは、一度しか実行しないことが本当に業務上の契約なのかを確認する必要がある。
レビューで見たい3つの判断線
1. 初期化処理が失敗し得るなら、Onceだけで閉じていないか
ファイル読み込み、環境変数の構文検証、定数登録のように、失敗時に即座にプロセスを止める設計なら sync.Once は合うことがある。
一方、外部接続や一時的なI/Oを伴う初期化は別だ。
@Reviewer: この初期化は外部サービス接続を含むため、一時失敗があり得ます。`sync.Once` に閉じ込めると再試行不能になるため、失敗時の再初期化方針を明示してください。2. グローバル状態にテストやリクエスト依存値が混ざっていないか
sync.Once とパッケージ変数の組み合わせは、テストで扱いにくい。
特に context.Context やリクエスト由来の値が初期化に混ざると、どの呼び出しが初期状態を作ったのか分からなくなる。
@Reviewer: `ctx` に依存した初期化結果がパッケージグローバルに保存されています。リクエストごとのキャンセルや値に影響される初期化をグローバル化しないでください。3. 「初期化済み」と「利用可能」が同じ意味になっていないか
Once.Do が終わったことは、クライアントが利用可能であることを意味しない。
失敗時にも Once は完了済みになる。
@Reviewer: `Once.Do` 実行済みとクライアント利用可能状態が同一視されています。初期化状態、失敗状態、再試行可否を分けて表現してください。改善方針:失敗する初期化は状態を明示する
再試行を許可するなら、sync.Once ではなく状態を持ったProviderに寄せるほうがレビューしやすい。
type PaymentClientProvider struct {
mu sync.Mutex
client *PaymentClient
}
func (p *PaymentClientProvider) Client(ctx context.Context) (*PaymentClient, error) {
p.mu.Lock()
defer p.mu.Unlock()
if p.client != nil {
return p.client, nil
}
client, err := NewPaymentClient(ctx)
if err != nil {
return nil, fmt.Errorf("initialize payment client: %w", err)
}
p.client = client
return p.client, nil
}この形なら、初期化に失敗しても client は保存されない。
次回呼び出しで再試行できることがコードから読める。
レビュー観点としても次が確認しやすい。
- 成功時だけ状態が保存される
- 失敗時はエラーを返し、再試行可能
- グローバル変数ではなく所有者が明確
- テストでProviderを差し替えやすい
sync.Once が向いているケース
sync.Once を使ってよい場面もある。
レビューでは、次の条件を満たしているかを確認したい。
- 初期化処理が実質的に失敗しない
- 失敗するならプロセス起動失敗として扱う
- リクエストごとの値に依存しない
- 再設定や再接続を運用上想定しない
- テストで状態を共有しても問題にならない
例えばメトリクスの登録、静的テーブルの構築、正規表現のコンパイル結果キャッシュなどは、sync.Once と相性がよい。
var (
compileOnce sync.Once
orderIDPattern *regexp.Regexp
)
func OrderIDPattern() *regexp.Regexp {
compileOnce.Do(func() {
orderIDPattern = regexp.MustCompile(`^ord_[0-9]+$`)
})
return orderIDPattern
}ここでは失敗を復旧する設計ではなく、コード不備なら起動時やテストで検出すべきという前提がある。
この前提が説明できるなら、sync.Once は自然に使える。
レビュー観点チェックリスト
- 初期化処理は失敗し得るか
- 初回失敗後に再試行が必要な業務要件か
Once.Do実行済みと利用可能状態を混同していないか- リクエスト依存の
contextや入力値がグローバル初期化に混ざっていないか - テスト間で状態が残っても問題ないか
sync.OnceではなくProviderや明示的な初期化関数にすべきではないか
まとめ
sync.Once は「一度だけ実行」を強制する。
その強さは、失敗しない初期化には有効だが、外部接続や一時障害を含む初期化では復旧性を奪うことがある。
レビューでは、sync.Once の有無ではなく、
初期化失敗時にシステムがどう回復する設計なのかを確認したい。