😊

公式singleflightを約2倍高速化したGenerics対応実装と最適化 #gocon

に公開

Go Conference 2025発表資料

自己紹介

ISUCON本書影

  • catatsuyのIDで各種SNSにいます
  • ISUCON練習用のprivate-isuや、GoのCLIツールとしてsed/grep代替のpurl、Slackにいい感じに投稿するnotify_slack、大量のファイルのリストを出せるlls、ChatGPTやGemini APIを手軽にCLIで利用できるbentoなどのOSSを趣味で開発・メンテナンスしています
    • 新作としてソースコード改竄検知ツールkekkaiをGoで開発して、会社のブログで紹介しました
    • 気になるrepoがあれば⭐️をお願いします!

https://developers.prtimes.jp/2025/09/22/kekkai-design-and-deployment/

singleflightとは

  • Goのgolang.org/x/sync/singleflightに含まれるライブラリ
  • 同じキーに対する並行呼び出しを1回にまとめ、他はその結果を待つ仕組み
  • メリット
    • 重複処理の抑制
    • データベースや外部APIへの負荷軽減
  • 利用シーン
    • キャッシュミス時の高コスト処理の多重実行防止
    • 標準ライブラリでのDNS解決の同時実行抑制

この発表で得られること

  • singleflightの内部実装の理解
  • 公式実装の設計意図とその課題点
  • Lock削減によるパフォーマンス改善の勘所
  • 実用的な自作実装とベンチマーク結果

https://github.com/catatsuy/cache

こちらの自作ライブラリの解説をします。気に入ったら⭐️を付けてくれるとうれしいです!

singleflightのFork状況

  • Go本体
    • internal/singleflightがあり、標準ライブラリ内部で利用
  • golang/groupcache
    • 古いsingleflight実装を同梱して利用
  • tailscale
    • bradfitzによるGenericsに対応したsingleflightを内部で利用

実は標準内外の複数プロジェクトでForkが使われている

singleflightの使い方

ISUCON本 7章4節 具体的なキャッシュ実装方法より抜粋(自著、Goに不慣れな読者のために簡略化したコードになっている)

var group singleflight.Group

type Cache struct {
  mu    sync.Mutex
  items map[int]int
}

func (c *Cache) Get(key int) int {
  c.mu.Lock()
  v, ok := c.items[key]
  c.mu.Unlock()

  if ok {
    return v
  }

  // singleflightを使うと複数回同時に呼び出された場合は2つ目以降は1つ目の実行が終了するのを待つ
  vv, err, _ := group.Do(fmt.Sprintf("cacheGet_%d", key), func() (interface{}, error) {
    value := HeavyGet(key)
    c.Set(key, value)
    return value, nil
  })

  if err != nil {
    panic(err)
  }

  // interface{}型なのでint型に型アサーション
  return vv.(int)
}

singleflightの実装

複雑なので省略しながら紹介

注:実装が簡略化されている自作singleflightの実装を後で詳しく解説するので、ここでは大づかみで流れを追えれば十分です

func (g *Group) Do(key string, fn func() (interface{}, error)) (v interface{}, err error, shared bool) {
  // g.m(map)の読み書きを直列化するためにLock
  g.mu.Lock()
  if g.m == nil {
    // 初回のみmapを作成する必要がある
    g.m = make(map[string]*call)
  }
  // 毎回ここで存在確認を行う
  if c, ok := g.m[key]; ok {
    // このブロックに入るということは現在実行中
    c.dups++
    g.mu.Unlock()
    // ここで処理完了を待つ。c.wg.Done()されたら再開する。
    c.wg.Wait()

    if e, ok := c.err.(*panicError); ok {
      panic(e)
    } else if c.err == errGoexit {
      runtime.Goexit()
    }
    return c.val, c.err, true
  }
  // 初回実行の場合はここに来る
  c := new(call)
  c.wg.Add(1)
  g.m[key] = c
  g.mu.Unlock()

  g.doCall(c, key, fn)
  return c.val, c.err, c.dups > 0
}

