(この記事は Why you can have millions of Goroutines but only thousands of Java Threads の翻訳です)

経験のあるエンジニアならば JVM 言語で次のようなエラーを見たことがあるでしょう。

[error] (run-main-0) java.lang.OutOfMemoryError: unable to create native thread:
[error] java.lang.OutOfMemoryError: unable to create native thread:
[error] 	at java.base/java.lang.Thread.start0(Native Method)
[error] 	at java.base/java.lang.Thread.start(Thread.java:813)
...
[error] 	at java.base/java.lang.Thread.run(Thread.java:844)

OutOfMemory …スレッド作成の失敗。Linux が動いている私のノート PC では、だいたい 11,500 スレッドを作成した辺りでこのエラーが発生します。

Go で無限に sleep する Goroutine を作成すると、これとは大きく違う結果になります。私のノート PC では 70,000,000 の Goroutine を作れましたが、飽きたのでそこでやめてしまいました。なぜ Goroutine はスレッドよりもたくさん作れるのでしょう? この答えを探るためには OS を理解する必要があります。また、これは学術的な問題ではなく、実際に使われるソフトウェアをデザインするための問題です。私は本番環境で JVM スレッドの限界にぶつかった経験が何度もあります。問題のあるコードがスレッドを解放させ損ねたこともあるし、単にエンジニアが JVM スレッドの限界について認知していなかったこともあります。

スレッドとは何でしょう?

“スレッド” という言葉は文脈によって違うものを指すことがあります。この記事では、論理的なスレッドを指すことにします。つまり、順々に行われる一連の処理のことをスレッドと呼びます。CPU はコアあたりにひとつの論理的なスレッドのみを実行できます。コア数を超えるスレッドがある場合、いくつかのスレッドは他のスレッドが仕事をするために一時停止し、また順番が来たら仕事を再開をするというように振る舞います。一時停止と再開の機能を実現するため、スレッドには少なくともふたつのものが求められます。

  1. ある種のインストラクションポインタ。つまり「一時停止時にどこの行を実行していたか」の情報。
  2. スタック。つまり、現在の状態を保存しておくためのもの。スタックにはローカル変数とヒープに保存された変数へのポインタが含まれる。プロセス内のすべてのスレッドはひとつのヒープを共有する。

これらの情報から、CPU スケジューラはスレッドを一時停止するために十分な情報を取得し、他のスレッドを走らせ、その後に元のスレッドを再開させます。この処理はふつうはスレッドからは完全に透過的です。スレッドの観点からみれば、自身は連続的に動作しています。スレッドが自分の一時停止を観測するためには、続きの処理が行われるまでの時間を計測するしかありません。

元の問に戻りましょう: なぜ多数の Goroutine を作ることができるのか?

JVM は OS のスレッドを使っている

仕様で定められているわけではありませんが、私の知る限り、全ての現代的な JVM はスレッディングを可能な限り OS のスレッドに委譲します。ここから先は “ユーザー空間スレッド” という言葉でプログラミング言語がスケジュールするスレッドを表現します。OS が作るスレッドと区別するためです。OS のスレッドは作成可能な数を制限する特性がふたつあります。プログラミング言語が作るスレッドと OS のスレッドを 1:1 で割り当てる方法では大規模な並列性をサポートできません。

JVM の問題: 固定長のスタックサイズ

OS のスレッドを使うことで、スレッドあたり固定長の大きなメモリを消費する

OS のスレッドに関するふたつ目の問題は、スタックサイズが固定長であることです。スタックサイズそのものは設定可能で、64 ビット環境での JVM デフォルトのスレッドあたりのスタック長は 1MB になります。デフォルトのスタックサイズを小さくすることはできますが、スタックオーバーフローのリスクとのトレードオフになります。再帰の多いコードであれば、よりスタックオーバーフローに遭遇する確率が上がるでしょう。デフォルト値のままであれば、1k 個のスレッドは 1GB の RAM を使用することになります! 最近は RAM は安くなりましたが、それでも数百万のスレッドを動かすために必要となるであろうテラバイト単位の RAM を持っている人はほぼいないでしょう。

Go のアプローチ: 動的なスタックサイズ

