[徹底解説] Linux パイプのしくみ

パイプは、1970年初頭に Third Edition Unix で実装された、Unix の IPC (Interprocess Communication / プロセス間通信) 。
パイプは、関連プロセス間でデータの受け渡しを行い、異なるプログラムを実行する2プロセスにおいて、一方の出力をもう一方の入力とすることができる。

概要

パイプは以下のように使用される。

# ls / | wc -l
22

この例の場合、以下のように順に処理されている。

  1. ls / コマンドで対象ファイルの一覧を表示する。

    bin   dev  home  lib64  mnt  proc  run   srv  tmp  vagrant  work
    boot etc lib media opt root sbin sys usr var
  2. 前の出力結果を入力値として、wc -l を実行して出力ライン数をカウント・表示する。

    22

パイプの実装

カーネルにおけてパイプは以下のように実装されている。

  • パイプはファイルシステム上に実態を持たないが、 inode 構造体などの管理構造を維持する必要があるため、pipefs という仮想ファイルシステム (VFS) を導入している
  • pipefs ファイルシステムは、システムのディレクトリツリーにマウントポイントを持っていないため、ユーザから見ることはできない
  • pipefs のエントリポイントは、シェルや他のプログラムでパイプを実装するために使用される pipe(2) システムコール
  • pipe(2) は新しいパイプを作成し、ファイルディスクリプターを二つ返す(ディスクリプターのうち、一方はパイプの読み出し側を、もう一方は 書き込み側を参照している)

パイプ I/O、バッファリング、容量

Linux ではパイプの容量が限られており、パイプの容量がフルになると write(2)がブロックされる(O_NONBLOCK フラグがセットされている場合は失敗する)。
Linux 2.6.35 以降、デフォルトのパイプ容量は 65,536 バイト、それ以前のバージョンでは、パイプの容量はシステムのページサイズと同一(例えば i386 の場合は 4,096 バイト)となっている。

プロセスが空のパイプからの読み取りを試みた際、read(2) はデータがパイプ内で利用可能になるまでブロックする。
パイプの書き込み側を指すファイルディスクリプタがすべて閉じられた場合、パイプから読み取りを試みると EOF が返される(read(2) が 0 を返す)。

プロセスがフルになったパイプに書き込もうとすると、write 呼び出しが成功するのに十分なデータがパイプから読み取られるまで、write(2) はブロックされる。
パイプの読み込み側のファイルディスクリプタがすべて閉じられると、パイプに書き込んだ際に SIGPIPE シグナルが送られる。
呼び出し元のプロセスがこのこのシグナルを無視している場合、write(2) はエラー EPIPE で失敗する。

つまり、プロセスAからの書き込みと、プロセスBからの読み込みが同じ速度で行われている場合は、最大のパフォーマンスを出すことができるが、速度が乖離している場合には、パフォーマンス劣化を引き起こす場合があるので、注意が必要となる。

シェルでパイプを行う方法

ここからは、Linux においてプロセスがどのように生成されるのかについて、理解していることを前提として説明をしていく。
ご存知でない方は “[徹底解説] Linux プロセス生成のしくみ” を参照のこと。

シェルは、「リダイレクションの実装方法」と非常によく似た方法でパイプを実装します。
基本的には、親プロセスは、一緒にパイプ処理される2つのプロセスごとに、 pipe(2) を1回呼び出します。
上記の例では、bash は2つのパイプを作成するために pipe(2) を2回呼び出す必要があります。次に、bash はプロセスごとに1回ずつフォークします(この例では3回)。
各子は1つのコマンドを実行します。
しかし、子供がコマンドを実行する前に、stdin または stdout(またはその両方)を上書きします。上記の例では、次のように動作します。

  • bash は2つのパイプを作成します.1つはソートするパイプ、もう1つはソートするパイプです
  • bash は自分自身を3回フォークします(各コマンドごとに1つの親プロセスと3つの子プロセス)
  • 子1 (ls) は、標準出力ファイル記述子をパイプ A の書き込み終了に設定します
  • 子2 (sort) は、パイプ A の読み込み側のファイルディスクリプタを stdin に設定します(ls からの入力を読み込むため)
  • 子2 (sort) は、標準出力ファイル記述子をパイプBの書込み終了に設定します
  • 子3 (less) は、パイプ B の読み込み側のファイルディスクリプタを stdin に設定します(sort からの入力を読み込むため)
  • それぞれの子はコマンドを実行する

カーネルはプロセスを自動的にスケジューリングして、大まかに並行して実行します。
子2がそれを読み取る前に子1がパイプAにあまりにも多くの書き込みを行った場合、子2はパイプから読み出すまでの間、しばらくブロックします。
これは通常、あるプロセスが他のプロセスがデータの処理を開始するのを待つ必要がないため、非常に高いレベルの効率を可能にします。
もう1つの理由は、パイプのサイズが限られていることです(通常、メモリの1ページのサイズ)。

パイプサンプルコード

ここでは、bash のようなプログラムがパイプを実装する方法の C の例を示します。
私の例はかなりシンプルで、2つの引数を受け取ります。ディレクトリと検索する文字列です。
ls -la を実行してディレクトリの内容を取得し、grep にパイプして文字列を検索します。

