Go

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)も用意されています。