体はドクペで出来ている

インフラ、Goの割合が多い技術ブログ

GolangのContext入門

Contextとは何か?

Golangには context という標準パッケージがあります(以前は実験パッケージ golang.org/x/net/context でしたがGolang1.7から標準採用されました)。これは「コールグラフ下流をまとめてキャンセルさせたい」「リクエストスコープな値をコールグラフ下流に伝播させたい」という場合に使用します。

Webアプリケーションを作る場合で想定してみましょう。リクエストが来るとHTTPハンドラーが何か値を取り出して関数に渡し、その関数が更に別の関数を呼び、DBから情報を取得したりと諸々の処理を経てユーザーへレスポンスを返すとします。この時何らかのトラブルがあってDBのレスポンスが極端に遅くなるとユーザーはいつまでも待つことになってしまうのでタイムアウトをかけたいですね?しかし真っ当に実装するとchannelをあちこちに持ち回したりしないといけませんし、どんな理由でエラーとなったのかをまた別途伝えたりしなければならない等大変煩わしいので、そういったものをひとまとめに扱えるようにしたのが context パッケージです。

キャンセル以外にも1回のリクエストであちこちに持ち回すような性質の値を一緒に運ぶことができます(例えばユーザーIDやトークン等)。関数の引数で userID string のように持ち回してもいいのですが、やはりこれも何度もやるのは煩雑になるのでキャンセルと同様にひとまとめにして扱う方が便利です。

キャンセルの使い方

手動キャンセル

ctx-with-cancel.go

package main

import (
    "context"
    "errors"
    "log"
    "sync"
    "time"
)

func main() {
    ctx, cancel := context.WithCancel(context.Background()) // キャンセル機能付きContextを発行
    defer cancel()

    var wg sync.WaitGroup
    wg.Add(1)
    go func() {
        defer wg.Done()
        if err := heavyProcess(ctx, 5); err != nil { // 5秒後にエラーを起こす
            cancel() // エラーが発生したらキャンセルを行う
            log.Printf("Failed to heavy process(1): %s\n", err.Error())
        }
    }()

    wg.Add(1)
    go func() {
        defer wg.Done()
        if err := heavyProcess(ctx, 10); err != nil { // 10秒後にエラー起こす
            cancel()
            log.Printf("Failed to heavy process(2): %s\n", err.Error())
        }
    }()

    wg.Wait()
}

func heavyProcess(ctx context.Context, waitSec int) error {
    select {
    case <-ctx.Done():
        return ctx.Err()
    case <-time.After(time.Duration(waitSec) * time.Second): // 指定された時間が経過したらエラーを発生させる
        return errors.New("some error")
    }
}

上記のコードで試してみましょう。まずキャンセル機能付きのContextを発行するため ctx, cancel := context.WithCancel(context.Background()) を行います。 context.Background() は値もキャンセル機能も無い空のContextを発行するので、これに対しキャンセル機能を付与する、というような意味合いです。メソッドを見るとわかる通りContextはイミュータブルなので下流向けに変更を加える場合は必ずラップする必要があります。

そして関数 heavyProcess は何らかの重たい処理を模したもので第一引数に context.Context を、第二引数で処理にかける時間を指定します。このheavyProcessは第二引数で指定された時間が経過すると errors.New("some error") を返却するため必ず失敗します。これがgoroutineで2つ起動され1つ目は5秒後、2つ目は10秒後にerrorを返却するので通常であれば実行開始から5秒後と10秒後にエラーのログが出力されるはずです。しかし今回はエラーが発生したら cancel() が発動するようになっているので5秒後に2つのログが出ます。

Contextからキャンセルの発動を受け取るには Done() メソッドを使用します。このメソッドは空struct型の受信専用channelを返却するのでこれと本来の処理完了をselectで待ち受ければキャンセル処理を行うことができます(キャンセルが発動するとこのchannelがcloseされる)。

Err() メソッドはContextがキャンセルされた理由が含まれるerrorを返却するので Done() でシグナルを受け取った後はこれを返却するのが基本になります。

では実行してみましょう。ログの時刻とメッセージに注目すると想定通り1つ目のエラーが起きた時点でまだ処理中である2つ目のheavyProcessがキャンセルされていることがわかります。

$ go run main.go
2019/05/03 16:14:41 Failed to heavy process(1): some error
2019/05/03 16:14:41 Failed to heavy process(2): context canceled

タイムアウト

ctx-with-timeout.go

package main

