静かなる名辞

pythonとプログラミングのこと



C言語でshellの多段パイプを実装

はじめに

 学校の課題でCでshellもどきを書きました。

 今後、同じ目にあう人のために、「shellの多段パイプをどうやって実装したら良いのか」を記事としてまとめておきます。

 目次

パイプの概要

 shellのパイプとは……という話はさすがに要らないと思いますが、以下のような機能があります。なお、カレントディレクトリに今回の記事で紹介するCのコードtest.cを置いてあるとします(内容については後述します)。

$ cat test.c | head | grep char
char *cmd1[] = {"cat", "test.c", NULL};
char *cmd2[] = {"head", NULL};
char *cmd3[] = {"grep", "char", NULL};
char **cmds[] = {cmd1, cmd2, cmd3};

 「cat test.c」はtest.cの内容を標準出力に吐き出します。が、今回はその出力はパイプによって「head」に繋がれます。「head」は入力の先頭10行を出力するコマンドです。その出力も「grep char」の入力に繋がれて、先頭10行の中でcharにマッチする行だけが出てきます。

 C言語のコードでこれと同じものが動くことが今回の目標です。

使用する関数

 shellのパイプ機能を実装するために最低限必要な関数を示します。なお、上の例のtest.cで察した方もおられるかと思いますが、コマンドの入力やパースは今回省略し、あくまで「実行するとパイプでコマンドをつないで一回動作するプログラム」を作ります。

 更に、エラー処理等も省略し、コードを極力シンプルな形になるまで削ぎ落としてあります。ヘッダファイルは「unistd.h」だけincludeすればコンパイルできます。使う関数はたった6種類です。

 以下に使用する関数の簡単な説明を記述します。あくまでも簡単な説明なので、ちゃんとした説明が必要ならmanなどを読んでください。

int pipe(int pipefd[2])

 名前無しパイプを生成します。pipefdは配列のアドレスを渡し、ファイルディスクリプタを受け取ります。pipefd[1]にデータを書き込むとpipefd[0]から読み出せます。

int close(int fd)

 引数に渡されたファイルディスクリプタを閉じます。

 パイプをうまく機能させようと思うと、必要のないファイルディスクリプタは片っ端から閉じておく必要があります。無駄に開いている読み出し口や書き込み口があると、入力が終わってもEOFが返されません。するとパイプで繋がれたプログラムが終了しないので、いつまでも待ち続ける羽目になります。必要なものだけ開いた状態にするのが鉄則です。

int dup2(int oldfd, int newfd)

 newfdをoldfdのコピーとして作成します。

 と急に言われても何をするのかよくわかりませんが、上で説明したpipe()でパイプを作っておいたとして、

dup2(pipefd[1], 1);

 で標準出力をパイプの書き込み口に繋ぐことができ、同様に

dup2(pipefd[0], 0);

 とすれば標準入力をパイプの読み出し口に繋ぐことができる、ということだけ覚えておけば、今回は十分です。

pid_t fork(void)

 プロセスをforkします。返り値はpid_tという型ですが、これはただの整数型です。forkが成功した場合、pid_tが0なら子プロセス、0以外(実際には子プロセスのPID)なら親プロセスです。失敗すると-1が返ります。

pid_t wait(int *status)

 子プロセスが返るのを待ちます。成功した場合、返り値は子プロセスのPIDで、statusに終了情報が格納されます。

int execvp(const char *file, char *const argv[])

 コマンドを実行します。第一引数はコマンドの文字列、第二引数は引数の配列でNULL終端とする必要があります。

 要するに、

char *cmd1[] = {"ls", NULL};

 と定義しておけば、

execvp(cmd1[0], cmd1);

 でlsが実行できます。

パイプの実装方針

 さて、シェルのパイプを作ることを考えます。ここで考え込んでもあまり良いアイデアは浮かんでこないので、とりあえず実際のコマンドを見ます。

