Chapter 04

キャンセルの伝播

さき(H.Saki)
さき(H.Saki)
2021.08.28に更新

この章について

ここからは、

  • 同じ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にまで波及する」ということが確認できました。

(おまけ)子から親のキャンセル

「親から子へのキャンセル(=ctx2ctx3)」は確認できましたが、「子から親へのキャンセル(ctx2ctx1)」は行われませんでした。

このような設計になっていることについて、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チャネルがレシーブオンリーであるのと同じ理由です。キャンセル信号を受信した関数が、そのままその信号を別の関数に送ることになるわけではないのです。
特に、親となる関数が子関数の実行場としてゴールーチンを起動した場合、その子関数側から親関数をキャンセルするようなことはやるべきではありません。

出典:Go公式ブログ - Go Concurrency Patterns: Context