Go言語の並行処理入門:goroutineとchannelの使い方を完全解説

Go言語の並行処理入門:goroutineとchannelの使い方を完全解説 | mohablog

並行処理をシンプルに扱える言語として、Go言語は多くのエンジニアに選ばれています。その中心的な役割を担うgoroutinechannelは、最初は理解しづらいかもしれませんが、実際のプロジェクトで一度使うと、その便利さに気づくはずです。本記事では、基本的な使い方から実践的なパターンまで、実際に私がハマった経験も交えて解説していきます。Go言語で効率的で高速な並行プログラムを書きたい方には、参考になると思いますよ。

目次

goroutineとは何か

goroutineは、Go言語が提供する軽量なスレッドのようなものだと考えてください。通常のスレッドと比べると、メモリ使用量が圧倒的に少なく、起動も驚くほど高速です。Go言語のランタイムが複数のgoroutineを効率的にスケジュールしてくれるおかげで、数千から数百万のgoroutineを扱うことさえ可能になっています。

  • メモリ効率が非常に高い(スレッドの1000倍以上軽量)
  • 起動と管理が驚くほど簡単
  • ランタイムが自動的にスケジューリング
  • 複数のCPUコアを活用可能

goroutineの基本的な使い方

goroutineを実行するなら、goキーワードを関数の前に付けるだけで動きます。以下の例を見てみましょう。

package main

import (
    "fmt"
    "time"
)

func printMessage(msg string) {
    for i := 0; i < 5; i++ {
        fmt.Println(msg, i)
        time.Sleep(time.Second)
    }
}

func main() {
    go printMessage("Goroutine 1")
    go printMessage("Goroutine 2")
    
    // メインのgoroutineが終了しないように待機
    time.Sleep(6 * time.Second)
}

このコードは2つのgoroutineを並行して実行します。goキーワードを付けた関数呼び出しは、バックグラウンドで実行され、メインプログラムはすぐに次の行へ進んでいくわけです。

channelについて理解する

channelは、goroutine間でデータを安全に受け渡すための仕組みです。channelを使うことで、複数のgoroutine間での同期と通信が驚くほど簡潔に実現できたりします。

「Do not communicate by sharing memory; instead, share memory by communicating.」(メモリを共有することで通信するのではなく、通信することでメモリを共有しなさい)- Go言語の設計思想

channelの基本的な使い方

channelはmake関数で作成し、<-演算子でデータを送受信します。最初はこの記号が何を意味するのか、ちょっと違和感を感じるかもしれません。

package main

import "fmt"

func main() {
    // int型のchannelを作成
    ch := make(chan int)
    
    // goroutineでchannelにデータを送信
    go func() {
        ch <- 42  // 42をchannelに送信
    }()
    
    // channelからデータを受信
    value := <-ch
    fmt.Println("受信した値:", value)
}

このコードでは、goroutine内で値をchannelに送信し、メインgoroutineで受信しています。channelは送受信が完了するまでブロックするため、自動的に同期が取られるという仕組みですね。

バッファ付きchannelの活用

デフォルトのchannelはバッファがない(バッファサイズが0)ため、送受信が同時に起こらないとデッドロックになってしまいます。バッファを持たせることで、複数の値を蓄積できるようになるんです。

package main

import "fmt"

func main() {
    // バッファサイズ3のchannelを作成
    ch := make(chan string, 3)
    
    // 3つの値を送信(ブロックしない)
    ch <- "apple"
    ch <- "banana"
    ch <- "cherry"
    
    // 3つの値を受信
    fmt.Println(<-ch)  // apple
    fmt.Println(<-ch)  // banana
    fmt.Println(<-ch)  // cherry
}

バッファ付きchannelは、送信元と受信元のタイミングがずれている場合に特に役に立ちます。

複数のgoroutineと並行処理の実例

より現場寄りな例として、複数のworker goroutineがタスクを処理する場面を想像してみてください。

package main

import (
    "fmt"
    "time"
)

func worker(id int, jobs <-chan int, results chan<- int) {
    for job := range jobs {
        fmt.Printf("Worker %d が job %d を処理中\n", id, job)
        time.Sleep(time.Second)
        results <- job * 2
    }
}

func main() {
    jobs := make(chan int, 5)
    results := make(chan int, 5)
    
    // 3つのworkerを起動
    for w := 1; w <= 3; w++ {
        go worker(w, jobs, results)
    }
    
    // 5つのjobを投入
    for j := 1; j <= 5; j++ {
        jobs <- j
    }
    close(jobs)  // jobsのchannelを閉じる
    
    // 結果を受け取る
    for r := 1; r <= 5; r++ {
        fmt.Println("結果:", <-results)
    }
}

このコードでは、3つのworkerが同時に複数のタスクを処理しています。close()関数でchannelを閉じることで、rangeループを適切に終了させることができるというわけです。

select文による複数channelの制御

複数のchannelを同時に監視する必要がある場合、select文を使用すると便利です。これはswitch文に似ていますが、複数のchannel操作を監視する点が異なります。

