普段シェルを使っていると、次のようなコマンドによく出会います。
cat access.log | grep ERROR | wc -l
echo hello > out.txt
wc -l < input.txt
|はコマンド同士をつなぎ、>は出力先をファイルに変更し、<はファイルを入力元にします。
この記事では、Cで実装した簡易的なシェルのコードを使って、このようなパイプとリダイレクトがどのように実現されているかを見ていきます。
実装は全体で約200行なので、コード全体を見ながら本文を追える程度の規模です。
ソースコードは以下にあります。
ファイルディスクリプタ
ファイルディスクリプタ(fd)は次のように定義されています。
“A per-process unique, non-negative integer used to identify an open file”
POSIX.1-2024 — File Descriptor
fdは、プロセスが開いているファイルやパイプなどを扱うための整数です。
0、1、2には、それぞれ次の用途があります。
fd 0 : 標準入力
fd 1 : 標準出力
fd 2 : 標準エラー出力
例えば、端末からcatを実行した場合は次の状態になります。
cat
fd 0 = 端末
fd 1 = 端末
cat自身が「端末から読み、端末へ書く」と決めているわけではありません。catはfd 0から読み、fd 1へ書きます。
このfd 0やfd 1の接続先をコマンドの実行前に変更することで、パイプやリダイレクトを実現できます。
パイプライン
例えば、
cat file | wc -l
では、catの標準出力がwcの標準入力につながります。
パイプラインは、あるコマンドの標準出力をパイプの書き込み側へ接続し、その読み取り側を次のコマンドの標準入力へ接続するものと定められています。
POSIX.1-2024 — Shell Command Language / Pipelines
fdの接続先は次の状態です。
cat
fd 1 = パイプの書き込み側
wc
fd 0 = パイプの読み取り側
catがfd 1へ書いたデータはパイプに入り、wcは同じパイプからfd 0を通してデータを読み取ります。
Cでは、この接続をpipe()とdup2()を使って作ります。
pipe()
pipe()の定義は次のとおりです。
#include <unistd.h>
int pipe(int fildes[2]);
pipe()を呼ぶと、パイプの読み取り側と書き込み側を表す2つのfdが作られます。
fildes[0] = パイプの読み取り側
fildes[1] = パイプの書き込み側
書き込み側へ書いたデータは、読み取り側から書き込まれた順に読み出せます。
例えば、
int pipe_fds[2];
pipe(pipe_fds);
を実行し、fd 3と4が割り当てられたとします。
fd 3 = パイプの読み取り側
fd 4 = パイプの書き込み側
この時点では、まだcatやwcの標準入出力とはつながっていません。
dup2()
dup2()の定義は次のとおりです。
#include <unistd.h>
int dup2(int fildes, int fildes2);
POSIX.1-2024 — dup(), dup2(), dup3()
dup2(fildes, fildes2)は、fildes2をfildesと同じ入出力先につなぎ直します。
例えば、
fd 1 = 端末
fd 4 = パイプの書き込み側
という状態で、
dup2(4, 1);
を実行すると、
fd 1 = パイプの書き込み側
fd 4 = パイプの書き込み側
となります。同じ入力先を2つのfdから参照している状態です。
cat側では、
dup2(pipe_fds[1], STDOUT_FILENO);
として標準出力をパイプの書き込み側へ接続します。
cat
fd 1 = パイプの書き込み側
wc側では、
dup2(pipe_fds[0], STDIN_FILENO);
として標準入力をパイプの読み取り側へ接続します。
wc
fd 0 = パイプの読み取り側
この状態でcatが標準出力へ書くと、そのデータをwcが標準入力から読み取れます。
パイプの実装
まず、コマンド数に応じて必要なパイプを作ります。
size_t n_pipes = pipeline->n_cmds - 1;
int pipes[MAX_CMDS - 1][2];
for (size_t i = 0; i < n_pipes; i++)
{
if (pipe(pipes[i]) == -1)
{
perror("pipe");
exit(EXIT_FAILURE);
}
}
例えば、
A | B | C
なら、3つのコマンドの間をつなぐためにパイプが2本必要です。
続いて、各コマンドについてfork()します。
for (size_t i = 0; i < pipeline->n_cmds; i++)
{
pid_t pid = fork();
if (pid == 0)
{
if (i > 0)
{
dup2(pipes[i - 1][0], STDIN_FILENO);
}
if (i < n_pipes)
{
dup2(pipes[i][1], STDOUT_FILENO);
}
/* 不要なパイプ用fdはここでcloseする */
execvp(
pipeline->cmds[i]->progname,
pipeline->cmds[i]->args
);
perror("execvp");
exit(EXIT_FAILURE);
}
}
A | B | Cなら、各プロセスのfdは次のようになります。
A
fd 1 = パイプ0の書き込み側
B
fd 0 = パイプ0の読み取り側
fd 1 = パイプ1の書き込み側
C
fd 0 = パイプ1の読み取り側
最初のコマンドは標準出力だけをパイプへ、中間のコマンドは標準入力と標準出力の両方をパイプへ、最後のコマンドは標準入力だけをパイプへ接続しています。
close()
dup2()で必要な接続を作った後は、元のパイプ用fdを閉じます。
例えばwc側で次の状態を残したとします。
wc
fd 0 = パイプの読み取り側
fd 1 = 端末
fd 3 = パイプの読み取り側
fd 4 = パイプの書き込み側
wcが入力に使うのはfd 0ですが、fd 4として同じパイプの書き込み側も保持しています。
read()では、空のパイプについて次のように定められています。
“If no process has the pipe open for writing, read() shall return 0”
パイプが空でも、書き込み側のfdが1つでも残っていれば、read()は次のデータを待ちます。書き込み側がすべて閉じられるとread()は0を返し、EOFとなります。
そのため、wc自身や親プロセスが使わない書き込み側のfdを持ったままにしないよう、不要なパイプ用fdを閉じます。
子プロセスでは、dup2()が終わった後に元のパイプ用fdをすべて閉じます。
for (size_t j = 0; j < n_pipes; j++)
{
close(pipes[j][0]);
close(pipes[j][1]);
}
親プロセスも、すべての子プロセスをfork()した後でパイプ用fdを閉じます。
for (size_t i = 0; i < n_pipes; i++)
{
close(pipes[i][0]);
close(pipes[i][1]);
}
リダイレクト
次にリダイレクトを見てみます。
リダイレクトは次の形で定義されています。
[n]redir-op word
POSIX.1-2024 — Shell Command Language / Redirection
nは対象となるfd番号、redir-opは<や>などの演算子、wordは入力元や出力先を表します。
今回扱う<と>では、fd番号を省略した場合、<は標準入力(fd 0)、>は標準出力(fd 1)が対象になります。今回の簡易シェルではこの2種類のみを実装し、2>や2>&1、>>などは扱いません。
例えば、
echo hello > out.txt
では標準出力であるfd 1が対象になります。
echo
fd 1 = out.txt
同様に、
wc -l < input.txt
では標準入力であるfd 0が対象になります。
wc
fd 0 = input.txt
パイプラインではfd 0やfd 1をパイプに接続しましたが、リダイレクトではファイルに接続します。
そのため、まずファイルをfdとして扱えるようにopen()します。
open()
open()の定義は次のとおりです。
#include <fcntl.h>
int open(const char *path, int oflag, ...);
open()は、成功すると開いたファイルを扱うためのfdを返します。
>では、次のフラグを使います。
O_WRONLY = 書き込み専用
O_CREAT = ファイルがなければ作成
O_TRUNC = 既存のファイルなら長さを0にする
<では、読み取り専用を表すO_RDONLYを使います。
リダイレクトの実装
出力側の>では、open()でファイルを開いた後、そのfdを標準出力へ複製します。
int fd = open(
cmd->redirect_file[STDOUT_FILENO],
O_WRONLY | O_CREAT | O_TRUNC,
0644
);
if (fd == -1)
{
perror("open");
exit(EXIT_FAILURE);
}
dup2(fd, STDOUT_FILENO);
close(fd);
例えばopen()がfd 5を返したとします。
fd 1 = 端末
fd 5 = out.txt
ここで、
dup2(fd, STDOUT_FILENO);
を実行すると、
fd 1 = out.txt
fd 5 = out.txt
となります。
fd 5はもう必要ないのでclose(fd)で閉じます。fd 1はそのままout.txtを参照しています。
入力側の<も同じです。
int fd = open(
cmd->redirect_file[STDIN_FILENO],
O_RDONLY
);
if (fd == -1)
{
perror("open");
exit(EXIT_FAILURE);
}
dup2(fd, STDIN_FILENO);
close(fd);
wc -l < input.txtなら、exec()の前に次の状態を作ることになります。
wc
fd 0 = input.txt
パイプとリダイレクト
パイプとリダイレクトは同時に指定することもできます。
今回の実装では、子プロセス側でパイプラインの接続を作った後に、ファイルへのリダイレクトを適用しています。
if (i > 0)
{
dup2(pipes[i - 1][0], STDIN_FILENO);
}
if (i < n_pipes)
{
dup2(pipes[i][1], STDOUT_FILENO);
}
if (cmd->redirect_file[STDIN_FILENO] != NULL)
{
int fd = open(
cmd->redirect_file[STDIN_FILENO],
O_RDONLY
);
if (fd == -1)
{
perror("open");
exit(EXIT_FAILURE);
}
dup2(fd, STDIN_FILENO);
close(fd);
}
if (cmd->redirect_file[STDOUT_FILENO] != NULL)
{
int fd = open(
cmd->redirect_file[STDOUT_FILENO],
O_WRONLY | O_CREAT | O_TRUNC,
0644
);
if (fd == -1)
{
perror("open");
exit(EXIT_FAILURE);
}
dup2(fd, STDOUT_FILENO);
close(fd);
}
この順序はBashの動作とも対応しています。パイプによる接続はコマンド自身のリダイレクトより先に行われると説明されています。
“This connection is performed before any redirections specified by command1.”
GNU Bash Reference Manual — Pipelines
例えば、
echo hello > out.txt | wc -l
では、まずechoのfd 1がパイプの書き込み側に設定されます。
echo
fd 1 = パイプの書き込み側
その後、> out.txtによって同じfd 1の接続先がout.txtに変更されます。
echo
fd 1 = out.txt
そのためBashでは、echoの出力はパイプに流れません。
$ echo hello > out.txt | wc -l
0
helloはout.txtに書き込まれます。
zshのMULTIOS
zshでは、同じコマンドの結果が異なる場合もあります。
% echo hello > out.txt | wc -l
1
これはzshのMULTIOSという機能によるものです。
MULTIOSが有効な場合、同じfdに複数の出力先が指定されると、zshが出力をそれぞれの宛先へコピーします。helloはout.txtとパイプの両方へ送られるため、wc -lは1を出力します。
zshのドキュメントでは、teeに似た処理として説明されています。
zsh Documentation — Redirection / Multios
teeは、標準入力から受け取った内容を標準出力へ流しながら、同じ内容をファイルにも書き込むコマンドです。
echo hello | tee out.txt
この場合、helloは端末に表示されると同時にout.txtにも書き込まれます。