Part of Putting the “You” in CPU: a rabbit hole into how your computer runs programs.
Chapter 6:
フォークと牛について話そう(Forks and Cows)
Edit on GitHub
最後の質問:どのようにしてここに到達したのか?最初のプロセスはどこから来たのか?
この記事はほぼ完成しています。最終ストレッチに入っています。ホームランを打つ寸前です。より良い未来に向かって進んでいます。そして、あなたがCPUアーキテクチャについての15,000ワードの記事を読んでいないときに、草を触るか何をしているのか、その他さまざまなひどい慣用句です。
もしexecveが現在のプロセスを置き換えて新しいプログラムを起動するのであれば、新しいプログラムを別々のプロセスで起動するにはどうすればいいのでしょうか?これは、コンピュータ上で複数のことをしたい場合に非常に重要な能力です。アプリをダブルクリックして起動すると、そのアプリは別に開かれ、以前のプログラムは引き続き実行されます。
答えは別のシステムコールです:fork、すべてのマルチプロセッシングに基本的なシステムコールです。forkはかなりシンプルで、実際には現在のプロセスとそのメモリをクローンし、保存された命令ポインタをそのままにして、両方のプロセスが通常通りに進行することを可能にします。介入しない場合、プログラムはお互いに独立して実行され、すべての計算が倍になります。
新しく実行されるプロセスは「子」と呼ばれ、forkを呼び出した最初のプロセスは「親」と呼ばれます。プロセスは複数回forkを呼び出すことができ、それにより複数の子プロセスを持つことができます。各子プロセスにはプロセスID(PID)が割り当てられ、1から始まります。
同じコードを無知に倍にすることはかなり無駄なので、forkは親と子で異なる値を返します。親では、新しい子プロセスのPIDを返し、子では0を返します。これにより、新しいプロセスで異なる作業を行うことができるため、forkingが実際に役立ちます。
pid_t pid = fork();
// Code continues from this point as usual, but now across
// two "identical" processes.
//
// Identical... except for the PID returned from fork!
//
// This is the only indicator to either program that they
// are not one of a kind.
if (pid == 0) {
// We're in the child.
// Do some computation and feed results to the parent!
} else {
// We're in the parent.
// Probably continue whatever we were doing before.
}
プロセスのフォークは少し頭を巻かせることがあります。これ以降、あなたがそれを理解したと仮定します。理解していない場合は、この見た目がひどいウェブサイト をチェックして、かなり良い説明を見てください。
とにかく、Unixプログラムは新しいプログラムを起動する際に fork を呼び出し、その後すぐに子プロセスで execve を実行します。これは fork-exec パターン と呼ばれています。プログラムを実行すると、コンピューターは次のようなコードを実行します:
pid_t pid = fork();
if (pid == 0) {
// Immediately replace the child process with the new program.
execve(...);
}
// Since we got here, the process didn't get replaced. We're in the parent!
// Helpfully, we also now have the PID of the new child process in the PID
// variable, if we ever need to kill it.
// Parent program continues here...
ムーーーー!
プロセスのメモリを複製して、すぐに異なるプログラムを読み込む際にそれをすべて破棄することが少し非効率的に聞こえるかもしれません。幸い、私たちはMMU(メモリ管理ユニット)を持っています。物理メモリ内でデータを複製するのが遅い部分であり、ページテーブルを複製しないだけです。したがって、RAMを複製しないのです。古いプロセスのページテーブルのコピーを新しいプロセスのために作成し、マッピングを同じ基盤の物理メモリを指すように保ちます。
しかし、子プロセスは親から独立して隔離されているべきです!子プロセスが親のメモリに書き込むこと、またその逆が許容されるわけではありません!
ここで登場するのがCOW(コピー・オン・ライト)ページです。COWページを使用すると、メモリに書き込もうとしない限り、両方のプロセスは同じ物理アドレスから読み取ります。どちらかがメモリに書き込もうとすると、そのページがRAM内でコピーされます。COWページにより、両方のプロセスがメモリの隔離を持つことができ、メモリ全体をクローンする前に前払い費用がかからないのです。これがfork-execパターンが効率的である理由です。新しいバイナリを読み込む前に古いプロセスのメモリが書き込まれないため、メモリのコピーが必要ありません。
COWは、多くの楽しいことと同様に、ページングのハックとハードウェア割り込み処理で実装されています。forkが親をクローンすると、両方のプロセスのすべてのページを読み取り専用としてフラグ付けします。プログラムがメモリに書き込もうとすると、メモリが読み取り専用であるため、書き込みに失敗します。これにより、セグフォルト(ハードウェア割り込みの種類)がトリガーされ、カーネルによって処理されます。カーネルはメモリを複製し、ページを書き込み可能に更新し、割り込みから復帰して書き込みを再試行します。
A: ノック、ノック!
B: 誰ですか?
A: 割り込み牛。
B: 割り込み牛は —
A: モーーー!
はじめに(創世記1:1ではないよ)
コンピュータ上のすべてのプロセスは、親プログラムによってフォークされ、実行されましたが、1つだけ例外があります:initプロセスです。initプロセスは、カーネルによって直接手動で設定されます。これは最初に実行されるユーザーランドプログラムであり、シャットダウン時には最後に終了します。
クールな瞬間のブラックスクリーンを見たいですか? macOSまたはLinuxを使用している場合は、作業を保存し、ターミナルを開いてinitプロセス(PID 1)を終了してみてください:
$ sudo kill 1
著者の注意: initプロセスに関する知識は、残念ながらmacOSやLinuxなどのUnix系システムにのみ適用されます。これから学ぶことのほとんどは、非常に異なるカーネルアーキテクチャを持つWindowsの理解には適用されません。
execveセクションと同様に、これについては明示的に言及しています — NTカーネルについて別の記事を書くこともできますが、今はそれを控えています。(今のところ)
initプロセスは、オペレーティングシステムを構成するすべてのプログラムとサービスを起動する責任を負っています。それらの多くは、さらに自分自身のサービスとプログラムを起動します。

