onemuri.space

golang と並行・並列処理

並行処理と並列処理の違いは?

golang の良さが語られる際に並列処理の容易さがあります。それは関数の前に go をつけるだけで実装できる手軽さがあるからです。

では似た意味で並行という言葉がありますが、並列と並行の違いはなんでしょうか。

並行処理…複数のタスクを瞬間的に切り替えながら同時にさばく
並列処理…複数のタスクを複数同時にさばく

と定義してみます。どういうことかを"いらすとや"を使ってわかりやすく説明した記事がありました。

言葉の定義は、若干解釈が分かれるところかと思いましたが、parallel と concurrent、並列と並行の違いという記事の定義もわかりやすかったです。

Concurrent(並行)は「複数の動作が、論理的に、順不同もしくは同時に起こりうる」こと
Parallel(並列)は、「複数の動作が、物理的に、同時に起こること」

並列と並行を golang のプログラム的にも理解してみましょう。

まずは並行から

func sleep(sec time.Duration) {
  time.Sleep(time.Second * sec)
}

func main() {
  start := time.Now()
  sleep(1)
  sleep(2)
  end := time.Now()
  fmt.Println(end.Sub(start)) // 3s
}

https://play.golang.org/p/q2TK3mnU_FZ

time.Sleep() 関数によって、1s と 2s の時間分 sleep させたので、プログラムの実行に 3s かかっていますが、これは直列に実行されているので想像通りの結果ですね。

次に並列です

func wgSleep(wg *sync.WaitGroup, sec time.Duration) {
  time.Sleep(time.Second * sec)
  wg.Done()
}

func main() {
  var wg sync.WaitGroup
  start := time.Now()
  wg.Add(2)
  go wgSleep(&wg, 1)
  go wgSleep(&wg, 2)
  wg.Wait()
  end := time.Now()
  fmt.Println(end.Sub(start)) // 2s
}

https://play.golang.org/p/3G3gDcoZBOB

こちらは goroutine を使って並列処理を実行しました。 1s の処理と 2s の処理が並列になっているので、2s の方の処理が終わるのを待った後に goroutine の処理が全て終了します。なので、最後の実行時間も 2s になっています。

今回は並行処理で書いたプログラムは直列に進んでいたので、一つ目の sleep(1) が終わった後に、 sleep(2) が実行されてという順番でした。golang の並列処理(goroutine)には優先順位をつけるものがないので順番に実行しますが、OS とかだとタスクに重み付けがなされて実行順序も変われば、処理が終わる前に違うタスクに移行してまた順番がきたら続きから処理をして。という頭の良いことをしているみたいです。

goroutine の参考記事をのせておきます。

OS からみたスレッド

前章では並列処理と並行処理の違いを goroutine を使いながら説明しましたが、ここからは goroutine について考えてみます。

goroutine のことを知るためにはどうやらスレッドという概念を知る必要があるみたいです。A Tour of Goの Goroutine の項目ではこのように説明しています。

goroutine (ゴルーチン)は、Goのランタイムに管理される軽量なスレッドです。

そもそもスレッドという言葉は OS が提供する CPU 利用の単位が有名かもしれません。OS は CPU の数しかプログラムを同時に実行できません。なので、CPU が1つならば瞬間瞬間には1つのアプリケーションしか動作していません。アプリケーション1つひとつはプロセスで管理されているとしたら以下のような図になるでしょうか。

    |---> プロセス ---> CPU
OS -|
    |---> プロセス ---> CPU

OS は異なるアプリケーション(プロセス)を同時に実行しているかのように、瞬間瞬間でプロセスを切替、1 プロセスに対して 1CPU で実行していく感じです。

1つのプロセスがメモリをどのように使うかを示していますが、大きく分けるとスタック・ヒープ領域、プログラムコードの領域でです。

--------------
      ↑
   スタック
      ↓
--------------
  ガードページ
--------------
      ↑
    ヒープ
      ↓
--------------
プログラムコード
--------------

さらに詳細にしたプロセスにおけるメモリの使われ方(レイアウト)を示した図です。

Imgur

参照: イベントループなしでのハイパフォーマンス – C10K 問題への Go の回答 | POSTD

ガードページがあるのは、スタックとヒープ領域の上書きが発生しないように空間を確保するためです。スタック領域にはそれぞれのスレッドとガードページの領域を確保するようにします。

goroutine のプロセスと OS のスレッド

先ほどの章では OS 目線でのスレッドやメモリ領域での扱われ方について説明しましたが、この章では goroutine と OS 目線でのスレッドなどとの関係について考えています。

goroutine は OS でいうスレッドに値する概念です。スレッドごとに goroutine のリストがあり、そのタスク(goroutine)を順番に切り替えて実行します。

