Go1.7のcontextパッケージ

by Taichi Nakashima,

Go1.7ではgolang.org/x/net/contextcontextパッケージとして標準パッケージに仲間入りする.そしていくつかの標準パッケージではcontextパッケージを使ったメソッド/関数も新たに登場する.contextパッケージは今後さらに重要な,Gopherは普通に扱うべき,パッケージになると考えられる.本記事ではそもそもcontextパッケージとは何か?なぜ登場したのか?なぜ重要なのか?どのように使うべきか?についてまとめる.

contextパッケージが初めて紹介されたのは2014年のThe Go Blogの記事 “Go Concurrency Patterns: Context”である.この記事ではなぜGoogleがcontextパッケージを開発したのか,どのように使うのか具体的な検索タスクを例に解説されている.まだ読んだことがない人はそちらを先に読むと良い.

contextパッケージとは何か

ここでは具体的な利用例からcontextとは何かを説明する.

例えばGoの典型的な利用例であるWebアプリケーションを考える.Goのサーバにおいてリクエストはそれぞれ個別のgoroutineで処理される.そしてリクエストHandlerは新たなgoroutineを生成しバックエンドのDBや別のサーバにリクエストを投げ結果を得てユーザに対してレスポンスを返す.

このような別サーバへのリクエストのように時間のかかる処理をgoroutineで実行する場合どのようなことに注意する必要があるだろうか.まず最初に注意するべきはその処理に適切なTimeoutやDeadlineを設定して処理が停滞するのを防ぐことである.例えば別のサーバにリクエストを投げる場合にネットワークの問題でリクエストに時間がかかってしまうことは大いに考えられる.リクエストにTimeoutを設定して早めにレスポンスを返しリトライを促すべきである.

次に注意するべきは生成したgoroutineを適切にキャンセルしリソースを解放することである.例えば別のサーバにリクエストを投げる場合に適切なキャンセル処理を行わないとTimeout後もネットワークリソースが使われ続けることになる(CPUやメモリを使い続けるかもしれない).この場合net/httpパッケージレベルでリクエストをキャンセルするべきである.

さらにそのgoroutineは別のgoroutineを呼び出しそれがまた別の…と呼び出しの連鎖は深くなることが考えられる.その場合も親のTimeoutに合わせてその子は全て適切にキャンセルされリソースは解放されるべきである..

このようにキャンセル処理は重要である.contextパッケージはこのキャンセルのためのシグナルをAPIの境界を超えて受け渡すための仕組みである.ある関数から別の関数へと,親から子へと,キャンセルを伝搬させる.

これはcontextを使わなくても実現できる.しかし標準パッケージになったことでcontextは「キャンセルのためのシグナルの受け渡しの標準的なインターフェース」として使える.この流れは別の標準パッケージに新たに追加された関数に見ることができる.

(後述するがcontextパッケージは限定されたスコープの値,例えば認証情報など,の受け渡しとしても利用できる.しかし筆者はこれは付随的な機能でありキャンセル機構としてのcontextの方が重要であると考えている)

コードで追うcontextパッケージ

言葉のみでは伝わりにくいので具体的なサンプルコードを使ってcontextパッケージの使いどころを説明する.

以下のような単純なリクエストHandlerを考える.このHandlerはユーザからのリクエストを受けバックエンドのサービスにリクエストを投げる.そして得た結果をユーザに返す(具体的なレスポンスの書き込みなどは省略している).リクエストは別のgoroutineで投げ,エラーをchannelで受け取る.このコードを改善していく.

func handler(w http.ResponseWriter, r *http.Request) {
    // 新たにgoroutineを生成してバックエンドにリクエストを投げる
    // 結果をerror channelに入れる
    errCh := make(chan error, 1)
    go func() {
        errCh <- request()
    }()

    // error channelにリクエストの結果が返ってくるのを待つ
    select {
    case err := <-errCh:
        if err != nil {
            log.Println("failed:", err)
            return
        }
    }

    log.Println("success")
}

まず現状のコードはネットワークの問題などでrequest()に時間がかかりユーザへのレスポンスが停止してしまう可能性がある.これを防ぐためにはTimeoutを設定するべきである.timeパッケージのtime.Afterを使うと以下のようにTimeoutを設定することができる.

