Goの並行パターン:コンテキスト (Go Concurrency Pattern: Context)

Goの並行パターン:コンテキスト #

Go Concurrency Pattern: Context by Sameer Ajmani

はじめに #

Goで書かれたサーバでは、サーバに来たリクエストはそれぞれそれ自身のゴルーチンで処理されます。 リクエストハンドラはしばしばデータベースやRPCサービスといったバックエンドにアクセスするために追加のゴルーチンを起動します。 リクエストの処理を行っているゴルーチンは、通常エンドユーザのアイデンティティや認可トークン、リクエストの期限などリクエスト固有の値へのアクセス権が必要です。 リクエストがキャンセルされたりタイムアウトした場合には、それらのゴルーチンが使っていたリソースをシステムが再度要求することができるように、そのリクエストの処理を行っているすべてのゴルーチンは素早く終了すべきです。

Googleで私たちは、APIの境界をまたぐリクエスト固有の値やキャンセルのシグナル、期限などを、 あるリクエストの処理に関与するすべてのゴルーチンに投げることを容易にする、 context パッケージというパッケージを開発しました。 パッケージは golang.org/x/net/context に公開されています。 1 この記事ではそのパッケージの使い方と実際に動作する例を紹介したいと思います。

コンテキスト(Context) #

context パッケージの核となっているのは Context 型です。

// ContextはAPIの境界を越えて期限とキャンセルシグナルとリクエスト固有の値を保持します。
// メソッドは複数のゴルーチンから同時に呼び出されても安全です。
type Context interface {
    // Doneはこのコンテキストがキャンセルされたりタイムアウトした場合にcloseされます。
    Done() <-chan struct{}

    // ErrはDoneチャンネルが閉じた後なぜこのコンテキストがキャンセルされたかを知らせます。
    Err() error

    // Deadlineは設定されている場合にはいつこのContextがキャンセルされるかを返します。
    Deadline() (deadline time.Time, ok bool)

    // Valueはkeyに紐付いた値を返し、設定がない場合はnilを返します。
    Value(key interface{}) interface{}
}

(この説明は要約されたもので、 godoc が正式なものです。)

Done メソッドは、 Context の代わりに動作する関数に対するキャンセルシグナルとして振る舞うチャンネルを返します。チャンネルが閉じられたときに、関数は処理を中断して戻るべきです。 Err メソッドはなぜその Context がキャンセルされたかを示すエラーを返します。 パイプラインとキャンセル の記事では Done チャンネルのイディオムについてより詳細に議論しています。

Context には Done チャンネルが受信専用であるのと同様の理由で Cancel メソッドがあり ません 。 キャンセルシグナルを受け取る関数は通常シグナルを送る関数ではありません。 特に、親の操作が子の操作を行うためのゴルーチンを起動したときに、それらの子の操作が親の操作をキャンセルできるべきではありません。 代わりに WithCancel 関数(あとで説明します)で新しい Context の値をキャンセルする方法を提供します。

Context は複数のゴルーチンから同時に使われても安全です。コード内では1つの Context を任意の数のゴルーチンに渡し、その Context をキャンセルしてすべてのゴルーチンに伝えることができます。

Deadline は関数が処理を始めるべきかどうかを決定することができるメソッドです。 もし残り時間が少なければ、起動する価値はありません。コードでは期限をI/O操作のタイムアウトとして利用することもあるでしょう。

Value によって Context がリクエスト固有のデータを運ぶことができます。 そのようなデータは複数のゴルーチンによって同時に利用されても安全です。

派生したコンテキスト #

context パッケージでは既存のコンテキストから新しい Context の値を 派生する 関数を提供しています。 これらの値は木構造になっていて、 Context がキャンセルされたときに、そこから派生した Context もすべてキャンセルされます。

Background はすべての Context 木構造の根になっていて、決してキャンセルされることはありません。

// Background は空の Context を返します。
// そのコンテキストは決してキャンセルされることはなく、期限はなく、また値を持ちません。
// Backgroundは通常、main、init、テストの中で使われ、到着するリクエストの
// 最上位の Context として使われます。
func Background() Context

WithCancelWithTimeout は派生した Context を返します。 この Context は親の Context よりも早くキャンセルされます。 到着するリクエストに紐付いた Context は通常リクエストハンドラが戻すとキャンセルされます。 WithCancel は複数のレプリカを使うときにまた冗長なリクエストをキャンセルするのにも便利です。 WithTimeout はバックエンドサーバーへのリクエストの期限を設定するのに便利です。

// WithCancel は parent のコピーを返し、 parent.Done が閉じられた、
// またはキャンセルが呼ばれるとすぐに、その Done チャンネルが閉じられます。
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)

// CancelFunc は Context をキャンセルします。
type CancelFunc func()

// WithTimeout は parent のコピーを返し、 parent.Done が閉じられた、
// または timeout が過ぎるとすぐに、その Done チャンネルが閉じられます。
// Context の Deadline は 現在時刻+timeout か親の期限のどちらか早いほうに設定されます。
// もしタイマーがまだ動いていた場合、キャンセル関数はそのリソースを解放します。
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