$ cat test.c | head | grep char

 では3つのコマンドを2つのパイプで繋いでいます。要するにコマンド数-1のパイプを作れば良い訳です。

 ということは、パイプを配列で管理するのかな? と一瞬思いますが、それでも確かにできるのですが、ちょっと煩雑そうです。

 もう少し簡単にする方法はないでしょうか? あります。再帰を使います。

 まずメインのプロセスからforkして、パイプを作り、更にforkします。親はstdinをパイプに繋いで右端(右から0番目)のコマンドの実行、子はstdoutをパイプに繋いで更にパイプを作ってforkして、今度は親になった先程の子が右端から1番目のコマンドを実行、子はまたforkしてパイプを作り……と繰り返していって、左端のコマンドに達したら単に実行して終わりです。この手続きは再帰的に行えます。

 「なぜ素直に左端からforkしないの?」と疑問を持つ方もいると思いますが、実は左から始めてもパイプそのものはできます。ただし、execしてしまうとプロセスの制御は呼び出し元に戻ってきません。execの中でexitされると思ってください。

 なので、左端からforkすると左端のコマンドが終了した段階でメインプロセス側のwaitが返り、他のコマンドがまだ実行途中であっても制御が戻ってしまいます。実際にやるとわかりますが、出力の途中でプロンプトが出てきたりして、ちょっと不格好な結果になります。右端からforkすれば、右端のコマンドは最後に終了するので、確実にメインプロセス側でコマンドの終了を検知できます。左端からforkする方法でこの問題を回避しようとすると、何らかの制御手段を追加する必要があります。

 左端からやろうと右端からやろうと、execしてしまう以上、途中では親が子をwaitできないことに違いはありません。ゾンビにならないの? と思うかもしれませんが、この場合は最終的に親が死ぬので、initが引き取ってくれてゾンビになりません。大本の親だけ回収しておけば、それほど気にする必要はありません。

 説明だけ読んでいてもどうなっているのかよくわからないと思うので、図にしてみました。

パイプ実行時のforkの流れ
パイプ実行時のforkの流れ

 この図は上から下に実行されていると思ってください。分岐はfork、合流はwaitでプロセスを看取っていることを示します。分岐の左側がforkの親で、右側が子です。

 cmd3はメインプロセスが看取ります。省略していますが、cmd1とcmd2はcmd3が看取られた後にinitが看取ります。

コード

 実際に書いたコードを以下に示します。上の説明はこのコードを書いてから起こしたものなので、ここまでの内容を読んだ方であれば簡単に理解できると思います。50行ちょっとなので読みやすいはずです。

test.c

#include <unistd.h>

char *cmd1[] = {"cat", "test.c", NULL};
char *cmd2[] = {"head", NULL};
char *cmd3[] = {"grep", "char", NULL};
char **cmds[] = {cmd1, cmd2, cmd3};
int cmd_n = 3;

void dopipes(i) {
  pid_t ret;
  int pp[2] = {};
  if (i == cmd_n - 1) {
    // 左端なら単にexecvp
    execvp(cmds[0][0], cmds[0]);
  }
  else {
    // 左端以外ならpipeしてforkして親が実行、子が再帰
    pipe(pp);
    ret = fork();

    if (ret == 0) {
      // 子プロセスならパイプをstdoutにdup2してdopipes(i+1)で再帰し、
      // 次のforkで親になった側が右からi+1番目のコマンドを実行
      close(pp[0]);
      dup2(pp[1], 1);
      close(pp[1]);
      
      dopipes(i+1);
    }
    else {
      // 親プロセスならパイプをstdinにdup2して、
      // 右からi番目のコマンドを実行
      close(pp[1]);
      dup2(pp[0], 0);
      close(pp[0]);
      
      execvp(cmds[cmd_n-i-1][0], cmds[cmd_n-i-1]);
    }
  }  
}

int main(void) {
  pid_t ret;
  
  ret = fork();
  if (ret == 0)
    dopipes(0);
  else
    wait(NULL);

  return 0;
}

 コンパイルして実行すると(実行ファイルはソースコードと同一ディレクトリに置いてください)、

char *cmd1[] = {"cat", "test.c", NULL};
char *cmd2[] = {"head", NULL};
char *cmd3[] = {"grep", "char", NULL};
char **cmds[] = {cmd1, cmd2, cmd3};

 と最初のshellから打ち込んだパイプコマンドと同じ結果が出力されます。

改良した方が良い点

 とりあえず、システムコールは失敗することもあり得るので、ちゃんとエラー処理しましょう。この記事ではわかりやすさを重視してすべて端折っていますが、

 あとはdup2に関してですが、

// stdoutにdup2
close(pp[0]);
dup2(pp[1], 1);
close(pp[1]);

// ------

// stdinにdup2
close(pp[1]);
dup2(pp[0], 0);
close(pp[0]);

 dup2の際にnewfdが開いていれば勝手に閉じられます。これに失敗する可能性があり、その場合エラー情報は握りつぶされます。なので、stdinとstdoutも明示的に閉じてエラー処理をした方が良いとされます。

まとめ

 これでパイプを実装しないといけなくなっても大丈夫!

参考にしたサイト

シェルの多段パイプを自作してみる | 慶應義塾大学ロボット技術研究会
 こちらは配列で管理する方法でパイプを実装しています。

linux上で動くシェルを自作しています。多段階のパイプを実装方法を教... - Yahoo!知恵袋
 結構ヒントになりました。ここの回答をコードに起こしたようなものでした。