Chapter 11

(おまけ)低レイヤの話 ~Linuxとの比較~

さき(H.Saki)
さき(H.Saki)
2021.06.18に更新

この章について

ここではおまけとして、Linuxカーネル内でタスクを扱う機構である

  • プロセス
  • スレッド
  • シグナル

について簡単にまとめて、それらとGoランタイムとの類似性について考察します。

プロセスとは

プロセスとは「実行されているプログラム」のことをいいます。
よくバイナリファイルと比較されて、「バイナリファイルはdormant(休眠中)のプログラムで、プロセスはrunning(実行中)のプログラム」ともいわれます。

また、「プロセス」としてプログラムを実行するために必要なのは、プログラムコードだけでは不十分です。メモリやCPUといったリソースを用意しなくてはいけません。
そのような「プログラムを実行するために必要なリソース群」も含めて「プロセス」と呼ぶことも多いです。

プロセスの実体

Linuxカーネルでは、プロセスの情報はtask_struct型構造体にまとめられています。
出典:Linux kernel source tree /include/linux/sched.h

プロセスがそれぞれ個別に持っているものとしては、以下のようなものがあります(一部抜粋)。

  • pid: プロセス識別のために与えられた一意のID
  • ppid: 親プロセスのpid
  • 状態: 実行中(running)、終了済み(terminated)などといった状態
  • ユーザー権限(uid) : このプロセスを実行する権限をもつユーザーのID
  • ユーザーグループ権限(gid) : プロセス実行権限をもつユーザーグループのID
  • バイナリイメージ: 実行しているプログラムのバイナリ
  • 仮想メモリ: バイナリイメージをロードするために仮想的に用意された、プロセス固有のメモリ空間。(task_struct型構造体におけるstruct mm_struct *mm;フィールドに該当)
  • ページテーブル: 各プロセスに与えられた仮想メモリのアドレスは、物理メモリのどこのアドレスに対応するのかをまとめたテーブル

プロセスの生成

プロセスは、オペレーティングシステムが実行ファイルを読み込んで実行するときに新しく作られます。

プロセス生成にあたり特筆すべき性質といえば、「全てのプロセスには親となるプロセスがある」ということです。
言い換えると、プロセスはinitプロセスを根とする木構造になっています。

あるプロセス(=親プロセス)が新しく別のプロセスを立ち上げたくなった場合は、親プロセスの中でforkシステムコールが呼ばれることで作成されます。
forkシステムコールの動作は「forkを呼んだプロセスと全く同じ中身のプロセス(=子プロセス)を新規作成する」というものです。
このままだと親のコピーがもう一つできるだけなので、新しく作られた子プロセスの中身をexecシステムコールを使って書き換えて、本来子プロセスにやらせたかった内容にしてやります。

スレッドとは

スレッドは、プロセスの中にある「並列可能なひとまとまりの命令処理」の単位のことです。

例えば「あるファイルに書いてある内容を読み込み、標準出力に書き出す」という内容のプロセスを考えます。
このプロセスの中には大きく分けて「ファイル内容の読み込み」と「標準出力への書き出し」という2つのタスク単位があります。
2つのタスク単位は独立できて、例えば「ファイル書き込みと標準出力の書き出しの間に、何か別のことをやったらプログラムがおかしくなる」なんてことは起こらないわけです。
そのため、このプロセスを「ファイルの中身を読む」というスレッドと「標準出力に書き込む」というスレッドに分割してやることで、CPUコアに仕事を割り当てるスケジューリングをより柔軟に行うことができるようになります。
このことから、「スレッドはスケジューラが扱うことができる処理実行単位のうち最小のもの」ともいうことができます。

1つのプロセスが複数のスレッドから構成されることもあり、いわばプロセスとスレッドは1:Nの関係であるともいえます。
1プロセスに1スレッドの場合を「シングルスレッド」、1プロセスに複数スレッドの場合を「マルチスレッド」と呼称します。

スレッドの実体

Linuxカーネルの中では、スレッドはtask_struct構造体で表されます。

「プロセスと同じ構造体?」と思った方は鋭いです。
実は、task_struct構造体の中身に違いがあるだけで、Linuxカーネル(=スケジューラ)にとっては「プロセス」も「スレッド」も変わらないもの、という捉え方となるのです。

task_struct構造体の中が、プロセスとスレッドでどう変わってくるのかについては、次の「スレッドの生成」で詳しく説明します。

スレッドの生成

シングルスレッドの場合、プロセスそのものが(メイン)スレッドそのものと捉えることができます。
シングルスレッドから新たなスレッドを作り、マルチスレッドに移行したい場合はcloneシステムコールを呼ぶことで新スレッドを作成しています。

プロセス作成の際に出てきたforkシステムコールと、cloneシステムコールの違いは、作成されるtask_struct構造体の中身に出ます。
それぞれ、

  • fork: task_struct型構造体フィールドを一から初期化
  • clone: task_struct型構造体のフィールドのうち、仮想メモリやページテーブルといった一部のコンテキスト[1]をコピーして作成

