Ruby
 Computer >> コンピューター >  >> プログラミング >> Ruby

Rubyの内部構造:ファイルの「スラーピング」とストリーミング処理

今回のRuby Magicでは、Rubyにおけるファイルのストリーミング処理について学びます。IOクラスがどのようにファイルをメモリに完全に読み込まずに処理するか、またバッファリングを用いて行単位で読み込む仕組みを解説します。さっそく始めましょう!

「スラーピング」とストリーミング

RubyのFile.readメソッドは、ファイルを読み込みその全内容を返します。

irb> content = File.read("log/production.log")
=> "I, [2018-06-27T16:45:02.843719 #9098]  INFO -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22] Started GET \"/articles\" for 127.0.0.1 at 2018-06-27 16:45:02 +0200\nI, [2018-06-27T16:45:02.846719 #9098]  INFO -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22] Processing by ArticlesController#index as HTML\nI, [2018-06-27T16:45:02.848212 #9098]  INFO -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22]   Rendering articles/index.html.erb within layouts/application\nD, [2018-06-27T16:45:02.850020 #9098] DEBUG -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22]   Article Load (0.3ms)  SELECT \"articles\".* FROM \"articles\"\nI, [2018-06-27T16:45:02.850901 #9098]  INFO -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22]   Rendered articles/index.html.erb within layouts/application (1.7ms)\nI, [2018-06-27T16:45:02.851633 #9098]  INFO -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22] Completed 200 OK in 5ms (Views: 3.4ms | ActiveRecord: 0.3ms)\n"

内部的には、ファイルを開き、内容を読み込み、ファイルを閉じ、その内容を単一の文字列として返します。このようにファイルの内容を一度に「スラープ(一気飲み)」すると、Rubyのガベージコレクタによってクリーンアップされるまで、その内容がメモリ上に保持され続けます。

例えば、ファイル内の全文字を大文字に変換して別のファイルに書き込みたい場合、File.readで内容を取得し、String#upcaseを呼び出し、結果をFile.writeに渡せます。

irb> upcased = File.read("log/production.log").upcase
=> "I, [2018-06-27T16:45:02.843719 #9098]  INFO -- : [86A5D18C-19DD-4CBF-9D7A-461C79E98C22] STARTED GET \"/ARTICLES\" FOR 127.0.0.1 AT 2018-06-27 16:45:02 +0200\nI, [2018-06-27T16:45:02.846719 #9098]  INFO -- : [86A5D18C-19DD-4CBF-9D7A-461C79E98C22] PROCESSING BY ARTICLESCONTROLLER#INDEX AS HTML\nI, [2018-06-27T16:45:02.848212 #9098]  INFO -- : [86A5D18C-19DD-4CBF-9D7A-461C79E98C22]   RENDERING ARTICLES/INDEX.HTML.ERB WITHIN LAYOUTS/APPLICATION\nD, [2018-06-27T16:45:02.850020 #9098] DEBUG -- : [86A5D18C-19DD-4CBF-9D7A-461C79E98C22]   ARTICLE LOAD (0.3MS)  SELECT \"ARTICLES\".* FROM \"ARTICLES\"\nI, [2018-06-27T16:45:02.850901 #9098]  INFO -- : [86A5D18C-19DD-4CBF-9D7A-461C79E98C22]   RENDERED ARTICLES/INDEX.HTML.ERB WITHIN LAYOUTS/APPLICATION (1.7MS)\nI, [2018-06-27T16:45:02.851633 #9098]  INFO -- : [86A5D18C-19DD-4CBF-9D7A-461C79E98C22] COMPLETED 200 OK IN 5MS (VIEWS: 3.4MS | ACTIVERECORD: 0.3MS)\n"
irb> File.write("log/upcased.log", upcased)
=> 896

これは小さなファイルなら問題ありませんが、大きなファイルを扱う場合、ファイル全体をメモリに読み込むのは問題になります。例えば14ギガバイトのログファイルをパースする場合、一度に全体を読み込むと高コストな操作となります。ファイルの内容がメモリに保持されるため、アプリケーションのメモリフットプリントが大幅に増加し、最終的にはメモリスワップが発生し、OSによってプロセスが強制終了される可能性があります。

