きっかけ
コンピューターサイエンスの初学者です。現在通っている学校で、C言語でbashのパイプ"|"を再現してみるという課題があったのですが、いくら解説サイトを読んでも、見本のソースを見ても、なぜこのようなコードになるのかいまいちピンと来ませんでした。しかし1週間ほどソースとにらめっこしていたところ、分からないような少し分かったような気になってきたので記事にまとめてみたいと思います。間違っている記載がありましたらご教示いただけますと幸いです。
前提
今回再現したいのは、配列としてコマンドが送られてきたとき、それらが"|"でつながっているものとして実行し、その結果を標準出力に書き出す、というものです。
以下のようなbashのコマンドがあるとします。
コマンド: echo "abcabc" | cat | sed "s/a/x/g"
実行結果: xbcxbc
これを今回作成するソースで実行する際、このように3つの要素を持つリストとして送られて来るイメージです。
- 1コマンド目 → "echo" "abcabc"
- 2コマンド目 → "cat"
- 3コマンド目 → "sed" "s/a/x/g"
今回使用する関数は以下の"pipe"、"fork"、"dup2"、"execvp"です。
pipeとはなんぞや?
int pipe(int pipefd[2]);
パイプとは、プロセス間でのデータのやり取りをできるようにするための仕組みです。Linuxでは、通常のファイルの入出力と同じように操作できるよう、これを「無名の特殊ファイル」として実装しています。 pipe関数は引数に2つの要素を持つint型の配列が必要です。実行すると、新しくパイプを作成してその配列に以下のファイルディスクリプタを設定してくれます。
- fd[0] → 読み取り口のファイルディスクリプタ
- fd[1] → 書き込み口のファイルディスクリプタ
成功時は戻り値として0を、失敗時は-1を返します。
forkとはなんぞや?
pid_t fork(void);
呼び出し元のプロセスを複製して新しいプロセスを生成する関数です。生成された子プロセスは、fork()を実行した瞬間から親プロセスより分裂して実行されます。戻り値のpid_tは、親プロセスの場合には子プロセスのプロセスIDが、子プロセスの場合は0が、fork()に失敗した場合-1が返ってきます。
子プロセスは親プロセスの持つ変数とは別の領域に変数を確保します。しかし、ファイルディスクリプタの対応関係はそのままコピーされます。そのため、親と子は「同じパイプ」「同じファイル」を指すファイルディスクリプタを持つことになります。そのためどちらかのプロセスで読み書きすると、その結果はもう一方からも観測できます。ただし、ファイルディスクリプタの番号自体はコピーされているだけなので、親と子で独立して close() する必要があります。
dupとはなんぞや?
int dup(int oldfd);
int dup2(int oldfd, int newfd);
dup()は引数として受け取ったファイルディスクリプタoldfdのコピーを作成して、最も小さい番号の未使用のファイルディスクリプタを返す関数です。成功した場合、古いファイルディスクリプタと新しいファイルディスクリプタは同じファイルを読み書きする関係になります。どちらかで読み書きすると、もう一方にもその影響が反映されます。
dup2()はdup()とほぼ同じですが、コピーとして新たに使用するファイルディスクリプタを第2引数で指定することができます。ファイルディスクリプタ newfd が使用されていた場合には、そのファイルディスクリプタをクローズしてから再利用します。
補足として、ファイルディスクリプタはプロセスが持つ単なる番号であり、実際にどのパイプやファイルにつながっているかという情報は、OS側で管理されているそうです。dup() や dup2() が複製するファイルディスクリプタはその同じOS内部の管理情報を参照する関係になります。
execvpとはなんぞや?
int execvp(const char *file, char *const argv[]);
第1引数fileで指定された文字列をコマンドとして解釈して実行します。指定されたファイル名にスラッシュ "/" が含まれていない場合、実行可能ファイルを環境変数PATHから検索して実行します。その際のパラメタとしてargvを使用します。
execvpが呼ばれた時、プロセスは新しいプログラムに置き換わり、execvp より後ろに書いたprintfなどの処理はエラーがない限り実行されません。そのためfork()で子プロセスに分裂させてから実行する必要が出てきます。
1コマンド目
初期状態
各プロセスはファイルディスクリプタ 0 (STDIN)と 1 (STDOUT)、 2 (STDERR)を持っています。分かりづらいので以降はSTDERRと親の標準入出力は省略します。

