goroutineについて調べたところ、その良さについてしっかりと理解するためには、低レイヤの知識がないと理解できないなと思ったので、ネットや本を読んでのインプットと並行してこの記事にアウトプットとして調べたことを基礎的な内容になりますがまとめました。「goroutineとは何か」から入り、そこから掘り下げます。
goroutineとは?
goroutineとは、他のメソッドや関数と並行処理されるメソッドや関数のこと。Goのランタイムに管理される軽量なスレッドとも言うことができ、goroutineを作成するコストはスレッドに比べてずっと小さい。
ここで、スレッドってなんだっけ、と思って調べると、プロセス・仮想アドレス空間・ヒープ・スタック・・・と聞いたことはあるけどよく知らない言葉がたくさん出てきて深みにハマり混乱しました。
整理できたので、順番に解説します。
スレッドって?
CPUは一つ一つの命令を順番に読み込み、解釈しながら実行する。メモリ内で、CPUに実行される命令の列をスレッドという。CPU利用の単位。
プロセスには最低一つのスレッドがあり、CPUはそのスレッドを追い続けながら命令を実行していく。
プロセスに比べて、プログラムを実行するときのコンテキスト情報が最小で済むので切り替えが速くなる。プロセス内に作られる並列動作可能な「処理の単位」。
マルチスレッド
プロセスはスレッドに含まれます。プロセスに複数のスレッドを持たせることもでき、これをマルチスレッドと言います。
複数のスレッドがあるということからも想像できる通り、すべてのスレッドにはスレッド優先順位が割り当てられ、スレッドの実行は優先順位に基づいてスケジュールされます。スケジュールされるということは、スレッドのスイッチングが発生するということです。このスレッドのスイッチングはプロセスと比べるとずっと早いものの、goroutineよりは時間がかかります。後述しますが、goroutineはスイッチングコストが非常に少ないので、これはgoroutineのスレッドに対する優位性の一つです。
プロセスとは?
プロセスとは、メモリ上で実行されているプログラムのことで、プログラムの実行単位。プロセスには必ず、プロセスごとにユニークなプロセスIDがある。これがプロセスの識別子として使われる。
メモ帳アプリでメモを取ったり、表計算アプリでの計算などPCで行える全ての処理はいずれもコンピュータへの命令が実行されて実現しており、そのような実行中のプログラム(コンピュータへの命令の実行手順)一つ一つがプロセスである。一つのアプリでも、複数のプロセスで動くことがある。
プロセスは、OSによる「リソース」割り当ての単位でもある。例えばメモリーは、プロセスごとに管理され、プロセスはそれぞれに専用のメモリ領域を割り当てられる。そのメモリ領域を仮想アドレス空間という。仮想アドレス空間の内部構造は図で後ほど示します。
子プロセス
プロセスからは、さらにプロセスを起動できる。あるプロセスから起動されたプロセスは子プロセスと呼び、起動した側を「親プロセス」と呼ぶ。プロセスにはこのような親子関係があり、セキュリティーの設定や管理などに利用されている。しかし親子関係のあるプロセス同士の場合でも、お互いに別の仮想アドレス空間を使う。また、実行のタイミングもそれぞれ違っている。
ほとんどのプロセスはすでに存在している別のプロセスから作成された子プロセスとなっている。Go言語で、プロセスIDと親プロセスのIDを知るためのメソッドがosパッケージで用意されている。
package main
import (
"fmt"
"os"
)
func main() {
fmt.Printf("プロセスID: %d\n", os.Getpid())
fmt.Printf("親プロセスID: %d\n", os.Getppid())
}
コンテキストスイッチ
CPUは瞬間的に1つの処理しかできないため、同時に複数の処理をしているように見せるために実行するプログラムを超高速で切り替えている。プロセスを切り替えることをコンテキストスイッチと呼ぶ。
コンテキストスイッチでは、その時点でのCPU内部の状態(レジスタやフラッグの値)を保存して、別のプログラムを実行していたときのCPUの状態に戻すということが行われる。このコンテキストスイッチは時間がかかる高コストな作業。1
このコストをなんとかするべくスレッドが発達した。同プロセス内のスレッドアドレス空間を共有するので、プロセスよりはスケジュールコストが低い。しかし、スレッドのスイッチングも無視できないコストがかかるため、スイッチングコストが非常に低いgoroutineが有用。
プロセス>>>スレッド>>>>>>>>>>...>>>>>>>>>goroutine(>の数はイメージです)
プロセスのコンテキストスイッチングについての詳しい説明はこちらの記事をお読みください。
仮想アドレス空間とは?
仮想アドレス空間は,プロセスごとに割り当てられた論理的なメモリー領域で,実行しているプロセスごとに独立して複数存在する。仮想アドレス空間は「ユーザー空間」と「カーネル空間」の2つに分けられる。2つに分けられている理由は、アプリケーションの誤作動によりシステム全体がダウンしてしまうことを防ぐため。
*ちなみに、論理的というのは「見かけ上の」とか、「(実物/ハードウェアとは異なる具合に)ソフトウェア的に解釈した」と読み替えてしまって問題ありません。
ユーザー空間
ユーザー空間は,ユーザー・アプリケーションのプロセスが動作する際に利用されるメモリー領域。
カーネル空間
一方,カーネル空間はシステム全体を制御する際に利用されるメモリー領域。カーネル空間には,さまざまな制御情報,各種キャッシュや共有メモリーなどの各プロセスが共有する情報,カーネル自身が格納される。
プロセスのアドレス空間
ここで、後述するといったプロセス一つ一つに割り当てられる仮想アドレス空間の内部構造について説明します。
これがプログラムがメモリにロードされた時のアドレス空間になります。スレッドは図中のテキストセグメントと呼ばれる部分にあります。上が0番地、下がFFFFFFFF番地です。
データセグメント
データセグメントはデータを格納する領域で、さらに3つの領域に分けらる。
- 定数領域
- 静的変数領域
- ヒープ
定数領域、静的変数領域はそのままなので説明な特に不要かと思います。ヒープは、プログラムの実行中に動的に確保される領域です。例えばC言語のmalloc関数やJavaなどの言語ではnew
でインスタンス化したものがここに収められます。不要になったオブジェクトは言語によってはdelete
関数で削除されたり、ガーベージコレクションがある言語ではそれによって適切に廃棄され、ヒープを解放して枯渇するのを防ぎます。
テキストセグメント
CPUは機械語で書かれたプログラムをメモリから読み込んで実行していく。スレッドはCPUに実行される命令の列。テキストセグメントはプログラムがロードされる領域。すなわちテキストセグメントは命令の列、スレッドが格納される領域である。図にあるように、スレッドはアドレス空間を共有することができ、その分スレッドはプロセスよりもスケジュールのコストが低い。
スタックセグメント
- テキストセグメントと違い、スタックセグメントはスレッドの数だけ用意される(複数スレッドで共有することができない)
- プログラムの実行時に、一時的に記憶しておくデータを格納するために動的に使用される(引数やリターンアドレスなど)
- FILO(First In Last Out)方式でデータの格納と取り出しが行われる
Goでのデータの割り当て
Goではヒープやスタックの扱いなどがどうなっているのか気になりますね。Goにもガベージコレクションがあります。丁度そこらへんについて記述してある記事がトレンド入りしていましたので、引用させていただきました。
Goでは変数や関数をメモリに割り当てる際に、コンパイラがスタック領域かヒープ領域への割り当てを決定します。
スタック領域への割り当てと解放は軽い処理ですが、ヒープ領域への割り当てと解放は重い処理です。
スタック領域はそれぞれCPUへの命令のみで可能なのに対して、ヒープ領域はmalloc関数(割り当て)とGC(解放)を定期的に実行する必要があるからです。
したがって、コンパイラはまずはスタック領域への割り当てが可能かを検討し、無理であればヒープ領域への割り当てを実施します。
GoのGCを10分で学ぼうより引用
goroutineとスレッドの違い
goroutineはスレッドに対して以下のような利点を持ちます。
- スタックサイズが少なく済むため、メモリ消費量が少ないだけでなく、スタックサイズを柔軟に変更することも可能(プログラマからは隠蔽されている)
- スイッチングに要する時間が少ない
- OSにリソースのリクエストをする必要がないため、その分生成と破棄にかかる時間が少ない
メモリ消費量
先ほど述べたスタックセグメントですが、Linux/x86-32では2MB、Windowsでは1MB、Macでは512kbがデフォルトとなっているようです。
環境 | デフォルトサイズ |
---|---|
Linux | 2MB |
Windows | 1MB |
Mac | 512kb |
goroutine | 大体2~5?kb |
つまり、Linuxサーバ上でスレッドを1000個生成したらそれだけで2GBメモリを使うことになります。
それに対し、goroutineを使うとこのスタックセグメントが数キロバイトで済むのが利点となっています。そのため、一つのアドレス空間に数十万ものgoroutineを作ることもできます。もしこれをスレッドで実現しようとしたら数十万に到達するよりずっと早くシステムリソースが枯渇してしまいます。https://golang.org/doc/faq#goroutines より
スタックセグメントはスレッドの数に合わせてスレッドのために用意されるものです。goroutineを使うと、このスタックセグメントの容量を数百分の一にまで小さく抑えることができます。
それに加え、スレッドの場合はスタックのサイズを事前に指定する必要があり、その後は変更することができないのに対し、goroutineは必要に応じて自動で適切なサイズにスタックサイズを調節することができます。
生成と破棄
また、スレッドはOSにリクエストを投げてレスポンスが返ってくるまで待つので生成と破棄に時間がかかる一方、goroutineの生成と破棄はランタイムによってなされますが、非常に低コストです。
goroutineとスレッドの違いについての詳細はこのページとこのページを参照してください。
最後に
ここまででgoroutineについての理解を深めるため、低レイヤ周りについて書いてきました。この記事に書いたのはごく基礎的な内容でこれ以外にも知らなければいけないことはたくさんありますが、ここら辺が頭に入っていればgoroutineに関する記事の説明もよりすっと頭に入ってくるのではないかと思います。
例えば、goroutineはメモリ消費量がスレッドと比べてずっと少ないと書きましたが、なぜgoroutineではそれほどメモリ消費量を抑えることができるのかという部分については以下の記事にその説明があります。記事内のスタックマネジメントという部分です。本記事で書いてある知識があればすんなりと理解できるのではないかと思います。
https://postd.cc/performance-without-the-event-loop/
今回goroutineをきっかけに本記事にまとめたようなことについて調べた結果、システムプログラミングなどを通して言語の違いによって左右されないプログラミングの根底にある部分についての理解を深めようという気持ちが強くなりました。低レイヤはちゃんと勉強して、知識がついたらまた記事にしようと思います。
最後までお読みいただき、ありがとうございました。
参考
基礎からわかるTCP/IPネットワークコンピューティング入門 松山公保[著]
https://tech.nikkeibp.co.jp/it/article/Keyword/20070207/261211/
https://tech.nikkeibp.co.jp/it/article/lecture/20070824/280260/
https://qiita.com/Kohei909Otsuka/items/26be74de803d195b37bd
https://christina04.hatenablog.com/entry/why-goroutine-is-good
http://sairoutine.hatenablog.com/entry/2017/12/02/182827#f-f0f467e9
https://golangbot.com/goroutines/
https://gihyo.jp/dev/serial/01/jvm-arc/0002
https://codeburst.io/why-goroutines-are-not-lightweight-threads-7c460c1f155f
https://medium.com/eureka-engineering/goroutine-3c92f566dcc5?
-
プロセスが異なるとアドレス空間が異なる。プロセスを切り替えるためには論理アドレスと物理アドレスのアドレス変換テーブルを入れ替える必要があるため、プロセスを切り替えるコンテキストスイッチは時間がかかる。 ↩