contextのキャンセル伝搬の流れを調べてみました。
キャンセル処理を行う簡単な例として以下のサンプルコードを用意しました。こちらを元にソースコードから流れを追っていきます。
サンプルコード
main関数でgoroutinを起動してsleepFunc
を呼び出し、そのなかでさらにgoroutinを起動してtime.Sleep
を実行しています。
package main
import (
"context"
"fmt"
"time"
)
func sleepFunc(ctx context.Context, duration time.Duration) string {
doneCh := make(chan struct{})
go func() {
time.Sleep(duration * time.Second)
doneCh <- struct{}{}
}()
select {
case <-doneCh:
return "done"
case <-ctx.Done():
//ここでキャンセル処理を行う
return "cancel"
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
resultCh := make(chan string)
go func() {
resultCh <- sleepFunc(ctx, 10)
}()
cancel()
fmt.Println(<-resultCh)
}
sleepFunc()
内で起動したgoroutinでtime.Sleep()
を実行しますが、time.Sleep()
が完了する前にmain関数でcancel()
が呼ばれるため、標準出力にcancel
と表示されます。
動作はGo Playgroundで確認できます。
結論
<- ctx.Done()
で待機しているところに対して、cancelCtx
構造体がもつchan struct{}
型のdone
をclose()
することでキャンセルシグナルを伝搬しています。
contextの取得
ctx, cancel := context.WithCancel(context.Background())
main関数では最初にcontext.Backgroud()
を引数にしてcontext.WithCancel()
を呼び出してcontextを取得しています。
context.Background()
context.Background()
は空のcontextであるbackground
を返す関数です。
https://github.com/golang/go/blob/release-branch.go1.10/src/context/context.go#L202-L208
backgroud
はemptyCtx
をnew()
で初期化したものです。
emptyCtx
はContext
インターフェースを満たすint型の変数ですが、実装しているメソッドのうちString()
以外のメソッドには実処理がなく、returnするだけになっています。
https://github.com/golang/go/blob/release-branch.go1.10/src/context/context.go#L167-L195
context.WithCancel()
context.WithCancel()
は引数で受け取ったcontextを親としてもつ新たなcontextを返します。
サンプルコードではcontext.Background()
を引数にしていますので、emptyCtx
を初期化したオブジェクトを渡しているということになります。
https://github.com/golang/go/blob/release-branch.go1.10/src/context/context.go#L224-L234
中身をもう少し見ていきます。
newCancelCtx()
はcancelCtx
構造体を初期化して返す関数です。
cancelCtx
にはContext
インターフェースが埋め込まれており、その他にいくつかのフィールドの定義がされています。
https://github.com/golang/go/blob/release-branch.go1.10/src/context/context.go#L236-L239
https://github.com/golang/go/blob/release-branch.go1.10/src/context/context.go#L314-L323
次の行ではparent
と&c
を引数に渡してpropagateCancel()
を実行しています。
親contextとそこから派生したcontext(のアドレス)を使って何らかの処理をしていると考えられます。
propagateCancel()
では、まずはじめにparent.Done()
がnil
であるかをチェックして、nil
である場合はそこでreturnします。
サンプルコードでは、parent
はemptyCtx
を初期化したオブジェクトなのでemptyCtx
に実装されているDone()
メソッドを見ます。これはnilを返すのでここでreturnされます。
https://github.com/golang/go/blob/release-branch.go1.10/src/context/context.go#L243-L245
最後にnewCancenCtx()
で取得した新しいcontextのアドレスと、キャンセル用の関数をreturnしています。
これらはmain関数でctx
, cancel
という変数に格納しています。
https://github.com/golang/go/blob/release-branch.go1.10/src/context/context.go#L233
キャンセルの実行
サンプルコードではcontextを取得した後、goroutinを起動してsleepFunc()
を実行します。このとき引数でcontextを渡しています。
sleepFunc()
は内部で更にgoroutinを起動してtime.Sleep()
を実行します。
goroutinの外ではselect
でcannelの受信を待機しています。doneCh
はtime.Sleep()
が正常に完了した場合に受信し、ctx.Done()
はキャンセルが実行された場合に受信します。
main関数でgoroutinを起動した直後にcancel()
を実行しているので、実際にはctx.Done()
からシグナルを受信して標準出力に"cancel"という文字列が表示されます。
ctx.Done()
キャンセル伝搬の流れを見る前にまずはctx.Done()
の中身を見たいと思います。
cancelCtx
のフィールドに含まれるsynx.Mutex
を利用してロックを取得します。
https://github.com/golang/go/blob/release-branch.go1.10/src/context/context.go#L326
これは定義している箇所のコメントから、他のフィールドを保護する用途で使われていることが読み取れます。
https://github.com/golang/go/blob/release-branch.go1.10/src/context/context.go#L319
c.done
がnil
である場合は初期化したchannelをc.done
に代入します。その後c.done
をd
に代入してreturnしています。
https://github.com/golang/go/blob/release-branch.go1.10/src/context/context.go#L327-L332
なお、channelは参照型なので一旦d
に代入していますがこれはc.done
と同じデータ構造を参照しています。
https://golang.org/doc/effective_go.html#channels
Like maps, channels are allocated with make, and the resulting value acts as a reference to an underlying data structure.
cancel()
キャンセル伝搬の流れです。
cancel
はcontext.WithCancel()
の2つめの返りとして受け取ったものでした。
context.WithCancel()
の2つめの返り値はfunc() { c.cancel(true, Canceled) }
でしたので、キャンセル処理の流れを追うにはcancelCtx
のcancel()
メソッドの中身を追っていけばよさそうです。
err == nil
である場合にpanicを発生させています。err
は第二引数で渡されているCanceled
です。errors.New()
で定義したerrorが代入されていてnil
ではないので、次の処理に進みます。
https://github.com/golang/go/blob/release-branch.go1.10/src/context/context.go#L348-L350
https://github.com/golang/go/blob/release-branch.go1.10/src/context/context.go#L154-L155
cancelCtx
のフィールドに含まれるsynx.Mutex
を利用してロックを取得します。
https://github.com/golang/go/blob/release-branch.go1.10/src/context/context.go#L351
c.err != nil
の場合はすでにキャンセル済みとみなされ、ロックを開放したあとにreturnされます。
次の行でc.err
にerr
を代入していますので、このcancel()
が実行されるとc.err
がnil
でなくなり、次の呼び出しのときはc.err != nil
になるということのようです。
https://github.com/golang/go/blob/release-branch.go1.10/src/context/context.go#L352-L356
c.done == nil
の場合はclosedchan
が代入され、そうでない場合はc.done
がクローズされます。
あらかじめctx.Done()
を実行していた場合、c.done
には初期化したchannelがセットされているため、そのchannelがクローズされます。
つまりcase <-ctx.Done():
のようにしていた場合、c.done
がクローズされたタイミングでシグナルを受信しますので、これによりキャンセルを伝搬しているということになります。
https://github.com/golang/go/blob/release-branch.go1.10/src/context/context.go#L357-L361
closedchan
はここで定義されています。empty structのchannelです。
https://github.com/golang/go/blob/release-branch.go1.10/src/context/context.go#L307-L308
c.children
の各要素のcancel()
メソッドを実行しています。
https://github.com/golang/go/blob/release-branch.go1.10/src/context/context.go#L362-L365
第一引数のremoveFromParent
がtrueである場合にremoveChild()
が実行されます。
https://github.com/golang/go/blob/release-branch.go1.10/src/context/context.go#L369-L371
親contextがある場合は、親contextが持つ子contextのmapから自らを削除します。
https://github.com/golang/go/blob/release-branch.go1.10/src/context/context.go#L287-L298
まとめ
context.WithTimeout()
やcontext.WithValue()
を使った場合や複数のgoroutinをキャンセルする場合の流れについても見てみようと思います。