Linux におけるファイル I/O の基礎

May 10, 2017    Linux

すべてがファイルというモデルの Linux (Unix) において、ファイル I/O (以降単に I/O と書く) を知っておいて損はない。 この記事では、基本的なファイルと関連する I/O について、対応する Linux システムコールも併せて説明する。 次回はこれらを実際に Linux 上で確認する予定。

ファイル

Unix におけるファイルとは、普通「通常ファイル」のことを指し、バイトがリニアに並んだデータ (byte stream) のことである。 ファイル内のバイトは読み書きが可能で、指定されたバイトから開始する。この開始バイトはファイル内の「位置」と考えることができ、ファイルポジションまたはファイルオフセットという。

通常ファイルとは別に、スペシャルファイルというファイルとして表現されたカーネルオブジェクトがある。Linux では、スペシャルファイルとしてデバイスノード・名前付きパイプ・ソケットに対応している。名前付きパイプは、FIFO とも呼ばれ、プロセス間通信 (IPC) に使われるファイルである。ソケットは歴史的にも特殊なので、またの機会に説明する。

ディレクトリとリンク

Unix におけるディレクトリは、単にファイル名と対応する inode 番号のリストを保持するものである。ディレクトリが保持するファイル名をディレクトリエントリ、またファイル名と inode の対応をリンクと呼ぶ。

複数のリンクが同じ inode 番号を指すことも可能である。複数リンク間で、どのリンクが「主」や「元」という概念はなく、すべてのリンクは平等に扱われる。このようなリンクをハードリンクという。ファイルは任意の数のリンクを持つことができる。通常ほとんどのファイルのリンクカウントは 1 であり、1つのディレクトリエントリが1つの inode を指す。 ハードリンクの追加は link(2) で行える。

リンクの種類には、シンボリックリンクもある。シンボリックリンクは、ファイルシステムがマッピングするものではなく、実行時に解釈される、より上位で処理されるポインタである。 実際には、ディレクトリエントリ追加ではなく、特殊な型を持つ専用ファイルである。 この専用ファイルは、他のファイルのパス名を格納するもので、これをシンボリックリンクのターゲットという。シンボリックリンクのパス名は、実行時にカーネルにより参照先へ置換される。 ハードリンクと異なり、シンボリックリンクはファイルシステムに跨って作成することが可能で、また存在しないファイルに対しても作成可能である。対応するシステムコールは symlink(2) である。 リンク作成の反対は、アンリンク、すなわちパス名の削除である。unlink(2) が対応する。 ディレクトリを削除するには rmdir(2) を用いる。

デバイスノード

Unix におけるデバイスへのアクセスはデバイスノードというスペシャルファイルを介して行われる。 アプリケーションから、デバイスドライバへアクセスするためのデバイスノードに対してファイル I/O を行うと、カーネルは通常のファイル I/O として処理せず、要求をデバイスドライバへ渡す。 デバイスノードはデバイスを抽象化したもので、Unix システムでハードウェアにアクセスする際の標準的なインターフェースとなっている。この設計は、マシン上のハードウェアを統一的に操作できるという美しいインターフェースであり、Unix の大きな功績の1つである。ただし、ネットワークデバイスだけは例外である。 カーネルは、メジャー番号とマイナー番号というデバイスドライバに割り当てられた2つの数字を用いて、カーネル内にロードされたデバイスドライバにマッピングすることにより、要求された処理をどのデバイスドライバに渡すかを決定している。

デバイスノードは主にキャラクタデバイスブロックデバイスの2つに分類される。 キャラクタデバイスの典型はキーボードで、バイトがリニアに並んだものとしてアクセスし、デバイスドライバがキューにデータを1つずつ挿入し、ユーザ空間からキューのデータを読み取る。 対照的に、ブロックデバイスはデータをバイト配列としてアクセスするものであり、通常はストレージデバイスである。 デバイスドライバは、シーク可能なデバイスにデータバイトをマッピングし、ユーザ空間からはバイト配列内ならばどのバイトにもアクセス可能である。 ブロックデバイスへのアクセスはセクタという通常 2 の累乗 (512 バイトが多い) で表される単位で行われ、すべての I/O は1つまたは複数のセクタ単位で行われる。

