[Go言語]goroutineとChannel~バグの少ない平行処理の書き方~

こんにちは。KOUKIです。

とあるWeb系企業でGoエンジニアをしています。

Goの最も特徴的な機能の一つが平行処理を行うためのgoroutineです。今回は、Goroutineを使いこなすためのポイントやChannelを使ったデータの受け渡し、シンプルな平行処理を書くために知っておくべきことなどを紹介したいと思います。

golang

Goの平行処理におけるデータ競合問題

Goで平行処理を書く上で気をつけたいことは、複数のgoroutineから変数へアクセスする際のデータ競合でしょう。

Goにはこのようなデータ競合を避けるためのChannelやMutexが提供されています。

Metexは、複数のgoroutine間でメモリを共有した領域を保護し、値の状態を担保する仕組みです。しかし、このメモリを共有する行為自体がプログラムを複雑にし、バグを発生させやすくします。

公式サイトにも以下の注意文が記載されていました。

Do not communicate by sharing memory; instead, share memory by communicating.

メモリを共有することでコミュニケーションを図るのではなく、コミュニケーションすることでメモリを共有せよ!

このコミュニケーションとは、goroutine間のやりとりを指します。

つまり、メモリを共有して複数のgoroutineがアクセスするのではなく、goroutine間でコミュニケーションを行い、メモリ内容を共有することで平行処理をしなさい、ということです。

Channelは上記のアプローチで平行処理を行うので、Mutexよりも推奨されています。しかし、場合によってはMutexの方が簡潔に書ける場合があるので、必要に応じて使い分けた方が良さそうです。

Gorouine

前述の通り、Goroutineにより平行処理を行えます。

使い方はかなりシンプルで、「go」という予約語を関数呼び出しの前に付与すると、goroutineが生成されます。

goでは、mainパッケージのmain関数から処理が呼び出されますが、このmain関数自身もgoroutineであり、main goroutineと呼ばれています。

このmain goroutineが完了すると他のgoroutine(child goroutine)の起動の有無に関係なく、強制的にプログラムが終了します。

起動したchild goroutineが完了するまでmain goroutineを終了させない方法として、Channelが用いられます。

Channel

Channelを使うと複数のgoroutine間で値の送受信ができます。Channelを介して、goroutine間でコミュニケーションを計り、送信側のgoroutineから受信側のgoroutineへ処理をリクエストする、ということも可能になります。

作成

Chanelは、make関数で作成することができます。

makeの第二引数はCapacity(容量)を指定でき、保存できるデータ数を指定できます。

送信

データの送信は「<-」オペレータを使います。

バッファなしChannelの場合、送信している値が受信されるまで後続処理をブロックします。

停止

close関数を使うとChannelの利用を停止し、受信側に停止したことを知らせます。

このcloseしたChannelに対して、値の送信や2度目のcloseを行うなどすると、panicが発生しプログラムが停止します。

受信

データの受信も「<-」オペレータを使います。ただし、データの向きに注意が必要です。

同時処理

selectを使うと複数のChannelから同時に受信・送信ができます。

range

Channelがcloseされるまで受信を行うのに、rangeが便利です。

Channel制御

Channelを受信あるいは送信のみに限定したい場合は、一方向Channel型を使います。これは関数のパラメータや戻り値に指定します。

プログラムの流れが見やすくなるので、結構おすすめな書き方です。

Channel Pattern

Channelの扱いにはPatternがあり、同じようなコードをよく見かける機会があると思います。

select-default

selectとChannelはベストパートナーです。

defaultを付与すると、ブロックされるChannelしかなくなった場合に行う処理を記述できるようです。

for-select

定期実行される処理を書きたい場合は、for-selectを組み合わせます。

nil channel

Channelのゼロ値は、nilです。nil channelを使った送信及び受信は、永久にブロックできるようです。

上記の例では、ch1が受信したらch1にnilを代入し、ch1からの受信をブロックするようにしました。

broadcast

closeされたChannelを受信するとブロックされずにnilが返却されます。そして、Channelがcloseされたことを複数のgoroutineへ一斉に伝えることができます。

closeするには、close関数を使えばOKです。

goroutineの管理

goroutineを乱用すると複雑なコードになりがちです。しかし、goroutineを使わないという選択肢もまたありません。

