⚔️

Go初心者が1日でGoを一周した内容を雑にまとめる part3

2025/01/01に公開

Go 言語の大きな特徴のひとつが「並行処理(concurrency)」!
本記事では、その並行処理について代表的な要素をまとめました。goroutine, channel, WaitGroup, sync.Mutex を中心にサンプルコードも交えて紹介していきます。

1. goroutine の基本

Go で関数を並行実行する場合は、単に “go <関数名>” と書くだけで OK です。 (正直ビビった。超便利)
これが「goroutine」と呼ばれる仕組みで、Go の並行処理の根幹となります。

以下のように書くと、goroutine("Hello") と numal("world") を並行に実行しようとします。

package main

import (
    "fmt"
    "time"
)

func goroutine(s string) {
    for i := 0; i < 5; i++ {
        time.Sleep(100 * time.Millisecond)
        fmt.Println(s)
    }
}

func numal(s string) {
    for i := 0; i < 5; i++ {
        time.Sleep(100 * time.Millisecond)
        fmt.Println(s)
    }
}

func main() {
    go goroutine("Hello")
    numal("world")
}

実行すると、それぞれの関数が少しずつ交互に出力を行い、コンソールには例として以下のような結果が表示されます:

world
Hello
world
Hello
...

ただし、goroutine はあくまで「並行実行を開始」するだけであり、メイン関数が先に終了するとそこでプログラムが終了してしまうという点に注意が必要です。


2. 実行されない goroutine

上記のサンプルで time.Sleep(100 * time.Millisecond) を削除した場合、下記のように numal("world") 側が先に実行を終えてしまい、main() 関数も同時に終了してしまうため、goroutine("Hello") が一度も実行されないまま終了することがあります。

package main

import (
    "fmt"
)

func goroutine(s string) {
    for i := 0; i < 5; i++ {
        // time.Sleep(100 * time.Millisecond)
        fmt.Println(s)
    }
}

func numal(s string) {
    for i := 0; i < 5; i++ {
        // time.Sleep(100 * time.Millisecond)
        fmt.Println(s)
    }
}

func main() {
    go goroutine("Hello")
    numal("world")
}

実行結果:

world
world
world
world
world

「Hello」の文字が一度も出力されないわけです。これはメイン関数の終了を待つすべがないことが原因です。


3. WaitGroup で goroutine を待ち合わせる

並行に動く複数の処理がすべて終わるまで待ち合わせたい場合、sync.WaitGroup を使うと便利です。次の例では、goroutine("Hello") として起動した関数をちゃんと最後まで実行するようになっています。

package main

import (
    "fmt"
    "sync"
)

func goroutine(s string, wg *sync.WaitGroup) {
    defer wg.Done()
    for i := 0; i < 5; i++ {
        fmt.Println(s)
    }
}

func numal(s string) {
    for i := 0; i < 5; i++ {
        fmt.Println(s)
    }
}

func main() {
    var wg sync.WaitGroup

    wg.Add(1)
    go goroutine("Hello", &wg)

    numal("world")

    wg.Wait() // goroutine が終わるのを待つ
}

実行結果:

world
world
world
world
world
Hello
Hello
Hello
Hello
Hello
  • wg.Add(1) : goroutine を 1 つ起動する分の「チケット」を追加
  • wg.Wait() : すべてのチケットが返却されるまで(= goroutine がすべて終了するまで)待機
  • wg.Done() : goroutine が処理終了したらチケットを返却
  • 関数内で defer wg.Done() と書くと、その関数が終了するタイミングで自動的に Done() が呼ばれます

4. channel の基本

Go の “channel” は goroutine 間でデータの送受信を行う仕組みです。
下記は、goroutine 内で計算した合計値を channel を通じてメイン関数へ渡すサンプルです。

package main

import (
    "fmt"
)

func goroutine(s []int, c chan int) {
    sum := 0
    for _, v := range s {
        sum += v
    }
    c <- sum // 結果を channel に送る
}

func main() {
    s := []int{1, 2, 3, 4, 5}
    c := make(chan int)

    go goroutine(s, c)
    fmt.Println(<-c) // channel から受け取る
}

実行結果は合計値の “15” が表示されます:

15

5. 複数の goroutine と channel

チャンネルを使うと、複数の goroutine からの計算結果を受け取ることも簡単です。

package main

import (
    "fmt"
)

func goroutine1(s []int, c chan int) {
    sum := 0
    for _, v := range s {
        sum += v
    }
    c <- sum
}

func goroutine2(s []int, c chan int) {
    sum := 0
  ![](https://storage.googleapis.com/zenn-user-upload/a27364f7dd02-20250101.png)  for _, v := range s {
        sum += v
    }
    c <- sum
}