キャラクタデバイスとブロックデバイスに該当しない特殊なデバイスノードもある。 典型的な例である /dev/null は null device を表し、メジャー番号は 1, マイナー番号は 3 である。このデバイスに対する書き込みは暗黙的にカーネルがすべて破棄し、読み取りは常に EOF を返す。類似した /dev/zero は zero device を表し、メジャー番号は 1, マイナー番号は 5 である。読み取りは常に null 文字を返し、書き込みは常にエラーとなり ENOSPC がセットされる。 /dev/random/dev/urandom という、カーネルの乱数生成デバイスもある。

アプリケーションからデバイスを制御する場合には、ioctl(2) (I/O control) を使う。ioctl(2) が発行されると、カーネルは fd に対応するファイルシステムまたはデバイスドライバを特定し、要求を渡す。

ファイルシステム

ファイルシステムとは、ファイルの集合体で、ある形式で階層化された構造である。ファイルシステムは通常ディスク上に保存される。物理ファイルシステムの場合、デバイスはパーティショナブルであり、複数のファイルシステムを使用することが可能である。

ファイルシステムへのアクセスは、ブロック (論理ブロック) という単位で行われる。ブロックはファイルシステムの概念であり、物理メディアの概念ではない。通常はセクタサイズの2の累乗になり、一般にはセクタサイズより大きいが、ページサイズを超えることはない。ページとは、MMU (Memory Management Unit) がメモリを扱う最小単位である。

ファイルへのアクセスは、通常ファイル名を使用するが、実際にはファイル名とファイルは直接対応していない。ファイルに実際に対応するのは inode (information node, or indexed node) であり、ファイルシステムにおいて inode を一意に識別する数字を inode 番号という。 inode はアクセスパーミッション、アクセス時刻、オーナ、グループ、サイズなどのメタデータを保持する。 ファイルのメタデータは stat(2) システムコール郡で参照できる。stat(2) は引数にファイルパス、fstat(2) は引数に fd をとる。lstat(2) は対象がシンボリックリンクパスの場合、シンボリックリンク自身の情報を返す。

ファイル操作

ファイル操作で最も基本的なのはコピーと移動である。 ファイルシステムレベルでは、ファイルコピーとはファイルの内容を新規パスへ複写することを指す。ハードリンクの新規作成とは異なり、他方には影響しない異なるファイルが、異なるディレクトリエントリとして存在する状態である。 Unix では、ファイル・ディレクトリをコピーするシステムコール・ライブラリを提供していない。 cp(1) コマンドなどは、src と dst のファイルをオープンし、src の内容を読み取り dst へ書き込むという操作を行い、ファイルコピーを実現している。

対してファイル移動は、ディレクトリエントリのファイル名の変更を行う。新たなファイルができるわけではない。ファイル移動のシステムコールは存在し、rename(2) が対応する。 POSIX 標準では、ファイルとディレクトリの両方に対応する。

ファイルイベント監視

Linux では inotify というインターフェースを提供しており、ファイルが移動された、読み取られた、書き込まれた、削除されたなどのイベントを監視することが可能である。 inotify を使用すると、ファイル関連のイベントをカーネルがアプリケーションへ通知する。 詳細は省略する。

ファイル I/O に関連した事項

ここでは、ファイル I/O を説明する上で必要な知識を説明する。

ページキャッシュ

ページキャッシュは、最近アクセスしたファイルシステムのデータをメモリに蓄えるものであり、第一に参照されるキャッシュとして機能する。このページキャッシュは、参照の局所性の1つ、時間的局所性に基づく。すなわち、ある時点で参照されたデータは、高確率でその後すぐに参照されるというものである。 すべての読み書きは、ページキャッシュを介して透過的に、すなわちページキャッシュが存在しないかのように行われることが保証される。

Linux のページキャッシュは動的にサイズを変化させ、効率を向上させる。ページキャッシュがすべてのメモリを使い果たし、新たなメモリ割り当て要求が発生すると、ページキャッシュは切り詰められ (prune) 使用頻度の低いページが解放される。この操作はカーネルにより自動的に行われる。

