안녕하세요, 단테입니다.
오늘은 러스트의 오너쉽과 메모리에 대해 알아보겠습니다.
러스트에서 오너쉽이란 메모리에서 값이 관리되는 생명주기를 의미합니다. 오너쉽이라는 개념을 통해 러스트는 메모리 세이프를 보장할 수 있으며 러스트 컴파일러가 자동으로 메모리 할당 해제를 할 수 있게 도와주기도 합니다.
러스트에서 스택은 메모리 공간이며 프로그램이 실행되는 런타임에 자동으로 관리되는 공간입니다. 컴파일 타임에 파악할 수 있는 Scalar Type 값들을 저장하는 공간이며 힙에 비해 저장하는 속도가 빠릅니다.
예를 들어 합수가 호출될 때 함수 내부에 선언된 변수들은 스택 공간안에 저장되며 함수가 끝나고 반환할 때 지역 변수들은 스택에서 사라집니다.
힙은 런타임에서 동적으로 변경될 수 있는 값들을 저장하는 장소입니다. 오너쉽을 가지고 있는 변수에서 경우에 따라 힙에 선언된 메모리 해제를 해주어야 하는 경우가 있습니다. 힙에 값을 할당하게 될 경우 해당값의 주소를 가르키고 있는 것을 포인터라 부르며 이 포인터는 스택에 저장됩니다. 메모리 내부에 있는 빈공간을 memory allocator가 찾아줘야 하므로 스택에 저장하는 것에 비해 느립니다.
러스트에서 variable scope
란 특정 변수를 참조할 수 있는 지역
을 의미합니다. 이 범위를 영어로는 visibility
라고 하는데요, 이 visibility
란 해당 변수에 대한 컴파일러의 인지 여부라고도 볼 수 있습니다.
스코프는 코드 내에 선언된 변수의 위치와 더불어 curly brace {}
로 결정됩니다.
러스트의 변수들은 ownership
, 즉 소유권이라고 하는 속성을 가지고 있습니다. value
들로 인해 어떻게 메모리가 점유당했는지 관리하는 속성입니다.
variable
이 스코프를 벗어난다면 러스트의 borrow checker
가 variable
에 저장된 value
값을 메모리에서 해제해야 하는지 확인하고, 해당 값이 다른 variable
에 의해 사용될 수 있게 합니다.
fn main() {
// variable x is declared and initialized in the main function
let x = 5;
// variable x is in scope within the curly braces of the main function
println!("The value of x is: {}", x);
{
// variable y is declared and initialized within a new block of code
let y = 10;
// variable y is in scope within the curly braces of this block
println!("The value of y is: {}", y);
}
// variable y is no longer in scope after the curly braces end
// the following line will cause a compile-time error because y is not in scope
// println!("The value of y is: {}", y);
}
main 함수 내부에서 {}
를 벗어난 이후 y는 스코프 바깥에서 참조될 수 없습니다.
지난 포스팅에서 Character
타입만 배웠었습니다.
러스트에서 String
타입은 동적으로 변경될 수 있기에 heap에 저장됩니다. 내부적으로 Vec<u8>
을 통해 구현되어 있는데요, 이 말은 heap에서 연속된 배열의 바이트로 구성되어있다는 뜻입니다.
자바스크립트나 자바는 Garbage Collection (GC)가 있어 참조되지 않는 값은 메모리에서 자동으로 삭제해줍니다. C는 alloc을 통해 메모리 할당과 해제를 명시적으로 해주어야 합니다.
러스트는 다른 방법을 사용합니다. 변수가 스코프를 벗어날 때 해당 변수가 참조하고 있는 메모리를 자동으로 해제합니다.
앞서 봤던 String
타입을 다시 가져오겠습니다.
이 문자열 타입이 스코프를 벗어날 때 borrow checker
는 문자열 변수에 바인딩된 값이 메모리 해제되어야 하는지, 혹은 해당 메모리 주소를 다른 변수로 옮겨야 하는지 판단합니다. 만약 런타임에서 아무 변수에도 바인딩 되지 않는다면 해당 메모리는 비워지게 됩니다.
다음의 예제 코드에서 변수 s는 immutable하며 hello 문자열을 담은 heap의 메모리 주소를 가르킵니다.
변수 s가 내부 {}
스코프로 옮겨지며 outer scope에 속하지 않게 됩니다.
{}
가 끝나면 s
가 가르키는 문자열은 더 이상 heap에 존재하지 않기 때문에 에러가 발생하게 됩니다.
fn main() {
// s is a String value that is stored on the heap
let s = String::from("hello");
// s is in scope within the curly braces of the main function
println!("The value of s is: {}", s);
{
// s is moved into the new scope and is no longer in the outer scope
let _s = s;
// s is in scope within the curly braces of this block
println!("The value of s is: {}", _s);
}
// s is no longer in scope after the curly braces end
// the following line will cause a compile-time error because s is not in scope
// println!("The value of s is: {}", s);
}
아래 코드에서 변수 x에 값 5를 바인딩하고 y에 x의 값을 복사했습니다.
이 때 x, y의 값은 stack
에 저장됩니다.
let x = 5;
let y = x;
러스트는 integer와 같은 Scalar type의 데이터의 정확한 사이즈를 컴파일 타임에 알 수 있기 때문에 heap이 아닌 stack에 저장시킵니다.
이때 move가 아닌 Copy
trait가 디폴트로 실행됩니다.
하지만 다음 코드는 에러를 발생시킵니다.
fn main() {
let s1 = String::from("hello");
let s2 = s1;
println!("{}, world!", s1); // compile time error
}
그 이유는 바로 Copy
trait이 실행된 것이 아니라 move
가 실행되었기 때문인데요.
s2에 s1의 주소를 옮긴 이후 s1은 heap 영역에서 삭제됩니다.
러스트는 타입에 따라 move, copy중 하나를 적용합니다.
만약 값을 deep copy
하고 싶다면 다음처럼 해야 합니다.
let s1 = String::from("hello");
let s2 = s1.clone();
println!("s1 = {}, s2 = {}", s1, s2);
heap을 도식화 하면 다음과 같습니다.
러스트 컴파일러에는 borrow checker
라고 하는 컴포넌트가 있습니다. 작성된 코드에 따라 data race나 segmentation fault와 같은 에러 발생 여부를 참조해주는 컴포넌트입니다.
우리가 도서관을 운영하는 관리자라면 책을 고객에게 대출해주기 전에 다음 규칙을 준수하는지 먼저 확인해야 합니다.
동일한 책을 여러 명에게 동시에 대출해줄 수 없습니다.
대출 중인 책을 다시 빌려줄 수 없습니다.
대출 중인 책을 수정할 수 없습니다.
여기서 관리자는 러스트 컴파일러, 고객은 variable
입니다. 책들은 변수안에 담길 value
입니다.
관리자는 여러 고객에게 동일한 책을 대출해줄 수 없습니다.
동시에 여러 변수에게 동일한 값을 바인딩해줄 수 없습니다.
관리자는 대출 중인 책의 표지에 수정을 가할 수 없습니다.
value는 immutable하게 borrow된 상태에서 (& reference)를 사용해서 mutable하게 (&mut reference를 사용해서) 수정될 수 없습니다.
위와 같은 borrow checker
는 위와 같은 규칙을 기반으로 컴파일 타임에 위배되는 작업이 있지는 않은지 확인해줍니다.
예제 코드를 통해 borrow checker
를 활용해 컴파일 타임에 소유권 문제를 점검해보겠습니다.
아래 코드에선 variable x
는 5로 초기화 되었습니다. y
는 x에 대한 immutable reference를 나타냅니다.
이 말은 y는 x의 value를 빌렸지만 수정하지는 못하는 것입니다.
z
는 x
에 대한 mutable reference를 나타냅니다. 이 말은 z는 x의 값을 빌리고 또한 수정할 수도 있다
는 의미입니다.
fn main() {
let x = 5;
let y = &x; // y is an immutable reference to x
let z = &mut x; // this line will cause a compile-time error because x is already borrowed immutably
}
let z 에서 에러가 발생함을 확인할 수 있습니다. 변수 y에서 x가 immutable하게 borrow 되었기 때문에 바로 아래줄에서 mutable하게 빌리지 못한 것입니다.
앞서서 data race
라는 생소한 단어가 등장했습니다. data race는 두 개 이상의 스레드가 동시에 단일 데이터에 접근할 때 쓰기 동작을 하는 스레드가 있는 상황을 의미합니다. 데이터 일관성을 해치는 원인이 될 수 있기 때문에 디버깅이 어렵게 만들고 데이터의 결과가 undefined가 되는 경우가 나타날 수 있습니다.
borrow checker
는 이러한 data race
를 방지하는 역할또한 하게 됩니다.
use std::sync::Arc;
use std::thread;
fn main() {
let data = Arc::new(5);
let data_clone = data.clone();
println!("Is same reference", *data == *data_clone); //true
handle.join().unwrap();
}
위 코드에서 변수 data
는 메인 스레드에 의해 immutable하게 borrow
되었는데요, 동시에 data_clone 은 같은 heap 주소를 가르킵니다.
fn main() {
let data = Arc::new(5);
let data_clone = data.clone();
let handle = thread::spawn(move || {
// this line will cause a data race because data_clone is being borrowed mutably
// while data is being borrowed immutably by the main thread
*data_clone += 1;
});
handle.join().unwrap();
}
위와 같이 *data_clone을 통해 heap에 할당된 값을 변경하려고 하면 두 개의 variable이 동시에 동일한 heap 주소를 바라보고 있는 상태에서 값의 변경이 일어나는 것인데, 이를 borrow checker
가 방지해줍니다.
앞서서 러스트는 타입에 따라 move와 cop를 다르게 적용시킨다고 했는데요,
value가 함수에게 전달될 때 함수는 해당 value에 대한 오너쉽을 가지고 이후 과정을 어떻게 진행함에 따라 메모리 해제 여부를 결정합니다. 이 말은 함수 argument로 전달된 변수는 전달된 이후에 해당 값에 대한 오너쉽을 박탈당하는 것인데요
fn foo(mut x: Vec<i32>) -> Vec<i32> {
// x is in scope within the function and has ownership of the value
x.push(5);
// x is returned and the ownership of the value is transferred back to the caller
x
}
fn main() {
// v is a variable that is initialized with a Vec<i32> value
let v = vec![1, 2, 3];
foo(v);
// the value of v is moved into the foo function and is no longer in the main function
// the following line will cause a compile-time error because v is not in scope
println!("The value of v is: {:?}", v); // <- 에러 발생
}
위 코드에서 함수 foo
는 Vec<i32>
타입을 argument로 사용합니다. 러스트는 Vec<i32>
타입은 Copy trait을 사용하는 것이 아니라 move를 사용하기 때문에 함수 foo 호출 이후 함수 main 스코프에서는 변수 v는 vec![1,2,3] 에 대한 오너쉽을 더 이상 가지지 않습니다.
만약 다시 오너쉽을 되찾고 싶다면 다음 처럼 오너쉽을 취득한 함수가 해당 값을 다시 반환시켜 주고 이 값을 스코프 내부에서 다시 바인딩한 후 사용해야 합니다.
fn main() {
let v = vec![1, 2, 3];
let u = foo(v);
println!("The value of u is: {:?}", u);
}
Vec<i32>
의 값 뿐만 아니라 reference를 주고 받는 경우를 살펴보겠습니다.
fn foo(x: &Vec<i32>) -> &i32 {
// x is a reference to a Vec<i32> value and does not have ownership of the value
// the value of x can be accessed but it cannot be modified
let y = &x[0];
// y is a reference to an i32 value and does not have ownership of the value
// the value of y can be accessed but it cannot be modified
y
}
fn main() {
// v is a variable that is initialized with a Vec<i32> value
let v = vec![1, 2, 3];
// the reference to the value of v is passed to the foo function
// the value of v is still in the main function and can be accessed
let x = foo(&v);
// the value of v can be accessed because it has not been moved
println!("The value of v is: {:?}", v); // [1,2,3]
// the value of x can be accessed because it is a reference to a value that is in scope
println!("The value of x is: {:?}", x); // 1
}
여기서 함수 foo는 변수 x의 오너쉽을 가지고 있지 않습니다. 그저 Vec<i32>
의 reference를 가지고 있는 것입니다. reference는 해당 주소가 가르키고 있는 값에 대한 오너쉽이 없습니다. 그저 해당 값을 가르키기만 합니다.
let x = foo(&v)
가 선언된 라인에서 변수 v는 여전히 value에 대한 오너쉽을 유지하고 있습니다. 호출부에서 오너쉽을 잃어버리지 않는 것인데요, 함수 foo 내부에서는 x에 Vec<i32>
에 대한 reference만 가지고 있기 때문에 x가 가르키는 값에 대해서 수정을 할 수 없습니다.
만약 수정하고 싶으면 &mut를 사용해야 합니다.
오늘은 러스트가 다른 프로그래밍 언어와 비교하여 두드러지게 드러나는 차이점인 오너쉽, 그리고 메모리 할당에 대해 알아보았습니다. 공식 문서를 통해 오늘 포스팅을 한번 공부해보시기 바랍니다.
감사합니다.