はじめに
普段goroutineを使っている人も、Java.Threadとの違いや、そもそもプロセスやOSがどのような仕組みで並列処理をサポートしているかについて知らない人も多いと思うので、それについて自分の理解を話し口調でわかりやすくまとめました。
UnixOSに限定された挙動を一般的なOSの挙動のように扱っている箇所や、厳密な言葉の定義に外れたりしている箇所がございましたら、コメントでご指摘いただけると幸いです。
そもそもプロセスとは何か
皆さんのパソコンでも複数のプロセスが起動していると思いますが、そもそもプロセスとは何なのか。
基本的には、プログラム実行の単位として、プロセスを定義しています。
というのも、マルチプログラミングにおいては、1つのCPUは特定のプログラムを実行することしかできない(PCをインクリメントすることによって一行ずつメモリから命令を読み込み、レジスタが実行するというコンピュータアーキテクチャの性質)という性質を克服するためには、実行したい複数のプログラムを管理する必要があったのです。
具体的には、どのプログラムを実行中で、どのプログラムはどこの行で中断していて、プログラムはどこのメモリ領域に退避させたのか。
という内容をOSが管理する必要があります。
この時に、それぞれのプログラムを区別する必要があったので、プロセス(またはタスク)という概念を導入しました。
それと同時に、プログラム側は、これらの仕組みを意識せず、自分専用のプロセッサが与えられたように振る舞って欲しいという設計から、Unixを含む多くのOSにおいて、プロセスに対して、ページを割り当てるという挙動をします。
※ もっと厳密に言えば、デファクトスタンダートなっている「demand paging」というスケジューリングアルゴリズムにおいては、プロセスを立ち上げた時には実メモリは確保せずに、仮想メモリ領域のみを確保します。実メモリが存在するかどうかの存在ビットというのを作ることによっています。
プログラムが存在ビットが0であるようなメモリ領域を参照したときに、page faultが起きることで、割り込みルーチンが走り、空きメモリ領域を割り当てるという挙動が走ります。
仮想ページングテーブルによって、こういった実メモリの節約や、補助記憶装置を用いたスワップの挙動を実現しています。
厳密には、実メモリが確保されるのはプログラム参照されるタイミングですが、メモリ領域自体が、プロセスごとに管理されています。
プロセスがkillされれば、そのプロセスに対応するようなメモリ領域も全て解放されます。
プロセスを構成するもの
- プロセッサ
- メモリ領域
- 開かれているプログラムファイル
- 親プロセスの情報
- 使用しているユーザーの情報
- プロセスの状態
プロセススケジューラは、プロセスの状態を確認することで、どのプロセスが実行中でどのプロセスが実行待ちかを管理することができるようになっている。
ここでは、プロセスの状態について、少しだけ説明を補足する。
Unix系のシステムでは、プロセスディスクリプターと呼ばれていて、特にその ステートメンバには、以下のような識別子を用いて管理されている。
- TASK_RUNNING: 実行可能な状態。CPUが空いていれば実行できる。
- TASK_UNINTERRUPTABLE: 割り込み不能な待ち状態。ディスクI/O待ちなど、短時間で復帰するもの。
- TASK_INTERRUPTABLE: 割り込み可能な待ち状態。ユーザの入力待ちなど、復帰時間が予測できないもの。
- TASK_STOPPED: 実行中断になった状態。リジュームされるまでスケジューリングされない。
- TASK_ZOMBIE: ゾンビプロセス。子プロセスがexitして親プロセスにwaitされていないもの。
ちなみに、一般にサーバーの負荷の指標として用いられているload_averageの定義は、
ready queueに入っているプロセスのうち、TASK_INTERRUPTABLE と TASK_UNINTERRUPTABLE のステートが割り振られているプロセスの数を物理CPUの数で割ったものである。
並列にプログラムを動かすために
親プロセスをforkすることで、並列に動く別のプロセスを生成する方法が提案された。
これが、子プロセスと呼ばれるプロセスである。
子プロセスを生成する際には、親プロセスをforkする。
forkという関数の挙動は、子プロセスようのメモリ領域を確保したのちに、親プロセスのデータセグメントに書き込まれた変数などをコピーして、同じものにする。
fork関数から復帰した時から、別々にプログラムが走るようにすることで、並列に動作させることができるようになる。
ただし、全く個々のプロセスではなく、子プロセスは親プロセスを把握しているし、プログラム領域はコピーせず同じ番地を参照する。
要点をまとめると、
- 親子で同じプログラムセグメントのプログラムを参照している
- データセグメントはコピーすることで生成され、それぞれ別のメモリを参照している。
子プロセスの課題
Unixなどの多くのOSにおけるプロセスでは、いくつかのパフォーマンス的なオーバーヘッドが課題として挙げられている。
- プロセスを生成する際に上記の構成を確保するために、メモリ領域を確保する必要がある。
- アドレス空間が別物であるので、メモリを共有することで、変数の受け渡しができない。プロセス間通信が必要になる。
- プロセスを切り替える際には仮想アドレスと物理アドレスのマッピングをしているMMU(Memory Management Unit)のキャッシュをクリアする必要がある
このような課題を解決するために、一つのプロセスが複数の処理を制御できるような仕組みであるスレッドが作られた。
スレッドの仕組み
一つのプロセスの中で動く仕組みがスレッドである。
そのために、プロセスもつメモリ領域の中に、スレッドのメモリ領域を作成する必要があるので、
- プログラムカウンタ
- スタックポインタ
- スタック領域のデータ
はスレッドに割り振られるのだが、
すでにプロセスによって、確保されているメモリの中に作成するので、新たに確保する必要がなく、
プロセス切り替えの必要もないので、コンテキストスイッチにかかるオーバーヘッドがとても小さくすむ。
また、プロセス間通信を用いなくても、メモリを共有することで通信を行うことができる。
これは、同一プロセス内に複数のスレッドを立てることで、プロセスのもつヒープ領域の変数には、全てのスレッドがアクセスできることに起因する。
その代わり、全ての変数がスレッドセーフであるように工夫しないと、意図しない変更が他のスレッドによって加えられることがあるので、注意が必要である。
スレッドの課題
スタックサイズが固定長
超大規模な並列処理を行いたいとすると、全てのスレッドに対して、スタック領域が必要になる。
スタック領域というのは、ローカル変数や、サブルーチン情報が書かれる領域で、普遍的なプログラムでは大きなメモリを消費しないのだが、固定長なものとして実装されている。
Linuxでは、デフォルトで、スレッド一つあたり2MBと設定されていて、変更することが可能であるが、減らしすぎると、スタックオーバーフローを起こしてしまう可能性が高くなってしまうので、減らすことも難しい。
仮に、2MBと仮定すると、たかが2000のスレッドで4GBのRAMを消費することになる。
スレッドの切り替えにコストがかかる
新たにスレッドを生成するのは、プロセスを生成するのに比べ、メモリ領域を割り当てる必要がないから、コストが低いという話をしたが、大規模な並列処理を行う場合は話が大きく変わる。
スレッドはOSの仕組みであり、OSカーネル上で動作するため、システムコール関数を発行する、完全なコンテキストスイッチが必要で、ここにコストがかかるため、スレッドの切り替えにもコストがかかりる。
仮に、スレッドの切り替えにかかるのが、100msだとすれば、一秒間に10万回しか切り替えが行えないことになる。
これが意味することは、10万のスレッドを動作させるのに、秒単位の遅れがでてしまうことだ。
goroutineの解決策
動的なスタックサイズ
スレッドでは、2MBで固定していた、スタックサイズであるが、goでは、動的に変わるため、最小のスタックサイズは2KBに設定されている。
これによって、単純計算で1000倍のメモリ効率でgoroutineは生成できることになる。
(参考)
https://medium.com/a-journey-with-go/go-how-does-the-goroutine-stack-size-evolve-447fc02085e5
https://blog.cloudflare.com/how-stacks-are-handled-in-go/
独自のスケジューラを持っている
goroutineのスケジューラはm:nスレッドを用いているため、ユーザーモードのまま、goroutineの切り替えができ、コンテキストスイッチの必要が無いので、
スレッドの再スケジュールより低コストにスケジューリング可能です。
また、gotoutineスケジューラはチャネルと統合されていて、待ち状態のgoroutineに対して、実行することはなく、最適にプロセッサを割り当てるような工夫がプログラムレベルで行われています。
結果
goroutineでは、これまでのスレッドによる並行処理では絶対に出せないようなパフォーマンスを実現しています。
goroutineと仲良くなれるように勉強を頑張ります。
(補足) メモリ領域の説明
スタック領域
一般にコールスタック・制御スタックと呼ばれている。LIFO方式で構成されプログラムの実行中サブルーチンの情報を記憶しておくメモリ領域。
サブルーチン終了後の戻りアドレスや局所変数などを保持する。
ヒープ領域
mallock関数によって、動的に確保することができる領域で、プロセスがメモリが足りない場合などに、必要に応じて新たにメモリを確保することができる。
動的にメモリ取得・解放を繰り返すことによりメモリ上にどこからも参照されない領域(ガベージ)が発生する。
テキストセグメント
プログラムファイルがオンメモリに乗っかる必要があるので、その領域。
プログラムカウンタの指し示す行をフェッチしてきて実行しするような挙動をとる。
データセグメント
プロセッサのデータエリア。
上記のプログラムカウンタや、アキュミュレータ上のメモリでは演算が遂行できない場合に用いられるスタックポインタなどが主に該当する。
(補足) コンテキストスイッチの挙動
コンテキストスイッチでは、実行中のプロセスの状態を何らかの方法で保存し、後にそのプロセスを再開する際にその状態を復元して、正常に実行を継続できるようにしなければならない。
プロセスの状態には、そのプロセスが使用し得る全てのレジスタ(特にプログラムカウンタ)や、プロセスの実行に必要となるオペレーティングシステム固有の情報が含まれる。多くの場合、これらのデータは1つのデータ構造として保存される。
プロセスを切り替えるためには、実行中のプロセスの状態を表すデータ構造を作成し、保存しなければならない。このデータは、カーネルメモリ上にあるプロセスごとに割り当てられるスタックか、あるいはオペレーティングシステムによって定義された固有のデータ構造に保存される。