태그 보관물: memory

Rust의 Sized / ?Sized (Marker Trait)

간단한 Rust Template 함수하나를 생각해 보자. Argument ‘T’를 받아서 화면에 출력하는 코드이다.

fn print_item<T: Debug>(item: &T) {
    println!("{:?}", item);
}

Template의 취지에 맞게 넘겨지는 argument의 type을 달리하면 그에 해당하는 함수들이 자동으로 만들어지고 해당 코드가 호출된다.

fn main() {
    let t1: i32 = 42;
    let t2: i64 = 42;
    let t3: f32 = 42.;
    let t4: f64 = 42.;
    let t5: String = "hello".to_string();
    print_item(&t1);
    print_item(&t2);
    print_item(&t3);
    print_item(&t4);
    print_item(&t5);
}

objdump를 이용해서 이 template에 의해 실제로 생성된 함수들을 살펴보면 다음과 같이 각 데이터 타입에 대해 생성된 것들을 확인할 수 있다.

$> objdump -d -C ./print_item|grep print_item
./print_item:	file format mach-o arm64
00000001000008c0 <print_item::print_item::<alloc::string::String>>:
0000000100000908 <print_item::print_item::<f64>>:
0000000100000950 <print_item::print_item::<f32>>:
0000000100000998 <print_item::print_item::<i32>>:
00000001000009e0 <print_item::print_item::<i64>>:
0000000100000ce8 <print_item::main>:
...

문자열 슬라이스 template

이번에는 print_item() 함수에 &str type을 한번 넘겨 보자

let t6: &str = "hello";
print_Item(t6);

Rust 컴파일러 특유의 길고 자세한 오류 보고인데 요약하자면:

`t6`의 크기를 컴파일 타임에 알 수 없어서 컴파일에 실패 했습니다. 왜냐하면 str 타입에 대해서 Sized trait 이 구현되어 있지 않거든요. 함수 정의 쪽에 ?Sized를 추가해 보세요.

Rust는 “Zero 비용 추상화“를 지향하기 때문에 이를 지원하기 위한 수단으로 컴파일 시간에 모든 데이터의 크기를 “알고” 있는 것을 전제로 한다. 그런데 &str 같은 슬라이스를 print_item(&T) 함수에 넘기면 T가 str이 되므로 원래 할당된 공간의 "hello"를 가리키게 된다. 현재는 그 크기가 다섯 글자짜리 문자열이지만, 추후에 받아들이는 문자열의 실제 길이에 따라 데이터의 크기가 변할 수 있는 상황이 되는 것이다.

이 경우에는 컴파일 오류에서 안내된 것 처럼 ?Sized를 template의 type에 추가해서 type 확인에 대한 제한을 완화해 주면 해당 오류가 해결되면서 str type의 함수 template도 잘 동작한다.

$> objdump -d -C ./print_item|grep print_item
./print_item:	file format mach-o arm64
00000001000008c0 <print_item::print_item::<alloc::string::String>>:
0000000100000908 <print_item::print_item::<f64>>:
0000000100000950 <print_item::print_item::<str>>:
000000010000099c <print_item::print_item::<f32>>:
00000001000009e4 <print_item::print_item::<i32>>:
0000000100000a2c <print_item::print_item::<i64>>:
0000000100000d58 <print_item::main>:
...

크기를 알아야 된다매?

대부분의 경우에는 Rust compiler가 암묵적으로 Sized Marker Trait을 자동으로 붙여주어서 그 크기를 알고 있다고 알린다. 하지만 사용자가 ?Sized를 붙이는 경우에는 크기 확인에 대한 제약을 완화해 주어서 컴파일을 가능하게 해준다. 위 예제의 경우 &str fat pointer를 넘기게 되므로 실제 데이터 “hello”의 크기는 늘거나 줄어들 수 있어도 이것을 가리키고 있는 fat pointer의 크기 자체는 변하지 않으므로 ?Sized 로 조건을 완화해도 문제가 없는 것이다.

다시 말해서, 컴파일 타임에 “모든” 데이터 타입의 크기를 알고 있어야 한다는 것은 Rust에서 엄밀하게 말해 정확하지 않다.

좀 더 정확하게 말하자면:

“스택에 직접 올라가는 값이나 변수의 크기는 컴파일 타임에 반드시 알아야 하지만, 포인터나 참조자로 간접 참조하는 데이터는 그렇지 않아도 된다.”

여기에서 “그렇지 않아도 되는” 조건에 &str template에 해당하는 것이어서 ?Sized로 이것을 풀어 줄 수 있었던 것이다.

Sized / ?Sized Marker Trait

Sized는 Rust 표준 라이브러리(std::marker::Sized)에 정의된 마커 트레이트(marker trait)이다. Sized marker trait은 다음과 같은 특징을 가진다.

  • 메소드가 없음: 실행해야 할 함수가 없고, 컴파일러에게 “이 타입은 크기를 컴파일 타임에 알 수 있다”고 알려주는 표식 역할만 수행.
  • 기본값(Default): Rust에서 제네릭 타입 파라미터를 만들 때, 우리가 따로 적지 않아도 컴파일러는 기본적으로 T: Sized가 붙어 있는 것으로 간주.

이런게 왜 필요할까?

