Posted at

golang.org/x/sync/singleflightで重複呼び出しを排除する

More than 1 year has passed since last update.

ユーザ操作などで、同じAPIを同時にリクエストされたけれど、例えばGETメソッドの場合は結果もほとんど同じになるので、リクエストを1回にまとめてしまいたい場合は少なくないと思います。

または、期限付きの認証トークンが必要なAPIを並行して実行しているケースで、トークンの期限が切れた直後で同時に2つのリクエストが行われても、トークンの更新は1回だけに制限したい場合もあるかもしれません。

そういった、「複数の呼び出しが同時に発生しても、結果は同じなので同時に1つだけ行って結果を共有する」という処理に、x/sync/singleflightが使えます。


実装例

重複の排除を行いたい部分を、singleflight.GroupDo(name, fn)でラップします。以下の例では、1ミリ秒ごとにcallAPI("work")が実行されますが、callAPI("work")は3ミリ秒の時間がかかるので、続く2回の呼び出しが起こった時にはまだ前の処理が終わっていません。そうするとsingleflight.Groupは1つ目の呼び出しが終わるまで待って、1回目の結果を使って、2回目と3回目の呼び出しが行われたかのように振る舞います。しかし実際にAPIが呼ばれるのは1回目だけです。

package main

import (
"log"
"sync"
"time"

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

var group singleflight.Group

func callAPI(name string) {
v, err, shared := group.Do(name, func() (interface{}, error) {
// 具体的に実行したい処理を書く
<-time.After(3 * time.Millisecond)
return time.Now(), nil
})
if err != nil {
log.Fatal(err)
}
log.Println("結果:", v, ", 重複が発生したか:", shared)
}

func main() {
log.SetFlags(0)

var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
callAPI("work")
}()
<-time.After(time.Millisecond)
}
wg.Wait()
}

この実行結果は、おおむね以下のようになります。時刻が完全に一致している点から、結果が再利用されているのがわかると思います。

結果: 2017-06-12 23:53:11.936580392 +0900 JST , 重複が発生したか: true

結果: 2017-06-12 23:53:11.936580392 +0900 JST , 重複が発生したか: true
結果: 2017-06-12 23:53:11.936580392 +0900 JST , 重複が発生したか: true
結果: 2017-06-12 23:53:11.940406256 +0900 JST , 重複が発生したか: true
結果: 2017-06-12 23:53:11.940406256 +0900 JST , 重複が発生したか: true
結果: 2017-06-12 23:53:11.940406256 +0900 JST , 重複が発生したか: true
結果: 2017-06-12 23:53:11.94409058 +0900 JST , 重複が発生したか: true
結果: 2017-06-12 23:53:11.94409058 +0900 JST , 重複が発生したか: true
結果: 2017-06-12 23:53:11.94409058 +0900 JST , 重複が発生したか: true
結果: 2017-06-12 23:53:11.94766342 +0900 JST , 重複が発生したか: false


説明

singleflight.GroupDo(name, fn)メソッドは、nameの値が同じ呼び出しが実行中であれば、2回目以降の呼び出しを止めておいて、実行中だった最初のfnの結果をそのまま共有します。そのため、重複した呼び出しは全て同じ結果となります。(最初がたまたまエラーになったら全てエラーです)

結果が、nameの一致したDo(name, fn)に共有された後は、nameは未実行の状態に戻るので、次の呼び出しのfnは待機されずに実行します。また、nameが実行中であっても、異なるnameが使われた場合はそのままfnが実行されます。

上記の例ではDo(name, fn)だけ使いましたが、戻り値ではなくチャネル経由で結果を返すDoChan(name, fn)も用意されています。