パイプとリダイレクトの仕組み - 2>&1の意味をファイルディスクリプタから理解する

パイプとリダイレクトの仕組み - 2>&1の意味をファイルディスクリプタから理解する

作成日:
読了:約46分
更新日:

当サイトは Amazon.co.jp を宣伝しリンクすることで紹介料を得る手段を提供する、Amazonアソシエイト・プログラムの参加者です。価格・在庫はリンク先の最新情報をご確認ください。

command > log.txt 2>&1という書き方は、シェルを使っていれば毎日のように目にします。ただ、「なぜ2>&1は後ろに書くのか」「&は何なのか」と聞かれて説明できる人は多くありません。順番を間違えると標準エラー出力だけファイルに入らず、エラーが残っていないログが出来上がります。

リダイレクトとパイプは、シェルの独自の魔法ではなく、OSのファイルディスクリプタを繋ぎ替える処理です。この仕組みが分かると、2>&1の順番も、sudo echo > fileが失敗する理由も、パイプにすると出力の順番が変わる理由も、同じ考え方で説明できます。

NOTE

仕様は2026年11月10日に、POSIX.1-2024のShell Command Language、pipe(7)、dup(2)、setvbuf(3)、zshのマニュアルと、手元のbash 5.3.9のmanページで確認しました。コマンドの実行結果は、特に断りがなければmacOS 27.2のbash 5.3.9のものです。lsのエラーメッセージなど細かい文言は、Linuxでは少し変わります。

先に結論: 2>&1の読み方

2>&1は次のように読みます。

  • 2>: ファイルディスクリプタ2番(標準エラー出力)の行き先を変える
  • &1: 行き先は「ファイルディスクリプタ1番が今指している先」

&は「この後ろの数字はファイル名ではなくファイルディスクリプタの番号だ」という印です。&を付け忘れた2>1は、1という名前のファイルを作ります。

&を付け忘れると1という名前のファイルができる
ls missing.txt 2>1
ls 1
cat 1
実行結果
1
ls: missing.txt: No such file or directory

2と>の間に空白を入れてもいけません。echo a 2 >&1はa 2と表示され、2はただの引数になります。

大事なのは「今」の部分です。2>&1は「fd 2をfd 1の別名にする」ものではなく、その瞬間のfd 1の行き先をfd 2にコピーするものです。プログラムの代入文でfd2 = fd1と書くのと同じで、あとからfd1を変えてもfd2は変わりません。

標準入力・標準出力・標準エラー出力とファイルディスクリプタ

0番、1番、2番

プロセスがファイルや端末、パイプ、ソケットを扱うときに使う番号がファイルディスクリプタ(file descriptor、以下fd)です。read(2)やwrite(2)といったシステムコールには、この番号を渡します。

0、1、2の3つは、慣習で役割が決まっています。

fd名前Cの定数stdioの名前用途
0標準入力STDIN_FILENOstdinプログラムへの入力
1標準出力STDOUT_FILENOstdoutプログラムの本来の出力
2標準エラー出力STDERR_FILENOstderrエラーや進捗など、本来の出力以外

標準出力と標準エラー出力が分かれているのは、結果をファイルやパイプに流しているときでも、エラーは画面に出せるようにするためです。端末で実行すると、どちらも同じ端末に繋がっているので区別がつきません。違いが見えるのは、片方だけを繋ぎ替えたときです。

標準出力だけをパイプに流す
ls exists.txt missing.txt 2>/dev/null | wc -l
ls exists.txt missing.txt 2>&1 | wc -l
実行結果
       1
       2

fd、open file description、inodeの3層

fdはただの番号です。番号の先には2段階の構造があります。

層持ち主持っている情報
fdテーブルプロセスごと番号と、close-on-execのフラグ
open file descriptionカーネル全体のテーブルファイルオフセット(今どこまで読み書きしたか)、O_APPENDなどの状態フラグ
inodeファイルシステムファイルの実体(サイズ、データの場所など)

open file descriptionはPOSIXの用語で、open(2)の説明の通り、カーネル全体の「開いているファイルの表」の1行のことです。macOSのdup(2)のmanページにも、別のファイル位置が欲しければopen(2)をもう一度呼ぶ必要があると書かれています。inodeについてはファイルシステムの記事で詳しく扱っています。

command > out.txt 2>&1を実行したときの状態を図にすると、次のようになります。

fd 1とfd 2が、同じopen file descriptionを指している
fd table (per process)    open file description (kernel)        inode
+------+                  +----------------------------+        +---------+
| fd 0 | ---------------> | offset, flags (terminal)   | -----> | tty     |
+------+                  +----------------------------+        +---------+
| fd 1 | ---+
+------+    +-----------> +----------------------------+        +---------+
| fd 2 | ---+             | offset, flags (out.txt)    | -----> | out.txt |
+------+                  +----------------------------+        +---------+

lsofで、実際のプロセスのfdを確かめられます。-Ffnは、fd番号(f)と名前(n)を1行ずつ出力する形式です。

