自分の理解を深めるために、図を書いて少しずつ(baby stepsで)goroutineやgo channelを理解していきたいと思います。
並行と並列の違い
まずは混同しやすい並行(concurrent)と並列(parallel)の違いを意識しておきたいと思います。(英語の方がわかりやすい気がするのは気のせいでしょうか)
検索すると色々と定義に違いがあって、私ごときがいい加減なことを書くと、すぐにマサカリで頭を真っ二つにされてしまうことがわかりました。
Erlangを作ったJoe Armstrongの5歳児にも分かる例えの図がわかりやすいので引用します。
ざっくりいうと、並行処理では共通のリソース(例えばCPUやメモリ)をやりくりしながら複数のタスクをさばいています。
並列処理は、それぞれ独立したリソースを使って複数のタスクをさばいています。
ちなみにRob Pike先生の有名なスライド"Concurrency is not Parallelism"も一読することをおすすめします。
goroutineで並行処理
goroutineを使って簡単な並行処理を書いてみます。
まずは1ミリ秒のSleepを1000回実行するタスクAと1ミリ秒のSleepを2000回実行するタスクBを作成してみます。
ちょっと遅いことが体感できる繰り返し回数にしています。
まずはタスクAとタスクBを順次処理するとどうなるか見てみます。
package main
import (
"fmt"
"time"
)
type Task struct {
name string
process_millisec int
}
func (t *Task) ProcessTask() {
fmt.Printf("Start %s\n", t.name)
for i := 0; i < t.process_millisec; i++ {
time.Sleep(1 * time.Millisecond)
}
fmt.Printf("Finished %s\n", t.name)
}
// 共通で使用するタスクの定義
var TaskA = Task{name: "A", process_millisec: 1000}
var TaskB = Task{name: "B", process_millisec: 2000}
// 順次実行
func NormalShori() {
TaskA.ProcessTask()
TaskB.ProcessTask()
}
func main() {
}
実行時間を見たり、CPUの数を増やしたり、したいのでtestingパッケージを使って実行してみます。
テスト用のコードは以下の通りです。
package main
import (
"testing"
)
func TestNormalShori(t *testing.T) {
NormalShori()
}
実行時間が見えるようにtestに-vオプション(詳細出力)をつけて実行します。
$ go test -v
=== RUN TestNormalShori
Start A
Finished A
Start B
Finished B
--- PASS: TestNormalShori (3.84s)
PASS
ok <snip /> 3.849s
タスクAとタスクBを順次実行すると3秒ほどかかることがわかります。
次にgoroutineを使って並行処理を実装します。
try_goroutine.goに以下を追加します。
頭にgo
をつけて関数を呼び出すだけです。
// goroutine で実行
func GoShori() {
go TaskA.ProcessTask()
go TaskB.ProcessTask()
}
テストコードには以下を追加します。
unc TestGoShori(t *testing.T) {
GoShori()
}
Go1.5以降は実行するマシンに搭載されているCPUを可能な限り使うようになっているので、-cpu
オプションを使って1つのCPUで実行するようにします。
$ go test -v -cpu 1
=== RUN TestNormalShori
Start A
Finished A
Start B
Finished B
--- PASS: TestNormalShori (3.97s)
=== RUN TestGoShori
--- PASS: TestGoShori (0.00s)
PASS
ok <snip /> 3.983s
goroutineを使ったテストケースでは何も出力されませんでした。
goroutineが標準出力に文字列を出力する前に、テストが終わったからです。
通常はやらない実装ですが、バッファとしてテストケースにSleep処理を入れると少し見えるようになります。
$ go test -v -cpu 1
=== RUN TestNormalShori
Start A
Finished A
Start B
Finished B
--- PASS: TestNormalShori (3.99s)
=== RUN TestGoShori
Start B
Start A
--- PASS: TestGoShori (0.00s)
PASS
ok <snip /> 3.997s
上の実行例ではgoroutineを使ってタスクB、タスクAの順に実行されたことがわかります。
もちろん順次処理よりも早いことがわかります。
0.00sとなっている理由は、ミリ秒ではなく、ナノ秒の単位で見ないとわからないくらい実行時間が短かったからです。
どのくらい早いか知るにはBenchmarkを使わないといけませんが、ここでは順次処理よりも早いということがわかれば良しとします。
WaitGroupを使ってコントロールする
goroutineで実行される全ての処理を完了してから、ある処理を実行するケースを考えてみます。
例えばタスクAとタスクBを平行に実行して、両方のタスクが終わってからタスクCを実行したいとします。
そんな時はsyncパッケージのWaitGroupを使います。
WaitGroupは全てののgoroutineが終わるのを待ってくれます。
WaitGroupにはカウンタという概念があって、WaitGroupに登録する処理の数だけ登録します。
カウンタにgoroutineの数を登録するにはAdd関数を使います。
各処理が終了したらDone関数を使ってカウンタをデクリメントします。
Wait関数はカウンタが0になるまで待ってくれます。
package main
import (
"fmt"
"sync"
"time"
)
type Task struct {
name string
process_millisec int
}
func (t *Task) ProcessTask(wg *sync.WaitGroup) {
fmt.Printf("Start %s\n", t.name)
for i := 0; i < t.process_millisec; i++ {
time.Sleep(1 * time.Millisecond)
}
fmt.Printf("Finished %s\n", t.name)
wg.Done() //カウンタをデクリメント
}
var TaskA = Task{name: "A", process_millisec: 1000}
var TaskB = Task{name: "B", process_millisec: 2000}
var TaskC = Task{name: "C", process_millisec: 3000}
var Tasks []Task = []Task{TaskA, TaskB}
func GoShori() {
var wg1 sync.WaitGroup
var wg2 sync.WaitGroup
for _, task := range Tasks {
wg1.Add(1) //wg1のカウンタをインクリメント
go func(task Task) {
go task.ProcessTask(&wg1)
}(task)
}
wg1.Wait() //wg1の処理が全て完了するまで待つ
wg2.Add(1)
go TaskC.ProcessTask(&wg2)
wg2.Wait()
}
package main
import "testing"
func TestGoShori(t *testing.T) {
GoShori()
}
$ go test -v -cpu 1
=== RUN TestGoShori
Start B
Start A
Finished A
Finished B
Start C
Finished C
--- PASS: TestGoShori (6.62s)
PASS
ok <snip /> 6.631s
先ほどの例でFinished Cなどは、標準出力に出ていませんでしたが、Wait関数のおかげで結果が確認できます。
気になるのは実行時間です。
タスクAは1秒、タスクBは2秒、タスクCは3秒ほどかかるので、合計時間を見ると、最初のWaitGroup(wg1)内で並行処理しているように見えません。
おそらくWaitGroupの処理に時間がかかっていると思います。
試しに、タスクA、タスクB、タスクCをTasks配列に入れてWaitGroupを使って実行してます。(2つ目のWaitGroupは削除して、タスクCを1つ目のWaitGroupに追加します)
$ go test -v -cpu 1
=== RUN TestGoShori
Start C
Start A
Start B
Finished A
Finished B
Finished C
--- PASS: TestGoShori (3.82s)
PASS
ok <snip /> 3.828s
3.8秒と順次処理よりは早い結果が得られました。
長くなったので、Channelの話は次にまわします。