michimani.net

Go 言語による並行処理に入門してみる - sync.WaitGroup 編

2020-10-20

今年に入ってから少し Go 言語を使う機会があり、今後はもっと使っていくことになりそうということで最近は Go の勉強をしています。Go 言語には並行処理を簡単に扱うことができるという特徴があるので、今回はその並行処理に入門したいと思います。

目次

概要

今回は入門ということで、 go というキーワードを使った並行処理と、 sync.WaitGroup を使った並行処理の管理について触ってみます。ちなみに、内容としては O’Reilly の 「Go言語による並行処理」 という書籍をもとに勉強しています。

やってみる

ということで、とりあえずコードを書いてみます。

go キーワードを使った並行処理

まずは次のようなコードを書きました。

package main

import "fmt"

func printNum(n int) {
	fmt.Println(n)
}

func main() {
	fmt.Println("Start main function...")

	for i := 1; i <= 10; i++ {
		printNum(i)
	}

	fmt.Println("Finished main function.")
}

printNum() という、引数で受け取った数値を標準出力に出力するだけの関数を作って、 main() 関数内では 1 から 10 までの数値を for 文で回して printNum() に渡しています。このコードを実行すると、次のような出力が得られます。

$ go run sample.go
Start main function...
1
2
3
4
5
6
7
8
9
10
Finished main function.

想定通りの出力です。

Go 言語では、並行で処理したい関数の前に go キーワードを書くことで、簡単に並行処理を実現することができます。ということで、上記のコードを次のように変更して、あらためて実行してみます。

  for i := 1; i <= 10; i++ {
- 	printNum(i)
+ 	go printNum(i)
  }
$ go run sample.go
Start main function...
Finished main function.
1
7
5
6

はて。どういうことでしょう。

この現象が起こった理由の前に、 goroutine (ゴルーチン) という概念について簡単に触れておきます。

goroutine とは

goroutine (ゴルーチン) とは、何なのか、冒頭に紹介した Go言語による並行処理 で書かれている説明文を引用します。

ゴルーチンはGoのプログラムでの最も基本的な構成単位です。したがって、それが何で、どのように動作するのかを理解することは重要です。事実、すべてのGoのプログラムには最低1つのゴルーチンがあります。それがメインゴルーチンです。

“単純に言えば、ゴルーチンは他のコードに対し並行に実行している関数のことです(注意:必ずしも並列ではありません!)。ゴルーチンはgoキーワードを関数呼び出しの前に置くことで簡単に起動できます。”

goroutine は、 Go 言語におけるプログラムの基本単位ということがわかりました。これに加えて、 Go 言語での並行処理のモデルについても、同著の説明を引用します。

Goはfork-joinモデルと呼ばれる並行処理のモデルに従っています。分岐(fork)という用語は、プログラムの任意の場所で、プログラムが子の処理を分岐させて、親と並行に実行させることを指しています。合流(join)という用語は、分岐した時点から先でこれらの並行処理の分岐が再び合流することを指します。

main() 関数、つまりメインの goroutine にて go キーワードを使って goroutine を生成すると、それはメインのプログラムから分岐した場所で実行されることになります。その結果、先ほどの出力では main() 関数の終了を示す文字列が出力されたあとに、 goroutine として分岐された printNum() の出力がされています。

また、分岐する形で作成された goroutine は、あくまでも実行のスケジューリングがされた状態なので、明確な実行タイミングはわかりません。そのため、出力される数値の順番もバラバラで、実行するたびに変わります。

さらに、分岐した goroutine は元のプログラム (上のコードであれば main() 関数) が終了した時点で実行されていない場合は、実行されません。先ほどの実行結果で 1 から 10 までの数値がすべて出力されていないのはこのためです。

Go 言語の並行処理は fork-join モデルということなので、分岐した goroutine の実行を待つためには呼び出し元で join する (合流ポイントを作成する) 必要があります。ただ単に処理を待つだけであれば time.Sleep() で待つこともできますが、確実ではありません。これを解決するのが、 sync.WaitGroup です。

sync.WaitGroup を使った goroutine の管理

先ほどのコードを次のようなコードに変更します。

package main

import (
	"fmt"
	"sync"
)

func printNum(n int, w *sync.WaitGroup) {
	defer w.Done()
	fmt.Println(n)
}

func main() {
	fmt.Println("Start main function...")

	var wg sync.WaitGroup
	for i := 1; i <= 10; i++ {
		wg.Add(1)
		go printNum(i, &wg)
	}

	wg.Wait()
	fmt.Println("Finished main function.")
}

変更点としては、 sync.WaitGroup 型の wg を生成して、 printNum() 関数の実行前に wg.Add() で goroutine の起動を表しています。 printNum() 関数内では、関数の実行完了を WaitGroup に伝えるために、ポインタで渡された WaitGroup に対して w.Done() を実行しています。そして、 main() 関数の最後では、生成した goroutine の終了を待つように wg.Wait() を実行しています。

このコードを実行すると、次のような出力が得られます。

$ go run sample.go
Start main function...
1
3
7
5
6
8
9
10
4
2
Finished main function.

実行タイミングは約束されていないので順番はバラバラですが、 1 から 10 までの数値がすべて出力されています。

まとめ

Go 言語における並行処理入門として、 WaitGroup を使った goroutine の制御を試してみました。とりあえず並行処理の実行と それらを待つという処理はできたので、次は生成した goroutine 間でのメモリアクセス同期について sync.RWMutex の仕様について理解を深めたいと思います。


comments powered by Disqus