はじめに
Java から Go に移行して学ぶ中で、過去に高並行処理のシナリオで使っていた手法を、つい Go に当てはめてしまいがちです。
例えば、キャッシュのスレッドセーフな読み込みや、ホットキーの過負荷対策などです。Java ではこれを実現するために、よく以下のようなコンポーネントを使います:
Guava CacheLoader、ReentrantLock、FutureTask、場合によってはメッセージキューまで。
そんな中、Go の公式拡張パッケージで singleflight という名前を見つけたとき、正直驚かされました。
わずか数行のコードで、同じリソースへの同時リクエストをスマートに処理できるのです。
複雑な抽象や外部依存は一切不要。
数百行もない小さなライブラリでこの問題を解決できるというのは、非常に印象的でした。
singleflight の原理
singleflight の核心は「重複リクエスト排除(deduplication)」です。
複数の goroutine がほぼ同時に同じキャッシュキーにアクセスする場合を考えます。
従来の方法では、複数の並行リクエストが同時に DB や下流サービスにアクセスし、無駄な負荷やシステムの雪崩を引き起こすことがあります。
しかし singleflight は非常にシンプルです:
- 最初のリクエストが実際の処理を実行
- 同じ key の後続リクエストは待機
- 最初のリクエストが完了すると、その結果が待機中のすべての goroutine に共有される
- すべてのリクエストは同じ結果とエラーを受け取る
こうして軽量な「リクエスト統合メカニズム」が実現されます。
この設計は、次のような場面で非常に有効です:
- キャッシュのホットキー同時失効(キャッシュミス集中)
- 下流 API の負荷抑制
- 重複計算や読み込みの防止
注意点として、singleflight はキャッシュではなく、同時に同じリクエストが発生するのを防ぐための小道具です。
簡単な実装例
典型的なユースケースは、キャッシュからのデータ読み込みです。
import (
"golang.org/x/sync/singleflight"
)
var g singleflight.Group
func GetData(ctx context.Context, key string) (string, error) {
v, err, _ := g.Do(key, func() (interface{}, error) {
// DB やリモート API からの取得処理
return queryFromDB(key)
})
return v.(string), err
}
処理の流れ:
- 最初のリクエストが
queryFromDBを実行 - 他の同じ key のリクエストは待機
- 実行完了後、結果が共有される
これにより:
- キャッシュレイヤで手動ロックする必要がなくなる
- 下流 DB への負荷が大幅に減少
- コードはシンプルで明快なまま
Go singleflight の内部構造
主要な構造体は以下の通りです(重複リクエスト排除のための構造):
type Group struct {
mu sync.Mutex
m map[string]*call
}
type call struct {
wg sync.WaitGroup
val interface{}
err error
dups int
}
Group は現在実行中の key を管理する map を保持。call は特定の key に対する実際の呼び出し処理を表します。WaitGroup によって他の goroutine は処理完了を待機。つまり Group は重複排除を担当し、call は実行と同期を担当しています。
Group.Do() の簡略版は次の通りです:
func (g *Group) Do(key string, fn func() (interface{}, error)) (interface{}, error, bool) {
g.mu.Lock()
if c, ok := g.m[key]; ok {
// 同じ key の処理がすでに実行中
c.dups++
g.mu.Unlock()
c.wg.Wait()
return c.val, c.err, true
}
c := new(call)
c.wg.Add(1)
if g.m == nil {
g.m = make(map[string]*call)
}
g.m[key] = c
g.mu.Unlock()
c.val, c.err = fn()
c.wg.Done()
g.mu.Lock()
delete(g.m, key)
g.mu.Unlock()
return c.val, c.err, false
}
処理の流れ:
- map に同じ key の処理があるか確認
- 既存なら待機し結果を再利用
- 新規なら
callを作成して実行 - 処理完了後に
Done()で待機者を起こし、map から key を削除
ロックは map の読み書き時のみ使用され、fn() の実行中は解放されます。これにより、並行安全を確保しつつロックの粒度は最小化され、性能への影響はほとんどありません。dups は重複リクエスト数の記録用で、処理には影響しません。
有名プロジェクトでの利用例
groupcache
singleflight はもともと groupcache から生まれました。複数ノードで同じ key を同時取得する際の重複を防ぎます:
v, err, _ := gsf.Do(key, func() (interface{}, error) {
return getFromPeer(key)
})
これにより、同じ key のリクエストは一度だけ遠隔呼び出しを行い、他は結果を待つことでキャッシュ雪崩を防ぎます。
etcd / Kubernetes / go-cloud
これらのプロジェクトでも、サービスディスカバリや設定同期のモジュールで採用されています。複数 goroutine が同時に設定を更新したりリモートデータを取得する際、singleflight でリクエストを統合し、重複 I/O を削減しています。
Java と Go の比較
| 比較項目 | Java 実装 | Go 実装 |
|---|---|---|
| リクエスト統合 | Guava Cache、FutureTask、ロック | singleflight.Group |
| 依存度 | 高(多層抽象、スレッドプール) | 低(標準パッケージのみ) |
| 実装コード量 | 数十行以上になる場合あり | 2〜3行で十分 |
| 核心思想 | 抽象化で複雑さを隠す | 明示的に制御 |
Java の実装は抽象化と安全性を重視しますが、複雑さと開発者の負荷が増します。
Go の実装はより「実用的」で、直接的かつ透明。開発者は処理の流れを完全に理解できます。
一言でまとめると:
Java は抽象化で複雑さを隠す傾向がある一方、Go は明示的に制御する傾向があります。
まとめ
singleflight は小さなライブラリで、
複雑な抽象はなく、汎用性も追求していません。
解決するのはただ一つ:
複数のリクエストが同じ key に同時アクセスした場合、一度だけ実行し、他は結果を待つ
シンプル、直接、効果的。
これが singleflight の魅力です。