幸い、RubyにはFile.foreachを使ってファイルを行単位で読み込む方法があります。ファイルの全内容を一度に読み込む代わりに、渡されたブロックを各行に対して実行します。

この結果はEnumerablerであるため、ブロックが渡されれば各行に対してyieldし、ブロックがなければEnumeratorオブジェクトを返します。これにより、全内容をメモリに載せずに大きなファイルを読み込めます。

irb> File.foreach("log/production.log") { |line| p line }
"I, [2018-06-27T16:45:02.843719 #9098]  INFO -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22] Started GET \"/articles\" for 127.0.0.1 at 2018-06-27 16:45:02 +0200\n"
"I, [2018-06-27T16:45:02.846719 #9098]  INFO -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22] Processing by ArticlesController#index as HTML\n"
"I, [2018-06-27T16:45:02.848212 #9098]  INFO -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22]   Rendering articles/index.html.erb within layouts/application\n"
"D, [2018-06-27T16:45:02.850020 #9098] DEBUG -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22]   Article Load (0.3ms)  SELECT \"articles\".* FROM \"articles\"\n"
"I, [2018-06-27T16:45:02.850901 #9098]  INFO -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22]   Rendered articles/index.html.erb within layouts/application (1.7ms)\n"
"I, [2018-06-27T16:45:02.851633 #9098]  INFO -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22] Completed 200 OK in 5ms (Views: 3.4ms | ActiveRecord: 0.3ms)\n"

ファイル全体を大文字化するには、入力ファイルを行単位で読み込み、大文字化して出力ファイルに追記します。

irb> File.open("upcased.log", "a") do |output|
irb*   File.foreach("production.log") { |line| output.write(line.upcase) }
irb> end
=> nil

では、ファイル全体を先に読み込まずに、どうやって行単位で読み込んでいるのでしょうか?それを理解するには、ファイル読み込みを取り巻くいくつかの層を剥がして見る必要があります。RubyのIOクラスを詳しく見ていきましょう。

I/OとRubyのIOクラス

File.readFile.foreachは存在しますが、Fileクラスのドキュメントにはこれらは記載されていません。実は、ファイルの読み書きメソッドはFileクラスのドキュメントには一切なく、それらは親クラスであるIOクラスから継承されているためです。

I/Oとは

I/Oデバイスとは、コンピュータとの間でデータを転送するデバイスのことで、キーボード、ディスプレイ、ハードディスクなどが該当します。これらはデータのストリームを読み取ったり生成したりすることで、入出力、つまりI/Oを実行します。

ハードディスクからのファイル読み書きが最も一般的なI/Oですが、ソケット通信、ターミナルへのログ出力、キーボードからの入力などもI/Oの一種です。

RubyのIOクラスは、ファイルの読み書きを含むすべての入出力を扱います。ファイルの読み込みは他のI/Oストリームからの読み込みと本質的に変わらないため、FileクラスはIO.readIO.foreachといったメソッドを直接継承しています。

irb> IO.foreach("log/production.log") { |line| p line }
"I, [2018-06-27T16:45:02.843719 #9098]  INFO -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22] Started GET \"/articles\" for 127.0.0.1 at 2018-06-27 16:45:02 +0200\n"
"I, [2018-06-27T16:45:02.846719 #9098]  INFO -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22] Processing by ArticlesController#index as HTML\n"
"I, [2018-06-27T16:45:02.848212 #9098]  INFO -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22]   Rendering articles/index.html.erb within layouts/application\n"
"D, [2018-06-27T16:45:02.850020 #9098] DEBUG -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22]   Article Load (0.3ms)  SELECT \"articles\".* FROM \"articles\"\n"
"I, [2018-06-27T16:45:02.850901 #9098]  INFO -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22]   Rendered articles/index.html.erb within layouts/application (1.7ms)\n"
"I, [2018-06-27T16:45:02.851633 #9098]  INFO -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22] Completed 200 OK in 5ms (Views: 3.4ms | ActiveRecord: 0.3ms)\n"

