• logo

      SeolMyeongTang

  • Rust, 소유권으로 memory safety와 thread safety를 만나다

    2023년 12월 17일

    C, C++ 뒤를 이을 차세대 시스템 프로그래밍 언어로 불리는 언어가 있습니다. 바로 Mozilla 재단에서 만든 Rust 입니다. Rust가 차세대 시스템 프로그래밍 언어로 불리는 이유 중 하나는 이전 시스템 프로그래밍 언어에서 볼 수 없는 ‘안전성’이라고 생각합니다. Rust는 호환성, 메모리 오류, 동기화 문제와 같은 고민을 보다 덜 신경쓸 수 있습니다. Rust는 Firefox 엔진에 멀티 코어 환경을 지원하면서 나타난 동시성 문제를 해결하기 위해 만들었습니다. 이전에 ‘Firefox 브라우저, 멀티 코어와 만나다’ 글에서 어떻게 Rust가 동시성 문제를 해결했는지 잠깐 언급했습니다. 이번 글은 MDN의 Fearless Security: Memory SafetyFearless Security: Thread Safety 글을 참고하여 왜 Rust는 memory safety(이하 메모리 안전성)하고 thread safety(이하 스레드 안전성) 한지 살펴보겠습니다.

    Rust는 메모리 안전, 스레드 안전을 보장하는 언어입니다. 다른 시스템 프로그래밍 언어들도 메모리 안전성, 스레드 안전성을 위한 장치들이 있지만 Rust와 비교하면 아쉬운 부분이 있습니다. Rust의 비법을 알기 보기 전에 개발자들은 왜 시스템 프로그래밍 언어를 쓰는 걸까요?

    저수준(low-level) 언어라고도 불리는 시스템 프로그래밍 언어는 성능이 좋습니다. 시스템 프로그래밍 언어는 사람들이 해석하기 어려운 기계어를 추상화한 어셈블리어를 추상화한 언어입니다. 기계어에 가까운 언어라서 직접적인 하드웨어 접근, 메모리 관리가 가능합니다. 이러한 특징은 보다 효율적인 프로그램을 만들 수 있어 성능 향상에 도움이 됩니다.

    기계어, 어셈블리어, C언어
    기계어, 어셈블리어, C언어

    기계어는 사람이 읽기 어려워 그렇다 치고, 어셈블리어를 사용하면 시스템 프로그래밍 언어보다 더욱 성능이 좋은 프로그램을 만들 수 있는데도 잘 쓰이지 않는 걸까요?

    어셈블리어를 한 단계 추상화하여 만든 저수준 언어는 프로그래머가 이해하기 쉬운 언어이기도 하고, 성능 이점을 다소 포기하더라도 이식성을 높이기 위함이라고 생각합니다. 어셈블리어로 프로그램을 작성한다면 컴퓨터 아키텍처마다 문법이 다른 어셈블리어를 작성해야 합니다. 반면에 C, C++는 똑같은 코드를 아키텍처가 다른 여러 하드웨어에서 컴파일하고 실행할 수 있다는 장점이 있습니다. 물론 컴파일러는 해당 컴퓨터 아키텍처를 지원해야 합니다.


    시스템 프로그래밍 언어를 사용하면 성능이 좋은 프로그램을 만들 수 있습니다. 하지만 프로그래머가 수동으로 메모리를 관리하다 보니 온갖 메모리 버그들이 나타날 수 있습니다. 아래와 같이 프로그래머의 실수로 잘못된 자원에 접근할 수 있습니다.

    • Use after free: 메모리 해제한 메모리에 접근
    • Null pointer dereference: null 포인터 역참조
    • Using uninitialized memory: 초기화 되지 않는 메모리 사용
    • Double free: 메모리 해제 2번 시도
    • Buffer overflow: 설정한 메모리 범위 초과

    이러한 잘못된 메모리 접근은 프로그램이 예상치 못한 동작을 하거나 심각한 보안 문제를 일으킬 수 있습니다.

    • Crash: 유효하지 않은 메모리 접근으로 프로세스가 예상치 못하게 죽음
    • Information leakage: 비밀번호와 같은 민감한 정보가 노출
    • Arbitrary code execution(ACE), Remote code execution(RCE): 사용자의 시스템에 해커가 원하는 명령어를 실행

    메모리 관리는 어렵습니다. C와 C++는 pointer 변수를 가지고 메모리를 관리합니다. Pointer를 잘 다루면 성능 최적화된 프로그램을 작성할 수 있지만 잘못 사용하면 만악의 근원이 됩니다. C와 C++에서 pointer는 중요하지만, 어려운 개념으로 숙련된 프로그래머라도 잘못된 사용으로 위와 같은 버그가 일어날 수 있습니다. 메모리를 할당하고 해제하는 것을 깜빡한다든지, 충분한 공간의 메모리를 할당하지 않고 사용한다든지, buffer 범위를 넘어 접근한다든지 프로그래머가 신경 써야 할 부분이 한둘이 아닙니다.

    Pointer can be evil
    Pointer can be evil 출처

    C++11 문법에 smart pointer 개념이 도입되면서 메모리 안전을 위한 도구가 소개되었습니다. 일반적인 pointer와 달리 smart pointer는 self-destruct, 경계 검사 등의 메모리 관리를 garbage collection(이하 GC)처럼 알아서 관리합니다. 하지만 smart pointer는 순환 참조로 deadlock 상황이 발생하거나 overhead 비용이 발생하는 등 멀티 스레드 환경에 활용하기 까다롭습니다. 또한 smart pointer는 pointer를 편하게 다루기 위한 수단이므로 여전히 유효하지 않은 메모리에 접근하여 오류를 일으킬 수 있습니다.

    C, C++ 계열 외의 Java, Python, Go와 같은 프로그래밍 언어들은 GC를 사용하여 안전하게 메모리를 관리합니다. GC를 사용하는 언어는 메모리 관리에 신경 쓰지 않고 메모리 할당, 해제 모든 과정을 알아서 수행하여 프로그래머의 입장에서는 그저 빛 같은 존재입니다. GC를 통해 유효한 메모리 사용을 보장해 주지만 알고리즘으로 동작하는 GC는 성능 측면으로 pointer 방법과 비교하면 비효율적입니다. 즉각적으로 메모리 자원 해제를 하지 않기도 하고, runtime 환경에서 메모리를 계속 추적하고 자원 분배를 하므로 상당히 큰 overhead가 듭니다.


    Matthwe Hertz의 Quantifying the Performance of Garbage Collection vs. Explicit Memory Management 논문을 참고하면, 물리 메모리가 부족한 상태일 때 두 배 더 많은 메모리를 가졌던 GC(ex. Java)가 explicit memory management(ex. C의 malloc, free)보다 성능이 약 70% 감소한 내용을 확인할 수 있습니다.

    2005년에 발표한 논문이라 그 사이 많은 발전을 이뤘던 현재의 GC 성능와 차이가 있겠지만, GC의 overhead가 pointer 방법보다 비용이 듭니다. 관련해서 궁금하신 분들은 논문의 abstract 부분이라도 읽어보는걸 추천드립니다.


    Pointer를 쓰자니 성능은 좋으나 메모리 안전성 문제가 있고, GC를 쓰자니 메모리 안전성이 보장되나 메모리를 많이 잡아먹고… 과연 Rust는 어떤 선택을 했을까요? 메모리 안전성과 성능, 두 마리 토끼를 잡기 위해 Rust는 pointer도 GC도 아닌 새로운 방법을 고안했습니다. 바로 소유권(ownership)입니다.

    Rust는 메모리 관리를 위해 pointer, garbage collector가 아닌 ownership을 선택
    Rust는 메모리 관리를 위해 pointer, garbage collector가 아닌 ownership을 선택

    Rust는 메모리 안전성의 근본적인 문제를 해결하기 위해 소유권 규칙을 만들었습니다. 모든 Rust 코드는 다음의 소유권 규칙을 지킵니다.

    • Each value has a variable, called the owner
    • There can only be one owner at a time
    • When the owner goes out of scope, the value will be dropped

    한 줄로 쉽게 정리하자면, ‘Rust에서는 변수에 대한 소유권이 있어야 접근할 수 있으며, 한 번에 한 명의 소유자만 존재한다’라고 말할 수 있겠습니다. 이러한 규칙은 Rust 컴파일 중 한 phase인 borrow checker에서 확인합니다. 즉, Rust는 소유권 규칙과 이 규칙을 컴파일할 때 확인함으로써 메모리 안전성을 보장하고 성능을 끌어올릴 수 있습니다.

    다음의 3가지의 예시를 보면서 간단하게 소유권 개념을 알아보도록 하겠습니다.

    1. Moving ownership

      RUST
      fn main() {
      	let s1 = String::from("hello");
      	let s2 = s1;
      
      	println!("{}, world!", s1);
      }
      
      // error[E0382]: borrow of moved value: `s1`
      

      “hello” 값에 대한 소유권이 s1에서 s2로 넘어가면서 s1이 가지고 있던 소유권은 유효하지 않게 됩니다.

    2. Dangling pointer

      RUST
      fn main() {
      	let r;
      	{
      		let x = 5;
      		r = &x;
      	}
      	println!("r {}", r);
      }
      
      // error[E0597]: `x` does not live long enough
      

      Scope 밖으로 나간 변수 x의 값은 유효하지 않은 상태가 되며 해당 값을 가르키고 있는 변수 r은 dangling pointer가 되므로 컴파일 에러가 납니다.

    3. Using an uninitialized variable

      RUST
      fn main() {
      	let x: i32;
      	println!("{}", x);
      }
      
      // error[E0381]: used binding `x` isn't initialized
      

      Rust는 초기화되지 않는 변수를 선언할 수 있지만 출력을 하게 되면 컴파일 에러가 납니다.

    Rust는 소유권 뿐만 아니라 null pointer를 방지하기 위한 Option 타입, buffer overflow를 방지하기 위한 내장 buffer 타입iterator API와 같은 장치들로 메모리 안전성을 보장합니다.


    위 1번의 Moving ownership 예시에서는 컴파일 에러가 발생하지만 아래의 예시는 정상 작동합니다.

    RUST
    fn main() {
    	let s1 = 1;
    	let s2 = s1;
    
    	println!("{}", s1);
    }
    
    // output
    // 1
    

    String::from 함수는 가변 문자 길이를 할당하므로 heap 메모리에 저장됩니다. let s2 = s1; 과정에서 Rust는 이동(move)이 일어났다고 표현하고 소유권이 이동됩니다. 반면에 정수(i32 타입)는 고정된 크기를 갖으므로 stack 메모리에 할당되고 let s2 = s1; 과정에서 복사(copy)가 됩니다. 이 경우는 소유권이 이동 되지 않습니다.


    소유권이 메모리 안전성을 보장해 주지만 스레드 안전성도 보장해 줄 수 있는 장치입니다. 안정적인 프로그램을 위해 스레드 안전성 또한 메모리 안전성 못지않게 중요합니다. 오늘날의 프로그램들은 순차적으로 task를 처리하기보다는 여러 스레드로 여러 task를 동시에 처리합니다. 여러 task를 실행함으로써 빠른 처리가 가능합니다. 하지만 동시성, 병렬성으로 동기화라는 골치 아픈 문제를 발생시킵니다.

    그런데 가만, 메모리 안전성은 말 그대로 메모리 자원의 안전성을 보장하여 데이터를 안전하게 관리하는 건데 스레드 안전성은 무엇을 말하는 걸까요? MIT에서는 스레드 안전성을 ‘여러 스레드가 실행되어도 데이터 타입 혹은 정적 함수(static method)가 올바르게 작동되는 것‘으로 정의합니다.

    A data type or static method is threadsafe if it behaves correctly when used from multiple threads, regardless of how those threads are executed, and without demanding additional coordination from the calling code - MIT


    동시성(concurrency)과 병렬성(parallelism)은 서로 비슷하지만 다른 의미를 갖습니다. 동시성은 여러 작업이 동시에 실행되는 것처럼 보이도록(ex. interrupt, single thread non-blocking) 동작하는 것입니다. 병렬성은 실제로 여러 작업이 동시에 동작하는(ex. multicore 환경) 것입니다.


    메모리 안전성과 스레드의 안전성을 지키지 못해서 발생한 버그는 공통적으로 유효하지 않은 자원 사용으로 일어납니다. 여러 스레드 사이의 잘못된 자원 사용으로 deadlock, race condition, data race 문제가 발생할 수 있습니다. Deadlock의 경우 보안 문제를 발생시키지 않지만, 성능 문제를 일으킵니다. Race condition과 data race는 데이터 유실, 조작처럼 심각한 보안 문제를 발생할 수 있습니다.


    Race condition과 data race도 비슷하지만 다른 부분이 있습니다. Race condition은 task의 순서가 프로그램에 영향을 끼칠 수 있는 상태를 말합니다. Data race는 최소 하나의 스레드는 write 작업을 하는 상황에서 여러 스레드가 동시에 같은 메모리 주소에 접근하려는 상태를 말합니다. Race condition, data race는 겹치는 부분이 많지만 서로 독립적으로 일어날 수 있습니다.


    앞서 말했듯이 Rust의 변수들은 소유자(owner)를 갖습니다. 다른 스레드에서 자원을 수정하려고 하면 변수의 소유권을 이동(move)시켜 새로운 스레드가 변수에 접근할 수 있도록 합니다. 여러 스레드는 동일한 메모리 주소에 write 할 수 있지만 절대로 동시에는 안 됩니다. 다른 스레드에서 소유권을 가지지 않고 변수에 접근하고 싶은 경우, 이동이 아닌 빌림(borrow)으로써 변수에 접근할 수 있습니다.

    Rust에서 변수에 접근하려면 2가지의 방법이 있습니다. 소유권을 옮기는 이동과 소유권을 옮기지 않는 빌림이 있습니다. 빌림은 immutable borrow(불변 참조)와 mutable borrow(가변 참조)로 나누어집니다. Immutable borrow는 읽기 전용 참조이며 mutable borrow는 수정이 가능한 참조입니다. Mutable borrow로 변수 값을 수정할 수 있지만 앞서 말했듯이 동시에는 불가합니다.

    Rust의 변수는 기본적으로 immutable(불변)으로 값을 수정할 수 없습니다. 변수를 초기화하고 변수의 값을 수정하고 싶다면 mut 키워드를 사용해야 합니다. 참조를 할 때 immutable borrow와 mutable borrow를 동시에 가질 수 없고 여러 immutable borrow를 가지거나 하나의 mutable borrow를 가질 수 있습니다. 만약 아래의 예시와 같이 immutable borrow를 했는데 mutable borrow가 발생한다면 컴파일 에러가 일어납니다.

    RUST
    fn main() {
        let mut x = String::from("Hello");
    
        // 여러 immutable borrow 가능
        let y = &x;
        let z = &x;
    
        // 동시에 immutable borrow, mutable borrow 불가능
        let w = &mut x;
    
        println!("y: {}", y);
        println!("z: {}", z);
        println!("w: {}", w);
    }
    
    // error[E0502]: cannot borrow `x` as mutable because it is also borrowed as immutable
    
    RUST
    fn main() {
        let mut x = String::from("Hello");
    
        // 여러 mutable borrow 불가능
        let y = &mut x;
        let z = &mut x;
    
        println!("y: {}", y);
        println!("z: {}", z);
    }
    
    // error[E0499]: cannot borrow `x` as mutable more than once at a time
    

    아래의 코드는 정상적으로 컴파일이 됩니다. 왜 정상적으로 컴파일이 되는지 아시겠나요? 🙄

    RUST
    fn main() {
        let mut x = String::from("Hello");
    
        // immutable borrow
        let y = &x;
        let z = &x;
    
        println!("y: {}", y);
        println!("z: {}", z);
    
        // mutable borrow
        let w = &mut x;
    
        println!("w: {}", w);
    }
    
    // ouput
    // y: Hello
    // z: Hello
    // w: Hello
    

    이처럼 소유권으로 메모리 데이터를 안전하게 관리하고 스레드 사이에서의 데이터를 수정, 공유 문제를 해결할 수 있습니다. 게다가 컴파일 시간에 메모리, 스레드 안전성을 확인해 주니 새삼 Rust가 대단한 언어인 거 같습니다. Rust가 하루아침에 C, C++를 대체할 수 없겠지만 간간이 들려오는 소식을 들어보면 머지않은 미래에 너도나도 Rust를 쓰는 날이 오지 않을까 기대해 봅니다.

    Rust 소유권을 살펴보면서 개인적으로 떠돌고 있었던 컴퓨터 지식이 연결된 느낌을 받았습니다. GC가 느리게 동작하는 이유, primitive 타입이 스택 영역에 저장된다는 사실, race condition과 data race의 차이, 스레드 안전이 무엇을 뜻하는지, 동시성과 single thread에서의 non-blocking과의 관계 등을 정리하면서 조금 더 확장된 세계를 보는 거 같은 기분이 들었습니다. MDN가 소개한 Rust 글들을 시간 가는 줄 모르게 읽었는데 원문으로 읽어보는 걸 추천해 드립니다.