参照サイクルによるメモリリーク

Beginner

This tutorial is from open-source community. Access the source code

はじめに

参照サイクルによるメモリリークへようこそ。この実験は、Rust Bookの一部です。LabEx で Rust のスキルを練習することができます。

この実験では、Rust のメモリセーフティ保証が、偶然にメモリリークを作成するのを困難にはしますが不可能にはしない方法を探ります。特に、Rc<T>RefCell<T>を使用する場合、参照サイクルが発生して値が破棄されず、メモリがリークする可能性があります。

参照サイクルによるメモリリーク

Rust のメモリセーフティ保証は、偶然にクリーンアップされないメモリ(「メモリリーク」と呼ばれる)を作成することを困難にはしますが、不可能にはしません。メモリリークを完全に防止することは、Rust の保証事項の 1 つではありません。つまり、メモリリークは Rust においてメモリセーフです。Rc<T>RefCell<T>を使用することで、Rust がメモリリークを許容していることがわかります。つまり、サイクル内で互いに参照する項目が存在する参照を作成することができます。これによりメモリリークが発生します。なぜなら、サイクル内の各項目の参照カウントが 0 になることはなく、値が破棄されることもないからです。

参照サイクルの作成

参照サイクルがどのように発生するか、およびそれを防止する方法を見てみましょう。まずは、リスト 15-25 に示すList列挙体の定義とtailメソッドから始めます。

ファイル名:src/main.rs

use crate::List::{Cons, Nil};
use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
enum List {
  1 Cons(i32, RefCell<Rc<List>>),
    Nil,
}

impl List {
  2 fn tail(&self) -> Option<&RefCell<Rc<List>>> {
        match self {
            Cons(_, item) => Some(item),
            Nil => None,
        }
    }
}

リスト 15-25: RefCell<T>を保持するコンスリストの定義。これにより、Consバリアントが参照する値を変更できるようになります。

ここでは、リスト 15-5 のList定義の別のバリエーションを使用しています。Consバリアントの 2 番目の要素は、今やRefCell<Rc<List>>になっています[1]。これは、リスト 15-24 のようにi32値を変更できるようにするのではなく、Consバリアントが指し示すList値を変更したいという意味です。また、tailメソッドも追加しています[2]。これにより、Consバリアントがある場合に 2 番目の項目にアクセスするのが便利になります。

リスト 15-26 では、リスト 15-25 の定義を使用するmain関数を追加しています。このコードは、aにリストを作成し、baのリストを指すリストを作成します。その後、aのリストをbに指すように変更して、参照サイクルを作成します。このプロセスのさまざまなポイントでの参照カウントを表示するためにprintln!文があります。

ファイル名:src/main.rs

fn main() {
  1 let a = Rc::new(Cons(5, RefCell::new(Rc::new(Nil))));

    println!("a initial rc count = {}", Rc::strong_count(&a));
    println!("a next item = {:?}", a.tail());

  2 let b = Rc::new(Cons(10, RefCell::new(Rc::clone(&a))));

    println!(
        "a rc count after b creation = {}",
        Rc::strong_count(&a)
    );
    println!("b initial rc count = {}", Rc::strong_count(&b));
    println!("b next item = {:?}", b.tail());

  3 if let Some(link) = a.tail() {
      4 *link.borrow_mut() = Rc::clone(&b);
    }

    println!(
        "b rc count after changing a = {}",
        Rc::strong_count(&b)
    );
    println!(
        "a rc count after changing a = {}",
        Rc::strong_count(&a)
    );

    // 次の行をコメントアウト解除して、サイクルがあることを確認します。
    // スタックがオーバーフローします。
    // println!("a next item = {:?}", a.tail());
}

リスト 15-26: 互いに指す 2 つのList値の参照サイクルの作成

まず、変数a5, Nilの初期リストを持つRc<List>インスタンスを作成します[1]。次に、変数b10の値を持ち、aのリストを指す別のList値を持つRc<List>インスタンスを作成します[2]。

aを変更して、Nilではなくbを指すようにして、サイクルを作成します。これは、tailメソッドを使用してaRefCell<Rc<List>>への参照を取得し、それを変数linkに入れることで行います[3]。その後、RefCell<Rc<List>>borrow_mutメソッドを使用して、内部の値をNil値を持つRc<List>からbRc<List>に変更します[4]。

このコードを実行して、最後のprintln!を一時的にコメントアウトしておくと、次の出力が得られます。

