참조 사이클은 메모리 누수를 발생시킬 수 있습니다

Beginner

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

소개

Reference Cycles Can Leak Memory에 오신 것을 환영합니다. 이 랩은 Rust Book의 일부입니다. LabEx 에서 Rust 기술을 연습할 수 있습니다.

이 랩에서는 Rust 의 메모리 안전성 보장이 실수로 메모리 누수를 발생시키기 어렵게 만들지만, 불가능하게 만드는 것은 아니라는 점을 살펴봅니다. 특히 Rc<T>RefCell<T>를 사용할 때, 이는 값들이 drop 되지 못하게 하고 메모리 누수를 유발하는 참조 사이클 (reference cycles) 을 초래할 수 있습니다.

Reference Cycles Can Leak Memory

Rust 의 메모리 안전성 보장은 실수로 정리되지 않는 메모리 (이른바 메모리 누수 (memory leak)) 를 생성하는 것을 어렵게 만들지만, 불가능하게 만드는 것은 아닙니다. 메모리 누수를 완전히 방지하는 것은 Rust 의 보장 사항 중 하나가 아니며, 이는 Rust 에서 메모리 누수가 메모리 안전하다는 것을 의미합니다. Rc<T>RefCell<T>를 사용하여 Rust 가 메모리 누수를 허용한다는 것을 알 수 있습니다. 항목들이 서로를 순환적으로 참조하는 참조를 생성하는 것이 가능합니다. 이는 순환 내 각 항목의 참조 횟수가 0 에 도달하지 않고, 값들이 drop 되지 않기 때문에 메모리 누수를 생성합니다.

참조 사이클 생성하기

참조 사이클이 어떻게 발생할 수 있는지, 그리고 이를 방지하는 방법을 살펴보겠습니다. Listing 15-25 의 List enum 정의와 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,
        }
    }
}

Listing 15-25: Cons 변형이 참조하는 것을 수정할 수 있도록 RefCell<T>를 포함하는 cons list 정의

Listing 15-5 의 List 정의의 또 다른 변형을 사용하고 있습니다. Cons 변형의 두 번째 요소는 이제 RefCell<Rc<List>> [1]입니다. 즉, Listing 15-24 에서 했던 것처럼 i32 값을 수정하는 대신, Cons 변형이 가리키는 List 값을 수정하려고 합니다. 또한 Cons 변형이 있는 경우 두 번째 항목에 편리하게 접근할 수 있도록 tail 메서드 [2]를 추가하고 있습니다.

Listing 15-26 에서는 Listing 15-25 의 정의를 사용하는 main 함수를 추가하고 있습니다. 이 코드는 a에 리스트를 생성하고, a의 리스트를 가리키는 b에 리스트를 생성합니다. 그런 다음 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)
    );

    // Uncomment the next line to see that we have a cycle;
    // it will overflow the stack
    // println!("a next item = {:?}", a.tail());
}

Listing 15-26: 서로를 가리키는 두 개의 List 값의 참조 사이클 생성

5, Nil의 초기 리스트를 가진 List 값을 포함하는 Rc<List> 인스턴스를 변수 a에 생성합니다 [1]. 그런 다음 값 10을 포함하고 a의 리스트를 가리키는 또 다른 List 값을 포함하는 Rc<List> 인스턴스를 변수 b에 생성합니다 [2].

aNil 대신 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

ab 모두에서 Rc<List> 인스턴스의 참조 횟수는 a의 리스트를 b를 가리키도록 변경한 후 2 입니다. main의 끝에서 Rust 는 변수 b를 drop 하며, 이는 b Rc<List> 인스턴스의 참조 횟수를 2 에서 1 로 감소시킵니다. Rc<List>가 힙에 가지고 있는 메모리는 이 시점에서 drop 되지 않습니다. 왜냐하면 참조 횟수가 1 이지 0 이 아니기 때문입니다. 그런 다음 Rust 는 a를 drop 하며, 이는 a Rc<List> 인스턴스의 참조 횟수를 2 에서 1 로 감소시킵니다. 이 인스턴스의 메모리도 drop 될 수 없습니다. 왜냐하면 다른 Rc<List> 인스턴스가 여전히 이를 참조하고 있기 때문입니다. 리스트에 할당된 메모리는 영원히 수집되지 않은 상태로 남게 됩니다. 이 참조 사이클을 시각화하기 위해 그림 15-4 에 다이어그램을 만들었습니다.

그림 15-4: 서로를 가리키는 리스트 ab의 참조 사이클

마지막 println!의 주석을 해제하고 프로그램을 실행하면 Rust 는 ab를 가리키고, ba를 가리키는 등의 사이클을 출력하려고 시도하여 스택 오버플로우가 발생합니다.

실제 프로그램과 비교했을 때, 이 예제에서 참조 사이클을 생성하는 결과는 그다지 심각하지 않습니다. 참조 사이클을 생성한 직후 프로그램이 종료됩니다. 그러나 더 복잡한 프로그램이 사이클에서 많은 메모리를 할당하고 오랫동안 유지하는 경우, 프로그램은 필요 이상으로 많은 메모리를 사용하고 시스템을 압도하여 사용 가능한 메모리가 부족하게 될 수 있습니다.

