👋

【Go】 singleflight の Do メソッドを理解した

2023/07/02に公開

Go の singleflight の Do メソッドを"完全に理解"したので、そのメモです。

環境

Go 1.20.4
singleflight 0.3.0

書くこと

  • Go の singleflight パッケージ
  • singleflight の Do メソッド
  • サンプルコード
  • Do メソッドの実装
  • singleflight とは本質的には関係ないけど、実際の開発シーンではまったところ

Go の singleflight パッケージ

Go言語の singleflight は、任意の処理の同時・重複実行を効率化するためのパッケージです。

Package singleflight provides a duplicate function call suppression mechanism.

https://pkg.go.dev/golang.org/x/sync/singleflight

複数の呼び出し元から同時に同じ関数が呼び出された際、最初の呼び出し元から呼ばれた処理結果を一時的に保存し、他の呼び出し元でも再利用することが可能となります。
実際のユースケースとしては、Redis や Memcached などのキャッシュと組み合わせてDBリクエスト処理時に使用して、キャッシュの TTL が切れたタイミングでも一度の DB リクエストだけで再度キャッシュできるため、DB接続数を抑えることができます。
また、CPU やメモリなどのリソースを大量に消費する処理で使用することにより、サーバリソースを節約することができます。

singleflight の Do メソッド

第二引数で指定された関数を実行します。第一引数をキーとして、関数の実行中に同じキー文字列でDo メソッドが実行された場合、関数は実行されません。
既に実行中の関数が完了すると、その結果を受け取ります。

Do メソッドのシグネチャ
func (g *Group) Do(key string, fn func() (interface{}, error)) (v interface{}, err error, shared bool)

  • 引数
    • key: キー文字列、第二引数で指定する関数の実行結果を一意に特定するために使用
    • fn: 実行する関数、この関数に定義された戻り値が Do メソッドの戻り値
  • 戻り値
    • v, err : 第二引数で指定した関数の戻り値
    • shared: 複数の呼び出し元で結果を共有しているかどうかを示すフラグ

サンプルコード

手元で動かしてみます。

サンプルコード

package main

import (
	"context"
	"fmt"
	"time"

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

func main() {
	fmt.Println("--- Start ---")
	eg, _ := errgroup.WithContext(context.Background())

	var g singleflight.Group
	for i := 0; i < 5; i++ {
		eg.Go(func() error {
			val, _, _ := g.Do("key", func() (interface{}, error) {
				fmt.Println("function called")
				time.Sleep(1 * time.Second)
				return "value", nil
			})

			fmt.Println(val)
			return nil
		})
	}

	_ = eg.Wait()
	fmt.Println("--- Done ---")
}

実行結果

--- Start ---
function called
value
value
value
value
value
--- Done ---
  • 複数 goroutine から同時に Do メソッドを呼び出しています
    • goroutine は errgroup を利用しています(本筋ではありません)
  • Do メソッドの第2引数で指定した関数は、一度だけ実行されます
    • Do メソッドは5回呼び出していますが、関数は1回だけ実行されます
    • 1回目の呼び出し時に実行された関数の戻り値を、2回目以降の呼び出し時にも使用しています

ここでkey の値をループの度に変更すると、関数は5回実行されます。(function called が5回表示されます)

Do メソッドの実装

Do メソッドの実装をコードリーディングしたので、そのメモです。

Do メソッドの実装
https://cs.opensource.google/go/x/sync/+/refs/tags/v0.3.0:singleflight/singleflight.go;l=82

Group の構造体には、キャッシュされた結果を保持するマップ型の m フィールドがあります。

Group 構造体の定義

type Group struct {
	mu sync.Mutex       // protects m
	m  map[string]*call // lazily initialized
}

m フィールドは、キー文字列をキーとして関数(引数で受けた関数のポインタ)をキャッシュします。
マップのキーにキー文字列が存在する場合、関数は実行されません。m フィールドから関数のポインタを取得し、関数の実行完了を待ってからその関数の戻り値を返します。

Do メソッドの実装(抜粋)

if c, ok := g.m[key]; ok {
  c.dups++
  g.mu.Unlock()
  c.wg.Wait()

  (エラーハンドリング)

  return c.val, c.err, true
}

マップのキーにキー文字列が存在しない場合、関数を実行します。
マップに関数のポインタを入れて、他の呼び出し元からも同じ結果を得られるようにしています。

Do メソッドの実装(抜粋)

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

doCall では、第二引数で受け取った関数を実行しています。
defer の中で、マップから関数のポインタを削除しています。

singleflight とは本質的には関係ないけど、実際の開発シーンではまったところ

Do メソッドの戻り値は、第二引数で指定した関数の戻り値です。
つまり、第二引数の戻り値がポインタの場合、Do メソッドの戻り値もポインタになります。

戻り値をポインタとする場合、ポインタを複数のスレッドで共有されることとなるため、スレッドセーフな実装にする必要があります。
例えば、ファイルやソケットをポインタで複数スレッドと共有する場合、一つのスレッドでファイルを閉じたりソケットをクローズすることで、他のスレッドではファイルやソケットにアクセスできなくなります。

実際、sql パッケージの Query メソッドで取得した結果(sql.Rows のポインタ)を他のスレッドで使いまわし、結果を共有されたスレッドで意図しないタイミングで Rows.Close() が呼ばれたため、処理が失敗するケースがありました。
singleflight の使用箇所ではエラーが起きないこと、キャッシュが切れるタイミングでの同時リクエスト、かつ、処理中のスレッドが存在するタイミングで Rows.Close() を発生させないとエラーとならないことから、原因が分かりづらく調査に時間がかかりました。

まとめ

  • singleflight は、同時に発生する処理の重複を抑制するためのパッケージです。
  • singleflight の Do メソッドの実装を追ってみました。意外とシンプルな実装で読みやすく、Go 自体の勉強にもなりました。

最後に戒め

同僚の Go のエキスパートにとあるエラー事象の説明と仮説について相談したところ、とてつもない速さで Go のパッケージの実装を読んでくれて、仮説を机上検証してくれました。
強強エンジニアがちゃんと実装を読んでいるのに自分がやらないのでは弱々のままだな、と反省しました。ちゃんと読みます。

以上です。

GitHubで編集を提案

Discussion