File.foreachIO.foreachと等価なので、IOクラス版を使っても同じ結果が得られます。

カーネル経由でのI/Oストリーム読み込み

内部的に、RubyのIOクラスの読み書き機能は、カーネルのシステムコールをラップした抽象化層に基づいています。オペレーティングシステムのカーネルが、I/Oデバイスからの読み取りや書き込みを担当します。

ファイルのオープン

IO.sysopenは、カーネルにファイルへの参照をファイルテーブルに登録させ、プロセスのファイルディスクリプタテーブルにファイルディスクリプタを作成することでファイルを開きます。

ファイルディスクリプタとファイルテーブル

ファイルを開くと、ファイルディスクリプタ(I/Oリソースにアクセスするための整数)が返されます。

各プロセスは独自のファイルディスクリプタテーブルを持ち、ファイルディスクリプタをメモリ上で管理します。各ディスクリプタは、システム全体で共有されるファイルテーブルのエントリを指しています。

I/Oリソースから読み書きするには、プロセスがファイルディスクリプタをシステムコール経由でカーネルに渡します。カーネルはプロセスに代わってファイルにアクセスします。プロセス自体はファイルテーブルに直接アクセスできません。

ファイルを開いてもその内容がメモリに保持されることはありませんが、ファイルディスクリプタテーブルがいっぱいになる可能性があるため、ファイルを開いたら必ず閉じるのが良い習慣です。File.openをラップするメソッド(File.readなど)や、ブロックを受け取るメソッドは、これを自動的に行います。

ここではさらに一歩進んで、IO.sysopenメソッドを直接呼び出してみます。ファイル名を渡すと、後で開いているファイルを参照するためのファイルディスクリプタが作成されます。

irb> IO.sysopen("log/production.log")
=> 9

Rubyが読み書きできるIOインスタンスを作るには、ファイルディスクリプタをIO.newに渡します。

irb> file_descriptor = IO.sysopen("log/production.log")
=> 9
irb> io = IO.new(file_descriptor)
=> #<IO:fd 9>

I/Oストリームを閉じ、ファイルテーブルからファイルへの参照を削除するには、IOインスタンスでIO#closeを呼びます。

irb> io.close
=> nil

バイトの読み込みとカーソルの移動

IO#sysreadは、IOオブジェクトから指定したバイト数を読み込みます。

irb> io.sysread(64)
=> " [86a5d18c-19dd-4cbf-9d7a-461c79e98c22] Started GET \"/articles\" "

この例では、先ほどIO.newにファイルディスクリプタ整数を渡して作成したIOインスタンスを使用しています。IO#sysreadに64を引数として呼び出すことで、ファイルの先頭から64バイトを読み込み返却します。

irb> io.sysread(64)
=> "for 127.0.0.1 at 2018-06-27 16:45:02 +0200\nI, [2018-06-27T16:45:"

最初にバイトを要求したとき、カーソルは自動的に移動します。そのため、同じインスタンスで再度IO#sysreadを呼ぶと、ファイルの次の64バイトが得られます。

カーソルの移動

IO.sysseekで、ファイル内の任意の位置にカーソルを手動で移動できます。

irb> io.sysseek(32)
=> 32
irb> io.sysread(64)
=> "9098]  INFO -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22] Started "
irb> io.sysseek(0)
=> 0
irb> io.sysread(64)
=> " [86a5d18c-19dd-4cbf-9d7a-461c79e98c22] Started GET \"/articles\" "

この例では、位置32に移動してからIO#sysreadで64バイト読み込んでいます。再度IO.sysseekで0を指定するとファイルの先頭に戻り、最初の64バイトをもう一度読み込めます。

ファイルを行単位で読み込む

ここまでで、IOクラスの便利メソッドがどのようにI/Oストリームを開き、バイトを読み込み、カーソル位置を移動するかがわかりました。