initプロセスを終了させると、そのすべての子プロセスとその子プロセスが終了し、オペレーティングシステム環境がシャットダウンします。
カーネルに戻る
第3章でLinuxカーネルコードを見て楽しんだので、もう少し詳しく見てみましょう!今回は、カーネルがinitプロセスを起動する方法を見てみます。
コンピュータは次のようなシーケンスで起動します:
マザーボードには、接続されたディスクを検索し、ブートローダと呼ばれるプログラムを探す小さなソフトウェアがバンドルされています。それはブートローダを選び、そのマシンコードをRAMに読み込み、実行します。
OSが実行中ではないことに注意してください。OSカーネルがinitプロセスを起動するまで、マルチプロセッシングやシスコールは実際には存在しません。初期化前の状態では、プログラムを”実行”するとは、戻りの期待なしにRAM内のそのマシンコードに直接ジャンプすることを意味します。
ブートローダは、カーネルを見つけ、RAMに読み込み、実行する責任があります。一部のブートローダ、例えばGRUBのようなものは、設定可能で、複数のオペレーティングシステムから選択できます。BootXとWindows Boot Managerは、それぞれmacOSとWindowsの組み込みのブートローダです。
カーネルは今実行中であり、割り込みハンドラの設定、ドライバの読み込み、初期メモリマッピングの作成など、大規模な初期化タスクを開始します。最終的に、カーネルは特権レベルをユーザーモードに切り替え、initプログラムを起動します。
ついにオペレーティングシステムのユーザーランドにいます!initプログラムはinitスクリプトの実行、サービスの起動、シェル/UIのようなプログラムの実行を開始します。
Linuxの初期化
Linuxでは、ステップ3(カーネルの初期化の大部分)は、init/main.c内のstart_kernel関数で実行されます。この関数は、さまざまな他の初期化関数への200行以上の呼び出しで構成されているため、全体をこの記事に含めることはしませんが、スキャンをお勧めします! start_kernelの最後で、arch_call_rest_initという名前の関数が呼び出されます:
/* Do the rest non-__init'ed, we're now alive */
arch_call_rest_init();
非__init’edとは何ですか?
start_kernel関数はasmlinkage __visible void __init __no_sanitize_address start_kernel(void)と定義されています。__visible、__init、および__no_sanitize_addressなどの奇妙なキーワードは、Linuxカーネルで関数にさまざまなコードや動作を追加するために使用されるCプリプロセッサマクロです。この場合、
__initは、ブートプロセスが完了すると関数とそのデータをメモリから解放するようカーネルに指示するマクロであり、単にスペースを節約するためです。では、どのように機能するのでしょうか?詳細に立ち入らずに説明すると、Linuxカーネル自体がELFファイルとしてパッケージ化されています。
__initマクロは__section(".init.text")に展開され、これは通常の.textセクションの代わりにコードを.init.textセクションに配置するためのコンパイラディレクティブです。他のマクロもデータや定数を特別なイニシャライズセクションに配置できるようにします。たとえば、__initdataは__section(".init.data")に展開されます。
arch_call_rest_init は単なるラッパー関数です:
void __init __weak arch_call_rest_init(void)
{
rest_init();
}
コメントには「残りの部分は __init ではないものを実行してください」と書かれています。なぜなら rest_init は __init マクロで定義されていないからです。これは、初期化メモリのクリーンアップ時に解放されないことを意味します:
noinline void __ref rest_init(void)
{...}
rest_initは今や初期化プロセス用のスレッドを作成します:
/*
* We need to spawn init first so that it obtains pid 1, however
* the init task will end up wanting to create kthreads, which, if
* we schedule it before we create kthreadd, will OOPS.
*/
pid = user_mode_thread(kernel_init, NULL, CLONE_FS);
user_mode_thread に渡される kernel_init パラメータは、いくつかの初期化タスクを完了し、その後有効な初期化プログラムを検索して実行する関数です。この手順はいくつかの基本的なセットアップタスクから始まります。ほとんどの部分ではこれらをスキップしますが、free_initmem が呼び出される箇所は例外です。ここで、カーネルは私たちの .init セクションを解放します!
free_initmem();
今、カーネルは適切な初期化プログラムを実行できるようになりました:
/*
* We try each of these until one succeeds.
*
* The Bourne shell can be used instead of init if we are
* trying to recover a really broken machine.
*/
if (execute_command) {
ret = run_init_process(execute_command);
if (!ret)
return 0;
panic("Requested init %s failed (error %d).",
execute_command, ret);
}
if (CONFIG_DEFAULT_INIT[0] != '\0') {
ret = run_init_process(CONFIG_DEFAULT_INIT);
if (ret)
pr_err("Default init %s failed (error %d)\n",
CONFIG_DEFAULT_INIT, ret);
else
return 0;
}
if (!try_to_run_init_process("/sbin/init") ||
!try_to_run_init_process("/etc/init") ||
!try_to_run_init_process("/bin/init") ||
!try_to_run_init_process("/bin/sh"))
return 0;
panic("No working init found. Try passing init= option to kernel. "
"See Linux Documentation/admin-guide/init.rst for guidance.");
Linuxでは、initプログラムはほぼ常に/sbin/initに配置されているか、シンボリックリンクされています。一般的なinitプログラムには systemd(非常に優れたウェブサイトを持っています)、OpenRC、および runit が含まれます。kernel_init は他に見つからない場合にはデフォルトで /bin/sh を使用します — そして、/bin/sh が見つからない場合、何かが非常に問題があることを意味します。
MacOSにもinitプログラムがあります!それはlaunchdと呼ばれ、/sbin/launchdに配置されています。カーネルでないことに対して叱られたい場合、ターミナルでそれを実行してみてください。
ここから、ブートプロセスのステップ4に進みます:initプロセスはユーザーランドで実行され、フォーク-エグゼクパターンを使用してさまざまなプログラムを起動し始めます。
フォークメモリマッピング
Linuxカーネルがプロセスをフォークする際にメモリの下半分をどのようにリマップするのかについて興味を持ち、少し調査しました。kernel/fork.c は、プロセスのフォークに関するほとんどのコードが含まれているようです。このファイルの冒頭部分は、私に正しい場所を示す役に立ちました:
/*
* 'fork.c' contains the help-routines for the 'fork' system call
* (see also entry.S and others).
* Fork is rather simple, once you get the hang of it, but the memory
* management can be a bitch. See 'mm/memory.c': 'copy_page_range()'
*/
この copy_page_range 関数は、メモリマッピングに関する情報を取得し、ページテーブルをコピーするようです。この関数が呼び出す関数を大まかに見てみると、ここでページを読み取り専用に設定してCOW(Copy-On-Write)ページにする場所でもあるようです。これを行うかどうかは、is_cow_mapping という関数を呼び出すことで判断されます。
is_cow_mapping は、include/linux/mm.h で定義されており、メモリマッピングに関連する flags が、そのメモリが書き込み可能でプロセス間で共有されていないことを示す場合に true を返します。共有メモリは共有を前提としているため、COW する必要はありません。少し理解しづらいビットマスキングに感嘆しましょう:
static inline bool is_cow_mapping(vm_flags_t flags)
{
return (flags & (VM_SHARED | VM_MAYWRITE)) == VM_MAYWRITE;
}
kernel/fork.cのkernel/fork.cに戻りましょう。copy_page_rangeを検索すると、dup_mmap関数から1つの呼び出しを見つけることができます。そして、dup_mmapはさらにdup_mmから呼び出され、dup_mmはcopy_mmから呼び出されます。最終的には、巨大なcopy_process関数から呼び出されます! copy_processはフォーク機能の中心であり、ある意味でUnixシステムがプログラムを実行する中心点です - 常に最初のプロセスのために起動時に作成されたテンプレートをコピーして編集します。
まとめ…
それでは、プログラムはどのように実行されるのでしょうか?
最も低いレベルでは、プロセッサは単純です。メモリ内のポインタを持ち、指示を連続して実行します。指示が別の場所にジャンプするように指示されるまで、そのまま実行を続けます。
ジャンプ命令の他にも、ハードウェアおよびソフトウェアの割り込みが、実行のシーケンスを中断し、予め設定された場所にジャンプしてからどこにジャンプするかを選択できます。プロセッサコアは複数のプログラムを同時に実行できませんが、タイマーを使用して割り込みを繰り返しトリガーし、カーネルコードに異なるコードポインタ間で切り替えることでシミュレートできます。
プログラムは、自分たちが統一された孤立した単位として実行されているかのようにだまされています。ユーザーモードではシステムリソースへの直接アクセスが防止され、ページングを使用してメモリスペースが分離され、システムコールは真の実行コンテキストについてあまり知識を必要とせずに一般的なI/Oアクセスを許可するように設計されています。システムコールは、CPUにカーネルコードを実行するように要求する命令であり、その位置はカーネルが起動時に設定します。
しかし… プログラムはどのように実行されるのでしょうか?
コンピュータが起動すると、カーネルがinitプロセスを起動します。これは、その機械語が多くの具体的なシステムの詳細を心配する必要がない、抽象度の高い最初のプログラムです。initプログラムは、コンピュータのグラフィカルな環境をレンダリングし、他のソフトウェアを起動する責任があります。
プログラムを起動するために、initプロセスはforkシスコールを使用して自身を複製します。このクローンは効率的です。すべてのメモリページはCOW(Copy On Write)であり、メモリを物理RAM内でコピーする必要はありません。Linuxでは、これはcopy_process関数が動作しています。
両方のプロセスは、自身がフォークされたプロセスであるかどうかをチェックします。フォークされたプロセスである場合、新しいプログラムを起動するためにexecシスコールを使用してカーネルに現在のプロセスを新しいプログラムで置き換えるように要求します。
新しいプログラムはおそらくELFファイルであり、カーネルはプログラムをどのようにロードし、新しい仮想メモリマッピング内にコードとデータを配置するかの情報を解析します。カーネルは、プログラムが動的にリンクされている場合にはELFインタープリタを準備するかもしれません。
カーネルはプログラムの仮想メモリマッピングをロードし、プログラムが実行中であるということは、実際にはCPUの命令ポインタを新しいプログラムの仮想メモリ内のコードの開始位置に設定することを意味します。
Continue to Chapter 7: 最後に