エンジニアになりたい人募集!X(旧Twitter)からフォローしたらリプライで質問常時OK!

Go言語の並行処理入門:初心者のためのゴルーチンとチャネル解説

この記事はで読むことができます。

こんにちは!今回は、Go言語(Golang)の魅力的な特徴の1つである並行処理について、初心者の方にもわかりやすく解説していきます。特に、Goの並行処理の中心となる「ゴルーチン」と「チャネル」に焦点を当てて、基本的な概念から実践的な使い方まで、じっくりと見ていきましょう。

1. 並行処理とは何か?

まずは、「並行処理」という言葉の意味から理解していきましょう。

並行処理とは、複数の処理を同時に進行させることを指します。例えば、料理をする時を想像してみてください。鍋でスープを煮ながら、同時に野菜を切ったり、ご飯を炊いたりすることがあると思います。これが並行処理の基本的な考え方です。

コンピュータプログラミングにおいても、1つのタスクが完了するのを待つ間に別のタスクを進めることで、全体的な処理効率を向上させることができます。

Go言語は、この並行処理を簡単かつ効率的に実現するための機能を備えています。その中心となるのが、「ゴルーチン」と「チャネル」です。

2. ゴルーチンとは?

ゴルーチン(Goroutine)は、Go言語における並行処理の基本単位です。軽量なスレッドのようなもので、同時に多数のゴルーチンを実行することができます。

ゴルーチンの特徴:

  1. 軽量:少ないメモリで動作し、数千、数万と同時に実行可能
  2. 管理が簡単:OSのスレッドよりも作成や破棄のコストが低い
  3. 通信が容易:後述するチャネルを使って、ゴルーチン間で簡単にデータをやり取りできる

ゴルーチンの基本的な使い方を見てみましょう。

go
package main

import (
    "fmt"
    "time"
)

func sayHello() {
    fmt.Println("Hello from goroutine!")
}

func main() {
    go sayHello() // ゴルーチンの起動
    time.Sleep(1 * time.Second) // メイン関数の終了を1秒遅らせる
    fmt.Println("Main function")
}

このコードでは、go キーワードを使って sayHello 関数をゴルーチンとして起動しています。go キーワードを付けることで、その関数は別のゴルーチンで実行されます。

注意点として、メイン関数(main)が終了すると、他のゴルーチンも強制終了してしまいます。そのため、この例では time.Sleep を使ってメイン関数の終了を遅らせています。実際のアプリケーションでは、もっと適切な同期方法を使用します(後ほど解説します)。

3. チャネルとは?

チャネル(Channel)は、ゴルーチン間でデータをやり取りするための通信路です。Go言語の並行処理モデルは “Do not communicate by sharing memory; instead, share memory by communicating”(メモリを共有することで通信するのではなく、通信することでメモリを共有しろ)という思想に基づいています。チャネルはこの思想を実現する重要な機能です。

チャネルの特徴:

  1. 型安全:特定の型のデータのみを扱うチャネルを作成できる
  2. 同期機能:デフォルトでは、送信側と受信側が準備できるまでブロックする
  3. 双方向通信:送信と受信の両方が可能

基本的なチャネルの使用例を見てみましょう。

go
package main

import "fmt"

func sum(s []int, c chan int) {
    sum := 0
    for _, v := range s {
        sum += v
    }
    c <- sum // チャネルにsumを送信
}

func main() {
    s := []int{7, 2, 8, -9, 4, 0}

    c := make(chan int)
    go sum(s[:len(s)/2], c) // 前半の和を計算
    go sum(s[len(s)/2:], c) // 後半の和を計算

    x, y := <-c, <-c // チャネルから受信

    fmt.Println(x, y, x+y)
}

このコードでは、整数のスライスを2つに分割し、それぞれの和を別々のゴルーチンで計算しています。計算結果はチャネルを通じてメイン関数に送られ、最後に合計を表示します。

make(chan int) でint型のチャネルを作成し、c <- sum でチャネルにデータを送信、<-c でチャネルからデータを受信しています。

4. バッファ付きチャネル

デフォルトのチャネルは「バッファなし」で、送信と受信が同時に行われる必要があります。一方、「バッファ付き」チャネルは、一定数のデータをバッファに保持できます。

バッファ付きチャネルの例

go
package main

import "fmt"

func main() {
    ch := make(chan int, 2) // バッファサイズ2のチャネル
    ch <- 1
    ch <- 2
    fmt.Println(<-ch)
    fmt.Println(<-ch)
}

この例では、バッファサイズ2のチャネルを作成しています。バッファが埋まるまで(この場合は2つのデータが送信されるまで)、送信操作はブロックされません。

