[Golang] ゴルーチン

2021-12-08 hit count image

Golangでスレッドを使う方法であるゴルーチン(Goroutine)について説明します。

概要

今回のブログポストではGolangでゴルーチン(Goroutine)を使う方法について説明します。ゴルーチンを理解するためスレッドについても簡単に説明する予定です。このブログポストで紹介するコードは次のリンクで確認できます。

スレッド

スレッド(Thread)はプログラム内での実行の流れを意味を意味します。プログラムは一般的には1つの実行の流れ(スレッド)を持てますが、場合によって1つ以上のスレッドを持つこともあります。これをマルチスレッドと呼びます。

CPUは単純な計算機です。従って、渡して貰った値を計算をするたけで、この値がどこから来たか、どこに行くのかは気にしてないです。マルチスレッドの場合、OSがスレッドを管理して、スレッドの数がCPUyori多い場合、スレッドを交代しながらCPUを使います。これをコンテキストスイッチング(Context Switching)と呼びます。

コンテキストスイッチングハ1つのCPUが複数のスレッドを扱う時、スレッドを転換しながらCPUを使えるようにすることを意味します。このようにこテキストスイッチングが発生すると転換の費用が発生するので、性能が低下する問題が発生します。

逆に、CPUの数がスレッドの数と同じ場合、コンテキストスイッチングが発生しないので性能には何も問題が発生しません。

ゴルーチン

ゴルーチン(Goroutine)はGolangが使う軽量スレッドを意味します。Golangで実行される全てのプログラムはゴルーチンで実行されます。つまり、メイン関数もゴルーチンで実行されます。

言い換えると、Golangの全てのプログラムは必ず1つ以上のゴルーチンを持っていることを意味します。

Golangでは次のようにgoキーワードを使って関数をコールしてゴルーチンを実行することができます。

go FUNCTION()

これを確認するためmain.goファイルを作って次のように修正します。

package main

import (
  "fmt"
  "time"
)

func PrintAlphabet() {
  alphabet := "abcdefghijklmnopqrstuvwxyz"

  for _, v := range alphabet {
    time.Sleep(200 * time.Millisecond)
    fmt.Printf("%c", v)
  }
}

func PrintNumbers() {
  for i := 1; i <= 10; i++ {
    time.Sleep(200 * time.Millisecond)
    fmt.Printf("%d", i)
  }
}

func main() {
  PrintAlphabet()
  fmt.Println("")

  PrintNumbers()
  fmt.Println("")

  go PrintAlphabet()
  go PrintNumbers()
  time.Sleep(3 * time.Second)
}

これを実行すると次のような結果が表示されます。

# go run main.go
abcdefghijklmnopqrstuvwxyz
12345678910
a12bc34d5ef67g8hi910jklmn

アルファベットを200ミリセコンドで出力するPrintAlphabet関数と1~10の数字を同じように200ミリセコンドで出力するPrintNumbers関数を、一般的にコールする場合、関数の動作が終わった後、次の関数が実行されることが確認できます。

ここでgoキーワードを使ってコールする場合、順番と関係なく関数がコールされることが分かります。

最後にmain関数で3秒間待機するコードを追加しました。当該コードはmainゴルーチンを3秒間維持するためです。もし、このコードがないと、main関数はPrintAlphabetPrintNumbersをゴルーチンで実行して実行結果を待てなくメインゴルーチンが終了されるようになります。そしたら、メインゴルーチンで実行されたゴルーチン(サブゴルーチン)も一緒に終了されて結果が何も表示されないです。

サブゴルーチン待機

前で見たようにメインゴルーチンが終了されるとメインゴルーチンで実行したサブゴルーチンも全て終了されます。サブゴルーチンがどのぐらいで終わるか予想できる場合は、前のような方法でメインゴルーチンを待機させることができますが、普通はサブゴルーチンがいつ終わるか分からないです。

Golangではサブゴルーチンが終了されるまでメインゴルーチンを待機させるためWaitGroupを提供してます。

var wg sync.WaitGroup

wg.Add(3)

wg.Done()
wg.Wait()

WaitGroupAddを使ってメインゴルーチンが待機するサブゴルーチンの数を指定します。その後、サブゴルーチンではDone関数を使ってゴルーチンの終了を通知します。最後にWait関数を使ってサブゴルーチンが終了されるモデメインゴルーチンを待機させます。

これを確認するためmain.goファイルを次のように修正します。

package main

import (
  "fmt"
  "sync"
  "time"
)

func PrintAlphabet() {
  alphabet := "abcdefghijklmnopqrstuvwxyz"

  for _, v := range alphabet {
    time.Sleep(200 * time.Millisecond)
    fmt.Printf("%c", v)
  }

  wg.Done()
}

func PrintNumbers() {
  for i := 1; i <= 10; i++ {
    time.Sleep(200 * time.Millisecond)
    fmt.Printf("%d", i)
  }

  wg.Done()
}

var wg sync.WaitGroup

func main() {
  wg.Add(2)

  go PrintAlphabet()
  go PrintNumbers()

  wg.Wait()
}

これを実行すると下記のような結果が表示されます。

# go run main.go
a1b23cd4e56fg78hi910jklmnopqrstuvwxyz%

以前と違ってメインゴルーチンが全てのサブゴルーチンが終わるまで終了されないことが確認できます。

ゴルーチンの動作原理

ゴルーチンはOSスレッドを使う軽量スレッド(lightweight thread)です。ゴルーチンはスレッドではなくスレッドを使うものです。

ゴルーチンはOSスレッド別で実行されて、OSスレッドよりゴルーチンが多い場合、ゴルーチンは他のゴルーチンが終わるまで待機します。OSスレッドを使ってるゴルーチンが終わると、待機していたゴルーチンが実行されます。

