無駄と文化

実用的ブログ

やってみよう!メモリリーク

メリークリスマス!
この記事は はてなエンジニア 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.