Go初心者が1日でGoを一周した内容を雑にまとめる part3
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