참조 사이클을 생성하는 것은 쉽지 않지만, 불가능한 것도 아닙니다. Rc<T> 값을 포함하는 RefCell<T> 값 또는 내부 가변성 및 참조 횟수가 있는 유사한 중첩된 유형 조합이 있는 경우, 사이클을 생성하지 않도록 해야 합니다. Rust 가 이를 감지하도록 의존할 수 없습니다. 참조 사이클을 생성하는 것은 프로그램의 논리적 버그이며, 자동화된 테스트, 코드 검토 및 기타 소프트웨어 개발 관행을 사용하여 최소화해야 합니다.

참조 사이클을 피하는 또 다른 해결책은 일부 참조가 소유권을 표현하고 일부 참조는 그렇지 않도록 데이터 구조를 재구성하는 것입니다. 결과적으로, 일부 소유권 관계와 일부 비소유권 관계로 구성된 사이클을 가질 수 있으며, 소유권 관계만 값이 drop 될 수 있는지 여부에 영향을 미칩니다. Listing 15-25 에서 우리는 항상 Cons 변형이 자신의 리스트를 소유하기를 원하므로, 데이터 구조를 재구성하는 것은 불가능합니다. 비소유권 관계가 참조 사이클을 방지하는 적절한 방법인 경우를 보기 위해 부모 노드와 자식 노드로 구성된 그래프를 사용하는 예를 살펴보겠습니다.

Weak<T>를 사용하여 참조 사이클 방지하기

지금까지 Rc::clone을 호출하면 Rc<T> 인스턴스의 strong_count가 증가하고, Rc<T> 인스턴스는 strong_count가 0 일 때만 정리된다는 것을 보여드렸습니다. 또한 Rc::downgrade를 호출하고 Rc<T>에 대한 참조를 전달하여 Rc<T> 인스턴스 내의 값에 대한 약한 참조 (weak reference) 를 생성할 수 있습니다. 강한 참조는 Rc<T> 인스턴스의 소유권을 공유하는 방법입니다. 약한 참조는 소유권 관계를 표현하지 않으며, 해당 횟수는 Rc<T> 인스턴스가 정리되는 시점에 영향을 미치지 않습니다. 약한 참조가 포함된 사이클은 관련 값의 강한 참조 횟수가 0 이 되면 깨지므로 참조 사이클을 유발하지 않습니다.

Rc::downgrade를 호출하면 Weak<T> 유형의 스마트 포인터를 얻게 됩니다. Rc::downgrade를 호출하면 Rc<T> 인스턴스의 strong_count를 1 증가시키는 대신 weak_count가 1 증가합니다. Rc<T> 타입은 strong_count와 유사하게 weak_count를 사용하여 Weak<T> 참조가 얼마나 존재하는지 추적합니다. 차이점은 Rc<T> 인스턴스가 정리되기 위해 weak_count가 0 일 필요가 없다는 것입니다.

Weak<T>가 참조하는 값은 drop 되었을 수 있으므로, Weak<T>가 가리키는 값으로 무언가를 하려면 해당 값이 여전히 존재하는지 확인해야 합니다. Weak<T> 인스턴스에서 upgrade 메서드를 호출하여 이 작업을 수행합니다. 그러면 Option<Rc<T>>가 반환됩니다. Rc<T> 값이 아직 drop 되지 않은 경우 Some 결과를 얻고, Rc<T> 값이 drop 된 경우 None 결과를 얻습니다. upgradeOption<Rc<T>>를 반환하므로 Rust 는 Some 케이스와 None 케이스가 처리되도록 보장하며, 유효하지 않은 포인터가 존재하지 않습니다.

예를 들어, 다음 항목에 대해서만 아는 리스트를 사용하는 대신, 자식 항목 부모 항목에 대해 아는 트리를 생성합니다.

트리 데이터 구조 생성하기: 자식 노드가 있는 노드

먼저, 자식 노드에 대해 아는 노드로 트리를 구성합니다. 자체 i32 값과 자식 Node 값에 대한 참조를 모두 저장하는 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> 타입의 값으로 정의합니다. 또한 다른 노드의 자식 노드를 수정하고 싶으므로, Vec<Rc<Node>> 주위에 childrenRefCell<T>를 사용합니다.

다음으로, 구조체 정의를 사용하여 value3이고 자식이 없는 leaf라는 Node 인스턴스와, value5이고 leaf를 자식 중 하나로 갖는 branch라는 또 다른 인스턴스를 생성합니다. Listing 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)]),
    });
}

Listing 15-27: 자식이 없는 leaf 노드와 leaf를 자식 중 하나로 갖는 branch 노드 생성

