LinuxのファイルI/Oの仕組みを徹底解説|open・read・write・Page Cache・fsync

  1. はじめに
  2. LinuxのファイルI/Oとは
    1. ファイルI/Oの基本
    2. open / read / write / close
    3. User SpaceとKernel Space
  3. ファイルを開くと何が起こるのか
    1. open()の処理の流れ
      1. ① Application
      2. ② Path Resolution(VFS)
      3. ③ dentry
      4. ④ inode
      5. ⑤ struct file
      6. ⑥ File Descriptor
  4. ファイルを読み込むと何が起こるのか
    1. read()システムコール
      1. ① read()を呼び出す
      2. ② File Descriptorからファイルを特定する
      3. ③ Page Cacheを確認する
      4. ④ Cache MissならStorageから読み込む
      5. ⑤ User Spaceのバッファへコピーする
      6. ⑥ read()の戻り値を返す
  5. ファイルを書き込むと何が起こるのか
    1. write()システムコール
      1. ① write()を呼び出す
      2. ② File Descriptorからファイルを特定する
      3. ③ Page Cacheへ書き込む
      4. ④ 必要に応じてPage Cacheのページを用意する
      5. ⑤ Dirty PageをStorageへ反映する
      6. ⑥ write()の戻り値を返す
  6. Buffered I/Oの仕組み
    1. なぜ直接Storageへアクセスしないのか
    2. Page Cacheを利用したread / write
    3. MemoryとファイルI/Oの関係
  7. fsync()を実行すると何が起こるのか
    1. fsync()はいつ実行するのか
    2. write()とStorageへの書き込みの違い
    3. fsync()の役割
    4. データが永続化されるまで
  8. ファイルを閉じると何が起こるのか
    1. close()システムコール
      1. ① close()を呼び出す
      2. ② File Descriptorを特定する
      3. ③ File Descriptorを切り離す
      4. ④ struct fileへの参照を解除する
      5. ⑤ 他の参照があるか確認する
      6. ⑥ close()の処理を完了する
    2. close()とデータの永続化
  9. まとめ

はじめに

Linuxで動作するアプリケーションは、設定ファイルの読み込み、ログの出力、データベースファイルの更新など、さまざまな場面でファイルI/Oを行っています。普段はコマンドやプログラムからファイルを操作するだけですが、その裏側ではLinuxカーネルが複数の仕組みを連携させています。

例えば、アプリケーションがread()を呼び出しても、毎回ストレージからデータを読み込むとは限りません。必要なデータがメモリ上のPage Cacheにあれば、ストレージへアクセスせずに読み込みを完了できます。同様に、write()が成功しても、その時点でデータがSSDやHDDへ永続化されているとは限りません。

この違いを理解するには、open()、read()、write()、close()などのシステムコールだけでなく、File Descriptor、VFS、ファイルシステム、Page Cache、Dirty Page、Writebackといった仕組みを一連の流れとして把握する必要があります。

本記事では、Linuxでファイルを開き、読み込み、書き込み、閉じるまでに何が起こるのかを解説します。

LinuxのファイルI/Oとは

ファイルI/Oの基本

ファイルI/Oとは、プログラムがファイルからデータを読み込んだり、ファイルへデータを書き込んだりする処理です。I/OはInput / Outputの略で、ファイルからの読み込みがInput、ファイルへの書き込みがOutputに相当します。

Linuxでは、通常のファイルだけでなく、ディレクトリ、端末、パイプ、ソケット、デバイスなども、ファイルに似た共通のインターフェースで扱います。「Everything is a file」という表現で説明されることがありますが、すべてが通常のディスクファイルと同じという意味ではなく、ファイルディスクリプタと共通の操作体系を利用できるという考え方です。

open / read / write / close

Linuxでファイルを操作するときは、一般に次のシステムコールを利用します。

システムコール主な役割
open() / openat()パスで指定したファイルを開き、File Descriptorを取得する
read()開いたファイルからデータを読み込む
write()開いたファイルへデータを書き込む
close()File Descriptorを閉じ、プロセスからの参照を解放する

最初にopen()などでファイルを開き、戻り値として得たFile Descriptorをread()やwrite()へ渡します。処理が終わったらclose()でFile Descriptorを閉じます。この流れにより、カーネルはどのプロセスがどのファイルを操作しているかを管理できます。

User SpaceとKernel Space

