Go言語における並行処理のベストプラクティス

さいと

2023.03.28

271

こんにちは。

僕個人としては、3年ちょいほどフロントエンドエンジニアとして活動してましたが、最近はバックエンドにも視野を広げて活動していて、最近は、GoやRailsを触っています。


この記事では、「Go言語における並行処理のベストプラクティス」について適宜ソースコードを挟みながら解説していきます。

Go言語の並行処理とは

Go言語は、並行処理を実現するために並行処理のための独自の仕組みを持っています。並行処理は、複数の処理を同時に実行することで、アプリケーションのパフォーマンスを向上させることができます。


Go言語における並行処理は、goroutineという軽量スレッドを使用して実現されます。goroutineは、スタックサイズが非常に小さく、起動や停止のオーバーヘッドが低いため、多数のgoroutineを起動することができます。


また、並行処理を実現するために、Go言語はチャネル(channel)という仕組みを提供しています。チャネルは、goroutine間でデータのやりとりを行うための機能であり、同期的にデータの送受信を行うことができます。

並行処理のベストプラクティス

goroutineのスケジューリングについて

goroutineは、ランタイムによってスケジューリングされます。スケジューラは、goroutineが実行可能であるかどうかを判断し、実行可能なgoroutineがある場合はスケジュールします。


しかし、goroutineのスケジューリングは、厳密には制御できません。そのため、goroutineを適切に制御することが重要です。


例えば、無限ループを含むgoroutineを起動すると、そのgoroutineが無限ループを繰り返し、CPUリソースを占有してしまいます。そのため、goroutineを制御するために、タイムアウトやキャンセル処理を実装することが重要です。


以下は、goroutineにタイムアウト処理を実装する例です。

func timeoutFunc() {
  select {
  case <-time.After(time.Second * 5):
    fmt.Println("timeout")
  case <-quit:
    fmt.Println("quit")
  }
}

func main() {
  go timeoutFunc()
  time.Sleep(time.Second * 6)
  quit <- true
  fmt.Println("done")
}


上記の例では、timeoutFunc()関数が5秒間実行されます。しかし、main()関数から6秒間スリープした後、quitチャネルにtrueを送信することで、timeoutFunc()関数をキャンセルします。timeoutFunc()関数内のselect文は、time.After()で5秒待機するか、quitチャネルからの受信を待機します。どちらかが先に発生した場合に、その処理を実行します。つまり、5秒以内にquitチャネルにtrueが送信された場合には、quitという文字列を出力し、6秒後にmain()関数が終了するため、doneという文字列を出力します。


また、quitチャネルは、bool型のチャネルであり、timeoutFunc()関数が実行されている間、main()関数からtrueが送信されるまで待機します。main()関数がtrueを送信することで、timeoutFunc()関数がキャンセルされます。


このように、goroutineを適切に制御することで、効率的で安全な並行処理を実現することができます。

チャネルのバッファリングについて

チャネルは、同期的にデータの送受信を行うことができます。しかし、チャネルを使った通信は、送信側が受信側にデータを送信するまでブロックされます。そのため、バッファリングされたチャネルを使用することで、ブロックされることなく非同期的にデータの送受信を行うことができます。


以下は、バッファリングされたチャネルを使用する例です。

func main() {
  ch := make(chan int, 1)
  ch <- 1
  fmt.Println(<-ch)
}


上記の例では、チャネルをバッファリングして、1つの値を送信します。その後、受信側で値を取得します。この例では、送信側が受信側よりも先に実行されるため、データを受信する前にブロックされることはありません。

syncパッケージを使用する

Go言語には、syncパッケージがあります。syncパッケージは、並行処理における排他制御を行うための機能を提供しています。

例えば、複数のgoroutineが同時に共有リソースにアクセスする場合、排他制御を行わないとデータ競合が発生する可能性があります。そのため、syncパッケージのMutexを使用して、共有リソースに対する排他制御を行うことが重要です。


以下は、sync.Mutexを使用した排他制御の例です。

import (
  "fmt"
  "sync"
)

var count int
var lock sync.Mutex

func increment() {
  lock.Lock()
  defer lock.Unlock()
  count++
}

func main() {
  var wg sync.WaitGroup
  for i := 0; i < 1000; i++ {
    wg.Add(1)
    go func() {
      defer wg.Done()
      increment()
    }()
  }
  wg.Wait()
  fmt.Println("count:", count)
}


上記の例では、increment()関数でcount変数に対する排他制御を行っています。increment()関数内で、MutexをLock()してカウントアップ処理を実行し、処理が終わったらMutexをUnlock()します。これにより、複数のgoroutineが同時にincrement()関数を実行しても、データ競合が発生しません。


また、main()関数でWaitGroupを使用して、すべてのgoroutineの処理が完了するまで待機しています。

contextパッケージを使用する

Go言語には、contextパッケージがあります。contextパッケージは、goroutineのキャンセルやタイムアウト処理を簡単に実装するための機能を提供しています。


例えば、Webサーバーを実装する場合、HTTPリクエストの処理に対してcontextを使用することができます。これにより、リクエストがタイムアウトした場合には、リクエスト処理をキャンセルすることができます。


以下は、contextパッケージを使用したキャンセル処理の例です。

import (
  "context"
  "fmt"
  "time"
)

func longProcess(ctx context.Context) {
  fmt.Println("start")
  defer fmt.Println("end")

  for {
    select {
    case <-ctx.Done():
      fmt.Println("canceled")
      return
    default:
      fmt.Println("processing")
      time.Sleep(1 * time.Second)
    }
  }
}

func main() {
  ctx := context.Background()
  ctx, cancel := context.WithCancel(ctx)
  go func() {
    time.Sleep(3 * time.Second)
    cancel()
  }()
  longProcess(ctx)
}


上記の例では、longProcess()関数で、contextを使用してキャンセル処理を実装しています。longProcess()関数では、無限ループを行い、データの処理を続けます。しかし、ctx.Done()が発生した場合には、処理をキャンセルして終了します。

main()関数では、contextをBackground()で生成し、cancel()関数を生成します。cancel()関数は、3秒後に実行され、longProcess()関数をキャンセルします。

まとめ

以上が、Go言語における並行処理のベストプラクティスです。goroutineの制御やチャネルのバッファリング、syncパッケージやcontextパッケージを使用します。


今回3パターンを紹介しましたが、それぞれに適切な使用場面があり、これらのベストプラクティスを実践することで、安全で効率的な並行処理を実現することができます。(適切な使用場面の説明は時間がある時に追記するかもしれないです。。)

この記事をシェアする