leaf에서 Rc<Node>를 복제하여 branch에 저장합니다. 즉, leafNode는 이제 leafbranch 두 개의 소유자를 갖습니다. branch.children을 통해 branch에서 leaf로 이동할 수 있지만, leaf에서 branch로 이동할 방법은 없습니다. 그 이유는 leafbranch에 대한 참조가 없고, 그들이 관련되어 있다는 것을 알지 못하기 때문입니다. leafbranch가 자신의 부모라는 것을 알기를 원합니다. 다음 단계에서 그렇게 할 것입니다.

자식에서 부모로의 참조 추가하기

자식 노드가 부모를 인식하게 하려면 Node 구조체 정의에 parent 필드를 추가해야 합니다. 문제는 parent의 타입을 결정하는 것입니다. Rc<T>를 포함할 수 없다는 것을 알고 있습니다. 그렇게 하면 leaf.parentbranch를 가리키고 branch.childrenleaf를 가리키는 참조 사이클이 생성되어 strong_count 값이 0 이 되지 않기 때문입니다.

관계를 다른 방식으로 생각해보면, 부모 노드는 자식을 소유해야 합니다. 부모 노드가 drop 되면 자식 노드도 drop 되어야 합니다. 그러나 자식은 부모를 소유해서는 안 됩니다. 자식 노드를 drop 하면 부모는 여전히 존재해야 합니다. 이것이 약한 참조의 경우입니다!

따라서 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>>>,
}

노드는 부모 노드를 참조할 수 있지만 부모를 소유하지 않습니다. Listing 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()
    );
}

Listing 15-28: 부모 노드인 branch에 대한 약한 참조가 있는 leaf 노드

leaf 노드를 생성하는 것은 parent 필드를 제외하고 Listing 15-27 과 유사합니다. leaf는 부모가 없는 상태로 시작하므로 새롭고 비어 있는 Weak<Node> 참조 인스턴스를 생성합니다 [1].

이 시점에서 upgrade 메서드를 사용하여 leaf의 부모에 대한 참조를 얻으려고 하면 None 값을 얻습니다. 첫 번째 println! 문 [2]의 출력에서 이를 확인할 수 있습니다.

leaf parent = None

branch 노드를 생성할 때 branch에는 부모 노드가 없으므로 parent 필드에 새로운 Weak<Node> 참조가 있습니다 [3]. 여전히 leafbranch의 자식 중 하나로 가지고 있습니다. branchNode 인스턴스가 있으면 leaf를 수정하여 부모에 대한 Weak<Node> 참조를 제공할 수 있습니다 [4]. leafparent 필드에서 RefCell<Weak<Node>>에 대해 borrow_mut 메서드를 사용한 다음, Rc::downgrade 함수를 사용하여 branchRc<Node>에서 branch에 대한 Weak<Node> 참조를 생성합니다.

leaf의 부모를 다시 출력하면 [5], 이번에는 branch를 포함하는 Some 변형을 얻게 됩니다. 이제 leaf는 부모에 접근할 수 있습니다! leaf를 출력할 때 Listing 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가 생성된 다음 범위에서 벗어날 때 어떻게 되는지 확인할 수 있습니다. 수정 사항은 Listing 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),
    );
}

Listing 15-29: 내부 범위에서 branch를 생성하고 강하고 약한 참조 횟수 검사

leaf가 생성된 후 해당 Rc<Node>는 strong count 가 1 이고 weak count 가 0 입니다 [1]. 내부 범위 [2]에서 branch를 생성하고 leaf와 연결합니다. 이 시점에서 카운트를 출력하면 [3], branchRc<Node>는 strong count 가 1 이고 weak count 가 1( leaf.parentWeak<Node>branch를 가리키는 경우) 이 됩니다. leaf에서 카운트를 출력하면 [4], branch가 이제 branch.children에 저장된 leafRc<Node> 복사본을 가지고 있기 때문에 strong count 가 2 가 되지만 weak count 는 여전히 0 이 됩니다.

내부 범위가 종료되면 [5], branch가 범위를 벗어나고 Rc<Node>의 strong count 가 0 으로 감소하므로 해당 Node가 drop 됩니다. leaf.parent의 weak count 1 은 Node가 drop 되는지 여부와 관련이 없으므로 메모리 누수가 발생하지 않습니다!

범위가 끝난 후 leaf의 부모에 접근하려고 하면 다시 None을 얻게 됩니다 [6]. 프로그램이 종료될 때 [7], leafRc<Node>는 strong count 가 1 이고 weak count 가 0 입니다. 왜냐하면 변수 leaf가 이제 다시 Rc<Node>에 대한 유일한 참조이기 때문입니다.

카운트 및 값 drop 을 관리하는 모든 로직은 Rc<T>Weak<T>Drop 트레이트의 구현에 내장되어 있습니다. Node의 정의에서 자식에서 부모로의 관계가 Weak<T> 참조여야 한다고 지정하면, 참조 사이클과 메모리 누수를 생성하지 않고 부모 노드가 자식 노드를 가리키고 그 반대로도 가리킬 수 있습니다.

요약

축하합니다! 참조 사이클은 메모리 누수 (Reference Cycles Can Leak Memory) 랩을 완료했습니다. LabEx 에서 더 많은 랩을 연습하여 실력을 향상시킬 수 있습니다.