[Goのはじめ方] Part21: Channelの基本とselect文

Go言語の大きな特徴である並行処理。前回はGoroutineについて学びました。今回は、Goroutine間で安全にデータをやり取りするための仕組みであるChannel(チャネル)と、複数のチャネルを扱うためのselect文について学びましょう!

Channel(チャネル)とは?

チャネルは、Goroutine間でデータを送受信するためのパイプのようなものです。 Go言語では「通信によってメモリを共有する」という考え方が推奨されており、チャネルはその中心的な役割を担います。

チャネルを使うことで、複数のGoroutineが同時に同じデータにアクセスしようとして起こる問題(競合状態)を防ぎ、安全なデータのやり取りが可能になります。

チャネルは以下の特徴を持っています:

  • 型付けされている: チャネルは特定の型のデータしか送受信できません(例: chan int はint型専用)。
  • 送受信操作: <- 演算子を使ってデータの送信と受信を行います。
  • 同期機構: デフォルト(バッファなしチャネル)では、送信側と受信側が揃うまで処理がブロックされます。これによりGoroutine間の同期を取ることができます。

チャネルの作成

チャネルは組み込みのmake関数を使って作成します。

package main

import "fmt"

func main() {
    // int型の値を送受信するチャネルを作成
    ch := make(chan int)
    fmt.Printf("チャネルの型: %T, 値: %v\n", ch, ch) // 出力例: チャネルの型: chan int, 値: 0xc00001a0e0

    // string型の値を送受信するチャネルを作成
    strCh := make(chan string)
    fmt.Printf("チャネルの型: %T, 値: %v\n", strCh, strCh)

    // bool型の値を送受信するチャネルを作成
    boolCh := make(chan bool)
    fmt.Printf("チャネルの型: %T, 値: %v\n", boolCh, boolCh)

    // makeで作成しない場合、チャネルのゼロ値はnil
    var nilCh chan int
    fmt.Printf("nilチャネルの型: %T, 値: %v\n", nilCh, nilCh) // 出力例: nilチャネルの型: chan int, 値: 
}

makeで作成しない場合、チャネル変数の値はnilとなり、そのまま送受信しようとするとデッドロック(プログラムが停止してしまう状態)になります。

チャネルへの送信と受信

<- 演算子を使ってチャネルにデータを送信したり、チャネルからデータを受信したりします。

  • 送信: チャネル変数 <- 値
  • 受信: 変数 := <-チャネル変数
package main

import (
	"fmt"
	"time"
)

func sendData(ch chan string) {
	fmt.Println("Goroutine: 送信するよ! ")
	ch <- "こんにちは、チャネル!" // チャネルに文字列を送信
	fmt.Println("Goroutine: 送信完了!")
}

func main() {
	ch := make(chan string) // string型のチャネルを作成

	go sendData(ch) // Goroutineを起動してsendData関数を実行

	fmt.Println("Main: データ受信待ち... ")
	// チャネルからデータを受信 (データが送信されるまでここでブロックされる)
	message := <-ch
	fmt.Printf("Main: 受信したデータ: %s\n", message)

	fmt.Println("Main: 処理完了!")
	time.Sleep(1 * time.Second) // Goroutineの終了を待つ(デモ用)
}

上記の例では、sendData Goroutineがチャネルにデータを送信するまで、main Goroutineのmessage := <-chの部分で処理が待機(ブロック)されます。データが送信されると、main Goroutineは受信を完了し、処理を続行します。

バッファなしチャネル vs バッファ付きチャネル

makeで作成する際、第二引数でバッファサイズを指定できます。

  • バッファなしチャネル (Unbuffered Channel): make(chan T)
    • サイズが0のチャネル。
    • 送信操作は、対応する受信操作が準備できるまでブロックされる。
    • 受信操作は、対応する送信操作が準備できるまでブロックされる。
    • Goroutine間の確実な同期が必要な場合に適しています。
  • バッファ付きチャネル (Buffered Channel): make(chan T, capacity)
    • サイズがcapacityのバッファを持つチャネル。
    • 送信操作は、バッファがいっぱいになるまでブロックされない。
    • 受信操作は、バッファが空になるまでブロックされない。
    • 一時的にデータを溜めておくキューのような使い方ができます。 FIFO(先入れ先出し)の順序が保証されます。