なるべく簡単に、わかりやすくgoroutineを使う必要があります。

sync.WaitGroup

sync.WaitGroupは複数のgoroutineを管理します。

errgroup.Group

英列に実行する関数からエラーを返却できます。

semaphore.Weighted

特定リソースへの同時アクセスを制御する方法として、セマフォが採用されることがしばしばあります。

semaphoreパッケージのWeightedは、特定の処理が動いている場合は、同時実行数を少なくしたいなどの要望を叶えます。

上記のw変数は、取得したいトークン数である重みを示しており、上記の例では1の重みでトークンを取得しようとしています。この重みを増やすほど、goroutineの同時実行数は少なくなります。なぜなら、取得可能なトークン数が取得したいトークン数より少ない場合、取得したいトークン数以上になるまでgoroutineをブロックするからです。

sync.Once

複数の後ルーチンがアクセスする可能性があるものの、一度だけ実行できれば良いような処理を実装したい場合は、sync.Onceが便利です。

アプリケーションの実行中に一度だけ呼び出すべきものを扱うときに有効です

singleflight.Group

短期間に重複してAPIを呼び出してしまうような状況で、これを抑制したい場合、singleflight.Groupを使いましょう。

※参考

このコードでは、”foo”及び”bar”のキーを持つDoメソッドが実行されます。そして、最もDoメソッドの呼び出しが早かったgoroutineが持つsの値がキャッシュされ、後続の1つのgorutineにはキャッシュされた値が返されます。

アプリケーションの実行中に更新処理が起きるようなものに有効です。

なお、Forgetメソッドを呼ぶとキャッシュされた値をクリアできます。

バグを発生さないように気をつける方法

バグが発生しにくいgoroutineの実装を行いたい場合、goroutineのライフサイクルを意識すると良いです。

方法1: 一方向Channel

基本的に、一方向Channelを使い、「送信」or「受信」のみできるように制限をかけましょう

  • closeしたChannelに送信してpanicが発生することを防ぐ
  • closeしたChannelで受信しようとして永久にブロックされることを防ぐ
  • 送信/受信どちらかの操作のみに集中すればよくなる

方法2: 受信より送信を先に終了させる

受信Channelは、closeされている場合でもpanicを起こさず、ゼロ値(nil)とboo値(closeしたかどうかを示すもの)を返します。そして、送信Channelは受信しているgoroutineがない場合はブロックされ続けます。

よって、送信しているgoroutine側でChannelをcloseし、その後、Channelのcloseを検知させて、受信goroutineを終了すると滞りなく全体を停止することが可能です。

方法3: goroutineリークを検出する

goroutineリークは、生成されたgoroutineが終了せずに滞留することです。サーバーのリソース枯渇に繋がったりするので、厄介な存在です

これを検出するために、uber-go/goleakツールが便利です。

簡単なサンプルを提示しましょう。

TestMain関数でuber-go/goleakの関数を呼ぶとテストの終了時にgoroutineがリークしていないかチェックできます。ただし、このツールは絶対にgoroutineリークが起こらないことを保証するものではなく、テスト時に起きていないことをチェックできるだけです。テスト漏れやgoroutineリークが起きるケースをテストできない場合は効果範囲外ということですね。

方法:4 データ競合を検出する

データ競合とは、あるリソースに対して複数のgoroutineがアクセスしてしまうことです。

goコマンドの-raceフラグを有効にすると、テスト時にメモリ上でデータ競合が発生したか検出できます。

このコードは、Mutexを使わずにリソース(変数a)に対して並行してアクセスしているため、データ競合が発生します。

testing.go:1152: race detected during execution of test」が表示されているのでデータ競合が発生したことがわかりますね。

「-race」フラグもテスト時にデータ競合したものだけ検出できることに注意が必要です。

まとめ

goroutineはとても便利ですが、とても難しいものであるとご理解いただけたと思います。

調子に乗ってgoroutineを使いまくると思わぬバグが発生したり、保守するときに地獄を見たりと危険がいっぱいです。

しかし、結構楽しくプログラミングができるので、実装している時はハイな気分で仕事ができると思いますw

それでは、また!

Go記事まとめ

Go記事まとめです

コメントを残す