singleflightは、同じキーに対する関数呼び出しが同時に重なったとき、実際の実行を1回に絞り、待っていた全員に同じ結果を返すGoのパッケージです。キャッシュが切れた瞬間にDBへ同じクエリが殺到する「キャッシュスタンピード」の対策によく使われます。この記事では、golang.org/x/sync v0.24.0(2026年9月23日公開)のソースとGo 1.27.3での実測をもとに、Do・DoChan・Forgetの使い方、先頭の呼び出し元のキャンセルやpanicが全員に波及する落とし穴、1つの Group に集まるロック競合までを扱います。
まとめ:singleflightの要点と使う前の確認事項
- 提供元は標準ライブラリではなく
golang.org/x/sync/singleflight。v0.23.0 以降は go.mod が go 1.26.0 のため、Go 1.25 のプロジェクトで入れると go 行が 1.26.0 に上がります。 - 重複を抑えるのは「実行中」の呼び出しだけです。完了した時点でキーは消えるので、結果は保存されません。キャッシュとは別に持つ必要があります。
- 1000ゴルーチンから同じキーで同時に呼ぶと、実行は1回でした。エラーも全員に共有されます。
- 関数の中で先頭の呼び出し元の context を使うと、その1件のキャンセルで待っていた全員が
context.Canceledを受け取ります(100件中100件で再現)。共有処理にはcontext.WithoutCancelと独自のタイムアウトを使います。 - DoChan に渡した関数から panic が漏れると、呼び出し元側の recover では回収できず、プロセスごと終了します。
- 1つの
Groupは全キーを1本のMutexで扱います。16並列・1024キーでは、Groupを16個に分けると1回あたりの時間が708nsから362nsに縮みました。 - 書き込み処理と、ユーザーごとに結果が変わる処理を、ユーザーIDの入らないキーでまとめてはいけません。
singleflightの仕組みと標準ライブラリとの関係
同じキーの実行中呼び出しに相乗りするGroupの内部構造
中心になる singleflight.Group は、sync.Mutex と map[string]*call の2つだけを持つ構造体です(v0.24.0のソース)。最初の呼び出し元は map にキーを登録して関数を実行し、同じキーで後から来た呼び出し元は登録済みの call が持つ sync.WaitGroup で完了を待ちます(DoChan の場合は結果を受け取るチャネルを登録します)。関数が返ると結果を全員に渡し、キーを map から削除します。
違うキーの関数同士は互いの完了を待たず、user:1 と user:2 の取得は並行して進みます。ただし map への登録と削除はどのキーでも同じ g.mu を取るため、呼び出し回数が多いと後述のロック競合が起きます。ロックの基本はGoのsync.RWMutexとMutexの違いと使い分けで解説しています。
Go 1.27.3(darwin/amd64)で、1000個のゴルーチンを同時に起動して50ミリ秒かかる関数を同じキーで呼ぶと、関数の実行回数は1回でした。map は最初の呼び出しで作られるので、Group はゼロ値のまま使えます。
golang.org/x/syncでの配布と標準ライブラリ内部版の位置付け
singleflightは準標準の golang.org/x/sync モジュールに入っており、go get golang.org/x/sync で追加します。標準ライブラリにも internal/singleflight がありますが、internal パッケージなので外部からは import できません。net パッケージの Resolver が、同じホスト名のDNS問い合わせをまとめるために内部で使っています。
| x/sync の版 | 公開日 | go.mod の go 行 |
|---|---|---|
| v0.24.0 | 2026-09-23 | go 1.26.0 |
| v0.23.0 | 2026-08-31 | go 1.26.0 |
| v0.22.0 | 2026-07-01 | go 1.25.0 |
| v0.19.0 | 2025-12-04 | go 1.24.0 |
公開日は proxy.golang.org の版情報、go 行は各版の go.mod の値です。v0.23.0 と v0.24.0 の singleflight パッケージはソースに差分がなく、v0.24.0 で変わったのは errgroup のテストだけでした。go 1.25.0 と書いたモジュールで go get golang.org/x/[email protected] を実行すると、go: upgraded go 1.25.0 => 1.26.0 と表示されて go 行が書き換わります。Go 1.25 に留めたい場合は v0.22.0、Go 1.24 なら v0.19.0 を指定します。
Doの基本形と戻り値の型アサーション
Do はキーと関数を受け取り、値・エラー・共有されたかを返します。値は any 型なので、呼び出し側で型アサーションが要ります。
var g singleflight.Group
func GetUser(id int) (*User, error) {
v, err, _ := g.Do(fmt.Sprintf("user:%d", id), func() (any, error) {
return queryUser(context.Background(), id)
})
if err != nil {
return nil, err
}
return v.(*User), nil
}
エラーを先に確かめてから型アサーションします。関数が return nil, err のように型のない nil を返すと v は nil インターフェースになり、v.(*User) が panic するためです。queryUser の戻り値をそのまま返す形では、エラー時の v は「nil の *User を持つ any」になり、v == nil では失敗を判定できません。Group は内部に sync.Mutex を持つので、パッケージ変数か構造体のフィールドに置き、値でコピーしないでください。go vet の copylocks がコピーを検出します。
3つ目の戻り値 shared を「自分が待たされた側か」の判定に使うのは誤りです。ドキュメントの定義は「v が複数の呼び出し元に渡されたか」で、相乗りが1件でもあれば実際に関数を実行した先頭の呼び出し元にも true が返ります。1000並行の実測では1000件すべてが true でした。先頭は false になるべきだというバグ報告は、golang/go #74880 で「shared は値が複数の呼び出し元に返されたことを意味する」という回答とともに、2025年8月に対応予定なしで閉じられています。
この性質のため、shared の true の数は「重複を抑えた回数」になりません。数え方は次章で扱います。
DoChanによる呼び出し元ごとのタイムアウト
DoChan は結果を Result{Val, Err, Shared} としてチャネルで返します。ソースではチャネルは容量1で作られ、ドキュメントに「close されない」と明記されています。for range で読まず、1回だけ受信してください。
Do との違いは、待っている間に select で自分の ctx.Done() も見られることです。相乗りした呼び出し元が先に諦めても、共有処理は止まらず、残りの呼び出し元は結果を受け取れます。ただし諦めた後に来た呼び出し元は、同じ遅い処理に相乗りして待ち直します。select の挙動はGoのchannel(チャネル)の使い方で検証しています。
Forgetによる後続呼び出しの切り離し
Forget(key) はキーを map から消し、以降の同じキーの呼び出しに新しく関数を実行させます。実行中の処理は止まりません。50ミリ秒の処理の途中で Forget を呼び、直後に同じキーで呼ぶと、関数は2回実行されました。
使う場面は、更新直後に古い読み込みへ相乗りさせたくないときです。書き込みを終えたら Forget を呼べば、その後の読み込みは書き込み前に始まった取得結果を受け取りません。ただし実行中の古い取得は止まらないため、次章の実装例のように関数内でキャッシュへ書き込む場合は、書き込み前の値でキャッシュが上書きされます。バージョン番号や更新時刻を持たせ、古い結果を書き戻さない処理を別に入れてください。標準ライブラリの内部版には「相乗りがいなければ忘れる」ForgetUnshared がありますが、x/sync への追加提案(#54439)は2026年11月時点で未決です。
キャッシュスタンピード対策への組み込み方
キャッシュミス時だけDoChanを通す実装例
singleflightはキャッシュの手前ではなく、キャッシュミスの経路にだけ置きます。ヒットのたびに Group のロックを取る必要はありません。次の例は、有効期限付きの map キャッシュの読み込みにsingleflightを組み合わせたものです。c.get は期限を確かめて読む補助メソッド、items は make で初期化済みとします。
type UserCache struct {
mu sync.RWMutex
items map[int]entry // entry{u *User; expires time.Time}
sf singleflight.Group
}
func (c *UserCache) Get(ctx context.Context, id int) (*User, error) {
if u, ok := c.get(id); ok { // 期限内ならそのまま返す
return u, nil
}
ch := c.sf.DoChan(fmt.Sprintf("user:%d", id), func() (any, error) {
// 待っている間に別の呼び出しが埋めている場合がある
if u, ok := c.get(id); ok {
return u, nil
}
// 先頭の呼び出し元のキャンセルを共有処理に持ち込まない
fctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second)
defer cancel()
u, err := queryUser(fctx, id)
if err != nil {
return nil, err
}
c.mu.Lock()
c.items[id] = entry{u: u, expires: time.Now().Add(30 * time.Second)}
c.mu.Unlock()
return u, nil
})
select {
case r := <-ch:
if r.Err != nil {
return nil, r.Err
}
return r.Val.(*User), nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
500ゴルーチンから同時に呼び、そのうち1件だけに10ミリ秒のタイムアウトを付けて実行しました。DB呼び出しを模した100ミリ秒の関数は1回だけ実行され、499件が成功し、タイムアウトを付けた1件だけが context.DeadlineExceeded で先に戻りました。関数の中で再度キャッシュを見ているのは、ミスを確認してから DoChan に入るまでの間に、別の呼び出しがキャッシュを埋め終えている場合があるためです。大量のエントリによるGCの走査負担を抑えたい場合は、map の代わりにBigCacheなどを使います。
完了後の再実行と結果・エラーの非保存
重複を抑えるのは実行中の間だけです。1回目の呼び出しが終わった後に同じキーで2回逐次呼ぶと、関数はそのたびに実行されました。キャッシュを置かずにsingleflightだけを入れても、リクエストが時間的に重ならなければ負荷は減りません。
エラーの共有にも注意が要ります。関数がエラー(DBのタイムアウトを模したもの)を返すと、相乗りしていた100件すべてが同じエラーを受け取りました。次のリクエストは再びDBへ行くので、障害中は「毎回1件ずつDBに当たって全員が失敗する」状態が続きます。失敗を数秒だけ覚えておくネガティブキャッシュか、circuit breakerを併用して、障害中のDBを叩き続けないようにします。
相乗りで抑えた件数の数え方
導入後に効果を確かめるなら、shared ではなく「呼び出し回数」と「関数の実行回数」を別々に数え、その差を抑えた件数とします。100ゴルーチンが5ミリ秒の取得を同じキーで20回ずつ呼んだ実測では、呼び出し2000回・実行20回で、抑えた件数は1980件でした。同じ実行で shared が true だった呼び出しは2000件で、実行した本人の分まで含むため実際より20件多く出ます。
v, err, _ := g.Do(key, func() (any, error) {
execs.Add(1) // 実際に取得した回数
return fetch(key)
})
calls.Add(1) // 正常に戻った呼び出し数。全件完了後にcalls - execsを集計する
差がほぼ0なら、同じキーへの同時アクセスが起きていないか、取得が速すぎて重なる時間がないかのどちらかです。上流が速くなっても差は縮むので、数字が減ったことだけで退行とは判断できません。
複数インスタンス構成で残る台数分の重複
Group はプロセスのメモリ上の map なので、まとめられるのは同じプロセス内の呼び出しだけです。各台が1プロセスで同じキーに1つのGroupを使い、Forgetを呼ばないなら、そのキーの同時実行は10台で最大10件です。完了後の再実行を含む累計回数の上限ではありません。DBが台数分の同時クエリに耐えられるなら、ここで止めて構いません。
キャッシュできるHTTPレスポンスなら、手前のリバースプロキシでまとめる方法もあります。nginx の proxy_cache_lock(1.1.12で追加)を on にすると、通常は同じキャッシュキーの要求1件が上流へ送られますが、proxy_cache_lock_age の経過や待機のタイムアウトで追加の要求が上流へ送られる場合があり、残りはキャッシュに応答が入るかロックが外れるまで、proxy_cache_lock_timeout で指定した時間を上限に待ちます。認証付きの応答やRPC、トークン更新のようにプロキシでキャッシュしない処理は、アプリ側のsingleflightで受けます。
台数分の重複も減らしたい場合は、Redis や Valkey の SET key value NX PX 5000 のような期限付きの排他キーで再取得を1台に寄せるか、期限前に確率的に再取得を始める方式(Vattaniらが2015年のVLDBで発表したXFetch)を組み合わせます。どちらも重複ゼロの保証ではありません。排他キーは取得が期限の5秒を超えると別の台が取得を始め、XFetchは再取得が重なる確率を下げる方式です。Valkey の基本はValkeyとは?Redisとの違いで扱っています。
context・panic・共有値で全員が巻き込まれる落とし穴
先頭の呼び出し元のキャンセルによる全員への波及
関数の中で、最初に Do を呼んだリクエストの ctx をそのまま使うと、そのリクエストがキャンセルされた時点で共有処理が中断し、相乗りしていた無関係のリクエストまでエラーになります。100ミリ秒かかる取得の途中で先頭の ctx をキャンセルすると、100件中100件が context.Canceled を受け取りました。
対策は2つの組み合わせです。共有処理には context.WithoutCancel(ctx)(Go 1.21で追加)で値だけを引き継いだ context を渡し、別途 context.WithTimeout で上限を掛けます。呼び出し元側は DoChan と select で自分の ctx.Done() を待ちます。この形にすると、先頭がキャンセルされても99件は成功し、先頭だけが先に戻りました。
標準ライブラリの net/lookup.go も同じ形です。コメントに「ctx のキャンセルが lookupGroup の処理に影響してほしくない。別の context を使う問い合わせにエラーが返ってしまうからだ」という趣旨の記述があり、値だけを引き継いだ context を共有処理に渡して、DoChan の結果と ctx.Done() を select で待っています。さらに、キャンセルされた時点でその問い合わせに一度も相乗りが無かったときだけ、ForgetUnshared でキーを外して問い合わせ自体を取り消します。相乗りが一度でもあれば、相乗りした側が先に諦めていても取り消しません。x/sync にはこの関数がないので、待ち手がいなくなった処理を止めたい場合は後述のフォークを検討します。キャンセルとタイムアウトのエラーの見分け方はGoの「context deadline exceeded」「context canceled」の原因と対処、context の基本はGoのcontext入門を参照してください。
panicはDoなら全員に再送出、DoChanならプロセスが終了
関数が panic したときの挙動は Do と DoChan で違います。Do では、実行した呼び出し元だけでなく待っていた全員の側でも panic が再送出されます。10件で試すと、各呼び出し元の defer と recover で10件すべてを回収できました。
DoChan では回収できません。ソースは panic を別ゴルーチンで go panic(e) として投げ直しており、コメントに「待っているチャネルが永久にブロックしないよう、この panic を回復不能にする必要がある」とあります。呼び出し元に recover を書いても、Go 1.27.3 でもプログラムは panic: boom とスタックトレースを出して終了しました。DoChan に渡す関数は、内部で recover して error に変換してから返すように書きます。
関数が runtime.Goexit を呼んだ場合(テスト中の t.FailNow など)は、Do で待っていた呼び出し元も runtime.Goexit で終了します。DoChan では結果が送られず、ctx に期限が無い待ち手は永久にブロックします(golang/go #67087 は対応予定なしで閉じられています)。
共有されたポインタ・スライスの書き換え禁止
相乗りした全員が受け取るのは、同じ値そのものです。*User やスライス、map を返す場合、1つのリクエストが中身を書き換えると、同じ結果を受け取った他のリクエストにも反映されます。キャッシュに入れた値も同じ参照なので、後続のリクエストにまで影響が残ります。
受け取った値は読み取り専用として扱い、加工が必要なら呼び出し側でコピーしてから変更します。net パッケージは、shared が true のときだけ []IPAddr を make と copy で複製してから返しています。shared の実用的な使い道はこの「コピーが要るか」の判定です。構造体を値で返しても、フィールドにスライス・map・ポインタがあれば参照先は共有されるので、変更する参照先までコピーします。
Groupのロック競合と1回あたりのコスト
同じキーと分散したキーで測った並列度別の時間
singleflightには、重複がなくても毎回かかる固定費があります。関数を直接呼ぶと3.6ns、競合のない Do 経由では505nsと80バイトの確保1回がかかりました(go1.27.3・Intel Core i9-9880H・cpu=1・5回の中央値)。並列にしたときの値が次の表です。b.RunParallel の ns/op は全ゴルーチン合計の処理数で割った時間なので、小さいほど単位時間あたりに多く捌けています。
| 並列度 | 同じキー | 同じキーの共有率 | 1024キー・Group 1個 | 1024キー・16個に分割 |
|---|---|---|---|---|
| 1 | 521ns | 0% | 516ns | 557ns |
| 2 | 497ns | 0.6% | 526ns | 451ns |
| 4 | 595ns | 5.0% | 599ns | 392ns |
| 8 | 729ns | 18.0% | 652ns | 369ns |
| 16 | 1,018ns | 35.0% | 708ns | 362ns |
関数は値を返すだけの処理で、計測日は2026年11月8日、別の処理が動いている状態での値です。時間の列は計測用のカウンタを入れずに測り、共有率だけは shared を数える別のベンチマークで測りました。1つの Group では並列度を上げても1回あたりの時間が縮まず、16並列では1並列より遅くなりました。共有率は shared が true だった割合で、一瞬で終わる関数は重なる時間が短く、16並列でも共有された実行に属する呼び出しは35%でした。この割合には実行した本人も含まれます。
キーのハッシュでGroupを分けるシャーディング
呼び出しが多い場合は、Group を複数持ち、キーのハッシュで振り分けます。同じキーは必ず同じ Group に入るので、重複を抑える働きは変わりません。上の表では、16個に分けると16並列で708nsが362nsになりました。
type Sharded struct {
seed maphash.Seed // maphash.MakeSeed() で初期化
groups [16]singleflight.Group
}
func (s *Sharded) Do(key string, fn func() (any, error)) (any, error, bool) {
h := maphash.String(s.seed, key)
return s.groups[h&15].Do(key, fn)
}
取得に数ミリ秒かかる処理を毎秒数百回まとめる程度なら、500ns前後の固定費は取得時間に埋もれるので、分割は要りません。分割を検討するのは、プロファイルで Group の Mutex 待ちが目立ったときです。
singleflightを使わない方がよい場面
次の場面では採用しません。上から順に、事故になったときの影響が大きいものです。
- 書き込みや決済など副作用のある処理。2人分の注文をまとめれば、片方の注文は実行されません。
- 認証情報や権限で結果が変わる処理を、ユーザーIDを含まないキーでまとめる場合。別ユーザーのデータを返す情報漏えいになります。キーには結果を左右するすべての入力を入れます。
- 関数自体が数マイクロ秒で終わる処理と、キーがほとんど重ならない処理。
Doの固定費約500nsが増えるだけで、相乗りはほとんど起きません。
判断の目安は、取得に数ミリ秒以上かかり、同じキーへの同時アクセスが実際に起きているかどうかです。同時アクセスがなければsingleflightは不要です。取得が短時間でも、呼び出し頻度と重複数によっては効果があるため、キャッシュの可否と計測結果で判断します。Goのバックエンドでキャッシュ層や外部APIの呼び出し設計から見直す場合は、フルスクラッチ開発の支援でも相談を受けています。
ジェネリクス対応と代替ライブラリ
公式はany型のまま、ジェネリック版の提案は未決
x/sync の Group は v0.24.0 でもキーが string、値が any です。型パラメータを付けた版を追加する提案 #53427 は2022年6月から議論が続いており、Do 自体をジェネリックにする提案 #80120 は2026年6月に重複として閉じられました。当面は、次のような薄いラッパーで型アサーションを1か所に閉じ込めます。
func Do[T any](g *singleflight.Group, key string, fn func() (T, error)) (T, error, bool) {
v, err, shared := g.Do(key, func() (any, error) { return fn() })
if err != nil {
var zero T
return zero, err, shared
}
if v == nil {
var zero T
return zero, nil, shared
}
t := v.(T) // 同じGroup・キーでは結果型を統一する
return t, nil, shared
}
// 呼び出し側
u, err, _ := Do(&g, "user:7", func() (*User, error) {
fctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second)
defer cancel()
return queryUser(fctx, 7) // 先頭のキャンセルを持ち込まない
})
context対応のフォークを選ぶ条件
先頭のキャンセル問題を毎回 WithoutCancel で書くのが手間なら、context を引数に取るフォークを使う手があります。resenje.org/singleflight(2026年11月時点の最新は2024年9月3日公開の v0.4.3)は型パラメータに対応し、関数に渡す context は「待っている呼び出し元すべての context がキャンセルされたときだけ」キャンセルされます。待っている全員が諦めた時点で処理を止められるのが公式版にない利点で、一度も相乗りが無かった場合しか取り消さない net パッケージの ForgetUnshared より踏み込んだ動きです。
ただし戻り値の順序が (v, shared, err) と公式版と異なるため、置き換えの際は呼び出し側をすべて書き換えることになります。公式版の挙動で困っていないなら、ラッパー関数で済ませるほうが依存を増やしません。
高速化版catatsuy/cacheとの挙動の違い
Go Conference 2025で発表された github.com/catatsuy/cache(v0.5.2)の SingleflightGroup[V] は、型パラメータに対応し、READMEで公式版より最大2倍速いと説明しています。ソースのコメントには、panic と runtime.Goexit を扱わないこと、shared を返さないこと、完了したキーの削除を別ゴルーチンで非同期に行うことが明記されています。同じ機械で、どちらも計測用のカウンタを入れない同じ条件で測ると次のとおりでした。
| 条件 | x/sync v0.24.0 | catatsuy/cache v0.5.2 |
|---|---|---|
| 逐次・同じキー | 505ns | 97ns |
| 16並列・同じキー | 1,018ns | 277ns |
| 4並列・1024キー | 599ns | 1,406ns |
| 16並列・1024キー | 708ns | 1,887ns |
| 逐次2回目の呼び出し | 毎回実行 | 1000回中1000回、1回目の結果 |
| 関数のpanic後 | 待ち手に再送出 | 待ち手と以後の呼び出しが停止 |
同じキーを繰り返す条件では速い一方、この計測では分散したキーを並列で呼ぶと公式版より遅くなりました。理由は切り分けていません。挙動も違い、削除が非同期なので、直後の逐次呼び出しは関数を再実行せず1回目の結果を受け取りました。関数が panic すると、待っていた呼び出し元も、その後に同じキーで呼んだ呼び出し元も、2秒待っても戻りませんでした。取得関数が panic しないと保証でき、完了直後の結果を受け取っても困らない用途に限って選びます。
singleflightに関するよくある質問
singleflightはGoの標準ライブラリですか?
標準ライブラリではありません。Goチームが管理する準標準モジュール golang.org/x/sync のパッケージで、import パスは golang.org/x/sync/singleflight です(pkg.go.dev)。標準ライブラリの internal/singleflight は内部専用で import できません。v0.24.0 は go.mod が go 1.26.0 のため、ビルドには Go 1.26 以上のツールチェーンが使われます(Go 1.21 以降は既定で自動取得)。
single-flightパターンとは何ですか?
同じ対象への同時リクエストを1つの実行にまとめ、結果を全員で共有する設計パターンの呼び名です。リクエストコアレッシングとも呼ばれます。Goではこのsingleflightが代表的な実装で、Goチームの分散キャッシュ groupcache にも同名のパッケージがあります。nginx の proxy_cache_lock のように、プロキシや CDN がオリジンへの同時取得をまとめる機能も同じ発想です。
singleflightの結果はキャッシュされますか?
されません。関数が返った時点でキーは map から削除され、その後の呼び出しは関数を再実行します。結果を保存したい場合は、この記事の実装例のように、キャッシュを別に持ち、キャッシュミスの経路だけをsingleflightで包みます。
Javaでsingleflightに当たる仕組みはありますか?
標準APIに同名の機能はありません。近いのは ConcurrentHashMap.computeIfAbsent で、キーがなければ関数を1回だけ実行します。ただし Java SE 25 の Javadoc は、計算中は他スレッドの更新が待たされることがあるため計算を短く単純にするよう求めています。時間のかかる取得では CompletableFuture を値として登録し、完了後に map から取り除く形で書けます。
Groupはグローバル変数で1つ持てばよいですか?
ゼロ値で使えるので、パッケージ変数でも構造体のフィールドでも動きます。キーの名前空間は Group ごとに分かれるため、ユーザー取得と商品取得で別の Group を持つと、キーに接頭辞を付け忘れても衝突しません。用途ごとに分けておくと、1つの Mutex に呼び出しが集中するのも避けられます。