package main

import "fmt"

func main() {
    // バッファなしチャネル (同期)
    // unbufCh := make(chan int)
    // unbufCh <- 1 // ここで受信側がいないとデッドロック!
    // go func() { <-unbufCh }() // 別Goroutineで受信すればOK

    // バッファ付きチャネル (非同期)
    bufCh := make(chan int, 2) // サイズ2のバッファ

    bufCh <- 1 // ブロックされない
    bufCh <- 2 // ブロックされない
    // bufCh <- 3 // バッファが一杯なので、ここで受信されるまでブロックされる

    fmt.Println("バッファ付きチャネルから受信:", <-bufCh)
    fmt.Println("バッファ付きチャネルから受信:", <-bufCh)
}

バッファ付きチャネルは、送信側と受信側の処理速度に差がある場合などに便利ですが、バッファがいっぱいになると送信側が、バッファが空になると受信側がブロックされる点はバッファなしチャネルと同じです。

チャネルのクローズ

close関数を使ってチャネルを閉じることができます。これは、もうこれ以上データを送信しないことを受信側に伝えるための合図です。

package main

import "fmt"

func main() {
	jobs := make(chan int, 5)
	done := make(chan bool)

	go func() {
		for {
			// 受信操作は2つの値を受け取れる
			// j: 受信した値
			// ok: チャネルが開いているか (true: 開いている, false: 閉じている)
			j, ok := <-jobs
			if ok {
				fmt.Println("受信したジョブ:", j)
			} else {
				fmt.Println("全ジョブ受信完了!チャネルは閉じられました。")
				done <- true // 完了を通知
				return
			}
		}
	}()

	// 3つのジョブを送信
	for j := 1; j <= 3; j++ {
		jobs <- j
		fmt.Println("送信したジョブ:", j)
	}
	// これ以上ジョブは送信しないのでチャネルを閉じる
	close(jobs)
	fmt.Println("全ジョブ送信完了。チャネルを閉じました。")

	// 受信側Goroutineの完了を待つ
	<-done
}

注意点:

  • 閉じたチャネルに送信しようとするとパニックが発生します 。
  • 閉じたチャネルから受信すると、バッファに残っている値を受信し続けます。バッファが空になると、そのチャネルの型のゼロ値(intなら0, stringなら””など)が即座に返され、2つ目の戻り値okfalseになります。
  • チャネルを閉じるのは、通常は送信側の役割です。
  • すでに閉じているチャネルを再度closeしようとするとパニックが発生します。

for rangeを使ってチャネルから受信することもできます。この場合、チャネルが閉じられると自動的にループが終了します。

package main

import "fmt"

func main() {
	queue := make(chan string, 2)
	queue <- "one"
	queue <- "two"
	close(queue) // closeしないとfor rangeがデッドロックになる!

	// チャネルが閉じられるまで値を受信し続ける
	for elem := range queue {
		fmt.Println(elem)
	}
	// 出力:
	// one
	// two
}

select文

select文は、複数のチャネル操作(送受信)を同時に待機し、いずれか一つが実行可能になったときにその操作を実行するための仕組みです。switch文に似ていますが、チャネル操作専用です。

基本的な使い方

select文はcase節を持ち、各caseはチャネルの送受信操作に対応します。

package main

import (
	"fmt"
	"time"
)

func main() {
	c1 := make(chan string)
	c2 := make(chan string)

	// 1秒後にc1へ送信
	go func() {
		time.Sleep(1 * time.Second)
		c1 <- "one"
	}()
	// 2秒後にc2へ送信
	go func() {
		time.Sleep(2 * time.Second)
		c2 <- "two"
	}()

	// 2つのチャネルの受信を待つ
	// selectはどちらかのcaseが実行可能になるまでブロックする
	for i := 0; i < 2; i++ {
		select {
		case msg1 := <-c1:
			fmt.Println("received", msg1)
		case msg2 := <-c2:
			fmt.Println("received", msg2)
		}
	}
    // 出力例 (実行順は不定):
    // received one
    // received two
}

