GoのContext.Done()、closeされたchannelは何度でも受信できる
moguraのcontext化を進めていく中で、変な挙動が起きて調べたら、channelの仕組みをちょっと知れたのでメモ。
先に結論
Goのchannelは複数のgoroutine間で使うことができ、channelをcloseすることで、全てのgoroutineに終了を通知することができます。Context.Done()は、この仕組みを利用して、contextのキャンセルを伝えています。 これは何か不思議な力を使って1回ブロードキャストしているのではなく、受信側が存在している間、closeのメッセージを送り続けるという動きで実現されています。そのため、誤って受信を行い続けると際限なく受信してしまうというものです。
何をしてて気づいたか
moguraの処理の中で、リモートのDNSを定期的に解決するというtime.Tickerを使った繰り返し処理を行なっているgoroutineがあります。これをcontextを使って、moguraのmain()がsignalを受信して終了するときに、合わせて終了しようとしたことが始まりでした。 元のコードは単純化すると以下です。
func StartCycle() error { tick := time.Tick(1 * time.Second) go func() { for _ = range tick { fmt.Println("resolve DNS") } }() return nil }
Tickを作って、for-rangeでループしてDNS解決を行うというものでした。これをcontext対応させようとして、引数にcontextを追加し、for-select文にして、<-ctx.Done()
と<-tick()
を待ち受けるcaseを設置するというものでした。ctx.Done()
の終了処理後に、別のchannelをcloseしていたんですが、その後returnないしbreakするのを忘れていてやらかしました。*1
func StartCycle(ctx context.Context) error { tick := time.Tick(1 * time.Second) go func() { for { select { case <-ctx.Done(): fmt.Println("close(otherChan)") // forgot return... case <-tick: fmt.Println("resolve DNS") } } }() return nil }
この状態で動作確認していると、close of closed channel
が出てしまい、あれ、どっかでclose2回呼んでしまった?と思いましたが、コードを見ても複数回呼ばれる要素がない。
<-ctx.Done()
は1回だけしか来ないだろうし(これが間違いだった)実はこのfuncを複数起動してしまっていたのかとも思い、デバッグログを入れてみるも、複数起動していることはありませんでした。仕方なくclose前にもログを仕込んでみると、なんと複数回呼ばれていました。実行ごとに微妙に異なるのは、signalを受信してmainが終了するまでのタイミングによるものでした。
Context.Done()は受信用のchannelで、キャンセルされた時に発火し、内部的にはchannelをclose()*2しています。そこでやっと、channelのcloseのブロードキャストは全部に1回送っているわけではなく、ひたすら送ってるのでは?という可能性に気づきます。
channelのクローズはどうなっているのか
そしてGoのruntimeのコードを見てみると、それらしいものがありました。
https://golang.org/src/runtime/chan.go#L377
// release all readers 377 for { 378 sg := c.recvq.dequeue() 379 if sg == nil { 380 break 381 } 382 if sg.elem != nil { 383 typedmemclr(c.elemtype, sg.elem) 384 sg.elem = nil 385 } 386 if sg.releasetime != 0 { 387 sg.releasetime = cputicks() 388 } 389 gp := sg.g 390 gp.param = unsafe.Pointer(sg) 391 sg.success = false 392 if raceenabled { 393 raceacquireg(gp, c.raceaddr()) 394 } 395 glist.push(gp) 396 }
377-396行目のchosechan()内の処理で受信が存在している限り、メッセージを発生させ続けることで、ブロードキャストする仕組みのようです。が、ぱっと見これで本当になってるかよく分からず。
試しに、channelをcloseした挙動を見るコード*3を書いてみました。
package main import ( "fmt" "time" ) /* closed channel is not also send message to receivers only once but send to channel until release all receivers or program finished. */ func main() { ch := make(chan struct{}) go func() { for { select { case _, ok := <-ch: if ok { fmt.Printf("chan value\n") } else { fmt.Printf("closed\n") } } } }() time.Sleep(500 * time.Millisecond) close(ch) fmt.Println("closed channel") time.Sleep(200 * time.Millisecond) fmt.Println("end") }
動かしてみると、channelがcloseされたメッセージを受け取った時のclosed
がたくさん表示されたのちend
が出力されます。実行によってはend
以降にclosed
が表示されます。これはgoroutineが動いているのとend
出力後にmain(Goのプログラム自体)が終了するタイミングによるものです。どうやら予想通り、受信がある限り送ってきている動きをしています。
よくある任意の処理終了またはタイムアウトの場合、その処理が終わって自前のchannelをクローズすることやWaitGroupで終了を待ち、タイムアウトはcontextで待ちます。その場合は、どちらかが終わるまでselectのみで待つため、複数回呼ばれるミスは起こりません。
wg := new(sync.WaitGroup) for i := 0; i < 3; i++ { wg.Add(1) go func() { // something wg.Done() }() } ... select { case <-ctx.Done(): // timeout case <-wg.Wait(): // finish }
今回私はfor-selectでcontextの<-ctx.Done()
(実際はchannelのclose待ち)を行い、受信後にforを抜けることを忘れていたため、for-select-Done()を繰り返していたのです。forを抜けるようにしたら、問題なく動くようになりました。
まとめ
- Goのchannelをcloseした時のブロードキャストは、一斉に1回送っているのではなく、受信が存在する限り送っている
- for-selectでchannelのclose(自前、またはcontextのDone()など)を扱う時は、必ず受信後にforを抜ける必要がある。(1回受け取ったら受信をやめる)
はまってしまいましたが、runtimeが不思議な力で実現していたわけではなく、とても単純な方法で行っていたのは面白かったです。なんとなく一斉に同じメッセージを1回送って終わらせていると思い込んでました。
多分大学のネットワーク授業で、ブロードキャストアドレスに送ると、ネットワーク全体に送信されるというところからイメージができてしまっていて、それと複数goroutineへの終了ブロードキャストという表現が重なったのかなと思います。
*1:2歳児が数分に1回話しかけてくるのを相手しつつコードを書いてるとダメですね
*2:https://golang.org/src/context/context.go#L404
*3:https://gist.github.com/reiki4040/b7e634e105b8bfba54515b3d51280c52#file-close_channel_messages-go