#include <unistd.h>
#include <stdio.h>
#include <errno.h>
#include <fcntl.h>

#define READ_END 0
#define WRITE_END 1

int main(int argc, char *argv[])
{
int pid, pid_ls, pid_grep;
int pipefd[2];

// Syntax: test . filename
if (argc < 3) {
fprintf(stderr, "Please specify the directory to search and the filename to search for\n");
return -1;
}

fprintf(stdout, "parent: Grepping %s for %s\n", argv[1], argv[2]);

// Create an unnamed pipe
if (pipe(pipefd) == -1) {
fprintf(stderr, "parent: Failed to create pipe\n");
return -1;
}

// Fork a process to run grep
pid_grep = fork();

if (pid_grep == -1) {
fprintf(stderr, "parent: Could not fork process to run grep\n");
return -1;
} else if (pid_grep == 0) {
fprintf(stdout, "child: grep child will now run\n");

// Set fd[0] (stdin) to the read end of the pipe
if (dup2(pipefd[READ_END], STDIN_FILENO) == -1) {
fprintf(stderr, "child: grep dup2 failed\n");
return -1;
}

// Close the pipe now that we've duplicated it
close(pipefd[READ_END]);
close(pipefd[WRITE_END]);

// Setup the arguments/environment to call
char *new_argv[] = { "/bin/grep", argv[2], 0 };
char *envp[] = { "HOME=/", "PATH=/bin:/usr/bin", "USER=brandon", 0 };

// Call execve(2) which will replace the executable image of this
// process
execve(new_argv[0], &new_argv[0], envp);

// Execution will never continue in this process unless execve returns
// because of an error
fprintf(stderr, "child: Oops, grep failed!\n");
return -1;
}

// Fork a process to run ls
pid_ls = fork();

if (pid_ls == -1) {
fprintf(stderr, "parent: Could not fork process to run ls\n");
return -1;
} else if (pid_ls == 0) {
fprintf(stdout, "child: ls child will now run\n");
fprintf(stdout, "---------------------\n");

// Set fd[1] (stdout) to the write end of the pipe
if (dup2(pipefd[WRITE_END], STDOUT_FILENO) == -1) {
fprintf(stderr, "ls dup2 failed\n");
return -1;
}

// Close the pipe now that we've duplicated it
close(pipefd[READ_END]);
close(pipefd[WRITE_END]);

// Setup the arguments/environment to call
char *new_argv[] = { "/bin/ls", "-la", argv[1], 0 };
char *envp[] = { "HOME=/", "PATH=/bin:/usr/bin", "USER=brandon", 0 };

// Call execve(2) which will replace the executable image of this
// process
execve(new_argv[0], &new_argv[0], envp);

// Execution will never continue in this process unless execve returns
// because of an error
fprintf(stderr, "child: Oops, ls failed!\n");
return -1;
}

// Parent doesn't need the pipes
close(pipefd[READ_END]);
close(pipefd[WRITE_END]);

fprintf(stdout, "parent: Parent will now wait for children to finish execution\n");

// Wait for all children to finish
while (wait(NULL) > 0);

fprintf(stdout, "---------------------\n");
fprintf(stdout, "parent: Children has finished execution, parent is done\n");

return 0;
}

私はそれを徹底的にコメントしたので、うまくいけば意味がある。

名前付きパイプと無名パイプ

上記の例では、名前のない/匿名のパイプを使用しています。
これらのパイプは一時的なもので、プログラムが終了するか、ファイル記述子がすべて閉じられると破棄されます。
それらは最も一般的なタイプのパイプです。

名前付きパイプは、FIFO (first in, first out) としても知られ、ハードディスク上の名前付きファイルとして作成されます。
無関係な複数のプログラムを開いて使用することができます。
非常にシンプルなクライアント/サーバー型設計のために、複数のライターを1つのリーダーで簡単に作成できます。
たとえば、Nagios はこれを行います。
マスタープロセスは名前付きパイプを読み込み、すべての子プロセスは名前付きパイプにコマンドを書き込みます。

名前付きパイプは、mkfifo コマンドまたは syscall を使用して作成しています。
例:

$ mkfifo ~/test_pipe

彼らの作成以外にも、名前のないパイプとほとんど同じ働きをします。
作成したら、 open(2) を使って開くことができます。
O_RDONLY を使って読み込み終了を開くか、O_WRONLY を使って書き込み終了をオープンする必要があります。
ほとんどのオペレーティングシステムでは単方向パイプが実装されているため、読み取り/書き込みモードで開くことはできません。

FIFO は、複数のプロセスを持つシステムのために、単方向 IPC 技術としてしばしば使用されます。
マルチスレッドアプリケーションは、共有メモリセグメントなどの他の IPC 技術と同様に、名前付きパイプまたは名前のないパイプを使用することもできます。

FIFO は inode として作成され、i_pipe プロパティは実際のパイプへの参照として設定されます。
名前がファイルシステム上に存在するかもしれませんが、inode が読み込まれると、パイプは無名のパイプのように振る舞い、メモリ内で動作するため、パイプは基盤となるデバイスへの I/O を引き起こしません。

参考 URL