1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[Ruby] Apache Arrow (red-arrow) で Buffer を扱うときはメモリ管理を意識する

Posted at

はじめに

Rubyでデータ処理を行う際に便利なApache Arrowですが、Arrowの大きな特徴である「Zero-Copy(メモリコピーをしない)」という特性は、RubyのGC(ガベージコレクション)による自動メモリ管理の裏をかく形になりやすく、意識せずに実装すると容易にメモリ破壊を引き起こします。

本記事では、陥りやすい 「メモリ管理を意識していない危険な実装パターン」 を2つ紹介し、その回避策を提示します。

環境

  • Ruby 3.4.7
  • red-arrow 21.0.0
  • Apache Arrow 21.0.0_10

危険な例1:シリアライズした文字列が欲しいがために、Buffer を破棄してしまう

Arrow形式のデータを他のプロセスと連携(IPC)したり、外部ライブラリにバイナリとして渡したりする過程で、便宜上 Ruby の String オブジェクト(バイナリ列)として扱いたいケースがあります。
しかし、以下のように「Stringを作って満足し、元のBufferを捨ててしまう」のは危険なアンチパターンです。

# ❌ 危険な実装例
def serialize_to_string(table)
  buffer = Arrow::ResizableBuffer.new(0)
  Arrow::BufferOutputStream.open(buffer) do |stream|
    Arrow::RecordBatchStreamWriter.open(stream, table.schema) do |writer|
      writer.write_table(table)
    end
  end
  
  # String を返しているように見えるが、実体である buffer はここでスコープを抜け、GC対象になる
  buffer.data.to_s
end

なぜ危険なのか

buffer.data.to_s によりシリアライズされた文字列が Ruby の String として作られますが、これを持っていてもデータは安全ではありません。
ここで得られた String は実体である Arrow::ResizableBuffer のメモリ領域を指しているため、この関数のスコープを抜けて buffer が保持されなくなると、データの安全性が失われます。この状態で String からテーブルを参照しようとすると、解放済みメモリにアクセスすることになり、データが不安定になります。

検証

buffer.data.to_s が元のバッファーのデータを参照していることは、以下のコードで確認できます。

require 'arrow'

# 1. 元の Buffer (Resizable) を作成し、"AAAA..." を書き込む
buffer = Arrow::ResizableBuffer.new(10)
# 直接メモリに書き込む (C++レベルの操作)
buffer.set_data(0, "A" * 10)

puts "1. Original Buffer created: #{buffer.data.to_s}"

# 2. String を作成 (to_s)
generated_string = buffer.data.to_s

puts "2. String generated: #{generated_string}"

# 3. 元の Buffer の中身を "BBBB..." に強制的に書き換える
puts "3. Overwriting Original Buffer with 'BBBB'..."
buffer.set_data(0, "B" * 10)

puts "   Original Buffer is now: #{buffer.data.to_s}"

# String の中身も変わっているか確認
puts "String generated is also: #{generated_string}"

出力

以下のように、元の buffer を書き変えただけなのに、生成済みの generated_string の中身も変わっています。

1. Original Buffer created: AAAAAAAAAA
2. String generated: AAAAAAAAAA
3. Overwriting Original Buffer with 'BBBB'...
   Original Buffer is now: BBBBBBBBBB
String generated is also: BBBBBBBBBB

危険な例2:Buffer が勝手に解放される可能性を考えていなかった

受け取った String から Table を復元する際の実装です。
ここで元の buffer の状態(ライフサイクル)を意識しないでいると、データが壊れます。

# ❌ 危険な実装例
def deserialize_unsafe(binary_string)
  # Stringのメモリ領域を直接参照するBufferを作成(Zero-Copy)
  buffer = Arrow::Buffer.new(binary_string)
  
  input_stream = Arrow::BufferInputStream.new(buffer)
  reader = Arrow::RecordBatchStreamReader.new(input_stream)
  
  # Tableを作成して返す
  # しかし、このTableは元の binary_string への所有権(Reference)を持っていない
  reader.read_all