func handler(w http.ResponseWriter, r *http.Request) {
    errCh := make(chan error, 1)
    go func() {
        errCh <- request()
    }()

    select {
    case err := <-errCh:
        if err != nil {
            log.Println("failed:", err)
            return
        }
        
    // Timeout(2秒)を設定する.
    // 例えばしばらく経ってから再度リクエストをするように
    // レスポンスを返す.
    case <-time.After(2 * time.Second):
        log.Println("failed: timeout")
        return
    }

    log.Println("success")
}

これでリクエストがネットワークなどの不調によりリクエストが停滞してしまう問題は解決できた.しかしこれでは不十分である.Timeoutでリクエストをユーザに返した後もrequestは別のgoroutineで動き続ける.つまりサーバのリソースを使い続ける.少量であれば問題ないがリクエストが増えれば増えるほど問題になる.これを防ぐにはrequest()をキャンセル可能にリソースを解放するべきである.contextを使わない場合は,これは例えば以下のように実装できる.

func handler(w http.ResponseWriter, r *http.Request) {
    // handlerからrequestをキャンセルするためのchannelを準備する
    doneCh := make(chan struct{}, 1)
    
    errCh := make(chan error, 1)
    go func() {
        errCh <- request(doneCh)
    }()

    // 別途goroutineを準備してTimeoutを設定する
    go func() {
        <-time.After(2 * time.Second)
        // Timeout後にdoneChをクローズする
        // 参考: https://blog.golang.org/pipelines
        close(doneCh)
    }()

    select {
    case err := <-errCh:
        if err != nil {
            log.Println("failed:", err)
            return
        }
    }

    log.Println("success")
}

request()は以下のように書ける.

func request(doneCh chan struct{}) error {
    tr := &http.Transport{}
    client := &http.Client{Transport: tr}
    
    req, err := http.NewRequest("POST", backendService, nil)
    if err != nil {
        return err
    }
  
    // 新たにgoroutineを生成して実際のリクエストを行う
    // 結果はerror channelに投げる
    errCh := make(chan error, 1)
    go func() {
        _, err := client.Do(req)
        errCh <- err
    }()

    select {
    case err := <-errCh:
        if err != nil {
            return err
        }
    
    // doneChはhandlerからのキャンセル シグナル(close(doneCh))
    // を待ち受ける
    case <-doneCh:
        // キャンセルが実行されたら適切にリクエストを停止して
        // エラーを返す.
        tr.CancelRequest(req)
        <-errCh
        return fmt.Errorf("canceled")
    }

    return nil
}

contextパッケージを使うとこれはより簡単に書くことができる.

func handler(w http.ResponseWriter, r *http.Request) {
    // 2秒でTimeoutするcontextを生成する
    // cancelを実行することでTimeout前にキャンセルを実行することができる
    //     
    // また後述するようにGo1.7ではnet/httpパッケージでcontext
    // を扱えるようになる.例えば*http.Requestからそのリクエストの
    // contextを取得できる.
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel()

    errCh := make(chan error, 1)
    go func() {
        errCh <- request3(ctx)
    }()

    select {
    case err := <-errCh:
        if err != nil {
            log.Println("failed:", err)
            return
        }
    }

    log.Println("success")
}

request()は以下のように書ける.

func request(ctx context.Context) error {
    tr := &http.Transport{}
    client := &http.Client{Transport: tr}
    
    req, err := http.NewRequest("POST", backendService, nil)
    if err != nil {
        return err
    }

    // 新たにgoroutineを生成して実際のリクエストを行う
    // 結果はerror channelに投げる
    errCh := make(chan error, 1)
    go func() {
        _, err := client.Do(req)
        errCh <- err
    }()

    select {
    case err := <-errCh:
        if err != nil {
            return err
        }
    
    // Timeoutが発生する,もしくはCancelが実行されると
    // Channelが返る
    case <-ctx.Done():
        tr.CancelRequest(req)
        <-errCh
        return ctx.Err()
    }

    return nil
}

doneChと比べるとcontextを使った場合はよりシンプルに書けているのがわかる.これだけではない.標準パッケージになるということは,今後はこの重要なキャンセル処理を統一的なインターフェースとして書くことができるということである.

contextの契約

具体的な使い方はドキュメントが詳しいのでそれを読むのが良い.大きなパッケージではないのですぐに読めると思う.以下では注意するべきことを簡単にまとめる.

まず自分でcontextを前提としたメソッド/関数を提供する場合は以下の形式を守る.必ずメソッド/関数の第一引数にcontext.Contextを渡せるようにする.structなどに埋め込んではいけない.

