HN 日本語サマリー

← 一覧へ戻る
プログラミング

読み書きはあなたが(おそらく)思うよりも対称性が低い

Reading and writing are less symmetric than you (probably) think (fgiesen.wordpress.com)

7 pointsby luu0 コメント

要約

コンピューティングにおけるI/O操作、特にread()とwrite()システムコールは、一見対称的に見えますが、バッファリングの仕組みにより、エラー処理やデータ永続性の保証において重要な非対称性があります。読み込み操作では、エラーはデータがアプリケーションに渡される前に検出されることが多いですが、書き込み操作では、データがディスクに書き込まれるまでエラーが遅延し、プログラムがこれらの遅延を適切に処理しないとデータ損失のリスクが生じます。

全文翻訳

読み書きはあなたが(おそらく)思うよりも対称性が低い

ここで言うI/O操作とは、コンピューティングにおけるI/O操作のことです。このようなものが公開される典型的な例は、POSIXのread(2)およびwrite(2)システムコールであり、これらは以下のC言語の関数プロトタイプを持っています。

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

これらは生のシステムコールであり、ユーザープログラムは直接使用できますが、通常はそうしません。通常はバッファリングされたI/Oレイヤーを経由します。C標準ライブラリでは、これはFILE*とfreadおよびfwrite関数を意味します。これらはcountを2つの値の積に分割しますが、それ以外は同等です。具体的に、実際のストレージ(パイプ、ソケット、仮想ファイルシステムなどではない)とインターフェースすると仮定しましょう。「read」クラスの操作(readやfreadなど)は、ディスク上のファイルからバイトを取得し、特定のメモリバッファに配置します。「write」クラスの操作は、メモリバッファ内のバイトを取得し、ディスクに書き込みます。これは確かにきれいで対称的に聞こえますが、特にエラーが混在する場合、それらの間には重要な動作上の非対称性があります。その理由はバッファリングにあります。

バッファリングされたI/O

一般的に、プログラム内のファイルI/O操作はストレージデバイスに直接行われません。データは複数のバッファリングレイヤーを通過します(これらの多くはさまざまなフラグを使用して無効にできますが、通常の利用ではこれらのレイヤーは有効になっています)。これらのレイヤーが存在するのには正当な理由があります。カーネル側には、伝統的に「バッファキャッシュ」と呼ばれるものがあります。ストレージデバイスは「ブロックデバイス」であり、データをブロック単位で格納します。ブロックサイズはデバイスによって異なります。古いハードディスクでは512バイトでしたが、CD、DVDなどは2kブロックを使用し、新しいストレージデバイスは現在4kブロックを使用しています。ブロックデバイスは一度に完全なブロックのみを読み取ります。これは、「バイトオフセット1234567から100バイトを読み取る」や「場所987654に2000バイトを書き込む」といったランダムなバイト単位のI/O要求は、デバイスに直接渡すことができないことを意味します。バッファキャッシュは、これらの要求をデバイスが理解できるブロック単位の読み書き操作に変換するために使用されます。ブロック単位でない書き込みでは、上書きされないブロックの以前の内容を読み取る必要があり、それらもバッファキャッシュに格納されます。そしてもちろん、名前が示すように、キャッシュとして機能します。

ユーザー空間側にもバッファがありますが、理由は異なります。readとwriteはシステムコールであり、カーネル空間への遷移と復帰が発生します。また、呼び出されるたびにエラーをチェックして報告する必要があります。そしてもちろん、私たちが望む作業を実行する必要があります。つまり、バッファキャッシュからデータをコピーする(read)か、バッファキャッシュにコピーする(write)ことです。システムコールのオーバーヘッドはOSによって異なりますが、最良の場合でも、全体的なプロセスで少なくとも数百クロックサイクルかかることを想定するのは安全です。したがって、オーバーヘッドが実際に行われる作業を完全に支配しないようにするには、通常、一度に数キロバイトを読み書きしたいと考えます。参考までに、現在のI/Oバッファサイズは、4096バイト(例: Visual C++ 2013 FILE*、Go bufio.Reader/bufio.Writer)または8192バイト(例: GNU libc FILE*、Java BufferedReader/BufferedWriter)が一般的です。

