この章について
ここからは、
- 同じcontextを複数のゴールーチンで使いまわしたらどうなるか
- 親のcontextをキャンセルしたら、子のcontextはどうなるか
というキャンセル伝播の詳細な仕様を探っていきたいと思います。
同じcontextを使いまわした場合
直列なゴールーチンの場合
例えば、以下のようなコードを考えます。
func main() {
ctx0 := context.Background()
ctx1, _ := context.WithCancel(ctx0)
// G1
go func(ctx1 context.Context) {
ctx2, cancel2 := context.WithCancel(ctx1)
// G2-1
go func(ctx2 context.Context) {
// G2-2
go func(ctx2 context.Context) {
select {
case <-ctx2.Done():
fmt.Println("G2-2 canceled")
}
}(ctx2)
select {
case <-ctx2.Done():
fmt.Println("G2-1 canceled")
}
}(ctx2)
cancel2()
select {
case <-ctx1.Done():
fmt.Println("G1 canceled")
}
}(ctx1)
time.Sleep(time.Second)
}
go
文にて新規に立てられたゴールーチンはG1, G2-1, G2-2の3つ存在します。
それらの関係と、それぞれに引数として渡されているcontextは以下のようになっています。
ctx2
のキャンセルのみを実行すると、G2-1とG2-2が揃って終了し、その親であるG1は生きたままとなります。
$ go run main.go
G2-1 canceled
G2-2 canceled
並列なゴールーチンの場合
それでは、今度は以下のコードについて考えてみましょう。
func main() {
ctx0 := context.Background()
ctx1, cancel1 := context.WithCancel(ctx0)
// G1-1
go func(ctx1 context.Context) {
select {
case <-ctx1.Done():
fmt.Println("G1-1 canceled")
}
}(ctx1)
// G1-2
go func(ctx1 context.Context) {
select {
case <-ctx1.Done():
fmt.Println("G1-2 canceled")
}
}(ctx1)
cancel1()
time.Sleep(time.Second)
}
メイン関数の中で、go
文を二つ並列に立てて、そこに同一のcontextctx1
を渡しています。
ここで、ctx1
をキャンセルすると、G1-1, G1-2ともに連動して終了します。
$ go run main.go
G1-1 canceled
G1-2 canceled
まとめ
同じcontextを複数のゴールーチンに渡した場合、それらが直列の関係であろうが並列の関係であろうが同じ挙動となります。
ゴールーチンの生死を制御するcontextが同じであるので、キャンセルタイミングも当然連動することとなります。
兄弟関係にあるcontextの場合
続いて、以下のようなコードを考えます。
func main() {
ctx0 := context.Background()
ctx1, cancel1 := context.WithCancel(ctx0)
// G1
go func(ctx1 context.Context) {
select {
case <-ctx1.Done():
fmt.Println("G1 canceled")
}
}(ctx1)
ctx2, _ := context.WithCancel(ctx0)
// G2
go func(ctx2 context.Context) {
select {
case <-ctx2.Done():
fmt.Println("G2 canceled")
}
}(ctx2)
cancel1()
time.Sleep(time.Second)
}
メイン関数の中でgo
文を二つ並列に立てて、ゴールーチンG1,G2を立てています。
そしてそれぞれには、ctx0
を親にして作ったcontextctx1
,ctx2
を渡しています。
ここで、ctx1
をキャンセルすると、G1のみが終了し、G2はその影響を受けることなく生きていることが確認できます。
$ go run main.go
G1 canceled
親子関係にあるcontextの場合
以下のようなコードを考えます。
func main() {
ctx0 := context.Background()
ctx1, _ := context.WithCancel(ctx0)
// G1
go func(ctx1 context.Context) {
ctx2, cancel2 := context.WithCancel(ctx1)
// G2
go func(ctx2 context.Context) {
ctx3, _ := context.WithCancel(ctx2)
// G3
go func(ctx3 context.Context) {
select {
case <-ctx3.Done():
fmt.Println("G3 canceled")
}
}(ctx3)
select {
case <-ctx2.Done():
fmt.Println("G2 canceled")
}
}(ctx2)
cancel2()
select {
case <-ctx1.Done():
fmt.Println("G1 canceled")
}
}(ctx1)
time.Sleep(time.Second)
}
go
文にて新規に立てられたゴールーチンはG1, G2, G3の3つ存在します。
それらの関係と、それぞれに引数として渡されているcontextは以下のようになっています。
ctx2
のキャンセルのみを実行すると、ctx2
ともつG2と、その子であるctx3
を持つG3が揃って終了します。
一方、ctx2
の親であるctx1
を持つG1は生きたままとなります。
$ go run main.go
G2 canceled
G3 canceled
これで、「親contextがキャンセルされたら、子のcontextにまで波及する」ということが確認できました。
(おまけ)子から親のキャンセル
「親から子へのキャンセル(=ctx2
→ctx3
)」は確認できましたが、「子から親へのキャンセル(ctx2
→ctx1
)」は行われませんでした。
このような設計になっていることについて、Go公式ブログ - Go Concurrency Patterns: Contextでは以下のように述べられています。
A Context does not have a Cancel method for the same reason the Done channel is receive-only: the function receiving a cancelation signal is usually not the one that sends the signal.
In particular, when a parent operation starts goroutines for sub-operations, those sub-operations should not be able to cancel the parent.(訳):contextが自発的な
Cancel
メソッドを持たないのは、doneチャネルがレシーブオンリーであるのと同じ理由です。キャンセル信号を受信した関数が、そのままその信号を別の関数に送ることになるわけではないのです。
特に、親となる関数が子関数の実行場としてゴールーチンを起動した場合、その子関数側から親関数をキャンセルするようなことはやるべきではありません。