func DoSomething(ctx context.Context, arg Arg) error {
    // ... use ctx ...
}

さらにcontextをもつ関数は適切なキャンセル処理を実装するべきである.この関数を使う側は呼び出し側(つまり親context)でTimeoutが発生した,もしくはCancelを実行した場合に適切にキャンセル処理・リソースの解放が実行されることを期待する.例えば,上のサンプルコードで示したようにHTTPリクエストであればCancelRequestを呼び確実に処理を終了させる必要がある.

内部で別の関数を呼ぶ場合もcontextを前提とし親contextからキャンセル可能にするべきである.標準パッケージでcontextを前提としたメソッド/関数が実装され始めているのはこの理由によると思う.

これらがGopherの間のcontextの契約になると思う.

Valueの扱い

contextパッケージは限定されたスコープの値,例えば認証情報など,の受け渡しとしても利用できる.しかしこれはキーと値をinterface{}型で指定するため利用には注意が必要である.ドキュメントにも利用するときの注意点がしっかり書かれている.例えば,値の取り出しには専用のメソッド/関数を準備してちゃんとした型として値を返すようにする,キーは公開しないなどである.

またどのような値を渡すべきかに関してはgo-kitの開発者であるPeter Bourgo氏のブログが非常に参考になる.

標準パッケージの中のcontext

先にも述べたようにGo1.7ではいくつかの標準パッケージでcontextパッケージを使ったメソッド/関数が実装された.実装されたのはnetnet/http,そしてos/execである.それぞれ簡単に紹介する.

net

netパッケージにはDialerに新たにDialContext()メソッドが追加された.これは既存のDial()メソッドにcontext.Contextを渡せるようにしたメソッドである.例えば以下のように使うことができる.

ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()

var dialer net.Dialer
conn, err := dialer.DialContext(ctx, "tcp", "google.com:80")
if err != nil {
    log.Fatal(err)
}

net/http

net/httpにはRequestに新たにContext()メソッドとWithContext()メソッドが追加された.Context()はそのリクエストにおけるcontext.Contextを取得するために,WithContext()は変更に用いる.

Clientとしては以下のようにリクエストのキャンセルに使うことができる.

req, err := http.NewRequest("GET", "http://google.com", nil)
if err != nil {
    log.Fatal(err)
}

ctx, cancel := context.WithTimeout(req.Context(), 100*time.Millisecond)
defer cancel()

req = req.WithContext(ctx)

client := http.DefaultClient
res, err := client.Do(req)
if err != nil {
    log.Fatal(err)
}

サーバーとしては以下のようにcontext.WithValue()を用いて各リクエストのスコープに限定した値の受け渡しなどに使うことができる.

const tokenKey = "tokenKey"

func withAuth(a Authorizer next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        auth := r.Header.Get("Authorization")
        token := a.Auth(auth)
        
        ctx := r.Context()
        ctx = context.WithValue(ctx, tokenKey, token)
        next.ServeHTTP(w, r.WithContext(ctx))
	}
}

またデフォルトでServerContextKeyLocalAddrContextKeyというキーでリクエストのcontext.Contextにそれぞれ*http.Servernet.Addrの値がセットされておりそれを使うこともできる.

func handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    srv := ctx.Value(ServerContextKey)
    ....
}    

os/exec

os/execにはCommandContext()関数が追加された.これは既存のCommand()関数にcontext.Contextを渡せるようにした関数である.例えば以下のように使うことができる

cmd := exec.CommandContext(ctx, "sleep", "2")
if err := cmd.Run(); err != nil {
    log.Fatal(err) 
}

context.Contextが終了するとos.Process.Killが実行される.

contextnet関連で主に使われてきたが,そうではない場合であってもタスクにDeadlineやTimeout,Cancelを持たせるための標準的なインターフェースに利用可能であることを示す良い例である.

Context leakを避ける

WithCancelWithTimeoutWithDeadlineで返されるcancelが呼ばれないと,その親Contextcancelされるまでその子ContextがLeakする(context leak).Go1.7からのgo vetはそれを検出する(-lostcancel).例えば以下のような出力が得られる.

func leak() {
    var ctx, cancel = context.WithCancel() 
    // the cancel function is not used on all paths 
    // (possible context leak)
    
    // this return statement may be reached 
    // without using the cancel var defined on line x
}

こちらの変更を見ると別の検出パターンもわかる.

まとめ

どんどん使っていきましょう.

参考