概要
Go言語で並行処理を実装する機会があり、勉強していたため、知識の整理もかねて記事を書きたいと思います。Go言語の特徴の1つとして、ゴルーチンを利用して並行処理が簡単に実装できることがあげられます。この特徴はGo言語が人気を集めている1つの要因かと思います。
今回はWaitGroupを利用したGo言語の並行処理について書きます。
ゴルーチンについて
ゴルーチンはGo言語の実行の単位です。Go言語において並行処理を実装する際には、このゴルーチンを複数起動して関数を実行していきます。すべてのGo言語で記載されたコードはゴルーチンを利用して実行されます。例えば、main関数も1つのゴルーチンで実行されますので、意識していなくてもGo言語で書かれたコードを実行する場合にはゴルーチンが利用されています。
コードの中でゴルーチンを起動する場合は、下記のように実行する関数の前にgo
をつけることで実現できます。非常に簡単ですね。
go dosomething()
ゴルーチンの実行順序
ゴルーチンによる並行処理を実装する際の注意点として実行順序があります。
初めてGo言語で並行処理を実装する際には、はまりどころなポイントかと思います。筆者も例外ではありませんでした。
例えば、実行時間が1秒かかる処理があるとします。
package main
import (
"fmt"
"time"
)
func task(i int) {
time.Sleep(1 * time.Second)
fmt.Printf("Task number %v : complete\n", i)
return
}
func main() {
task(1)
}
これを通常通り実行すると、約1秒後に下記のような出力が得られます。
Task number 1 : complete
これは想定通りの動作です。
では次にゴルーチンを利用した場合、どうなるでしょうか。
下記の通り、task(1)
をgo task(1)
として実行します。
package main
import (
"fmt"
"time"
)
func task(i int) {
time.Sleep(1 * time.Second)
fmt.Printf("Task number %v : complete\n", i)
return
}
func main() {
go task(1)
}
おそらく何も出力されずに実行が終了してしまうでしょう。
これは、ゴルーチンで関数が実行される際の実行順序に保証がないためです。
上記の場合、go task(1)
で呼び出されたゴルーチンの実行が完了する前に、main関数が終了したことを意味します。そのため、親のゴルーチンがなくなったため、子のゴルーチンの実行が完了せずに終了してしまいました。
sync.WaitGroupの利用
実行順序の保証がないままだとゴルーチンで起動した処理が実行されるかされないかわからないため、このままでは活用が難しそうです。
そこでこれを解決する方法の1つとして、Go言語の標準パッケージの1つであるsync
パッケージのWaitGroup
があります。
WaitGroup
を利用することで、起動したゴルーチンの実行完了を待つ、といった実装が可能になります。
それでは実際にコードの例をみていきましょう。
先ほどご紹介した1秒かかる処理を30回実行することを考えます。比較のため、並行処理を実行しない場合とした場合の両方で実行結果を確認します。また並行処理の有効性を確認するため、処理の実行時間も合わせて計測します。
並行処理なし
まずは並行処理を利用せずに処理を30回実行します。
実行コード
package main
import (
"fmt"
"time"
)
func task(i int) {
time.Sleep(1 * time.Second)
fmt.Printf("Task number %v : complete\n", i)
return
}
func main() {
start := time.Now()
for i := 1; i <= 30; i++ {
task(i)
}
end := time.Now()
fmt.Printf("実行時間 : %v秒\n", (end.Sub(start)).Seconds())
}
実行結果
実行結果は下記の通りです。
Task number 1 : complete
Task number 2 : complete
Task number 3 : complete
Task number 4 : complete
Task number 5 : complete
Task number 6 : complete
Task number 7 : complete
Task number 8 : complete
Task number 9 : complete
Task number 10 : complete
Task number 11 : complete
Task number 12 : complete
Task number 13 : complete
Task number 14 : complete
Task number 15 : complete
Task number 16 : complete
Task number 17 : complete
Task number 18 : complete
Task number 19 : complete
Task number 20 : complete
Task number 21 : complete
Task number 22 : complete
Task number 23 : complete
Task number 24 : complete
Task number 25 : complete
Task number 26 : complete
Task number 27 : complete
Task number 28 : complete
Task number 29 : complete
Task number 30 : complete
実行時間 : 30.097339734秒
30回の処理を順番に実行するので、合計で約30秒の時間がかかっているのがわかります。
並行処理あり
次に並行処理を利用して処理を30回実行します。
実行コード(WaitGroupなし)
まずはWaitGroup
を用いずにゴルーチンを30個起動して処理を実行してみましょう。
package main
import (
"fmt"
"time"
)
func task(i int) {
time.Sleep(1 * time.Second)
fmt.Printf("Task number %v : complete\n", i)
return
}
func main() {
start := time.Now()
for i := 1; i <= 30; i++ {
go task(i)
}
end := time.Now()
fmt.Printf("実行時間 : %v秒\n", (end.Sub(start)).Seconds())
}
実行結果(WaitGroupなし)
筆者の環境では、下記のような出力になりました。
実行時間 : 7.5845e-05秒
先ほど説明したように、ゴルーチンで起動された30個の処理の実行が完了する前にmain関数の実行が終了してしまったため、実行時間のみしか表示されません。
実行コード(WaitGroupあり)
それではWaitGroup
を使って、起動したゴルーチンの処理が完了するのを待つようにしてみましょう。
WaitGroupを利用して、先ほどのコードを下記のように書き換えました。
package main
import (
"fmt"
"sync"
"time"
)
func task(i int) {
time.Sleep(1 * time.Second)
fmt.Printf("Task number %v : complete\n", i)
return
}
func main() {
var wg sync.WaitGroup
start := time.Now()
for i := 1; i <= 30; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
task(i)
}(i)
}
wg.Wait()
end := time.Now()
fmt.Printf("実行時間 : %v秒\n", (end.Sub(start)).Seconds())
}
WaitGroup
で多く利用されるメソッドは、Add(N)
、Done()
、そしてWait()
です。
メソッド | 概要 |
---|---|
Add(N) |
内部で持っているカウントをNだけカウントアップ |
Done() |
内部で持っているカウントを1つカウントダウン |
Wait() |
内部で持っているカウントが0になるまで処理をブロック |
これらのWaitGroup
のメソッドを利用して、すべてのゴルーチンの実行が完了するようにコードを変更しました。
上記のコードでは、便宜上task(i)
を別のゴルーチンで囲んで呼び出しています。
ゴルーチンがお呼び出される前に、wg.Add(1)
で1だけカウントアップします。これにより、起動されたゴルーチンの分だけ、カウントが加算されることになります。
次に起動されたゴルーチンの中で、defer wg.Done()
と記載しておくことで、起動されたゴルーチンの実行が完了した際に、必ず1つカウントダウンされるようにしておきます。
最後にwg.Wait()
を記載することで、カウントが0になるまで、つまり起動された全てのゴルーチンの処理が完了するまで、実行をブロックします。これにより、全てのゴルーチンの処理が完了するまで、main関数の実行は終了しなくなります。
実行結果(WaitGroupあり)
最後に上記のコードの実行結果をみてみましょう。
Task number 29 : complete
Task number 7 : complete
Task number 23 : complete
Task number 24 : complete
Task number 2 : complete
Task number 12 : complete
Task number 25 : complete
Task number 11 : complete
Task number 20 : complete
Task number 10 : complete
Task number 19 : complete
Task number 21 : complete
Task number 15 : complete
Task number 8 : complete
Task number 9 : complete
Task number 6 : complete
Task number 26 : complete
Task number 1 : complete
Task number 30 : complete
Task number 27 : complete
Task number 14 : complete
Task number 22 : complete
Task number 5 : complete
Task number 16 : complete
Task number 3 : complete
Task number 17 : complete
Task number 4 : complete
Task number 18 : complete
Task number 13 : complete
Task number 28 : complete
実行時間 : 1.00028092秒
全ての処理が実行されているのがわかります。またこの実行結果から2つのことがわかります。
1つ目は、約1秒で全ての処理が完了している点です。これは並行処理の大きなメリットで、各処理の実行には1秒かかるため、それぞれの処理が全て並行で実行されたというのがわかります。これにより、大幅に実行時間が短縮されました。
2つ目は、各処理の実行順序が必ずしもゴルーチンが起動された順番にはなっていない点です。これはすでに説明した通り、ゴルーチンの実行順序保証がないため、各処理がどのタイミングで実行されるかがわからないためです。この点は、並行処理を実装する上で考慮しておくべきポイントで、1つのゴルーチンの処理結果が別のゴルーチンの処理結果に依存するような場合は、それらの処理は並行化するのには向いていません。
まとめ
Go言語のゴルーチンとWaitGroupを使った並行処理について記載させていただきました。
Go言語を利用した並行処理としては、基本的な機能ではありますが、筆者の場合、これだけでも十分に役立つ並行処理を実装することが可能になりました。
このような並行処理も簡単にかけるGo言語はとても楽しいですね!