🙄

go のチャネルをちゃんと理解したい!

2023/06/24に公開

ゴルーチンにより並行処理が簡単に書くことができる go 言語でゴルーチン間の通信に使われるチャネル。
初学者がハマりやすいポイントの一つでもあり、ちゃんと理解してかっこよく使いこなしたい!と言うことで、自分なりにまとめてみました。

チャネルとは 🤔

様々な記事や書籍から定義を確認したいと思います。

A channel provides a mechanism for concurrently executing functions to communicate by sending and receiving values of a specified element type.
(訳) チャネルは、同時に実行される関数が、指定された要素タイプの値を送受信することによって通信するためのメカニズムを提供する。

出典:The Go Programming Language Specification#Channel_types

チャネルは Hoare の CSP に由来する、 Go における同期処理のプリミティブの1つです。メモリに対するアクセスを同期するのに使える一方で、ゴルーチン間の通信に使うのが最適です。

出典:Go言語による並行処理 3.3チャネル

チャネル( Channel )型は、チャネルオペレータの <- を用いて値の送受信ができる通り道です。

出典:A Tour of Go. Channels

Go言語による並行処理に書かれている文は少し難しいと感じましたが、どれも同じようなことが書かれていますね。
つまり、チャネルとはゴルーチン間の通信に使うことができる、型の一つである。 と理解しました。

それではこのチャネルをどのように使うのでしょうか。

チャネルの使い方 🫡

先述した通り、チャネルとはゴルーチン間の通信を行うために使用します。
以下のようなサンプルを用意しました。

package main

import "fmt"

func main() {
	// チャネル初期化
	sampleChan := make(chan string)

	go func() {
		// 作成したチャネルに渡す。
		sampleChan <- "Hello Channel!"
	}()

	// チャネルから文字列を受信し stdout に表示する。
	fmt.Println(<-sampleChan)
}

おそらくこれが一番ベーシックなチャネルの使用方法です。

また、一方向チャネルを定義することも可能です。

package main

func main() {
	// 受信専用
	readOnlyChan := make(<-chan string)

	// 送信専用
	sendOnlyChan := make(chan<- string)
}

ただし、一方向チャネルを宣言することはあまり行わないらしく、これらは関数の引数や返り値でよく使われるようです。

デッドロックを引き起こさないようコードを書く ✍🏻

チャネルはその性質上、間違った書き方をするとデッドロックを起こしてプログラムが panic します。
これはゴルーチンの仕組みも関係しています。

ゴルーチンはスケジュールされたからといって、そのプロセスが終了する前に全てのゴルーチンが実行されることは保証されていません。
ですが、先述したコード例の無名関数のゴルーチンはメインゴルーチンが終了するまでに必ず実行されます。

これはチャネルがブロックするためです。
先ほどのコード例をもう一度見てみます。

package main

import "fmt"

func main() {
	// チャネル初期化
	sampleChan := make(chan string)

	go func() {
		// 作成したチャネルに渡す。
		sampleChan <- "Hello Channel!"
	}()

	// チャネルから文字列を受信し stdout に表示する。
	fmt.Println(<-sampleChan)
}

上記のコードを順番に見ていくと以下のようになります。

  1. まずチャネルが初期化されます。
  2. 次に無名関数のゴルーチンが Go ランタイムにスケジュールされどこかのタイミングで走り出します。
  3. メインのゴルーチンはそのまま最後まで走り切り、文字列を受信できたら stdout に出力します。
  4. 無名関数のゴルーチンが走り出すと、sampleChan に文字列が送信されます。

ここに、先述したチャネルはブロックするということが作用してきます。

3番目で sampleChan から文字列を受信していますが、この時 sampleChan に何も入っていなければ、少なくとも1つは何かが入ってくるまで(4の処理で文字列が送信されるまで)待ちます。
つまり、チャネルが空のチャネルから読み込もうとした時、ゴルーチンの処理をブロックし、ゴルーチンはそこで待機することになる。と言うことです。

ゴルーチンが待機してくれるので、プロセスが終了するまでに必ず無名関数のゴルーチンも実行され、プロセス内で全てのゴルーチンの処理が完了する。という挙動になります。

ここまでわかったところで、デッドロックの話に戻ります。
以下のコードを見てください。

package main

import "fmt"

func main() {
	sampleChan := make(chan string)
	isFlag := true

	go func() {
		if isFlag {
			return
		}
		sampleChan <- "Hello Channel!"
	}()

	fmt.Println(<-sampleChan)
}

上記のコードの無名関数ゴルーチン内で条件がずっと変わることない if によって早期 return されています。
先述したゴルーチンの待機という挙動をこのコードに当てはめると、メインゴルーチンが最後まで走って、最後に stdout する際 sampleChannel から値が受信できるまで待機します。
ですが、無名関数のゴルーチン内で早期 return されているため sampleChan に値が送信されることはありません。
このような状況になるとデッドロックが発生して panic し、プログラムは終了します。

先ほどのコード例の実行結果
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan receive]:
main.main()
        /path/to/projects/channel_practice/main.go:16 +0x88

Process finished with the exit code 2

このようにチャネルの性質を理解していないと、簡単に panic するプログラムになってしまうので、注意してコーディングしたいですね。

最後に

このほかにも select 文やチャネルを閉じたあとの挙動など面白そうな要素がたくさんあります。
今回はかっこよくチャネルを使いたいというモチベーションで、自分の知識の整理を兼ねて諸々まとめてみました。これからも並行処理についての知識を蓄えていきたいです。

参考

https://www.oreilly.co.jp/books/9784873118468/

https://zenn.dev/hsaki/books/golang-concurrency/viewer/goelement#チャネル

https://go-tour-jp.appspot.com/concurrency/2

Discussion