Goの並列・並行処理

森下篤氏(以下、森下):私のところでは、Golangについて説明します。弊社は会社名もGOなので非常にわかりにくいのですが、基本的には大文字で「GO」と書くと会社を示すことが多いです。(スライドを示して)小文字の「Go」やスライドのアイコンを使った時には、言語のGoと思ってもらえると助かります。

まず、Goでの並列・並行処理について話します。独自の軽量スレッドであるGoルーチンというものがあって、Goルーチン同士の同期・非同期通信の仕組みであるチャネルが構文に含まれています。

Goランタイム自体は、OSのスレッドを隠蔽し、Goルーチンを時分割に割り当てて動かします。割り当てのスケジューリング自体はGoランタイムに任されていて、指示ができません。

つまり、Goルーチンを起動したからといって、それをすぐに並列処理で実行してくれるというわけではありません。そしてGoルーチンは、OSのスレッドを管理するよりもかなり軽量なため、使い捨てができます。

(スライドを示して)下に書いてあるものが簡単に描いてみた図ですが、プログラミングを実装している、Goを実装している人にとっては、複数のGoルーチンをたくさん起動するという概念しか扱うことができなくて、これを実際にCPU上で実行する、どのGoルーチンを実行するかというのは、Goランタイムに任されているというのがGoの並列処理のGoルーチンの考え方です。

実際のGoルーチンの実行方法

森下:では、実際にGoルーチンの実行方法を説明します。go 関数()で実行します。すごくシンプルです。即時関数を使うと、go func(){ }()というかたちになります。

(スライドを示して)右側がその実装例ですが、「go func」と書いて、関数の処理をfuncの中に書いていきます。そうすると、中の部分がGoルーチンとして別に切り出されて実行される動きになります。

これは、実際に引数の配列を受け取っていて、その配列の要素単位にめちゃくちゃGoルーチンを起動する実行例です。自分の経験則では、1ミリセック以上のタスクであれば、逐次で起動しまくっても大きなオーバーヘッドにはならないと見ています。

そして、CPUを可能な限り使ってGoランタイムが実行をしてくて、ネットワーク通信などの処理も積極的にGoルーチン化することで、CPUが必要なGoルーチンを優先的に割り当ててくれます。

ではそのGoルーチンのもう1個の例として、ストリーミング処理にGoルーチンを使う例を話したいと思います。実際に(私たちのサービスで)やっているものですが、車両のGPS座標データをDBに取り込む処理があります。その処理を3つの段階に分けました。データを受信する処理、受信したデータをデコードして整形する処理、そしてDBに書き込む処理の3つです。こんな感じで、段階に分けて分割します。

そして、段階に分けて分割したものを、それぞれGoルーチンで実行します。この時に、真ん中のデコード・整形処理みたいなより高負荷な処理のところでGoルーチンをたくさん起動しておいて、そこだけ並列数を増やして処理をすることができます。

(スライドを示して)ではこのGoルーチン、それぞれ起動したところで、Goルーチン間のデータ渡しはどうすれば良いかというのを次に話します。

Goルーチン間のデータ受け渡し

森下:ストリーミング処理のデータ渡しにチャネルを使います。チャネルはFIFOバッファになっています。Goルーチン間にチャネルを置いて、その間をチャネルを使ってデータをやり取りするように実装できます。その実装もすごくシンプルな記法でできて、「変数 <- チャネル名」「チャネル名 <- 変数」で受け取りと受け渡しができます。

(スライドを示して)右側のコードは実際にそういう処理をした例ですが、上部が実行というところで、forループで64並列でGoルーチンをめちゃくちゃgo funcで起動しまくっておいて、1個1個の処理はfor文で無限ループするようにしておきます。

その一つひとつの処理の中では、一つひとつのgo func自体が64並列に起動したGoルーチンに当たって、その中で前のチャネルから受け取って、処理をして、次のチャネルに渡すというような実装ができます。forループなのでずっとぐるぐる回ります。このチャネルですが、中に滞留できるデータの量を設定できて、0だと同期処理に、1以上だと非同期処理になります。

これだけだとイメージが湧きにくいので、全体の動きを次に説明したいと思います。

チャネルの実行イメージ

森下:(スライドを示して)チャネルの実行イメージに、先ほどのGoルーチンと間にチャネルがあるイメージを書いてみました。まずデータの受信側、次のGoルーチンにチャネル経由で渡す側としては、データを受信したらチャネルにデータを入れます。

そしてこのGoルーチンはデータを入れたらそれで終わりで、すぐに次のデータの処理に入ります。なのでここは非同期に、ひたすらチャネルの中にデータを送り続けることができます。

そして、真ん中のGoルーチンは複数のGoルーチンが立っているわけですが、それぞれのGoルーチンで、チャネルからデータを1個ずつ取り出します。この時、もしチャネルの中が空だったら、そのGoルーチンはチャネルの中に値が入るまで待機している動きになります。

そして処理が終わると後ろのチャネルBにデータを渡して、for文でまた次のチャネルからデータを取って処理するというかたちで、動き続けることができます。