a initial rc count = 1
a next item = Some(RefCell { value: Nil })
a rc count after b creation = 2
b initial rc count = 1
b next item = Some(RefCell { value: Cons(5, RefCell { value: Nil }) })
b rc count after changing a = 2
a rc count after changing a = 2

aのリストをbに指すように変更した後、abRc<List>インスタンスの参照カウントはともに 2 になります。mainの終わりで、Rust は変数bを破棄します。これにより、bRc<List>インスタンスの参照カウントは 2 から 1 に減少します。この時点で、Rc<List>がヒープ上に持っているメモリは破棄されません。なぜなら、その参照カウントは 1 であり、0 ではないからです。次に、Rust はaを破棄します。これにより、aRc<List>インスタンスの参照カウントも 2 から 1 に減少します。このインスタンスのメモリも破棄できません。なぜなら、他のRc<List>インスタンスがまだそれを参照しているからです。リストに割り当てられたメモリは、永遠に回収されないままになります。この参照サイクルを視覚化するために、図 15-4 に図を作成しました。

図 15-4: 互いに指すリストabの参照サイクル

最後のprintln!をコメントアウト解除してプログラムを実行すると、Rust はabを指し、baを指すようなサイクルを表示しようとします。そして、スタックがオーバーフローするまで続けます。

この例で参照サイクルを作成した場合の結果は、現実世界のプログラムと比較するとあまり深刻ではありません。参照サイクルを作成した直後に、プログラムは終了します。ただし、もっと複雑なプログラムがサイクル内で大量のメモリを割り当て、長時間保持している場合、プログラムは必要以上のメモリを使用し、システムを圧倒して利用可能なメモリを枯渇させる可能性があります。

参照サイクルを作成することは簡単ではありませんが、不可能でもありません。RefCell<T>値がRc<T>値を含んでいたり、内部可変性と参照カウントを持つ型の類似したネストされた組み合わせがある場合、サイクルを作成しないようにする必要があります。Rust がそれをキャッチすることを依存することはできません。参照サイクルを作成することは、プログラムの論理バグになります。自動テスト、コードレビュー、その他のソフトウェア開発の慣行を使って、それを最小限に抑える必要があります。

参照サイクルを回避する別の解決策は、データ構造を再編成することです。そうすることで、一部の参照は所有権を表し、一部の参照は所有権を表さなくなります。結果として、所有権関係と非所有権関係から構成されるサイクルができるようになり、値が破棄されるかどうかを影響するのは所有権関係だけになります。リスト 15-25 では、常にConsバリアントがそのリストを所有するようにしたいので、データ構造を再編成することはできません。親ノードと子ノードで構成されるグラフを使った例を見て、非所有権関係が参照サイクルを防止する適切な方法である場合を見てみましょう。

Weak<T>を使った参照サイクルの防止

これまでのところ、Rc::cloneを呼び出すとRc<T>インスタンスのstrong_countが増加すること、そしてRc<T>インスタンスはstrong_countが 0 になるときだけクリーンアップされることを示してきました。また、Rc<T>インスタンス内の値に対する 弱参照 を作成することもできます。それは、Rc::downgradeを呼び出してRc<T>への参照を渡すことで行います。強参照は、Rc<T>インスタンスの所有権を共有する方法です。弱参照は所有権関係を表しておらず、そのカウントはRc<T>インスタンスがクリーンアップされるタイミングに影響を与えません。弱参照が含まれるサイクルは、関係する値の強参照カウントが 0 になるときに破棄されるため、参照サイクルを引き起こすことはありません。

Rc::downgradeを呼び出すと、Weak<T>型のスマートポインタが得られます。Rc::cloneを呼び出すとRc<T>インスタンスのstrong_countが 1 増えるのとは異なり、Rc::downgradeを呼び出すとweak_countが 1 増えます。Rc<T>型は、strong_countと同様に、存在するWeak<T>参照の数を追跡するためにweak_countを使用します。違いは、Rc<T>インスタンスがクリーンアップされるためにはweak_countが 0 になる必要はないということです。

Weak<T>が参照する値は既に破棄されている可能性があるため、Weak<T>が指し示す値を何かするには、まずその値がまだ存在することを確認する必要があります。これは、Weak<T>インスタンスのupgradeメソッドを呼び出すことで行います。これによりOption<Rc<T>>が返されます。Rc<T>値がまだ破棄されていなければSomeの結果が得られ、Rc<T>値が破棄されていればNoneの結果が得られます。upgradeOption<Rc<T>>を返すため、Rust はSomeの場合とNoneの場合を処理し、無効なポインタになることはありません。

