はじめに
こんにちは。昨年の11月にYappliへ入社したしがないサーバーサイドエンジニアの佐野(@Kiyo_Karl2)です。
自分はYappliに入社するまでGo言語を利用した経験が無く、言語仕様についての理解がまだ浅いと感じる部分があるなと思っています。
そのため、今回はGo言語の最大の特徴でもあるGoroutine
についてまとめてみました。
本記事は、4本の連載記事の1本目となります。
- Goの並行処理入門-Goroutine基礎編 ←今ここ
- Goの並行処理入門-syncパッケージ編
- Goの並行処理入門-channel編
- Goの並行処理入門-select編
対象読者
- Go言語の基礎はわかっているが、Goroutineについてはあまり理解していない
- メモリ、プロセス、スレッド、並行処理、並列処理といったワードについて概要とその違いを理解している
連載記事を通して取り扱わないこと
本連載記事ではGoroutine、syncパッケージ、channel、select文の基礎にフォーカスを当てています。 以下のような発展的な内容については触れていませんのでご了承くださいm(_ _)m
- ゴルーチンリークを避ける手法
- 並行処理のエラーハンドリング
- チャネルやselect文の発展的な内容
- ゴルーチンのスケジューリングについて
- ファンアウト、ファンイン
- contextパッケージによるゴルーチンのタイムアウトや中断方法
Goroutineとは
Tour of Goには以下のような記述があります。
A goroutine is a lightweight thread managed by the Go runtime. (ゴルーチンは、Goランタイムによって管理される軽量スレッドである。)
よって、ゴルーチンは一言で言うとGoのランタイムによって管理される軽量スレッドであると言えます。
ただし、当然と言えば当然かもしれませんが、ゴルーチンの実行にはOSスレッドが利用されるため、すべてがGoのランタイムによって制御されるわけではなく、ランタイムとOSスレッドの間で協調してスケジューリングされ実行されます。
ゴルーチンのスケジューリングは、GoランタイムによるM:Nスケジューリング(多数のゴルーチンをより少数のOSスレッド上でスケジューリングする仕組み)に基づいており、複数のゴルーチンを少数のOSスレッド上で効率的に実行することができます。
この設計により、ゴルーチンは高いスループットと低いレイテンシを持つアプリケーションの開発を可能にします。(M:Nスケジューリングの詳細はここでは割愛させていただきます)
コルーチン
また、ゴルーチンはGo言語特有のものであり、並列処理を扱うことができるコルーチンの一種です。
The Rust Unstable Bookでは、コルーチンについて以下のような記載があります。
The coroutines feature gate in Rust allows you to define coroutine or coroutine literals. A coroutine is a "resumable function" that syntactically resembles a closure but compiles to much different semantics in the compiler itself. The primary feature of a coroutine is that it can be suspended during execution to be resumed at a later date. Coroutines use the yield keyword to "return", and then the caller can resume a coroutine to resume execution just after the yield keyword. (Rustのコルーチン機能ゲートでは、コルーチンやコルーチン・リテラルを定義できる。コルーチンは「再開可能な関数」であり、構文的にはクロージャに似ているが、コンパイラ自体のセマンティクスは大きく異なる。コルーチンの主な特徴は、実行中に一時停止して後で再開できることである。コルーチンはyieldキーワードを使って "return "し、呼び出し側はyieldキーワードの直後にコルーチンを再開して実行を再開することができる。)
つまりコルーチンには「プログラマ自身で実行中に一時停止し、任意の箇所で再開することができる」という特徴があります。
一方、ゴルーチンは、非プリエンプティブ1な並行処理のサブルーチン(Goでいうと関数、メソッド、クロージャ)です。 割り込み処理をされることがありません。 よって、コルーチンと違い一時停止や再エントリーのポイントをGoを利用するプログラマ向けに提供してません。
ゴルーチンがブロックしたら自動的に一時停止し、ブロックが開放されたら再開するようにGoのランタイムよってのみ制御されます。 以上のことからゴルーチンは特殊なコルーチンと言えます。
fork-joinモデル
Goはfork-joinモデルと呼ばれる並行処理のモデルに従ってます。
まずGoのmain関数で実行される処理はゴルーチンです。これをmainゴルーチンと呼び、これがGoプログラムの最小の構成単位となります。
分岐(fork)はプログラムが任意の場所で、mainゴルーチンから子ゴルーチンを分岐させ親スレッドと並行に実行させることを指しています。 合流(join)はforkで分岐した処理が完了後、再びmainスレッドへ合流することを指します。 この合流位置を合流ポイントといいます。
やりがちなバグ-その1
ゴルーチンが実行されずにプログラムが終了してしまう
ここで簡単なサンプルコードを載せます。
package main import "fmt" func main() { hello := func() { fmt.Println("Hello") } go hello() // 以後続きの処理が実行される }
ゴルーチンの文法は非常にシンプルで、goキーワードに続けて関数呼び出しを記述するだけで非同期的に実行することができます。
go hello()
このとき、hello()
関数は新しいゴルーチン上で非同期的に実行されるようになります。
このサンプルコードには1つ問題があります。
hello
関数が実行されるという保証がありません。
Goのランタイムによって親スレッドからforkされ子ゴルーチンが生成されますが、子ゴルーチンが実行されるまでにmainゴルーチンが終了してしまうのです。(fork-joinモデルで言う合流ポイントが無い状態)
実行してみるとわかりますが、「Hello」という文字がコンソールへ出力されることはないでしょう
では、ここで time.Sleep
を書いて処理が終了するのを待てばよいのではないかと思うかもしれません。
実際にやってみます。
package main import ( "fmt" "time" ) func main() { hello := func() { fmt.Println("Hello") } go hello() // 3秒待つ time.Sleep(3 * time.Second) }
実行してみます。
$ go run main.go Hello
確かに、Helloという文字が標準出力されました!!
しかし、これはアンチパターンです。 スリープ処理を入れたところで確実にゴルーチンが実行されるとは限りません。 長い時間スリープすればするほどプログラムが正しい時間を出力する確率があがるだけです。
結果としては正しいように見えますが、スリープを入れることはアルゴリズム的にかなり非効率です。
正しいやり方をすれば(詳細はsyncパッケージ編で後述)1マイクロ程度で済みます。 例のように3秒も待つ必要はありません。
ではどうすれば良いのかと言うと、ここで確実にhello
関数が実行されるように、上述した合流ポイントをつくってあげる必要があります。
この合流ポイントによって、メインゴルーチンとhello
関数を同期します。
いろいろ方法がありますが、syncパッケージのsync.WaitGroup
を利用する方法があります。
package main import ( "fmt" "sync" ) func main() { var wg sync.WaitGroup hello := func() { defer wg.Done() fmt.Println("Hello") } wg.Add(1) go hello() wg.Wait() // ① 合流ポイント }
上記で、wg.Wait()
によって、hello
関数をホストしているゴルーチン(forkされた親スレッドから分岐したゴルーチンスレッド)が終了するまでメインゴルーチンをブロックすることができます。ここが合流ポイント(同期ポイント)となります。
やりがちなバグ-その2
正しい値を参照できない
次に、クロージャに注目して見ていきます。 Goのクロージャは、関数とその関数が定義された場所周辺のスコープにアクセスすることができます。 ゴルーチンの中でクロージャを実行したとき、クロージャは変数のコピーに対して操作するのでしょうか?それとも元の変数の参照に対してでしょうか?
下記で試してみます。
package main import ( "fmt" "sync" ) func main() { var wg sync.WaitGroup salutation := "Hello" wg.Add(1) go func() { defer wg.Done() salutation = "World" // 1 }() wg.Wait() fmt.Println(salutation) }
1 でsalutation
の値を上書きしています。
これを実行したときsalutation
の値はHelloとWorldのどちらになるでしょうか?
$ go run main.go World
答えは"World"となります。
この結果から、メインゴルーチンと匿名ゴルーチンが変数salutation
のメモリを共有しており、片方の値を変更すると、もう一方のゴルーチンにもその変更が反映されるということがわかります。
では、次のコードはどうなるでしょうか?
package main import ( "fmt" "sync" ) func main() { var wg sync.WaitGroup for _, salutation := range []string{"Hoge", "Fuga", "Piyo"} { wg.Add(1) go func() { defer wg.Done() fmt.Println(salutation) }() } wg.Wait() }
実行すると以下のようになります。
$ go run ./cmd/sample_app/gorutine.go Piyo Piyo Piyo
これはちょっと予想外ではないでしょうか? なぜ同不順で「Hoge、Fuga、Piyo」が出力されないのでしょうか?
なぜこうなるのか、順を追って解説します。
この例では、ゴルーチンは文字列型のsalutation
を含むクロージャをループの中で実行しており、ループされるごとにsalutation
へスライスの値が代入されていきます。
ゴルーチンは任意のタイミングにランタイムによってスケジュールされるため、ゴルーチンが開始する前にループ処理が終了してしまいます。
そうすると、salutation
変数はスコープ外となってしまうのです。
しかし、スコープ外になるにもかかわらず、変数salutation
の「Piyo」を参照できています。
これは、実はGoのランタイムが、「変数salutation
への参照が保持されているのか?」ということを知っており、ゴルーチンがそのメモリにアクセスし続けられるようにメモリをヒープ領域へ移してくれているので、参照することができるのです。
ループが終了してしまった後、最後の値である「Piyo」への参照を保持したままヒープへ移されます。
そして、fmt.Println(salutation)
実行時にはヒープ領域にある値を参照するため、「Piyo」が3回表示されてしまうのです。
これを期待した結果にするには、各イテレーションでsalutation
変数のコピーを匿名ゴルーチンへ引数で渡すようにします。
こうすると、各ゴルーチンはループのその時点でのsalutation
の値のコピーを持ち、それぞれ異なる値を出力します。
func main() { var wg sync.WaitGroup for _, salutation := range []string{"Hoge", "Fuga", "Piyo"} { wg.Add(1) go func(salutation string) { defer wg.Done() fmt.Println(salutation) }(salutation) //引数で渡す } wg.Wait() }
実行すると以下のようになります。
$ go run main.go Hoge Fuga Piyo
上記を踏まえて意識しておいた方が良いことは、「自身のゴルーチンのスコープ外の変数は参照しない方がバグを生みにくい」ということです。 これを防ぐために、値のコピーを引数に渡す方法はよく使いますので覚えておくと良いでしょう。
余談: エスケープ解析
これは本題とは少し逸れるので興味の無い方は読み飛ばしてもらってOKです!
先程「Goのランタイムは「変数salutation
への参照が保持されているのか?」ということを知っており、ゴルーチンがそのメモリにアクセスし続けられるようにメモリをヒープ領域へ移します。」といった話をしましたが、これは、Goのコンパイラによるエスケープ解析という解析により変数のポインタをスタックからヒープへ退避させるかどうかの判断がされています。
実は、ドキュメントにこの解析結果を出力する方法が記載されています。
Use
-gcflags -m
to observe the result of escape analysis and inlining decisions for the gc toolchain.(TODO: explain the output of
-gcflags -m
). Go公式ドキュメントより引用
先程のコードに-gcflags -m
オプションを付与して実行して、本当にsalutation
がヒープへ退避されているのか確認してみます。
$ go build -gcflags '-m -l' ./cmd/sample_app/gorutine.go # command-line-arguments cmd/sample_app/gorutine.go:9:6: moved to heap: wg cmd/sample_app/gorutine.go:10:9: moved to heap: salutation cmd/sample_app/gorutine.go:10:36: []string{...} does not escape cmd/sample_app/gorutine.go:12:6: func literal escapes to heap cmd/sample_app/gorutine.go:14:15: ... argument does not escape cmd/sample_app/gorutine.go:14:16: salutation escapes to heap
上記の結果にmoved to heap: salutation
とあります。
これは、「salutation
がゴルーチンのクロージャにキャプチャされ、それぞれのゴルーチンが非同期に実行されるため、salutation
のライフタイムが関数のスコープを超える可能性がある」とエスケープ解析によりコンパイラが判断した証拠であると言えます。(ループの各イテレーションでsalutation
変数のコピーを匿名ゴルーチンに渡すように修正したコードでエスケープ解析の結果を出力すると、moved to heap: salutation
は解析結果に出力されません。)
最後に
ここまで読んでいただき、ありがとうございます。 今回はGo言語最大の特徴とも言えるゴルーチンの基本的なことについてまとめてみました。 次回はゴルーチンと同時に利用する重要なパッケージのひとつであるsyncパッケージについて解説していきたいと思います。
参考
- Go言語による並行処理
- Goでの並行処理を徹底解剖!
- Go 公式ドキュメント
- Goコンパイラのお勉強(2)~高階関数のためのインライン展開とエスケープ解析~
- Effective Go
- The Rust Unstable Book - coroutines
- 「非プリエンプティブ」という用語は、主にマルチタスキングやスケジューリングの文脈で使用されます。これは、タスクやプロセスがオペレーティングシステム(OS)によって強制的に中断されず、タスク自身が自発的に制御を放棄するまで実行を続けることを指します。↩