年中アイス

いろいろつらつら

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