Go 言語の goroutine と channel についての考察

  • 59
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

はじめに

Go 言語の醍醐味といえばやっぱり並列・並行プログラミングだと思います。もちろん、大抵のプログラミング言語でも実現可能です。Go 言語では、goroutine と channel を使って、比較的簡単にそれを実現可能です。ここでは、goroutine と channel とは何なのか、なぜ優れているのかを考察し、どういったアプリケーションを実装するのに Go 言語が向いているのかを考えてみます。

goroutine

goroutine (ゴルーチン) は Go ランタイムによって管理される軽量スレッドです。スレッドのようでスレッドでなく、coroutine のようで coroutine ではありません。しかし、スレッドで coroutine を実行するかのごとく利用することが可能で、かつそれを簡単な構文で実現できます。

channel

channel を使うと、オペレータ <- を使って goroutine 間で値の送受信ができます。channel にはどういう型の値を扱うのか、キューをいくつ設けるかを指定することができます。マルチスレッドプログラミングの難点である排他制御や同期化を、簡単な構文で容易に実現できます。

考察

goroutine とスレッド

まずはスレッドとの違いについて考えてみましょう。FAQ に Why goroutines instead of threads? という質問がありますが、要約すると「OS のシステムスレッドよりも小さなメモリ使用量で済む」と書いてあるように見えます。実際、スレッドのデフォルトスタックサイズは Windows だと 1 MB、Linux だと 2 MB なので、数キロバイトで済む goroutine に比べると、10 KB だとしても 100 〜 200 倍の違いがあります。

メモリ使用量が多くなり過ぎると、スワップが発生し性能低下を招くため、小さなメモリ使用量で済むというのは大きな利点だと思います。

例えば、C10K のように大量のリクエストを捌くアプリケーションサーバを Linux で作ることを考えると、これはかなり大きな差を生むでしょう。単純にリクエスト・パー・スレッドで実装する場合、10K × 2 MB = 20 GB のメモリが必要になります。最近のハードウェアでもちょっとつらいですね。これを goroutine で置き換えた場合、100 MB で済みます。最近のハードウェアなら楽勝ですね。

goroutine と coroutine

coroutine は大抵の OS ではファイバを使って実現できます。ファイバはスレッドほどはスタックメモリを使用しません。

しかし、ファイバはあくまで協調的マルチタスクであり、ひとつのスレッド上で擬似的に複数のタスクを実行するものなので、スレッドのように複数のタスクを並列実行することはできないので、複数の CPU
を搭載したハードウェアの能力を使い切ることができません。また、アリケーションサーバだと、リクエストをひとつずつしか処理できないことになり、サーバとしては致命的なので、結局スレッドを組み合わせて使用する必要が出てきます。

リクエストハンドリングの変遷

昔は process/request、thread/request なアプリケーションサーバが普通でしたが、C10K 問題が騒がれるようになってから、epoll/kqueue などを使ってシングルスレッドで I/O を多重化してイベント駆動でプログラムを組む、というアプローチが流行しました。nginx、Node.js もそのひとつですね。このアプローチは、あまり CPU を使わずに済むアプリケーションサーバでは絶大な効果を発揮すると思います。ベンチマークでよく使われる静的ファイル配信や echo サーバなど。

Go 言語の場合、epoll/kqueue などを自動的に使ってうまいことやってくれる、という風にはなっていません。普通に実装すると、goroutine/request になり、I/O は各 goroutine がそれぞれ実行することになるので、epoll/kqueue でやるより効率は落ちると思います。しかし、「サーバ書くなら epoll 使うべき」は、今でも正しいのかで触れられているように、メモリ使用量を気にしなければ thread/request も結構スケールするようなので、goroutine/request ならもっとよくスケールする気がします。

最近は SSL/TLS で通信経路を保護することが増えてきていると思いますが、SSL/TLS アクセラレータを別途設けない限り、SSL/TLS の暗号化・復号化処理はその処理量に比例して CPU を多く使います。並列性と応答性をうまくバランスさせて向上させるには、複数のスレッドによる多重化が必要になります。そうするとシングルスレッドで I/O を多重化させるだけでは不十分になるかもしれません。こういったケースでも、goroutine は GOMAXPROCS を上げることでうまく対応できる気がします。

まとめ

Go 言語の goroutine と channel をうまく使いこなすと、多くのクライアントからのリクエストのハンドリングにそれなりの CPU 性能が必要な場合にうまく対応できると思います。SSL/TLS アクセラレータ、音声・動画の交換器などに向いている気がします。

参考資料

  1. Why goroutines instead of threads?
  2. Windows: プロセスとスレッド
  3. Man page of PTHREAD_CREATE

和訳メモ

Why goroutines instead of threads?

goroutine は並行性(並列性ではない)を扱いやすくするための機能です。複数スレッドで独立した関数を多重化して coroutine のように実行するというアイデアは以前からありました。

ブロッキングするシステムコールを呼び出したときなどで、ある coroutine がブロックするとき、ランタイムは自動的に OS の同一システムスレッド上で他の coroutine が一緒にブロックされないように、実行可能な別のスレッド上に移動します。プログラマがこれを意識しない、これが重要な点です。goroutine の呼び出しコストは非常に小さく抑えることができます。そのコストは、数キロバイトほどのスタックメモリというオーバーヘッドとして現れます。

スタックを小さくするために、Go のランタイムはサイズ変更可能でサイズが制限されたスタックを使用します。新しくできたばかりの goroutine には数キロバイトが与えられます。大抵の場合これで十分です。不足すると、多くの goroutine が適切なメモリ容量で生きていられるように、ランタイムが自動的にスタックを格納するためのメモリを拡張(と縮小)を行います。このオーバーヘッドは、一回の関数呼び出しで平均3つの低コストなインストラクションで済みます。実際、同一アドレス空間で数十万個の goroutine を作成することもできます。もし goroutine がただのスレッドであれば、もっと少ない数でシステム資源が枯渇します。