Skip to content

Latest commit

 

History

History
301 lines (209 loc) · 18.8 KB

File metadata and controls

301 lines (209 loc) · 18.8 KB

ファイルディスクリプタ

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

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

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

番号札システムで理解するファイル操作

プロセスとOSの間のファイル操作は、図書館の貸し出しシステムのような「番号札」を使ったやり取りで行われています:

ファイルを開くとき:

  • プロセス:「この本(ファイル)を借りたいです」
  • OS:実際に本棚からファイルを取り出して貸し出しカウンターに用意
  • OS:「はい、3番の番号札です。この札で本を識別してください」
  • プロセス:3番の番号札を受け取る

ファイルに書き込むとき:

  • プロセス:「3番の番号札の本に、これを書き足してください」
  • OS:「3番ですね。はい、書き込み完了しました」

ファイルを閉じるとき:

  • プロセス:「3番の番号札の本、もう使わないので返却します」
  • OS:「3番ですね。本を本棚に戻して、番号札も回収しました」

この「番号札」のことを、正式には**「ファイルディスクリプタ」**と呼びます。実際、ファイルディスクリプタは3、5、7のような整数値で表現されています。

以降、この概念に慣れ親しんでもらうために「ファイルディスクリプタ」という正式名称で呼んでいきます。

例を見てみましょう。

# fd.rb
# File.open:ファイルを書き込みモードで開く(openシステムコール)
file = File.open("nyan.txt","w")
# fileno:OSから登用されたファイルディスクリプタ(整数の番号札)を取得
puts file.fileno
# File.close:ファイルを閉じてファイルディスクリプタをOSに返却
file.close

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

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

$ ruby fd.rb
6

「nyan.txtが書き込みモードで開かれたもの」についてるファイルディスクリプタが、6番なのが確認できましたね。(環境によって異なる数字が表示される場合があります)

特別なファイルディスクリプタ:標準入出力

さて、勘のいいひとはそろそろ例の標準入力は0、標準出力は1、標準エラー出力は2、という謎の数字の正体について、感付きつつあるのではないでしょうか。

そうです。実は、プロセスが生まれた時から持っている3つの「口」には、あらかじめ決まったファイルディスクリプタが割り当てられているのです:

  • 標準入力のファイルディスクリプタ → 0番
  • 標準出力のファイルディスクリプタ → 1番
  • 標準エラー出力のファイルディスクリプタ → 2番

つまり、どのプロセスも「0番のfd=キーボードからの入力」「1番のfd=画面への出力」「2番のfd=エラーメッセージ用の画面」という3つのファイルディスクリプタを最初から持っているということですね。実際に確かめてみましょう

# std_fds.rb
# 標準入出力のファイルディスクリプタを確認
puts $stdin.fileno  # 標準入力 => 0
puts $stdout.fileno # 標準出力 => 1
puts $stderr.fileno # 標準エラー出力 => 2

おー。

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

&記号の意味をファイルディスクリプタで理解すると:

  • 2>&1 → 「ファイルディスクリプタ2番を1番と同じ場所に向けて」
  • 1>file.txt → 「ファイルディスクリプタ1番をfile.txtに向けて」

オープンファイル記述

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

OSが管理する「貸出台帳」システム

図書館で番号札を発行する司書さん(OS)は、どの本がどんな状態で貸し出されているかを把握しておく必要がありますよね。OSも同じで、以下の管理業務を行っています:

OSの管理業務:

  1. プロセスに「ファイル開いて」って言われたら実際にファイルを開く
  2. そのファイル専用の「貸出台帳」(詳細情報メモ)を作成
  3. 台帳に詳細情報を記録:
    • 読み込み/書き込みどちらのモード?
    • ファイルの場所はどこ?
    • 現在どこまで読み込んだ/書き込んだ?
  4. プロセス専用の番号札を発行
  5. 「この番号札はこの貸出台帳と対応している」という関係を記録
  6. 番号札をプロセスに渡す

この**「貸出台帳」が、正式には「オープンファイル記述」**と呼ばれるものです。

以降、この概念にも慣れ親しんでもらうために「オープンファイル記述」という正式名称で呼んでいきます。

ファイルディスクリプタだけでは「3番のファイル」としか分からないけど、オープンファイル記述があることで「3番のファイルは現在5行目まで読んでいて、読み込み専用で開いている」といった詳細な状況がOSに分かるわけですね。

オープンファイル記述がないと、プロセスから「次の行読み込んでよ」って言われても「ふぇぇ、次の行ってどこ〜〜〜〜〜」ってなっちゃいます。ファイルディスクリプタとオープンファイル記述の対応関係が分からないと、「5番のファイルディスクリプタに書き込んでよ」って言われても「ふぇぇ、どのオープンファイル記述を見ればいいのか忘れちゃったよ〜〜〜」ってなっちゃいます。

これで、たとえばpidが100番のプロセスから「ファイルディスクリプタ5番のファイルの、次の行読み込んでよ」と言われても、「ふぇぇ」ってならずに、OSは以下のように対応できます:

  1. 「100番のプロセスさんのファイルディスクリプタ5番に対応するオープンファイル記述はこれだな」
  2. 「オープンファイル記述には/path/to/fileの3行目まで読み込んだって書いてあるな」
  3. 「じゃあこのファイルの4行目を読み込めばいいね!」
  4. 「読み込み完了!オープンファイル記述も4行目まで読んだって更新しておこう」
  5. 「はい、データをお返しします!」

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

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

ファイルディスクリプタとオープンファイル記述はforkでどうなる?

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

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

図書館の例で説明すると:

fork前:

  • 親プロセス:「3番のファイルディスクリプタ」を持ち、「『夏目漱石全集』の100ページまで読んだ」というオープンファイル記述に対応

fork後:

  • 親プロセス:「3番のファイルディスクリプタ」を持つ
  • 子プロセス:「3番のファイルディスクリプタ」(複製)を持つ
  • 両方とも同じオープンファイル記述「『夏目漱石全集』の100ページまで読んだ」を共有

つまり、OSは新しいプロセス用に新しいファイルディスクリプタは発行するけど、そのファイルディスクリプタは同じオープンファイル記述に紐づけられているということです。オープンファイル記述は、親プロセスと子プロセスで共有されることになります。

そのため、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
  # 子プロセスの結果をchild.txtに書き込み
  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
  # 親プロセスの結果をparent.txtに書き込み
  write_file = File.new("parent.txt","w")
  write_file.write(lines.join)
  write_file.close
end
# ファイルを閉じる(親と子両方で実行)
read_file.close

子プロセスと親プロセスで、nyan.txtから一行ずつ入力を受け取っています。

もしもオープンファイル記述まで複製されているならば:

  • 親プロセスが一行読み込んだとき → 親のオープンファイル記述が一行分進む
  • 子プロセスのオープンファイル記述は独立しているので影響を受けない
  • 結果:親も子も同じファイル内容を全部読み込める

実際はオープンファイル記述が共有されているので:

  • 親プロセスが一行読み込んだとき → 共有のオープンファイル記述が一行分進む
  • 子プロセスが次に読み込もうとすると → オープンファイル記述はすでに一行分進んでいるので、その行はもう読めない
  • 結果:親と子でファイルの内容を「取り合う」ことになる

では実際に確かめて見ましょう。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を閉じるのを待つ

  # 親プロセスがファイルディスクリプタを閉じても、
  # 子プロセスは複製されたファイルディスクリプタを持っているので読み込める
  puts file.readlines.join

  file.close # 子プロセスもファイルディスクリプタをOSに返却
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サーバーを自分で書いてみるつもり