アプリケーションはUser Spaceで動作し、LinuxカーネルはKernel Spaceで動作します。User Spaceのプログラムは、ファイルシステムの内部データやストレージデバイスを自由に操作できません。これは、あるプロセスの不具合や不正な処理がシステム全体へ影響することを防ぐためです。

アプリケーションがファイルI/Oを行うときは、システムコールを通じてカーネルへ処理を依頼します。CPUはユーザーモードからカーネルモードへ移り、カーネルが権限や引数を確認して処理します。完了すると戻り値がアプリケーションへ返され、ユーザーモードでの実行が再開されます。

ファイルを開くと何が起こるのか

open()の処理の流れ

open()を実行すると、カーネルはファイルパスを解決して対象のファイルを特定し、アプリケーションがファイルを操作するためのFile Descriptorを返します。

下記の図の流れに沿って説明します。

① Application

アプリケーションは、ファイルパスとopen flagを指定してopen()を呼び出します。例えば、/var/log/app.logを読み取り専用で開く場合は、次のように実行します。

int fd = open(“/var/log/app.log”, O_RDONLY);

O_RDONLYは、ファイルを読み取り専用で開くことを指定するフラグです。

② Path Resolution(VFS)

open()を受け取ったカーネルは、VFS(Virtual File System)を通じて指定されたファイルパスを解決します。/var/log/app.logの場合は、ルートディレクトリからvar、log、app.logの順番にパスをたどり、対象のファイルを特定します。

このパス解決では、dentryやinodeなどが使用されます。

③ dentry

dentry(directory entry)は、ファイル名とinodeを結びつけるための情報です。図では、app.logという名前に対応するdentryからinode #12345を参照しています。

dentryはキャッシュされるため、一度参照したファイルやディレクトリのパス解決を高速化するためにも利用されます。

④ inode

inodeは、ファイルのメタデータを管理します。主に次のような情報が管理されています。

  • 所有者
  • パーミッション
  • ファイルサイズ
  • タイムスタンプ
  • ファイルデータが格納されている場所を管理するための情報

ファイル名はinodeではなく、dentry側で扱われます。図ではapp.logというdentryとinode #12345が対応しています。

⑤ struct file

対象のファイルをopen()すると、カーネルは開いたファイルの状態をstruct fileで管理します。

struct fileでは、現在の読み書き位置を示すoffsetやopen flag、inodeへの参照、ファイル操作関数への参照などを管理します。つまり、inodeがファイル自体の情報を管理するのに対して、struct fileは開いたファイルの状態を管理します。

⑥ File Descriptor

最後に、カーネルはFile Descriptorをアプリケーションへ返します。File Descriptorは、プロセスが開いたファイルを操作するために使用する整数値です。

一般的に、0、1、2は標準入出力として使用されます。そのため、通常は新しくファイルをopen()すると3以降のFile Descriptorが割り当てられます。

0 stdin 標準入力
1 stdout 標準出力
2 stderr 標準エラー出力
3 app.log

図の例では、app.logにfd = 3が割り当てられています。

アプリケーションは、その後のread()、write()、close()などで、このFile Descriptorを指定してファイルを操作します。

read(3, buf, size);
write(3, buf, size);
close(3);

このようにopen()では、ファイルパスをVFSで解決し、dentryとinodeから対象のファイルを特定します。その後、開いたファイルの状態をstruct fileで管理し、アプリケーションが利用するFile Descriptorを返します。

なお、この流れはopen()に関係する主要な要素を理解しやすいように単純化したものです。

ファイルを読み込むと何が起こるのか

read()システムコール

ファイルを開いたあと、アプリケーションはread()を使用してデータを読み込みます。read()の処理の流れを以下に示します。

① read()を呼び出す

ファイルを開いたあと、アプリケーションはread()を呼び出してデータを読み込みます。

ssize_t size = read(fd, buffer, sizeof(buffer));

read()には、対象ファイルを示すFile Descriptor(fd)、読み込み先となるUser Spaceのバッファ、読み込みたい最大サイズを指定します。

② File Descriptorからファイルを特定する

カーネルは、指定されたFile DescriptorをプロセスのFile Descriptor Tableから検索します。

File Descriptorからstruct fileを参照し、読み込み対象のファイルや現在のファイルオフセットを特定します。

通常のread()では、読み込みに成功するとファイルオフセットが読み込んだバイト数だけ進みます。そのため、同じFile Descriptorでread()を繰り返すと、ファイルの続きを順番に読み込めます。