func (g *Group) doCall(c *call, key string, fn func() (interface{}, error)) {
  defer func() {
    // 実行が完了したため、g.m[key]を削除する必要がある
    // doCall自体が初回実行時しか実行されていない
    // g.m[key]の削除は1回だけ実行すればよいので、ここで削除処理をする
    // mapの操作を行うのでLockが必要
    // 後ほど解説するが、ここをgoroutineで非同期にするのが自作singleflightの大きな最適化部分
    g.mu.Lock()

    // deferでUnlockしているので、この関数が実行終了するにはUnlockの完了を待つ必要がある
    defer g.mu.Unlock()
    // c.wg.Wait()をしている待機側はここで実行を再開できる
    c.wg.Done()

    if g.m[key] == c { delete(g.m, key) }

    // --- panic/Goexit 対応(詳細は省略) ---
    //   if e, ok := c.err.(*panicError) { ... re-panic ... }
    //   else if c.err == errGoexit { ... }
    //   else { deliver results to chans ... }
  }()

  // 実行とpanic捕捉(詳細は省略、recoverでc.errに格納)
  func() {
    defer func() {
      if r := recover(); r != nil {
        c.err = newPanicError(r) // 公式は専用型に包む
      }
    }()
    // 関数の実体を実行
    c.val, c.err = fn()
  }()
}

【補足】共有フラグの使いどころ

  • サンプルコードで無視している第3返り値は共有フラグで、関数が同時実行されていたらtrueが返ってくる
    • 呼び出し結果を再利用していることを示している
  • 実運用では使う場面が限られ、ほとんどのサンプルコードは無視している
    • 利用するパターンとして返り値がスライスのような参照型の場合、同じ参照先を共有しているため、要素を変更すると他の処理に影響を与えることがある
      • 問題がある場合は共有フラグがtrueのときはcopyする
    • これ以外のパターンで使うことはないと思っているので、それ以外の用途があれば教えてください
  • 実際にnet.Lookup*系の関数で同時にDNS解決されたリストを共有しないように利用されている
    • 内部的に同時にDNS解決をする場合は1つにまとめられるようになっている
    • 標準ライブラリは性質上、自前でキャッシュをしにくいが、アプリケーションで利用する場合、singleflightはキャッシュとセットで使うことがほとんどと思われるので、実際問題として共有フラグはまず使わないと考えられる
  • よって同時実行されていることを示すフラグと一般的に理解されているが、実際の用途を考えると、参照型を返す場合に「参照を共有しているか」を知らせるためのフラグと考えるとよい
    • 参照型を返して書き換える設計でなければ不要なので、今回の自作版では省略

公式版の問題点①「型安全性」

公式版の問題点②「Lockが多い」

  • mapの初期化分岐はコンストラクタで除去可能
    • ただし存在確認・登録・削除のためのg.muのLockは毎回必要(mapは並行安全ではない)
    • 変数を宣言したタイミングではmapが作成されていないので、mapの作成処理をなくすにはインターフェイスを変更する必要がある
  • delete(g.m, key)のためのg.muのLock獲得待ちで返却がブロック
    • mutexのLockは遅いので数を減らせるとパフォーマンス面で大きい
    • 自作版はここを非同期化(後述)

設計方針:panicは扱わず(関数はerrorで失敗を返す前提)

  • panicはバグのシグナルでsingleflightに渡す処理はerrorで失敗を返す前提
    • そもそもpanicする関数をsingleflightに渡す実装が誤っている
    • singleflightにpanicする関数を渡されたときのことを考慮してrecoverのコードを追加するとコードは複雑化する
    • 本実装は実行結果の返却早期化・ロック削減に特化
      • panic対応が必要なら公式版を推奨
  • 本実装は性能と実装の読みやすさを優先し、panic安全性はNon-Goal

