every Tech Blog

株式会社エブリーのTech Blogです。

Unixにおけるリダイレクト処理を改めて確認 >file 2>&1 は実際にはなにをやっているのか

開発2部の内原です。

シェルで >file2>&1 のような記号を使ってリダイレクト処理を行うことは多いかと思いますが、なぜこのような書き方をするのか、それが実際にカーネルやプロセスのレベルで何をやっているのか、は意外と説明しづらい、というかなんとなくふわっとした理解のままでいました。

そこでこの記事ではファイルディスクリプタとUnixシステムコールの観点から、これらの記号の意味を考えてみます。

ファイルディスクリプタ(fd)とは

ファイルディスクリプタとは、プロセスごとにカーネルが管理しているファイルテーブルへのインデックス(整数)のことです。プロセスがファイルやソケットを開くと、カーネル側でテーブルにエントリが作られ、その識別子となる整数(0, 1, 2, 3, ...)がプロセスに返されます。

プロセスは以降、この整数を read(2) / write(2) などのシステムコールに渡してファイル操作を行います。

0, 1, 2 という慣習

POSIXにおいてプロセス起動時点で、以下の3つのfdが予め確保されています。

fd 名前 用途
0 stdin 標準入力
1 stdout 標準出力
2 stderr 標準エラー出力

これらのファイルディスクリプタは通常、ターミナルのデバイスファイル(/dev/ttys00N など)にOSによって関連付けられています。

$ lsof -p $$ -a -d 0-2 # 今実行しているシェル自身が開いている標準入出力(fd 0〜2)を表示
COMMAND   PID     USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
zsh     49106 uchihara    0u   CHR   16,6     0t10 1167 /dev/ttys006
zsh     49106 uchihara    1u   CHR   16,6     0t10 1167 /dev/ttys006
zsh     49106 uchihara    2u   CHR   16,6     0t10 1167 /dev/ttys006

3つともターミナル端末(/dev/ttys006)を指していることがわかります。

open(2) で fd を取得する

新しくファイルを開くと、未使用の最小番号の fd が返却されます。起動時点で 0, 1, 2 が使われているので通常は 3 になります。

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

