singleflight

Table of Contents

  1. 問題
  2. singleflight の解決策
  3. 主な API
  4. 戻り値
  5. 典型的なユースケース
  6. 注意点
  7. サンプル
    1. main.go
    2. fetcher.go
    3. Result
    4. main_test.go

golang.org/x/sync/singleflight は、同じキーに対する重複した関数呼び出しを抑制する仕組みです。

問題

例えば、10個の goroutine が同時に同じユーザーデータを取得しようとすると、通常は 10 回 DB や API を叩きます

goroutine 1 → Fetch(“user:123”) → DB 呼び出し
goroutine 2 → Fetch(“user:123”) → DB 呼び出し ← 無駄
goroutine 3 → Fetch(“user:123”) → DB 呼び出し ← 無駄

singleflight の解決策

group.Do(key, fn) を使うと、同じキーで同時に呼ばれた場合、最初の1つだけが fn を実行し、残りはその結果を共有して待ちます。

goroutine 1 → Do(“user:123”, fn) → 実際に fn 実行 → 結果を返す
goroutine 2 → Do(“user:123”, fn) → 待機… → 同じ結果を受け取る
goroutine 3 → Do(“user:123”, fn) → 待機… → 同じ結果を受け取る

主な API

メソッド 用途
Do(key, fn) 重複抑制して実行。同じキーの呼び出しは結果を共有
DoChan(key, fn) Do の非同期版。チャネルで結果を受け取る
Forget(key) キーの進行中エントリを削除。次の呼び出しで再実行される

戻り値

1
2
v, err, shared := group.Do(key, fn)
// ^^^^^^ 結果が他の呼び出しと共有されたか

shared が true なら、他の goroutine と結果を共有したことを意味します。
shared が false なら、その結果を受け取ったのは自分だけだったことを意味します。

典型的なユースケース

  • キャッシュの thundering herd 対策 - キャッシュ期限切れ時に大量リクエストがDBに殺到するのを防ぐ
  • API ゲートウェイ - 同一リクエストの重複を排除
  • DNS リゾルバ - 同一ホスト名の並行解決を1回にまとめる

注意点

  • キャッシュではない - 実行完了後にキーは消える。次の呼び出しでは再実行される
  • エラーも共有される - 1つの実行が失敗すると、待機中の全呼び出しにエラーが返る
  • タイムアウト制御は自分で行う - context によるキャンセルは Do 自体にはないので、fn 内で対応する

サンプル

main.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package main

import (
"context"
"log/slog"
"os"
"sync"
)

func main() {
run("user:123")
slog.Default().Info("-----")
run("error")
}

func run(key string) {
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
fetcher := NewDataFetcher(logger)
ctx := context.Background()

const numGoroutines = 10

var wg sync.WaitGroup

wg.Add(numGoroutines)

for range numGoroutines {
go func() {
defer wg.Done()

result, err := fetcher.Fetch(ctx, key)
if err != nil {
logger.Error("fetch failed", logKeyError, err)
return
}

logger.Info("got result", logKeyValue, result.Value)
}()
}

wg.Wait()

logger.Info("done",
logKeyGoroutines, numGoroutines,
logKeyActualFetches, fetcher.FetchCount(),
)
}

fetcher.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
// Package main demonstrates singleflight usage to suppress duplicate concurrent calls.
package main

import (
"context"
"fmt"
"log/slog"
"sync/atomic"
"time"

"golang.org/x/sync/singleflight"
)

const (
logKeyKey = "key"
logKeyShared = "shared"
logKeyValue = "value"
logKeyError = "error"
logKeyGoroutines = "goroutines"
logKeyActualFetches = "actual_fetches"
)

// FetchResult represents data fetched from an external source.
type FetchResult struct {
Value string
FetchedAt time.Time
}

// DataFetcher fetches data using singleflight to deduplicate concurrent requests.
type DataFetcher struct {
group singleflight.Group
fetchCount atomic.Int64
logger *slog.Logger
}