多くの場合、さらに多くのバッファが存在します。例えば、ほとんどのハードディスクやRAIDコントローラーには独自のキャッシュがあり、ユーザー空間コードがさまざまな理由で複数のバッファリングレイヤーを持っていることは珍しくありません。しかし、これは基本的な構造を説明するには十分です。

これらのバッファはすべて、読み書きでほぼ同じように使用されます。では、私が話している読み書き間の動作上の非対称性はどこにあるのでしょうか?あなたは、readタイプの呼び出しを発行した後の世界の状況(いわば)と、writeタイプの呼び出し後の世界の状況がどのように異なるかを考える必要があります。

I/O操作を発行したときに何が起こるか

まず、readタイプの呼び出しの処理を見てみましょう。CのFILE*を開いてfreadで最初の100バイトを読み取りたいとします。C標準I/Oライブラリは、バッファが現在空であることを認識し、それを満たそうとします。4k分のデータを読み取るためにreadシステムコールを発行します。次にカーネルは、ファイルシステムにそのファイルの最初の4k分のデータがどこにあるかを尋ね、バッファキャッシュにすでにメモリ内にコピーがあるかを確認し、なければストレージデバイスにブロック読み取りコマンドを発行します。いずれにしても、カーネルはその4kのデータをバッファキャッシュに確実に格納し、そこからユーザー空間メモリの標準I/Oバッファにコピーしてから戻ります。標準I/Oライブラリは、システムコールの結果を見て、I/Oバッファの状態を更新し、要求された100バイトをアプリが提供したメモリバッファにコピーします。

そして、もし何かがうまくいかなかったらどうなるでしょうか?例えば、ファイルが100バイトより小さい場合、ディスクからの読み取りエラーが発生した場合、またはファイルが現在アクセスできないネットワークファイルシステム上にある場合です。もしそれが起こった場合、私たちはそれを捉えます。バッファキャッシュを満たす際に何か問題が発生した場合、カーネルはそれを検出し、適切なエラーをI/Oライブラリに返します。I/Oライブラリは、さらにそのエラーを適切なタイミングでアプリに渡すことができます。いずれにしても、起こりうることはすべてfread呼び出しが戻る前に起こります。介在するすべてのレイヤーは、エラー情報をアプリに適切なタイミングで渡せるように保持するだけで済みます。

今度は逆の方向に行きましょう。4kの書き込みバッファを持つ新しいファイルを新規に開き[1]、100バイトのfwriteを発行します。今回は、I/Oライブラリがアプリバッファから書き込みバッファに100バイトをコピーします…そしてすぐに成功を報告して戻ります。基盤となるwriteシステムコールは、バッファが満杯になるか、fflush、fseek、fcloseなどの呼び出しの結果としてフラッシュされるまで実行されません。

簡単な想像上の挙手:これを読んでいる人で、freadまたはfwriteの戻り値を常にチェックしている人はどれくらいいますか?「はい」と答えた人で、fflush、fseek、またはfcloseの戻り値をチェックすることを覚えている人はどれくらいいますか?おそらく多くはないでしょう。さて、もしあなたがそうしないなら、あなたの書き込みが成功したかどうかを実際にチェックしていることにはなりません。そして、これらの注意点はC言語固有ですが、この一般的なパターンはすべてのバッファリングされた書き込み実装に当てはまります。バッファリングされた書き込みは、実際のwriteシステムコールの実行を遅延させます。それがポイントです。しかし、それはエラー報告も遅延することを意味します!

さらに多くのバッファ

この種の問題は、ユーザー空間のバッファリングに限定されません。write自体の実装にも同様の問題があります。一般的に、成功したwrite呼び出しの後、データはバッファキャッシュに到達しましたが、実際のストレージにはまだ到達していません。カーネルは、そのデータを最終的にストレージに書き込むために最善を尽くします(うまくいけば数秒以内に)、しかしデバイスエラーやシステムクラッシュが発生した場合、そのデータは失われる可能性があります。これらは最近では比較的まれなので、あまり心配していませんよね?心配している人たちを除いては。

