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をキャンセルする場合の流れについても見てみようと思います。