yigarashiのブログ

学んだことや考えていることを書きます

Goのcontextについて調査したり実装を読んだりしたまとめ

最近Goを勉強している。仕事でたまに使うというのもあるし、用途が広く色々とつぶしがききそうという期待もある。ちゃんと勉強してみると思ったより手触りが良いので、気になったライブラリについて調査しながら少しずつGo言語力を高めようと思う。ということで今回は標準ライブラリのcontext。

概要

なにはともあれcontext - The Go Programming Languageを読む。

Package context defines the Context type, which carries deadlines, cancellation signals, and other request-scoped values across API boundaries and between processes.

なんらか処理をリクエストするときにContextオブジェクトを渡すと、呼び出し側でデッドラインを設定したり任意のタイミングでキャンセルできるというのがざっくりとした理解。ミニマムな例としては以下のようなものが書ける。これを実行すると、5秒スリープしたのちcontext canceledというメッセージが表示される。

package main

import (
    "context"
    "time"
)

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    ch := make(chan string)
    go callee(ctx, ch)
    cancel()
    println(<-ch)
}

func callee(ctx context.Context, ch chan string) {
    <-ctx.Done()
    time.Sleep(time.Second * 5)
    ch <- ctx.Err().Error()
}

上から簡単に解説していく。Contextオブジェクトはキャンセルを伝播できるようにツリー構造を形成しており、context.Background()はそのrootを取得するものである。

context.WithCancelは渡されたContextをコピーしてchildrenに加えたのちキャンセル関数とセットで返してくれる。cancelを呼ぶとctx.Done()でアクセスできるchannelにシグナルが送られる。callee側はそれを以って実行のキャンセルと判断しキャンセル処理を始めることができる。ここでは5秒スリープしたのち、ctx.Err()で取得できるContextのキャンセル理由をメインのスレッドに送信している。タイマーを設定したい場合はcontext.WithCancelに代わってcontext.WithDeadlinecontext.WithTimeoutを使うと良い。

またcontext.WithValueによってkey-valueペアを保持することもできる。これはrequest-scopedな値に使うと良いとされている。おそらく依存性の注入のようなイメージでrepositoryオブジェクトを渡すといった用途に使うのはあまり良いプラクティスではなさそうなので注意が必要。

Go Concurrency Patterns: Context - The Go Blogも入門としては良い感じがする。

実践的な利用例

時間によって処理をキャンセルする例として、Webサーバーのgracefulなシャットダウンが挙げられる。SIGTERMを受け取ったら、現在のリクエスト処理が全て完了するか、一定時間が経過したらサーバーをシャットダウンするものである。

標準ライブラリのnet/httpが提供しているHTTPサーバーは、まさにContextオブジェクトを受け取るShutdownメソッドを提供している。

https://golang.org/pkg/net/http/#Server.Shutdown

func (srv *Server) Shutdown(ctx context.Context) error

軽く実装を覗いてみると、基本的にexponentialに待ち時間を増やしながら全てのリスナーが閉じるのを待機し、それとは別にctx.Done()のchennelを待ち受けて強制的に終了することもできるようになっている。

go/server.go at 5203357ebacf9f41ca5e194d953c164049172e96 · golang/go · GitHub

    timer := time.NewTimer(nextPollInterval())
    defer timer.Stop()
    for {
        if srv.closeIdleConns() && srv.numListeners() == 0 {
            return lnerr
        }
        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-timer.C:
            timer.Reset(nextPollInterval())
        }
    }

このサーバーを利用する側としてはgorilla/muxのGraceful Shutdownの例が参考になる。OSのシグナルを受け取るチャンネルで処理をブロックしておき、シグナルが来たらcontext.WithTimeoutでContextを生成して先程のShutdown関数に渡している。

contextの実装を軽く読む

せっかくなのでライブラリ自体の実装も軽く読んでみる。

まず以下はcontext.WithCancelが返すContextオブジェクトの実体を表すstructである。 go/context.go at 5203357ebacf9f41ca5e194d953c164049172e96 · golang/go · GitHub

type cancelCtx struct {
    Context

    mu       sync.Mutex            // protects following fields
    done     atomic.Value          // of chan struct{}, created lazily, closed by first cancel call
    children map[canceler]struct{} // set to nil by the first cancel call
    err      error                 // set to non-nil by the first cancel call
}

公開されているContextのメソッドを持っているのは当然として、Contextを複数のgoroutineでシェアしても問題ないようにsync.Mutexで保護している。donecontext.Doneが返すチャンネルで、こちらもatomic.Valueで保護されている。並行処理に詳しくなく、doneに二重の保護が必要な理由はよく分からなかった。childrenはContextのツリー構造を管理するためのものである。errはキャンセル理由を格納するフィールドである。

キャンセル処理は以下のようになっている。context.WithCancelなどが返すキャンセル関数は以下の関数を簡単にラップしたものになっている。

go/context.go at 5203357ebacf9f41ca5e194d953c164049172e96 · golang/go · GitHub

// cancel closes c.done, cancels each of c's children, and, if
// removeFromParent is true, removes c from its parent's children.
func (c *cancelCtx) cancel(removeFromParent bool, err error) {
    if err == nil {
        panic("context: internal error: missing cancel error")
    }
    c.mu.Lock()
    if c.err != nil {
        c.mu.Unlock()
        return // already canceled
    }
    c.err = err
    d, _ := c.done.Load().(chan struct{})
    // 解説: 閉じたチャンネルを格納するか既存の開いたチャンネルをcloseするかしてreceiverのブロックを解除する
    if d == nil {
        c.done.Store(closedchan)
    } else {
        close(d)
    }
    // 解説: 子のContextにcancelを伝播させる
    for child := range c.children {
        // NOTE: acquiring the child's lock while holding parent's lock.
        child.cancel(false, err)
    }
    c.children = nil
    c.mu.Unlock()

    if removeFromParent {
        removeChild(c.Context, c)
    }
}

removeFromParentは親のContextから自分自身を取り除くかどうかを表すフラグで、Contextの利用者がキャンセル関数を呼ぶ場合やデッドラインが来た場合はそのContextだけがキャンセルされるのでtrue、親からキャンセルが伝播してきたり親が管理する場合はfalseとするようだ。

他にもcontext.WithDeadlineなんかを見ると、time.Afterfuncを使ってcancel関数を仕込んでいることがわかる。

go/context.go at 5203357ebacf9f41ca5e194d953c164049172e96 · golang/go · GitHub

    if c.err == nil {
        c.timer = time.AfterFunc(dur, func() {
            c.cancel(true, DeadlineExceeded)
        })
    }

全体的に抽象度の高いAPIを組み合わせて簡潔に実装されており、初学者でも読みやすいライブラリだったと思う。structやErrorの設計、並行処理についても典型的なことをやっていそうに見える。

その他の話題

Contexts and structs - The Go Blog

Contextはstructの中に含めずそのままの形で関数の第一引数に置くべきということが書かれている。本文の例を見るに、特にメソッドのレシーバーに含めて種類の異なるリクエストで単一のContextを共有するのがマズいということらしい。WithValueの解説を見ても、Contextオブジェクトのスコープを単一のリクエストに制限するというのは重要なプラクティスに見える。net/httpパッケージで後方互換性のためにRequestにContextを含めたという話も書いてあるが、確かにRequestオブジェクトに含める分にはContextのスコープは単一のリクエストで済むケースが多そうなので、妥当なように見えた。