Golang におけるチャネルの基本操作

GolangBeginner
オンラインで実践に進む

はじめに

Go 言語の最も魅力的な特徴の 1 つは、並行プログラミングに対するネイティブサポートです。並列処理、並行プログラミング、ネットワーク通信に対する強力なサポートを備えており、マルチコアプロセッサをより効率的に活用できます。

共有メモリによって並行性を実現する他の言語とは異なり、Go はチャネルを使用して異なるゴルーチン間での通信を行うことで並行性を実現します。

ゴルーチンは Go における並行性の単位であり、軽量スレッドと見なすことができます。従来のスレッドと比較して、ゴルーチン間を切り替える際の消費リソースが少なくなります。

チャネルとゴルーチンは合わせて、Go における並行性のプリミティブを構成します。このセクションでは、この新しいデータ構造であるチャネルについて学習します。

学習ポイント:

  • チャネルの型
  • チャネルの作成
  • チャネルの操作
  • チャネルのブロッキング
  • 単方向チャネル

チャネルの概要

チャネルは、主にゴルーチン間の通信に使用される特殊なデータ構造です。

ミューテックス(mutex)やアトミック関数を使用してリソースへの安全なアクセスを実現する他の並行モデルとは異なり、チャネルは複数のゴルーチン間でデータを送受信することにより同期を可能にします。

Go において、チャネルはコンベアベルトやキューのようなもので、先入れ先出し(FIFO: First-In-First-Out)のルールに従います。

チャネルの型と宣言

チャネルは参照型であり、その宣言形式は以下の通りです。

var channelName chan elementType

各チャネルには特定の型があり、同じ型のデータのみを転送できます。いくつかの例を見てみましょう。

~/project ディレクトリ内に channel.go という名前のファイルを作成します。

cd ~/project
touch channel.go

例えば、int 型のチャネルを宣言してみましょう。

var ch chan int

上記のコードは、ch という名前の int チャネルを宣言しています。

これに加えて、他にも一般的に使用される多くのチャネル型があります。

channel.go に、以下のコードを記述します。

package main

import "fmt"

func main() {
    var ch1 chan int   // 整数チャネルを宣言
    var ch2 chan bool  // ブーリアンチャネルを宣言
    var ch3 chan []int // 整数のスライスを転送するチャネルを宣言
    fmt.Println(ch1, ch2, ch3)
}

上記のコードは、単に 3 種類の異なる型のチャネルを宣言しています。

以下のコマンドを使用してプログラムを実行します。

go run channel.go

プログラムの出力は以下の通りです。

<nil> <nil> <nil>

チャネルの初期化

マップと同様に、チャネルも宣言後に使用する前に初期化する必要があります。

チャネルを初期化するための構文は以下の通りです。

make(chan elementType)

channel.go に、以下のコードを記述します。

package main

import "fmt"

func main() {
    // 整数データを格納するチャネル
    ch1 := make(chan int)

    // ブーリアンデータを格納するチャネル
    ch2 := make(chan bool)

    // []int データを格納するチャネル
    ch3 := make(chan []int)

    fmt.Println(ch1, ch2, ch3)
}

以下のコマンドを使用してプログラムを実行します。

go run channel.go

プログラムの出力は以下に示されます。

0xc0000b4000 0xc0000b4040 0xc0000b4080

上記のコードでは、3 種類の異なる型のチャネルが定義され、初期化されています。

出力を見ると、マップとは異なり、チャネル自体を直接出力してもその中身は表示されないことがわかります。

チャネルを直接出力すると、チャネル自体のメモリアドレスのみが表示されます。これは、チャネルがポインタと同様に、メモリアドレスへの参照にすぎないためです。

では、チャネル内の値にアクセスし、操作するにはどうすればよいでしょうか?

チャネル操作

チャネルの操作は、データの送信と受信を中心に行われ、これには <- 演算子を使用します。チャネルが実際に動作する様子を見るためには、Go の並行処理のための軽量スレッドであるゴルーチンと組み合わせて使用する必要があります。

チャネルは、送信側のゴルーチンと受信側のゴルーチンを接続します。片方が準備できていない場合、もう一方はブロックされます。完全な例を見てみましょう。

channel.go に、以下のコードを記述します。

package main

import (
    "fmt"
    "time"
)

