📫

[+1tips] Goのclose()の挙動を理解していないあなたへ

2025/02/24に公開

対象読者

「close()って何となく使っとるな……」
「公式ドキュメントに記載ほぼないし、何ができるのかはっきりわからない」
と開発中にモヤモヤしているあなたに朗報です。

そんなモヤモヤを5分程度でスッキリさせる記事が爆誕しました!
スッキリした後はぜひいいねを!

close()とは

仕様について記載のある箇所を抜粋してみました。

A Tour of Go - Range and Close -

A sender can close a channel to indicate that no more values will be sent. Receivers can test whether a channel has been closed by assigning a second parameter to the receive expression: after

v, ok := <-ch

ok is false if there are no more values to receive and the channel is closed.
The loop for i := range c receives values from the channel repeatedly until it is closed.
Note: Only the sender should close a channel, never the receiver. Sending on a closed channel will cause a panic.
Another note: Channels aren't like files; you don't usually need to close them. Closing is only necessary when the receiver must be told there are no more values coming, such as to terminate a range loop.

書籍『プログラミング言語Go』p260

チャネルは三つ目の操作であるクローズをサポートしており、そのチャネルに値がこれ以上送信されないことを示すフラグを設定します。その後に送信を試みるとパニックになります。閉じられたチャネルに対する受信操作は値がなくなるまで送信された値を生成します。値がなくなった後の受信操作はすぐに完了し、チャネルの要素型のゼロ値を生成します。

p266

close操作は、チャネルに対する送信がこれ以上は行われないと断定することなので、送信しているゴルーチンだけがその操作を呼び出せる立場にあります。そのため、受信専用チャネルを閉じようとするとコンパイル時にエラーとなります。

つまり、close(ch) を呼ぶと以下のような挙動になります。

  • チャネルの送信元(sender)はこれ以上値が送信されないことを示すためにチャネルを閉じることができる
  • 閉じた後でも、バッファ内のデータは受信可能
  • i := range ch のループは、チャネルが閉じられるまでチャネルから値(i)を繰り返し受信できる
  • 新規の送信(ch <-)はできなくなる、閉じられたチャネルに送信するとパニック
  • 受信専用チャネルを閉じようとするとコンパイル時にエラーとなる
  • データがなくなると、受信時にゼロ値を返す
  • 受信側は、受信時に2番目のパラメータを割り当てることで、チャネルが閉じられているかどうかを判定できるブーリアン値を得ることができる

※閉じたチャネルで送信するとパニックが発生するので、チャネルを閉じることができるのは送信者のみ
※チャネルはI/Oリソースとは異なり閉じなくてもメモリリークするわけではない。閉じる必要があるのは、rangeループを終了する場合や受信側にこれ以上値が来ないことを通知する必要がある場合のみ(close() を呼ばなくても、チャネル自体はガベージコレクション (GC) の対象になるため、メモリリークは発生しません。by chatGPT)

ここで「なるほどね」と完全に理解した方はいいねボタンだけポチッと押してここで読み終えても良いです。
まだ、「わからん」という方はコードベースで一つ一つ説明していくので読み進めてスッキリしてください。

コードから仕様を理解

  • 閉じた後でも、バッファ内のデータは受信可能
  • i := range ch のループは、チャネルが閉じられるまでチャネルから値(i)を繰り返し受信できる
package main

import "fmt"

func main() {
    ch := make(chan int, 2)
    ch <- 1
    ch <- 2
    close(ch) // チャネルをクローズ

    for v := range ch {
        fmt.Println(v) // 1, 2 を出力
    }
}
  • close(ch) を呼んだ後も元々送信済みの既にバッファ内に存在するデータは受信することができる。
  • range を使うと、チャネルが閉じるまでデータを受け取り続け、閉じたらループが自動で終了する。
    • break する必要がない。

  • データがなくなると、受信時にゼロ値を返す
  • 受信側は、受信時に2番目のパラメータを割り当てることで、チャネルが閉じられているかどうかを判定できるブーリアン値を得ることができる
package main

import "fmt"