このときのタスクの順番や、暇になったプロセスがどのようにしてタスクを他のプロセスからもってきて処理するのかというのは先ほども紹介したgoroutine はなぜ軽量なのかスケジューラの動き方のところから読み進めるのが、僕が曖昧な知識で話すよりも明確に示してくれていますので、是非ご参考にしてみてください。

goroutine は同じアドレス空間で実行される

goroutine は 同じアドレス空間で実行されます。どういうことかというと、サンプルコードで実際に確認してみるのが良さそうです。

まずは goroutine を使わずに行きます。

func print(s string) {
  for i := 0; i < 3; i++ {
    time.Sleep(100 * time.Millisecond)
    fmt.Println(s)
  }
}

func main() {
  print("first")
  print("second")
}

goroutine 使わずに実行してみると、以下のような結果になると思います。

first
first
first
second
second
second

次に、goroutine を使ってみましょう。

func main() {
  go print("first")
  print("second")
}

一つ目の print()関数を goroutine で呼び出すように修正しただけです。結果は以下のようになります。(結果は実行するごとに変わると思います。)

second
first
second
first
first
second

このように実行の順番は同期的ではありません。なので共有メモリへのアクセスをする時には必ず同期する必要があります。もうひとつサンプルを載せてみます。こちらはよく使われる例だと思います。

func main() {
  for i := 0; i < 5; i++ {
    go func() {
      fmt.Println(i)
    }()
  }
  time.Sleep(time.Second)
}

for 文の中で goroutine を呼び出し、main()関数の最後には 1s スリープさせるようにしています。もし、1s スリープさせなければ、console 上では何も出力されないとおもいます。

main()関数(メインゴルーチンとでも呼んでおきましょうか。)が goroutine を for 文の中で呼び出していますが、この時 for 文の中で呼ばれている goroutine はメインゴルーチンから派生した goroutine となります。このサブ的な goroutine は生成元の goroutine(今回はメインゴルーチン)が終了したら自身の処理が終わっていなくても終了になるからです。

確認してみます。for 文の中の goroutine で文字出力より前に 2s スリープする処理を入れてみましょう。

func main() {
  for i := 0; i < 5; i++ {
    go func() {
      time.Sleep(time.Second * 2)
      fmt.Println(i)
    }()
  }
  time.Sleep(time.Second)
}

結果は、何も表示されません。これは for 文の中の goroutine よりも先に親であるメインゴルーチンが終了するからです。

上記のサンプルで goroutine の実行順序はある程度分かったと思います(思うことにします)。次に、この章で最も伝えたいgoroutineは同じアドレス空間で実行されることについてサンプルコードを載せます。

意図としては、グローバル変数としてaを定義し、メインゴルーチンでbという変数が定義して、goroutine 上で pointer を確認します。

var a int
func main() {
  var b int
  for i := 0; i < 3; i++ {
    go func() {
      fmt.Printf("first goroutine:  a: %d(%p), b: %d(%p)\n", a, &a, b, &b)
      a++
      go func() {
        fmt.Printf("second goroutine: a: %d(%p), b: %d(%p)\n", a, &a, b, &b)
        b++
      }()
    }()
  }
  time.Sleep(time.Second)
}

実行環境によって変わると思いますが、以下のようになると思います。

first goroutine:  a: 0(0x1195980), b: 0(0xc00008a008)
first goroutine:  a: 0(0x1195980), b: 0(0xc00008a008)
second goroutine: a: 2(0x1195980), b: 0(0xc00008a008)
second goroutine: a: 1(0x1195980), b: 0(0xc00008a008)
first goroutine:  a: 0(0x1195980), b: 0(0xc00008a008)
second goroutine: a: 3(0x1195980), b: 2(0xc00008a008)

これを見れば明らかなように、goroutine 上で同じポインタ、つまり同じアドレス空間が用いられている事が確認できます。なので、先ほど

共有メモリへのアクセスをする時には必ず同期する必要があります

と書いたのはアドレス空間を共有するために実行順序によっては意図せぬ上書きや処理がなされる可能性があるために、同期的に実行することでスレッドセーフに処理する必要が出てくるのです。ではどのようにスレッドセーフに実行するのかを以下の 2 つ紹介します。

  • sync.Mutex
  • sync.WaitGroup

sync.Mutex

まずはsync.Mutexです。

var a int

func main() {
  var b int
  for i := 0; i < 3; i++ {
    go func() {
      fmt.Printf("first goroutine:  a: %d(%p), b: %d(%p)\n", a, &a, b, &b)
      a++
      go func() {
        fmt.Printf("second goroutine: a: %d(%p), b: %d(%p)\n", a, &a, b, &b)
        b++
        a++
      }()
    }()
  }
  fmt.Printf("a: %d, b: %d\n", a, b)
}