Go は大きな (かつ、ほとんど使われない) スタックのせいでメモリ不足に陥ることを巧みに避けています。Go のスタックサイズは保存するデータに応じて動的に伸縮します。これは簡単なことではなく、設計のためにいくつかのディスカッションがありました。実装の詳細に立ち入ることはしませんが (それだけで記事として成立してしまうし、すでに何人かに書かれている話でもあります)、端的にいうと新しい Goroutine はたかだか 4KB のスタックを持つことになります。ひとつのスタックあたり 4KB の容量なので、250万もの Goroutine を 1GB の RAM に収められます。ひとつのスレッドあたりに 1MB を要する Java と比べると大きな違いです。

JVM の問題: コンテキストスイッチの遅延

OS のスレッドはコンテキストスイッチの遅さから、数万件程度に制限される

JVM は OS のスレッドを使うので、OS カーネルのスケジューラに頼ることになります。OS は実行中のプロセスとスレッドのリストを持ち、それぞれに “公平な” 時間を CPU に割り当てようとします。カーネルがスレッドを切り替えるときに必要な処理は膨大です。新しいスレッドやプロセスは、他のスレッドが同じ CPU で動いているという事実を隠蔽する抽象化レイヤの中で起動します。ここでは重箱の隅をつつくことは避けますが、興味があればさらに調べることもできます。もっとも重要なことはコンテキストスイッチには 1~100 マイクロ秒くらいの時間がかかるといことです。それほどではないと思うかもしれませんが、コンテキストスイッチあたり 10 マイクロ秒かかるということは、1秒で全てのスレッドに処理させようとするとたかだか 1 コアの CPU に 100k スレッドしか走らせられないということになります。しかも、スレッドが実際の仕事をする時間は換算されていません。

Go のアプローチ: ひとつの OS スレッドの上で複数の Goroutine を動かす

Go は OS で多数の Goroutine を動かすために独自のスケジューラを実装しています。仮に Go がカーネルと同じようにコンテキストスイッチをするとしても、そのために リング0 にスイッチする必要性を割くことで膨大な時間を節約できるでしょう。これだけではありません。100万もの Goroutine をサポートするために、Go はさらに洗練されたことをしています。

仮に Java のスレッドがユーザー空間で動くものだとしましょう。それでも数100万ものスレッドを動かすことはできないでしょう。少し立ち止まって、新しいシステムではスレッドのスイッチにたかだか 100 ナノ秒かかるとしましょう。もし全ての時間をコンテキストスイッチに割くとしても、概算で1秒あたり100万スレッドに10回ずつしか機会を与えられません。これだけで CPU を使い切ることになります。真に大規模な並列処理を実現するにはさらなる最適化が必要です。それは、意味のある仕事をするスレッドにのみ時間を与えるということです! そんなに多くのスレッドが動いているのであっても、意味のある仕事をしているものは一握りでしょう。Go はチャネルとスケジューラを統合することでこの最適化を実現します。Goroutine が空のチャネルで待つようであれば、スケジューラはそれを感知し、その Goroutine を実行しません。さらに Go はヒマな Goroutine を自身の OS のスレッドに留めます。このことにより、(願わくばずっと少ない数の) アクティブな Goroutine がひとつのスレッドに割り当てられ、大多数のヒマな Goroutine が別に管理されます。こうすることで遅延がぐっと少なくなります。

Java のスケジューラが環境を観測できるようにならない限り、かしこいスケジューリング機能の実現は不可能でしょう。一方で “ユーザー空間で” いつスレッドが仕事をできるかを管理するランタイムスケジューラを作ることは可能です。これは Akka のように数万のアクターをサポートするフレームワークの基礎となります。

終わりに

OS のスレッドを使うモデルから、ユーザー空間で動く軽量なスレッドモデルへの移行はこれまでも行われてきたことで、未来でもこのトレンドは変わらないでしょう。大規模な並列処理が必要な場合、これ以外の手段はありません。ただし、それ相応の複雑さも伴います。Go が独自のスケジューラと動的なスタックサイズを使わず、OS のスレッドを使うことを選択していたら、数千行のコードをランタイムから削除することができたでしょう。多くの場合、軽量スレッドはよいモデルです。複雑さは言語やライブラリ作者によって抽象化され、ソフトウェアエンジニアは大規模な並列プログラムを書くことができます。

Leah Alpert さんには、この記事の草稿を読んでいただき感謝しています。