5. select文

select 文は、複数のチャネル操作を同時に待機するための制御構造です。最初に準備できた操作が実行されます。

go
package main

import (
    "fmt"
    "time"
)

func main() {
    ch1 := make(chan string)
    ch2 := make(chan string)

    go func() {
        time.Sleep(2 * time.Second)
        ch1 <- "one"
    }()

    go func() {
        time.Sleep(1 * time.Second)
        ch2 <- "two"
    }()

    for i := 0; i < 2; i++ {
        select {
        case msg1 := <-ch1:
            fmt.Println("received", msg1)
        case msg2 := <-ch2:
            fmt.Println("received", msg2)
        }
    }
}

この例では、2つのチャネルからのデータ受信を select 文で待機しています。ch2の方が早くデータを受信できるため、”two”が先に表示されます。

6. 並行処理のパターンと注意点

6.1 ファンアウト/ファンイン

「ファンアウト」は1つの処理を複数のゴルーチンに分散させ、「ファンイン」はそれらの結果を1つにまとめる処理パターンです。

go
package main

import (
    "fmt"
    "sync"
)

func worker(jobs <-chan int, results chan<- int) {
    for n := range jobs {
        results <- n * 2
    }
}

func main() {
    jobs := make(chan int, 100)
    results := make(chan int, 100)

    // ファンアウト:3つのワーカーゴルーチンを起動
    for w := 1; w <= 3; w++ {
        go worker(jobs, results)
    }

    // ジョブを送信
    for j := 1; j <= 9; j++ {
        jobs <- j
    }
    close(jobs)

    // ファンイン:結果を収集
    for a := 1; a <= 9; a++ {
        fmt.Println(<-results)
    }
}

この例では、3つのワーカーゴルーチンが9つのジョブを処理し、その結果を1つのチャネルに集約しています。

6.2 コンテキスト(Context)の使用

長時間実行されるゴルーチンを制御するために、context パッケージを使用することがあります。これにより、ゴルーチンのキャンセルやタイムアウトを管理できます。

go
package main

import (
    "context"
    "fmt"
    "time"
)

func worker(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println("Worker: Stopping due to cancellation")
            return
        default:
            fmt.Println("Worker: Working...")
            time.Sleep(1 * time.Second)
        }
    }
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    go worker(ctx)

    time.Sleep(6 * time.Second)
    fmt.Println("Main: Finished")
}

この例では、5秒後にタイムアウトするコンテキストを作成し、ワーカーゴルーチンに渡しています。ワーカーはコンテキストのDoneチャネルを監視し、キャンセル指示があれば停止します。

6.3 競合状態(Race Condition)の回避

並行処理を行う際、複数のゴルーチンが同じデータにアクセスする場合、競合状態が発生する可能性があります。これを回避するためには、ミューテックスやチャネルを適切に使用する必要があります。

go
package main

import (
    "fmt"
    "sync"
)

type Counter struct {
    mu    sync.Mutex
    value int
}

func (c *Counter) Increment() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.value++
}

func (c *Counter) Value() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.value
}

func main() {
    counter := &Counter{}
    var wg sync.WaitGroup

    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            counter.Increment()
        }()
    }

    wg.Wait()
    fmt.Println("Final count:", counter.Value())
}

この例では、sync.Mutex を使用して、カウンターへの並行アクセスを安全に管理しています。

まとめ

Go言語の並行処理は、ゴルーチンとチャネルを中心とした独自のモデルを採用しています。これにより、効率的で理解しやすい並行プログラミングが可能になります。

ここで学んだ基本的な概念と使用方法は以下の通りです:

  1. ゴルーチン:軽量な並行実行単位
  2. チャネル:ゴルーチン間の通信手段
  3. select文:複数のチャネル操作の待機
  4. バッファ付きチャネル:一時的なデータ保持
  5. ファンアウト/ファンイン:並行処理の分散と集約
  6. コンテキスト:ゴルーチンの制御
  7. 競合状態の回避:安全な並行アクセスの実現

これらの概念を理解し、適切に使用することで、効率的で信頼性の高い並行プログラムを作成することができます。

Go言語の並行処理はとても奥が深いトピックです。この記事で学んだ基礎を元に、さらに複雑な並行処理パターンや最適化テクニックを学んでいくことをお勧めします。実際のプロジェクトで使用しながら、経験を積んでいくことが大切です。

最後に、Go言語の公式ドキュメントや、コミュニティのリソースを活用することも、スキル向上には非常に効果的です。並行処理の世界を楽しんで探索してください!