singleflightを自作することに

  • Generics対応したsingleflightが欲しい
  • パフォーマンス面で無駄なLockが公式版にあることに気付いて改善したい
  • ほぼ使われていない共有フラグやpanicのために処理が増えている
  • 公式がGenericsに対応するのはしばらく先になりそうなのと、Generics対応をしたらAPIの互換性はなくなるので、1から自作することに
  • シンプルで高速な実装を目指す

自作singleflightのNon-Goals

  • panic安全性
  • 共有フラグ(第3返り値)
  • DoChan等のチャネル対応

自作singleflightの使い方

// NewSingleflightGroup関数で内部でmapを作成することで、初回実行時にmapを作るLockをそもそもなくす
sf := cache.NewSingleflightGroup[string]()

// Genericsで型を指定しているので返り値を型アサーションする必要はない
v, err := sf.Do("example_key", func() (string, error) {
  return "result", nil
})

NewSingleflightGroup関数

// mを操作するときはLockする必要があるので、mを操作する前にmuでLockする
type SingleflightGroup[V any] struct {
  mu sync.Mutex
  m  map[string]*call[V]
}

// 実行中の関数はcall構造体で表現される
type call[V any] struct {
  // 関数を1回しか実行しないようにsync.Mutexでロックする
  mu    sync.Mutex
  // 関数の返り値
  value V
  err   error
  // 関数の実行が完了しているかどうかを表す
  done  bool
}

// NewSingleflightGroup関数でmapを作成することで、Lock内の処理を減らす
func NewSingleflightGroup[V any]() *SingleflightGroup[V] {
  return &SingleflightGroup[V]{
    m: make(map[string]*call[V]),
  }
}

Do関数

func (sf *SingleflightGroup[V]) Do(key string, fn func() (V, error)) (v V, err error) {
  // mapに値が存在していなければ初回実行、存在していれば実行中なのでLockして確認する
  sf.mu.Lock()
  c, ok := sf.m[key]
  if !ok {
    // 存在していなければこれから実行するためにmapに値を代入する
    c = &call[V]{}
    sf.m[key] = c
  }
  sf.mu.Unlock()

  // ここは関数実行のLockになる。ここでLockされる場合は関数が実行中で、Lockされない場合は初回実行
  c.mu.Lock()
  // c.muをLockしているので、cの変数を安全に参照することができる。c.doneは関数が実行済みかどうかを表す
  if !c.done {
    // doneがfalseなら実行されていないので関数を実行する
    c.value, c.err = fn()
    // 関数が実行終了したらdoneをtrueにする
    c.done = true

    // ここが高速化の要
    // 関数は実行が完了しているので、もうsf.mのmapからは削除する必要がある
    // しかし値はもう返してよいので、goroutineで削除処理を非同期にすることで実行結果を早く返せる
    // 直後の同一キー呼び出しは、削除前のエントリを再利用する可能性がある
    // ただしsingleflightの目的(同時実行抑止)には反しないため、意図通りのトレードオフ
    go func() {
      sf.mu.Lock()
      if sf.m[key] == c {
        delete(sf.m, key)
      }
      sf.mu.Unlock()
    }()
  }
  c.mu.Unlock()

  return c.value, c.err
}

公式版・自作版のフロー図

実用的なサンプルコード

github.com/catatsuy/cacheにはシンプルなキャッシュの仕組みも別に存在しているので、以下のようなコードが書ける。

var (
  c  = cache.NewWriteHeavyCache[int, int]()
  sf = cache.NewSingleflightGroup[int]()
)

// Getは、キャッシュから値を取得するか、キャッシュされていない場合はHeavyGetを用いてロードする。
// Singleflight により、同じキーに対しては一度しかHeavyGetを呼び出しません。
func Get(key int) (int, error) {
  if value, found := c.Get(key); found {
    return value, nil
  }

  // 同じキーに対する HeavyGet の重複呼び出しを防ぐために SingleflightGroup を使用
  v, err := sf.Do(fmt.Sprintf("cacheGet_%d", key), func() (int, error) {
    // HeavyGetが実際に値を取得する処理
    value := HeavyGet(key)
    // 取得した値をキャッシュに保存
    c.Set(key, value)
    return value, nil
  })
  if err != nil {
    return 0, err
  }

  return v, nil
}