また、ゴルーチンはシステムコール(ファイル読み書き、ネットワーク読み書きなど)を実行すると、OSのレスポンスがあるまでゴルーチンがやることがないので、そのゴルーチンは待機列に入って、待機列にある他のゴルーチンが実行されます。

ゴルーチンをどんなにたくさん生成してもOSスレッドを使うためOSレベルのコンテキストスイッチング費用は発生しません。

並行性プログラミングの注意点

ゴルーチンのようにスレッドを使うプログラミングを並行性プログラミング(Concurrent programming)といいます。この時、同じメモリリソースを複数のゴルーチン(スレッド)がアクセスする場合並行性問題が発生します。

これを確認するためmain.goファイルを次のように修正します。

package main

import (
  "fmt"
  "sync"
  "time"
)

type Account struct {
  Balance int
}

func DepositAndWithdraw(account *Account) {
  fmt.Println("Balance:", account.Balance)
  if account.Balance < 0 {
    panic(fmt.Sprintf("Balance should not be negative value: %d", account.Balance))
  }
  account.Balance += 1000
  time.Sleep(time.Millisecond)
  account.Balance -= 1000
}

func main() {
  var wg sync.WaitGroup

  account := &Account{Balance: 10}
  wg.Add(10)
  for i := 0; i < 10; i++ {
    go func() {
      for {
        DepositAndWithdraw(account)
      }
    }()
  }
  wg.Wait()
}

これを実行すると次のような結果が表示されます。

# go run main.go
Balance: 6010
Balance: 10
Balance: 7010
Balance: 6010
Balance: 5010
panic: Balance should not be negative value: -1990

ゴルーチンを使うので、結果が同じではない場合があります。DepositAndWithdraw関数はAccount変数の値を1000増やして減少させます。例題でこの関数は無限に実行するゴルーチン10個を作ります。

もし、関数がゴルーチンを使わなく順番で実行されたら、負の値は絶対出ないので、panicが発生しません。しかし、複数のゴルーチンが同じメモリ後レスの値を変更してるので上のように並行性問題でpanicが発生することが確認できます。(パニックが発生しない場合、プログラムを終了させて、再び実行して見てください。)

ミューテックス

このような並行性問題を解決するためミューテックス(Mutex, Mutual Exclusion)を使ってメモリリソースを1つのゴルーチンだけアクセスするようにLockをかけることができます。

これを確認するためmain.goファイルを次のように修正します。

package main

import (
  "fmt"
  "sync"
  "time"
)

var mutex sync.Mutex

type Account struct {
  Balance int
}

func DepositAndWithdraw(account *Account) {
  mutex.Lock()
  defer mutex.Unlock()

  fmt.Println("Balance:", account.Balance)
  if account.Balance < 0 {
    panic(fmt.Sprintf("Balance should not be negative value: %d", account.Balance))
  }
  account.Balance += 1000
  time.Sleep(time.Millisecond)
  account.Balance -= 1000
}

func main() {
  var wg sync.WaitGroup

  account := &Account{Balance: 10}
  wg.Add(10)
  for i := 0; i < 10; i++ {
    go func() {
      for {
        DepositAndWithdraw(account)
      }
    }()
  }
  wg.Wait()
}

これを実行すると次のような結果が表示されます。

# go run main.go
Balance: 10
Balance: 10
Balance: 10
Balance: 10
Balance: 10
Balance: 10

ミューテックスは簡単に並行性の問題を解決することができますが、下記のような問題点を持っています。

  1. 並行性プログラミングによるパフォーマンスの向上が期待できない。逆に多いロッキングで性能が悪くなる。
  2. ゴルーチンを完全に止めるデッドロック(Deadlock)の問題が発生する可能性がある。

従って、ミューテックスを使う場合は注意する必要があります。

デッドロック

並行性プログラミングの問題中で1つであるデッドロックを確認するため食事をする哲学者たち(Dining Philosophers Problem)の問題を見て見ましょう。

食事をする哲学者だちについて詳しい内容はWikiを参考してくださし。

これを確認するためmain.goファイルを次のように修正します。

package main

import (
  "fmt"
  "math/rand"
  "sync"
  "time"
)

var wg sync.WaitGroup

func diningProblem(name string, first, second *sync.Mutex, firstName, secondName string) {
  for i := 0; i < 100; i++ {
    fmt.Printf("(%s) Try to eat\n", name)
    first.Lock()
    fmt.Printf("(%s) Grab %s\n", name, firstName)
    second.Lock()
    fmt.Printf("(%s) Grab %s\n", name, secondName)
    fmt.Printf("(%s) Eating\n", name)
    time.Sleep(time.Duration(rand.Int()) * time.Millisecond)
    second.Unlock()
    first.Unlock()
  }
  wg.Done()
}

func main() {
  rand.Seed(time.Now().UnixNano())

  wg.Add(2)
  fork := &sync.Mutex{}
  spoon := &sync.Mutex{}

  go diningProblem("A", fork, spoon, "Fork", "Spoon")
  go diningProblem("B", spoon, fork, "Spoon", "Fork")

  wg.Wait()
}

これを実行すると次のような結果が表示されます。

# go run main.go
(B) Try to eat
(A) Try to eat
(A) Grab Fork
(B) Grab Spoon
fatal error: all goroutines are asleep - deadlock!

ミューテックスは簡単に並行性問題を解決することができますが、デッドロックの問題でプログラムが意図せず終了されることがあります。

完了

これでGolangでスレッドを使うためゴルーチンについて見て見ました。また、サブゴルーチンを待つ方法やミューテックスを使って並行性問題を解決する方法についても見て見ました。

私のブログが役に立ちましたか?下にコメントを残してください。それは私にとって大きな大きな力になります!

Posts