プロセス・パイプ・リダイレクション・ファイルディスクリプタの実体を見に行く

プロセス置き換えとか、名前付きパイプとか、とても便利な機能なのだけど、その仕組みはどうなっているのだろう?断片的な知識ばかりでは、その核心にはなかなか辿り着けない。サンプルコードの真似はできるけど、それ以上の発想はできない...。もっと根本的なところからちゃんと理解しておかないと、いつまでたってもコマンドの使い方の本質が理解できないと感じた。プロセスとは何か?パイプとは何か?リダイレクションとは何か?ファイルディスクリプタとは何か?可能な限りその本質を探ってみようと思う。

UNIXのプロセス

  • UNIXでは、複数のユーザーがログインした状態で、同時に複数の処理を依頼される状況が多々ある。
  • ところが、どんなに高性能なCPUであっても、ある瞬間に処理できるのはたった1つの処理だけである。
  • そんな時OSは、それぞれの処理に必要なメモリ領域を割り当てて、CPUをタイミングよく切り替えながら同時並行的に処理を進めるのだ。
  • CPUの処理速度に対して、ファイルやネットワーク等へのアクセスは非常に遅く、CPUには長い待ち時間が発生してしまう。
  • その待ち時間を利用して、効率よく他の処理に切り替えて、処理を進めているのだ。(マルチタスク
  • ミリ秒単位の時間であっても、動作周波数1GHzのCPUにとっては100万ステップの処理が実行できる。
  • 一瞬の待ち時間を有効に活用することで、人間の目にはまるで同時に処理されているように見えるのだ。
  • そのようにしてCPUを切り替えながら実行する処理の1単位をプロセスと呼んでいる。
  • 例えば、あるコマンドを実行すると...
    • ファイルに保存された実行コードがメモリに読み込まれて、
    • その処理に必要なメモリ領域が確保されて、
    • CPUの利用時間を割り振られる。
  • それがプロセスとして認識されているものの実体である。
  • OSはプロセスに重複しない番号を割り当て、プロセス管理テーブルに必要な情報を記録しながら、管理する。

プロセスの状態

  • 基本的にはCPUの待ち時間を見計らってマルチタスクを実現できると無駄がないのだが、
  • プロセスによってはCPUの待ち時間がほとんど発生しない状況もありうる。
  • CPUの待ち時間がなくても、OSは一定時間経過後プロセスを一時停止して、OSに必要な処理を実行する。
  • また、その他のプロセスにも処理に見合ったCPU利用時間を割り当て、マルチタスクを実現しようとする。
  • そうしないと、仮に1分間フルにCPUを使い続けるプロセスがあったとすると、それ以外の処理が完全に止まってしまうので。
  • 以上のことを考えると、プロセスには大きく分けて3つの状態があることが分かる。
Running 実行状態 OSからCPUの利用時間を割り当てられて、実行されている状態
Waiting 待ち状態 外部機器へのアクセス等のため、待ち時間が発生して、OSからCPUの利用をブロック(除外)されている状態
Ready 実行可能状態 いつでも実行可能であり、OSからCPUの利用時間が割り当てられるのを待っている状態

プロセスの外部アクセス

  • それぞれのプロセスは完全に独立した存在であり、OSに割り当てられたメモリ領域以外へのアクセスは禁止されている。
  • ところで、プロセスが活動するためには、情報の入力と出力が必要である。
    • キーボードからの情報を受け取って、処理の結果を画面へ出力したり、
    • あるいはファイルから読み込んで、ファイルに出力するかもしれない。
  • CPUが外部機器と情報をやり取りするには、外部機器と連動した特殊なメモリ領域(I/O領域)へアクセスすることで実現している。
    • 画面出力はVRAM領域に書き込んだり、キー入力はキーに反応するI/O領域を読み取ることで可能になる。
    • そもそもCPUが外部とつながる手段は、メモリ領域しかないのだ。
  • しかし、UNIXにおいては、ユーザーのプロセスがI/O領域を直接アクセスすることはできない。
    • そもそもユーザーのプロセスに割り当てられるメモリ領域に、I/O領域は含まれない。
  • そのため、OSにはドライバという、外部機器にアクセスするためのプログラムが組み込まれている。
  • そのドライバを経由して、外部機器と情報をやり取りするのだ。
  • ドライバは、外部機器固有の取り扱い方法の差異を吸収してくれる。
  • ドライバによって、すべての外部機器は、一般的なファイルと同じ方法でアクセス可能になるのだ。
    • 具体的には、デバイスファイル(/dev/*)を利用することで、外部機器にアクセスできる。
  • しかし、プロセスはOSに割り当てられたメモリ領域しかアクセスできないので、ファイルアクセスさえも許可されていない。
  • 今やファイルアクセスさえできれば、すべての外部機器へアクセスできるというのに、プロセスにはそれができない...。
  • 実はファイルアクセスはOSだけに許可された特権である。
  • それではプロセスは、どうやってファイルアクセスを実現しているのか?
  • プロセスは、OSにファイルアクセスを依頼しているのだ。

OS経由のファイルアクセス

  • 以下のコマンドは、hello world という出力を、hello.txt というファイルに書き込む。
$ echo hello world > hello.txt
  • しかし、echoコマンドが直接ファイルに書き込んでいる訳ではない。追跡してみる。
子プロセスの生成
  • シェルは、子プロセスの生成をOSに依頼する。

依頼を受けると...

  • OSは、シェル自身をコピーした子プロセスを生成する。(フォーク)
リダイレクト処理
  • シェルは、echoコマンドの実行に先立って、子プロセスのファイルディスクリプタ1番の出力を、hello.txtファイルへ向けるよう、OSに依頼する。

依頼を受けると...

  • OSは、hello.txtのファイルパスとか、どこまで読み書きしたか等のファイルの管理情報メモを用意する。(オープンする)
    • ちなみに、ファイルの管理情報メモのことを、オープンファイル記述(open file description)と呼ぶ。
  • OSは、ファイルの管理情報メモを番号で管理する。(仮にここでは5番で管理する)
  • この番号こそが、ファイルディスクリプタである。
  • OSは、リダイレクト指定に従って、ファイルディスクリプタ1番の管理情報メモに、ファイルディスクリプタ5番(hello.txt)の管理情報メモをコピーする。
  • 以降OSは、ファイルディスクリプタ1番への出力依頼に対して、hello.txtへ書き込むことになる。
  • OSは、hello.txtのファイルディスクリプタ5番と紐づく管理情報メモを破棄する。(クローズする)
  • OSは、以上の作業を完了して、シェルに戻る。
コマンドの実行
  • シェルは、echoコマンドの実行をOSに依頼する。

依頼を受けると...

  • OSは、子プロセスをechoコマンドに置き換えて実行する。
ファイルへ出力

依頼を受けると...

  • OSは、ファイルディスクリプタ1番の管理情報メモを見て、hello.txtへ書き込む。

デフォルトの入力と出力

  • シェルを起動した時、デフォルトで以下のファイルディスクリプタがオープンしている。
ファイルディスクリプタ 設定先
標準入力 0番 キーボード
標準出力 1番 画面
標準エラー 2番 画面
  • ファイルディスクリプタの0番、1番、2番は、特別なファイルディスクリプタである。
  • それぞれ、標準入力(stdin)、標準出力(stdout)、標準エラー(stderr)、と呼ばれている。
  • コマンドは、引数または標準入力からデータを読み込んで、実行結果を標準出力へ出力し、エラーが発生したら標準エラーへ出力する、という仕組みになっている。
  • よって、標準のファイルディスクリプタの0番、1番、2番を閉じてしまうと、コマンドを正常に実行できなくなる。
 $ echo abc >&-
 -bash: echo: write error: Bad file descriptor

 $ echo abc | cat <&-
 cat: stdin: Bad file descriptor
  • もし出力をすべて破棄したいのであれば、標準出力を閉じるのではなく、/dev/null へリダイレクションするのだ。
    • /dev/null は書き込まれたデータをすべて破棄する、特殊なデバイスファイル。
$ echo abc > /dev/null

コードの実装で見る仕組み

fork()の仕組み
  • fork()は、実行中のプロセスをコピーするそうである。
  • そしてコピー後...
    • 親プロセスには、コピーした子プロセスのPID(プロセスID)を返す。
    • 子プロセスには、0を返す。
    • エラーが発生したら、-1を返す。
  • fork()が正常に完了すると、親プロセスと子プロセスで、fork()の次のコードから実行が継続されることになる。
  • 実験してみる。
#include <stdio.h>

main()
{
	fork();
	printf("hello\n");
}
  • 上記コードを fork_test.c として保存して、
$ gcc fork_test.c
  • 実行してみると、
$ ./a.out
hello
hello

helloが2行表示された!

  • これは、fork()後、親プロセスと子プロセスで printf("hello\n"); がそれぞれ実行された結果である。
  • では、fork(); を3つにするとどうなるか?
#include <stdio.h>

main()
{
	fork();
	fork();
	fork();
	printf("hello\n");
}
$ gcc fork_test.c
$ ./a.out
hello
hello
hello
hello
hello
hello
hello
hello

今度はhelloが8行も表示された!

  • 何が起こっているのかと言えば...
    • 1回目のfork()(元プロセス×2)で、2プロセスになり、
    • 2回目のfork()(2プロセス×2)で、4プロセスになり、
    • 3回目のfork()(4プロセス×2)で、8プロセスになり、
    • 最後に8プロセスで printf("hello\n"); が実行され、helloが8行も表示されているのだ。
リダイレクトの内部処理
  • fork()の動きが理解できると、ファイルへリダイレクトする場合の内部の動きも分かりやすくなる。
  • echo hello world > hello.txt を実行した時は、内部では以下のような流れになっていると思われる。
# echo hello world > hello.txt の内部の動き(エラー処理なし)

pid = fork();                                         /* シェルが実行する時子プロセスをコピー */
if (pid == 0) {    /* 子プロセス */
    fd = open("hello.txt", O_CREAT | O_WRONLY, 0666); /* hello.txtをfdでオープン */
    dup2(fd, 1);                                      /* fdを標準入力にコピー */
    close(fd);                                        /* 以後fdは不要なのでクローズ */
    execl("/bin/echo", "hello", "world", NULL);       /* echo hello worldを実行 */
}
else {             /* 親プロセス */
    wait();                                           /* 子プロセスが終了するまで待ち */
}
パイプの内部処理
  • pipe()システムコールは、パイプを作って、fd[1]にパイプの入口、fd[0]にパイプの出口のファイルディスクリプタを返す。
  • パイプの場合は2つ以上のコマンドを実行するので、fork()も2回以上呼び出すことになる。
# echo hello world | wc の内部の動き(エラー処理なし)

int fd[2];
pipe(fd);                                       /* パイプを作成 (入口)fd[1] ==パイプ== fd[0](出口) */
if (fork() == 0) {     /* 出力側の子プロセス */
    dup2(fd[1], 1);                             /* パイプの入口を標準出力にコピー */
    close(fd[0]);                               /* 不要になったfdをクローズ */
    close(fd[1]);                               /* 不要になったfdをクローズ */
    execl("/bin/echo", "hello", "world", NULL); /* echo hello worldを実行 */
}
else
    if (fork() == 0) { /* 入力側の子プロセス */
        dup2(fd[0], 0);                         /* パイプの出口を標準入力にコピー */
        close(fd[0]);                           /* 不要になったfdをクローズ */
        close(fd[1]);                           /* 不要になったfdをクローズ */
        execl("/bin/wc", NULL);                 /* wcを実行 */
    }
    else {             /* 親プロセス */
        close(fd[0]);                           /* 不要になったfdをクローズ */
        close(fd[1]);                           /* 不要になったfdをクローズ */
        wait();                                 /* 子プロセスが終了するまで待ち */
        wait();                                 /* 子プロセスが終了するまで待ち */
    }
  • もしC言語しか使えなかったら、上記のようなコードを書いて、コンパイルして実行する必要がある。
  • コマンドやパイプやリダイレクションによって、1行で簡潔に表現して、コンパイルなしで実行できる。

コマンドって素晴らしい!


  • ここまでechoコマンドのサンプルで話を進めてきて、コメントされて気付いてしまった。
  • echoは内部コマンドなので、フォークしてないかも。
$ type -a echo
echo is a shell builtin
echo is /bin/echo
  • 調べてみると、自分の環境には内部コマンドechoと、/bin/echoの二つが存在している。
  • echo実行時、フォークされているのか、いないのか、どうやったら調べられるのだろう?


  • mkfifo fifo して、/bin/echo > fifo と echo > fifo を観察してみた。
$ mkfifo fifo
$ /bin/echo > fifo #観察が終わったらcontrol-Cで終了
$ echo > fifo      #観察が終わったらcontrol-Cで終了
  • /bin/echo > fifo の場合...PID 51470の子プロセスが生まれた。

  • echo > fifo の場合...何も生まれない。

やはり、内部コマンドはフォークされないのだ!

ルートプロセス

  • 子プロセスは、システムコールfork()によって、元のプロセス(親プロセス)をコピーすることで生成される。
  • ということは、親プロセスを辿って行くと、最初に作られる原点となるオリジナルなプロセスがあるはずだ。
  • これは全くそのとおりであり、アクティビティモニタで簡単に確認できる。
  • 表示を「すべてのプロセス(階層表示)」でたった2つのプロセスになる。


  • kernel_taskはOSのプロセスである。OSが提供する様々なサービスを担っている。
  • ユーザーのプロセスの原点には、プロセスの実行を管理するプロセスlaunchdがいる。
  • 例えば、ターミナルでechoコマンドを実行する場合は、以下のようなプロセス階層が出来上がっているのだ。
    • launchd >> launchd >> ターミナル >> login >> bash >> echo
  • 原点のlaunchdは、OSとして必要なプロセスを管理するlaunchdである。
  • 二つ目のlaunchdは、OSXにログインした時のユーザー別のプロセスを管理するlaunchdである。

FM7の頃

  • 初めて使ったパソコンは、FM7だった。(new 7だったかも)
  • FM7は非常にシンプルな8ビットパソコンであった。
  • 電源オンで起動するのは、F-BASICのエディタである。
  • OSという概念のソフトウェアは添付されていないが...
  • 画面描画やデータレコーダ等にアクセスするAPIは用意されていた。(BIOSと呼ばれていた)
  • それらのAPIとF-BASICコマンドは、ほぼ1対1で対応していた。
  • よって、F-BASICコマンドしか使えないターミナルがイメージに近いと思う。
  • F-BASICだけを使っていれば、それは今で言うF-BASICシェルの環境である。
  • FM7においては、ユーザーは完璧な自由を持っていた。
  • すべてのメモリ領域へ、制限なくアクセスできる。
  • 外部機器と情報をやり取りする特殊なメモリ領域(IO領域)へのアクセスさえも自由だ。
  • 例えば、ピクセルと対応するVRAMに書き込めば、接続されたCRTモニタに何か描画されることになる。
  • 接続されたデータレコーダの再生音に反応するメモリを監視して読み取れば、ファイルをロードできる。
  • あらゆることが許されていたので、工夫次第でかなり高度なこともできた。
  • VRAMのオフセットアドレスを利用して、滑らかなスクロールを実現するとか、
  • データレコーダ経由で音声をサンプリングして、FM7でしゃべらせるとか。
  • しかし自由の代償として、マシン語コードに不具合があると、CPUは簡単に暴走する。
    • 暴走とは、マシン語コードの間違いから、CPUが想定外のコードの実行を続けてしまう状態である。
    • OSのフリーズのようなものだが、FM7にはOSがないのでCPUの暴走と表現するのだ。
  • また、マルチタスクという概念もなく、バックグラウンドで同時平行的に処理する仕組みはなかった。
    • 一旦ユーザー作成のマシン語コードの処理が始まったら、その処理が完了するまでずっと続くのだ。
    • そういえば、タイマー割り込みの機能はあったような気がする。


発見!

  • FD02番地のBit 2が1なら、2.03m秒ごとに割り込みが発生するのだ!
  • そしてタイマー割り込みであれば、FD03番地のBit 2が1になるのだ。
  • FD03番地を参照することで、その割り込みが何であるかが分かるのである。