【Go】goroutineの基本:軽量スレッドで並行処理する

Go

こんにちは、かつコーチです。

前回の記事ではエラーハンドリングの基本を解説しました。

今回はGoの大きな魅力の1つであるgoroutineについて、基本から実体験を交えて解説します。

goをつけるだけで並行処理できるらしいけど、実際どう動くの?」という疑問を、コード例とともに解消していきます。

goroutineとは?

goroutineの定義

goroutineとは、机の上で同時に動かせる小さな歯車のようなものです。

もう少し技術的に言うと、goroutineは軽量スレッド(OSが管理する通常のスレッドよりもずっと軽い、Goのランタイムが管理する処理の実行単位)です。

1つのプログラムの中で、歯車をいくつも同時に回すように、複数の処理を並行して走らせることができます。

普通のスレッドを大量に作るとメモリ消費が大きくなりがちですが、goroutineは数千〜数万個作っても現実的に動くほど軽量です。

なぜgoroutineが必要なのか

Webサーバーで複数のリクエストを同時に処理したり、複数のファイルを並行して読み込んだりする場面では、処理を1つずつ順番に行うと時間がかかります。

歯車を1つしか置かない机と、複数の歯車を同時に回せる机を想像してみてください。

後者の方が、同じ時間でより多くの作業をこなせます。

goroutineを使うことで、Goのプログラムは「歯車を複数同時に回す」書き方が驚くほど簡単にできるようになっています。

基本の書き方

手順1: goキーワードで起動する

goroutineを起動する方法はとてもシンプルです。

関数呼び出しの前にgoというキーワードを置くだけです。

package main

import (
	"fmt"
	"time"
)

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

func main() {
	go sayHello() // goroutineとして起動
	time.Sleep(100 * time.Millisecond)
	fmt.Println("main関数終了")
}

go sayHello()と書いた瞬間、sayHello関数は新しい歯車として並行に動き始めます。

手順2: goroutineを使わない直列処理との比較

goroutineの効果を実感するために、まずは直列処理のコードを見てみましょう。

package main

import (
	"fmt"
	"time"
)

func heavyTask(name string) {
	time.Sleep(1 * time.Second)
	fmt.Println(name, "完了")
}

func main() {
	start := time.Now()

	heavyTask("タスクA")
	heavyTask("タスクB")
	heavyTask("タスクC")

	fmt.Println("合計時間:", time.Since(start))
}

このコードでは、3つのタスクを順番に実行するため、合計でおよそ3秒かかります。

これをgoroutineで並行化すると、次のようになります。

package main

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

func heavyTask(name string, wg *sync.WaitGroup) {
	defer wg.Done()
	time.Sleep(1 * time.Second)
	fmt.Println(name, "完了")
}

func main() {
	start := time.Now()
	var wg sync.WaitGroup

	tasks := []string{"タスクA", "タスクB", "タスクC"}
	for _, task := range tasks {
		wg.Add(1)
		go heavyTask(task, &wg)
	}
	wg.Wait()

	fmt.Println("合計時間:", time.Since(start))
}

3つのタスクを同時に走らせることで、合計時間はおよそ1秒に短縮されます。

歯車を1つずつ順番に回すのではなく、3つ同時に回すイメージです。

なお、ここで使っているsync.WaitGroup(複数のgoroutineの完了を待つための仕組み)の詳しい使い方は、後の記事で解説します。

つまずきやすい設定・注意点

goroutineは「起動したら勝手に完了を待ってくれる」わけではありません。

goをつけて起動した瞬間、main関数はそのgoroutineの終了を待たずに次の処理へ進んでしまいます。

そのため、goroutineの完了をきちんと待つ仕組みを用意しないと、期待した結果が得られないことがあります。

よくあるつまずきポイント・エラー対処

mainが先に終わって結果が表示されない

私が初めてgoroutineを使ったとき、一番驚いたのがこの現象でした。

「goroutineで処理を書いたのに、実行しても何も表示されない」という状態です。

❌ Before

package main

import "fmt"

func printMessage() {
	fmt.Println("これは表示されるはず...")
}

func main() {
	go printMessage()
	// このままmain関数がすぐ終わってしまう
}

このコードを何度実行しても、私の環境ではprintMessageの出力が一切表示されませんでした。

原因を調べて分かったのは、Goのmain関数はgoroutineの完了を待たずにプログラムを終了してしまう、という仕様でした。

go printMessage()でgoroutineを起動した直後、main関数にはもう他に実行する処理がありません。

そのためmain関数はすぐに終了し、プログラム全体も終了してしまい、printMessageが実行される前にすべてが打ち切られていたのです。

これは私が最初に踏んだgoroutineの落とし穴で、「goroutineは投げっぱなしにできない」ということを痛感した出来事でした。

✅ After

package main

import (
	"fmt"
	"sync"
)

func printMessage(wg *sync.WaitGroup) {
	defer wg.Done()
	fmt.Println("これは表示されるはず...")
}

func main() {
	var wg sync.WaitGroup
	wg.Add(1)
	go printMessage(&wg)
	wg.Wait() // goroutineの完了を待つ
}

sync.WaitGroupを使って「goroutineが終わるまでmain関数を待たせる」ようにすることで、確実に出力されるようになりました。

とっさにtime.Sleepで時間稼ぎをする人もいますが、それは処理時間が読めない場面では不安定な対処法なので、sync.WaitGroupを使う方が確実です。

変数のキャプチャでハマるパターン

もう1つよくあるのが、forループの中でgoroutineを起動するときに、ループ変数を意図せず共有してしまうミスです。

❌ Before

for i := 0; i < 3; i++ {
	go func() {
		fmt.Println(i) // 期待通りの値にならないことがある
	}()
}

✅ After

for i := 0; i < 3; i++ {
	i := i // ループ内でシャドーイングして値をコピーする
	go func() {
		fmt.Println(i)
	}()
}

Go1.22以降ではループ変数の仕様が変わり、この問題は起きにくくなっていますが、古いバージョンのコードを触るときは注意が必要です。

応用・一歩先の使い方

goroutine同士がデータをやり取りする場面では、channel(goroutine同士を安全につなぐベルトコンベアのような仕組み)を使うのがGoらしいやり方です。

goroutineを「歯車」、channelを「歯車同士をつなぐベルトコンベア」とイメージすると、Goの並行処理の全体像がぐっと理解しやすくなります。

channelの詳しい使い方は次の記事で解説します。

まとめ

この記事のポイント

  • goroutineは机の上で同時に動かせる小さな歯車のような、軽量な並行処理の単位
  • go関数名()と書くだけでgoroutineを起動できる
  • goroutineを使わないと処理は直列に実行され、時間がかかる
  • main関数はgoroutineの完了を待たずに終了してしまうので、sync.WaitGroupなどで待つ仕組みが必要
  • forループ内でgoroutineを起動するときは、ループ変数の扱いに注意する

次に読むべき記事

goroutine同士でデータをやり取りする方法を知りたい方は、次の「channelの基本:goroutine同士でデータをやり取りする」を読んでみてください。

デッドロックという実際に出るエラーも交えて解説しています。

タイトルとURLをコピーしました