Goで非同期的な処理の中断を検知したい場合、次のように select と ctx.Done() を使って書くことが多い。 とても便利なパターンなのだが、いくつか使うときの注意点がある。
select { case <-ctx.Done(): // done case <-ch: // ... }
selectによる受信のランダム性
channelに届いたタスクを順番に処理しつつ、キャンセル要求が来たら終了する簡単なプログラムを例にする。 コードにある通り次のような順序で実行される。
- task1を送る
- キャンセル要求を送る
- task2を送る
package main import ( "context" "log" "time" ) func main() { ctx, cancel := context.WithCancel(context.Background()) tasks := make(chan func()) go func() { tasks <- func() { log.Printf("start task1") time.Sleep(1 * time.Second) } time.Sleep(100 * time.Millisecond) cancel() tasks <- func() { log.Printf("start task2") time.Sleep(1 * time.Second) } }() for { select { case <-ctx.Done(): log.Printf("done") return case task := <-tasks: task() } } }
キャンセル要求を送った後にtask2を送っているので、task1だけ実行されて終了するように見える。
しかし、このプログラムの出力結果は不定である。何度も実行するとtask2が実行されたりされなかったりする。
2021/07/29 15:28:36 start task1 2021/07/29 15:28:37 start task2 2021/07/29 15:28:38 done
2021/07/29 15:28:47 start task1 2021/07/29 15:28:48 done
なぜこうなるのか? その理由は task1を実行している間にキャンセル要求とtask2が両方channelに入り、select がどちらを選ぶかは不定(ランダム)だから 1 である。
では、常にキャンセル要求を優先したい場合はどうすればいいか? ctx.Done()を2重でチェックするという小技が使える。次のように ctx.Done() とは違うcaseに入ってしまった場合に ctx.Done() を再度チェックするとよい。
select { case <-ctx.Done(): log.Printf("done") return case task := <-tasks: // Prefer the context's cancel. select { default: case <-ctx.Done(): log.Printf("done") return } task() }
Go本体のテストでもこの select のランダム性によってテストの実行結果が不安定となっていて、ctx.Done() の二重チェックを入れて修正したようなコミットがあった。
http2: make Transport prefer HTTP response header recv before body wr… · c3mb0/net@e325faf · GitHub
複数channel受信時のgoroutine leak
時間がかかる処理 (longRunningTask
) の完了を待ちつつ、途中でキャンセル可能にする次のプログラムを例にする。
時間がかかる処理が終わる前に意図的にキャンセルしている。
時間がかかる処理がエラーを返した場合は、エラーを受け取って表示する仕組みも入れている。 一見よくありそうな書き方に見えるが、このままだとgoroutine leakが発生する。
package main import ( "context" "errors" "log" "time" ) func main() { ctx, cancel := context.WithCancel(context.Background()) go process(ctx) time.Sleep(100 * time.Millisecond) cancel() time.Sleep(3 * time.Second) } func process(ctx context.Context) { errCh := make(chan error) go func() { if err := longRunningTask(); err != nil { errCh <- err } }() select { case <-ctx.Done(): log.Printf("done") case err := <-errCh: log.Printf("%+v", err) } } func longRunningTask() error { time.Sleep(1 * time.Second) return errors.New("error") }
これを実行すると次のような順序で処理される。
- longRunningTask 開始
- 100ms待機
- キャンセル要求
- 900ms待機
- longRunningTask 完了
キャンセル要求を送った時点で ctx.Done() を受け取るので select は終了する。 そのあとに longRunningTask が完了してエラーがあれば errCh にエラーを送ろうとする。
しかし、受信する select が先に消えてしまったので channel に値を送信できず、longRunningTaskのgoroutineがずっと残り続ける。
解決策としては errCh に送るときに ctx.Done() も一緒にチェックするなどだろうか。 これだと select で受信するchannelが3つ以上になったときに面倒かもしれない。
func process(ctx context.Context) { errCh := make(chan error) go func() { if err := longRunningTask(); err != nil { select { case errCh <- err: case <-ctx.Done(): } } }() select { case <-ctx.Done(): log.Printf("done") case err := <-errCh: log.Printf("%+v", err) } }
-
Twitterでnanasi880さんから教えてもらいました 感謝! https://twitter.com/_NANASI880/status/1222740812238180353↩