fd 1と2が同じファイルに繋がっていることを確かめる
bash -c 'lsof -a -p $$ -d 0,1,2 -Ffn' < /dev/null > lsof.txt 2>&1
cat lsof.txt
実行結果(p行はプロセスID)
p32040
f0
n/dev/null
f1
n/private/tmp/demo/lsof.txt
f2
n/private/tmp/demo/lsof.txt

複製と、2回openするのとの違い

dup2(2)でfdを複製すると、2つのfdは同じopen file descriptionを指します。dup(2)にある通り、ファイルオフセットと状態フラグは共有され、片方でlseekするともう片方の位置も変わります。共有されないのはclose-on-execのフラグだけです。

これが効いてくる例を見てみます。同じファイルへ、2>&1で出す場合と、2>で同じファイル名をもう一度開く場合です。

複製する場合と、同じファイルを2回開く場合
{ echo out; echo err >&2; } > dup.txt 2>&1
cat dup.txt
 
{ echo out-long-long-line; echo err >&2; } > two.txt 2> two.txt
cat two.txt
実行結果
out
err
err
long-long-line

複製したdup.txtでは、オフセットを共有しているのでoutの続きにerrが書かれます。

2回開いたtwo.txtでは、fd 1とfd 2のオフセットが別々です。fd 1がout-long-long-lineを書いても、fd 2のオフセットは先頭のままなので、errと改行の4バイトが先頭を上書きします。その結果、out-がerr\nに置き換わり、long-long-lineだけが残りました。> file 2> fileは、一見同じ意味に見えますが、ログが壊れる書き方です。

リダイレクトはforkとexecの間で行われる

シェルが行っていること

シェルが外部コマンドを実行するときの流れは、おおまかに次の通りです。

外部コマンドの実行とリダイレクト
1. シェルがコマンドラインを解釈する
2. fork で子プロセスを作る(fd テーブルは親からコピーされる)
3. 子プロセスの中で、リダイレクトを左から順に処理する
     open でファイルを開く
     dup2 で fd 1 などに付け替える
     不要になった元の fd を close する
4. exec でコマンドのプログラムに置き換わる(fd はそのまま引き継がれる)
5. コマンドは fd 1 や fd 2 に write するだけ

この流れが成り立つのは、forkとexecの性質のおかげです。macOSのfork(2)のmanページには、子プロセスは親のディスクリプタのコピーを持ち、同じオブジェクトを参照すると書かれています。execve(2)には、close-on-execが設定されていないディスクリプタは新しいプログラムでも開いたままだと書かれています。

つまり、コマンドの側は自分の出力先を知りません。lsは常にfd 1へ書くだけで、その先がファイルか端末かパイプかを決めるのはシェルです。これが、Unixでリダイレクトがどんなコマンドにも効く理由です。

同じ手順をCで書くと次のようになります。ls exists.txt missing.txt > out.txt 2>&1に相当するプログラムです。

mini.c: > out.txt 2>&1 と同じ状態を作る
#include <fcntl.h>
#include <stdio.h>
#include <sys/wait.h>
#include <unistd.h>
 
