go の chan のことをよく知らなかったので試してみた
知っておくといいかもしれないこと。
go の chan が close済みかどうかを知る方法は、昔あったらしいけど今はない。
途中のバージョンで削除されたらしい。
削除された理由はおそらく、無いほうがいいから。あるとむしろトラブルの原因になる。
「close済みでなければ xxx する」というコードを書いても、「close済みでなければ」と「xxxする」の間で他人が close する可能性があり、それを排除することができない。
chan が nil だったり close済みだったりする場合の動作
nil の chan からの読み出しと書き込み。
そこで止まり、書き込み・読み出しが終わることはない。
それが唯一の goルーチンの場合、panic する。Recover できない。
close済み の chan への書き込み
panic する。Recover できる。
send on closed channel
というメッセージ。
型は runtime.plainError
。
close済み の chan からの読み出し
panic せず、読み取れる。
読むべき値がない場合
ch := make(chan [3]int)
close(ch)
log.Println(<-ch, "ok") //=> [0 0 0] ok
log.Println(<-ch, "ok") //=> [0 0 0] ok
log.Println(<-ch, "ok") //=> [0 0 0] ok
ゼロ値が取れる。
close 前に値を積んである場合
ch := make(chan int, 2)
ch <- 111
ch <- 222
close(ch)
log.Println(<-ch, "ok") //=> 111 ok
log.Println(<-ch, "ok") //=> 222 ok
log.Println(<-ch, "ok") //=> 0 ok
log.Println(<-ch, "ok") //=> 0 ok
close 前に積んだ値が読める。
積んだ値がなくなったらゼロ値になる。
nil の chan を含む select
nil である chan から読み出しを試みるとそこで止まってしまうが、 select の場合は止まらずに「読めなかったね、じゃあ次」となるので問題ない。
chI := make(chan int)
var chF chan float64 = nil
go func() {
time.Sleep(500 * time.Millisecond)
chI <- 123
}()
select {
case i := <-chI:
log.Println("i:", i) // ここに来る。
case f := <-chF:
log.Println("f", f) // ここには来ない。
}
上記の例では、500ms 後に chI
から 123
が読み出され、特に困ったことは何も起きない。
close 済み chan を含む select
close 済みの chan から読み込むとゼロ値が取れるので、以下の例では即座に close 済みの chF
から 0.0
が読まれる。
500ms 後に chI
に積まれる 123 は、このコードでは読まれない。
chI := make(chan int)
chF := make(chan float64)
close(chF)
go func() {
time.Sleep(500 * time.Millisecond)
chI <- 123
}()
select {
case i := <-chI:
log.Println("i:", i) // ここには来ない。※
case f := <-chF:
log.Println("f", f) // ここに来る。
}
※ select を含む go ルーチンが何らかの事情で goルーチン作成から select
到達までに 500ms 以上の時間を要していたら chI
の方に来るかもしれない。
nil である chan の range
nil である chan を range にわたすと、そこで処理が止まる。
var ch chan int = nil
for i := range ch {
log.Println(i)
}
log.Println("exit for")
ループ内に入ることもないし、ループを抜けることもない。
このループが唯一の go ルーチンである場合、recover 不能な panic が発生する。
close 済み chan の range
当たり前だけど、 for..range
のループが始まってから close したのと同じような状況になる。
ch := make(chan int)
close(ch)
for i := range ch {
log.Println(i) // ここには来ない
}
log.Println("exit for") // すぐにここに来る
つまり、ループには入らず、すぐにループを抜ける。
nil である chan と close 済み chan の close
両方とも panic になる。
型は runtime.plainError
。
メッセージは「close of nil channel」と「close of closed channel」。
まとめ
やること |
nil である chan |
close 済み chan |
---|---|---|
読み出し | そこで停止 | ゼロ値が得られる |
書き込み | そこで停止 | パニック |
select 〜 case
|
その case にはならない | ゼロ値が得られる |
range |
そこで停止 | ループに内入らずにループ終了 |
close | panic | panic |
こうしてみると、 nil
である chan は、ほぼ、書き込み側からは「誰も読んでくれない バッファのない chan」、読み出し側からは「誰も書いてくれない chan」として機能しているが、 close の振る舞いだけが異なる。
chan の nil チェックを含むパターン
フィールドの nil チェック後に read / write
こんなの。
type hoge struct {
ch chan struct{}
}
func (h *hoge) foo() {
if h.ch == nil {
return
}
// [A]
a := <-h.ch // [B]
doSomething(a)
}
これはダメ。 [A] の箇所で誰かが h.ch
を nil
にするかもしれない。nil
になると [B] で止まる。
上記の例は read だけど、write も同じ理由でダメ。
ローカル変数の nil チェック後に read
こんなの。
func (h *hoge) bar() {
ch := h.ch
if ch == nil {
return
}
// [A]
a := <-ch // [B]
doSomething(a)
}
これは、明らかに駄目ということではないが、わりとよろしくない感じ。
[A] で h.ch
を誰かが nil にしても、 ch
にはその前の値が入っている。
ただ
-
ch
はすでに close 済みで、[B] で読める値はゼロ値かもしれない。
ということで、わりとまずいことになりそうではある。
ローカル変数の nil チェック後に write
こんなの。
func (h *hoge) baz() {
ch := h.ch
if ch == nil {
return
}
ch <- struct{}{} // [B]
}
[B] がnil
への write にならないことは間違いないが、 ch
が close 済みだとパニックになる。
つまり、事前条件と結果は
-
h.ch
が close 済みではない →ch
になんか送る -
h.ch
が close 済み →panic
-
h.ch
がnil
→ なにもしない
ということになる。
read の例と同じく、[B] の時点では h.ch
は nil
かもしれないので、 ch
を read する人はもういないかもしれない。
というわけで
というわけで、 chan が nil
かどうかで動作を変えるのはわりとうまく行かない感じ。
「close したら nil にして、nil かどうかで条件分岐」とかいう作戦より「そもそも close かどうかが不確定な状況にならないようにする」という作戦を取るべきと思う。
read 側が range で回せば write 側の close でループ抜けるので、それで済むならそれがよい。
あるいは、一回しか write しなくて、書き込み側は write → close。読み出し側は read 一回とか。
chan の仕様がそのようなメッセージとなっていると思う。
複雑なケースの場合に安全なパターンを作るのが難しくなることもあると思うけど、 nil チェックでは安全にしにくい。
あと。close 済みの chan を read するとゼロ値が得られるのが気持ち悪いような便利なような。
たとえば、 chan bool
にしておいて、いつも ch<-true
としておけば、 false
が得られた場合に close 済みだということを知ることができる。