[Golang]Goroutineをマスターしよう~平行処理完全マスターガイド~

golang

こんにちは。KOUKIです。

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

今日は、Goroutineに関して基本から応用まで記事にしました。平行処理を完全にマスターしましょう!

そもそもGoroutineって何?

Goroutineはプログラム内の処理を「平行」して実行できるGoの素晴らしい機能の一つです。

並列処理(マルチスレッド)とよく混同されがちなのですが、実行するプログラムを瞬間的に切り替える(コンテキストスイッチというらしい)ことで、複数のプログラムが同時に実行されているように動作します。しかし、起動している処理は常に一つです。

並列処理では、全ての処理が同時に実行されるイメージですね。この記事がわかりやすいです。

平行処理でプログラムを走らせるとリソースを遊ばせる時間が減るので、処理速度が上がります(逆に遅くなることもありますが)。

平行処理を二人乗り自転車で例えると、お姉ちゃん一人のケイデンスより妹ちゃんのケイデンスを合わせた方がより早く前に進める、そういった感じです。例え、わかりにくいですか? ^^;

Goroutineの基本

Hello Worldプログラム

「Hello World」文字列を出力する簡単なプログラムを実装します。

なんてことないHello Worldプログラムですが、実は既にGoroutineが動いています。

Goではmainパッケージのmain関数からプログラムが実行されますが、その時にMain Goroutineが起動します。つまり、Goのプログラムでは最低一つのGoroutineが起動することになります。

go キーワード

処理を平行に走らせるってどうやるんだ?難しいんだろう?」という声が聞こえてきそうです。

いえいえ、超簡単です。

平行で走らせたい処理の呼び出しに「go」キーワードをつける、ただそれだけですよ♪

これだけで平行処理になるので、Goは素晴らしい言語だとお伝えしているわけです。

では、プログラムを実行してみましょう。

「……………………….」。

結果が出力されない!

Goroutineのハンドリングは難しい

実は、Goroutineを扱う際には注意点があります。

それは、「処理の流れに注意せよ」です。

前述した通り、GoではMain Goroutineが必ず起動します。そして、goキーワードによりhelloworld関数が平行で走るわけですが、この処理の実行が完了する前にMain Goroutineが終了すると平行起動している処理の有無に関わらず、全ての処理が終了してしまうのです。

その証拠に、time.Sleepで処理を一時停止すると期待した結果が得られます。

無名関数でもOK

無名関数でGoroutineを動かすことができます。

goroutineの起動のしやすさと相まって、お手軽感がありますよね。

Sleep制御ってダサくない?

先ほど、Sleepを使ってGoroutineの制御方法をお見せしましたが、実際のプログラムだと何秒間Sleepさせておけばいいのかわかりませんし、そもそも無駄なSleepはプログラムを遅延させるボトルネックになります。

ゆえに、SleepでGoroutineを制御することはあり得ません(と思う)。

対策として、GoではsyncパッケージのWaitGroupが提供されています。これを使うといい感じに実装できます。

いい感じですよね。プログラムを実行してみましょう。

コールバック関数

コールバック関数という言葉を聞いたことはないでしょうか。

他の関数に引数として渡される関数で、外側の関数で何らかの処理やアクションを実行します

簡単な例を挙げてみましょう。

上記の例はすぐに実行される同期型のコールバック関数です。

一方、goroutineで非同期型のコールバック関数を実装することもできます。

プログラムを実行してみましょう。

OKですね。非同期のコールバックは処理が複雑になるので、個人的にはあまり実装したくないです^^;

ちなみに、無名関数を使うとより柔軟なコールバックが実装できます。

関数を数珠繋ぎに呼び出せてますね。

Mutex

Mutexは、排他制御機能を提供するGoの素晴らしい仕組みです。排他制御とは、あるリソース(例えば変数)に対して同時に読み書きすることを防いでくれるGoroutineで平行処理プログラムを実装する時には必須の知識になります。

簡単なサンプルを以下に示しましょう。

Counter構造体のvalueオプションが排他制御対象です。

排他制御を設定した箇所は、2つあります。

  1. valueのインクリメント
  2. valueの読み込み

そして、MutexのLock/Unlockメソッドで排他制御をしています。

プログラムを実行してみましょう。

OKですね。

ちなみに、「-race」オプションは、競合が発生していないか調べてくれる便利なツールなので積極的に活用していきましょう。

次に、Lock/Unlockをコメントアウトしたバージョンを以下に示します。

プログラムを実行すると競合が発生するはずです。

Found 1 data race(s)」と表示されたので、思惑通りになりましたね!

競合が発生するとプログラムがクラッシュするので、排他制御が必須なわけです。

Channels

Goroutine間でデータの受け渡しをするにはどうすればいいのか、不思議に思ったかもしれません。

簡単です。Channelを使えばいいのです。

実装の方法

簡単なサンプルを以下に示します。

Channelを作成するには、Go組み込みのmake関数を使います。

そして、「<-」オペレータを使って値の送受信をしています。

プログラムを実行してみましょう。

deadlockにご注意を

Hello World!が出力されましたね。感がいい人は「なんでMain Goroutineが先に終了しないんだ?HelloWorldの時は何も出力されなかったじゃん。」と思われるかもしれません。

実は、Channelはブロック機能を持っていて、「message := <-channel 」で値を受信するまで処理をブロックします。そのため、Main Goroutineが完了しなかったというわけです。

すごく便利そうな機能ではありますが、制御を誤るとdeadlockが発生します。

上記のコードは、「永久に値が受信されることのない」プログラムです。このような場合に、deadlockが発生します。

バッファ付きチャネル

