Techouse Developers Blog

テックハウス開発者ブログ|マルチプロダクト型スタートアップ|エンジニアによる技術情報を発信|SaaS、求人プラットフォーム、DX推進

Ruby のファイル操作で覚えておきたいバッファリングと flush/fsync

ogp

はじめに

この記事は、Techouse Advent Calendar 2024 13日目です。
昨日は ショーン さんによる インターン生が突如現れた最強CTOとペアプロした話 でした。 13日目は、Higashiji が担当します。

先日、新機能の開発を進める中で、ファイルへの書き込みを扱うコードを書く機会がありました。

その際、Ruby でファイル書きこみを行う際のバッファリングの仕組みと、それを制御する IO#flushIO#fsync というメソッドに改めて注目することになりました。

本記事では、これらのメソッドがどのような役割を果たし、どのような場面で利用すべきかを紹介します。

今回取り扱う内容は、Ruby を使用している方だけでなく、他のプログラミング言語でも共通して役立つ知識ですが、特に私と同じような Ruby 初心者の方の参考になれば幸いです。

基本概念の整理: ファイル I/O とバッファリング

ファイルにデータを書き込むとき、データは即座にディスクに保存されるわけではなく、一時的にバッファに貯められ、一定量に達したタイミングでまとめて書き込みが行われます。
この仕組みを「バッファリング」と言います。

バッファリングを行う理由

ファイルへの書き込みの際、バッファリングを行わず同期的にディスクに書き込もうとすると、ディスクの書き込み速度に制約されてしまい、書き込み待ちの間に CPU が暇になってしまうという問題が発生します。

ディスクへの書き込みを待つ間、CPUの処理が止まる図。

バッファリング は、この問題を軽減します。

バッファ領域への書き込みが完了次第CPUに制御が戻るため、待ち時間が大幅に短くなっている図。バッファ領域からディスクへの書き込みは後で非同期的に実行されている。

バッファリングを行う場合、データがメモリ上のバッファに書き込まれた時点で制御が CPU に返され、実際のディスクへの書き込みは後で非同期的に実行されます。メモリへの書き込みはディスクへの書き込みに比べて遥かに高速なため、アプリケーションの処理が再開されるまでの時間が大幅に短縮されます。

Ruby におけるバッファリング

ここまで、一般的なバッファリングの仕組みや意義について概観してきました。
ここからは Ruby におけるバッファリングについてです。

アプリケーション -IO#write-> Ruby内蔵バッファ -IO#flush-> カーネルキャッシュ -IO#fsync-> ディスク

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 では、社会課題の解決に一緒に取り組むエンジニアを募集しております。 ご応募お待ちしております。

jp.techouse.com