func main() {
    // バッファなしチャネルを作成
    ch := make(chan int)

    // 無名関数を使用して新しいゴルーチンを開始
    go func() {
        fmt.Println("Goroutine starts sending...")
        // チャネルに値 10 を送信
        ch <- 10
        fmt.Println("Goroutine finished sending.")
    }()

    fmt.Println("Main is waiting for data...")
    // チャネルから値を受信
    value := <-ch
    fmt.Printf("Main received: %d\n", value)

    // ゴルーチンが最後のメッセージを出力する時間を確保するために待機
    time.Sleep(time.Second)
}

以下のコマンドを使用してプログラムを実行します。

go run channel.go

プログラムの出力はこれと似たものになります(順序は若干異なる場合があります)。

Main is waiting for data...
Goroutine starts sending...
Main received: 10
Goroutine finished sending.

この例では:

  1. ch := make(chan int) はバッファなしチャネルを作成します。バッファなしチャネルでは、通信が発生するためには送信側と受信側の両方が同時に準備できている必要があります。これは同期と呼ばれます。
  2. go func() { ... }() は新しいゴルーチンを開始します。go キーワードは、その関数を main 関数と並行して実行します。
  3. ゴルーチン内で、ch <- 10 は値 10 をチャネルに送信します。この操作は、main ゴルーチンが受信する準備ができるまでブロックされます。
  4. main 関数内で、value := <-ch は値を受信します。この操作は、ゴルーチンが値を送信するまでブロックされます。
  5. 両方が準備できると、値が転送され、両方のゴルーチンはブロックを解除され、実行を続行します。ゴルーチンが最後のメッセージを出力する前にプログラムが終了しないように、time.Sleep を追加しました。

チャネルのクローズ

チャネルにこれ以上値が送信されなくなった場合、送信者は close() 関数を使用してチャネルをクローズできます。チャネルがクローズされるまでチャネルからすべての値を読み取る一般的な方法は、for range ループを使用することです。

channel.go を変更してみましょう。

package main

import "fmt"

func main() {
    // 複数の値を保持するためのバッファ付きチャネル
    ch := make(chan int, 3)

    // データを送信するゴルーチンを開始
    go func() {
        fmt.Println("Goroutine sending 10, 20, 30")
        ch <- 10
        ch <- 20
        ch <- 30
        // すべての値を送信した後、チャネルをクローズ
        close(ch)
        fmt.Println("Goroutine closed the channel.")
    }()

    fmt.Println("Main is receiving data...")
    // for-range ループを使用して、チャネルがクローズされるまで値を受信
    for value := range ch {
        fmt.Printf("Main received: %d\n", value)
    }
    fmt.Println("Main finished receiving.")
}

以下のコマンドを使用してプログラムを実行します。

go run channel.go

プログラムの出力は以下の通りです(順序は若干異なる場合があります)。

Main is receiving data...
Goroutine sending 10, 20, 30
Main received: 10
Main received: 20
Main received: 30
Goroutine closed the channel.
Main finished receiving.

これは一般的なパターンを示しています。for range ループは、チャネルがクローズされたかどうかをチェックし、クローズされたときに自動的に終了する処理を処理します。

クローズされたチャネルを扱う際には、次の重要なルールを覚えておいてください。

  • クローズされたチャネルへの送信は、実行時エラー(パニック)を引き起こします。
  • クローズされて空のチャネルからの受信は、チャネル型のゼロ値を直ちに返します。
  • すでにクローズされているチャネルを再度クローズすると、パニックが発生します。
  • 一般的に、チャネルをクローズするのは送信者のみであり、受信者が行うべきではありません。

チャネルのブロッキング

前のステップで確認したように、チャネル操作はブロッキングする可能性があります。これは、ゴルーチンが同期できるようにするための重要な機能です。これについて詳しく見てみましょう。

バッファなしチャネル

make(chan T) で作成されるチャネルはバッファなしです。

ch1 := make(chan int) // バッファなしチャネル

バッファなしチャネルには容量がありません。バッファなしチャネルへの送信操作は、別のゴルーチンが受信する準備ができるまで、送信側のゴルーチンをブロックします。逆に、受信操作は、別のゴルーチンが送信する準備ができるまで、受信側のゴルーチンをブロックします。これにより、前のステップの最初の例で見たように、強力な同期ポイントが作成されます。

バッファ付きチャネル

チャネルはバッファ付きにすることもでき、これはブロッキングする前に特定の数の値を保持する容量を持つことを意味します。

ch2 := make(chan int, 5) // 容量 5 のバッファ付きチャネル