stale-while-revalidateで古い値を即返し、更新はsingleflightで重複実行抑止

var (
  c  = cache.NewWriteHeavyCacheExpired[int, int]()
  sf = cache.NewSingleflightGroup[int]()
)

func Get(key int) (int, error) {
  if v, found, expired := c.GetWithExpireStatus(key); found {
    if !expired {
      return v, nil
    }

    // expireしていたら非同期で取得する
    go func(k int) {
      sf.Do(fmt.Sprintf("cacheGet_%d", k), func() (int, error) {
        value := HeavyGet(k)
        c.Set(k, value, 1*time.Minute)
        return value, nil
      })
    }(key)
    return v, nil
  }

  // キャッシュに存在しない場合は取得する
  v, err := sf.Do(fmt.Sprintf("cacheGet_%d", key), func() (int, error) {
    value := HeavyGet(key)
    c.Set(key, value, 1*time.Minute)
    return value, nil
  })
  if err != nil {
    return 0, err
  }
  return v, nil
}

ベンチマーク

4つの実装で比較

  • 公式singleflight (std)
  • 公式singleflightの返り値をintに型アサーション(std-cast
  • 公式singleflightに最小パッチでGenerics対応(generics
  • 自作singleflight(custom

ベンチマーク条件

  • 環境:EC2 c7g.xlarge (Graviton3, 4 vCPU) / Debian 13 / Go 1.25.1
  • 実行:go test -bench=. -benchmem -benchtime=3s -cpu=1,2,4(RunParallel)
    • GOMAXPROCS = 1 / 2 / 4
  • ワークロード:keys=1/10fnは軽量(return i, nil
    • keys=1常時同一キー(=常時衝突)
    • keys=1010種類に分散(1/10で衝突)
  • 指標:ns/op(低いほど速い)

結果


  • customが全条件で最速
    • keys=1/GOMAXPROCS=14.6倍(std: 195.1 ns/op → custom: 42.49 ns/op)
    • keys=10/GOMAXPROCS=41.5倍(std: 302.3 ns/op → custom: 199.8 ns/op)
  • stdとstd-castはほぼ同等(型アサーション有無の差は誤差)
  • genericsはstdとほぼ同等

ベンチマーク生データ(keys=1:常時同一キー)

Impl GOMAXPROCS=1 GOMAXPROCS=2 GOMAXPROCS=4
std 195.1 225.8 337.7
std-cast 198.7 221.6 331.6
generics 191.7 217.5 323.9
custom 42.49 149.9 151.2

ベンチマーク生データ(keys=10:衝突1/10)

Impl GOMAXPROCS=1 GOMAXPROCS=2 GOMAXPROCS=4
std 197.5 215.0 302.3
std-cast 197.3 214.9 296.0
generics 193.7 211.1 286.6
custom 49.51 135.4 199.8

実コードの使い分け

重視ポイント 自作 公式 コメント
軽量ユースケース 起動オーバーヘッド小、依存なし
Generics型安全 × 型アサーション不要
パフォーマンス最適化 Lock削減・割当削減
panic対応 × panicしうる関数は公式singleflight
共有フラグ × 参照型返却かつ書き換える場合は必要
超長期運用・実績重視 実績の安定性を重視するなら公式

最後に

  • 自作singleflightの特徴
    • 実装がシンプルで理解しやすい
    • Lockを減らしているのでパフォーマンスが強み
    • Generics対応なので、型アサーションが不要で型安全
  • 今回の発表でsingleflightの実装について理解を深めてくれたら幸いです
    • そして機会があればぜひcatatsuy/cacheを使ってみてください
    • 気に入ったら⭐️を付けてくれるとうれしいです!

Discussion