// NewDataFetcher creates a new DataFetcher.
func NewDataFetcher(logger *slog.Logger) *DataFetcher {
return &DataFetcher{
group: singleflight.Group{},
fetchCount: atomic.Int64{},
logger: logger,
}
}

// Fetch retrieves data for the given key. Concurrent calls with the same key
// are deduplicated so that only one actual fetch executes.
func (f *DataFetcher) Fetch(ctx context.Context, key string) (FetchResult, error) {
v, err, shared := f.group.Do(key, func() (any, error) {
return f.doFetch(ctx, key)
})
if err != nil {
return FetchResult{}, fmt.Errorf("fetch %s: %w", key, err)
}

result, ok := v.(FetchResult)
if !ok {
return FetchResult{}, fmt.Errorf("fetch %s: unexpected result type", key)
}

f.logger.Info("fetch completed", logKeyKey, key, logKeyShared, shared)

return result, nil
}

// FetchCount returns the number of actual fetch operations performed.
func (f *DataFetcher) FetchCount() int64 {
return f.fetchCount.Load()
}

func (f *DataFetcher) doFetch(_ context.Context, key string) (FetchResult, error) {
f.fetchCount.Add(1)
f.logger.Info("performing actual fetch", logKeyKey, key)

// Simulate slow external call.
time.Sleep(2 * time.Second)

if key == "error" {
return FetchResult{}, fmt.Errorf("simulated fetch error for key %s", key)
}

return FetchResult{
Value: fmt.Sprintf("data-for-%s", key),
FetchedAt: time.Now(),
}, nil
}

Result

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
time=2026-02-11T01:36:40.053+09:00 level=INFO msg="performing actual fetch" key=user:123
time=2026-02-11T01:36:42.054+09:00 level=INFO msg="fetch completed" key=user:123 shared=true
time=2026-02-11T01:36:42.054+09:00 level=INFO msg="fetch completed" key=user:123 shared=true
time=2026-02-11T01:36:42.054+09:00 level=INFO msg="got result" value=data-for-user:123
time=2026-02-11T01:36:42.054+09:00 level=INFO msg="got result" value=data-for-user:123
time=2026-02-11T01:36:42.054+09:00 level=INFO msg="fetch completed" key=user:123 shared=true
time=2026-02-11T01:36:42.054+09:00 level=INFO msg="got result" value=data-for-user:123
time=2026-02-11T01:36:42.054+09:00 level=INFO msg="fetch completed" key=user:123 shared=true
time=2026-02-11T01:36:42.054+09:00 level=INFO msg="fetch completed" key=user:123 shared=true
time=2026-02-11T01:36:42.054+09:00 level=INFO msg="got result" value=data-for-user:123
time=2026-02-11T01:36:42.054+09:00 level=INFO msg="fetch completed" key=user:123 shared=true
time=2026-02-11T01:36:42.054+09:00 level=INFO msg="got result" value=data-for-user:123
time=2026-02-11T01:36:42.054+09:00 level=INFO msg="fetch completed" key=user:123 shared=true
time=2026-02-11T01:36:42.054+09:00 level=INFO msg="got result" value=data-for-user:123
time=2026-02-11T01:36:42.054+09:00 level=INFO msg="fetch completed" key=user:123 shared=true
time=2026-02-11T01:36:42.054+09:00 level=INFO msg="got result" value=data-for-user:123
time=2026-02-11T01:36:42.054+09:00 level=INFO msg="fetch completed" key=user:123 shared=true
time=2026-02-11T01:36:42.054+09:00 level=INFO msg="got result" value=data-for-user:123
time=2026-02-11T01:36:42.054+09:00 level=INFO msg="got result" value=data-for-user:123
time=2026-02-11T01:36:42.054+09:00 level=INFO msg="fetch completed" key=user:123 shared=true
time=2026-02-11T01:36:42.055+09:00 level=INFO msg="got result" value=data-for-user:123
time=2026-02-11T01:36:42.055+09:00 level=INFO msg=done goroutines=10 actual_fetches=1
2026/02/11 01:36:42 INFO -----
time=2026-02-11T01:36:42.055+09:00 level=INFO msg="performing actual fetch" key=error
time=2026-02-11T01:36:44.056+09:00 level=ERROR msg="fetch failed" error="fetch error: simulated fetch error for key error"
time=2026-02-11T01:36:44.056+09:00 level=ERROR msg="fetch failed" error="fetch error: simulated fetch error for key error"
time=2026-02-11T01:36:44.056+09:00 level=ERROR msg="fetch failed" error="fetch error: simulated fetch error for key error"
time=2026-02-11T01:36:44.056+09:00 level=ERROR msg="fetch failed" error="fetch error: simulated fetch error for key error"
time=2026-02-11T01:36:44.056+09:00 level=ERROR msg="fetch failed" error="fetch error: simulated fetch error for key error"
time=2026-02-11T01:36:44.056+09:00 level=ERROR msg="fetch failed" error="fetch error: simulated fetch error for key error"
time=2026-02-11T01:36:44.056+09:00 level=ERROR msg="fetch failed" error="fetch error: simulated fetch error for key error"
time=2026-02-11T01:36:44.056+09:00 level=ERROR msg="fetch failed" error="fetch error: simulated fetch error for key error"
time=2026-02-11T01:36:44.056+09:00 level=ERROR msg="fetch failed" error="fetch error: simulated fetch error for key error"
time=2026-02-11T01:36:44.056+09:00 level=ERROR msg="fetch failed" error="fetch error: simulated fetch error for key error"
time=2026-02-11T01:36:44.056+09:00 level=INFO msg=done goroutines=10 actual_fetches=1