WithValue はリクエスト固有の値を Context に紐付ける方法を提供します。

// WithValue は親のコピーを返し、そのValueメソッドがkeyに対しvalを返すようにします。
func WithValue(parent Context, key interface{}, val interface{}) Context

context パッケージの使い方を理解するには動く実例を通して見るのが最良でしょう。

例: Google ウェブ検索 #

一つの例は /search?q=golang&timeout=1s のようなURLを処理して「golang」という検索クエリを Google Web Search API に投げて、 その結果を表示するようなHTTPサーバーです。 timeout パラメータはサーバーに指定時間が経過したら リクエストをキャンセルするように伝えます。

コードは3つのパッケージに分かれています。

  • servermain 関数と /search のハンドラを提供します。

  • userip はリクエストからユーザーのIPアドレスを抜き出し、 Context に紐付ける関数を提供します。

  • google はGoogleにクエリを送信する Search 関数を提供します。

サーバーのプログラム #

server のプログラムは /serach?q=golang のようなリクエストを処理して golang という検索クエリによるGoogle検索の最初の結果いくつかを返します。サーバーでは handleSearch という関数を /search のエンドポイントとして登録しています。ハンドラは ctx という最初の Context を生成して、ハンドラが値を返すときにそれがキャンセルされるように設定します。 もしリクエストに timeout というURLパラメーターが含まれていたら、 Context は期限が来たら自動的にキャンセルされます。

