ファイルディスクリプタ

さて、前回、プロセスがファイルを通じて外部との入出力する様を見て見ました。今回はさらにプロセスとファイル入出力について詳しく見てみましょう。

前回はさらっと流してしまいましたが、実はプロセスは自分自身で実際にファイルを開いたりディスクに書き込んだりディスクからデータを読み出したりすることはありません。そういう低レイヤーの処理は、プロセスがシステムコールをOSに送ることで、OSが代わりに行ってくれます。そのあたりの話を、きちんと見て行きましょう。

さて、なにはともあれ、プロセスが入出力をしたいと思ったら、ファイルを開くところから始めないといけません。

さて、ファイルを開いたら、今度はそこになにかを書き込んでみましょうか

じゃあ、今度はファイルを閉じましょう

と、こんな感じでファイルの入出力が行われているのですが、この「番号札」のことを、「ファイルディスクリプタ」と呼びます。実際、ファイルディスクリプタは整数値で表現されています。

例を見てみましょう。今回もRubyを使います。

# fd.rb
file = File.open("nyan.txt","w") # openシステムコールでnyan.txtを書き込みモードでopen
puts file.fileno # fileno メソッドで、ファイルディスクリプタ(番号札)を取得
file.close #fileをclose

1行目で、openシステムコールをOSに対して送っています。正常にopenされると、ファイルディスクリプタを内部に持ったfileオブジェクトが生成されます。2行目で、fileオブジェクトが保持しているファイルディスクリプタを取得してターミナルに出力しています。3行目で、fileを閉じていますが、これはRubyが内部でfileオブジェクトが保持しているファイルディスクリプタを使って、OSにcloseシステムコールを送っているわけです。IO#readlineとかIO#writeメソッドなんかも、内部ではIOオブジェクトが保持しているファイルディスクリプタを使って、読み込みのためのシステムコールを送ったり書き込みのためのシステムコールを送ったりしているわけですね。

さて、説明がすんだところで、実際にfd.rbを実行してみましょう。

$ ruby fd.rb
5

「nyan.txtが書き込みモードで開かれたもの」についてる番号札が、5番なのが確認できましたね。

標準入出力のファイルディスクリプタ

さて、勘のいいひとはそろそろ例の標準入力は0、標準出力は1、標準エラー出力は2、という謎の数字の正体について、感付きつつあるのではないでしょうか。そうです。実は、「標準入力のファイルディスクリプタは0、標準出力のファイルディスクリプタは1、標準エラー出力のファイルディスクプタは2」なのです。実際に確かめてみましょう

# std_fds.rb
puts $stdin.fileno  # => 0
puts $stdout.fileno # => 1
puts $stderr.fileno # => 2

おー。

つまり、前回出てきた & という記号は、「ファイルパスじゃなくてファイルディスクリプタを指定してるぜ」という意味の記号だったわけですね!そして、なぜリダイレクトのときに標準入力や標準出力にあのような数字が使われているのかが理解できたと思います。

オープンファイル記述

さて、今はプロセスの側からがファイルディスクリプタをどう扱っているかについて見てみましたが、今度はOSの側から見てみましょう。

OSのお仕事は、「プロセスからファイルの操作を頼まれたら、代わりにやってあげること」です。そのためには、OSは実際のdiskの読み書きの他に、少なくとも以下の仕事をしないといけません。

「ファイルの状況どうなってるっけメモ」を保持しておかないと、「次の行読み込んでよ」ってプロセスから言われたときに「ふぇぇ、次の行ってどこ〜〜〜〜〜」ってなっちゃいますよね。あるいは、どの「ファイルの状況どうなってるっけメモ」がどのプロセスの何番の番号札と紐づいているのかを覚えておかないと、あるプロセスが「5番の番号のやつに書き込んでよ」って言ってきても、「ふぇぇ、書き込みたいけどどのメモ見ればいいのか忘れちゃったよ〜〜〜」ってなっちゃいます。

