かーねるさんとか

発言は個人の見解であり、所属する組織の公式見解ではありません。

ファイルシステムについてざっくり理解する

ファイルシステムについて勉強したことについて書いてみようと思います。

今回は、概観について、ふわっと理解できるような資料になるように書いていきたいと思います。 読んでいただくと、read、write システムコールの裏側で何が起きているか若干想像がつくようになるかもしれません。

これから勉強をしてみようと思われる方の参考になれば幸いです。

ファイルシステムの概観

ファイルシステムについて見ていく時には、3つの層を考えると良いと思われます。

カーネル空間のファイルシステムが、ユーザー空間のアプリケーションと永続ストレージデバイス(ディスク)の間にたって、ファイルという抽象化を提供するというのが大まかな構成になります。

以後、簡単のため、永続ストレージデバイスについては、ディスクと書きます。

インターフェース

次に、各層の間でのコミュニケーションのインターフェースについて考えます。

アプリケーションとファイルシステム間のインターフェース

アプリケーションとファイルシステムは、主に read、write システムコールを通じてやりとりを行います。

read と write システムコールの定義は、以下のようになっています

ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);

上記のシステムコールはそれぞれ3つ引数があり、fd はファイルデスクリプタ、buf はユーザー空間のメモリアドレス、count は読み書きのバイト数です。

システムコール自体の定義は上のようになっていますが、アプリケーションはシステムコールを通じて、 

あるファイルの、ある箇所を、N バイト読み書きしてください

ということをファイルシステムへリクエストしていると考えると以降の部分についてわかりやすいかもしれません。

ファイルシステムとディスク間のインターフェース

ファイルシステムを含む OS の視点からは、ディスクは連続的な固定サイズのデータブロックの集合として見えます。ディスクは、通常、データブロックに対してブロック(セクター)番号をもとに読み書きのアクセスが行えるようになっています。

ファイルシステムはディスクに対して、デバイスドライバを通じて読み書きをリクエストしますが、主に以下の3点を指定します。

  • ディスク上のブロック(セクター)番号
  • 読み込みをリクエストする場合は、ディスクから読み出したデータが配置されるメモリ上のアドレス、書き込みをリクエストする場合は、書き込みたいデータが置いてあるメモリ上のアドレス
  • 読み書きのデータの長さ

ファイルシステムの役割

重要なのは、ディスク自体は、上記の番号ベースのインターフェースのように、言われた通りにデータを指定された場所へ読み書きすることしかできないので、ファイルという概念を認識できないことです。

言い換えると、アプリケーションが、あるファイルを読み込んでくださいというリクエストをディスクに対して直接送りたくても、ディスクからするとデータはあるけど、どれがファイルのどの部分かわからないということになります。

指定されたファイルの指定箇所からデータを読み取る、ということをするためには、ディスクという連続的なデータブロックの、どこに要求されたデータがあるか、という位置に関する情報が必要となります。

ファイルシステムの主な仕事は、まさにその、ディスク上のどこになにが置いてあるかを記録しておき、アプリケーションの要求に応じてデータを読み出す、もしくは書き込む、ということになります。

読み書きの流れ

上記の点を踏まえて、ファイルシステムの読み書きの大まかな流れを図にしてみました。 

図中の番号については、ざっくりした処理の順番なのですが、処理の順番が時と場合による部分が多分にあるため、図示されている番号は全ての場合に適応されるわけではないことにご留意ください。

書き込み処理

まず、書き込みから見ていきます。

https://raw.githubusercontent.com/yasukata/asset/master/img/fsbasic01_20200622/fswrite.png

図は、アプリケーションが write システムコールを通じて、file1.txt という名前のファイルの 300 バイト目から 5000 バイトにかけて、4700 バイトの書き込みを行うときの例を示しています。

図中の fd は file1.txt というファイルのファイルデスクリプタであると思ってください。

また、図中の lseek は、書き込みの開始位置をファイルの 300 バイト目に指定していることを明示するために書いてあります。

