はじめに
久川善法です。
しばらく放置していたcontextについて勉強してみました。
この記事ではcontextの用途から、具体的な使い方までを自分用にまとめました。
※ 勉強中、参考になる記事が多くあったので、記事の最後にURLを載せています。
コンテキストとは?
contextはGo1.7で標準パッケージに仲間入りしたライブラリです。
Go言語による並行処理によると、
contextが仲間入りする以前のGo言語での並行処理では、
doneチャネルを使って並行処理を終了させていました。
しかし、doneチャネルのみを使った方法だとキャンセル理由の伝搬やタイムアウトなど複雑になる箇所がありました。
そこでcontextが実験的に生まれて、最終的に標準パッケージとして活用されるようになったそうです。
コンテキストの主な2つの用途
contextの主な用途は2つあります。
- Goroutineの適切なキャンセル
- リクエスト情報の伝搬
この記事では1つ目のGoroutineの適切なキャンセルにフォーカスします。
リクエスト情報の伝搬については、また次の記事でまとめます。
Goroutineの適切なキャンセル
contextのキャンセルの役割として、
タイムアウトやデッドラインを設定し後続の処理が停滞するのを防ぎ、不要になったGoroutineはキャンセルしてリソースの解放をする役割があります。
タイムアウトやデッドラインを設定しないと、接続などの問題で遅い処理があった際に、後続の処理を停滞させてしまいます。
そこで、contextで事前にタイムアウト時間3秒などの設定すると、リトライ処理や他の手段を選択できるようになります。
また、Goroutineはキャンセルせずに放っておくと、消えずに残ってリソースを消費することがあります。
これもcontextでちゃんとキャンセルしてリソースを解放して、他の処理にそのリソースを使えるようにできます。
また、Goroutineをキャンセルしたいパターンは3つあると思います。
- 全てのGoroutineをキャンセルしたい場合
- 分岐した先のGoroutineをキャンセルしたい場合
- ブロック(遅い処理)している処理をキャンセルしたい場合
ここから具体的にコードをみながら、これらの場合の対応をまとめます。
コンテキストの使い方ざっくり
まずはドキュメントからcontextの使い方を学んでいきます。
contextを読んだ方が早いですが一応説明をします。
Contextの型
type Context interface {
// Deadlineが設定されている場合はその時刻を返却。ok==falseの時は未設定
Deadline() (deadline time.Time, ok bool)
// このコンテキストがキャンセルされたりタイムアウトした場合にcloseされます。
Done() <-chan struct{}
// Doneチャンネルが閉じた後なぜこのコンテキストがキャンセルされたかを知らせます。
Err() error
// Valueはkeyに紐付いた値を返し、設定した値がない場合はnilを返します。
Value(key interface{}) interface{}
}
Functionたち
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
WithCancelはparentのコピーを返します。
parent.Doneが閉じられるか、キャンセルが呼ばれるとコピーされたDoneチャンネルも閉じます。
イメージで例えると木構造になっていて、 生成元のContextがキャンセルされたときに、そこから派生した子のContextもすべてキャンセルされます。
func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc)
WithDeadlineはparentのコピーを返します。
parent.Doneが閉じられるか、キャンセルが呼ばれるとコピーされたDoneチャンネルも閉じます。
Contextのdeadlineは現在時刻+timeoutか親の期限(木構造になってるから親が優先)のどちらか早いほうに設定されます。
アラームの時刻を設定してまつようなイメージ
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
WithTimeoutはparentのコピーを返します。
parent.Doneが閉じられるか、キャンセルが呼ばれるとコピーされたDoneチャンネルも閉じます。
Contextのtimeoutは指定した時間か親の期限(木構造になってるから親が優先)のどちらか早いほうに設定されます。
ストップウォッチで3秒まつようなイメージ
-
func Background() Context
やfunc TODO() Context
どちらも空のContextを生成します。
基本的にBackground()を使うイメージですが、TODOを使う時がわかっていません。
(nilにはしたくないけど、あとで何か設定するといった時につかうのでしょうか?)
type CancelFunc = context.CancelFunc
Contextをキャンセルします。
具体的なコードベース
それでは、パターン別の対応を実際のコードと一緒にみていきます。
- 全てのGoroutineをキャンセルしたい場合
- 分岐した先のGoroutineをキャンセルしたい場合
- ブロック(遅い処理)している処理をキャンセルしたい場合
1. 全てのGoroutineをキャンセルしたい場合
func main() {
// contextを生成
ctx := context.Background()
// 親のcontextを生成し、parentに渡す
ctxParent, cancel := context.WithCancel(ctx)
go parent(ctxParent, "Hello-parent")
// parentのcontextをキャンセル。mainを先に終了させないように1秒待ってから終了
cancel()
time.Sleep(1 * time.Second)
fmt.Println("main end")
}
func parent(ctx context.Context, str string) {
// parentからcontextを生成し、childに渡す
childCtx, cancel := context.WithCancel(ctx)
go child(childCtx, "Hello-child")
defer cancel()
// 無限ループ
for {
select {
case <-ctx.Done():
fmt.Println(ctx.Err(), str)
return
}
}
}
func child(ctx context.Context, str string) {
// 無限ループ
for {
select {
case <-ctx.Done():
fmt.Println(ctx.Err(), str)
return
}
}
}
// context canceled Hello-child
// context canceled Hello-parent
// main end
ちゃんと木構造のように親のcontextがcancelされて、次に子のcontextが・・・と伝搬されているのがわかります。
2. 分岐した先のGoroutineをキャンセルしたい場合
func main() {
// contextを生成
ctx := context.Background()
// 親のcontextを生成し、parentに渡す
ctxParent, cancel := context.WithCancel(ctx)
go func() {
err := child(ctxParent)
if err != nil {
fmt.Println("*parent err*")
cancel()
return
}
}()
// 無限ループ
for {
select {
case <-ctxParent.Done():
fmt.Println(ctxParent.Err(), "*parent done*")
return
default:
fmt.Println("parent process")
}
time.Sleep(1 * time.Second)
}
}
func child(ctx context.Context) error {
// parentからcontextを生成し、childに渡す
childCtx, cancel := context.WithCancel(ctx)
go func() {
if err := getErr(); err != nil {
fmt.Println("*child err*")
// childCtxをcancel
cancel()
}
}()
// 無限ループ
for {
select {
case <-childCtx.Done():
fmt.Println(childCtx.Err(), "*child done*")
time.Sleep(4 * time.Second)
return errors.New("")
default:
fmt.Println("child process")
}
time.Sleep(1 * time.Second)
}
}
func getErr() error {
time.Sleep(2 * time.Second)
return errors.New("")
}
// parent process
// child process
// child process
// parent process
// *child err*
// context canceled *child done*
// parent process
// parent process
// parent process
// parent process
// *parent err*
// context canceled *parent done*
コードの流れを説明します。
parent process と child processを同時に動かします。
2秒後にcancel()を呼び出しchild processは終了させ、そのあとにparent processをcancel()しています。
このように、分岐先で問題があった場合は、分岐先のgoroutineのみ終了させることができます。
3. ブロック(遅い処理)している処理をキャンセルしたい場合
func main() {
ctx := context.Background()
// 3秒後をデッドラインにする
ctx, cancel := context.WithDeadline(ctx, time.Now().Add(3 * time.Second))
defer cancel()
go sayDeadLine(ctx)
select {
case <-ctx.Done():
fmt.Println(ctx.Err())
}
}
func sayDeadLine(ctx context.Context) {
for {
fmt.Println(ctx.Deadline())
time.Sleep(1 * time.Second)
}
}
// 2018-12-08 12:02:06.892819525 +0900 JST m=+3.000593144 true
// 2018-12-08 12:02:06.892819525 +0900 JST m=+3.000593144 true
// 2018-12-08 12:02:06.892819525 +0900 JST m=+3.000593144 true
// context deadline exceeded
コードの流れを説明します。
3秒後にcancelが呼ばれるようにdeadlineを設定します。
1秒に一回無限ループを回し、deadlineの設定を表示します。
3秒後にcancelが呼ばれ処理が終了します。
注意すること
-
contextを構造体の中に定義することはだめ
例えば、contextを含んだpublicな構造体を定義すると、いろんなところからcontexを使えます。
しかし、意図しないcancelや親子関係が複雑になってしまいます。
なので第一引数で渡していく方法が推奨されています。 -
contextを第一引数に
引数に渡す際は必ず第一引数に渡すのが決まりのようです。
最後に
contextの用途や具体的な使い方を学びました。
今回の記事では「Goroutineの適切なキャンセル」について学んだので
次回は「リクエスト情報の伝搬」について学んでいきます。
参考資料
・Goの並行パターン:コンテキスト (Go Concurrency Pattern: Context)
・Go1.7のcontextパッケージ
・golangでcontextパッケージを使う
・Go言語による並行処理