Channelには、サイズ(容量)を設定できます。例えば、サイズを1に設定したchannelを以下に示します。

このバッファが一杯になった場合、このチャネルへの書き込みはブロックされます。

この性質を利用すれば、Goroutineの最大起動数を制御することもできます。

ちなみに、close関数でチャネルを閉じることができます。

専属チャネルの作成

メソッドや関数のパラメータに「<-」オペレーターを明示することで、送信専用、受信専用のチャネルを作成することができます。

「<-」の向きに注意してください。

  • 変数 <- ・・・ 受信専用
  • <- 変数 ・・・ 送信専用

送信専用チャネルに値を送信しようとしたら(逆もまた然り)、エラーが発生して処理が停止します

Channelの振り分け

for + selectのコンボで、Channelを振り分けることができます。

このパターンは結構使えます。チャットアプリケーションなんかで使ったりしますね。

応用: シングルトン カウンター

そろそろGoroutineやChannel、Mutexに慣れてきたのではないでしょうか?

応用として、Singletonパターン + goroutine + Channel or Mutexでカウンタープログラムを作りましょう。

仕様は至ってシンプルです。

  • プログラム全体で一意のインスタンスであること(シングルトンが保障されていること)
  • インスタンスのcountプロパティをインクリメントする機能を有すること
  • インスタンスのcountの現在値を取得する機能を有すること

パターン1: チャネルを使ったバージョン

チャネルを使ったバージョンは、先ほど紹介した「select + for」構文を使います。

ポイントは、「GetInstance」関数です。インスタンスの生成/取得をここから行う縛りにすればシングルトンなインスタンスを取得できます。

あとは、Channelを使って値のカウント、取得をすれば良いだけです。

var getCountCh chan chan int = make(chan chan int)」は、あまりみない書き方ですよね。int型のchannelを型として定義し、そこからカウント数を取り出したいのでこの様な書き方になっています。

テストコードを実装して、挙動確認をしましょう。

このコードは何もテストしていないのですが、挙動を確認する上で役に立ちます。

パターン2: Mutexを使う

Mutexで実装したパターンも紹介します。

Channelのパターンと比べるとかなりスッキリしましたね。

先ほど紹介したテストコードから「singleton.Stop()」の行をコメントアウトして、テストを実行してみましょう。

応用: Barrier Concurrency Pattern

Barrier Concurrency Patternを実装しましょう。

Wikiによると「同期方法の一つであり、ソースコード中でスレッドプロセスがある箇所で停止し、他の全てのスレッドプロセスがバリアに到達するまで進行しないようなものを示す。」とあります。

このバリアに到達するまで進行しないようにするという処理はChannelで実現できそうです。

このコードは、あるURLに対してGoのクライアントでリクエストを飛ばします。ただ飛ばすのではなく、Goroutineを使って平行処理で飛ばしています

そして、全てのGoroutineの結果を受け取れるようにチャネルでブロック処理を入れています。

テストコードを実装して、動きを確認してみましょう。

captureBarrierOutputはヘルパー関数です。barrier関数は戻り値を返さず、結果を標準出力へ吐き出すので、この出力先を変数に変更し、テストで使えるようにしています。標準出力のテストをしたいときにも有効ですね!

Testは「正しいURL」、「不正なURL」、「Timeout」の3つあり、テストを実行するとbarrier関数が機能していることがわかります。

応用: Future Design Pattern

Future Design Patternは非同期処理パターンの一つで、ある処理をGoroutineで実行し、その結果を未来で受け取りたいといった処理に用いられるデザインパターンです。

例を挙げてみましょう。

このコードには、「Success/Fail/Execute」の3つのメソッドが存在します。Success/FailはMaybeStringを戻り値として返します。そして、ExecuteをGoroutineで実行したときに、Success/Failから結果を受け取ることができます。

とはいえ、わかりにくいと思うのでテストコードを実装してみましょう。

3パターンくらいテストコードを実装していますが、TestStringOrError_Executeテストさえ見ていただければOKです。

ここでは、Success/Failを事前に登録し、その後にExecuteメソッドを実行して、その結果を受け取ります。処理が成功したときはSuccess、失敗した時はFailが呼ばれる感じですね。

実用的かはともかく、面白い実装だと思います。

テストコードを実行するとそれぞれの結果がログとして確認できます。

応用: Pipeline Pattern

Pipeline Patternは、Channelを使って非同期に処理を繋げていくパターンです。

詳細は、以下の記事を確認ください。

今回は、generator(channel生成)、supply(供給)、sum(合計)の3つの処理をパイプラインで繋げて処理するサンプルを実装します。

単純明快の処理ですね。ここまでの内容を理解できていれば、簡単に理解できるソースコードです。

特徴的なのは「sum(supply(generator(amount)))」のような処理が書けることですね。Pipelineを彷彿とさせる書き方です。

goroutineで実行しているので、平行処理できている点もポイントが高いですよね^^

より理解を深めるために、テストコードを実装してみましょう。

テストコードを実行します。

OKですね。

おわりに

お疲れ様でした!少し長くなりましたね^^;

しかし、苦労する価値はあるくらい、GoroutineやChannelについての理解は深められたと思います。

GoプログラマーにとってGoroutineはかなり大切な存在ですので、たまに基礎から理解を深め直すことも必要だと個人的に考えています。

最近、仕事ではバックエンド側の言語をGo以外で書いてないです。それほど使いやすい言語なんですよね。

日本ではそれほど流行っていませんが、シリコンバレーではめちゃくちゃ流行っています。

もっとGo言語を流行らせたいですね〜〜〜

それでは、また!

Go記事まとめ

アルゴリズムまとめ

Go記事をまとめます。

コメントを残す