③ Page Cacheを確認する

対象ファイルを特定すると、通常のBuffered I/Oでは、必要なデータがPage Cacheに存在するか確認します。

Cache Hitの場合は、すでにメモリ上にデータがあるため、Storageへアクセスする必要はありません。

一方、必要なデータがPage Cacheに存在しない場合はCache Missとなり、Storageからデータを読み込みます。

④ Cache MissならStorageから読み込む

Cache Missの場合、ファイル内の位置をStorage上のブロックへ対応付け、読み込み要求を送ります。

同期的なread()では、データが届くまでプロセスが待ち状態になることがあります。

Storageから読み込まれたデータはPage Cacheへ格納されます。また、連続したアクセスでは、Linuxが次に必要になりそうなデータを先読みするReadaheadを行うこともあります。

⑤ User Spaceのバッファへコピーする

Page Cache上で必要なデータが利用可能になると、カーネルはそのデータをUser Spaceのバッファへコピーします。

つまり、一般的なBuffered I/Oのread()では、Storageからアプリケーションへ直接データが渡されるわけではありません。

Storage → Page Cache → User Space Buffer

という流れでデータが移動します。

⑥ read()の戻り値を返す

最後にread()は、実際に読み込んだバイト数をアプリケーションへ返します。

  • 0より大きい値:実際に読み込んだバイト数
  • 0:ファイル終端(EOF)
  • -1:エラー

要求したサイズより小さい値が返る場合もあるため、アプリケーション側ではread()の戻り値を確認することが重要です。

このように、通常のread()では、File Descriptorからファイルを特定 → Page Cacheを確認 → 必要ならStorageから読み込み → User Spaceへコピーという流れで処理されます。

ファイルを書き込むと何が起こるのか

write()システムコール

アプリケーションはwrite()を使用して、開いているファイルへデータを書き込みます。write()の処理の流れを以下に示します。

① write()を呼び出す

ファイルへデータを書き込む場合、アプリケーションはwrite()を呼び出します。

ssize_t size = write(fd, buffer, sizeof(buffer));

write()には、対象ファイルを示すFile Descriptor(fd)、書き込み元となるUser Spaceのバッファ、書き込みたいバイト数を指定します。

② File Descriptorからファイルを特定する

カーネルは、指定されたFile DescriptorをプロセスのFile Descriptor Tableから検索します。

File Descriptorからstruct fileを参照し、書き込み対象のファイルや現在のファイルオフセットを特定します。

通常のwrite()では、書き込みに成功するとファイルオフセットが書き込んだバイト数だけ進みます。そのため、同じFile Descriptorでwrite()を繰り返すと、その続きからデータを書き込めます。

③ Page Cacheへ書き込む

通常のBuffered I/Oでは、write()で渡されたデータはUser Spaceのバッファからカーネルへコピーされ、ファイルのPage Cacheへ書き込まれます。

書き込みによって変更されたページはDirty Pageとして管理されます。この時点では、データがSSDやHDDへ書き込まれているとは限りません。

④ 必要に応じてPage Cacheのページを用意する

書き込み対象のページがPage Cacheにない場合は、カーネルが必要なページを用意します。

その後、書き込みデータが反映され、変更されたページはDirty Pageとして管理されます。

⑤ Dirty PageをStorageへ反映する

Dirty Pageは、後からWritebackによってStorageへ書き込まれます。

そのため、一般的なBuffered I/Oのwrite()では、アプリケーションのデータがwrite()の呼び出し中に必ずStorageへ保存されるわけではありません。

データをStorageへ確実に反映する必要がある場合は、fsync()などを利用します。

⑥ write()の戻り値を返す

Page Cacheへの書き込みが完了すると、write()はアプリケーションへ戻り値を返します。

0以上の値は、write()によって正常に処理されたバイト数を表します。ただし、通常のBuffered I/Oでは、この時点でStorageへの永続化が完了しているとは限りません。

要求したサイズより小さい値が返る場合もあるため、アプリケーション側ではwrite()の戻り値を確認する必要があります。

つまり、write()とStorageへの実際の書き込みは必ずしも同じタイミングではありません。Page Cacheが両者の間に入り、Dirty Pageを後からStorageへ反映するのが基本的な流れです。

Buffered I/Oの仕組み

なぜ直接Storageへアクセスしないのか