func main() {
    ch := make(chan int, 2)
    ch <- 42
    close(ch)

    v, ok := <-ch
    fmt.Println(v, ok) // 42 true

    v, ok = <-ch
    fmt.Println(v, ok) // 0 false
}

  • 新規の送信(ch <-)はできなくなる、閉じられたチャネルに送信するとパニック
package main

func main() {
    ch := make(chan int, 1)
    close(ch)
    ch <- 10 // パニック: send on closed channel
}

  • 受信専用チャネルを閉じようとするとコンパイル時にエラーとなる
package main

import "fmt"

func printer(in <-chan int) {
	for v := range in {
		fmt.Println(v)
	}
	close(in) // Go build failed: cannot close receive-only channel in (variable of type <-chan int)
}

func main() {
	out := make(chan int)

	printer(out)

	for x := 0; x < 100; x++ {
		out <- x
	}
}

ユースケースから仕様を理解

パイプライン(プログラミング言語Goより)
パイプラインとは

一つのゴルーチンの出力が別のゴルーチンの入力になるように、複数のゴルーチンを接続するためにチャネルを使えます。これは、パイプライン (pipeline) と呼ばれます。

package main

import "fmt"

func main() {
    naturals := make(chan int)
    squares := make(chan int)

    go counter(naturals)
    go squarer(squares, naturals)
    printer(squares) // printerはmainゴルーチンで実行
}

func counter(out chan<- int) {
    for x := 0; x < 100; x++ {
        out <- x
    }
}

func squarer(out chan<- int, in <-chan int) {
    for v := range in {
        out <- v * v
    }
}

func printer(in <-chan int) {
    for v := range in {
        fmt.Println(v)
    }
}

最初のゴルーチンcounterは、整数0, 1, 2,...を生成し、チャネルを介して二つ目のゴルーチンsquarerに送信しています。二つ目のゴルーチンはそれぞれの値を受け取り、二乗し、その結果を三つ目のゴルーチンprinterへ別のチャネルを介して送信します。三つ目のゴルーチンは乗された値を受け取り表示します。

さて、このコードはうまく動くでしょうか?
実行してみるとわかるのですが、整数の二乗が出力されたあとデッドロックが発生すると思います。
これは

  • counterはデータを送信し終わると終了するが、チャネルを閉じていないため squarer が終了しない。(受信が永遠にブロック)
  • 同じ理由でsquarerもチャネルを閉じていないためprinterが終了しない。
  • すべてのゴルーチンがブロックし、mainゴルーチンも終了できずdeadlockになる。
    のような流れでパニックを引き起こしてしまうためです。

ゴルーチンがブロックしないようにゴルーチンを停止させるための適切な方法が必要になります。

package main

import "fmt"

func main() {
    naturals := make(chan int)
    squares := make(chan int)

    go counter(naturals)
    go squarer(squares, naturals)
    printer(squares) // printerはmainゴルーチンで実行
}

func counter(out chan<- int) {
    for x := 0; x < 100; x++ {
        out <- x
    }
    close(out)
}

func squarer(out chan<- int, in <-chan int) {
    for v := range in {
        out <- v * v
    }
    close(out)
}

func printer(in <-chan int) {
    for v := range in {
        fmt.Println(v)
    }
}

このコードでは counter ゴルーチンは 100 個の要素を生成し、チャネル naturals を閉じる。これにより squarer の for x := range naturals ループが終了し、 squares チャネルを閉じる。最後に main ゴルーチンが for x := range squares を使って値を受け取り続け、 squares が閉じられた時点でループが終了する。
よって適切にプログラムを終了することができます。

番外編

nilチャネルをclose()したら?
仕様についてのリソースは見つけられなかったのですが興味本位でnilチャネルをclose()したみました。

package main

func main() {
    var ch chan int
    close(ch) 
}
出力結果
panic: close of nil channel

nilチャネルをclose()したらパニックになります(何となく予想はできた)

まとめ

お疲れ様でした!
close()の挙動について理解できたでしょうか?
筆者自身、close()の仕様について何となくの理解で実装していましたが調べてみると意外といろんな仕様があるなと思いました。(builtinにしては少ないかな?)

こんな感じで今後も皆さんのモヤモヤを少しでも晴らせるような記事を書いていきたいと思います🙂‍↕️

Discussion