メリークリスマス!
この記事は はてなエンジニア Advent Calendar 2025 の3日目の記事です。
大遅刻ですね。すごいことですよこれは。
さて、Rust ネタです。
ご存知の通り Rust には GC がありません。代わりに所有権システムがあり、所有権を持つ変数がスコープを抜けるときに値がドロップされ、メモリが解放されます。
とはいえ通常のドロップ処理を回避したり相互に参照する値を作って変数を無限に長生きさせると Rust でもメモリは漏れます。
今日はメモリリークする Rust コードを実際に書いて、書き味を確かめてみましょう。
Rc<T> と相互参照
素朴な参照カウンター式のガベージコレクターはどこからか値が参照されている限りメモリを解放しません。
そのため相互に参照する値を作ると参照カウンターが0にならず、回収されない値を作れます。
JavaScript 風の仮想言語で書くとこんな感じ。
class Node { next = null; } function main() { const a = new Node(); const b = new Node(); a.next = b; b.next = a; } // ↑↑ ここで main は a, b の参照を辞める // しかし a は b を参照し続け、b は a を参照し続ける // 結果として a, b の参照が残り続ける main();
※ 実際の JavaScript ランタイムのガベージコレクターは賢いので、上記の実装でも適切にメモリを回収してくれます
Rust で上記のようなコードを書いてみましょう。
Rust には参照カウント方式のメモリ管理を提供する Rc<T> 型があります。ただし Rc<T> だけを素朴に使っても循環参照は作れません。
use std::rc::Rc; struct Node { next: Option<Rc<Node>>, } fn main() { let a = Rc::new(Node { next: None }); let b = Rc::new(Node { next: None }); *a.next = Some(Rc::clone(&b)); // ^^^^^^^ can't be dereferenced // error[E0614]: type `Option<Rc<Node>>` cannot be dereferenced *b.next = Some(Rc::clone(&a)); // ^^^^^^^ can't be dereferenced // error[E0614]: type `Option<Rc<Node>>` cannot be dereferenced }
Rc<T> は参照先を不変にするので上記のように可変参照を借用することはできません。
...ということが 書籍『プログラミング Rust』 に書いてあって。RefCell<T> による 内部可変 を使えばこの制限を回避できるとも書かれています。
やってみましょう。
use std::cell::RefCell; use std::rc::Rc; struct Node { next: RefCell<Option<Rc<Node>>>, } fn main() { { let a = Rc::new(Node { next: RefCell::new(None), }); let b = Rc::new(Node { next: RefCell::new(None), }); *a.next.borrow_mut() = Some(Rc::clone(&b)); *b.next.borrow_mut() = Some(Rc::clone(&a)); } // ↑↑ ここで a, b のスコープ終わり "変数" が所有権を失う // しかし循環参照により Rc<Node> の "値" はドロップされずに残る }
理屈ではこれでメモリが漏れるはず。とはいえ実際に目で見える形でないと面白くありませんね。
可視化してみましょう。
メモリリークを目で見る
やはりプログラムの実行中にメモリ消費が増えていく様を見るほうが実感が湧きますね。
もっと大きめのメモリをリークさせるプログラムを走らせつつ、ps u コマンドで RSS, VSZ あたりを観察してみましょう。
さきほどの Rust コードを改造します。
use std::cell::RefCell; use std::rc::Rc; use std::time::Duration; struct Node { #[allow(dead_code)] payload: Vec<u8>, next: RefCell<Option<Rc<Node>>>, } fn main() { let payload_bytes: usize = 8 * 1024 * 1024; // 8MiB let mut i: u64 = 0; for _ in 0..100 { { let a = Rc::new(Node { payload: vec![0u8; payload_bytes], next: RefCell::new(None), }); let b = Rc::new(Node { payload: vec![1u8; payload_bytes], next: RefCell::new(None), }); // 循環参照を作る *a.next.borrow_mut() = Some(Rc::clone(&b)); *b.next.borrow_mut() = Some(Rc::clone(&a)); } // ↑↑ ここで a, b のスコープ終わり "変数" が所有権を失う // しかし循環参照により Rc<Node> の "値" はドロップされずに残る i += 1; if i % 10 == 0 { println!("iter={}", i); } // 観察しやすいように少し待つ std::thread::sleep(Duration::from_millis(100)); } }
構造体に8MiBずつの配列を持たせて循環参照させます。それを100回繰り返します。
ログを取るためのスクリプトも用意しましょう。
#!/usr/bin/env bash # 使い方: # ./log_mem.sh <PID> # # 例: # ./leaky_program & # pid=$! # ./log_mem.sh "$pid" set -eu pid="${1:-}" if [ -z "$pid" ]; then echo "Usage: $0 <PID>" >&2 exit 1 fi log_file="mem_${pid}.csv" echo "timestamp,rss_kb,vsz_kb" > "$log_file" while kill -0 "$pid" 2>/dev/null; do ts=$(date +"%Y-%m-%dT%H:%M:%S") read _ rss vsz <<<"$(ps -o pid= -o rss= -o vsz= -p "$pid")" echo "$ts,$rss,$vsz" >> "$log_file" sleep 1 done echo "process $pid finished; log saved to $log_file"
これを log_mem.sh という名前で保存し、計測してみます。
$cargo run --release & pid=$! ./log_mem.sh "$pid" [1] 13333 Compiling leak-drill v0.1.0 (/Users/todays_mitsui/private/leak-drill) Finished `release` profile [optimized] target(s) in 0.50s Running `target/release/leak-drill` iter=10 iter=20 iter=30 iter=40 iter=50 iter=60 iter=70 iter=80 iter=90 iter=100 [1] + done cargo run --release process 13333 finished; log saved to mem_13333.csv
結果はこうなりました。
| timestamp | rss_kb | vsz_kb |
|---|---|---|
| 2025-12-24T21:54:10 | 16448 | 410363040 |
| 2025-12-24T21:54:11 | 34000 | 410332416 |
| 2025-12-24T21:54:12 | 173632 | 410870016 |
| 2025-12-24T21:54:13 | 329312 | 411001088 |
| 2025-12-24T21:54:14 | 468640 | 411133184 |
| 2025-12-24T21:54:15 | 624352 | 411395328 |
| 2025-12-24T21:54:16 | 771904 | 411658496 |
| 2025-12-24T21:54:17 | 894880 | 412051712 |
| 2025-12-24T21:54:18 | 1058720 | 412051712 |
| 2025-12-24T21:54:19 | 1222560 | 412051712 |
| 2025-12-24T21:54:20 | 1370080 | 412313856 |
| 2025-12-24T21:54:21 | 1517536 | 412313856 |
RSS が順調に増えて、どんどんメモリを食っているのが見えますね!
Vec<T> と .leak()
相互参照以外の方法も試しましょう。
Rust の可変長配列 Vec<T> は .leak() というメソッドを実装しています。 .leak() は配列が確保しているヒープ領域を意図的に解放せず、 &'static mut [T] の形でスライスを取り出すことを許します。
シングルクォートから始まる 'a は ライフタイム 指定子 で、その中でも 'static は特別な「プロセスの全期間」を表すライフタイムです。つまり .leak() によって返されたスライスはプロセス実行中に解放されないことが約束されます。
use std::hint::black_box; fn vec_leak(payload_bytes: usize) -> &'static [u8] { let v = vec![0u8; payload_bytes]; black_box(&v); v.leak() }
この関数は指定された長さの配列を作り、 .leak() を呼んでスライスを返します。
関数の中で定義された変数 v は、普通なら関数スコープの最後で所有権を失うか関数呼び出し元に所有権を move させるのですが。 .leak() によって意図的に所有権を放棄しているため関数スコープを抜けてもメモリが解放されません。
先ほどと同じく、この関数を100回呼んで計測しましょう。
use std::hint::black_box; use std::time::Duration; fn vec_leak(payload_bytes: usize) -> &'static [u8] { let v = vec![0u8; payload_bytes]; black_box(&v); v.leak() } fn main() { let payload_bytes: usize = 8 * 1024 * 1024; // 8MiB let mut i: u64 = 0; for _ in 0..100 { { let _slice = vec_leak(payload_bytes); } // ↑↑ ここで _slice のスコープが終わるが、_slice は借用なので所有権は失われない // もはやメモリは誰のものでもない i += 1; if i % 10 == 0 { println!("iter={}", i); } // 観察しやすいように少し待つ std::thread::sleep(Duration::from_millis(100)); } }
結果はこうなりました。
| timestamp | rss_kb | vsz_kb |
|---|---|---|
| 2025-12-24T22:20:06 | 16512 | 410494112 |
| 2025-12-24T22:20:07 | 50384 | 410462464 |
| 2025-12-24T22:20:08 | 107872 | 410855680 |
| 2025-12-24T22:20:10 | 165296 | 411117824 |
| 2025-12-24T22:20:11 | 239056 | 411248896 |
| 2025-12-24T22:20:12 | 312816 | 411379968 |
| 2025-12-24T22:20:13 | 394736 | 411379968 |
| 2025-12-24T22:20:14 | 476656 | 411379968 |
| 2025-12-24T22:20:15 | 542224 | 411511040 |
| 2025-12-24T22:20:16 | 607824 | 411773184 |
| 2025-12-24T22:20:17 | 689744 | 411773184 |
いいですね。
ManuallyDrop<T> とドロップ忘れ
std::mem::ManuallyDrop という構造体があります。変数の所有権が失われても自動でドロップ処理が呼ばれず、名前の通り手動でのドロップが必要になります。
let mut s = ManuallyDrop::new(String::new("Hoge")); unsafe { ManuallyDrop::drop(&mut s); }
忘れずに ManuallyDrop::drop() するのは実装者の責任です。これを忘れるとメモリは解放されずリークします。
手動ドロップをせずに100回呼んで観察してみましょう。
use std::hint::black_box;
use std::mem::ManuallyDrop;
use std::time::Duration;
fn main() {
let payload_bytes: usize = 8 * 1024 * 1024; // 8MiB
let mut i: u64 = 0;
for _ in 0..100 {
{
let mut v = ManuallyDrop::new(vec![0u8; payload_bytes]);
black_box(&mut v);
// unsafe {
// ManuallyDrop::drop(&mut v);
// }
}
// ↑↑ ここで v のスコープが終わる、v は ManuallyDrop なので所有権が失われても自動的にドロップされない
// ManuallyDrop::drop() を呼び忘れているのでメモリは残り続ける
i += 1;
if i % 10 == 0 {
println!("iter={}", i);
}
// 観察しやすいように少し待つ
std::thread::sleep(Duration::from_millis(100));
}
}
結果はこう。
| timestamp | rss_kb | vsz_kb |
|---|---|---|
| 2025-12-24T23:43:17 | 32 | 410060368 |
| 2025-12-24T23:43:18 | 1264 | 410593536 |
| 2025-12-24T23:43:19 | 66976 | 410864896 |
| 2025-12-24T23:43:20 | 140736 | 410995968 |
| 2025-12-24T23:43:21 | 222656 | 410995968 |
| 2025-12-24T23:43:22 | 288240 | 411127040 |
| 2025-12-24T23:43:23 | 362000 | 411258112 |
| 2025-12-24T23:43:24 | 435760 | 411389184 |
| 2025-12-24T23:43:25 | 517680 | 411389184 |
| 2025-12-24T23:43:26 | 591440 | 411520256 |
| 2025-12-24T23:43:27 | 629712 | 411651328 |
はい素晴らしい。
まとめ
今回はさまざまな方法でメモリリークするコードを動かして観察してみました。
Vec::leak(): &'static [T] や ManuallyDrop<T> など、メモリを漏らすのにも型表現がついていてお行儀がいいですね。
私からは以上です。
参考
Special Thanks.