例として、項目が次の項目だけを知るリストではなく、項目が子項目 親項目を知る木を作成します。

木構造データ構造の作成:子ノードを持つノード

まずは、子ノードを知っているノードを持つ木を構築します。Nodeという名前の構造体を作成します。これは、自身のi32値と、子ノードのNode値への参照を保持します。

ファイル名:src/main.rs

use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
struct Node {
    value: i32,
    children: RefCell<Vec<Rc<Node>>>,
}

Nodeがその子ノードを所有するようにし、その所有権を変数と共有して、木内の各Nodeに直接アクセスできるようにしたいと思います。これを行うために、Vec<T>の要素をRc<Node>型の値として定義します。また、どのノードが他のノードの子ノードであるかを変更できるようにするため、childrenにはVec<Rc<Node>>の周りにRefCell<T>を持っています。

次に、構造体の定義を使用して、値が3で子ノードがないleafという名前の 1 つのNodeインスタンスと、値が5で子ノードの 1 つがleafであるbranchという名前の別のインスタンスを作成します。これは、リスト 15-27 に示されています。

ファイル名:src/main.rs

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        children: RefCell::new(vec![]),
    });

    let branch = Rc::new(Node {
        value: 5,
        children: RefCell::new(vec![Rc::clone(&leaf)]),
    });
}

リスト 15-27: 子ノードがないleafノードと、子ノードの 1 つがleafであるbranchノードの作成

leaf内のRc<Node>をクローンしてbranchに格納します。これは、leaf内のNodeが現在 2 つの所有者を持つことを意味します。つまり、leafbranchです。branchからleafにアクセスすることは、branch.childrenを通じてできますが、leafからbranchにアクセスする方法はありません。その理由は、leafbranchへの参照を持っておらず、それらが関連していることを知らないからです。leafbranchがその親であることを知るようにしたいと思います。次にそれを行います。

子ノードから親ノードへの参照の追加

子ノードがその親を認識できるようにするには、Node構造体の定義にparentフィールドを追加する必要があります。問題は、parentの型を決定することです。Rc<T>を含めることはできないことがわかっています。なぜなら、それはleaf.parentbranchを指し、branch.childrenleafを指す参照サイクルを作成し、それらのstrong_count値が 0 にならなくなるからです。

別の方法で関係を考えると、親ノードはその子ノードを所有するはずです。つまり、親ノードが破棄された場合、その子ノードも破棄されるべきです。一方、子ノードは親ノードを所有してはいけません。つまり、子ノードが破棄された場合でも、親ノードは依然として存在する必要があります。これは弱参照のケースです!

したがって、Rc<T>の代わりに、parentの型をWeak<T>、具体的にはRefCell<Weak<Node>>を使用することにします。これで、Node構造体の定義は次のようになります。

ファイル名:src/main.rs

use std::cell::RefCell;
use std::rc::{Rc, Weak};

#[derive(Debug)]
struct Node {
    value: i32,
    parent: RefCell<Weak<Node>>,
    children: RefCell<Vec<Rc<Node>>>,
}

ノードは親ノードを参照できるようになりますが、親ノードを所有するわけではありません。リスト 15-28 では、mainを更新してこの新しい定義を使用し、leafノードが親ノードであるbranchを参照する方法を持つようにします。

ファイル名:src/main.rs

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
      1 parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![]),
    });

  2 println!(
        "leaf parent = {:?}",
        leaf.parent.borrow().upgrade()
    );

    let branch = Rc::new(Node {
        value: 5,
      3 parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![Rc::clone(&leaf)]),
    });

  4 *leaf.parent.borrow_mut() = Rc::downgrade(&branch);

  5 println!(
        "leaf parent = {:?}",
        leaf.parent.borrow().upgrade()
    );
}

リスト 15-28: 親ノードであるbranchへの弱参照を持つleafノード

leafノードを作成する方法は、parentフィールドを除けばリスト 15-27 と同じようになります。leafは最初は親がいないため、新しい空のWeak<Node>参照インスタンスを作成します[1]。

この時点で、upgradeメソッドを使用してleafの親への参照を取得しようとすると、None値が得られます。最初のprintln!文の出力でこれがわかります[2]。

leaf parent = None