参照の局所性のもう1つは、空間的局所性であり、データ参照はシーケンシャルに行われるというものである。この原理を応用し、カーネルではページキャッシュの先読み (readahead) を実装している。先読みとは、ディスクデータにアクセスする際、その先のデータを余分にページキャッシュに読み取る機能である。実際には、データを1ブロック分読み取ると、同時に先の数ブロック分も読み取る。データをまとめて読み取ることで、コストが最も高いディスクシークを省略できる。ページキャッシュと同様に、先読み量も動的に変化する。

ディスクとアドレッシング

古典的な磁気ディスク (ハードディスク) では、ディスク上のデータを特定するために、シリンダ・ヘッド・セクタを使用する CHS アドレッシングを行っていた。 ハードディスクは複数のプラッタ、スピンドル (回転軸)、ヘッドである。 プラッタは平滑な円盤状の記録板であり、スピンドルを中心として複数個積み重なっている。 ヘッドはプラッタの1面あたりに1つ対応する。プラッタは同心円状に並ぶトラックに分割され、さらにトラックはセクタに分割される。 シリンダは、データが存在するトラックを表す。ヘッドはプラッタに対応し、シリンダ・ヘッド・セクタの3つでディスク上のセクタ位置を特定できる。実際にはセクタの位置まで対応するヘッドを動かすことで読み書きする。

いい感じの図

現代では、シリンダ・ヘッド・セクタの組み合わせに対して一意の通し番号であるブロック番号をマッピングすることで、(物理) ブロックからセクタを特定できる。このアドレッシング方式は LBA (Logical Block Addressing) という。

I/O

ファイルを読み書きするためにはファイルをオープンする必要がある。オープンとは、対象ファイルをカーネルが扱える形で紐付ける操作である。カーネルはファイルテーブルを持ち、各プロセスがオープンしたファイルを管理している。実際にはファイルディスクリプタ (fd)という非負整数値がファイルと対応する。 明示的にクローズしない限り、プロセスは3つのオープンされた fd である 0, 1, 2 を持つ。それぞれ標準入力、標準出力、標準エラー出力に対応する。 ファイルオープンは open(2) システムコールで行う。読み書きは read(2), write(2) で行う。 read(2) は EOF (End-Of-File) を戻り値 0 で表す。EOF はエラーではなく、ファイルポジションがファイルの末尾まで到達した状態である。「データが存在しない」ことと「EOF」は異なる。 fd を使い終えたら、close(2) システムコールにより fd と ファイルの対応付けを解消する。

I/O システムコールは、エラーの場合戻り値に -1 をセットするとともに、エラーの原因をグローバル変数 errno に設定する。その他、ファイルポジションを移動させる lseek(2), ファイルをトランケートする ftruncate(2) などのシステムコールがある。

同期 I/O

書き込みを行うシステムコール write(2) を発行すると、通常は渡したバッファがカーネル空間のバッファにコピーされた後リターンされる。このとき、バッファの内容がディスクに書き出される保証はない。 その後カーネルはコピーされたバッファ (dirty バッファ) を集め、効率が良くなるように並び替えてからディスクへ書き出す (writeback という)。この仕組みを遅延書き込みと呼ぶ。遅延書き込みは書き込みの効率を上げる一方で、書き込み順序が指定できず、プロセスのエラー処理が正しくできない場合もある。 同期書き込みは、バッファを書き出すことを保証する I/O である。 fsync(2)fdatasync(2) でバッファの書き出しができる。また、open(2) の際に O_SYNC フラグを渡すと以降の I/O はすべて同期的に行われる。

ノンブロッキング I/O

通常 read(2) を発行し、読み取れるデータがまだ無い場合はブロックされる。ここでのブロックとは、スリープして待っていることである。特にシングルスレッドアプリケーションでは、I/O がブロックされてしまうとアプリケーション全体が停止してしまう。データがまだ無い場合にはリターンしてそれを判断する、という動作をノンブロッキング I/O という。 ノンブロッキング I/O を発行するには、open(2)O_NONBLOCK フラグを渡す。 ノンブロッキング I/O は、リターンする際に errnoEAGAIN をセットする。

多重 I/O

