【翻訳】goroutine の仕組み

訳者による概要

Krishna Sundarram 氏の記事「How Goroutines Work」の翻訳です。

「goroutine とは軽量スレッドである」という説明に対して抱くであろう 「どのようにして並行処理を実現しているのか」「既存のスレッド処理と何が違うのか」「なぜ軽量なのか」という疑問を解消する文章です。

とても良い文章なのですが、現在リンク切れになっており、 とてももったいないことだと思ったので、日本語に翻訳しました。

原文: How Goroutines Work (2017/12/02 現在、リンク切れ)

golang の紹介

もしあなたが golang 初心者で、並行処理(Concurrency)と並列処理(Parallelism)の違いがわからなければ、Rob Pike 氏のトーク (リンク先は英語)を参照してください。約 30 分のトークですが、30分視聴するだけの価値はあります。

違いを要約すると、「人が並行処理と聞いたときに想像するのは並列処理のことであり、それらは関連はしているがまったく別物である。平行処理とは、プロセス同士が独立して実行されることであり、並列処理はたくさんの計算を同時に実行すること(場合によっては互いに影響しあいながら)です」 並行処理とは一度にたくさんのことを扱うことであり、並列処理とは一度にたくさんのことを実行することです。*1

golang を使って、我々は並行処理プログラムを書くことができます。golang は、goroutine 及びそれら goroutine 同士が通信する仕組みを提供します。本文章では、前者の goroutine に焦点を当てます。

goroutine とスレッドの違い

Java がスレッドを使うように、golang では goroutine を使います。両者の違いは何でしょうか? この違いを語る上で 3つの要素があります。「メモリ消費量」「生成と破棄に要する時間」「スイッチングに要する時間」です。

メモリ消費量

goroutine は、メモリを大量に必要としません。スタック領域では 2kB しか消費せず、必要に応じてヒープ領域を割り当てたり開放したりします。*2*3一方でスレッドはスレッド間のメモリが干渉し合わないように「スタックガードページ」と呼ばれる 1Mb(goroutineの500倍) の領域の確保から始めます。*4

それゆえに、サーバーへリクエストがくるたびに、goroutine を生成するのは何も問題がないですが、スレッドでそれをやると、深刻な OutOfMemoryError が発生します。これは Java だけの制限ではなく、並行処理で OSのスレッド機構を使う全ての言語において、この問題に直面します。

生成と破棄に要する時間

スレッドは生成と破棄のたびに OS に要求を投げて、それが完了して返ってくるのを待つため時間がかかります。この問題を回避するには、一度生成したスレッドをスレッドプールに維持しておく必要があります。一方で goroutine では、生成と破棄に関する操作を非常に低コストで行うことができます。よって、golang では、goroutine を手動で管理する方法はサポートされていません。

スイッチングに要する時間

スレッドがブロックされると、別のスレッドがスケジューリングされます。 プリエンプティブ方式*5でスレッドがスケジューリングされ、スレッドの実行がスイッチされる際にスケジューラーは全てのレジスタ、つまり 16種類の汎用レジスタ、PC(プログラムカウンタ)、SP(スタックポインタ)、16種類の XMM レジスタ、 FP co-processor の状態、16種類の AVX レジスタ、そしてモデル固有のレジスタ etc... を別の場所に保存したり、保存したそれらをレジスタに戻す処理が必要になります。これはスレッド間で迅速にスイッチングしたい場合に、無視できません。

goroutine は協調してスケジューリングされ、スイッチングが発生したときも、たった 3つのレジスタ(PC、SP、DX)しか保存したり、レジスタに戻したりしません。これは非常に低いコストです。

以前に説明したように、goroutine の数は一般的にかなり多くの数になりますが、スイッチングにかかる時間に違いはありません。これには2つの理由があります。それは、実行可能な goroutine のみ考慮され、ブロックされた goroutine は考慮されないことと、また最近のスケジューラーは O(1) なため、実行可能な goroutine の数が、スイッチングの時間に影響を与えないからです。*6

goroutine がどのように実行されるか

前述したように、goroutine は生成から破棄までランライムが管理します。 ランタイムには goroutine が多重化された少数のスレッドが割り当てられます。 いつでも、それぞれのスレッドは1つの goroutine を実行します。goroutine がブロックされると、それはスワップアウトされ、別の goroutine が代わりにスレッドによって実行されます。*7

goroutine は協調してスケジューリングされるので、ループしつづける 1つの goroutine が、同一スレッド内の他の goroutine の実行を妨げることがあります。 Go 1.2 では、関数を実行する際に、Go のスケジューラーが呼び出されることがあるため、この問題は多少緩和されました。よって、ループ内でインライン関数でない関数を呼び出しておけば、他の goroutine がスケジューリングされます。

goroutine のブロック

goroutine は低コストであり、goroutine が以下の場合でブロックされた際に、それらが多重化されているスレッドのブロックを引き起こしません。

  1. ネットワーク入力
  2. sleep 処理
  3. channel 操作
  4. sync パッケージによるプリミティブのブロック

たとえ数万個の goroutine が生成され、それらがブロックされたとしても、 ランタイムが代わりに別の goroutine をスケジューリングするため、システムのリソースは無駄にしません。

簡単にいうと、goroutine はスレッドの軽量な抽象化です。 Go プログラマはスレッドを扱わず、また同様に OS は goroutine の存在を認識していません。OSから見れば、Go のプログラムは、イベント駆動の Cプログラムのように振る舞います。(脚注 6 参照)

スレッドとプロセッサ

ランタイムが生成するスレッドの数を直接制御することはできませんが、一方でプログラムが利用するCPUプロセッサの数を決めることはできます。 これは runtime.GOMAXPROCS(n) を呼び出して、変数 GOMAXPROCS を設定することで可能です。コアの数を増やしても、設計によっては必ずしもプログラムのパフォーマンスが上がるとは限りません。プロファイリングツールを利用して、プログラムの理想的なコア数を見つけることができます。

終わりに

他の言語と同様に、複数の goroutine によって、共有リソースの同時アクセスを防止することは大切です。goroutine 間でのデータのやり取りは、channel を通して行うのが最善です。共有メモリを通してデータのやり取りをするのではなく、代わりに channel を使って、メモリを共有します。*8

最後に、C. A. R. Hoare 氏による「Communicating Sequential Processes」を確認することを強くおすすめします。彼は本当の天才でした。1978年に発行されたこの論文で、彼はプロセッサの単一コアの性能が最終的に横ばいになり、チップメーカーは代わりにコア数を増やすことを予見しました。彼のプロポーザルの偉業は、Go 言語のデザインに深い影響を与えました。

*1:「Concurrency is not parallelism」by Rob Pike

*2:Effective Go: Goroutines

*3:goroutine のスタック上でのサイズは、Go 1.4 から、8kB -> 2kB に減りました。参考

*4:「Five things that make Go fast」 by Dave Cheney

*5:訳注 OSがプロセッサの実行権限を管理し,タスクの実行を切り替える方式

*6:Dmitry Vyukov 氏が、 golang-nuts グループにて gorouine のスケジューリングについて述べています。参考

*7:「Analysis of the Go runtime scheduler」 by Deshpande et al.

*8:Share Memory By Communicating