このとき、この「ファイルの状況どうなってるっけメモ」にあたるのが、オープンファイル記述と呼ばれるものです。OSは、「ファイル開いて」っていうシステムコールを受け取ると、オープンファイル記述を作り出して自分で保持しておきます。さらに、システムコールを送ってきたプロセスのidに対して、新しい番号札(ファイルディスクリプタ)を返します。このとき、オープンファイル記述とプロセスidと番号札の関連も、自分の中に保持しておきます。

これで、たとえばpidが100番のプロセスから「5番のファイルの、次の行読み込んでよ」と言われても、「ふぇぇ」ってならずに、「100番のプロセスさんの5番の番号札に紐づいたメモはこれだな」「メモには/path/to/fileの3行目まで読み込んだって書いてあるな」「じゃあこのファイルの4行目を読み込めばいいね!」「はいできた!」と言ってデータを返すことができるわけですね!

イメージを図にすると、こんな感じになります。

ファイルディスクリプタの作成 ファイルへの書き込み

ファイルディスクリプタ/オープンファイル記述とfork

さて、では、forkしたとき、ファイルディスクリプタやオープンファイル記述はどうなるのでしょうか?

先に答えを言ってしまいましょう。forkした場合、ファイルディスクリプタは複製されますが、複製されたファイルディスクリプタは同一のオープンファイル記述を参照します。

言い方を変えると、forkした場合、OSは新しいpidのために新しい番号札は作るけど、その番号札は同じ「ファイルの状況どうなってるっけメモ」に紐づけられてる、ということです。つまり、「ファイルの状況どうなってるっけメモ」は、親プロセスと子プロセスで共有するメモになります。

そのため、forkしたときに同じ番号札(ファイルディスクリプタ)にたいして親プロセスと子プロセス両方で操作をすると、おかしなことになることがあります。

オープンファイル記述は複製されない

例を見ましょう。

# fork_fd.rb
# -*- coding: utf-8 -*-

read_file = File.new("nyan.txt","r")

# ファイルをopenしたあとにforkしてみる
pid = Process.fork

if pid.nil?
  # 子プロセス
  lines = []
  while line = read_file.gets
    lines << line
  end
  write_file = File.new("child.txt","w")
  write_file.write(lines.join)
  write_file.close
else
  # 親プロセス
  lines = []
  while line = read_file.gets
    lines << line
  end
  write_file = File.new("parent.txt","w")
  write_file.write(lines.join)
  write_file.close
end
read_file.close

子プロセスと親プロセスで、nyan.txtから一行ずつ入力を受け取っています。もしもforkされたときに「ファイルの状況どうなってるっけメモ」まで複製されているならば、親プロセスが一行読み込んだとき親プロセスの「ファイルの状況どうなってるっけメモ」は一行分進みますが、子プロセスの「ファイルの状況どうなってるっけメモ」は書き変わらないので、親プロセスでの読み込みは子プロセスでの読み込みに影響を与えないはずですね。つまり、親プロセスでも子プロセスでも、同じくファイルの内容をすべて読み込むことができるはずです。逆に、親と子が共通の「ファイルの状況どうなってるっけメモ」を参照しているならば、親プロセスで一行読み込んだら、共通の「ファイルの状況どうなってるっけメモ」が1行分進んでしまい、子プロセスではその行を読み込むことができなくなってしまいます。

では実際に確かめて見ましょう。nyan.txtに以下の内容を書き込んだ上で、fork_fd.rbを実行してみましょう

nyan
nyan nyan
nyan nyan nyan
nyan nyan nyan nyan
nyan nyan nyan nyan nyan
nyan nyan nyan nyan nyan nyan

実行します

$ ruby fork_fd.rb

さて、結果はどうなったでしょうか?オープンファイル記述が複製されていないことが実感できたかと思います。

ファイルディスクリプタは複製される

では今度は、ファイルディスクリプタは複製されているのを見てみましょう

# -*- coding: utf-8 -*-
file = File.open("nyan.txt","r")

# ファイルをopenしてからforkする

pid = Process.fork

if pid.nil?
  #子プロセス
  sleep 1 # 親プロセスがfileを閉じるのを待つ

  # 親プロセスがfdを閉じてても、自分はまだ番号札を持ってるから読み込める
  puts file.readlines.join

  file.close #自分も番号札を返す