branchノードを作成すると、branchには親ノードがないため、parentフィールドにも新しいWeak<Node>参照があります[3]。branchの子ノードの 1 つとしてleafがある状態です。branch内のNodeインスタンスがあると、leafを変更して親へのWeak<Node>参照を与えることができます[4]。leafparentフィールド内のRefCell<Weak<Node>>borrow_mutメソッドを使用し、その後Rc::downgrade関数を使用して、branch内のRc<Node>からbranchへのWeak<Node>参照を作成します。

leafの親を再度表示すると[5]、今回はSomeバリアントがbranchを保持しているのがわかります。つまり、leafは今や親にアクセスできるようになりました!leafを表示するときも、リスト 15-26 のように最終的にスタックオーバーフローにつながるサイクルを回避できます。Weak<Node>参照は(Weak)として表示されます。

leaf parent = Some(Node { value: 5, parent: RefCell { value: (Weak) },
children: RefCell { value: [Node { value: 3, parent: RefCell { value: (Weak) },
children: RefCell { value: [] } }] } })

無限出力がないことは、このコードが参照サイクルを作成していないことを示しています。また、Rc::strong_countRc::weak_countを呼び出して得られる値を見ることでもわかります。

strong_count と weak_count の変化を視覚化する

新しい内部スコープを作成し、branchの作成をそのスコープに移動することで、Rc<Node>インスタンスのstrong_countweak_count値がどのように変化するか見てみましょう。これにより、branchが作成され、その後スコープ外になるときに破棄されるときに何が起こるかを確認できます。変更内容はリスト 15-29 に示されています。

ファイル名:src/main.rs

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![]),
    });

  1 println!(
        "leaf strong = {}, weak = {}",
        Rc::strong_count(&leaf),
        Rc::weak_count(&leaf),
    );

  2 {
        let branch = Rc::new(Node {
            value: 5,
            parent: RefCell::new(Weak::new()),
            children: RefCell::new(vec![Rc::clone(&leaf)]),
        });

        *leaf.parent.borrow_mut() = Rc::downgrade(&branch);

      3 println!(
            "branch strong = {}, weak = {}",
            Rc::strong_count(&branch),
            Rc::weak_count(&branch),
        );

      4 println!(
            "leaf strong = {}, weak = {}",
            Rc::strong_count(&leaf),
            Rc::weak_count(&leaf),
        );
  5 }

  6 println!(
        "leaf parent = {:?}",
        leaf.parent.borrow().upgrade()
    );
  7 println!(
        "leaf strong = {}, weak = {}",
        Rc::strong_count(&leaf),
        Rc::weak_count(&leaf),
    );
}

リスト 15-29: 内部スコープでbranchを作成し、強参照と弱参照のカウントを調べる

leafが作成された後、そのRc<Node>の強参照カウントは 1 で、弱参照カウントは 0 です[1]。内部スコープ内で[2]、branchを作成してleafと関連付けます。この時点でカウントを表示すると[3]、branch内のRc<Node>の強参照カウントは 1 で、弱参照カウントは 1 になります(leaf.parentWeak<Node>branchを指しているため)。leafのカウントを表示すると[4]、branchleafRc<Node>のクローンをbranch.childrenに格納しているため、強参照カウントは 2 になりますが、弱参照カウントは依然として 0 です。

内部スコープが終了すると[5]、branchがスコープ外になり、Rc<Node>の強参照カウントが 0 に減少するため、そのNodeが破棄されます。leaf.parentの弱参照カウントが 1 であることは、Nodeが破棄されるかどうかには影響しません。したがって、メモリリークは起こりません!

スコープ終了後にleafの親にアクセスしようとすると、再びNoneが得られます[6]。プログラムの終了時に[7]、leaf内のRc<Node>の強参照カウントは 1 で、弱参照カウントは 0 です。なぜなら、変数leafが再びRc<Node>への唯一の参照になっているからです。

カウントと値の破棄を管理するすべてのロジックは、Rc<T>Weak<T>およびそれらのDropトレイトの実装に組み込まれています。Nodeの定義で子ノードから親ノードへの関係がWeak<T>参照であることを指定することで、親ノードが子ノードを指し、逆も同じである場合でも、参照サイクルとメモリリークを作成することなく済みます。

まとめ

おめでとうございます!参照サイクルによるメモリリークの実験を完了しました。技術力を向上させるために、LabEx でさらに多くの実験を行って練習してください。