アプリケーションで複数の fd を同期的に管理する場合、ある I/O がブロックされると他の I/O もその影響を受けてしまう。 この問題に対し、ノンブロッキング I/O を発行する方法では、I/O を適当な順番で発行し続けなければならず、効率が悪い。 そこで、複数の I/O を多重に管理することが必要である。Linux では select(2), poll(2), epoll(2) という多重 I/O システムコールを提供する。カーネル側で複数の fd を一度にテストでき、いずれかの fd が使用可能になった場合に通知を受け取ることが可能である。

select(2) は、指定された fd のいずれかが使用可能になるまでまたは指定時間経過するまでブロックする。select(2) に渡す fd は3種類あり、それぞれ読み取り可能を調べる fd, 書き込み可能を調べる fd, 例外を調べる fd である。 select(2) が成功すると、I/O が可能になった fd だけを残すように fd のセットを変更する。 poll(2) は System V 由来の多重 I/O であるが、歴史的にあまり使用されない。

epoll(2) (event poll) は Linux 固有の多重 I/O であり、select(2)poll(2) の改良版である。 select(2)poll(2) では、システムコール発行のたびに fd のセットを渡す必要があった。逐一調べるのではスケーラビリティのボトルネックになるため、epoll(2) では、実際にイベントを調べる fd と、システムコールで渡す fd を切り分け、システムコールを3つに分解している。まず epoll_create(2) によって対応する fd (実際のファイルに対応するものではない) を得る。次に epoll_ctl(2) で epoll fd に調べたい fd を対応付ける。実際にイベントを調べるには epoll_wait(2) を使う。

ユーザバッファリング I/O

小さな I/O を多数発行するアプリケーションは、I/O のパフォーマンスが悪い。 すべてのディスク I/O は、ブロックに基づき処理されるため、ブロックサイズの整数倍でアラインメントされた I/O の方がパフォーマンス有利であるためである。ブロックサイズは 512, 1024, 2048, 4096 バイトのいずれかであるため、4096, 8192 バイトのどちらかにアラインメントすれば十分である。

しかし、アプリケーションではブロックという抽象概念で処理することはまずないため、バッファリングによって、あるバッファサイズにまでデータが溜まったら一度にバッファ全体を書き出すことをする。これがユーザバッファリング I/O である。 標準 I/O ライブラリ関数 (stdio) は、堅牢かつ実用的なユーザ空間のバッファリングを行う。 標準 I/O ライブラリ関数では、fd を直接使用せず、ファイルポインタという独自の識別子を扱う。 ファイルポインタは FILE typedef を指すポインタとして stdio.h で定義される。 標準 I/O では、オープンしたファイルをストリームと呼ぶ。ストリームはバッファリングの実装であり、文字・行・バイナリという単位での読み書きの API が提供される。ファイルポジションに対応する、ストリームポジションに対する API もある。 fflush(2) によってストリームをフラッシュしてカーネルバッファへの書き込みを保証できる。 実際にディスクへの書き出しを保証するには更に fsync(2) などが必要。

標準 I/O は、データを読み取る際、カーネルから標準 I/O ライブラリのバッファにコピーし、さらにそのバッファからアプリケーションバッファへコピーするという、二重コピーによるパフォーマンス低下が問題とされている (書き込みも同様)。

scatter-gather I/O

scatter-gather I/O は、複数のバッファを一度のシステムコールで読み書きする I/O である。 ベクタ I/O とも呼ばれ、この文脈では通常の I/O はリニア I/O と呼ばれる。 ベクタ I/O は、複数のリニア I/O を1つにまとめることができ、更にベクタ I/O 内部の最適化によってパフォーマンスは向上する。

read(2), write(2) に対応する scatter-gather I/O は readv(2), writev(2) である。独立したバッファはセグメントと呼ばれ、複数のセグメントをまとめたものをベクタと呼ばれる。readv(2), writev(2) は内部的にはセグメントを逐次処理する。

メモリマッピング I/O

標準ファイル I/O の変形として、ファイルをメモリ (プロセスアドレス空間) にマッピングするインターフェースも提供している。マッピングすると、ファイルデータはバイト単位でメモリアドレスと対応し、メモリへアクセスすることで直接ファイルにアクセス可能になる。

