Carpe Diem

備忘録

リダイレクトの仕組みを深掘りする

背景

$ コマンド > outfile 2>&1

$ コマンド 2>&1 > outfile

は結果が異なります。
前者は標準出力、標準エラーともにoutfileに出力されるのに対し、後者は標準出力はoutfileに、標準エラーはターミナルに出力されます。

この違いを理解するためにUnixのシステムを深掘ってみます。

カーネルデータ構造

Unixシステムでファイルをオープンした場合は以下のようなデータ構造になります。

f:id:quoll00:20191219062003p:plain

ref: https://www.usna.edu/Users/cs/aviv/classes/ic221/s16/lec/21/lec.html

  • プロセステーブルエントリ
  • ファイルテーブルエントリ
  • v-nodeテーブルエントリ

の3つの階層構造になっており、それぞれ説明します。

プロセステーブルエントリ

各プロセスはプロセステーブルに1つエントリを持ちます。プロセステーブルエントリにはオープンしているfdの表があり、以下を含みます。

  • fdフラグ
  • ファイルテーブルエントリへのポインタ

ファイルテーブルエントリ

カーネルはオープンしているすべてのファイルについてファイルテーブルを管理しています。
ファイルテーブルエントリは以下を含みます。

  • 読み取り、書き出し、追加書き、同期、非ブロックなどのファイルステータスフラグ
  • カレントファイルオフセット
  • ファイルのv-nodeテーブルエントリへのポインタ

v-nodeテーブルエントリとi-node

v-nodeは複数のファイルシステムを扱えるようにしたための抽象化メカニズムです。i-nodeへのポインタを含みます。

i-nodeはファイルに関する属性を持ち、実際のデータブロックへのポインタを持ちます。

f:id:quoll00:20191219070612p:plain

f:id:quoll00:20191219070547p:plain

ref: Section 4.14.  File Systems

stdoutとstderrはどうなっているか

一般的にプロセスのfd 1はstdout用のファイルテーブルエントリと、fd 2はstderr用のファイルテーブルエントリと紐付いています。
そして実際に紐づくファイルはtty(ターミナル)になっています。

f:id:quoll00:20191219063726p:plain

ファイルテーブルエントリにはオフセットの概念があり、そこをベースにread()/write()するのでstdout用のファイルテーブルエントリとstderr用のファイルテーブルエントリはどのプロセスも共有していると考えられます。

f:id:quoll00:20191219081957p:plain

つまり左の構造ではなく、右の構造ですね。

リダイレクトをするとどういうことが起きるのか

まずは

  • 2>&1
  • > outfile

とそれぞれがどういう挙動になるかを説明します。

2>&1パターン

2>&1のようにfd同士を扱う場合はdup2(1, 2)がコールされます。dup2()はfdの複製を作るシステムコールですが、中身は同じファイルテーブルエントリを参照するfdを生成する仕組みです。

f:id:quoll00:20191219075005p:plain

dup2(oldfd, newfd)はnewfdが開いている場合はまずクローズし、その後oldfdを複製します。なのでfd 2は一度閉じられます。

strace

システムコールを追うとこうです。

$ strace sh -c "echo foo 2>&1"
~~省略~~
fcntl(2, F_DUPFD, 10)                   = 10
close(2)                                = 0
fcntl(10, F_SETFD, FD_CLOEXEC)          = 0
dup2(1, 2)                              = 2
write(1, "foo\n", 4foo
)                    = 4
dup2(10, 2)                             = 2
close(10)                               = 0

標準エラー出力のttyへ繋がってるファイルテーブルエントリの参照が途切れないようにfd 10に退避してますね。

> outfileパターン

> outfileの場合は、新しくそのファイルを開くのでopen()が呼ばれて新しいファイルテーブルエントリが用意されます。
そしてfd 1の向き先をそのファイルテーブルエントリに変更します。

f:id:quoll00:20191219075511p:plain

strace

$ strace sh -c 'echo foo > foo.txt'
openat(AT_FDCWD, "foo.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 3
fcntl(1, F_DUPFD, 10)                   = 10
close(1)                                = 0
fcntl(10, F_SETFD, FD_CLOEXEC)          = 0
dup2(3, 1)                              = 1
close(3)                                = 0
write(1, "foo\n", 4)                    = 4
dup2(10, 1)                             = 1
close(10)                               = 0

こちらも標準出力のttyへ繋がってるファイルテーブルエントリの参照が途切れないようにfd 10に退避してますね。

一緒に使うとどうなるか

それでは組み合わせた場合どうなるかを考えます。

ポイントはシェルは左から右に処理を実行する点です。

コマンド > outfile 2>&1

  1. fd 1fd 2もそれぞれターミナルに紐づくファイルテーブルエントリを参照している
  2. > outfileはoutfileのファイルテーブルエントリを作成し、fd 1をそのファイルテーブルエントリに向ける
  3. 2>&1fd 2fd 1の向いているファイルテーブルエントリ(outfile)を参照するようにする

よってfd 1fd 2もoutfileへ出力されます。

コマンド 2>&1 > outfile

  1. fd 1fd 2もそれぞれターミナルに紐づくファイルテーブルエントリを参照している
  2. 2>&1fd 2fd 1の向いているファイルテーブルエントリ(ターミナル)を参照するようにする
  3. > outfileはoutfileのファイルテーブルエントリを作成し、fd 1をそのファイルテーブルエントリに向ける

よってfd 1はoutfileへ、fd 2はターミナルへ出力されます。

まとめ

カーネルのデータ構造を学ぶことでリダイレクトの仕組みをきちんと理解することができました。

ソース