という違いがあります。
そしてこれこそが、「プロセス」と「スレッド」の最も大きな違いなのです。

プロセスがもつ仮想メモリは「そのプロセス固有」のものでした。そのため、プロセスは「与えられた仮想メモリを占有している」ような動作をすることができ、それゆえに「他のプロセスが自分が使用しているメモリに干渉してくるかもしれない」という心配をしなくてすむようになっています。
しかしスレッドは、cloneシステムコールから作られた結果「同じプロセスから生成されたスレッド全てで、その仮想メモリを共有する」という性質を持ちます。

スレッド導入の利点

プロセスとは別に、わざわざ「リソースを共有するプロセス」であるスレッドという概念を導入することでなんのメリットがあるのでしょうか。
考えられるメリットとしては2つあります。

  • メモリを節約
  • プロセス切り替えよりスレッド切り替えの方がコストが低い

前述した通り、同じプロセスから作られたスレッドはメモリ空間を共有するため、いちいちメモリを割り当てる必要がなくなりメモリ節約になります。
またメモリ空間の共有によって、CPUで実行するものをスケジューラが変更するときに必要なコンテキストスイッチのコストが低くて済みます。
具体的には、1つのプロセスから別のプロセスを実行するように切り替えを行う場合はプロセスがもつメモリデータの読み込みが必要になりますが、同じプロセス内の1つのスレッドから別のスレッドへ切り替えする場合にはそれが不要になります。

ユーザースレッドとカーネルスレッド

スレッドがユーザー空間上で実装されたものか、カーネル空間上で実装されたものかで、それぞれ「ユーザースレッド[2]」「カーネルスレッド」と種類が分かれています。

それぞれの違いは以下のようになります。

ユーザースレッド カーネルスレッド
ロードされるメモリ空間 ユーザー空間 カーネル空間
スケジューリングの管理 ユーザー空間上のプログラム OSカーネル
実体(Linuxの場合) task_struct構造体 task_struct構造体(mm_structフィールドがNULL)
実行モード ユーザーモードとカーネルモードを行き来する カーネルモード
役割 ユーザーが書いたプログラム システムコールの実際の処理やメモリ回収といったクリティカルで大事な処理

スレッドがロードされる空間が違うと、そのスレッドを管理するプログラムが違います。
カーネルスレッドはOS自身が管理して、OSがスレッドの作成、スケジューリングなどを行います。
ユーザースレッドはユーザー空間のプログラムが管理していて、スケジューリングはライブラリ内のスレッドスケジューラが行います。

カーネルスレッドは、展開されている空間上、Linuxカーネルのコードのみを実行し、固有のメモリ空間を持ちません。
そのため、スレッドの実体であるtask_struct構造体のmm_structフィールドが、ユーザースレッドはメモリ空間を示すフィールドで埋まっていて、カーネルスレッドは埋まっていない(NULL)という特徴があります。

ユーザースレッドのライブラリ

ユーザースレッドは、ユーザー自身がプログラム中で「スレッドを作る/分ける」ということを意識して書く「スレッドプログラミング」を行うときにも出てきます。
スレッドプログラミングを行うためのライブラリとして有名なのはPOSIX標準のpthreadです。

pthreadライブラリを用いて新たにスレッドを作成したとしても、内部的にはシステムコールcloneを呼んでおり、そのcloneの返り値がそのままpthreadライブラリ上でのスレッドとなります。

2つの使い分け

ユーザースレッドとカーネルスレッドの使い分けが行われる例として、システムコールの実行が挙げられます。
例えば、ユーザーが書いたプログラムがまずユーザースレッドの形で実行されます。
そして、そのユーザープログラムの内部で、システムコール(例: writeシステムコール)の呼び出しがなされていたとしましょう。
この時、内部的にはソフトウェア割り込みが行われ、ユーザースレッドからカーネルスレッドへのコンテキストスイッチがなされます。
カーネルスレッドはカーネルモード(特権モード)で実行されているので、システムコールの中身の処理(例: 指定アドレスのメモリの中身をある値に書き換える)をそのままここで行うことができます。
カーネルモードで行うことをやり終わったら、ユーザースレッドに切り替え直して、続きの処理を続けるということになります。

この例のように、ユーザースレッドとカーネルスレッドの間にはある種の対応関係があることがわかるかと思います。
この対応関係の種類については、OSによって3種類存在します。

1:1

ユーザー空間上でのスレッドと、カーネル空間上でのスレッドが1:1対応するパターンです。カーネルレベルスレッディングともいいます。

一つのカーネルスレッドで稼働するユーザースレッドが一つだけなので、ユーザースレッドでもOSが提供するスケジューリングシステムを利用することができます。
「OSのスケジューリングシステムが利用できる」ということはすなわち「マルチコアでも動かせるシステムを使う」ということなので、複数プロセッサでの稼働による本当の並列実行を行うこともできるようになります。

