Go 言語の goroutine

ここでは Go言語の goroutine と WaitGroup を用いたタスクの終了待ち方法について説明します。

goroutine はどう読むの?

はじめに、goroutine の読み方について少し。いくつかの翻訳を見ると「ゴールーチン」「ゴルーチン」などと書かれているのを見かけます。どちらが正しいのでしょうか。

一般にプログラミングでは、まとまったコードブロックを各所から呼び出す仕組みを、関数とか、サブルーチン、あるいは単にルーチンなどと呼びますが、 それに Go 言語の go- が付いた形とすれば「ゴー・ルーチン」です。また、実際の英語の発音でも goroutine は「ゴールーチン」に近い読み方です。

一方、「ゴルーチン」というと「ゴルー」と「チン」の二音節のように見えるように思いますが、こちらは英語の発音にも遠いですし、 意味合いとしても「ルーチン」の部分が切れているので不自然に思われます。

そのため、当サイトではカタカナで書く場合は英語としても自然な「ゴールーチン」または「Go ルーチン」と書きたいと思います。が、基本的には goroutine と一単語とします。

並列処理と並行処理

並列処理 (parallelism) は複数のタスクを全く同時に処理することです。 例えばマルチコアのプロセッサで、同時に異なる二つの処理を独立して実行する場合は並列処理といえます。

一方、並行処理 (concurrency) というのは、複数のタスクの実行を短時間で切り替えることによって、 擬似的に同時実行する場合を指します。具体的には、ひとつのプロセス内で実行スレッドを複数作成して処理を行う場合などは並行処理にあたります。

goroutine を使うと並行処理を簡単に実装できます

Go 言語の goroutine の使い方

はじめに goroutine を使わない (並行処理を行わない) 場合をみてみましょう。

次のコードでは foo() 関数を呼ぶと、呼び出した時のラベルと数字の 0, 1, 2 を1秒おきに出力します。

package main

import (
	"fmt"
	"time"
)

func foo(label string) {
	for i := 0; i < 3; i++ {
		fmt.Println(label, i)
		time.Sleep(time.Second)
	}
}

func main() {
	foo("A")
	foo("B")
}
main.go

実行結果は次のようになります。

go run main.go
A 0
A 1
A 2
B 0
B 1
B 2

"A" という文字列を渡して foo() 関数を呼び出した結果、 A 0A 1、・・・ と順番に出力され、 次に "B" を渡して呼び出した foo() 関数が実行されていることがわかります。

goroutine は関数に go キーワードを付けて呼び出すだけで作成できます

上のコードの17行目で次のように go を付けて foo() を呼び出してみます。

func main() {
	go foo("A")
	foo("B")
}

すると、実行結果は次のようになります。

go run main.go
B 0
A 0
A 1
B 1
B 2
A 2

go を付けて foo("A") を呼び出したことで、17行目の関数呼び出しが終了することを待たずに、次の 18行目が実行されます。

このため "A""B" が入り混じって出力されています。

go を付けて foo("A") を呼び出したので、 foo("A") は goroutine で並行処理された、というわけです。

Go 言語の WaitGroup を用いた goroutine の処理待ち

上ではひとつめの foo() 関数の呼び出しを goroutine で実行しました。 ここでもし、二つ目の呼び出しも goroutine で実行した場合、どうなるでしょうか。

つまり、もし次のように両方ともに go を付けて foo() を呼び出した状況です。

func main() {
	go foo("A")
	go foo("B")
}

この場合、 foo() 関数を実行するより先に、 main() 関数の処理が先に終わってしまいます。main() 関数が終われば、プログラムは終了します。 このため foo() 関数は実行されずにプログラムが終了してしまいます。

どうすれば goroutine の終了を待つことができるでしょうか。

goroutine が終了するまで、タスクの実行を待つためには sync パッケージの WaitGroup などが利用できます。ここではこの WaitGroup の使い方を簡単に説明します。

WaitGroup は内部でカウンターを持っています。

WaitGroup の Wait() メソッドは、WaitGroup のカウンターが 0 になるまで待ちます

Add(delta int) メソッドでカウンターを、引数の delta だけ増加できます。 WaitGroup の Done() メソッドを一度呼ぶと、カウンターをひとつだけ減らせます。

メソッド 説明
Add(delta int) カウンターを delta の数だけ増やす
Done() カウンターを一つ減らす
Wait() カウンターが 0 になるまで待つ

それでは、WaitGroup を使ってみましょう。

上で見たコードを変更して、二つの foo() 関数の呼び出しが終わるまで main 関数が終了しないようにします。

package main

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

func foo(wg *sync.WaitGroup, label string) {
	defer wg.Done()

	for i := 0; i < 3; i++ {
		fmt.Println(label, i)
		time.Sleep(time.Second)
	}
}

func main() {
	var wg sync.WaitGroup
	wg.Add(1)
	go foo(&wg, "A")
	wg.Add(1)
	go foo(&wg, "B")

	fmt.Println("Waiting...")
	wg.Wait()
	fmt.Println("Done!")
}
main.go

この実行結果は次のようになります。

go run main.go
Waiting...
A 0
B 0
B 1
A 1
A 2
B 2
Done!

この例では WaitGroup を作成して、goroutine で実行するタスクをひとつ追加するたびに、 WaitGroup の Add(1) を呼んでいます。

そして、 goroutine で実行するタスクが終了する時に Done() メソッドを呼びます。

関数を抜ける時に Done() が呼び出されるように、 defer 文で Done() を実行しています。defer については「Go 言語の defer 文」をご覧ください。

こうして、WaitGroup の Wait() メソッドを呼ぶことによって、 foo() 関数の終了を待つことができます。

尚、 WaitGroup を foo() 関数に渡す時には WaitGroup の参照を渡しています。これは WaitGroup のコピーではなく、元の WaitGroup そのものを渡すようにするためです。

Go の WaitGroup でのデッドロックの発生

もし、main 関数以外の全ての goroutine が実行されていない状態 (asleep の状態) であるにもかかわらず、 WaitGroup の Wait() で待ちが発生している状態になると、次のエラーが発生します。

fatal error: all goroutines are asleep - deadlock!

これは WaitGroup のカウンターがゼロになる見込みがなく、Wait() が返らない状態、すなわちデッドロックが発生したためです。

この場合は WaitGroup のカウンター管理に誤りがあるので、修正が必要です。

以上、ここでは goroutine の基本的な使い方と WaitGroup を用いたタスクの終了待ち方法について説明しました。

ここまでお読みいただき、誠にありがとうございます。SNS 等でこの記事をシェアしていただけますと、大変励みになります。どうぞよろしくお願いします。

© 2024 Go 言語入門