それが僕には楽しかったんです。

僕と MySQL と時々 MariaDB

Golangのgoroutine周りを気合で理解する

はじめに

どうも最近netflixで配信されている「センターオブジアース」の吹き替えが絶妙に酷いことに気がついてしまったけんつです。

昨日はGolangのContextについて記事を書きましたが、goroutineやchanelへのある程度の理解なしに勉強しても得られるものは少ないことに気がついたので今日はそのあたりをガッツリやります。
まるでマラソンのようになっていますが明日の記事が投稿されることは保証されません。

goroutine

えぇ、Golangの特徴と言えば?と聞かれて真っ先に思い浮かぶやつ。
これが何かと言われると、「Go言語による並行処理」では次のように書かれている。

ゴルーチンはGoのプログラムのなかで最も基本的な構成単位です。

なぜこれが最も基本的な構成単位なのかというと、main関数はメインゴルーチンと呼ばれるゴルーチンの上で動作するためである。
メインゴルーチンはプロセスが開始するときに自動的に生成されて起動されるが、それ以外のゴルーチンは明示的に宣言することで使用できる。

go sampleFunc()

これはsampleFuncという名前の関数を並行に実行するということを表している。(必ずしも「並列」とは限らない)
めちゃくちゃ便利。

それでは一体、ゴルーチンとは何なのか。

パッと見たときに、ゴルーチンってすごくスレッドっぽいなと思うかもしれない。特にJavaやなにかその類のマルチスレッドを搭載している言語を使ったことがある人なら。
ただ参考文献の中には次のように書かれている。

ゴルーチンはGo特有のものです。ゴルーチンはOSスレッドではなく、また必ずしもグリーンスレッドではありません。ゴルーチンはコルーチンとして知られる高水準の抽象化です。

つまり何が言いたいのかというと、実行中のタスクを一時停止しない、割り込みされないサブルーチンのことらしいです。
ゴルーチン自体には一時停止や再エントリーを認めているいくつかのポイントが存在するが、ゴルーチンはそれらを定義しているわけではなくそのあたりはGoのランタイムが制御しています。
ただ、よくWeb上ではスレッドを更に改良したものだ。と言われることも多く厳密に理解する必要がない場合は軽量なGo特有のスレッドと思うとよいのかもしれません。

そしてここまでまとめた背景の上にゴルーチンが暗黙的の並行処理要素として成立しています。

goroutineをもう少し掘り下げる

ゴルーチンはM:Nスケジューラーという実装の上に成立しているらしいです。

このM:Nスケジューラーが一体何なのか、というとそれは「任意の数のOSスレッドに任意の数のゴルーチンをスケジューリングします。」*1
とのこと。

また、Goはfork-joinモデルという並行処理モデルに従っているためメインゴルーチン、または親となるゴルーチンから派生させる場合にはforkが行われあるタイミングで親のゴルーチンにjoinされ合流するというようになっています。
「Go言語による並行処理」ではそのように書かれていますが、同時に合流ポイントが発生しないまま終了してしまう可能性に注目しています

というのも次のようなコードを実行すると一目瞭然なのですが

package main

import "fmt"

func main() {

    go sayHello()
    fmt.Println("say hello from main goroutine")

}

func sayHello() {
    fmt.Println("say hello")
}

go sayHello() によりゴルーチンが生成(fork)されるがメインゴルーチンの処理が終わるまでにその実行タイミングが無い場合はそのゴルーチンは実行されないのです。

この場合に、前回の記事の様に Time Sleep してもいいのですが参考書籍ではそれは単に競合状態を作っているだけに過ぎず確実に起動するものを保証はしていないため Sync パッケージを使用するべきとなっています。*2

おまけ

「Go言語による並行処理」でゴルーチンが絡んだ面白い例が紹介されていたのでここでも紹介したい
次のようなコードがあった場合に出力が全てPHPになってしまうという事例がある。

package main

import (
    "fmt"
    "sync"
)

func main() {

    var wg sync.WaitGroup

    for _, value := range []string{"golang", "scala", "PHP"} {
        wg.Add(1)
        go func() {
            defer wg.Done()
            fmt.Println(value)
        }()
    }
    wg.Wait()

}