バッファ付きチャネルの場合:

  • 送信操作は、バッファが満杯になった場合にのみブロックされます。
  • 受信操作は、バッファが空になった場合にのみブロックされます。

バッファが満杯でない場合、受信側がすぐに準備できていなくても、送信側は値を送信できます。その値はチャネルのキューに格納されます。

デッドロックの例

チャネルの容量を超過するとどうなるでしょうか?プログラムはデッドロックになります。デッドロックとは、プログラム内のすべてのゴルーチンが、決して起こらない何かを待ってブロックされている状態を指します。

channel1.go という名前のファイルを作成します。

cd ~/project
touch channel1.go

容量 1 のチャネルに 2 つの値を送信しようとする以下のコードを、main ゴルーチン内だけで記述します。

package main

import "fmt"

func main() {
    ch := make(chan int, 1)
    ch <- 10 // これは成功する
    fmt.Println("Sent 10 to channel")
    ch <- 20 // チャネルが満杯のため、これは永遠にブロックされる
    fmt.Println("succeed")
}

以下のコマンドを使用してプログラムを実行します。

go run channel1.go

プログラムの出力は以下の通りです。

Sent 10 to channel
fatal error: all goroutines are asleep - deadlock!

プログラムはデッドロックエラーでパニックになります。main ゴルーチンは 10 を送信し、バッファを一杯にします。次に 20 を送信しようとしますが、バッファが満杯のためブロックされます。他のどのゴルーチンもチャネルから値を受信することはないため、main ゴルーチンは永遠にブロックされ、Go ランタイムがデッドロックを検出します。

これを修正するには、バッファ内のスペースを空けるために値を受信できる別のゴルーチンと操作を調整する必要があります。

一方向チャネル

デフォルトでは、チャネルは双方向であり、読み書きが可能です。時には、チャネルの使用を制限したい場合があります。例えば、特定の関数内でチャネルへの書き込みのみ、または読み取りのみを許可したい場合などです。これをどのように実現できるでしょうか?

初期化する前に、使用が制限されたチャネルを明示的に宣言することができます。

書き込み専用チャネル

書き込み専用チャネルとは、特定の関数内でチャネルへの書き込みのみが可能で、読み取りができないことを意味します。型 int の書き込み専用チャネルを宣言して使用してみましょう。

package main

import "fmt"

func writeOnly(ch chan<- int) {
    ch <- 10
    fmt.Println("Data written to channel")
}

func main() {
    ch := make(chan int, 1)
    writeOnly(ch)
    fmt.Println("Data received:", <-ch)
}

以下のコマンドを使用してプログラムを実行します。

go run channel.go

プログラムの出力は以下の通りです。

Data written to channel
Data received: 10

この例では、writeOnly 関数がチャネルを書き込み専用に制限しています。

読み取り専用チャネル

読み取り専用チャネルとは、特定の関数内でチャネルからの読み取りのみが可能で、書き込みができないことを意味します。型 int の読み取り専用チャネルを宣言して使用してみましょう。

package main

import "fmt"

func readOnly(ch <-chan int) {
    fmt.Println("Data received from channel:", <-ch)
}

func main() {
    ch := make(chan int, 1)
    ch <- 20
    readOnly(ch)
}

以下のコマンドを使用してプログラムを実行します。

go run channel.go

プログラムの出力は以下の通りです。

Data received from channel: 20

この例では、readOnly 関数がチャネルを読み取り専用に制限しています。

書き込み専用チャネルと読み取り専用チャネルの組み合わせ

書き込み専用チャネルと読み取り専用チャネルを組み合わせることで、データがどのように制限されたチャネルを流れるかを示すことができます。

package main

import "fmt"

func writeOnly(ch chan<- int) {
    ch <- 30
    fmt.Println("Data written to channel")
}

func readOnly(ch <-chan int) {
    fmt.Println("Data received from channel:", <-ch)
}

func main() {
    ch := make(chan int, 1)
    writeOnly(ch)
    readOnly(ch)
}

以下のコマンドを使用してプログラムを実行します。

go run channel.go

プログラムの出力は以下の通りです。

Data written to channel
Data received from channel: 30

この例は、書き込み専用チャネルがどのようにして読み取り専用チャネルにデータを渡すかを示しています。

まとめ

この実験では、チャネルの基本について学習しました。これには以下が含まれます。

  • チャネルの型とその宣言方法
  • チャネルを初期化する方法
  • チャネルに対する操作
  • チャネルのブロッキングの概念
  • 単方向チャネルの宣言