main_test.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
package main_test

import (
"context"
"log/slog"
"sync"
"testing"

main "github.com/mocoarow/go-playground/singleflight"
)

func newTestLogger(t *testing.T) *slog.Logger {
t.Helper()

return slog.New(slog.NewTextHandler(&testWriter{t: t}, nil))
}

type testWriter struct {
t *testing.T
}

func (w *testWriter) Write(p []byte) (int, error) {
w.t.Helper()
w.t.Log(string(p))

return len(p), nil
}

func Test_Fetch_shouldDeduplicateConcurrentCalls_whenSameKeyIsUsed(t *testing.T) {
t.Parallel()

// given
logger := newTestLogger(t)
fetcher := main.NewDataFetcher(logger)
ctx := context.Background()

const numGoroutines = 10

// when
var wg sync.WaitGroup

wg.Add(numGoroutines)

for range numGoroutines {
go func() {
defer wg.Done()

result, err := fetcher.Fetch(ctx, "user:123")
if err != nil {
t.Errorf("unexpected error: %v", err)
return
}

if result.Value != "data-for-user:123" {
t.Errorf("unexpected value: %s", result.Value)
}
}()
}

wg.Wait()

// then
if fetcher.FetchCount() >= int64(numGoroutines) {
t.Errorf("expected fewer fetches than goroutines, got %d fetches for %d goroutines",
fetcher.FetchCount(), numGoroutines)
}
}

func Test_Fetch_shouldFetchIndependently_whenDifferentKeysAreUsed(t *testing.T) {
t.Parallel()

// given
logger := newTestLogger(t)
fetcher := main.NewDataFetcher(logger)
ctx := context.Background()

// when
result1, err := fetcher.Fetch(ctx, "user:1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

result2, err := fetcher.Fetch(ctx, "user:2")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

// then
if result1.Value != "data-for-user:1" {
t.Errorf("unexpected value for key user:1: %s", result1.Value)
}

if result2.Value != "data-for-user:2" {
t.Errorf("unexpected value for key user:2: %s", result2.Value)
}

if fetcher.FetchCount() != 2 {
t.Errorf("expected 2 fetches, got %d", fetcher.FetchCount())
}
}