int main(void) {
    int fd = open("/tmp/hello.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    printf("fd = %d\n", fd);
    write(fd, "hello\n", 6);
    close(fd);
    return 0;
}
$ cc fd_open.c -o fd_open && ./fd_open
fd = 3
$ cat /tmp/hello.txt
hello

確かに fd = 3 が返ってきています。

dup(2) で fd を複製する

dup(2) はオープン済みの fd を複製し、未使用の最小番号で新しい fd を返します。複製された2つの fd は同じファイルテーブルエントリを指すため、ファイル位置(オフセット)も共有されます。

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

int main(void) {
    int fd1 = open("/tmp/dup.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    int fd2 = dup(fd1);
    printf("fd1=%d, fd2=%d\n", fd1, fd2);

    write(fd1, "via fd1\n", 8);
    write(fd2, "via fd2\n", 8);

    close(fd1);
    close(fd2);
    return 0;
}
$ cc fd_dup.c -o fd_dup && ./fd_dup
fd1=3, fd2=4
$ cat /tmp/dup.txt
via fd1
via fd2

オフセットが共有されているため、fd1 で書き込んだ続きから fd2 の書き込みが進んでいることがわかります。

dup2(2) で fd 番号を複製する

dup2(oldfd, newfd)newfd 番がすでに使われていれば一旦close してから、oldfd の複製を newfd 番に作るシステムコールです。これがリダイレクトの実態となります。

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

int main(void) {
    printf("before redirect\n");
    fflush(stdout);

    int fd = open("/tmp/dup2.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    dup2(fd, 1);   // fd 1 (stdout) を fd の指す先に差し替える
    close(fd);     // 元の fd はもう不要なので閉じる

    printf("after redirect\n");
    return 0;
}
$ cc fd_dup2.c -o fd_dup2 && ./fd_dup2
before redirect
$ cat /tmp/dup2.txt
after redirect

dup2(fd, 1) を境にして、printf の出力先が端末からファイルに切り替わっていることがわかります。

なお fflush(stdout) を挟んでいるのはstdioのバッファリングを考慮するためです。バッファに残ったまま fd 1 を差し替えると、後でフラッシュされたタイミングで意図しないファイルに書き込まれてしまいます。

>file の正体

シェルがリダイレクトを行うときの手順は以下の通りです。

  1. (新たに子プロセスでコマンドを実行する場合)fork(2) で子プロセスを生成
  2. 子プロセスで出力先ファイルを open(2)
  3. その fd を dup2(2) で 1 番(stdout)に複製
  4. 元の fd は close(2) で閉じる
  5. execve(2) で実コマンドに置き換える

自前でリダイレクトを再現してみる

./mini_redirect <出力ファイル> <コマンド> [引数...] のように実行すると、指定したコマンドの標準出力をファイルへリダイレクトします。

#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>

int main(int argc, char *argv[]) {
    if (argc < 3) {
        fprintf(stderr, "usage: %s <output_file> <cmd> [args...]\n", argv[0]);
        return 1;
    }
    const char *outfile = argv[1];

    pid_t pid = fork();
    if (pid < 0) { perror("fork"); return 1; }

    if (pid == 0) {
        // 子プロセス: 出力先を open し、stdout (fd 1) に dup2 してから exec
        int fd = open(outfile, O_WRONLY | O_CREAT | O_TRUNC, 0644);
        if (fd < 0) { perror("open"); _exit(1); }
        if (dup2(fd, 1) < 0) { perror("dup2"); _exit(1); }
        close(fd);
        execvp(argv[2], &argv[2]);
        perror("execvp");
        _exit(1);
    }

    // 親プロセス: 子の終了を待つ
    int status;
    waitpid(pid, &status, 0);
    return WIFEXITED(status) ? WEXITSTATUS(status) : 1;
}

実行してみます。

$ cc mini_redirect.c -o mini_redirect
$ ./mini_redirect /tmp/mini_out.txt echo hello world from mini_redirect
$ cat /tmp/mini_out.txt
hello world from mini_redirect

これは以下と同じ動作です。

$ echo hello world from mini_redirect >/tmp/mini_out.txt

2>&1 とはなにか

2>&1 という書き方忘れたりしません?(自分はたまに忘れます)これは実際には dup2(1, 2) の意味で、シェルの記述とシステムコールの引数が逆になっていて混乱しがちではあります。

あと、標準出力と標準エラー出力をまとめてファイルに書き出したい時に、>file 2>&12>&1 >file どっちだっけ?みたいになることもあります。

結論

書き方 stdout の行き先 stderr の行き先
cmd >file 2>&1 file file
cmd 2>&1 >file file 変化なし(通常はターミナル)

2>&1 は「fd 2 を fd 1 と同じにする」と説明されますが、これは正確には「2>&1 を実行した時点の fd 1 の指し先を fd 2 にコピーする」という操作で、以後 fd 1 が別の場所に切り替わっても fd 2 は連動しません。(リンクしているわけではないという意味)

シェルでの挙動

$ bash -c 'echo stdout-msg; echo stderr-msg >&2' >/tmp/sh_a.txt 2>&1
$ cat /tmp/sh_a.txt
stdout-msg
stderr-msg
$ bash -c 'echo stdout-msg; echo stderr-msg >&2' 2>&1 >/tmp/sh_b.txt
stderr-msg
$ cat /tmp/sh_b.txt
stdout-msg

パターンBではなぜ stderr が端末に残ってしまうのか、再現してみます。

C で再現してみる

シェルは >2>&1 といったリダイレクト指示を左から右に評価する仕様なので、順序が重要になります。

パターン A: >file 2>&1

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

int main(void) {
    int fd = open("/tmp/order_a.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    dup2(fd, 1);   // ① fd 1 をファイルに差し替え
    close(fd);
    dup2(1, 2);    // ② fd 2 を「現時点の fd 1」(=ファイル) に差し替え

    fprintf(stdout, "stdout: hello\n"); fflush(stdout);
    fprintf(stderr, "stderr: world\n"); fflush(stderr);
    return 0;
}
$ cc order_a.c -o order_a && ./order_a
$ cat /tmp/order_a.txt
stdout: hello
stderr: world

①の時点で fd 1(標準出力)はファイルを指します。

②でその「ファイルを指している fd 1」をコピーして fd 2(標準エラー出力) にセットしているので、fd 2 もファイルを指すことになります。

結果として両方ファイルに書き込まれます。

パターン B: 2>&1 >file

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

int main(void) {
    dup2(1, 2);    // ① fd 2 を「現時点の fd 1」(=ターミナル) に差し替え
    int fd = open("/tmp/order_b.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    dup2(fd, 1);   // ② fd 1 をファイルに差し替え(fd 2 は連動しない)
    close(fd);

    fprintf(stdout, "stdout: hello\n"); fflush(stdout);
    fprintf(stderr, "stderr: world\n"); fflush(stderr);
    return 0;
}
$ cc order_b.c -o order_b && ./order_b
stderr: world
$ cat /tmp/order_b.txt
stdout: hello

①の時点では fd 1(標準出力)はまだターミナルを指しており、fd 2(標準エラー出力)が同じところを指します。

②で fd 1 はファイルに切り替わりますが、fd 2 はすでに「①でコピーされた時点のターミナル」を保持し続けています。

これはシェルの挙動と一致しています。

なぜそうなるのか

カーネル内部の構造で見るとわかりやすいです。

  • 各 fd はプロセスごとの fd テーブルから「open file table」のエントリを指している
  • dup2(oldfd, newfd) は「newfd の指し先を oldfd の指し先と同じにする」というポインタ複製操作
  • 後から oldfd の指し先を変更しても、newfd は元の指し先を指したまま

つまり 2>&1 は「fd 2 と fd 1 を以後リンクする」のではなく、「実行時点の fd 1 の指し先を fd 2 にコピーする」ということになります。

覚え方

ログを全部ファイルに落としたいときは、まずファイルリダイレクト >file を先に書いて、その後 2>&1 でまとめる、と考えるのがよさそうです。

$ cmd >file 2>&1     # 両方 file
$ cmd 2>&1 >file     # stdout だけ file、stderr は端末

まとめ

シェルでよく使う >file2>&1 といった記号は、open dup2 close という Unix システムコールの組み合わせとしてみると、その挙動がそのままシステムコールによる実装になっていることが確認できました。

まあとは言え覚えづらいですよね・・・結局のところ慣れでしかないかもしれません。

あと、パイプやヒアドキュメントについてもまたいつか調べてみたいです。

  • fd は open 時に未使用の最小番号が返却される整数
  • >fileopendup2(fd, 1)close(fd)
  • 2>&1 は実行時点の fd 1 の指し先を fd 2 にコピーするだけで、以後 fd 1 が変わっても fd 2 は連動しない
  • 評価順は左から右なので、>file 2>&12>&1 >file は別物になる