この記事では、Go言語のチャネル(channel)について、基本からしっかり解説していきます。
Go言語といえば並行処理、その並行処理をスムーズに進めるための縁の下の力持ちが、このチャネルなんです。
この記事を読めば、チャネルの仕組みから使い方、ちょっとした落とし穴まで丸わかり。もうチャネルなんて怖くない!って思えるはずです。
この記事で学べること
- チャネルってそもそも何? なぜ必要なの?
- チャネルの作り方(宣言と初期化)
- チャネルを使ったデータの送り方・受け取り方
- Goroutineとチャネルを連携させる方法
- バッファあり・なしチャネルの違いと使い分け
- チャネルを閉じるってどういうこと?
- 複数のチャネルを扱う select 文の使い方
- チャネルを使うときに気をつけること(デッドロックとか!)
Go言語のチャネルとは? ~並行処理の要~
Go言語のチャネルは、一言でいうとGoroutine(ゴルーチン)間でデータの受け渡しをするためのパイプのようなものです。
Go言語では `go` キーワードを使うと、処理を並行して(ほぼ同時に)動かすGoroutineを簡単に作れます。とっても便利ですよね!
でも、別々に動いているGoroutine同士が情報を交換したり、処理のタイミングを合わせたりする必要が出てきます。例えば、ある Goroutine で計算した結果を、別の Goroutine で使いたい、みたいな場面です。
そんなとき、チャネルが登場します! チャネルを通してデータを安全に送受信できるので、複雑になりがちな並行処理のプログラムを、シンプルに書けるようになるんです。
他のプログラミング言語だと、共有メモリとかロックとか、ちょっと気難しい仕組みを使ってデータの受け渡しや同期を管理することが多いのですが、Go言語ではこのチャネルが、そうした面倒ごとをスマートに解決してくれる、というわけですね。
※ちなみに、Goroutineについてよくわかっていない方は以下の記事を参照してください。Go言語のチャネルの基本的な書き方(宣言と初期化)
チャネルを使うには、まず「こういう型のデータを通すチャネルですよ」と宣言して、それから `make` 関数で実際に使えるように初期化する必要があります。
チャネル変数の宣言
`var` キーワードを使って宣言します。`chan` の後に、そのチャネルで送受信したいデータの型を指定します。// int型のデータを送受信するチャネル変数chを宣言 var ch chan int
チャネルの初期化
宣言しただけだと、まだチャネルは使えません(`nil` という状態です)。`make` 関数を使って初期化しましょう。短縮変数宣言 `:=` を使うのが一般的です。// int型のデータを送受信するチャネルを作成し、変数chに代入 ch := make(chan int) // string型のデータを送受信するチャネル chStr := make(chan string) // bool型のデータを送受信するチャネル chBool := make(chan bool)
これで、指定した型のデータを受け渡しできるパイプ(チャネル)が用意できました!
チャネルのゼロ値はnil
チャネル変数を宣言しただけで `make` で初期化しないと、その変数の値は `nil` になります。
この `nil` のチャネルに対してデータを送ったり、受け取ろうとしたりすると、プログラムはずっと待ち続けてしまい、動きません(デッドロックの原因になります)。
package main func main() { var ch chan int // 宣言のみで初期化していない (nilチャネル) // nilチャネルへの送信 (ここで永遠にブロックする) // ch <- 1 // nilチャネルからの受信 (ここで永遠にブロックする) // <-ch // なので、チャネルを使う前には必ずmakeで初期化しよう! // ch = make(chan int) }
チャネルを使う前には、必ず `make` で初期化する、これ、テストに出ますよ!(笑) 忘れないようにしましょうね。
Go言語のチャネルの使い方① - データの送信と受信
チャネルが用意できたら、いよいよデータの送受信です。使うのは左向き矢印 `<-` の演算子です。
データ送信
チャネル変数名の右側に `<-` を書き、その右に送りたいデータを書きます。
ch := make(chan int) // Goroutineを起動して、チャネルにデータを送信する go func() { dataToSend := 100 ch <- dataToSend // chチャネルに100を送信 println("データを送信しました!") }() // ... (この後、受信処理が必要)
データ受信
チャネル変数名の左側に `<-` を書きます。受信したデータは変数に入れることができます。
// chチャネルからデータを受信し、変数receivedDataに入れる receivedData := <-ch println("データを受信しました!", receivedData) // -> データを受信しました! 100
ここで一つポイント!
デフォルト(バッファなしチャネルの場合)では、送信操作は、誰かが受信するまで処理を止めます。受信操作も、誰かが送信するまで処理を止めます。
この「待機する」性質が、Goroutine同士のタイミングを合わせる(同期を取る)のに役立つんです。
Go言語のチャネルの使い方② - Goroutineとの連携
チャネルの真価は、Goroutineと組み合わせたときに発揮されます。
簡単な例を見てみましょう。あるGoroutineで時間のかかる計算をして、その結果をメインのGoroutine(プログラム本体)にチャネル経由で送ってみます。
package main import ( "fmt" "time" ) // 時間のかかる計算(のフリ)をして結果をチャネルに送る関数 func heavyCalculation(resultChan chan int) { fmt.Println("計算開始...") time.Sleep(2 * time.Second) // 2秒待つ result := 1 + 1 // 簡単な計算 fmt.Println("計算完了!結果を送信します。") resultChan <- result // 計算結果をチャネルに送信 } func main() { fmt.Println("プログラム開始") // int型の結果を受け取るためのチャネルを作成 resultChan := make(chan int) // Goroutineを起動して計算を開始させる go heavyCalculation(resultChan) fmt.Println("計算結果を待っています...") // チャネルから結果を受信する (heavyCalculationが終わるまでここで待機) result := <-resultChan fmt.Printf("計算結果を受け取りました: %d\n", result) fmt.Println("プログラム終了") }
ソースコードの表示結果
プログラム開始 計算結果を待っています... 計算開始... 計算完了!結果を送信します。 計算結果を受け取りました: 2 プログラム終了
どうでしょう?
`go heavyCalculation(resultChan)` で計算処理はバックグラウンドで動き始めます。
メインの処理は `result := <-resultChan` のところで、チャネルにデータが送られてくるまで待機します。
計算が終わって `resultChan <- result` でデータが送信されると、待機が解除されて結果を受け取り、プログラムが進む、という流れです。
これで、時間のかかる処理を待っている間も、他の処理(今回は `fmt.Println("計算結果を待っています...")` だけですが)をブロックせずに進められますね。
Go言語のチャネルの種類 - バッファなしとバッファあり
チャネルには、大きく分けて2種類あります。`make` でチャネルを作るときに、ちょっとした違いがあります。
どちらを使うかは、やりたいこと次第です!
- バッファなしチャネル (Unbuffered Channel)
- `make(chan int)` のように、サイズを指定せずに作るチャネル。
- 送信者は受信者が現れるまで、受信者は送信者が現れるまで、お互いを待ちます。まるで直接手渡しするイメージです。
- バッファありチャネル (Buffered Channel)
- `make(chan int, 3)` のように、`make`の第2引数でサイズ(バッファサイズ)を指定して作るチャネル。
- このサイズ分だけ、送信データを一時的にチャネル内に溜めておくことができます。郵便ポストみたいなイメージですね。
- 送信者は、バッファに空きがある限り、受信者がいなくてもデータを送信して、すぐに次の処理に進めます。受信者も、バッファにデータがあれば、送信者がいなくてもデータを受け取れます。
- ただし、バッファが一杯の時に送信しようとすると待機します。バッファが空の時に受信しようとすると待機します。
バッファなしチャネルの特徴と使いどころ
バッファなしチャネルは、送信と受信が必ずペアになって行われるのが特徴です。
つまり、送信が完了した=誰かが受信した、受信が完了した=誰かが送信した、ということが保証されます。
この性質を利用して、以下のような場面でよく使われます。
- Goroutine間の確実な同期を取りたいとき。
- ある処理が終わったことを別のGoroutineに確実に伝えたいとき(完了通知)。
まさに、「ピッタリ息を合わせたい」ときに活躍するチャネルですね!
バッファありチャネルの特徴と使いどころ
バッファありチャネルは、送信側と受信側の処理速度が違っても、ある程度処理をスムーズに進められるのがメリットです。
バッファが、その速度差を吸収してくれるクッションの役割を果たします。
例えば、こんな場面で便利です。
- データをどんどん作るGoroutine(生産者)と、そのデータをゆっくり処理するGoroutine(消費者)がいるとき。生産者はバッファに空きがあればデータを置いて先に進めます。
- たくさんのGoroutineから送られてくる結果を、一時的に溜めておきたいとき。
「マイペースに進めたい」「とりあえずデータを置いておきたい」ときに便利なのが、バッファありチャネルです。
ただし、バッファサイズをいくつにするかは、ちょっとした悩みどころ。小さすぎると結局待つことになり、大きすぎるとメモリを使いすぎる可能性もあります。
Go言語のチャネルの操作 - closeとrange
チャネルは、もうこれ以上データを送らないよ、という合図として「閉じる(close)」ことができます。
閉じるには `close()` 関数を使います。
ch := make(chan int, 3) // 送信する側がcloseを呼ぶのが一般的 close(ch)
チャネルを閉じるのは、データを送信する側が行うべき、というルールがあります。受信側で閉じてしまうと、まだ送信したいデータがあった場合に困ってしまいますからね。
閉じたチャネルに対して、さらにデータを送信しようとするとプログラムがパニックを起こして止まってしまうので注意が必要です。
でも、閉じたチャネルからデータを受信することはできます。データが残っていればそれを受け取り、もう空っぽなら、その型のゼロ値(intなら0, stringなら空文字など)が即座に返ってきます。
チャネルが閉じられたかどうかを知るには、受信操作で2つ目の戻り値を使います。
value, ok := <-ch if ok { fmt.Println("データを受信しました:", value) } else { fmt.Println("チャネルは閉じられています。") }
`ok` が `true` ならデータ受信成功、`false` ならチャネルは閉じられています。
そして、チャネルと相性が良いのが `for range` ループです。
チャネルに対して `for range` を使うと、チャネルにデータが送られてくるたびにループが回り、データを受信して処理できます。
そして、チャネルが閉じられると、この `for range` ループは自動的に終了します。これはとっても便利!
package main import "fmt" func main() { queue := make(chan string, 2) queue <- "one" queue <- "two" close(queue) // 送信が終わったら閉じる // for rangeでチャネルから受信 // チャネルが閉じられるとループが終了する for elem := range queue { fmt.Println(elem) } // 出力: // one // two }
これで、受信側はチャネルがいつ閉じられるかを気にせずに、送られてくるデータを順番に処理できますね!
Go言語のチャネルの応用 - select文の使い方
もし、複数のチャネルを同時に扱いたい場合はどうしましょう?
例えば、「チャネルAかチャネルB、どちらか先にデータが来た方を受け取りたい」とか、「チャネルCにデータを送信したいけど、もしすぐ送信できなかったら別の処理をしたい」みたいなケースです。
そんなときに活躍するのが `select` 文です!
`select` 文は `switch` 文に似ていますが、チャネル操作専用です。複数の `case` にチャネルの送受信操作を書き、そのうち最初に準備ができた(ブロックしない)操作が実行されます。もし同時に複数準備できた場合は、ランダムにどれか一つが選ばれます。
package main import ( "fmt" "time" ) func main() { c1 := make(chan string) c2 := make(chan string) // Goroutine 1: 1秒後にc1に送信 go func() { time.Sleep(1 * time.Second) c1 <- "one" }() // Goroutine 2: 2秒後にc2に送信 go func() { time.Sleep(2 * time.Second) c2 <- "two" }() // selectを使って、c1とc2の両方を待つ // 先に準備ができた方から受信する for i := 0; i < 2; i++ { // 2回ループして両方受信する select { case msg1 := <-c1: fmt.Println("received", msg1) case msg2 := <-c2: fmt.Println("received", msg2) } } // 出力: // received one (約1秒後) // received two (約2秒後) }
`select` には `default` ケースを書くこともできます。
`default` ケースがあると、`select` 文が評価されたときに、どの `case` のチャネル操作もすぐに実行できない(ブロックする)場合に、`default` ケースが実行されます。これにより、チャネル操作をブロックせずに行う(ノンブロッキング操作)ことができます。
select { case msg := <-messages: fmt.Println("received message", msg) case sig := <-signals: fmt.Println("received signal", sig) default: fmt.Println("no activity") // すぐに送受信できなければ、これが実行される }
さらに、`time.After` 関数と組み合わせることで、タイムアウト処理も簡単に実装できます。
select { case res := <-resultChan: fmt.Println("結果:", res) case <-time.After(1 * time.Second): // 1秒待つチャネル fmt.Println("タイムアウトしました!") }
`select` を使いこなせると、より柔軟で堅牢な並行処理プログラムが書けるようになりますよ!
Go言語のチャネル利用時の注意点
チャネルは便利ですが、使い方を間違えるとプログラムが予期せぬ動きをしたり、最悪の場合、完全に停止してしまう「デッドロック」を引き起こしたりします。
デッドロックは、すべてのGoroutineがチャネル操作(送受信)で互いに待ち状態になってしまい、誰も処理を進められなくなる状態のことです。こうなるとプログラムは応答しなくなります。
デッドロックが起きやすい典型的なパターンを知っておきましょう。
- バッファなしチャネルへの送信だけして、受信がない
送信側はずっと受信者を待ち続けます。 - バッファなしチャネルからの受信だけして、送信がない
受信側はずっと送信者を待ち続けます。 - バッファありチャネルが一杯なのに、さらに送信しようとする
バッファに空きが出るまで待ち続けます。 - バッファありチャネルが空なのに、受信しようとする
データが送信されるまで待ち続けます。 - `nil` チャネルへの送受信
先ほど説明した通り、永遠にブロックします。
NG例:デッドロック!
package main func main() { ch := make(chan int) // バッファなしチャネル ch <- 1 // 送信するが、誰も受信しない... デッドロック! }
OK例:Goroutineで受信する
package main import "fmt" func main() { ch := make(chan int) go func() { data := <-ch // 別Goroutineで受信する fmt.Println("受信:", data) }() ch <- 1 // これで送信できる // (実際にはGoroutineの完了を待つ処理が必要になる場合が多い) }
デッドロック以外にも、チャネルの閉じ忘れによって、受信側の `for range` ループが終わらなかったり、余計なGoroutineが動き続けてしまう(Goroutineリーク)といった問題も起こりえます。
これらの問題を避けるためには、
- チャネルの送受信が必ずペアになるように設計する。
- バッファサイズを適切に設定する。
- `nil` チャネルを操作しない(必ず `make` する)。
- 不要になったチャネルは適切に `close` する(主に送信側で)。
- `select` の `default` やタイムアウトをうまく使う。
といった点を意識すること大切です。
【まとめ】Go言語のチャネルを理解して並行処理をマスターしよう!
今回はGo言語のチャネルについて、基本的なところから応用、注意点まで一通り見てきました。盛りだくさんでしたね!
最後に要点をまとめます。
- チャネルはGoroutine間でデータを安全に受け渡すためのパイプ。
- `make(chan 型)` や `make(chan 型, サイズ)` で作る。
- `<-` 演算子で送受信。送受信はブロックすることがある(同期)。
- バッファなしは同期用、バッファありは緩衝材として。
- `close()` でチャネルを閉じると `for range` が終了する。
- `select` 文で複数のチャネルを扱える。
- デッドロックやGoroutineリークに気をつけよう!
チャネルは、Go言語らしい、シンプルでパワフルな並行処理を実現するための核となる仕組みです。
最初はちょっと戸惑うかもしれませんが、実際にコードを書いて動かしてみるのが一番の近道!この記事のサンプルコードもぜひ試してみてください。
チャネルを使いこなせれば、あなたのGo言語プログラミングは、きっと一段階レベルアップするはずです。
【関連記事】 Go言語とは?特徴・できること・将来性
0 件のコメント:
コメントを投稿
注: コメントを投稿できるのは、このブログのメンバーだけです。