なぜそのような事態が起こるかというと、forで使用している変数 value を表示はしているがゴルーチンが開始するよりも先にループが終わってしまうために発生する。
ではなぜ最後の要素である"PHP"が三回表示されるのかというと、これはGolangGCの話でゴルーチン内で該当する変数が使用されていると判断されスライスの最後の要素である"PHP"への参照が維持されるからである。

これを回避するため(正しく動作させるため)に次の様に少し変更を加えるだけでよい。

     go func(value string) {
            defer wg.Done()
            fmt.Println(value)
      }(value)

これは面白い問題だった。

Syncパッケージ

ここからはぼちぼち公式ドキュメントも参照していく。
golang.org


Syncパッケージは排他制御や同期処理といったものに必要な基本的な機能を提供している。

WaitGroup

WaitGroupは先程も紹介した例にあるが、これは複数のゴルーチンを利用している場合に同期処理を行いたい時に使われる。
特定の集計などの処理を行った場合にその処理が全て終わるのを待ちたい場合などに有効である。

例えば先程のおまけで紹介したコードを次のように修正したとしよう。

package main

import (
    "fmt"
    "sync"
)

func main() {

    var wg sync.WaitGroup

    for _, lang := range []string{"Golang", "Scala", "PHP"} {
        wg.Add(1)
        go func(lang string) {
            defer wg.Done()
            fmt.Println(lang)
        }(lang)
        wg.Wait()
    }
}

まずここで疑問に思うことは Add(1) だろう。
この関数では delta 、つまり変化量として整数値を渡すが内部カウンタの値がそれによって変動する。
カウンタが0であればブロックしていたゴルーチンが全て開放されていたことになり、正の整数であればブロックしているゴルーチンが存在していることになり負の値になれば panic となる。

つまり同期処理したいものがあればこの Add には 1 を渡すべきである。

そしてゴルーチン内で defer を使って Done を呼び出しているが、これはこの関数が終了した時に呼ばれる
そして Done はカウンタに対して作用し、カウンタの値を1減算する。

ループ内最後の Wait() これはカウンタが0になるまで待つものである。

これらの WaitGroup に含まれるメソッドを使うことで同期処理が可能となっている。

Mutex

これは割とよく見かけるのでわかると思うが、並行処理において共通リソースにたいして排他的アクセスを安全に提供するために存在している。

まず共有リソースにアクセスしてしまっているまずい例から見ていく。

package main

import (
    "fmt"
    "sync"
)

func main() {

    count := 0

    var wg sync.WaitGroup
    
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            count++
        }()

        wg.Add(1)
        go func() {
            defer wg.Done()
            fmt.Println(count)
        }()
    }

    wg.Wait()

}

これは wait が for のスコープ外にあるためにそれぞれのゴルーチンの起動順が守られずに値がバラバラに表示される。

こいつに適切な排他制御を加えることで想定どおりの解を出力させる。
上記のコードを以下のように変更する。

package main

import (
    "fmt"
    "sync"
)

func main() {

    count := 0

    var wg sync.WaitGroup
    var mutex sync.Mutex

    for i := 0; i < 10; i++ {
        wg.Add(1)
        mutex.Lock()
        go func() {
            defer wg.Done()
            defer mutex.Unlock()
            count++
        }()

        wg.Add(1)
        mutex.Lock()
        go func() {
            defer wg.Done()
            defer mutex.Unlock()
            fmt.Println(count)
        }()
        wg.Wait()
    }

}

やっていることは非常に単純で、上のゴルーチンが起動する前にロックをかける
次のゴルーチンが起動するときにロックがかかったままならアンロックされるまで待つというもの。

これで排他的に共有リソースにアクセスできる。
他にも似ているもので RWMutexというものも存在している。
RWMutexはRead Writeにおいて片方にロックをかけることができる。

おわりに

ここまでで前回と関連のありそうなものを中心に触れたが、まだ他にも Cond, RWMutex, Poolなどが残っているため次回にまわすことにする。

*1:GoConで発表してきたのでついでにruntime以下の知識をまとめていく #golang - niconegoto Blog https://niconegoto.hatenadiary.jp/entry/2017/04/11/092810

*2:Go言語における並行処理の注意点 https://medium.com/eureka-engineering/go-waitgroup-map-data-race-ec3561de6e47