만약, Rust가 Sized / ?Sized 같은 번접한 marker trait을 빼고 “모든 타입의 크기를 몰라도 된다”라고 기본 설정을 했다면 컴파일 타임에 ‘모든’ 데이터 크기를 알고 Zero cost abstraction 정책으로 stack을 중심으로 하는 효율적 데이터 할당을 통한 성능 최적화 추구를 위한 설계 철학 자체가 이뤄질 수 없었을 것이다. 모든 데이터가 동적으로 할당되는 heap에 있었어야 했을 테니 말이다.

  • 스택(Stack): CPU의 스택 포인터 레스트터와 변경하면 되는 매우 빠른 메모리 할당, 데이터의 크기를 미리 알고 있는 경우(Sized가 선언된 경우) 이 영역을 사용할 수 있음.
  • 힙(Heap): 빈 공간을 검색하고, 단편화를 관리하며 운영체제 시스템 콜을 부를 수도 있는 상대적으로 느린 동적 할당.

Rust의 모든 generic이 기본적으로 Sized를 보장 함으로써, 데이터를 스택에 올려 둘 수 있었던 것이고 이것이 Rust의 빠른 성능의 바탕이 된다. 결론적으로 ?Sized는 이 철학을 깨는 것은 아니고 fat pointer의 고정된 크기는 알고 있으니 그것이 가리키고 있는 영역에 대한 검사 조건을 완화 해도 문제가 없다는 표시인 것이다.

Linux에서 메모리 포인터의 유효성 검증

Windows에서와 달리 Linux환경에서는 딱히 포인터의 유효성을 검증할 수 있는 system call이 없다. 이 포스팅은 Linux환경에서 이와 유사한 기능을 구현하기 위해 “정보의 바다”에서 찾은 내용들을 정리해 둔 것이다.

1. _etext를 이용하는 방법

첫번째 방법은 Define and use a pointer validation function 이라는 위키문서에서 가져온 것인데 컴파일러가 생성하는 text 영역의 시작점을 이용해서 포인터 값이 이를 침범 하는지 여부를 검사하고 유효성을 판단한다. 하지만 위 링크의 커멘트에도 나와 있듯이 시스템에 따라 동작하지 않을 수도 있으므로 reliable 한 구현이라 볼 수는 없다.

bool isValidPointer_ET(void *ptr) {
    extern const char _etext;
    return (ptr != nullptr) && ((const char*)ptr > &_etext);
}

2. msync()를 이용하는 방법

Checking whether a pointer is valid in Linux라는 블로그 포스트에 소개된 방법으로 매핑된 메모리 공간을 동기화 할 때 쓰는 msync() 시스템 콜을 호출 하면서 유효하지 않은 page 시작 주소를 넘겨 주면 0이 아닌 음수 값을 반환하는 것을 이용하는 방법이다. (이 경우 errno에 ENOMEM이 설정된다)

#include <sys/mman.h>
#include <unistd.h>

bool isValidPointer_MS(void *ptr) {
    const size_t pageSize = sysconf(_SC_PAGESIZE);
    void *basePtr = (void *)((((size_t)ptr) / pageSize) * pageSize);
    return msync(basePtr, pageSize, MS_SYNC) == 0;
}

3. mincore()를 이용하는 방법

Stackoverflow에 올려진 Testing pointers for validity (C/C++)라는 질문에 대한 답변에 있는 아이디어 중 하나인데 메모리 페이지의 swap 상태를 확인해서 반환해 주는 mincore()을 이용하는 방법이다. 해당 답변에는 다른 아이디어 들도 있으니 필요에 따라 참고.

#include <sys/mman.h>
#include <unistd.h>

bool isValidPointer_MC(void *ptr) {
    unsigned char vec = 0;
    const size_t pageSize = sysconf(_SC_PAGESIZE);
    void *basePtr = (void *)((((size_t)ptr) / pageSize) * pageSize);
    int ret = mincore(basePtr, pageSize, &vec);
    return (ret == 0 && ((vec & 0x1) == 0x1));
}

시험 결과와 결론

위의 함수들에 대해 Linux환경에서 전역 변수 포인터, 지역 변수 포인터, 널 포인터, 널 포인터는 아니지만 명백하게 무효한 포인터(0x04 같은), 동적 할당된 공간에 대한 유효성 여부는 잘 동작한다.

하지만, 이미 해제된 포인터나 할당되지 않은 heap 공간 내의 임의 주소에 대해서는 제대로 유효성 여부를 판단하지 못하고, 주소 범위가 유효 하다면 포인터 역시 유효 하다고 판단하는 오류가 세가지 구현 모두에 있다.

    // 해제된 heap공간에 대한 유효성 여부 확인. 모두 실패함.
    unsigned int* dynamicVar = new unsigned int[100];
    delete[] dynamicVar;
    EXPECT_FALSE(isValidPointer_ET(dynamicVar));
    EXPECT_FALSE(isValidPointer_MS(dynamicVar));
    EXPECT_FALSE(isValidPointer_MC(dynamicVar));


    // 할당 되지 않은 Heap공간 내의 임의 포인터에 대한 유효성 확인. 모두 실패함.
    unsigned int* dynamicUnallocVar = dynamicVar + 100;
    EXPECT_FALSE(isValidPointer_ET(dynamicUnallocVar));
    EXPECT_FALSE(isValidPointer_MS(dynamicUnallocVar));
    EXPECT_FALSE(isValidPointer_MC(dynamicUnallocVar)); 

즉, 위의 구현 들은 주어진 포인터가 유효한 메모리 공간내에 속하는지는 확인할 수 있어도, 동적 할당 영역의 메모리 포인터가 실제 read/write 가능한 상태인지 여부는 정확히 반환 할 수 없다.

시험에 사용한 code는 여기에 붙여 둔다.