複数のcaseが同時に実行可能になった場合、selectはそのうちの一つをランダムに選択して実行します。

defaultケース

select文にdefaultケースを追加すると、どのcaseもすぐに実行できない場合にdefaultケースが実行されます。これにより、select文がブロックしなくなります(ノンブロッキング操作)。

package main

import (
	"fmt"
	"time"
)

func main() {
	messages := make(chan string)
	signals := make(chan bool)

	// 何も送信しないGoroutine (デモ用)
	// go func() {
	// 	time.Sleep(1 * time.Second)
	// 	messages <- "hi"
	// }()

	// すぐに実行できるcaseがないかチェック
	select {
	case msg := <-messages:
		fmt.Println("received message", msg)
	default: // どのcaseもすぐに実行できない場合
		fmt.Println("no message received")
	}

	msg := "hi"
	// 送信を試みる (messagesチャネルはバッファなしなので受信側がいないとブロックする)
	select {
	case messages <- msg: // 受信側がいないため、すぐには実行できない
		fmt.Println("sent message", msg)
	default: // すぐに送信できない場合
		fmt.Println("no message sent")
	}

	// 複数のcaseを持つノンブロッキングselect
	select {
	case msg := <-messages:
		fmt.Println("received message", msg)
	case sig := <-signals:
		fmt.Println("received signal", sig)
	default:
		fmt.Println("no activity")
	}

    // 出力:
    // no message received
    // no message sent
    // no activity
}

タイムアウト処理

selecttime.Afterチャネルを組み合わせることで、タイムアウト処理を簡単に実装できます。time.Afterは指定した時間が経過した後に現在時刻を送信するチャネルを返します。

package main

import (
	"fmt"
	"time"
)

func main() {
	c1 := make(chan string, 1)
	go func() {
		// 2秒かかる処理
		time.Sleep(2 * time.Second)
		c1 <- "result 1"
	}()

	// c1からの受信を待つが、1秒でタイムアウトする
	select {
	case res := <-c1:
		fmt.Println(res)
	case <-time.After(1 * time.Second): // 1秒後に値を受信するチャネル
		fmt.Println("timeout 1")
	}

	c2 := make(chan string, 1)
	go func() {
		// 0.5秒かかる処理
		time.Sleep(500 * time.Millisecond)
		c2 <- "result 2"
	}()

    // c2からの受信を待つが、1秒でタイムアウトする
	select {
	case res := <-c2:
		fmt.Println(res)
	case <-time.After(1 * time.Second):
		fmt.Println("timeout 2")
	}
    // 出力:
    // timeout 1
    // result 2
}

最初のselectでは、c1からの受信(2秒後)よりもtime.After(1秒後)が先に実行可能になるため、タイムアウトします。二番目のselectでは、c2からの受信(0.5秒後)がtime.After(1秒後)よりも先に実行可能になるため、結果を受信できます。

まとめ

今回は、Goの並行処理において重要な役割を果たすチャネルとselect文について学びました。

  • チャネル: Goroutine間で安全にデータを送受信するための型付きパイプ。makeで作成し、<-で送受信する。バッファなし(同期的)とバッファ付き(非同期的)がある。closeで閉じることができる。
  • select文: 複数のチャネル操作を待ち受け、最初に実行可能になったものを(ランダムに)実行する。defaultでノンブロッキングに、time.Afterと組み合わせてタイムアウト処理を実装できる。

これらの機能を使いこなすことで、Goの強力な並行処理をより安全かつ効率的に実装できるようになります。最初は少し難しく感じるかもしれませんが、実際にコードを書いて動かしてみるのが一番の近道です!どんどん試してみてくださいね。

次回は、並行処理をより安全に行うためのsyncパッケージ(MutexやWaitGroupなど)について見ていきましょう!

次のステップに進みましょう!次は syncパッケージ(Mutex, WaitGroupなど) について学びます。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です