Go言語には、goroutineという並行処理の仕組みがあります。
関数呼び出しの前に go をつけることで、その処理を別の流れで実行できます。
たとえば、時間のかかる処理を3つ実行するとします。
普通に書くと、
処理Aが始まる
処理Aが終わる
処理Bが始まる
処理Bが終わる
処理Cが始まる
処理Cが終わる
という順番になります。
しかし、処理A・処理B・処理Cが互いに独立しているなら、本当は順番に待つ必要はありません。
処理A
処理B
処理C
を同時に始められれば、全体の待ち時間を短くできます。
本記事では、Google Colab上でGoのコードを動かしながら、Goの並列実行・並行処理の雰囲気を学びます。
なお、厳密にはgoroutineは「並行処理」の仕組みです。実際にCPU上で本当に同時に実行されるかは環境や処理内容によります。ただし、APIアクセス、ファイル読み込み、待ち時間の多い処理などでは、複数の処理を同時に進めることで、実行速度を高速化できる場合があります。
対象読者
この記事は、次のような人を対象にしています。
- Google Colabを使ったことがある人
- Go言語の基本文法を学んだことがあり、簡単なGoのコードなら読める人
- Goのgoroutineを最初に試してみたい人
- 並列実行・並行処理の雰囲気をつかみたい人
本格的な並行処理の設計というより、まずは「Goではこういうことができるのか」という感覚をつかむことを目的にしています。
Google Colabは標準ではPythonの実行環境ですが、Linuxコマンドを実行できるため、Goもインストールできます。
学習を進めるために
なお、「Google ColabでGoのプログラムを動かすなんてできるの?」で引っかかってる読者は、まず下記記事で予習してください。
また、先に、Go言語の基本文法が知りたいという人は、下記記事を見てください。
すぐに使えるチートシートはこちら Google Colab版
すぐに実行して、試せるコードレシピはこちら。
時間がかかる処理の例・「2秒かかる処理」を作る
まず、2秒かかる処理を作ります。
実際の業務であれば、
- APIを呼び出す
- ファイルを読み込む
- 画像を変換する
- CSVを処理する
- 外部サービスからレスポンスを待つ
といった処理が「時間のかかる処理」になります。
今回は入門用・たんなる「遅い処理」の見本なので、time.Sleep を使って「2秒かかる処理」を再現します。
%%writefile sequencial_execution.go
package main
import (
"fmt"
"time"
)
// 2秒間かかるタスク
func task(name string) {
fmt.Println(name, "start")
time.Sleep(2 * time.Second)
fmt.Println(name, "finish")
}
func main() {
start := time.Now()
task("A")
task("B")
task("C")
fmt.Println("全部終了")
fmt.Println("かかった時間:", time.Since(start))
}
実行します。
A start
A finish
B start
B finish
C start
C finish
全部終了
かかった時間: 6.0020938s
task は1回あたり2秒かかります。
それを3回、順番に実行しているため、
2秒 + 2秒 + 2秒 = 約6秒
かかります。
コードの流れとしても、
task("A")
task("B")
task("C")
と書いているため、Aが終わるまでBは始まりません。
Bが終わるまでCも始まりません。
前から順番にひとつずつ。これが普通の逐次実行です。
goをつけるとどうなるか──まずは、失敗例から
Goでは、関数呼び出しの前に go をつけると、その関数をgoroutineとして実行できます。早速、先ほどのコードに go をつけてみます。
%%writefile goroutine_failure.go
package main
import (
"fmt"
"time"
)
func task(name string) {
fmt.Println(name, "開始")
time.Sleep(2 * time.Second)
fmt.Println(name, "終了")
}
func main() {
start := time.Now()
// ただ「go」と付けるだけで、並列処理ができると思ってしまった人
go task("処理A")
go task("処理B")
go task("処理C")
fmt.Println("全部終了")
fmt.Println("かかった時間:", time.Since(start))
}
実行結果の例です。
全部終了
処理A 開始
かかった時間: 35.887µs
かなり不思議な結果になりました。1秒未満で終了です。
しかも、go task("処理A") と書いたのに、処理A・処理B・処理Cが最後まで実行されていません。
これは、go をつけた処理が動き始める一方で、main 関数が、その終了を待たずに進行してしまったためです。
つまり、このコードは失敗例であり、次のようなフローになっています。
1.
処理Aを別の流れで開始しようとする
処理Bを別の流れで開始しようとする
処理Cを別の流れで開始しようとする
2.
main関数は待たずに次へ進む
3.
全部終了 と表示する
かかった時間を表示する
4.
main関数が終了する
プログラム全体が終了する
main 関数が終わると、プログラム全体が終了します。
そのため、goroutineで動き始めた処理が、最後まで表示される前にプログラムが終わってしまいます。
goをつけると、別の流れで処理を始められる。
しかし、goをつけただけでは、その処理の終了を待ってくれるわけではない。
とりあえず3秒待ってみる
先ほどの失敗を踏まえて、雑に3秒待ってみます。
各タスクは2秒で終わるはずなので、3秒待てば、処理A・処理B・処理Cは終わるだろう、という考え方です。
%%writefile goroutine_v1.go
package main
import (
"fmt"
"time"
)
func task(name string) {
fmt.Println(name, "開始")
time.Sleep(2 * time.Second)
fmt.Println(name, "終了")
}
func main() {
start := time.Now()
go task("処理A")
go task("処理B")
go task("処理C")
// 失敗から学んで、「3秒待つ」という教訓を反映
time.Sleep(3 * time.Second)
fmt.Println("全部終了")
fmt.Println("かかった時間:", time.Since(start))
}
実行結果の例です。
処理C 開始
処理A 開始
処理B 開始
処理B 終了
処理A 終了
処理C 終了
全部終了
かかった時間: 3.000169742s
今度は、処理A・処理B・処理Cが最後まで実行され、全体の時間も短くなりました。goroutineで同時に始めると、約3秒で終わっています。
並列・並行処理では開始順と終了順は、必ずしも一致しない
しかし、今回の処理自体は各2秒なので、本来は2秒少しで終わってほしいところです。ここでは、最後に time.Sleep(3 * time.Second) と書いているため、全体として約3秒かかっています。
このコードは、入門用にあえて紹介していますが、あまり望ましい書き方ではありません。まず、実際の処理が何秒で終わるかは、事前にはわからないのが普通です。そして、万一3秒以上かかった場合には、結局バグが生じます。
そのため、本質的に作るべきは、「3秒待つ」ではなく、「3つの処理が全部終わるまで待つ」仕掛けです。
sync.WaitGroupで正しく待つ
複数のgoroutineが全部終わるまで待つには、sync.WaitGroup を使います。
%%writefile goroutine_v2.go
package main
import (
"fmt"
"sync"
"time"
)
func task(name string) {
fmt.Println(name, "開始")
time.Sleep(2 * time.Second)
fmt.Println(name, "終了")
}
func main() {
start := time.Now()
var wg sync.WaitGroup
wg.Add(3)
go func() {
task("処理A")
wg.Done()
}()
go func() {
task("処理B")
wg.Done()
}()
go func() {
task("処理C")
wg.Done()
}()
wg.Wait()
fmt.Println("全部終了")
fmt.Println("かかった時間:", time.Since(start))
}
実行結果の例です。
処理C 開始
処理A 開始
処理B 開始
処理B 終了
処理A 終了
処理C 終了
全部終了
かかった時間: 2.000392763s
今度は、全体の時間が約2秒になりました。
それぞれの処理は2秒かかりますが、3つの処理をほぼ同時に開始しているため、
2秒 + 2秒 + 2秒 = 約6秒
ではなく、2秒の処理を同時に実行し、終了させることができました。
WaitGroupの考え方
WaitGroup は、複数の処理が終わるまで待つためのカウンターのようなものです。
今回のコードでは、3つの処理を待ちます。
wg.Add(3) // これから3個の処理を待つ
wg.Done() // 処理が1個終わりました
wg.Wait() // Addした数だけDoneが呼ばれるのを待つ
フローで示すと、次のとおり
1.wg.Add(3)
1-1.処理Aが終わる → wg.Done()
1-2.処理Bが終わる → wg.Done()
1-3.処理Cが終わる → wg.Done()
2.三つのDoneが呼ばれた
3.wg.Wait()を抜ける
4.全部終了 と表示する
go func() { ... }() という見た目
先ほどのコードには、次のような書き方が出てきました。
go func() {
task("処理A")
wg.Done()
}()
これは、名前のない関数(無名関数)をその場で作って、すぐにgoroutineとして実行する書き方です。しかし、まだ見た目に違和感があるかもしれません。
待ち合わせ込みの関数にする
無名関数の活用に違和感があるなら、待ち合わせ込みの関数を作って、さらに読みやすくもできます。
ついでに、defer も使って、もっと洗練させていきます、
defer は、その関数が終わるときに実行する処理を予約する書き方です。
%%writefile goroutine_v4.go
package main
import (
"fmt"
"sync"
"time"
)
func task(name string) {
fmt.Println(name, "開始")
time.Sleep(2 * time.Second)
fmt.Println(name, "終了")
}
func runTask(name string, wg *sync.WaitGroup) {
defer wg.Done()
task(name)
}
func main() {
start := time.Now()
var wg sync.WaitGroup
wg.Add(3)
go runTask("処理A", &wg)
go runTask("処理B", &wg)
go runTask("処理C", &wg)
wg.Wait()
fmt.Println("全部終了")
fmt.Println("かかった時間:", time.Since(start))
}
実行結果の例です。
処理C 開始
処理A 開始
処理B 開始
処理B 終了
処理A 終了
処理C 終了
全部終了
かかった時間: 2.000405881s
このコードでは、次の関数を追加しています。
func runTask(name string, wg *sync.WaitGroup) {
defer wg.Done()
task(name)
}
runTask は、
taskを実行する
終わったらwg.Done()を呼ぶ
という関数です。
そのため、main 関数側は少し読みやすくなります。
go runTask("処理A", &wg) // 処理Aを別の流れで実行する
go runTask("処理B", &wg) // 処理Bを別の流れで実行する
go runTask("処理C", &wg) // 処理Cを別の流れで実行する
なお、ここで出てくる &wg は、WaitGroupのポインタを関数に渡すための書き方です。ここでは細かいポインタの説明には入りませんが、WaitGroupを別の関数に渡すときは &wgと書く必要があります。(ポインタについては、別記事で開設予定。)
逐次実行とgoroutine実行の違い
goroutineは高速化に便利であるものの、開始順や終了順は保証されなくなります。そのため、goroutineは、
- 複数URLへのアクセス
- 複数ファイルの読み込み
- 複数画像の変換
- 複数ユーザーへの通知
- 複数データの個別処理
のように、順番に依存しない、独立した処理に適しています。
一方で、
- Aの結果を使ってBを実行する
- Bの結果を使ってCを実行する
というような処理であれば、単純に並行化できません。
まとめ
本記事では、Google Colab上でGoの並列実行・並行処理を試しました。
- 最初は逐次実行で書く。
- 次に
goをつけて失敗する。 - 一度
time.Sleepで雑に待つ。 - その後、
WaitGroupを使って正しく待つ。 - 最後に
deferや関数化で少しずつ整える。
今回の学習で重要なのは、次の3点です。
・goをつけると、関数を別の流れで実行できる
・ただし、main関数はgoroutineの終了を自動では待たない
・複数のgoroutineを待つには、WaitGroupを使う
また何より、Google ColabやJupyter Notebookで学ぶと、こうした試行錯誤の過程をそのまま残すことができます。
完成したコードだけを見ると、なぜその形になったのかわかりにくいことがあります。ノートブック上に失敗や修正の過程が残っていると、「なぜこのコードになったのか」が記録できるようになります。
さいごに一言(余談)
今日、ビッグデータというと、モダンなITの仕事、みたいなイメージを持つ人は多いと思います。
しかしその実、ビッグデータは件数が多いので、処理が遅い・検証が難しい(事故が怖い) ことも、課題になりがちだと思います。
データエンジニアリング領域では依然Pythonのシェアが強いものの、Goの最適化・高速化の仕組みも、ビッグデータ領域と相性がよいと思います。
弊社について
本記事を書いている 合同会社インクルーシブソリューションズ は、データ基盤構築・分析基盤設計・システム改善支援を中心に活動している小規模IT法人です。
主な領域は、
- データマート設計・データパイプライン構築
- SQL / Python を用いたデータ処理設計
- BI導入支援・分析基盤の整備
- 既存システムの運用改善・可視化支援
といった、「データを使える状態にする」ための活動です。
弊社の企業活動に興味がある方は、ぜひ公式サイトも覗いてみてください。