まず、write システムコールは、ファイルシステムの視点からは、file1.txt への 300 バイト から 5000 バイトまでの書き込みリクエストに見えます(図中番号1)。

write システムコールによって、処理のコンテキストがカーネル空間に切り替わると、まず始めに、ページキャッシュと呼ばれるカーネル空間内のメモリ領域に、アプリケーションが書き込みをリクエストしている引数 buf で指定されたデータをコピーします。この処理は、図中番号2の部分にあたります。

ここで一つポイントは、ファイルシステムは多くの場合、4キロバイト (厳密には 4096 バイト) 単位でファイルのコンテンツデータを管理しています。これは、OS がメモリを4キロバイトごとに区切ったページと呼ばれる単位を元に管理していることに起因しているように思われます。 

今回の例では、一つ目のページキャッシュのページは 300 バイト目から、4095 バイト目までを保持(赤い部分)、二つ目のページは、4096 バイト目から 5000 バイト目にかけてのデータ(青い部分)を保持します。このとき、アプリケーション側では、4キロバイトのアラインメントに対して配慮する必要はありません。

ここまでが write システムコールで行われる処理で、図中の番号3以降は、実装次第ですが、必ずしもシステムコールの中で実行されません。

図中の1と2番の処理に関しては、アプリケーションとファイルシステム間のやりとりですが、3番以降は、ファイルシステムとディスク間のコミュニケーションです。

図中番号3以降のファイルシステムとディスクとのやりとり、特に書き込みは、多くの場合、アプリケーションとは独立した、カーネルスレッドで実装され、アプリケーションの処理とは非同期でディスクの書き込みが行われます。ディスクへの書き込みは一般的にとても時間がかかるため、書き込みを外部のスレッドへ任せることで、アプリケーションへのディスク書き込みの遅延の影響を抑えようとする意図があります。

書き込みスレッドは、定期的に(例えば30秒ごと)、もしくはページキャッシュが新しい書き込みデータでいっぱいになってしまった場合に OS によって書き込みを始めるように促されます。そのときに、初めて図中3番の処理が開始されます。

書き込みスレッドがまず始めにすべきことは、新しいデータをディスクのどこに書き込むか決めることです。この書き込み位置の決定はファイルシステムの設計において非常に重要で、性能や機能に大きく影響があります。 (詳細は別途記事を書く予定です。)

今回の図の例では、ファイルシステムは、なんとなくページキャッシュの1ページ目、300 バイトから 4095 バイト目まではディスク上のブロック番号 400 へ、2ページ目は 950 へ書くことにしたようです。

ここで重要なのが、ファイルシステムは、この、file1.txt の1ページ目(最初の4KB)がブロック番号 400、2ページ目(4キロバイトから8キロバイトにかけて)がブロック番号 950 に書かれている、という情報も保持しなければならないということです。このような情報はメタデータと呼ばれ、メモリ( DRAM ) 上のデータが消えてしまった再起動後にもアクセス可能なように、ファイルのコンテンツがディスク書き込まれるときに、一緒に書き込まれます。このメタデータの管理は、ファイルシステムの設計において非常に重要で難しい部分になります。(詳細は複雑なので、こちらも別の記事として書こうと思っています。)

書き込み先を決定したら、次は、デバイスドライバを通じて、ページキャッシュ上のデータをディスクへ書いてもらうようにリクエストを送信します(図中番号4)。

その後、リクエストに応じて、データが指定したディスク上の位置に書き込まれます(図中番号5)。

ここまでがおおまかな、ユーザー空間のデータがディスクまで送り届けられるまでの流れです。

省略した点として、図の write システムコールの例は、ページの一部にしか書き込みを行わないため、実際は、データがページキャッシュになかった場合、0 〜 299 バイト目と、5000 〜 8191 バイト目までをページキャッシュへロードするために、図中番号1と2の処理の間にディスクからの読み込みが行われます。ファイルサイズが0で対応するデータがディスク上にないことが明らかな場合はこの限りではありません。もしくは、書き込み前のファイルサイズが1ページに収まっていた場合は、読み込みは最初の1ページのみだけになります。また、今回の例のように書き込みがページの一部ではなく、1ページ全体を上書きする場合には、この読み込みは不要になります。