fd に対してマッピングを行うのが mmap(2) システムコールである。 マッピングはページ単位で行われる。ファイルサイズがページサイズにアラインメントされていない場合、ゼロパディングされる。Linux では getpagesize(2) でページサイズを知ることができる。 マッピングしたメモリ領域は munmap(2) により削除できる。

mmap(2) の利点は、まず read(2), write(2) で発生するバッファコピー処理が無いことである。 ページフォルトが発生する可能性はあるが、メモリマッピングの場合ではシステムコールやコンテキストスイッチのオーバーヘッドが発生しない。 また、複数のプロセスが同じファイルをマッピングした場合、データはプロセス間で共有できる。シーク操作は単純なポインタ操作で済み、lseek(2) を発行する必要がない。

一方で注意点もある。適切にアラインメントせずにマッピングすると、多くのアドレス空間が無駄になってしまう。様々なサイズのマッピングを行うと、フラグメンテーションにより大きな連続した領域が確保できなくなる恐れもある。以上から、mmap(2) が効果的なのは、ファイルサイズが大きく、マッピングサイズがページサイズで割り切れる場合である。

I/O へのヒント

カーネルに対して、I/O 最適化のためのヒントを与えることができる。 posix_fadvice(2) は、例えばある範囲内をデータをシーケンシャルにアクセスする予定がある ことや逆にランダムアクセスする予定がある、このデータはすぐにアクセスする、一度しかアクセスしない、などの情報を与える。 また、readahead(2) という Linux 固有のインターフェースもある。 これらのヒントは、ページキャッシュの扱いに対して影響する。

例えば、POSIX_FADC_WILLNEED を用いてアクセス予定のデータ範囲を渡すと、カーネルは非同期にデータを読み込み、アプリケーションが実際にファイルへアクセスする時は I/O がブロックされず読み込むことができる。逆に、動画ストリーミングなど大量のデータを読み書きした後には、POSIX_FADV_DONTNEED によって、対象データをページキャッシュから破棄することをカーネルに通知できる。 ファイル全体を読み取る予定のアプリケーションでは、POSIX_FADV_SEQUENTIAL を用いてより多く先読みするように通知できる。

I/O スケジューラ

現代のシステムでは、ディスクがパフォーマンス面で大きく劣っている。足を引っ張っているのがシーク (seek) で、ディスクヘッドが移動する処理である。シークの回数と移動量を最小化するために、カーネルには I/O スケジューラが実装されている。I/O スケジューラは、処理する I/O 要求の順序と回数を管理し、ディスクアクセスによるパフォーマンス劣化を最低限に留めるものである。 ただし、ディスクドライブをエミュレートできる SSD は、ディスクシークが発生しないため、ディスクと比較するとディスクアクセスのコストは格段に低い。

I/O スケジューラの基本的な処理はマージとソートである。マージは複数の隣接するディスクブロックへの I/O 要求を1つの I/O 要求へ変換する処理であり、ソートは待機中の I/O 要求をブロック番号が昇順になるように並び替える処理である。これらの処理によって、ディスクヘッドの移動量は最小になり、ヘッドがあちこちに移動せず直線的に移動することによってパフォーマンスを向上させる。

一般的なアプリケーションでは、短い時間に複数の read I/O 要求を発行するが、要求は他の要求完了に依存していることがある。このとき、I/O スケジューラによって常にソートが行われると、結果的により長く I/O がブロックされてしまう恐れがある。そこで、この問題を防ぐ仕組みとして、初期には単純な方式である Linus エレベータ が採用された。I/O 要求キュー内に、一定時間以上残されたままの I/O がある場合には、ソートせずにキューに追加するという動作である。単純なゆえに問題もあり、後に複数の新しい I/O スケジューラが登場した。

I/O パフォーマンスの最適化

I/O は現代のコンピューティングにおいて非常に重要であるが、コストも高いため、最大限の I/O パフォーマンスを引き出すことが必要になる。例えば以下の項目を意識する。

  • 多数の小さい I/O を少数の大きな I/O にまとめるなど、I/O 処理数を最小化する
  • I/O をブロックサイズでアラインメントする
  • ユーザバッファリング I/O を使う
  • ベクタ I/O, 非同期 I/O などを活用する

参考文献

  • このエントリーをはてなブックマークに追加


comments powered by Disqus