最近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.WithDeadline
やcontext.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
done atomic.Value
children map[canceler]struct{}
err error
}
公開されているContextのメソッドを持っているのは当然として、Contextを複数のgoroutineでシェアしても問題ないようにsync.Mutex
で保護している。done
がcontext.Done
が返すチャンネルで、こちらもatomic.Value
で保護されている。並行処理に詳しくなく、done
に二重の保護が必要な理由はよく分からなかった。children
はContextのツリー構造を管理するためのものである。err
はキャンセル理由を格納するフィールドである。
キャンセル処理は以下のようになっている。context.WithCancel
などが返すキャンセル関数は以下の関数を簡単にラップしたものになっている。
go/context.go at 5203357ebacf9f41ca5e194d953c164049172e96 · golang/go · GitHub
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
}
c.err = err
d, _ := c.done.Load().(chan struct{})
if d == nil {
c.done.Store(closedchan)
} else {
close(d)
}
for child := range c.children {
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のスコープは単一のリクエストで済むケースが多そうなので、妥当なように見えた。