また、ユーザースレッドごとにカーネルスレッドが異なるということは、他スレッドのI/Oブロッキングに影響を受けることなく応答を早くすることができる、という特性もあります。

N:1

ユーザー空間上でのスレッドN個が、カーネル空間上でのスレッド1個に対応するものです。ユーザーレベルスレッディングともいいます。

ユーザースレッドとカーネルスレッドが1:1対応でないことから、OSのスケジューラを使うことはできず、スレッド実行のスケジューリング(=どのユーザースレッドをいつカーネルスレッドに実行させるか)の機構をユーザーレベルのライブラリで提供する必要があります。

どれだけユーザー空間上でスレッドを作ったとしても、カーネル空間上では1つにまとまってしまうので、マルチプロセッサでの並列処理というのは起こりえません。その代わりコンテキストスイッチのコストを削減することができます。

N:M

1:1での「真の並列性」と、N:1での「低コストなコンテキストスイッチ」を両取りするためのハイブリットスレッディングがこのN:M型です。
1:1とN:1のいいとこ取りができるかわりに、ユーザスレッドとカーネルスレッドのマッピングが複雑になります。

シグナルとは

シグナルとは、非同期イベントを扱うソフトウェア割り込み(Software Interrupt)の一種です。
「実行中のプロセスに割り込みをかけ、そのプロセスが行っている作業を停止させ、直ちに所定の動作を行わせる」という使い方をします。
プロセス同士でやり取りをするPIC通信の役割を果たしています。

シグナルの種類

主なシグナルとしては、以下のようなものがあります。

  • SIGINT: Ctrl+Cによるユーザー割り込み
  • SIGFPE: 不正な演算(0割りなど)が行われた
  • SIGKILL: 強制終了の指示
  • SIGTSTP: Ctrl+Zによる一時停止
  • etc...

シグナル受信時の挙動

シグナルが発せられたら(raised)、カーネルは受信側のプロセスがそれを処理する準備ができるまでそれを保持(stored)します。
そして、プロセスを処理する準備が整ったら、プロセス側でそのシグナルの番号を受け取った上でそれを処理(handle)します。

シグナルを受け取ったときに、プロセスがどのような挙動をするのかについては、大まかに3つに分類されます。

  • シグナルを無視する(SIGKILLSIGTOPは無視できない)
  • signal()システムコールによって事前登録されたハンドラ関数を実行する
  • デフォルト動作をする

GoランタイムとLinuxカーネルのざっくり比較

GoランタイムもLinuxカーネルも、それぞれ複数個存在するゴールーチン/スレッドをどう実行させていくかというのをコントロールする機構であると言えます。
似た役割を果たす両者の概念を並べ、ざっくりと比較してみると以下のようになります。

Linuxカーネル Goランタイム (補足)
プロセス Goプログラム本体
スレッド ゴールーチン スタックサイズや、優先度の有無といった細かい違いあり
シグナル チャネルによる通信 送信可能な情報の自由度が圧倒的に違う
ユーザースレッド:カーネルスレッド = 1:1 ゴールーチンG:スレッドM = N:M(多:多)

こうして比べてみると、それぞれ類似の概念を持っていながらも、対応している概念の性質がぴったり一致しているわけではないということがわかります。
スケジューリングやプリエンプトという仕組みについても、それを実行するアルゴリズムの違いこそあれど仕組みそのものは両者に存在しています。

カーネルもGoのランタイムも、両者「タスクを実行するためのリソースをどうやって割り当て、制御するか」という目的のもと作られているものなので、部品構成が似ているのは必然といえるでしょう。
しかし、「その部品の挙動」に共通点を見出そうとするのはおそらくナンセンスで、WindowsとMacのOSの挙動が違うように、LinuxカーネルとGoのランタイムもそれぞれ独自のやり方を採用したのだ、という認識がしっくりくるのではないでしょうか。

また、こうした独自のやり方を採用したことで生まれるエコシステム思想の違いも興味深い事柄として存在します。

例えば、異なるスレッド/ゴールーチン間でやり取りをする機構は、OSとGoランタイムではそれぞれシグナルとチャネルが該当します。
しかしシグナルは「ある種のシグナルを受け取った」ということしかスレッド側からわからないのに対し、チャネルを使ったやり取りでは、コードを書くユーザーがどんな型を作ってどんな値を渡すかということに関してとても大きな自由度があります。

このような性質の違いが「OSではメモリロックを躊躇わずに使う」「Goではチャネルによるコミュニケーションを好む」という思想の違いの源泉[3]のように筆者は思えてきます。

脚注
  1. メモリ以外に共有されるものは、pid、ppid、uid、gid、カレントディレクトリ位置、ファイルデスクリプタなどがあります。 ↩︎

  2. ユーザースレッドのうち、VM上で動いているものを特にグリーンスレッドといいます。 ↩︎

  3. もちろんOSとGoランタイムというレイヤの違いもこの違いに寄与していることは間違いないとは思いますが。 ↩︎