func handleSearch(w http.ResponseWriter, req *http.Request) {
    // ctx はこのハンドラの Context です。 cancel を呼ぶことで
    // ctx.Done チャンネルが閉じられます。これで、このハンドラからのリクエスト用の
    // キャンセルシグナルです。
    var (
        ctx    context.Context
        cancel context.CancelFunc
    )
    timeout, err := time.ParseDuration(req.FormValue("timeout"))
    if err == nil {
        // リクエストにはタイムアウトがあるので、期限が来たら自動的にキャンセルされる
        // コンテキストを作成します。
        ctx, cancel = context.WithTimeout(context.Background(), timeout)
    } else {
        ctx, cancel = context.WithCancel(context.Background())
    }
    defer cancel() // handleSearchが値を返したらすぐに ctx をキャンセルします。

このハンドラは google.Searchctxquery を使って呼び出します。

    // Google検索を実行して結果を表示します。
    start := time.Now()
    results, err := google.Search(ctx, query)
    elapsed := time.Since(start)

検索に成功したら、ハンドラは結果を返します。

    if err := resultsTemplate.Execute(w, struct {
        Results          google.Results
        Timeout, Elapsed time.Duration
    }{
        Results: results,
        Timeout: timeout,
        Elapsed: elapsed,
    }); err != nil {
        log.Print(err)
        return
    }

userip パケージ #

userip パッケージはリクエストからユーザーのIPアドレスを抜き出し、それを Context に紐付ける関数を提供します。 Context はキーと値の対応表を提供します。このとき、キーも値もともに interface{} 型です。 キーの型は同値性をサポートしなければならず、値の型は複数のゴルーチンから同時に使われても安全でなければなりません。 userip のようなパッケージはこの対応表の詳細を隠し、特定の Context の値にたいして強く型付けされたアクセスを提供します。

キーの衝突を避けるために、 userip ではエクスポートされていない型である key を定義し、この型の値をコンテキストのキーとして使います。

// key型は他のパッケージで定義されているコンテキストのキーと衝突しないよう、エクスポートされません。
type key int

// userIPkey はユーザーのIPアドレスのためのコンテキストのキーです。値がゼロの場合は任意の値となります。
// もしこのパッケージが他のコンテキストのキーであったなら、別の整数値になっていたことでしょう。
const userIPKey key = 0

FromRequestuserIP の値を http.Request から抜き出します。

func FromRequest(req *http.Request) (net.IP, error) {
    ip, _, err := net.SplitHostPort(req.RemoteAddr)
    if err != nil {
        return nil, fmt.Errorf("userip: %q is not IP:port", req.RemoteAddr)
    }

NewContext は与えられた userIP の値を持つ新しい Context を返します。

func NewContext(ctx context.Context, userIP net.IP) context.Context {
    return context.WithValue(ctx, userIPKey, userIP)
}

FromContextContext から userIP を抜き出します。

func FromContext(ctx context.Context) (net.IP, bool) {
    // ctx.Value が nil を返す場合、 ctx はキーになる値を持っていません。
    // このとき net.IP 型のアサーションは ok=false を返します。
    userIP, ok := ctx.Value(userIPKey).(net.IP)
    return userIP, ok
}

google パッケージ #

google.Search 関数は Google Web Search API に対してHTTPリクエストを送り、JSONエンコードされた結果をパースします。この関数は Context のパラメーター ctx を受け取り、もし ctx.Done が閉じられていたら、リクエストが実行中だったとしても、直ちに結果を返します。

Google Web Search APIのリクエストには、クエリパラメータとして検索クエリとユーザーのIPアドレスが含まれています。

func Search(ctx context.Context, query string) (Results, error) {
    // Google Search API へのリクエストの準備
    req, err := http.NewRequest("GET", "https://ajax.googleapis.com/ajax/services/search/web?v=1.0", nil)
    if err != nil {
        return nil, err
    }
    q := req.URL.Query()
    q.Set("q", query)

    // ctx にユーザーのIPアドレスが有った場合、それをサーバーに転送します。
    // Google API はサーバーが起動したリクエストとエンドユーザーのリクエストを区別するために
    // ユーザーのIPアドレスを使います。
    if userIP, ok := userip.FromContext(ctx); ok {
        q.Set("userip", userIP.String())
    }
    req.URL.RawQuery = q.Encode()

Search はHTTPリクエストを発行するためにヘルパー関数 httpDo を使い、リクエストまたはレスポンスが処理中に ctx.Done が閉じられるとHTTPリクエストをキャンセルします。 Search はHTTPレスポンスを処理するために httpDo にクロージャーを渡します。

    var results Results
    err = httpDo(ctx, req, func(resp *http.Response, err error) error {
        if err != nil {
            return err
        }
        defer resp.Body.Close()

        // JSON形式の検索結果をパースする。
        // https://developers.google.com/web-search/docs/#fonje
        var data struct {
            ResponseData struct {
                Results []struct {
                    TitleNoFormatting string
                    URL               string
                }
            }
        }
        if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
            return err
        }
        for _, res := range data.ResponseData.Results {
            results = append(results, Result{Title: res.TitleNoFormatting, URL: res.URL})
        }
        return nil
    })
    // httpDo は先ほど渡したクロージャーが結果を返すのを待つので、ここで結果を読み込んでも安全です。
    return results, err

httpDo 関数はHTTPリクエストを実行し、そのレスポンスを新しいゴルーチンで処理します。 ctx.Done が閉じられた場合にはゴルーチンが終了する前にリクエストをキャンセルします。

func httpDo(ctx context.Context, req *http.Request, f func(*http.Response, error) error) error {
    // HTTPリクエストをゴルーチン内で実行し、レスポンスを f に渡す。
    tr := &http.Transport{}
    client := &http.Client{Transport: tr}
    c := make(chan error, 1)
    go func() { c <- f(client.Do(req)) }()
    select {
    case <-ctx.Done():
        tr.CancelRequest(req)
        <-c // f が値を返すのを待つ
        return ctx.Err()
    case err := <-c:
        return err
    }
}

Context用のコードを適用する #

多くのサーバーフレームワークがリクエスト固有の値を保持するためにパッケージと型を提供しています。 既存のフレームワークを使ったコードと Context パラメータを期待するコードとの架け橋として Context インターフェースの新しい実装を定義することができます。

たとえば、Gorillaの github.com/gorilla/context パッケージは、HTTPリクエストからキーと値のペアへの対応表を提供することで、ハンドラがデータと受け取ったリクエストを紐付けることができるようになっています。 gorilla.go では、Value メソッドがGorillaパッケージ内の特定のHTTPリクエストに紐付いた値を返すような Context の実装を提供しています。

他のパッケージでは Context と似たキャンセルの仕組みをサポートしてきています。たとえば Tomb では、 Dying チャンネルを閉じることでキャンセルシグナルを送る Kill メソッドを提供しています。 また Tomb は処理用のゴルーチンが終了するのを待つ、sync.WaitGroup に似たメソッドも提供しています。 tomb.go では、親の Context がキャンセルされる、もしくは与えられた Tomb が殺された場合にキャンセルされる Context の実装を提供しています。

結論 #

Googleでは、Goプログラマに受信と送信とのリクエスト間の経路での呼び出しにおいて、すべての関数で第1引数に Context パラメーターを渡すことを要求しています。 これによって、多くの異なるチームが開発したGoのコードがお互いに上手く動くようになっています。 Context によって期限とキャンセルをシンプルに制御できるようになり、またセキュリティ上の認証情報といった重要な値がGoのプログラム内を適切に通過することを確実にしています。

Context 上に構築したいサーバーフレームワークは、そのパッケージと Context パラメーターがあると期待されるパッケージ間の橋渡しをするような Context の実装を提供すべきでしょう。そうするとクライアントライブラリは Context を呼び出し元のコードから受け取るでしょう。リクエスト固有のデータとキャンセルに関する共通のインターフェースを構築することで、 Context はパッケージ開発者がスケーラブルなサービスを作るとためにコードを共有することをより簡単にします。

By Sameer Ajmani

あわせて読みたい #


  1. 訳註: 原文では code.google.com/p/go.net/context を参照していますが、現状に合わせてURLを変更しました。 ↩︎