The Go Blog

Go並行処理パターン: Context

Sameer Ajmani
2014年7月29日

はじめに

Goサーバーでは、各受信リクエストは独自のゴルーチンで処理されます。リクエストハンドラは、データベースやRPCサービスなどのバックエンドにアクセスするために、追加のゴルーチンを起動することがよくあります。リクエストを処理するゴルーチンのセットは、通常、エンドユーザーのID、認証トークン、リクエストの期限など、リクエスト固有の値へのアクセスを必要とします。リクエストがキャンセルされたりタイムアウトしたりした場合、そのリクエストを処理しているすべてのゴルーチンはすぐに終了し、システムが使用しているリソースを再利用できるようにする必要があります。

Googleでは、リクエストスコープの値、キャンセルシグナル、および期限をAPI境界を越えて、リクエストの処理に関わるすべてのゴルーチンに簡単に渡せるようにするcontextパッケージを開発しました。このパッケージはcontextとして公開されています。この記事では、このパッケージの使用方法を説明し、完全な動作例を提供します。

コンテキスト

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

// A Context carries a deadline, cancellation signal, and request-scoped values
// across API boundaries. Its methods are safe for simultaneous use by multiple
// goroutines.
type Context interface {
    // Done returns a channel that is closed when this Context is canceled
    // or times out.
    Done() <-chan struct{}

    // Err indicates why this context was canceled, after the Done channel
    // is closed.
    Err() error

    // Deadline returns the time when this Context will be canceled, if any.
    Deadline() (deadline time.Time, ok bool)

    // Value returns the value associated with key or nil if none.
    Value(key interface{}) interface{}
}

(この説明は簡潔にまとめられたものです。godocが正式な情報源です。)

Doneメソッドは、Contextに代わって実行されている関数へのキャンセルシグナルとして機能するチャネルを返します。このチャネルが閉じられると、関数は作業を中断して戻る必要があります。Errメソッドは、Contextがキャンセルされた理由を示すエラーを返します。パイプラインとキャンセルの記事では、Doneチャネルのイディオムについて詳しく説明しています。

ContextCancelメソッドを持っていません。これはDoneチャネルが受信専用であるのと同じ理由です。キャンセルシグナルを受信する関数は、通常、シグナルを送信する関数ではないからです。特に、親操作がサブ操作のためにゴルーチンを起動する場合、これらのサブ操作は親をキャンセルするべきではありません。代わりに、WithCancel関数(以下で説明)が新しいContext値をキャンセルする方法を提供します。

Contextは複数のゴルーチンによる同時使用に対して安全です。コードは単一のContextを任意の数のゴルーチンに渡し、そのContextをキャンセルしてそれらすべてにシグナルを送ることができます。

Deadlineメソッドを使用すると、関数は作業を開始すべきかどうかを判断できます。残り時間が少なすぎる場合、作業する価値がないかもしれません。コードは、I/O操作のタイムアウトを設定するためにも期限を使用することができます。

Valueを使用すると、Contextはリクエストスコープのデータを保持できます。このデータは、複数のゴルーチンによる同時使用に対して安全でなければなりません。

派生コンテキスト

contextパッケージは、既存のContext値から新しいContext値を派生させる関数を提供します。これらの値はツリーを形成します。あるContextがキャンセルされると、そこから派生したすべてのContextもキャンセルされます。

BackgroundはすべてのContextツリーのルートであり、決してキャンセルされません。

// Background returns an empty Context. It is never canceled, has no deadline,
// and has no values. Background is typically used in main, init, and tests,
// and as the top-level Context for incoming requests.
func Background() Context

WithCancelWithTimeoutは、親Contextよりも早くキャンセルできる派生Context値を返します。受信リクエストに関連付けられたContextは、通常、リクエストハンドラが戻るときにキャンセルされます。WithCancelは、複数のレプリカを使用する際に冗長なリクエストをキャンセルするのにも役立ちます。WithTimeoutは、バックエンドサーバーへのリクエストに期限を設定するのに役立ちます。

// WithCancel returns a copy of parent whose Done channel is closed as soon as
// parent.Done is closed or cancel is called.
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)

// A CancelFunc cancels a Context.
type CancelFunc func()

// WithTimeout returns a copy of parent whose Done channel is closed as soon as
// parent.Done is closed, cancel is called, or timeout elapses. The new
// Context's Deadline is the sooner of now+timeout and the parent's deadline, if
// any. If the timer is still running, the cancel function releases its
// resources.
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

WithValueは、リクエストスコープの値をContextに関連付ける方法を提供します。

// WithValue returns a copy of parent whose Value method returns val for key.
func WithValue(parent Context, key interface{}, val interface{}) Context

contextパッケージの使い方は、実際の例を見るのが一番です。

