Goブログ

Go並行処理パターン:コンテキスト

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チャネルのイディオムについて詳しく説明しています。

Contextには、Doneチャネルが受信専用であるのと同じ理由で、Cancelメソッドがありません。キャンセル信号を受信する関数は通常、信号を送信する関数ではありません。特に、親オペレーションがサブオペレーションのためにゴルーチンを開始する場合、これらのサブオペレーションは親をキャンセルすることはできません。代わりに、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つのパッケージに分割されています

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

サーバープログラム

serverプログラムは、golangの最初のいくつかのGoogle検索結果を提供することで、/search?q=golangのようなリクエストを処理します。/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

FromRequestは、http.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)
}

FromContextは、Contextから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エンコードされた結果を解析します。リクエストが進行中にctx.Doneが閉じられた場合は、Contextパラメーターctxを受け取り、すぐに戻ります。

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

コンテキストに対応するコードの適用

多くのサーバーフレームワークは、リクエストスコープの値を保持するためのパッケージと型を提供しています。既存のフレームワークを使用するコードと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がkillされたときにキャンセルされるContext実装を提供します。

結論

Googleでは、Goプログラマーが、受信リクエストと送信リクエストの間の呼び出しパス上のすべての関数に、最初の引数としてContextパラメーターを渡すことを要求しています。これにより、さまざまなチームによって開発されたGoコードがうまく相互運用できるようになります。タイムアウトとキャンセルを簡単に制御でき、セキュリティ認証情報のような重要な値がGoプログラムを適切に通過することを保証します。

Contextをベースに構築したいサーバーフレームワークは、パッケージとContextパラメーターを予期するパッケージとの間でブリッジするために、Contextの実装を提供する必要があります。クライアントライブラリは、呼び出し元のコードからContextを受け入れることになります。リクエストスコープのデータとキャンセルに共通のインターフェイスを確立することにより、Contextはパッケージ開発者がスケーラブルなサービスを作成するためのコードを共有することを容易にします。

さらに読む

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