Yappli Tech Blog

株式会社ヤプリの開発メンバーによるブログです。最新の技術情報からチーム・働き方に関するテーマまで、日々の熱い想いを持って発信していきます。

Goの並行処理入門 - Goroutine基礎編

はじめに

こんにちは。昨年の11月にYappliへ入社したしがないサーバーサイドエンジニアの佐野(@Kiyo_Karl2)です。 自分はYappliに入社するまでGo言語を利用した経験が無く、言語仕様についての理解がまだ浅いと感じる部分があるなと思っています。 そのため、今回はGo言語の最大の特徴でもあるGoroutineについてまとめてみました。

本記事は、4本の連載記事の1本目となります。

対象読者

  • 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スレッドへ合流することを指します。 この合流位置を合流ポイントといいます。

image.png

やりがちなバグ-その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」という文字がコンソールへ出力されることはないでしょう

mainゴルーチンが先に終了してしまう様子を表した画像

では、ここで 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された親スレッドから分岐したゴルーチンスレッド)が終了するまでメインゴルーチンをブロックすることができます。ここが合流ポイント(同期ポイント)となります。

fork-joinモデル
fork-joinモデル

やりがちなバグ-その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パッケージについて解説していきたいと思います。

参考


  1. 「非プリエンプティブ」という用語は、主にマルチタスキングやスケジューリングの文脈で使用されます。これは、タスクやプロセスがオペレーティングシステム(OS)によって強制的に中断されず、タスク自身が自発的に制御を放棄するまで実行を続けることを指します。