int main(void) {
    pid_t pid = fork();
    if (pid == 0) {
        /* 子プロセス: exec する前に fd を繋ぎ替える */
        int fd = open("out.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
        dup2(fd, 1);   /* > out.txt */
        close(fd);
        dup2(1, 2);    /* 2>&1 */
        execlp("ls", "ls", "exists.txt", "missing.txt", (char *)NULL);
        _exit(127);
    }
    waitpid(pid, NULL, 0);
    return 0;
}
実行結果(out.txtの中身)
ls: missing.txt: No such file or directory
exists.txt

シェルのコマンドと同じ結果になりました。

NOTE

このCのプログラムは、シェルと同じ手順を自分で書いて結果を確かめたものです。bashやzsh自体のシステムコールをトレースしたわけではありません。POSIXが定めているのはリダイレクトの結果で、実際のコードはシェルごとに異なります。

主なリダイレクトと、開き方

書き方意味開き方(代表的なフラグ)
> filefd 1へ、ファイルを切り詰めて書くO_WRONLY、O_CREAT、O_TRUNC
>> filefd 1へ、ファイルの末尾に追記するO_WRONLY、O_CREAT、O_APPEND
< filefd 0へ、ファイルを読むO_RDONLY
2> filefd 2へ、ファイルを切り詰めて書く> fileと同じ
n<> filefd nで、読み書き両用に開くO_RDWR、O_CREAT
2>&1fd 2をfd 1の複製にする開かない(dup2相当)
3>&-fd 3を閉じる開かない(close相当)

番号を省略すると、>はfd 1、<はfd 0が対象になります。

>>が本当にO_APPENDで開いているかは、子プロセスから状態フラグを読めば確かめられます。

fd 1にO_APPENDが立っているかを調べる
P='import os, fcntl; print("O_APPEND:", bool(fcntl.fcntl(1, fcntl.F_GETFL) & os.O_APPEND))'
python3 -c "$P" > a1.txt
python3 -c "$P" >> a2.txt
cat a1.txt a2.txt
実行結果
O_APPEND: False
O_APPEND: True

リダイレクトはコマンドより先に処理される

リダイレクトは、コマンドが動き始める前に済んでいます。このことから2つの性質が分かります。

1つ目は、コマンドが存在しなくても、ファイルは作られる(切り詰められる)ことです。

存在しないコマンドでも、ファイルは作られる
nonexistent_command_xyz > created.txt
echo "rc=$?"
ls -l created.txt | awk '{print $5, $NF}'
実行結果
bash: line 1: nonexistent_command_xyz: command not found
rc=127
0 created.txt

2つ目は、リダイレクトに失敗するとコマンドは実行されないことです。次の例では、出力先のディレクトリがないので、touchは動かずmarker.txtも作られません。

リダイレクトに失敗すると、コマンドは実行されない
touch marker.txt > /nonexistent-dir/x
echo "rc=$?"
ls marker.txt
実行結果
bash: line 1: /nonexistent-dir/x: No such file or directory
rc=1
ls: marker.txt: No such file or directory

execでシェル自身のfdを変える

コマンドなしのexecにリダイレクトを付けると、シェル自身のfdを変更できます。bashのmanページには「execと使うと、現在のシェルの実行環境のファイルハンドルを変更する」と書かれています。スクリプトの途中から全部の出力をログに流したいときに使えます。

シェル自身のfdを変える
exec 3> fd3.txt      # fd 3を開く
echo hello >&3       # fd 3へ書く
exec 3>&-            # fd 3を閉じる
cat fd3.txt
実行結果
hello

2>&1は複製である: fdの表で追う

リダイレクトは左から順に処理されます。POSIXには「複数のリダイレクト演算子が指定された場合、評価の順序は先頭から末尾へ」(If more than one redirection operator is specified with a command, the order of evaluation is from beginning to end.)と書かれています。これを踏まえて、順番が違う2つの書き方を追います。

cmd > file 2>&1の場合

段階fd 1の行き先fd 2の行き先
開始時端末端末
> fileを処理file端末
2>&1を処理(fd 1の今の行き先をコピー)filefile

標準出力も標準エラー出力もファイルに入ります。

cmd 2>&1 > fileの場合

段階fd 1の行き先fd 2の行き先
開始時端末端末
2>&1を処理(fd 1の今の行き先をコピー)端末端末
> fileを処理file端末

2>&1を処理した時点では、fd 1はまだ端末を指しています。そのためfd 2には端末がコピーされ、あとでfd 1をファイルへ向けてもfd 2は端末のままです。

代入文に直すと、違いがはっきりします。

代入文に置き換えて考える
# cmd > file 2>&1
fd1 = open(file)
fd2 = fd1          # fd2 は file を指す
 
# cmd 2>&1 > file
fd2 = fd1          # fd1 はまだ端末なので、fd2 も端末を指す
fd1 = open(file)   # fd1 だけが file に変わる

実際に実行すると、manページの説明の通りになります。

順番だけを変えて実行する
ls exists.txt missing.txt > out.txt 2>&1
echo '--- out.txt:'; cat out.txt
 
ls exists.txt missing.txt 2>&1 > out.txt
echo '--- out.txt:'; cat out.txt
実行結果
--- out.txt:
ls: missing.txt: No such file or directory
exists.txt
ls: missing.txt: No such file or directory
--- out.txt:
exists.txt

2回目は、エラーメッセージが端末に出て、out.txtにはexists.txtだけが入っています。

パイプと組み合わせたときの順番

パイプはリダイレクトより先に繋がります。POSIXは、パイプラインによる標準入出力の割り当ては、そのコマンドのリダイレクトより前に行われるとしています。bashのmanページにも同じことが書かれています。

このためcmd 2>&1 | grep fooは次のように動きます。

段階fd 1の行き先fd 2の行き先
開始時端末端末
パイプを接続パイプ端末
2>&1を処理パイプパイプ

fd 1がすでにパイプに変わっているので、2>&1でfd 2もパイプに繋がります。> file 2>&1の順番と同じ形です。

順番の違いをわざと利用すると、標準エラー出力だけをパイプに流すことができます。

標準エラー出力だけをパイプに流し、標準出力は捨てる
ls exists.txt missing.txt 2>&1 >/dev/null | tr a-z A-Z
実行結果
LS: MISSING.TXT: NO SUCH FILE OR DIRECTORY

2>&1の時点でfd 1はパイプなので、fd 2はパイプになります。その後>/dev/nullでfd 1だけを捨てています。

標準出力と標準エラー出力を入れ替えることもできます。一時的な保管場所としてfd 3を使い、変数の値を入れ替えるのと同じ操作をします。

fd 1とfd 2を入れ替える
{ echo to-stdout; echo to-stderr >&2; } 3>&1 1>&2 2>&3 3>&- | tr a-z A-Z
実行結果(大文字になったものがパイプを通った)
to-stdout
TO-STDERR

&>と|&はbashの拡張

> file 2>&1を短く書くために、bashには次の書き方があります。bashのmanページによると、&>wordと>&wordのうち&>wordが推奨で、>word 2>&1と同じ意味です。

  • &> file: 標準出力と標準エラー出力をファイルへ。> file 2>&1と同じ
  • &>> file: 両方をファイルへ追記。>> file 2>&1と同じ
  • |&: 両方をパイプへ。2>&1 |の省略形
bashの短い書き方
ls exists.txt missing.txt &> both.txt
cat both.txt
ls exists.txt missing.txt |& tr a-z A-Z
実行結果
ls: missing.txt: No such file or directory
exists.txt
LS: MISSING.TXT: NO SUCH FILE OR DIRECTORY
EXISTS.TXT

ただ、これらはPOSIXのシェルにはありません。POSIXの範囲で動かすスクリプトで&>を書くと、問題が起きます。dashで試すと次のようになります。

dashでは&>が別の意味になる
dash -c 'ls missing.txt &> both.txt; wait; wc -c < both.txt'
実行結果
ls: missing.txt: No such file or directory
       0

dashは&>を1つの演算子と解釈しません。ls missing.txt &(バックグラウンドで実行)と> both.txt(空のコマンドでファイルを空にする)の2つの文として読みます。構文エラーにならず、エラーは端末に出て、ファイルは0バイトになります。気付きにくい失敗です。

|&は、dashではSyntax error: "&" unexpectedという構文エラーになります。また、macOSに最初から入っている/bin/bashは3.2.57で、|&と&>>は構文エラーでした。bashのNEWSによると、この2つが追加されたのはbash 4.0です。

書き方POSIXbash 4.0以降zsh手元の結果
> file 2>&1使える使える使えるどのシェルでも同じ
&> fileない使える使えるdashは別の意味になる
&>> fileない使える使えるbash 3.2は構文エラー

シェルスクリプトを#!/bin/shで書くなら、> file 2>&1と書くのが安全です。/bin/shの実体はOSによって違い、bashとは限りません。手元の環境はls -l /bin/shなどで確かめられます。macOSの/bin/shでは&>が使えました。

パイプの実体

pipe(2)が作るカーネル内のバッファ

パイプは、pipe(2)というシステムコールが作る、カーネル内のバッファです。読み出し用と書き込み用の2つのfdが作られ、書き込み側に書いたデータを読み出し側から順に読めます。

command1 | command2は、次のように組み立てられます。

パイプラインができるまで
1. pipe で fd を2つ作る(読み出し用 r、書き込み用 w)
2. fork して command1 用の子プロセスを作る
     dup2(w, 1) で fd 1 を書き込み側にする
3. fork して command2 用の子プロセスを作る
     dup2(r, 0) で fd 0 を読み出し側にする
4. 使わない r と w を close する
5. それぞれ exec する

図にすると次のようになります。ファイルと違って、途中のlseekはできず、データは読まれると消えます。

パイプの構造
command1                  kernel                 command2
fd 1 ---> write() ---> [ pipe buffer ] ---> read() ---> fd 0

pipe(7)によると、パイプはバイトストリームで、メッセージの区切りという概念はありません。

容量と、書き手が速いときのブロック

Linuxの既定のパイプ容量は、pipe(7)によると16ページ(ページサイズが4096バイトのシステムで65,536バイト)です。Linux 2.6.11からこの値で、それより前はページサイズと同じでした。Linux 2.6.35からは、fcntlのF_GETPIPE_SZとF_SETPIPE_SZで確認と変更ができます。

パイプがいっぱいになると、書き手のwrite(2)はブロックします(O_NONBLOCKを付けている場合はEAGAINで失敗します)。逆に、読み手はパイプが空だとread(2)でブロックします。これによって、速い書き手と遅い読み手の間で、自然に速度が調整されます。これがパイプの背圧(バックプレッシャー)です。データが無限にメモリへ溜まることはありません。

ノンブロッキングのfdで複数のパイプやソケットを同時に扱う方法は、I/O多重化の記事で扱っています。

macOSで、パイプにどれだけ書けるかを測ってみます。

pipe_size.py: ノンブロッキングで書けるだけ書く
import os, fcntl
 
r, w = os.pipe()
fcntl.fcntl(w, fcntl.F_SETFL, os.O_NONBLOCK)
n = 0
try:
    while True:
        n += os.write(w, b"x" * 1024)
except BlockingIOError as e:
    print("wrote", n, e.errno, e.strerror)
print("PIPE_BUF", os.fpathconf(w, "PC_PIPE_BUF"))
実行結果(macOS 27.2の値。Linuxの仕様とは別です)
wrote 65536 35 Resource temporarily unavailable
PIPE_BUF 512

読み手が先に閉じた場合: SIGPIPEとEPIPE

パイプの終わり方は2通りあります。pipe(7)によると、次の通りです。

状況起きること
書き込み側のfdがすべて閉じられた読み手のread(2)は、残りのデータを読んだあとEOF(0バイト)を返す
読み出し側のfdがすべて閉じられた書き手のwrite(2)でSIGPIPEが発生する
上記で書き手がSIGPIPEを無視しているwrite(2)がEPIPEエラーで失敗する

SIGPIPEの既定の動作はプロセスの終了です。シグナルの詳しい話はUnixシグナルの記事にあります。よくある例がheadです。headは必要な行を読むと終了するので、書き続けている左側のコマンドがSIGPIPEで終わります。

headが閉じると、左側はSIGPIPEで終わる
yes | head -n 1
echo "PIPESTATUS=${PIPESTATUS[*]}"
kill -l 13
実行結果
y
PIPESTATUS=141 0
PIPE

141は128 + 13です。bashのmanページの通り、シグナルnで終了したコマンドの終了ステータスは128+nになり、SIGPIPEのシグナル番号は13です。

Pythonは起動時にSIGPIPEを無視する設定になっており(signal.getsignal(signal.SIGPIPE)がSIG_IGNでした)、writeがEPIPEで失敗してBrokenPipeErrorになります。

Pythonでは例外になる
python3 -c 'for i in range(1000000): print(i)' | head -n 1
echo "PIPESTATUS=${PIPESTATUS[*]}"
実行結果(Python 3.15.1)
0
Traceback (most recent call last):
  File "<string>", line 1, in <module>
    for i in range(1000000): print(i)
                             ~~~~~^^^
BrokenPipeError: [Errno 32] Broken pipe
Exception ignored while flushing sys.stdout:
BrokenPipeError: [Errno 32] Broken pipe
PIPESTATUS=120 0

スクリプトで| headの左側がエラーを出して落ちるのは、このためです。

PIPE_BUFと書き込みの原子性

複数のプロセスが同じパイプに書き込むと、データが混ざるのか。これを決めるのがPIPE_BUFです。pipe(7)によると、次の通りです。

  • PIPE_BUF以下の書き込みはアトミック(途中に他のプロセスのデータが割り込まない)
  • PIPE_BUFを超える書き込みはアトミックとは限らず、他の書き手のデータと混ざることがある
  • POSIXではPIPE_BUFは512バイト以上、Linuxでは4096バイト

先ほど測ったように、手元のmacOSではfpathconfの値は512でした。短い1行ずつのログなら混ざりませんが、大きなデータを同時に書くと混ざることがあります。

パイプラインは同時に起動する

a | b | cは「aが終わったらbを実行する」ものではありません。bashのmanページによると、パイプラインの各コマンドはそれぞれ別のプロセス(サブシェル)で実行されます。全部が同時に動いていることは、時間を測ると分かります。

sleep 2を3つパイプで繋ぐ
time ( sleep 2 | sleep 2 | sleep 2 )
実行結果
real	0m2.230s
user	0m0.005s
sys	0m0.013s

順番に実行されていれば6秒かかるはずですが、2秒ほどで終わっています。並行処理と並列処理の記事で扱ったプロセスの話そのものです。

起動する順番も決まっていません。次の例では、右側の表示が先に出ました。

左右のコマンドの開始時刻を表示する
{ echo "left start $(date +%S)" >&2; sleep 1; echo "left end $(date +%S)" >&2; } |
{ echo "right start $(date +%S)" >&2; sleep 1; echo "right end $(date +%S)" >&2; }
実行結果(秒の値は実行ごとに変わる)
right start 16
left start 16
right end 17
left end 17

パイプの中で変数を変えても残らない

各コマンドが別プロセスなので、パイプの右側でreadした変数は、シェルに戻ると消えています。これはbashでよくあるつまずきです。

パイプの右側の変数は残らない
n=0; echo x | read n; echo "n=[$n]"
n=0; read n < <(echo x); echo "procsub n=[$n]"
実行結果
n=[0]
procsub n=[x]

プロセス置換を使うか、bashならshopt -s lastpipe(ジョブ制御が無効のとき)で、最後のコマンドを現在のシェルで実行させれば値が残ります。bashのmanページによると、ジョブ制御が既定で有効なのは対話シェルです。

終了ステータス: PIPESTATUSとpipefail

パイプライン全体の終了ステータスは、既定では最後のコマンドの終了ステータスです。途中で失敗しても、最後が成功すれば成功扱いになります。

最後のコマンドの結果だけが$?に入る
false | true; echo "$?"
true | false; echo "$?"
実行結果
0
1

個々のコマンドの結果は、bashの配列変数PIPESTATUSで見られます。zshでは小文字のpipestatusです。

bashのPIPESTATUSと、上書きに注意
false | true; echo "PIPESTATUS=${PIPESTATUS[*]}"
false | true; echo "first: ${PIPESTATUS[*]}"; echo "second: ${PIPESTATUS[*]}"
実行結果
PIPESTATUS=1 0
first: 1 0
second: 0

PIPESTATUSは次のコマンドで上書きされます。echoを1回挟むとechoの結果に変わるので、必要なら先に変数へコピーしておきます。

set -o pipefailを有効にすると、右端の失敗したコマンドのステータスがパイプライン全体の結果になります。全部成功した場合は0です。

pipefailは、右端の失敗の値を返す
set -o pipefail
false | true; echo "$?"
exit3() { return 3; }; exit5() { return 5; }
exit3 | exit5 | true; echo "$?"
実行結果
1
5

pipefailは長くbashなどの拡張でしたが、POSIX.1-2024で標準に入りました。ただし実装の対応はまちまちで、手元のmacOSのdashではset: Illegal option -o pipefailというエラーでした。

注意: set -o pipefailとset -eを組み合わせると、yes | head -n 1のようにSIGPIPEで左側が終わる普通のパイプラインでも、失敗扱いでスクリプトが止まることがあります。set -euo pipefailの注意点は別の記事にまとめています。

バッファリング: 出力の順番が入れ替わる理由

stdioの既定の動作

Cのprintfなどのstdio関数は、write(2)をすぐに呼ばず、ユーザー空間のバッファに溜めてからまとめて書きます。setvbuf(3)によると、既定の動作は次の通りです。

ストリーム既定のバッファリング書き出すタイミング
端末に繋がったstdout行バッファ改行のとき
ファイルやパイプに繋がったstdoutブロック(完全)バッファバッファがいっぱいになったとき、終了時
stderr非バッファすぐ

つまり、同じプログラムでも、出力先が端末かパイプかで動きが変わります。

次のプログラムで確かめます。標準出力と標準エラー出力へ交互に書くだけです。

buf.c
#include <stdio.h>
int main(void) {
    printf("out 1\n");
    fprintf(stderr, "err 1\n");
    printf("out 2\n");
    fprintf(stderr, "err 2\n");
    return 0;
}

端末に繋いだ場合(scriptコマンドで疑似端末を用意して確認)と、パイプに繋いだ場合の結果です。

端末のとき(行バッファ)
out 1
err 1
out 2
err 2
パイプに繋ぐ
./buf 2>&1 | cat
実行結果(順番が変わる)
err 1
err 2
out 1
out 2

2>&1で同じパイプに繋いでいても、順番が変わります。stdoutはバッファに溜まったまま終了時に書き出され、stderrはすぐに書かれるからです。fdの繋ぎ替えが間違っているわけではなく、ユーザー空間のバッファが原因です。ログで「エラーが先に出ているように見える」原因は、たいていこれです。

出力が遅れて届く

同じ理由で、パイプの先へのデータが遅れることもあります。1秒ごとに1行出力するtickというプログラムをパイプに繋ぎ、受け取った時刻を付けて表示します。

パイプの先で受け取った時刻を表示する
./tick | while IFS= read -r l; do echo "$(date +%S) $l"; done
実行結果(3秒後にまとめて届く)
22 tick 1
22 tick 2
22 tick 3

stdbufとpython -uで対処する

GNU coreutilsのstdbufは、対象のコマンドのstdioのバッファリングを変更します。-oLで標準出力を行バッファにします。

stdbufで行バッファにする
stdbuf -oL ./buf 2>&1 | cat
stdbuf -oL ./tick | while IFS= read -r l; do echo "$(date +%S) $l"; done
実行結果(順番どおりで、1秒ごとに届く)
out 1
err 1
out 2
err 2
22 tick 1
23 tick 2
24 tick 3

パイプの途中のコマンドも同じようにバッファします。途中にgrepがあると、grepの所で溜まって遅れます。grepには行バッファにする--line-bufferedがあります。

途中のgrepでも溜まる
stdbuf -oL ./tick | grep tick | while IFS= read -r l; do echo "$(date +%S) $l"; done
stdbuf -oL ./tick | grep --line-buffered tick | while IFS= read -r l; do echo "$(date +%S) $l"; done
実行結果(grepの指定だけで届くタイミングが変わる)
14 tick 1
14 tick 2
14 tick 3
14 tick 1
15 tick 2
16 tick 3

stdbufのmanページには、teeのように自分でバッファリングを調整するコマンドではstdbufの指定が上書きされること、ddやcatのようにストリームを使わないコマンドには効かないことが書かれています。

Pythonは独自のI/O層を持っているので、手元ではstdbuf -oLが効かず、errが先に出たままでした。Pythonでは-uオプションか、環境変数PYTHONUNBUFFEREDを使います。

Pythonのバッファリングを止める
python3 p.py 2>&1 | cat
python3 -u p.py 2>&1 | cat
実行結果
err 1
err 2
out 1
out 2
out 1
err 1
out 2
err 2

/dev/null、ヒアドキュメント、tee

/dev/null

/dev/nullは、書き込まれたデータを捨て、読むと常に空(長さ0)のデバイスです。fdの繋ぎ先の1つなので、これまでのリダイレクトがそのまま使えます。

/dev/nullの使い方
command > /dev/null          # 標準出力を捨てる
command > /dev/null 2>&1     # 両方捨てる
command < /dev/null          # 標準入力を空にする(入力待ちで止まらないようにする)

ヒアドキュメントとヒアストリング

ヒアドキュメントは、コマンドの標準入力に複数行のテキストを渡す書き方です。区切りの語を引用符で囲むと、変数やコマンド置換が展開されません。<<-にすると、行頭のタブが取り除かれます。

ヒアドキュメント
name=world
cat <<EOT
hello $name $(echo cmd)
EOT
 
cat <<'EOT'
hello $name $(echo cmd)
EOT
実行結果
hello world cmd
hello $name $(echo cmd)

<<<はヒアストリングで、1つの文字列を標準入力に渡します。bashのmanページの通り、末尾に改行が付きます。POSIXにはなく、bashとzshで使えます。

ヒアストリング(末尾に改行が付く)
cat <<< "hello" | od -c | head -1
実行結果
0000000    h   e   l   l   o  \n

プロセス置換

<(list)は、コマンドの出力をファイル名のように渡すbashとzshの機能です。POSIXにはありません。

ファイルを作らずに2つのコマンドの結果を比較する
diff <(sort l.txt) <(sort r.txt)
echo <(true)
実行結果
2c2
< b
---
> c
/dev/fd/63

<(list)は/dev/fd/63のようなファイル名に展開されます。fdを開いたまま、そのパスをコマンドの引数として渡しているわけです。

tee

teeは、標準入力を標準出力とファイルの両方へ書き出すコマンドです。パイプの途中の様子をファイルに残せます。-aで追記になります。

画面に出しながらログに残す
echo hello | tee t1.txt t2.txt | tr a-z A-Z
cat t1.txt t2.txt
ls exists.txt missing.txt 2>&1 | tee all.log | wc -l
実行結果
HELLO
hello
hello
       2

よくある間違いとトラブル対応

sudo echo > fileが権限エラーになる

権限エラーになる書き方
sudo echo 'hello' > /etc/demo.conf

このとき、sudoが権限を与えているのはechoだけです。> /etc/demo.confは、sudoを起動する前にシェルが処理します。シェルは一般ユーザーの権限で動いているので、openが失敗します。

権限のないファイルへのリダイレクトは、シェルが失敗する
bash -c 'echo x > /etc/zzz-demo; echo rc=$?'
実行結果(メッセージの先頭がbashなのはシェルが失敗しているから)
bash: line 1: /etc/zzz-demo: Permission denied
rc=1

対処は、ファイルを開く処理自体をsudoの中で実行することです。teeをsudoで動かすか、シェルごとsudoで動かします。

sudoの中でファイルを開く(書式の例。ここでは実行していません)
echo 'hello' | sudo tee /etc/demo.conf > /dev/null     # 上書き
echo 'hello' | sudo tee -a /etc/demo.conf > /dev/null  # 追記
sudo sh -c "echo 'hello' > /etc/demo.conf"

> /dev/nullは、teeの標準出力が画面に出ないようにするためです。

sort file > fileでファイルが空になる

入力と出力に同じファイルを指定する
printf 'c\na\nb\n' > f.txt
sort f.txt > f.txt
wc -c f.txt
実行結果
       0 f.txt

シェルはsortを起動する前に> f.txtを処理して、ファイルを0バイトに切り詰めます。sortが読むときにはもう中身がありません。catやgrep、sedでも同じです。

対処は、一時ファイルを経由するか、sortなら-oオプションを使うことです。POSIXのsortは、-oの出力先は入力ファイルと同じでもよいとしています。

安全な書き方
sort -o f.txt f.txt
sort f.txt > f.txt.tmp && mv f.txt.tmp f.txt

WARNING

cat file >> fileも危険です。手元のmacOSの/bin/catでは止まらずファイルが増え続け、timeoutで打ち切る必要がありました。GNU coreutils 9.9のcatは、input file is output fileというエラーで止まります。試すときは必ずtimeoutを付けてください。

cronのログ取りは>> log 2>&1

cronでコマンドの出力を残すときの定番は次の形です。

crontabの例
30 4 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1

>>で追記し、2>&1でエラーも同じファイルに残します。cronのSHELLの既定は/bin/shなので、&>>のようなbashの書き方は避けるのが無難です。また、O_APPENDで開いているため、複数のジョブが同じログへ書いても追記の位置は末尾になります。

noclobberと>|

set -o noclobber(POSIXのset -C)を有効にすると、既存のファイルを>で上書きしようとするとエラーになります。うっかり上書きを防ぐ設定です。>|を使うと、この設定を無視して上書きできます。

noclobberと>|
echo one > nc.txt
set -o noclobber
echo two > nc.txt; echo "rc=$?"
cat nc.txt
echo three >| nc.txt
cat nc.txt
実行結果
bash: line 3: nc.txt: cannot overwrite existing file
rc=1
one
three

>>での追記は、noclobberでもbashなら問題なく動きます(ファイルがなくても作られます)。

シェルごとの違い

確認できた範囲で、POSIX sh、bash、zshの違いをまとめます。

  • &>と&>>: bashとzshにはあるが、POSIXにはない。dashでは別の意味になる
  • |&: bash 4.0以降とzshにあるが、POSIXにはない
  • pipefail: POSIX.1-2024で標準に入った。bash、zshにはあるが、手元のdashにはなかった
  • PIPESTATUS: bashの配列変数。zshは小文字のpipestatus
  • ヒアストリング<<<とプロセス置換<(list): bashとzshの機能
  • > fileだけの行: bashではファイルを空にする。zshではNULLCMD(既定はcat)が動き、標準入力を読みに行く

zshで特に違うのが、既定で有効なMULTIOSです。zshのマニュアルによると、1つのfdを複数の行き先へリダイレクトすると、zshはパイプと、入力をすべての行き先へコピーするプロセスを用意します。

zshとbashで同じことをする
echo hello > a.txt > b.txt
シェルa.txtの中身b.txtの中身
bash空hello
zsh(既定のMULTIOS)hellohello
zsh(unsetopt multios)空hello

パイプも暗黙のリダイレクトなので、zshではecho hello > c.txt | wc -cとしたときにファイルとパイプの両方にデータが流れます。手元の実行結果は、bashがパイプへ0バイト、zshが6バイトでした。入力の側でも、cat < a < bは両方のファイルを順に読みます。

なお、順番による2>&1の違いはzshでも同じです。ls exists.txt missing.txt 2>&1 > out.txtは、zshでもエラーが端末に出ました。zshのマニュアルには、&>はMULTIOSが有効な場合に> word 2>&1と同じとは限らないという注意があります。

zshでnoclobberにあたるのはCLOBBERオプションの無効化です。setopt noclobberのあとは>|のほかに>!でも上書きできます。また、手元のzsh 5.9では、noclobberを有効にすると>>で存在しないファイルに追記できず、no such file or directoryになりました(setopt append_createで作成できます)。.zshrcでnoclobberを設定している人は注意が必要です。

まとめ

  • fdはプロセスごとの番号で、その先にカーネルのopen file description、さらにその先にinodeがある
  • 2>&1は別名ではなく複製。「fd 1の今の行き先をfd 2へコピーする」という代入
  • リダイレクトは左から順に処理されるので、> file 2>&1と2>&1 > fileは結果が違う
  • シェルはforkしたあと、execの前にopenとdup2でfdを繋ぎ替える。コマンドは自分の出力先を知らない
  • パイプはカーネル内のバッファ。Linuxの既定の容量は65,536バイト(ページサイズ4096バイトの場合)で、いっぱいになると書き手はブロックする
  • 読み手が先に閉じると、書き手にはSIGPIPEが届く。無視している場合はEPIPE
  • パイプラインの各コマンドは同時に動く。終了ステータスは最後のコマンドのもので、PIPESTATUSやpipefailで途中の失敗を拾える
  • stdioのstdoutは、端末では行バッファ、パイプやファイルではブロックバッファ。stderrは非バッファなので順番が入れ替わる
  • sudo echo > fileとsort file > fileの失敗は、どちらも「リダイレクトはコマンドより先にシェルが処理する」ことが原因
  • #!/bin/shで書くなら、&>や|&は使わず> file 2>&1と書く

参考リンク

crontabの書き方入門 - cronの5つのフィールドと、動かないときに見る実行環境・ログ・二重起動

crontabの書き方入門 - cronの5つのフィールドと、動かないときに見る実行環境・ログ・二重起動

約26分

crontabは「分 時 日 月 曜日 コマンド」の1行で、決まった時刻にコマンドを動かす設定ファイルです。5つのフィールドと特殊文字(* , - /)の読み方、日と曜日を両方指定したときのOR条件、@dailyなどの短縮表記、crontab -eと/etc/cron.dの違い、PATHや%の落とし穴、タイムゾーン、ログの見方、flockによる二重起動の防止、systemd timerやGitHub Actionsのscheduleとの違いを、crontab(5)などのmanページを一次ソースに、cron式を試せるデモ付きで整理します。

UNIXシグナルとプロセスのライフサイクル - fork/exec/waitからゾンビ、PID 1問題、graceful shutdownまで

UNIXシグナルとプロセスのライフサイクル - fork/exec/waitからゾンビ、PID 1問題、graceful shutdownまで

約46分

UNIXのプロセスが生まれてから刈り取られるまでを、fork/exec/wait、終了ステータス、ゾンビと孤児、SIGTERMやSIGKILLなどのシグナル、async-signal-safe、コンテナのPID 1問題とtini、Kubernetesの終了フロー、Node.jsとPythonのgraceful shutdown実装まで一次ソース付きで解説します。

CPUスケジューラの仕組み - 古典アルゴリズムからCFS、EEVDF、sched_extまで

CPUスケジューラの仕組み - 古典アルゴリズムからCFS、EEVDF、sched_extまで

約46分

CPUスケジューラの仕組みを、スループット・レイテンシ・公平性のトレードオフ、FIFOやMLFQなどの古典アルゴリズム、LinuxのCFSからEEVDFへの移行、sched_ext、スケジューリングクラス、cgroupのcpu.maxによるスロットリング、観測方法まで一次ソース付きで解説します。

楽天市場のおすすめPR / 楽天アフィリエイト