はじめに
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 のメモリを借用することがあるという点にあります。
そのため、以下の流れで不具合が起こります。
-
Arrow::BufferOutputStreamで作ったArrow::ResizableBufferが破棄される - その Buffer を参照しているシリアライズ済みの Table が残る
- GC または上書きにより、そのメモリが消える/書き換わる
- 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]