func main() {
    s := []int{1, 2, 3, 4, 5}
    c := make(chan int)

    go goroutine1(s, c)
    go goroutine2(s, c)

    x := <-c
    y := <-c

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

実行結果:

15
15

6. Buffered Channel

channel はデフォルトでは「バッファなし」(make(chan int)) ですが、make(chan int, バッファサイズ) と指定すると「バッファ付き channel」を作ることができます。
バッファ付きにすると、受信側がまだ受け取っていない間に複数回送信しても、バッファ分までは送信側がブロックされずに済みます。
使い方に応じて unbuffered / buffered を切り替えてみてください。


7. fan-out fan-in パターン

Go の並行処理では、パイプラインのようにデータを受け渡す「fan-out fan-in」構成がよく使われます。下記では、整数を生成する producer() → 受け取った整数を 2 倍にする multi2() → さらに 4 倍にする multi4() の流れを channel でつなぎ、パイプラインで並行処理を行っています。

package main

import "fmt"

func producer(ch chan int) {
    defer close(ch)
    for i := 0; i < 10; i++ {
        ch <- i
    }
}

func multi2(first <-chan int, second chan<- int) {
    defer close(second)
    for i := range first {
        second <- i * 2
    }
}

func multi4(second <-chan int, third chan<- int) {
    defer close(third)
    for i := range second {
        third <- i * 4
    }
}

func main() {
    first := make(chan int)
    second := make(chan int)
    third := make(chan int)

    go producer(first)
    go multi2(first, second)
    go multi4(second, third)

    for result := range third {
        fmt.Println(result)
    }
}

実行結果:

0
8
16
24
32
40
48
56
64
72

3 段階の処理がそれぞれ並行で動きつつ、順番に処理結果が流れていきます。
また、引数に <-chan int (受信専用) と chan<- int (送信専用) を指定すると、関数の役割が明確になり読みやすくなります。


8. 複数の channel を使う (select)

複数の channel から同時にデータを待ちたい場合、select 文を使います。
以下の例では、goroutine1() のチャンネルからは "Hello"、goroutine2() のチャンネルからは "World" を受信し、どちらかにデータが来たら即座に処理します。

package main

import (
    "fmt"
    "time"
)

func goroutine1(c chan string) {
    for i := 0; i < 100; i++ {
        c <- "Hello"
        time.Sleep(1 * time.Second)
    }
}

func goroutine2(c chan string) {
    for i := 0; i < 100; i++ {
        c <- "World"
        time.Sleep(1 * time.Second)
    }
}

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

    go goroutine1(c1)
    go goroutine2(c2)

    for {
        select {
        case msg1 := <-c1:
            fmt.Println(msg1)
        case msg2 := <-c2:
            fmt.Println(msg2)
        }
    }
}

実際には、"Hello" と "World" のどちらが先に書き込まれるかはタイミング次第なので、コンソールの出力は毎回違った結果になりえます。


9. default selection とループの break

select は、どの case も成立しない場合に default 節を実行できます。
さらに for にラベルをつけておいて、特定タイミングで破棄する( break OUTLOOP ) といった制御が可能です。

package main

import (
    "fmt"
    "time"
)

func main() {
    tick := time.Tick(100 * time.Millisecond)   // 一定間隔で値が流れるチャネル
    boom := time.After(500 * time.Millisecond) // 500ms 後に 1 回だけ値が流れるチャネル

OUTLOOP:
    for {
        select {
        case <-tick:
            fmt.Println("tick")
        case <-boom:
            fmt.Println("BOOM")
            break OUTLOOP
        default:
            fmt.Println(" .")
            time.Sleep(50 * time.Millisecond)
        }
    }
    fmt.Println("########################")
}

実行結果:

 .
 .
 tick
 .
 .
 tick
 .
 .
 tick
 .
 .
 tick
 .
 .
 tick
BOOM
########################
  • tick が最初に値を流すまでは default 節が実行される
  • boom から値が流れたら break OUTLOOP でループを抜ける

10. sync.Mutex でデータ競合を防ぐ

Go は「 map はスレッドセーフではない」ため、複数の goroutine から同時に書き込みを行うとデータ競合が発生し、時にはランタイムエラーを起こします。
例えば以下のようなコードです:

package main

import (
    "fmt"
    "time"
)

func main() {
    c := make(map[string]int)

    go func() {
        for i := 0; i < 10; i++ {
            c["key"] += 1
        }
    }()

    go func() {
        for i := 0; i < 10; i++ {
            c["key"] += 1
        }
    }()

    time.Sleep(1 * time.Second)
    fmt.Println(c, c["key"])
}

このように複数 goroutine で同じ map へ同時に書き込みを行うと、

  • 運良くエラーが起きず「key:20」となる方が多いかもしれません
  • しかし、まれに "fatal error: concurrent map writes" が発生

これらを確実に防止するには、sync.Mutex で排他制御を行って書き込みタイミングを制御します。

Mutex を使ったサンプル

package main

import (
    "fmt"
    "sync"
    "time"
)

type Counter struct {
    v   map[string]int
    mux sync.Mutex
}

func (c *Counter) Inc(key string) {
    c.mux.Lock()
    defer c.mux.Unlock()
    c.v[key]++
}

func (c *Counter) Value(key string) int {
    c.mux.Lock()
    defer c.mux.Unlock()
    return c.v[key]
}

func main() {
    c := Counter{v: make(map[string]int)}

    go func() {
        for i := 0; i < 10; i++ {
            c.Inc("key")
        }
    }()

    go func() {
        for i := 0; i < 10; i++ {
            c.Inc("key")
        }
    }()

    time.Sleep(1 * time.Second)
    fmt.Println(c, c.Value("key"))
}
  • c.mux.Lock()c.mux.Unlock()map への同時書き込みを排他
  • goroutine からどんなに同時にアクセスがあっても、Map への更新は順番に実行されるため安全

11. まとめ

  • goroutine: “go 関数名” で簡単に並行処理が始まる
  • WaitGroup: 複数 goroutine の終了待ちを調整できる
  • channel: goroutine 間でデータを受け渡す仕組み
  • select: 複数の channel を同時に監視できる
  • sync.Mutex: 共有データへの同時書き込みを防止する

Go には他にも、高度な並行処理をサポートするための仕組みが数多く用意されています。
今回の記事では一部を紹介しましたが、Go の並行処理をマスターしていくには、自分で実際にコードを動かしながら挙動を体感するのが一番です。ぜひ今回紹介したサンプルを参考にしつつ、Go の並行処理ライフを楽しんでみてください!

Discussion