Goでchannelがcloseしてるかどうか知りたい というアンチパターン

そういえば金沢に行って来た話の2〜4日目をかいてる途中で2ヶ月くらい経ったことに気付きましたが、まぁその話はおいておいて今日はGoの話です。

さて、このタイトルを見てGoに詳しく賢明な読者の方々は「あぁまたこの話題だよ、Goでchannelがcloseしてるかどうか知りたいようなパターンはだいたい書いてるアプリの設計とかchannelの使い方が間違ってるんだからやめとけ」と眉をひそめるかもしれません。まぁちょっとまって! オレもそうなんじゃないかなぁという気はしているし、ハマリどころがありそうということはうすうす分かってるけど一応調べて考えてみてもいいじゃないか。

結局の所調べて「こうすればいいね!」ってことは分かったんですが、それも破綻する場合があるので、アンチパターンだなぁと思いつつこの記事を書くことにしました。

まずGoのchannelのナイーブさを再確認する

そもそもGoのchannelがcloseしてるかどうかを知りたいっていう理由は、だいたい「Goのchannelはナイーブだから」というところに起因するのはないかと思います。どのぐらいナイーブかというと…

  • closeしてるchannelに書き込むと死ぬ ``` func main() { ch := make(chan bool) close(ch) ch <- true } panic: send on closed channel```
  • closeしてるchannelをcloseすると死ぬ ``` func main() { ch := make(chan bool) close(ch) close(ch) } panic: close of closed channel```
  • nilなchannelに書き込むと永遠にブロックする ``` func main() { var ch chan bool ch <- true } fatal error: all goroutines are asleep - deadlock!```
  • nilなchannelから読み込むと永遠にブロックする ``` func main() { var ch chan bool ch <- true } fatal error: all goroutines are asleep - deadlock!```
  • …ということで使い方ミスるとスペランカーばりによく死ぬからです。なんかFUDっぽい書き方になってしまって恐縮しておりますがハマりどころなんでこういうのは知っておかないとならないかなと思います。FUDじゃないよ! Go好きだよ!

    ひとまずcloseされたchannelとか、nilなchannelに手を出すとヤバイということを理解していただけたかと思います。nilは単に if ch == nil { って書けばチェックできるのでいいとして、問題はcloseされたかどうかです。

    channelがcloseされてるかどうかを確認する組み込み関数は昔あったけど今はなくなりました。昔というのは1.0になるまえ、r57というリリースです。リリースノートには新しい記法も書いてあります。具体的には v, ok := <- ch という複数代入記法を使って第2変数で通信成功したか取ってください、ということです。

    よっしゃこれだ! って実際にやってみると、未使用のchannelではまた罠にはまります。

  • closeしてないchannelに、データが来てない状態で読み込むとブロックする ``` import "fmt" func main() { ch := make(chan bool) _, ok := <- ch // 2つめの戻り値は読み取れたかどうかを返すのでcloseしてればfalseだ if !ok { fmt.Println("channel is closed") } } fatal error: all goroutines are asleep - deadlock!```
  • 値が来てればいいんだけど値が来てないchannelからの読み込みはブロックするのでカジュアルに _, ok := <- ch って書くとブロックしちゃうこともあります。単にcloseされたかどうか知りたいだけなのにブロックされてしまうのは困った…ということで、これをなんとかするためにselectを使います。

    selectをつかってブロックする可能性のある文をcaseにかけば一番最初にブロックが外れたやつに分岐します。で、これは@fujiwaraさんに教えてもらった技ですが、なんもしないdefaultを書けばブロックせずにすぐdefaultにおちるとのこと。

    これを踏まえてかいたのが、これだ!

    <code class="golang">package main
    import "fmt"
    
    func main() {
        ch := make(chan bool)
        //close(ch)
    
        var ok bool
        select {
            case _, ok = <-ch:
            default:
                ok = true // living!
        }
    
        if !ok {
            fmt.Println("chan is closed")
        } else {
            fmt.Println("chan is live")
        }
    }
    </code>
    [http://play.golang.org/p/eFP3vDE6Sx](http://play.golang.org/p/eFP3vDE6Sx)
    

    とまぁこんな感じになります。あとはこれをもとに自作のclosed関数をつくればいいですね、万事解決だ! …って思ったでしょ? 実は突き詰めていくとこのアプローチは破綻するんです。

    現実はもっとフクザツ

    そもそも、もともとgolangの初期の仕様にあったclosed組み込み関数が廃止されたのはやっぱりなんか理由があるわけです。go-nutsのMLとか探せばはっきりでてくるのかもしれないですが、そういうの探すの面倒です。まぁなんかヤバイから変えたというのは想像に難くないので、そのヤバイパターンを考えてみることにしました。

    ここまでは単純なplaygroundレベルのコードで見てきましたが、実際にはもっとフクザツなわけです。たとえば、「channelをcloseしてないかどうかチェックしてcloseしてない場合はcloseする」という処理を書くとします。

    <code class="golang">// chanがcloseしてるかどうかわかるcoolなユーティリティ関数だ
    func isClosed(ch chan bool) bool {
        var ok bool
        select {
            case _, ok = <-ch:
            default:
                ok = true // living!
        }
    
        return !ok
    }
    
    // channelをもらってなんか処理して最後にchannelを閉じる関数だ
    func powawa(ch chan bool) {
        // なんか大事な処理をする
    
        // 処理がおわった! chanをclosedだ! 二重closeはコワイのでチェックしてからcloseするぞ!
        if isClosed(ch) {
            close(ch)
        }
    
        return
    }</code>```
    
    一見問題無いコードに見えますが、ここには大きな落とし穴がありまして。
    
    まず isClosedでチェックしているという時点で、潜在的にpowawa関数に渡された引数chは別のgoroutineからcloseされている可能性があるということを示唆しています。ということはどういうことかというと、** `isClosed(ch)` と `close(ch)` **の間に別goroutineからcloseされる可能性がある、ということです。そうじゃなければ、そもそもif文でcloseされたかどうかをチェックする必要も無く自信をもってclose(ch)を常に実行すればいいです。
    
    で、たちが悪いのは、突き詰めて考えるとisClosedの中でselectを抜けてreturn !okするまでの間にcloseされる可能性だってあるということです。つまり、isClosed自体がもう既に完全な結果を返すことを保証できていません。一見、ほとんどの場合はたぶんちゃんと結果が帰るけどタイミング問題で稀に嘘を返すというヤバイパターンの関数になってしまっています。
    
    ここまで想像してみて、えーーマジかマジかーーーと思ったんですが、これが組み込み関数のclosedが廃止された理由なんじゃないかなぁと。
    
    もちろん、アプリの実装者としてはchannelのcloseチェックをしてcloseする、という場所を全部mutexで保護してchannelのclose処理をアトミックにやるということは理論上可能です。可能ですが、正直そんなことやりたくないしそんなことやってるコードを人にレビューしてもらうのも恥ずかしいレベルです。
    
    それに加えて、言語の実装者としてはisClosedをちゃんと常に正しいように実装しようとするとchanの内部データにmutexを持たせてisClosedの呼び出しのたびに一度ロックしてあげないとならないわけですが、かたや普通にブロッキングで値を読もうとしているgoroutineがいる、という可能性を考えるとそれをまともに実装するのは正直ムリです。
    
    まーそんなわけで、channelの状態をブロックせずに戻すメソッドとかがあっても正直人類に使いこなすのはムズカシイということがわかったのでした。ならば、closeしたかどうかを確認するような設計にする時点でもうクソ設計といわれてもしかたないですね。
    
    ## 結論
    
    ということで、まぁなんか最初にかいた「channelがcloseされてるかどうかを気にしなきゃならない時点でヤバそうな気がする」という直感は当たってたわけです。やめたほうがいいですね。
    
    - Goでchannelをcloseしてるかどうかを気にする設計はヤバイ徴候
    - closeする必要があるときは1回だけ確実にcloseするように設計しよう
    - どうしてもcloseしたかどうかチェックしなきゃならない時、例えば外部プロセスからのシーケンスを無視したメッセージを無視したい場合とかはcloseしたchannelを保持する変数をnil代入してnilチェックするとかすればよいのでは?
    

    この記事をシェア

    acidlemonのアバター

    acidlemon

    |'-') れもんです。鎌倉市在住で、鎌倉で働く普通のITエンジニアです。 30年弱住んだ北海道を離れ、鎌倉でまったりぽわぽわしています。

    こういうのも読む?