end

# 呼び出し元
str = serialized_data
data = deserialize_unsafe(str)

# ここで str が不要になり、nil代入などで参照が切れると...
str = nil 
GC.start 

# data (Table) が参照していたメモリ領域が回収・上書きされ、中身が壊れる

なぜ危険なのか

Arrow::Buffer.new(string) は、文字列のメモリポインタを借用するだけです。
生成された Table オブジェクトは、元の buffer オブジェクトを保持しない(無関係な)ため、データを守ってはくれません。
その結果、Tableは 「解放済みのメモリ領域(Dangling Pointer)」 を参照し続け、次にその領域に別のデータが書き込まれた瞬間、データが化けます。

対策:メモリの所有権をArrow側に持たせる

この問題を回避する最も確実な方法は、Arrow管理下のメモリ領域にデータをコピーしてしまい、table に保持させてしまうことです。

# ✅ 安全な実装例
def deserialize_safe(binary_string)
  unsafe_buffer = Arrow::Buffer.new(binary_string)
  
  # 対策: copy を使って Arrow管理下のメモリ(Native Heap)へ退避させる
  safe_buffer = unsafe_buffer.copy(0, unsafe_buffer.size)
  
  input_stream = Arrow::BufferInputStream.new(safe_buffer)
  reader = Arrow::RecordBatchStreamReader.new(input_stream)
  table = reader.read_all
  
  # Tableが生きている間はBufferも維持されるよう紐付ける
  table.instance_variable_set(:@owned_arrow_buffer, safe_buffer)
  
  table
end

この手法は table オブジェクトに参照を残すことで GC による safe_buffer の解放を防ぐ方法です。ただ、これは迂回策なので、公式 API での適切な実装方法が用意されているならそちらを使うべきです。(もし正式な実装方法をご存知の方がいれば、コメント等で教えていただけると幸いです。)

余談

シリアライズした String をどうしても扱う必要がないのであれば、そもそもString化せず、直接 Arrow::ResizableBuffer を受け渡して Arrow::BufferInputStream を生成した方が安全です。

まとめ

問題は、Apache Arrow が Zero-Copy を採用していて、外部 Buffer のメモリを借用することがあるという点にあります。

そのため、以下の流れで不具合が起こります。

  1. Arrow::BufferOutputStream で作った Arrow::ResizableBuffer が破棄される
  2. その Buffer を参照しているシリアライズ済みの Table が残る
  3. GC または上書きにより、そのメモリが消える/書き換わる
  4. Table が壊れる

つまり、簡単にまとめると、Arrow::Table が参照している「元の Buffer」が消えていることが危険です。

検証用コード

require 'arrow'

# --- ヘルパー: データ生成 ---
def create_table(data)
  schema = Arrow::Schema.new(id: :uint64)
  id_array = Arrow::UInt64Array.new(data)
  Arrow::Table.new(schema, [id_array])
end

# --- ヘルパー: メモリ書き換え ---
def rewrite_memory
  # メモリを汚すダミー処理
  puts "Garbage generating..."
  1000.times do
    dummy = create_table([9, 9, 9])
    to_buffer(dummy) # 作って捨てる
  end
end

# --- [NG] 文字列化 ---
def serialize_to_string(table)
  buffer = Arrow::ResizableBuffer.new(0)
  Arrow::BufferOutputStream.open(buffer) do |stream|
    Arrow::RecordBatchStreamWriter.open(stream, table.schema) do |writer|
      writer.write_table(table)
    end
  end
  buffer.data.to_s
end

# --- [OK] Buffer 化 ---
def to_buffer(table)
  buffer = Arrow::ResizableBuffer.new(0)
  Arrow::BufferOutputStream.open(buffer) do |stream|
    Arrow::RecordBatchStreamWriter.open(stream, table.schema) do |writer|
      writer.write_table(table)
    end
  end
  buffer