else
  # 親プロセス
  file.close #番号札をOSに返す
  Process.wait(pid) #子プロセスが終わるの待つ
end

実行してみると、親プロセスがすでに番号札をOSに返してしまっても、子プロセスは複製された番号札を持っているので問題なくファイル操作ができているのが見て取れると思います。

このあたりのイメージを図にするとこんな感じです。

forkされたときのイメージ オープンファイル記述が共有されている

どうするのがベストプラクティスなの?

すでにfileがopenされている状態でforkすると、以上に見たように予期せぬ動作で混乱することがあります。そのため、forkした場合、親プロセスで使わないファイルは親プロセスですぐ閉じる、子プロセスで使わないファイルは子プロセスですぐ閉じるとすると、最も問題が起きにくいと思います。子プロセスでファイルを閉じたとしても、親プロセスでファイル使いたい場合に問題なく扱える(またはその逆も)のは、上に見た通りですからね

リダイレクトの順序ふたたび

さて、forkした際のファイルディスクリプタ、オープンファイル記述の振る舞いについては上に見たとおりです。では今度は前回謎の挙動として上げておいた、「リダイレクトの順序」について見てみましょう。

まずは、リダイレクトの順序の謎はどのようなものだったか簡単に復習してみましょう。

$ ruby stdout_stderr.rb 1>out.txt 2>&1

とすると、プロセス内で標準出力に書き出したものも標準エラー出力に書き出したものも out.txt に出力されるが

$ ruby stdout_stderr.rb 2>&1 1>out.txt

とすると、標準エラー出力に対する出力は、依然としてコンソールに出力されてしまう、というのがその謎の挙動でしたね。

このような挙動が何故起こるのか。それは、リダイレクトが実際にどのように実現されているのかを理解すると見えてきます。

リダイレクトはファイルディスクリプタの複製である

1>out.txt とすると、標準出力に対して出力した出力が、なぜコンソールにではなく out.txt に出力されるのか、その動きを見てみましょう。実は、1>out.txt というのは、「out.txtを書き込みモードで開いて、そのファイルディスクリプタを複製したものを fd:1(標準出力) とする」という意味なのです。

さて、ではここで、標準出力になにかを出力してみましょう。標準出力に対する書き込みは fd:1 に対する書き込みです。今、fd:1 は、out.txt を指していますね。こんな具合で、標準出力に対する書き込みは、out.txt に書き込まれることになるわけです。

では今度は、 2>&1 としたときのことを考えてみましょう。これは、「fd:1 を複製したものをfd:2 とする」という意味になりますね。これにより、fd:2 に対する書き込みは、fd:1 と同じ、ターミナルへ出力されることになります。

では、合わせ技を行ってみるとどうなるでしょうか。まずは意図通り動くパターンから見てみます。

$ command 1>out.txt 2>&1

まず、"1>out.txt" が評価されます。それによって、fd:1 は、out.txtを指すことになります。つぎに、"2>&1" が評価されます。この時点でfd:1 は out.txt を指していますから、fd:2 もout.txtを指すようになります。これで、無事に fd:1 (標準出力)に対する書き込みも out.txt に書かれるし、fd:2 (標準エラー出力)に対する書き込みも、out.txt に書かれるようになりました。Yay!

次に意図通りでないパターンを見ましょう。

$ command 2>&1 1>out.txt

まず、"2>&1"が評価されます。fd:2 は fd:1を複製したものになりますね。このとき、fd:1 はまだ変更されていないため、デフォルトのターミナルを指しています。というわけで、fd:2 はターミナルを指すことになります。次に、"1>out.txt" が評価されます。out.txt を書き込みモードで open して、そのファイルディスクリプタの複製が fd:1 になります。これで fd:1 は out.txt を指すようになりました。今、ファイルディスクリプタはどうなっているでしょうか? fd:1はout.txtを指していますが、fd:2はターミナルを指していますね。ここで、標準エラー出力(fd:2)に対して書き込みを行えば、当然、その出力結果はターミナルに出力されることになるわけです。Oops!

次回予告

ソケットの話してpreforkサーバーを自分で書いてみるつもり