프로그래밍 언어는 메모리를 다루는 방식에 따라 2가지로 분류할 수 있다.
메모리를 수동으로 할당, 해제하는 Unmanaged Language
C, C++, Pascal 등이 대표적이다.
메모리를 자동으로 할당, 해제하는 Managed Language는 대표적으로 C#, Java, Go 등이 있다.
Rust는 이 중 어디에도 속하지 않는데, 우선 두 진영의 지향점과 한계에 대해 살펴보자.
이 쪽 진영은 편의와 안전을 우선시한다.
개발자가 수동으로 메모리를 할당하고 해제하는 것이 더 빠르지만, 이는 메모리 관련 버그를 발생시킬 위험이 크다.
할당을 했으면 반드시 해제를 해야 하는데, 이를 수행하지 않는 경우 메모리 누수가 발생할 수 있다.
이를 매 번 신경쓰는 것은 여간 까다로운 일이 아니므로, Garbage Collector가 이를 대신 수행한다.
모든 객체의 메모리 레이아웃 전방에 Header를 달아두고 GC가 이를 참조하여 사용 중인 메모리를 mark하는 방식으로 수행된다.
덕분에 메모리 버그 및 누수로부터 어느 정도 해방될 수 있지만, 성능과의 Trade-Off가 발생한다.
GC도 하나의 프로그램이기 때문에, Managed Language를 사용하는 한, 백그라운드에서 GC가 계속해서 수행되어야 한다.
메모리를 지속적으로 검사하고, 개발자 대신 해제를 해야 하기 때문이다.
대량의 객체가 존재하는 경우, 해제를 위해 GC가 수행되는 동안 전체 프로세스가 멈추는 Stop the World(STW)라고 부르는 현상이 발생할 수 있다.
또한 정확히 언제 객체가 해제되는지 파악하기 어려울 수 있다는 단점도 있다.
메모리 통제권이 개발자가 아닌 GC에게 이관되었기 때문이다.
개발자가 수동으로 메모리 할당/해제를 수행해야 하는 Unmanaged Language 진영에서는 최우선순위가 성능이다.
최근에는 메모리 세이프티 관련 문제가 부각되며 NSA에서도 C, C++ 사용 중단을 권고하는 사태까지 발생했으나, 여전히 C, C++로 작성된 막대한 생태계를 대체하기 까지는 긴 시간이 소요될 것이다.
Managed Language에서 일어날 수 없는 메모리 관련 버그가 일어날 가능성이 있기 때문에 안전하지 않다고 하지만,
사실 Unmanaged Language로 작성된 OS는 그 어떤 프로그램보다도 탄탄하게 잘 돌아간다.
안전이라는 것이 상대적인 개념이라 코드 작성에 들이는 시간과 개발자의 역량에 따라 판이하게 달라지기 때문인 것으로 보인다.
그럼에도 불구하고 모든 개발자의 역량과 시간을 OS 수준으로 들일 수는 없기 때문에 성능이 매우 중요한 곳이 아닌 일반적인 서비스를 구축한다 라면 Unmanaged Language는 잘 사용되지 않는다.
참고로 하단의 기사를 살펴보면 Chrome 보안 문제 중 70%가 이러한 실수로 인해 발생한다는 내용을 볼 수 있다.
이렇듯 성능 우선주의인 C/C++ 진영에서는 최상급 개발자들 조차도 이러한 메모리 관련 버그를 피해갈 수 없다는 한계점을 엿볼 수 있다.
https://www.zdnet.com/article/chrome-70-of-all-security-bugs-are-memory-safety-issues/
Rust는 전통적인 두 범주에 속하지 않는, 제 3의 법칙을 통해 메모리를 관리한다.
우선 GC가 필요 없기 때문에 overhead가 발생하지 않는다.
또한 수동 할당 및 해제도 필요 없기 때문에 대부분의 메모리 문제도 발생하지 않는다.
Rust는 Ownership을 기반으로 메모리 문제를 해결하였다.
Rust의 메모리 관리를 위한 3가지 원칙은 다음과 같다.
코드 예시로 살펴보겠다.
fn main() {
// 1. msg는 "abc"의 owner
let msg = "abc".to_string();
// 2. msg2는 msg의 ownership을 전달 받음
let msg2 = msg;
} // 3. main의 scope를 벗어날 때, msg2는 drop
위 코드에 방금 본 3가지 원칙이 모두 다 들어있다.
우선 msg에 새로운 값을 할당하였기 때문에 msg는 해당 값의 Owner라고 볼 수 있으며,
해당 값의 Ownership을 갖는다고도 볼 수 있다.
어떤 값의 Owner는 반드시 하나만 존재할 수 있는데, msg를 선언한 1번까지는 이 규칙이 지켜진다.
그러나 2번에서 msg를 msg2에 할당하면서 이 규칙이 깨지게 된다.
msg가 갖고 있던 값을 msg2도 갖게 되기 때문이다.
고로 3원칙 중 2번째 원칙인 Ownership이 이동하면 이전 Owner는 사용할 수 없다.가 적용된다.
msg2에 msg의 값을 할당 하였으므로 msg는 더 이상 사용할 수 없게 된다.
고로 msg2는 msg의 값을 물려받고, msg는 소멸한다고 생각할 수 있는데,
이 현상을 move라 칭한다.
그리고 마지막으로 3원칙을 살펴보면,
모든 프로그래밍 언어에서는 함수의 block scope를 벗어날 때 stack frame이 해제된다.
3원칙도 이와 거의 비슷한 이야기를 하고 있는 것인데, Rust는 추가적인 작업이 수반된다.
String은 현대 프로그래밍 언어에서 단순 문자열이 아닌, 문자열의 시작 주소, 길이를 가진 객체 형태로 이루어져 있다.
대부분의 경우 String 객체가 가리키는 문자열의 실제 데이터는 Heap에 할당되어 있을 것이다.
(위 예제의 경우는 아니지만 일단 Heap에 할당 되었다고 가정하자.)
Java에서는 String이 Stack Frame을 벗어나면 Stack 상의 Pointer는 사라지고, Heap 상에 남아있던 실제 데이터는 GC에 의해 비동기적으로 소멸될 것이다.
그러나 Rust는 이러한 GC 작업을 일절 하지 않는다고 했다. GC 자체가 없으니 말이다.
그래서 stack frame이 scope를 벗어날 때, drop이라는 함수를 자동으로 호출하여 String의 Heap에 할당된 데이터를 모조리 drop시킨다.
코드로 표현하면 다음과 같다.
fn main() {
// 1. msg는 "abc"의 owner
let msg = "abc".to_string();
// 2. msg2는 msg의 ownership을 전달 받음
let msg2 = msg;
// 3. scope를 벗어날 때, 자동으로 String의 drop 호출(실제 작성 X)
String::drop(msg2);
}
GC에 의존하는 것도, 개발자가 직접 코드를 작성하는 것도 아닌, Rust Compiler가 메모리 해제 코드를 자동으로 삽입하는 것이다.
이렇게 하면 실수도 방지하면서 오버헤드도 전혀 생기지 않을 수 있다.
그렇다면 다른 언어에서는 왜 이러한 방법을 사용하지 않을까.
구조적으로 불가능하기 때문이다.
매우 간단하게만 보았기 때문에 이 방법이 당장은 매우 좋아 보일 수 있지만, 당연히 약점 및 이로 인해 수반되는 다양한 복잡도라는 비용이 존재한다.
Rust는 이런 방법론들을 적용하기 위해 수많은 시행착오 과정을 거쳐서 linux kernel 소스 코드에 들어갈 수 있을 정도로 production에서 검증과 인정을 받았다.
C++이 무려 30년 동안 linux kernel에 포함되길 거부당한 것을 생각하면 Rust의 안전성이 업계에서 얼마나 인정 받았는지를 깨달을 수 있다.
이 수준까지 온 언어는 C를 제외하고는 Rust가 유일무이하다.
왜 drop을 삽입하는 것이 Rust 말고는 불가한지를 간단하게 살펴보자
이해를 돕기 위해 Rust에 Ownership이 없다고 가정하겠다.
즉, 아래 코드에서 msg, msg2는 모두 유효하다고 가정한다.
fn main() {
let msg = "abc".to_string();
// ownership이 존재하지 않는다고 가정. 즉 msg, msg2가 모두 유효
let msg2 = msg;
// 자동으로 삽입되는 drop
String::drop(msg);
String::drop(msg2);
}
이 경우, msg, msg2는 모두 유효하므로 둘 다 scope를 벗어날 때, drop을 호출하게 될 것이다.
그런데 이들이 가리키는 Heap 상의 데이터는 동일한 주소를 가리킨다.
즉, 동일한 주소에 대해 2번의 메모리 해제 로직이 호출된 것이다.
C, C++에서도 메모리 해제를 위한 free, delete 등을 2회 호출하는 행위를 double free라고 부르는데,
이는 UB(undefined behaviour)을 발생시킨다.
즉, 어떤 오류가 발생할 지 예측 불가하다.
C에서 malloc 등을 통해 메모리를 할당 받으면 반환 받는 실제 메모리 시작 주소가 있다.
그 시작 주소 앞의 약간의 메모리 공간이 OS에 의해 관리되며, 이 안에 다양한 메타 데이터들이 담긴다.
예를 들어 할당 받은 메모리 공간이 몇 byte인지, 현재 상태는 어떤지, 다음 메모리 블록의 주소는 무엇인지 등이다.(세부 구현은 OS나 버전에 따라 다 다를 것이기 때문에 대략적으로만 생각해보자.)
메모리 해제가 한 번 일어나게 되면 해당 메모리의 메타 데이터가 변경될 것이다.
상태를 나타내는 flag bit는 사용중에서 해제중으로 바뀌어 있을텐데,
2번째 free가 호출되기 전에 다른 변수에 해당 메모리 공간이 할당된다면?
메모리 주소를 0x100이라고 치면,
free msg 0x100
malloc 0x100(다른 쓰레드에 의해 할당)
free msg2 0x100
위와 같은 일이 발생할 수 있다.
코드가 붙어 있다고 해서 항상 순서대로 호출되는 것이 아니며,
free는 Kernel mode로 전환이 필요하기 때문에 오버헤드가 발생하여 실제 해제되기까지 CPU 관점에서 봤을 때 상당한 시간이 지연될 수 있다.
free 0x100 -> malloc 0x100 -> free 0x100이 발생할 여지가 충분하다.
그렇다면 해당 메모리 공간을 할당 받은 다른 변수는 영문도 모르고 메모리가 해제되는 꼴이 된다.
그럼 그 뒤에 해당 메모리를 다시 다른 변수가 할당 받아서 덮어쓰게 될 것이고,
뒤에는 전혀 다른 값이 들어가 있을 것이다.
그것도 계속 다른 변수들에 의해 할당과 해제를 반복하면서 자신이 참조하는 메모리 공간에 대한 통제권 자체를 잃어버리게 된다.
무수한 버그가 발생할 수 있다는 것도 문제지만, 언제 어떤 버그가 발생할 지 예상이 불가하다는 것이 가장 타격적이다.
그것이 undefined behaviour이기도 하다.
그러나 Rust는 다른 언어들과 다르게 Ownershop이 존재하는 언어다.
fn main() {
let msg = "abc".to_string();
// ownership이 존재하므로 msg는 소멸
let msg2 = msg;
// 자동으로 삽입되는 drop
// String::drop(msg); -> ownership이 없으므로 drop 호출 X
String::drop(msg2);
}
그래서 위와 같이 Ownership이 유효한 msg2만 drop이 호출되고, double-free 문제가 절대로 발생할 수 없는 구조다.
Ownership은 오직 하나만 존재할 수 있기 때문에, drop도 한 번만 호출될 수 있는 것이다.
다른 언어는 Ownership이라는 개념이 존재하지 않기 때문에 이러한 구현 자체가 불가하다.
물론 뒤로 가면 다중 Ownership을 가지는 Rc, Arc 등의 Reference Counting 기법도 존재하지만 기본은 이렇다.
이렇게 자동으로 drop을 삽입하는 것을 RAII(Resource Acquisition Is Initialization)라고도 부른다.
C++에서 온 개념인데, 메모리 할당 후 해제, 파일 open 후 close, mutex lock 후 unlock 등 자원 획득, 해제를 나누면 실수로 해제를 하지 않는 경우가 많기 때문에 하나의 단위로 묶어 놓은 것이다.(C++, Rust에만 존재하는 것이 아니라 Java, Python의 try-with-resources 등도 이 패턴이라 볼 수 있고, C에서도 malloc-free를 래핑하여 직접 구현을 흉내낼 수 있다.)
Rust는 이 RAII 패턴을 통해 memory safety, thread safety를 보장한다.