end

# --- [NG] 危険な読み込み ---
def deserialize_unsafe(serialized_string)
  buffer = Arrow::Buffer.new(serialized_string)
  input_stream = Arrow::BufferInputStream.new(buffer)
  Arrow::RecordBatchStreamReader.new(input_stream).read_all
end

# --- [OK] 安全な読み込み ---
def deserialize_safe(serialized_string)
  unsafe_buffer = Arrow::Buffer.new(serialized_string)
  # コピーしてNativeメモリへ退避
  safe_buffer = unsafe_buffer.copy(0, unsafe_buffer.size)
  
  input_stream = Arrow::BufferInputStream.new(safe_buffer)
  reader = Arrow::RecordBatchStreamReader.new(input_stream)
  table = reader.read_all
  
  # Tableが生きている間はBufferも維持されるよう紐付ける
  table.instance_variable_set(:@owned, safe_buffer)
  table
end

# --- メイン処理 ---

puts "\n--- ケースA: バッファ破棄後にUnsafe読み込み ---"
begin
  original_data = [0, 1, 2]
  original_table = create_table(original_data)

  # バッファを破棄した文字列
  unsafe_serialized_string = serialize_to_string(original_table)

  puts "=== 1. 読み込み (Unsafe) ==="
  table_unsafe = deserialize_unsafe(unsafe_serialized_string)
  puts "Unsafe (Init): #{table_unsafe.find_column(:id).to_a}"

  # 文字列への参照を明示的に切る
  unsafe_serialized_string = nil
  GC.start

  puts "=== 2. 破壊工作 (GC & メモリ上書き) ==="
  rewrite_memory

  puts "=== 3. 結果確認 ==="
  begin
    puts "Unsafe: #{table_unsafe.find_column(:id).to_a}"
  rescue => e
    puts "Unsafe: Error (#{e.class}: #{e.message})"
  end
end


puts "\n--- ケースB: バッファ保持後にUnsafe/Safe読み込み ---"
begin
  original_data = [0, 1, 2]
  original_table = create_table(original_data)

  # バッファを保持した文字列
  buffer = to_buffer(original_table)
  serialized_string = buffer.data.to_s

  puts "=== 1. 読み込み (Unsafe vs Safe) ==="
  table_unsafe = deserialize_unsafe(serialized_string)
  table_safe   = deserialize_safe(serialized_string)

  puts "Unsafe (Init): #{table_unsafe.find_column(:id).to_a}"
  puts "Safe   (Init): #{table_safe.find_column(:id).to_a}"


  puts "=== 2. 破壊工作 (GC & メモリ上書き) ==="
  # BufferとString、両方の参照を切る
  buffer = nil
  serialized_string = nil 
  GC.start

  rewrite_memory

  puts "=== 3. 結果確認 ==="
  begin
    puts "Unsafe: #{table_unsafe.find_column(:id).to_a}"
  rescue => e
    puts "Unsafe: Error (#{e.class}: #{e.message})"
  end

  begin
    puts "Safe  : #{table_safe.find_column(:id).to_a}"
  rescue => e
    puts "Safe  : Error (#{e.class}: #{e.message})"
  end
end

出力

--- ケースA: バッファ破棄後にUnsafe読み込み ---
=== 1. 読み込み (Unsafe) ===
Unsafe (Init): [0, 1, 2]
=== 2. 破壊工作 (GC & メモリ上書き) ===
Garbage generating...
=== 3. 結果確認 ===
Unsafe: [9, 9, 9]

--- ケースB: バッファ保持後にUnsafe/Safe読み込み ---
=== 1. 読み込み (Unsafe vs Safe) ===
Unsafe (Init): [0, 1, 2]
Safe   (Init): [0, 1, 2]
=== 2. 破壊工作 (GC & メモリ上書き) ===
Garbage generating...
=== 3. 結果確認 ===
Unsafe: [9, 9, 9]
Safe  : [0, 1, 2]
1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?