読み込み処理

次に、読み込み処理を見ていきます。

https://raw.githubusercontent.com/yasukata/asset/master/img/fsbasic01_20200622/fsread.png

今度の例では、アプリケーションが read システムコールを使って、先ほど file1.txt へ書き込んだ 300 〜 5000 バイト目までのデータを読み込みます。最初の段階ではページキャッシュにデータがないと思って見てください。

まず、読み込みをリクエストするためにアプリケーションは read システムコールを発行します。(図中の番号1にあたります。)

実行コンテキストがカーネル空間へ移行し、ファイルシステムは、対応するデータがどこにあるかについて考えます。当たり前ですが、データはファイルシステムが自分で書き込んだ場所にあります。書き込み側の例では、300 〜 4095 バイト目までは、ディスク上のブロック番号 400 番に、4096 〜 5000 バイトにかけてはブロック番号 950 に書き込みました。この位置に関する情報は、ファイルシステムメタデータとして保持しており、このように、読み込みの際には参照されます。(保持の形式等については別の記事に分ける予定です。)

メタデータを参照することによって、ファイルシステムは file1.txt の 300 〜 4095 バイト目までは、ディスク上のブロック番号 400 番に、4096 〜 5000 バイトにかけてはブロック番号 950 にあることがわかりました。これが図中2番です。

データの場所がわかったので、デバイスドライバを通じて、ディスクからの読み出しをリクエストします(図中3番)。このとき、メモリ上の読み出し先としてページキャッシュを2ページ分新たに確保しておきます。

ディスクからの読み出しリクエストの結果、データは、ページキャッシュへ読み出されます(図中4番)。

最後に、ファイルシステムは、ページキャッシュに読み込まれたデータを、read システムコールの引数 buf で指定されたユーザー空間のメモリにコピーします(図中5番)。

これによって、アプリケーションは、ディスクに保存されたデータを取り出すことができました。

もう少しだけ厳密な読み込みの流れ

もう少しだけ厳密に読み込み処理の流れを書くと以下のようになります。

https://raw.githubusercontent.com/yasukata/asset/master/img/fsbasic01_20200622/fsrdseq.png

ポイントとして、ページキャッシュに前回の読み込みもしくは書き込みによって、すでに必要なデータがあった場合には、read システムコールはページキャッシュからデータをユーザー空間へコピーするだけで完了します。この場合、read システムコールの中で、ディスクからの読み取りは不要なので、ディスクへデータを読み取りに行った場合と比べてかなり遅延が短縮されます。

物理メモリのサイズが大きいほど、ページキャッシュも多く用意できるため、大きい容量のメモリを購入すると、ファイルからの読み込みの時間が短縮できる場合が増える可能性があります。 特に、ディスクが遅いほど効果が高そうです。

その他の情報

  • ファイルシステムの多くは、ページキャッシュを経由しないでディスクへの読み書きを行うオプション(O_DIRECT)を提供しています。
  • ページキャッシュは、他の箇所でのメモリ使用量と使用頻度に応じて解放されます。システム全体のメモリ使用量が切迫してくると、OS はページキャッシュを積極的に解放しようとします。
  • fsync のようなシステムコールを利用すると、アプリケーションは明示的にディスクへの書き出しをファイルシステムへリクエストできます。

まとめ

  • ファイルシステムはアプリケーションとディスクを仲介して、ファイルの概念を連続的なデータブロックの集合であるディスクの上に実装する。
  • write システムコールは、基本的にユーザー空間からカーネル空間のメモリコピーで完結し、多くの場合、ディスクへの書き込みは非同期で行われる。
  • read システムコールは、読み込みたいデータがすでにページキャッシュにあれば、カーネル空間からユーザー空間へのメモリコピーだけで完結し、もしページキャッシュにデータがなければディスクから読み込む。