ああ、そしてwriteはローカルファイルシステムへの書き込み時に不快な驚きがないようにするために(例えば、遅延書き戻しでも、ディスク上の空き容量を早期に確保したい[2]、さもないと書き戻しの間に容量不足になる可能性があります)、ある程度の努力をしますが、少なくともPOSIXシステムでは、特にNFSやSMB/CIFSのようなネットワークファイルシステムが関与している場合、close時にのみ通知される書き込みエラーが存在する可能性があります(Windowsでこのような遅延報告されるエラー条件があるかどうかはわかりませんが、存在しないとは限りません)。注意すべき点:これらのシステムコールを使用しており、closeの戻り値をチェックしていない場合、エラーを見逃している可能性があります。

ここで別の点に触れます。ローカルファイルシステム上でも、データがバッファキャッシュに到達したという保証しかありません。必ずしもストレージデバイスに到達したわけではありません!もしそれを望むなら(例えば、重要なデータを書き終えたばかりで、それが実際にすべて到達したことを確認したい場合)、ファイルを閉じる前に、ファイルディスクリプタに対してfsync[3]を呼び出す必要があります。Windowsの同等機能はFlushFileBuffersです。

したがって、すべてのwriteでエラーコードをチェックし、closeする前にfsync(再度エラーをチェックする)を行った場合、それらすべてを行った後、安全であり、データは正常に永続ストレージに到達したということになりますよね?

さて、最後に2つの難題があります。第一に、RAIDコントローラーやストレージデバイス自体にもキャッシュがあります。システムが突然電源を失った場合に、データを安全に書き込むための十分な電力が供給されるように、十分なコンデンサを備えているはずです。それが実際に真実であることを願っています。幸運を祈ります。第二に、データはストレージに到達したかもしれませんが、それにアクセスするために必要なメタデータがまだ書き込まれていない可能性があるため、それが実際に表示されているとは限りません。Linuxのfsync(2)のマニュアルページを引用します。

fsync()を呼び出しても、ファイルを含むディレクトリ内のエントリがディスクに到達したことは必ずしも保証されません。そのためには、ディレクトリのファイルディスクリプタに対する明示的なfsync()も必要です。

良くも悪くも、これを実際のコードで行っているのを見たことがありません。正直なところ、一般的なLinuxファイルシステムがこれらのことに関してどのような実際の保証を提供しているかはわかりません。本当に本当に重要なデータを扱っている場合は、調べるべきです。

結論と要約

読み込み側のバッファリングは素晴らしく、ほぼ透過的です。なぜなら、何か問題が発生した場合、データを見る前に問題が発生し、適切なエラーコードが得られるからです。

書き込み側のバッファリングははるかにトリッキーです。なぜなら、実際の書き込みとエラー報告を遅延させるからです。これはほとんどのプログラマーが認識しているはずですが、実際にはそうではありません。データが安全にストレージに到達したことを保証するために必要な実際のステップを認識している人はほとんどおらず、関連するOSの抽象化の一部は物事を簡単にはしていません(上記のfsyncの引用を参照)。ここには危険があります。

注釈

[1] 行バッファリングモードではなく、フルバッファリングモードの場合です。細かく気にする人がいるといけないので。
[2] 実際のブロック割り当て、つまりデバイス上のどの物理的な場所が書き込み先になるかの選択は、現代のファイルシステムではしばしば遅延されます。これにより、大きなファイルに対して可能な限り連続したスペースを割り当てやすくなります。しかし、遅延割り当てでも、現在保留中のすべての書き込みが完了したときにデバイスで利用可能になるスペースの量を追跡しておく必要があります。これにより、ユーザーが重要なドキュメントを編集して保存するために使用していたアプリを終了した10秒後にディスク容量不足になるのではなく、適切に「ディスク容量不足」エラーを返すことができます。それは悪いことでしょう。これは単なる会計の問題のように聞こえますが、エクステントを使用し、ビットマップベースのブロック割り当てを使用しないファイルシステムでは複雑になります。デバイス上の最後のいくつかの非連続ブロックを取得するには、ファイルエクステントを格納するために追加のスペースが必要になる場合があります!これらすべてが言いたいのは、これを正しく行うのは難しいということです。
[3] はい、名前はCライブラリのバッファリングI/Oパッケージの一部のように見えますが、これは正式なシステムコールです。