私たちの例は、/search?q=golang&timeout=1sのようなURLを処理するHTTPサーバーです。「golang」というクエリをGoogle Web Search APIに転送し、その結果をレンダリングします。timeoutパラメータは、指定された期間が経過した後にサーバーがリクエストをキャンセルするように指示します。

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

  • servermain関数と/searchのハンドラを提供します。
  • useripは、リクエストからユーザーのIPアドレスを抽出し、それをContextに関連付ける関数を提供します。
  • googleは、Googleにクエリを送信するためのSearch関数を提供します。

サーバープログラム

サーバープログラムは、/search?q=golangのようなリクエストを、golangのGoogle検索結果の最初のいくつかを返すことで処理します。/searchエンドポイントを処理するためにhandleSearchを登録します。ハンドラは、ctxと呼ばれる初期のContextを作成し、ハンドラが戻るときにそれがキャンセルされるように手配します。リクエストにtimeout URLパラメータが含まれている場合、タイムアウトが経過するとContextは自動的にキャンセルされます。

func handleSearch(w http.ResponseWriter, req *http.Request) {
    // ctx is the Context for this handler. Calling cancel closes the
    // ctx.Done channel, which is the cancellation signal for requests
    // started by this handler.
    var (
        ctx    context.Context
        cancel context.CancelFunc
    )
    timeout, err := time.ParseDuration(req.FormValue("timeout"))
    if err == nil {
        // The request has a timeout, so create a context that is
        // canceled automatically when the timeout expires.
        ctx, cancel = context.WithTimeout(context.Background(), timeout)
    } else {
        ctx, cancel = context.WithCancel(context.Background())
    }
    defer cancel() // Cancel ctx as soon as handleSearch returns.

ハンドラは、リクエストからクエリを抽出し、useripパッケージを呼び出してクライアントのIPアドレスを抽出します。クライアントのIPアドレスはバックエンドリクエストに必要なので、handleSearchはそれをctxにアタッチします。

    // Check the search query.
    query := req.FormValue("q")
    if query == "" {
        http.Error(w, "no query", http.StatusBadRequest)
        return
    }

    // Store the user IP in ctx for use by code in other packages.
    userIP, err := userip.FromRequest(req)
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    ctx = userip.NewContext(ctx, userIP)

ハンドラはctxqueryを使ってgoogle.Searchを呼び出します。

    // Run the Google search and print the results.
    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型を定義し、この型の値をコンテキストキーとして使用します。

// The key type is unexported to prevent collisions with context keys defined in
// other packages.
type key int

// userIPkey is the context key for the user IP address.  Its value of zero is
// arbitrary.  If this package defined other context keys, they would have
// different integer values.
const userIPKey key = 0

FromRequesthttp.RequestからuserIP値を抽出します。

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 returns nil if ctx has no value for the key;
    // the net.IP type assertion returns ok=false for nil.
    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) {
    // Prepare the Google Search API request.
    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)

    // If ctx is carrying the user IP address, forward it to the server.
    // Google APIs use the user IP to distinguish server-initiated requests
    // from end-user requests.
    if userIP, ok := userip.FromContext(ctx); ok {
        q.Set("userip", userIP.String())
    }
    req.URL.RawQuery = q.Encode()

Searchはヘルパー関数httpDoを使用してHTTPリクエストを発行し、リクエストまたはレスポンスの処理中にctx.Doneが閉じられた場合はそれをキャンセルします。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()

        // Parse the JSON search result.
        // 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 waits for the closure we provided to return, so it's safe to
    // read results here.
    return results, err

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

func httpDo(ctx context.Context, req *http.Request, f func(*http.Response, error) error) error {
    // Run the HTTP request in a goroutine and pass the response to f.
    c := make(chan error, 1)
    req = req.WithContext(ctx)
    go func() { c <- f(http.DefaultClient.Do(req)) }()
    select {
    case <-ctx.Done():
        <-c // Wait for f to return.
        return ctx.Err()
    case err := <-c:
        return err
    }
}

Contextsへのコードの適合

多くのサーバーフレームワークは、リクエストスコープの値を運ぶためのパッケージと型を提供します。既存のフレームワークを使用するコードと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プログラマーに対し、受信リクエストと送信リクエストの間の呼び出しパス上のすべての関数の最初の引数としてContextパラメータを渡すことを義務付けています。これにより、多くの異なるチームによって開発されたGoコードがうまく連携できるようになります。タイムアウトとキャンセルを簡単に制御でき、セキュリティ認証情報のような重要な値がGoプログラムを適切に通過することを保証します。

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

さらに読む

次の記事: OSCONでのGo
前の記事: GoはOSCON 2014に出展します
ブログインデックス