というように、それぞれのGoルーチンがチャネルを経由して、それぞれチャネルにデータを入れる処理と同期せずに動かすことができます。そして、処理の負荷によってチャネルに入るこのデータの数や、それぞれ処理するGoルーチンの数を調整したりします。

Goルーチンとチャネルでよく起こるトラブル

森下:こういうのをやるわけですが、このGoルーチンとチャネルでよくトラブルがあります。よくこういうストリーミング処理を実装すると、「このぐらいで入ってくるはずなのに、実際書き込んでいる量が少ない」みたいな、「どこかで詰まっているんじゃないかな」という処理によく出会います。

実際に期待するスループットが出ていないことがよくあります。(スライドを示して)こういうものをどうするかというと、自分の場合にはこうやっています。各チャネルの中にデータが入っているわけですが、チャネルの中に入っている現在のデータの量を一定時間ごとにログ出力、だいたい1分ぐらいに1回ぐらいのペースでログ出力しておきます。

デコード・整形処理部分がめちゃくちゃ詰まっている時には、その前のチャネルにめちゃくちゃデータが滞留していて、たいていは内容量のマックスに近い量になっていて、その次のところでは容量が0に近いことになっています。なので、処理で詰まっていることがチャネルからわかります。

この対処例はどうするかというと、詰まっているGoルーチンの数をさらに増やしたり、中が通信処理の場合には、相手先マイクロサービスで詰まっていないかを確認したりします。

もしCPUが100パーセントだったらプロセスの限界なので、プロセスのスケールアウトをするか、スケールアップを検討することになります。つまり、Goルーチンの1つのプロセスの中の世界じゃなくて、それより前のところ、受信処理のところでスケールアウトするか、そもそもたくさんのCPUが使えるインスタンスに移すかということをやったりします。

実際にこうやってトラブルなどを解決して、Goルーチン、チャネルを使ってストリーミング処理をしています。

Goにおける並列・並行処理のまとめ

森下:以上、Goの並列処理について話しました。Goルーチン、チャネルという軽量スレッドと、軽量スレッド間通信バッファが構文レベルで提供されていて、非常にすっきり記述できて、良いものになっています。

経験上、Goルーチンは1ミリセック以上なら、1処理で逐次起動してもかまいません。事前に起動しておいて、仕事を割り当てることを実装しなくてもよいです。

ストリーミング処理は、Goルーチンを起動しておいて、チャネルを使って処理を部分的に並列に実行できます。さらに、チャネルの内容量をログ出力しておくと、ストリーミングで詰まっている処理がわかります。こういったかたちのGoルーチンとチャネルで、並列処理を実装しています。

私からは並列の基本のところと、Goの並列処理について話しました。

質疑応答

司会者:森下さん、ありがとうございました。質問がいくつかSlidoに来ていますね。ありがとうございます。なるほど。なにが一番答えやすいかな。けっこう長文もあります。上からいきますか?

森下:いいねがたくさんついていますね。「Goルーチンの実行が異なるコアに効率良く振り分けられないと、あまり良い並列性は得られない気がしたのですが、ランタイムにそれを伝えるオプションなどはあるのでしょうか?」。

本当に……ないんですよ(笑)。確かにそうなんですが、Go自体にそれはなくて、「すべてGoのランタイムに任せなさい」ということになっています。前職では、CPUが複数載っているマシンでCPUを100パーセント使いたい時には、どちらのCPUに割り当てるかみたいな制御したりしていました。

今時たくさんのCPUを使う処理においてスケールさせようと思うと、どちらかというと、プロセスの数をたくさん増やそうという概念になります。最近だとコンテナをたくさん増やして、たくさんプロセスが走るようにすることが多いので、1プロセスに対してCPUをどう割り振るかというのは、あまり問題にならないことのほうが多いかなと感じています。

せいぜい8CPUとか16CPUぐらいのものをたくさん立てましょうということが多いので、実際にはないのですが、今のところよほどチューニングが必要な環境でない限り、困ることはないというのが回答になると思います。

司会者:ありがとうございます。時間の都合もあるので、あと1個だけ回答するようにしますか? いかがでしょう?

森下:「Goルーチンがスレッド管理よりも軽量な理由が載った書籍などはありますか?」。実際にかつて自分が調べたことがあるのですが、書籍では出てはいないとは思います。

「チャネルで分割した場合、タスクのキャンセルや停止時にチャネルに溜まった処理を消費しきるなど実装の工夫が必要に見えますがどんな工夫をしていますか?」

そういったGraceful Shutdownみたいなのは、けっこう気を配らなきゃいけなくて、チャネルをクローズできて、クローズすると受け取り側はそのチャネルに新しく入れられませんが、0になるまで取り合いをし続けられるようになるので、その機能を使ってGraceful Shutdownを実装したりしています。

司会者:ありがとうございます。すべての質問に答えられずすみませんが、森下さんのパートを以上としたいと思います。ありがとうございます。

森下:ありがとうございました。