Memoryへのアクセスと比べて、SSDやHDDへのアクセスには長い時間がかかります。すべてのread()やwrite()で物理ストレージの完了を待つと、アプリケーションの応答速度が低下し、CPUもI/O待ちによって有効に利用しにくくなります。

そこでLinuxは、通常のファイルI/OでPage Cacheを利用します。読み込んだデータを再利用し、小さな書き込みをメモリ上で受け付けてからまとめて書き出すことで、ストレージアクセスの回数や待ち時間を抑えます。

Page Cacheを利用したread / write

処理Page Cacheが担う役割
read()過去に読み込んだデータを保持し、Cache Hit時にはStorageへアクセスせずにデータを返す
write()書き込みデータをDirty Pageとして一時的に保持し、後からStorageへ書き出す

ここで説明しているBuffered I/Oは、LinuxカーネルのPage Cacheを利用するI/Oを指します。C言語のstdioが提供するfread()やfwrite()のユーザー空間バッファリングとは別の層です。stdioを使用した場合は、ユーザー空間のバッファとカーネルのPage Cacheという複数段階のバッファが存在することがあります。

MemoryとファイルI/Oの関係

Linuxは、利用可能なMemoryをPage Cacheとして積極的に使用します。そのため、freeコマンドで空きメモリが少なく見えても、多くが再利用可能なキャッシュであれば、直ちにメモリ不足というわけではありません。

アプリケーションがMemoryを必要とすると、カーネルは再読み込み可能なClean Pageなどを解放できます。一方、Dirty Pageは未反映のデータを含むため、原則としてStorageへ書き出してからでなければ解放できません。大量の書き込みはMemory使用量やWritebackの負荷にも影響します。

Page Cacheを迂回するDirect I/Oもありますが、アライメントなどの制約があり、常に高速になるわけではありません。データベースのように独自のキャッシュ管理を行うアプリケーションなどで、用途に応じて選択されます。

fsync()を実行すると何が起こるのか

fsync()はいつ実行するのか

fsync()は、write()を実行すると自動的に呼び出されるシステムコールではありません。Storageへのデータの永続化を確認したい場合に、アプリケーションが明示的に呼び出します。

例えば、重要なデータを書き込んだあと、そのデータがStorageへ反映されたことを確認してから次の処理へ進みたい場合に使用します。

write(fd, buffer, size);

if (fsync(fd) == -1) {
    /* エラー処理 */
}

一般的なBuffered I/Oでは、write()とfsync()は次のような関係になります。

なお、fsync()を呼び出さなくても、Dirty PageはカーネルのWritebackによって後からStorageへ書き出されます。fsync()は「今、このファイルの変更を永続化し、その完了を確認したい」という場合に利用します。

write()とStorageへの書き込みの違い

通常のBuffered I/Oにおいて、write()の成功は、指定したデータをカーネルが受け付けたことを表します。多くの場合、データはPage Cache上のDirty Pageとなり、write()はStorageへの書き込み完了を待たずに戻ることができます。

そのため、write()成功 ≠ Storageへの永続化完了となります。

write()の直後に電源断やカーネルクラッシュが発生すると、まだStorageへ書き出されていないデータが失われる可能性があります。

トランザクションログや重要な設定変更など、処理完了を通知する前にデータの永続性を確保したい場合には、fsync()などを利用してStorageへの反映完了を待つ必要があります。

fsync()の役割

fsync()は、指定したFile Descriptorに関連する変更済みのファイルデータと、ファイルを正しく参照するために必要なメタデータをStorageへ書き出すよう要求し、その完了を待つシステムコールです。

if (fsync(fd) == -1) {
    /* エラー処理 */
}

fsync()を実行すると、対象ファイルのDirty PageなどがWritebackされ、Storageへの反映が完了するまで呼び出したプロセスは待つことがあります。

そのため、write()のみの場合と比較して待ち時間が長くなり、fsync()を頻繁に実行するとI/O性能へ影響する可能性があります。

ファイルデータを中心に同期し、永続化に不要な一部のメタデータを省けるfdatasync()もあります。

データが永続化されるまで

fsync()を呼び出すと、ファイルシステムは対象ファイルのDirty Pageや必要なメタデータを処理します。

大まかな流れは次のようになります。

デバイス側に揮発性の書き込みキャッシュがある場合は、必要に応じてキャッシュの内容を安全な媒体へ反映するためのフラッシュ処理も関係します。

