はじめに
この記事は、Techouse Advent Calendar 2024 13日目です。
昨日は ショーン さんによる インターン生が突如現れた最強CTOとペアプロした話 でした。
13日目は、Higashiji が担当します。
先日、新機能の開発を進める中で、ファイルへの書き込みを扱うコードを書く機会がありました。
その際、Ruby でファイル書きこみを行う際のバッファリングの仕組みと、それを制御する IO#flush
と IO#fsync
というメソッドに改めて注目することになりました。
本記事では、これらのメソッドがどのような役割を果たし、どのような場面で利用すべきかを紹介します。
今回取り扱う内容は、Ruby を使用している方だけでなく、他のプログラミング言語でも共通して役立つ知識ですが、特に私と同じような Ruby 初心者の方の参考になれば幸いです。
基本概念の整理: ファイル I/O とバッファリング
ファイルにデータを書き込むとき、データは即座にディスクに保存されるわけではなく、一時的にバッファに貯められ、一定量に達したタイミングでまとめて書き込みが行われます。
この仕組みを「バッファリング」と言います。
バッファリングを行う理由
ファイルへの書き込みの際、バッファリングを行わず同期的にディスクに書き込もうとすると、ディスクの書き込み速度に制約されてしまい、書き込み待ちの間に CPU が暇になってしまうという問題が発生します。
バッファリング は、この問題を軽減します。
バッファリングを行う場合、データがメモリ上のバッファに書き込まれた時点で制御が CPU に返され、実際のディスクへの書き込みは後で非同期的に実行されます。メモリへの書き込みはディスクへの書き込みに比べて遥かに高速なため、アプリケーションの処理が再開されるまでの時間が大幅に短縮されます。
Ruby におけるバッファリング
ここまで、一般的なバッファリングの仕組みや意義について概観してきました。
ここからは Ruby におけるバッファリングについてです。
Ruby でファイルへの書き込みを行う際、意識すべきバッファ領域が 2 つ存在します。
それは、「Ruby 内部バッファ」と「カーネルキャッシュ」です。
画像は、各領域と、そこに対して書き込みを反映させるメソッドをまとめたものです。
Ruby 内部バッファについて
Ruby では、ファイルにデータを書き込む際、データはまず Ruby 内部のバッファに格納されます。
内部バッファの動作例
File.open("example.txt", "w") do |file| file.write("Hello, Ruby!") # この時点では、データはまだディスクには書き込まれていない end # ファイルを閉じる際に、内部バッファの内容がディスクに書き込まれる
file.write
を呼び出しただけでは、データは内部バッファに格納され、まだディスクには反映されていません。
file.write
直後に binding.irb
で処理を止めて、別のプロセスから File.read
などで中身を確認すると、実際に書き込みが行われていないことがわかります。
IO#flush
メソッド
IO#flush
メソッドは、Ruby 内部バッファにたまったデータを即座に OS の書き込みバッファへと送るために使用します。
flush という単語の「(水で)洗い流す」という意味を意識すると、直感的に理解しやすいと思います。
File.open("example.txt", "w") do |file| file.write("Hello, Ruby!") file.flush # flush を呼び出すと、この時点で内部バッファの内容が OS に送られる end
file.write
の直後に binding.irb
で処理を止めると、 flush
を実行したタイミングで別プロセスからも書き込んだデータが参照できるようになることが確認できます。
flush
を実行した時点でファイルへの書き込みが行われるように見えますが、これだけではデータがディスクに書き込まれたとは限らず、次に説明する OS の書き込みバッファの挙動も考慮する必要があります。
カーネルキャッシュについて
Ruby の内部バッファを通過したデータは、次に OS の管理する領域であるカーネルキャッシュに格納されます。
ここにあるデータはまだディスクに書き込まれていませんが、読み出しの際はまずバッファ領域を確認し、なければディスクを確認するという流れになるため、ユーザープロセスからはディスクに書き込まれている場合と同じように扱うことができます。
Linux においてカーネルキャッシュには ページキャッシュ と バッファキャッシュ の 2 種類があり、前者には通常ファイルの中身が、後者にはメタデータ(ファイル名など)が格納されます。
それぞれに格納されたデータのサイズは、Linux であれば free コマンドに -w オプションをつけることで確認できます。
$ free -w total used free shared buffers cache available Mem: 12250464 555116 8129456 311992 308660 3257232 11160228 Swap: 1048572 0 1048572
詳しい説明は割愛しますが、buffers
がバッファキャッシュ、 cache
がページキャッシュが利用するメモリの領域です。
あるプロセスが利用可能なバッファ領域の上限は事前に決まっているのではありません。
システムのメモリサイズと、他のプロセスが必要とするメモリサイズに基づいて、Linux カーネルが自動で判断します。
IO#fsync
メソッド
IO#fsync
メソッドは、カーネルキャッシュに格納されたデータをディスクに書き込みます。
fsync
とは、カーネルに特定の処理を実行させるための システムコール の 1 つで、メモリ上のバッファにあるファイルの内容を、ディスクと同期させることができます。
File.open("example.txt", "w") do |file| file.write("Critical data!") file.fsync # fsync を呼び出すことで、カーネルキャッシュの内容がディスクに書き込まれる end
Ruby の IO#fsync
は Ruby 内部バッファのデータをカーネルキャッシュに書き込んだ上で OS の fsync
を実行し、成功すれば 0 を返します。
万一 OS が fsync
をサポートしていない場合は nil
を返します。
明示的に fsync
を実行することで、そこまでの内容がディスクに書き込まれていることを保証できます。
これは、システムがクラッシュした場合にもデータが失われないことが求められるケースで有用です。
flush, fsync のオーバーヘッド
ファイルを確実に保存するためには、書き込みのために fsync を実行するのがベストですが、そうするとシステムコールの呼び出しや、ディスクへの書き込みによりパフォーマンスの悪化を招きます。
実際に flush, fsync のオーバーヘッドを確認してみましょう。
環境
CPU: Apple M3
Memory: 24GB
macOS: 15.0.1(24A348)
Ruby: ruby 3.2.0 (2022-12-25 revision a528908271) [arm64-darwin24]
ベンチマーク
require 'benchmark' # write のみを行う def write_without_flush_or_fsync(file_path, data, iterations) File.open(file_path, 'w') do |file| iterations.times do file.write(data) end end end # write のたびに flush def write_with_flush(file_path, data, iterations) File.open(file_path, 'w') do |file| iterations.times do file.write(data) file.flush end end end # write のたびに fsync def write_with_fsync(file_path, data, iterations) File.open(file_path, 'w') do |file| iterations.times do file.write(data) file.fsync end end end # 設定 file_path = 'test_file.txt' data = 'a' iterations = 10_000 # 書き込み回数 # ベンチマーク測定 Benchmark.bm(20) do |x| x.report('Write only:') { write_without_flush_or_fsync(file_path, data, iterations) } x.report('Write with flush:') { write_with_flush(file_path, data, iterations) } x.report('Write with fsync:') { write_with_fsync(file_path, data, iterations) } end
1 バイト の書き込みを 1 万回 行う場合の結果は以下のようになりました。
user system total real Write only: 0.000774 0.000205 0.000979 ( 0.000979) Write with flush: 0.002948 0.010175 0.013123 ( 0.013279) Write with fsync: 0.006238 0.078351 0.084589 ( 0.167797)
一度に書き込むデータを 100kB に増やした場合は、以下のようになります。
user system total real Write only: 0.004357 0.129947 0.134304 ( 0.255717) Write with flush: 0.004326 0.146282 0.150608 ( 0.324760) Write with fsync: 0.007617 0.173337 0.180954 ( 0.598839)
一度に書き込むデータ量が少ない場合、 システムコールの呼び出しやバッファの flush の処理時間が支配的になり、それぞれの処理時間の差は大きくなります。
一方で、一度に書き込むデータ量が大きい場合、今度はバッファにデータを書き込む時間が支配的になり、それぞれの処理時間の差は小さくなります。
パフォーマンスと整合性のトレードオフを考慮する際に、扱うデータ量が重要な要素となることがわかりました。
システムコールを確認する
IO#flush
, IO#fsync
を実行した際に、どのようなシステムコールが行われているかを確認してみましょう。
確認には、プログラムが実行するシステムコールを出力する Linux コマンドである strace
を利用します。
なお、関係ないシステムコールについては -e
オプションにて表示されないようにしています。
IO#flush
まず、IO#flush
を実行した際に呼び出されるシステムコールを確認します。
File.open("example.txt", "w") do |file| file.write("one") file.write("two") file.write("three") file.flush # flush を実行 end
このスクリプトを strace
を用いて実行してみましょう。
strace -e trace=write,writev,fsync ruby flush_test.rb
出力結果は以下のようになります。
write(5, "onetwothree", 11) = 11
write
システムコールは、Ruby の内部バッファに蓄積されたデータをカーネルキャッシュに送る役割を果たします。
第一引数の 3
はファイルディスクリプタであり、今回作成した example.txt
を指します。
第二引数の "onetwothree"
が実際に書き込む内容で、IO#write
によって Ruby 内部バッファに格納されたデータが一度に書き込まれることが確認できます。
第三引数の 11
は書き込まれたバイト数で、"onetwothree"
の長さを表しています。
IO#fsync
次に、IO#fsync
を実行した際のシステムコールを確認します。
File.open("example.txt", "w") do |file| file.write("Hello, fsync!") file.fsync # fsync を実行 end
こちらのスクリプトを strace
で出力してみると、以下の結果になります。
write(5, "Hello, fsync!", 13) = 13 fsync(5)
write
で書き込み内容がカーネルキャッシュに送られた後、fsync
システムコールが実行されています。
引数に渡されるのは、write
と同様ファイルディスクリプタです。
自動的に flush が実行されるケース
最後に、書き込むデータが Ruby 内部バッファのサイズを超える時に自動的にカーネルキャッシュにデータが送られる様子を確認しましょう。
File.open("example_large.txt", "w") do |file| data = "a" * 1024 # 1KB 10.times do |i| file.write(data) puts "Written chunk #{i + 1}" end end
1KB のデータを、10 回にわたって書き込むスクリプトです。
strace
の結果は以下のようになります。
writev(5, [{iov_base="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"..., iov_len=7168}, {iov_base="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"..., iov_len=1024}], 2) = 8192 write(5, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"..., 2048) = 2048
データの総量が 8KB に達したタイミングで、一度 writev
システムコールが実行されています。
writev
はカーネルキャッシュにデータを書き込むシステムコールですが、write
とは違い同時に複数のバッファを指定できます。
このことにより、Ruby 内部バッファに入りきらないことが判明したタイミングでカーネルキャッシュに送り出すことができ、事前に定義されたバッファサイズ(今回は 8KB)を超えることが無いような制御を実現しています。
今回の例では、Ruby 内部バッファ格納済みの 7168
バイトと、追加での書き込みを試みた 1024
バイトを同時にカーネルキャッシュへと書き込んでいます。
File.open
に渡したブロックが終了したタイミングで、まだ flush
されていないデータがまとめて write
システムコールによりカーネルキャッシュに書き込まれます。ここでは、まだ書き込まれていない 2 ループ分の 2048
バイトがカーネルキャッシュへと書き込まれていることがわかります。
まとめ
Ruby で File I/O を行う際に意識するべきバッファリングの仕組みと、 IO#flush
, IO#fsync
各メソッドの動作について確認しました。
IO#flush
: Ruby の内部バッファを OS の書き込みバッファに送るだけで、OS バッファからディスクに書き込まれるタイミングは保証しない。IO#fsync
: OS バッファにあるデータをディスクに確実に書き込む。データの永続性を保証する。
現代の Web アプリケーション開発においては、データの整合性や永続性の保証は RDBMS に任せるケースが多く fsync
が必要になる場面は多くはありません。
しかし、アプリケーションの全体を理解してエンジニアリングを行うためには、このような OS や ストレージも含む知識が必要になります。
日々の業務ではなかなかここまで意識することがないため、今回の記事執筆は良い機会になりました。
明日のTechouse Advent Calendar 2024は 青木真一 さんによる Figmaプラグインを使って楽をしようとしたらCORSの壁に阻まれた です。
Techouse では、社会課題の解決に一緒に取り組むエンジニアを募集しております。 ご応募お待ちしております。