import (
    "context"
    "errors"
    "log"
    "sync"
    "time"
)

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) // 3秒タイムアウト付きContextを発行
    defer cancel()

    var wg sync.WaitGroup
    wg.Add(1)
    go func() {
        defer wg.Done()
        if err := heavyProcess(ctx, 5); err != nil { // 5秒後にエラーを起こす
            cancel()
            log.Printf("Failed to heavy process(1): %s\n", err.Error())
        }
    }()

    wg.Add(1)
    go func() {
        defer wg.Done()
        if err := heavyProcess(ctx, 10); err != nil { // 10秒後にエラー起こす
            cancel()
            log.Printf("Failed to heavy process(2): %s\n", err.Error())
        }
    }()

    wg.Wait()
}

func heavyProcess(ctx context.Context, waitSec int) error {
    select {
    case <-ctx.Done():
        return ctx.Err()
    case <-time.After(time.Duration(waitSec) * time.Second): // 指定された時間が経過したらエラーを発生させる
        return errors.New("some error")
    }
}

基本的な考え方は手動キャンセルと同じですが、指定時間が経過すると自動的にキャンセルシグナルが発行されます。先程は context.WithCancel で発行していましたが今度は context.WithTimeout を使い(日時ベースにしたい場合は context.WithDeadline を使うと楽)、第二引数に3秒の制限時間を加えました。それ以外は同じ処理なので、今度は3秒後に両方のheavyProcessがタイムアウトするはずです。

これを実行してみると3秒でタイムアウトになり両方のgoroutineが終了していることが確認できます。

$ go run main.go
2019/05/03 16:40:51 Failed to heavy process(2): context deadline exceeded
2019/05/03 16:40:51 Failed to heavy process(1): context deadline exceeded

Valueの使い方

ctx-with-value.go

package main

import (
    "context"
    "fmt"
)

type ctxKey string

const (
    ctxUserID ctxKey = "UserID"
)

func main() {
    requestHandler("testID")
}

func requestHandler(userID string) {
    ctx := context.WithValue(context.Background(), ctxUserID, userID)
    printUserID(ctx)
}

func printUserID(ctx context.Context) {
    userID, ok := ctx.Value(ctxUserID).(string)
    if !ok {
        panic("なんか変")
    }
    fmt.Printf("UserID: %s\n", userID)
}

Contextに値を持たせたい場合は context.WithValue(ctx, key, value) の形で行います。書き方はやや変則的かもしれませんが、基本的な考え方はMapと同じくKey/Value方式です。但しキーは独自型を定義しそれを指定しています(詳細な理由は次の見出しで解説)。またContextのValueinterface{} 型で値をやりとりするため、受け取り時はアサーションで所定の型にする必要があります。

Valueのキーにプリミティブ型を使うのは非推奨

Contextは下流に向けて次々と受け継がれていくもの且つイミュータブルなので、もし誰かが同じキー名で値を上書きしてしまった場合いつの間にか値がすり替わる面倒なバグを生むことになります。なのでキー名にはstring等のプリミティブ型ではなく自パッケージにしかスコープが無い独自型を定義して扱うことが推奨されています。

Valueに何を含め、何を含めないのか

Contextパッケージの公式ドキュメントには以下のように記載されています *1

Use context Values only for request-scoped data that transits processes and APIs, not for passing optional parameters to functions.

意訳すると ContextのValueはプロセス・APIをまたがるリクエストスコープなデータで使う。関数へのオプションパラメーターの受け渡しとして使ってはいけない。 といったところでしょうか?私なりにContextへ含めていいものの性質を解釈してみました。

  • リクエストスコープ且つイミュータブルな値
    • プロセスやAPIをまたいで使われるもの
      • そうでないものをコールグラフの流れで受け継がれるための箱に含める意味は無い
    • 処理の途中で値が変化しないもの
      • 基本的にリクエストスコープな値は外から来るものなので、変化するものはこれに合致しないはず。
  • ロジックを含まないもの、ロジックに影響しないもの
    • Contextはコールグラフに沿ってデータを受け渡すためのもので、ロジックに影響を与えるものは引数でやりとりすべき
    • 「ロジックに影響」というのは処理の流れがまるっと変化する類のもの。極端な例としては、ユーザーがフォームで選択した内容をContextに含め後段で処理を変えるようなことはNG。

またValueが受けられる型は interface{} ですが、これを悪用して何でも放り込める魔法の箱にしてしまうと可読性が著しく下がりデバッグ・メンテナンス時に大変苦労することになるでしょう。Contextと引数で来ているのかが曖昧ではそれぞれの責務が重複してしまい混乱を招きます。

その他お約束事

  • Contextは第一引数で受け渡しすること
  • Contextの変数名は ctx もしくは c が一般的
  • 上流から来るContextが何かまだわからない場合は暫定として context.TODO を発行する

まとめ

  • Contextはリクエストで発生する関連処理をまとめてキャンセルできる機能を提供する
    • 時限発動・任意のタイミング両方に対応
  • Contextはリクエストで共通して使用するデータをまとめて運搬できる機能を提供する
    • 但し何でも入る魔法の箱にしてはいけない

参考文献