IO.foreachIO#getsのようなメソッドは、バイト数指定ではなく「行単位」でデータを要求できます。次の改行位置を効率よく探してそこまでのバイトを取得する方法はないため、Rubyがファイルの内容を分割する必要があります。

class MyIO
  def initialize(filename)
    fd = IO.sysopen(filename)
    @io = IO.new(fd)
  end
 
  def each(&block)
    line = ""

    while (c = @io.sysread(1)) != $/
      line << c
    end

    block.call(line)
    each(&block)
  rescue EOFError
    @io.close
  end
end

この実装例では、#eachメソッドがIO#sysreadを使ってファイルから1バイトずつ取得し、そのバイトが$/(改行文字)になるまで繰り返します。改行が見つかったらバイト取得を止め、その行をブロックに渡します。

この方法でも動きますが、ファイルの全バイトに対してIO.sysreadを呼ぶことになるため非効率です。

ファイル内容のバッファリング

Rubyはもっと賢く、ファイルの内容を内部バッファに保持して処理します。1バイトずつ読む代わりに、一度に512バイト読み込み、その中に改行があるかチェックします。改行があれば、その手前までを1行として返し、残りをメモリ上のバッファとして保持します。バッファに改行がなければ、さらに512バイト読み込んで改行を探します。

class MyIO
  def initialize(filename)
    fd = IO.sysopen(filename)
    @io = IO.new(fd)
    @buffer = ""
  end
 
  def each(&block)
    @buffer << @io.sysread(512) until @buffer.include?($/)

    line, @buffer = @buffer.split($/, 2)

    block.call(line)
    each(&block)
  rescue EOFError
    @io.close
  end
end

この例では、#eachメソッドが内部変数@bufferに512バイトずつ追加し、@bufferに改行が含まれるまで繰り返します。改行が見つかったら、最初の改行でバッファを分割します。前半がline、後半が新しいバッファになります。

その後、ブロックを行を引数として呼び出し、残った@bufferを次のループで使用します。

このようにファイル内容をバッファリングすることで、I/O呼び出しの回数を減らしつつ、ファイルを論理的なチャンク(行)に分割できます。

ファイルのストリーミング

まとめると、ファイルのストリーミングとは、OSのカーネルにファイルを開かせ、そこから少しずつバイトを読み出す仕組みです。Rubyでファイルを行単位で読むとき、データは512バイトずつ取得され、その後「行」に分割されます。

以上がRubyにおけるI/Oとファイルストリーミングの概要です。この記事についての感想や質問があれば、ぜひお聞かせください。Rubyの「魔法」的な仕組みで解説してほしいトピックがあれば、@AppSignalまでご連絡ください!

  1. Macファイルの整理整頓ガイド:散らかったデータをすっきり片付ける方法

    大量のファイルを扱うMacユーザーにとって、専用ツールを使ってMacファイルをきちんと整理・整頓することは、散らかりを解消する最善策です。ファイルがあちこちの場所に散在していると、目的のファイルを探すのが針のむしろ状態になったり、うっかりデータを失ってしまうリスクも高まります。 ファイルの整理やふるい分けのコツを身につければ、手作業による面倒なプロセスからくるストレスを回避できます。Macに内蔵されたFinderの機能を使えば、ファイルをきれいに整理することが可能です。このガイドでは、時間と労力を節約し、ストレスを取り除くための実証済みの効率的な方法をご紹介します。 さらに、Macをすっきり片

  2. 重複画像をプレビューして不要なファイルだけを削除する方法

    パソコンのストレージ容量を無駄に圧迫する「重複画像」は、世界中のユーザーを悩ませています。この問題への有効な解決策が、重複画像検出ソフトを使って不要な画像を削除することです。しかし、削除しようとしている画像が本当に不要なものなのか、確信が持てない場面もあります。 そこで重要になるのが、削除前の「プレビュー」です。Duplicate Photos Fixer Proは、まさにそのニーズに応えてくれる理想的なアプリです。Windows 10標準の画像プレビューを使う必要はなく、アプリ内蔵のプレビュー機能で内容を確認しながら、安心して重複画像を削除できます。 写真をプレビューして正しいファイルを削除