golang (go言語) で書いた以下のようなループ処理があるとします。
func execLoop(list []Item) {
for _, item := range list {
do_something(item)
}
}
list に格納された各 item に対して do_something()
を適用する、よくあるタイプのループ処理です。
goroutine で並列化、その副作用
golang ではこの do_something()
の適用を超お手軽に並列化できます。あ、もちろん do_something()
はリエントラントである前提ですね。
func execLoop(list []Item) {
for _, item := range list {
go do_something(item)
}
}
do_something()
の呼び出しを goroutine 化しただけです。ただこれだけだと問題がある場面がほとんどでしょう。
func execLoop(list []Item) {
for _, item := range list {
go do_something(item)
}
}
func main() {
list := make([]Item, 10)
execLoop(list)
}
ループが終わってしまって main をも抜けてしまう時 goroutine は実行されません。ちゃんと調べてはいませんけれども、実行されるのを見たことはまだありません。
WaitGroup を導入
つまり goroutine で実行されるすべての do_something()
の終了を待つ必要があります。そんなときは sync パッケージの WaitGroup を使いましょう。
import "sync"
func execLoop(list []Item) {
var wg sync.WaitGroup
for _, item := range list {
wg.Add(1)
go func(item2 Item) {
do_something(item2)
wg.Done()
}(item)
}
wg.Wait()
}
一気に複雑になりましたが、まずは wg
(=WaitGroup) のメソッドだけに着目してください。
メソッド | 適当な説明 |
---|---|
Add() |
WaitGroup のカウンタを上げる |
Done() |
WaitGroup のカウンタを下げる |
Wait() |
WaitGroup のカウンタが0になるまで待つ |
こうなってます。つまり各ループの先頭で Add()
してループが終わったあとで Wait()
すれば、あとは各ループの処理の最後= do_something() のあとで Done()
するだけで、待ち合わせ処理の完成です。
書くまでも無いことですが、do_something()
は無名関数でラップして item
はその引数 item2
として渡してます。こうしないと一度目の実行で item
が無名関数に bind されてしまい、意図した動作になりません。
最終版
仕上げに Done()
の呼び出しには defer
を使って無名関数の先頭でやってしまいましょう。それだけで do_something()
で何かあっても無名関数終了時に Done()
が呼び出されることが保証されます。
import "sync"
func execLoop(list []Item) {
var wg sync.WaitGroup
for _, item := range list {
wg.Add(1)
go func(item2 Item) {
defer wg.Done()
do_something(item2)
}(item)
}
wg.Wait()
}
execLoop()
の修正だけで並列化ができていることがこの記事におけるポイントです。
つまり execLoop()
から呼び出す do_something()
も、execLoop()
を呼び出す側(例えば main)も弄らずに並列化できています。これは「ちょっとループを並列化してパフォーマンスが改善するか見てみよう」という時に使える、スッキリとしたテクニックと言えるでしょう。
いかにも golang らしい性質の一端が垣間見えますね。