PIPE実行後
pipe()実行後にできたプロセスIDを仮に
- fd[0] (読み取り口) = 3
- fd[1] (書き込み口) = 4
FORK実行
fork()すると子プロセスは親プロセスと同じファイルディスクリプタテーブルのコピーを持つことになります。もちろん、 0 (STDIN)と 1 (STDOUT)も持っています。今回できた子プロセスを子Aとします。

子処理
パイプは書き込み口が1つでも開いたままだと、読み取り側は EOF を受け取れずブロックし続けるため、不要な書き込み口はすべてclose()する必要があります。これからexecvpで呼び出す処理のためにも余分なファイルディスクリプタを開放してあげたいと思います。子プロセスAで使用しないパイプAの読み取り口 3 はclose()してしまいます。
dup2(4, STDOUT);
上記のようにdup2を行うとファイルディスクリプタ 4 が複製され、ファイルディスクリプタ 1 (STDOUT、すなわち番号1)と同じ参照先を持つようになり、このプロセスの結果の書き込み先がパイプAになります。
パイプAの書き込み口はファイルディスクリプタ 1 として保持するようになったので、ファイルディスクリプタ 4 はclose()してしまいます。

親処理
親処理側ではパイプAの書き込み口 4 は使用しないのでclose()し、wait()やwaitpid()で子プロセスの終了を待ちます。
子Aのexecvp()でコマンド呼び出しを行った結果の文字列(今回はabcabc)がパイプAのバッファに書き込まれ、それを読み取るための口がファイルディスクリプタ 3 として親プロセスに残ります。パイプAの中の実行結果は読み取りが行われるまで保持されます。
※ここでは処理の流れを分かりやすくするため、各コマンドごとに親プロセスで wait() を行っています

2コマンド目以降
初期状態
前回のコマンド実行後の結果がファイルディスクリプタ 3 で読み取れる状態になっています。

PIPE実行
前回のものとは別に、新たにパイプBを作成します。
pipe()実行後にできたプロセスIDを仮に
- fd[0] (読み取り口) = 5
- fd[1] (書き込み口) = 6
FORK実行
子プロセスは親プロセスと同じファイルディスクリプタテーブルのコピーを持つので、新しく作られたパイプBの読み取り口と書き込み口に加えて、前回のパイプAとつながるファイルディスクリプタ 3 も同様にコピーされます。今回できた子プロセスを子Bとします。

子処理Ⅰ
今回は最初のコマンドとは異なり、データの読み取り口を前回のコマンドの実行結果にしてあげる必要があります。
dup2(3, STDIN);
上記の処理を実行するとファイルディスクリプタ 3 が複製され、ファイルディスクリプタ 0 (STDIN)と紐づき、このプロセスのデータの読み取り口が前回のパイプAになります。
パイプAの読み取り口はファイルディスクリプタ 0 を使用するようになったので、ファイルディスクリプタ 3 はclose()してしまいます。

子処理Ⅱ
こちらは最初のコマンドの子処理と同様です。
子プロセスB側で使用しないファイルディスクリプタ 5 はclose()してしまいます。
dup2(6, STDOUT);
上記のようにdup2を行うとこのプロセスBの結果の書き込み先がパイプBになります。ファイルディスクリプタ 6 は複製済みのためclose()してしまいます。

親処理
親処理側ではパイプAの読み込み口 3 やパイプBの書き込み口 6 は使用しないのでclose()します。
子Bの処理でexecvp()を行った結果がパイプBの中に入り、ファイルディスクリプタ 5 として保持されています。パイプBの中の実行結果は読み取りが行われるまで保持されます。

並び替えると...
1つ目のパイプAから2つめの子プロセスBを通じて2つ目のパイプBにつながる処理の繋がりができました。2コマンド目以降の処理を繰り返せばより長いコマンドの繋がりを作ることもできます。最後のコマンドでは、子処理の出力先を新しいパイプに接続せず、そのまま標準出力に書き出すことで、結果が画面に表示されます。処理の最後でパイプの閉じ忘れの無いように気をつけてください。

参考
・ふつうのLinuxプログラミング 第2版
・Operating Systems. Exercise 02
・Linux Programmer's Manual (2) DUP
・manページ — EXEC