ただし、fsync()による保証は、ファイルシステム、マウントオプション、デバイス、仮想化環境などが同期要求を正しく処理することを前提とします。ハードウェアが完了を誤って通知する場合や、電源断保護のないデバイスキャッシュが適切に制御されない場合には、期待した耐久性を得られない可能性があります。

また、新しいファイルを作成した場合やファイル名の変更を確実に残したい場合は、ファイル本体だけではなく親ディレクトリの更新も考慮する必要があります。

堅牢なファイル更新では、一時ファイルへのwrite()、ファイルのfsync()、rename()、親ディレクトリのfsync()などを組み合わせることがあります。

ファイルを閉じると何が起こるのか

close()システムコール

ファイルの操作が終わると、アプリケーションはclose()を使用してFile Descriptorを閉じます。

close(fd);

close()を実行すると、カーネルは指定されたFile DescriptorをFile Descriptor Tableから削除し、そのFile Descriptorからstruct fileへの参照を解除します。

大まかな流れは次のようになります。

① close()を呼び出す

アプリケーションは、不要になったFile Descriptorを指定してclose()を呼び出します。

close(fd);

② File Descriptorを特定する

カーネルは、指定されたFile DescriptorをプロセスのFile Descriptor Tableから検索します。

File Descriptorから対応するstruct fileを特定します。

③ File Descriptorを切り離す

対象のFile DescriptorをFile Descriptor Tableから切り離します。

これにより、そのFile Descriptorは使用できなくなり、アプリケーションは同じFile Descriptorを指定してread()やwrite()を実行できなくなります。

④ struct fileへの参照を解除する

File Descriptorを切り離すと、そのFile Descriptorからstruct fileへの参照を解除します。

この時点で、同じstruct fileが他のFile Descriptorなどから参照されている場合があります。

⑤ 他の参照があるか確認する

同じstruct fileへの参照が残っている場合、struct fileはそのまま残ります。

例えば、dup()によって複製されたFile Descriptorや、fork()によって作成されたプロセスが同じstruct fileを参照している場合があります。

一方、他の参照がなければ、そのstruct fileは不要になります。

⑥ close()の処理を完了する

他の参照がなければ、struct fileとそれに関連するカーネル内部のリソースが解放されます。

他の参照がある場合はstruct fileを残したまま、close()の処理を完了します。

このようにclose()では、File DescriptorをFile Descriptor Tableから切り離し、struct fileへの参照を解除します。struct fileは他の参照があれば残り、参照がなくなれば解放されます。

close()とデータの永続化

close()とfsync()は役割が異なります。

close()はFile Descriptorを閉じるためのシステムコールであり、データをStorageへ確実に永続化するためのシステムコールではありません。

通常のBuffered I/Oでは、write()によって変更されたデータがDirty PageとしてPage Cacheに残っている場合があります。close()したあとも、Dirty PageはWritebackによってStorageへ書き出されます。

そのため、重要なデータの永続化を確認してからファイルを閉じる場合は、fsync()を実行してからclose()します。

このようにclose()では、File DescriptorをFile Descriptor Tableから切り離し、struct fileへの参照を解除します。データの永続化が必要な場合は、close()とは別にfsync()などを使用する必要があります。

まとめ

本記事では、LinuxのファイルI/Oについて、open()、read()、write()、fsync()、close()の処理の流れを解説しました。

Linuxでは、open()によってFile Descriptorを取得し、read()やwrite()はFile Descriptorからstruct fileを参照して対象のファイルを操作します。

通常のBuffered I/Oでは、read()やwrite()とStorageの間にPage Cacheが存在します。read()ではPage CacheにデータがあればStorageへアクセスせずに読み込めます。write()ではデータがPage Cacheへ書き込まれ、Dirty Pageとして管理されたあと、WritebackによってStorageへ反映されます。

そのため、write()が成功してもStorageへの永続化が完了しているとは限りません。永続化の完了を確認する必要がある場合はfsync()などを使用します。

最後にclose()を実行すると、File DescriptorがFile Descriptor Tableから切り離され、struct fileへの参照が解除されます。他の参照がなければstruct fileも解放されます。

LinuxのファイルI/Oを調査するときは、システムコールだけを見るのではなく、File Descriptor、struct file、Page Cache、Dirty Page、Writeback、Storageまでの流れを理解することが重要です。

コメント