上記のサンプルコードは同期処理を行わずに処理したコードになります。結果は以下のようになります。

a: 0, b: 0
first goroutine:  a: 0(0x1195960), b: 0(0xc000094008) // これは出力されたりされなかったり

goroutine の中でabはインクリメントしていますが、出力結果には反映されていません。次にsync.Mutexを使った例を示してみます。

var a int

func main() {
  var b int
  var m sync.Mutex
  var m2 sync.Mutex
  for i := 0; i < 3; i++ {
    m.Lock()
    go func() {
      defer m.Unlock()
      a++
      fmt.Printf("first goroutine:  a: %d(%p), b: %d(%p)\n", a, &a, b, &b)
      m2.Lock()
      go func() {
        defer m2.Unlock()
        b++
        a++
        fmt.Printf("second goroutine: a: %d(%p), b: %d(%p)\n", a, &a, b, &b)
      }()
    }()
  }
  m.Lock()
  m2.Lock()
  fmt.Printf("a: %d, b: %d\n", a, b)
  m.Unlock()
  m2.Unlock()
}

goroutine が呼び出される前にLock()関数を呼び出し、goroutine 内部でUnlock()関数を呼び出します。また、メインゴルーチンでもLock()Unlock()を出力の前後で挟んであげてみています。結果、変数abは同期的に処理されている事が確認できます

first goroutine:  a: 1(0x1196980), b: 0(0xc00008a008)
first goroutine:  a: 2(0x1196980), b: 0(0xc00008a008)
second goroutine: a: 3(0x1196980), b: 1(0xc00008a008)
first goroutine:  a: 4(0x1196980), b: 1(0xc00008a008)
second goroutine: a: 5(0x1196980), b: 2(0xc00008a008)
second goroutine: a: 6(0x1196980), b: 3(0xc00008a008)
a: 6, b: 3

1 つ目の goroutine では変数 a をインクリメントして、2 つ目の goroutine では変数 a と b のインクリメントをしています。a はどちらの goroutine でもインクリメントされていて、b はsecond goroutineを出力する方の goroutine でのみインクリメントしています。

sync.WaitGroup

先ほどのsync.Mutexよりも少し使いやすいsync.WaitGroupを紹介します。

var a int

func main() {
  var b int
  var wg sync.WaitGroup
  for i := 0; i < 3; i++ {
    wg.Add(1)
    go func() {
      defer wg.Done()
      a++
      fmt.Printf("first goroutine:  a: %d(%p), b: %d(%p)\n", a, &a, b, &b)
      wg.Add(1)
      go func() {
        defer wg.Done()
        b++
        a++
        fmt.Printf("second goroutine: a: %d(%p), b: %d(%p)\n", a, &a, b, &b)
      }()
    }()
  }
  wg.Wait()
  fmt.Printf("a: %d, b: %d\n", a, b)
}

sync.MutexLock()Unlock()に比べると処理が書きやすいですね。goroutine の前でwg.Add()し、groutine 内部で defer wg.Done()し、goroutine の外でwg.Wait()でジョブの完了を待ちます。結果は以下です。

first goroutine:  a: 1(0x1196980), b: 0(0xc00008e008)
second goroutine: a: 4(0x1196980), b: 1(0xc00008e008)
first goroutine:  a: 2(0x1196980), b: 0(0xc00008e008)
first goroutine:  a: 3(0x1196980), b: 0(0xc00008e008)
second goroutine: a: 6(0x1196980), b: 3(0xc00008e008)
second goroutine: a: 5(0x1196980), b: 2(0xc00008e008)
a: 6, b: 3

変数 a は 6 で変数 b は 3 になっているので処理結果は間違い無いですね。

まとめ

本記事では golang からみたスレッド、OS からみたスレッドを取り扱いつつ、スレッドをプロセスやメモリ領域の話を交えて考えてみました。また、goroutine がアドレス空間を共有するために、同期処理の必要性と同期処理の仕方を解説してみました。

goroutine は同期処理のことを考えることで、圧倒的に処理速度を上げる事ができるのでしっかり学んで使えるようにしていきましょう!

参考

Go ならわかるシステムプログラミング

自己紹介用画像

Riki Akagi

2019年からDeNAで働いています。GCP(CloudSQL・GAE・Cloud Function etc)とGoでAPI開発に勤んでいます。睡眠やエンジニアリングに関することに興味を持って過ごしているのでその情報を皆さんに共有していけたらなと思っています。

自己紹介の詳細