package main

import (
    "fmt"
    "time"
)

func main() {
    ch1 := make(chan string)
    ch2 := make(chan string)
    
    go func() {
        time.Sleep(1 * time.Second)
        ch1 <- "メッセージ1"
    }()
    
    go func() {
        time.Sleep(2 * time.Second)
        ch2 <- "メッセージ2"
    }()
    
    // 2つのchannelのうち、先に値が来た方を受信
    for i := 0; i < 2; i++ {
        select {
        case msg1 := <-ch1:
            fmt.Println("受信:", msg1)
        case msg2 := <-ch2:
            fmt.Println("受信:", msg2)
        }
    }
}

select文は複数のchannelの受信を監視し、いずれかが準備できた時点で実行します。タイムアウト処理やデフォルトケースも組み合わせて使用できるので、複雑な並行制御も実装しやすくなるんです。

channelのクローズとrange

channelをclose()で閉じると、受信側で値がなくなったことを検出できるようになります。これは意外と重要なポイントです。

package main

import "fmt"

func main() {
    ch := make(chan int)
    
    go func() {
        for i := 1; i <= 3; i++ {
            ch <- i
        }
        close(ch)  // channelをクローズ
    }()
    
    // rangeでchannelから値を受け取る
    for value := range ch {
        fmt.Println(value)
    }
    fmt.Println("完了")
}

rangeループはchannelが閉じられると自動的に終了します。これはタスク完了の通知を待つ場面で非常に助かります。

注意点とベストプラクティス

  • デッドロックに注意:バッファなしchannelで送信側と受信側が揃わないと、プログラムがハングします。最初のころはこれで何度も引っかかりました
  • クローズ済みchannelへの送信はパニック:必ず送信側でchannelをクローズしましょう
  • 受信側でのクローズは避ける:送信元が複数の場合、クローズがエラーになってしまいます
  • goroutineの生存期間管理:メインgoroutineが終了する前に全goroutineが完了することを確認する必要があります
  • syncパッケージの活用:WaitGroupを使ってgoroutineの完了を待つと、安全で確実です

WaitGroupを使った安全な並行処理

複数のgoroutineの完了を確実に待つには、sync.WaitGroupを使うのが推奨されています。これを学んでからは、本番環境でのgoroutine管理がずっと堅牢になりました。

package main

import (
    "fmt"
    "sync"
)

func main() {
    var wg sync.WaitGroup
    results := make(chan int)
    
    // 3つのgoroutineを登録
    for i := 1; i <= 3; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()  // goroutine完了時に呼ばれる
            results <- id * 10
        }(i)
    }
    
    // 結果を受け取りながら並行実行
    go func() {
        wg.Wait()  // 全goroutineの完了を待つ
        close(results)
    }()
    
    for result := range results {
        fmt.Println("結果:", result)
    }
}

WaitGroupを使うことで、全goroutineの完了を確実に確認でき、プログラムが途中で終了するリスクを回避できるようになります。

まとめ

  • goroutinegoキーワード一つで簡単に並行処理を実現できる軽量スレッド
  • channelはgoroutine間での安全なデータ受け渡しを可能にする通信機構
  • バッファなしchannelは送受信が同期し、バッファ付きchannelは複数値を蓄積可能
  • select文で複数channelの監視やタイムアウト処理が実現できる
  • sync.WaitGroupを使うことで、全goroutineの完了を安全に待つことができる
  • channelのクローズやデッドロック対策など、エラーハンドリングに注意が必要
  • Go言語の並行処理は設計思想に基づき、メモリ共有ではなく通信でデータを共有する

よくある質問(FAQ)

goroutineはどのくらいの数まで実行できますか?

理論的には数百万のgoroutineを起動することが可能です。ただし、実際のシステムではメモリやファイルディスクリプタなどのリソース制限に達することが多いです。一般的には、数千から数十万程度のgoroutineを実運用で管理することになると考えておくと良いでしょう。

channelでデータを送信したら即座に受信される?

バッファなしchannelの場合、送信と受信が同時に起こるまで両者がブロックされます。バッファ付きchannelなら、バッファに空きがある限りは送信側がすぐに返ります。ただし、受信側がいつデータを受け取るかは保証されません。

クローズされたchannelから受信することはできますか?

はい、できます。クローズされたchannelから受信すると、ゼロ値が返されます。ただし、受信側ではvalue, ok := <-chという形式を使うことで、channelが閉じられたかどうかを判定することが推奨されています。

goroutineでエラーハンドリングはどうすればいい?

channelを使ってエラーを送信するか、あるいはエラーと結果を構造体にまとめて送信する方法が一般的です。公式ドキュメントでは、複数の値を送信する場合に構造体を活用することが推奨されていますよ。

WaitGroupは必須ですか?

必須ではありませんが、goroutineの完了をきちんと待つという点では強く推奨されています。特に複数のgoroutineを管理する場合、WaitGroupを使うことでメインgoroutineが早期に終了するバグを防げるので、使う価値は十分にあります。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次