mem -- 메모리
mem 모듈은 힙 할당과 메모리 연산을 감쌉니다. alloc/alloc_zeroed/
dealloc/realloc_buf는 특정한 런타임 얼로케이터 오용 버그들에 대해
구조적으로 안전합니다(아래 할당 안전성 참고). 이 함수들은
?&heap T 참조를 돌려주며, T는 대입받는 변수에서 정해집니다(타입 없는 바이트에는
void). 참조는 인덱싱할 수 없으므로, 여러 원소로 된 블록은
Slice로 보며 경계 검사와 함께 읽고 씁니다.
타입이 있는 배열에는 보통 new T[n](힙)과 new<R> T[n](아레나)이 더 간단합니다 —
메모리와 리전 참고.
#include <std/mem.h>
할당
alloc
generic<T> ?&heap T alloc(unsigned long size);
초기화되지 않은 size바이트의 힙 메모리를 할당합니다. 블록에 대한
참조를 반환하며, 실패 시 null을 반환합니다.
alloc_zeroed
generic<T> ?&heap T alloc_zeroed(unsigned long size);
0으로 초기화된 size바이트의 힙 메모리를 할당합니다. 블록에 대한
참조를 반환하며, 실패 시 null을 반환합니다.
dealloc
generic<T> void dealloc(?&heap T ptr);
이전에 할당된 블록을 해제합니다. null을 전달하는 것은 아무 동작도 하지
않습니다. 같은 포인터에 대해 이 함수를 두 번 호출하거나, alloc/
alloc_zeroed가 반환한 적 없는 포인터에 대해 호출하면 힙을 손상시키는
대신 진단 메시지와 함께 중단(abort)됩니다 — 할당 안전성
참고.
realloc_buf
generic<T> ?&heap T realloc_buf(?&heap T ptr, unsigned long new_size);
이전에 할당된 블록의 크기를 new_size바이트로 조정합니다. 내용은 기존
크기와 새 크기 중 작은 쪽까지 보존됩니다. (이동했을 수도 있는) 블록을
반환하며, 실패 시 null을 반환합니다(실패 시 기존 블록은 해제되지 않습니다).
ptr이 null이면 alloc(new_size)처럼 동작합니다.
checked_mul_size
unsigned long checked_mul_size(unsigned long a, unsigned long b);
두 개의 할당 크기 인자(보통 원소 개수와 원소 크기)를 곱하며, 곱셈
결과가 unsigned long을 오버플로하면 조용히 값을 감싸는(wrap) 대신
진단 메시지와 함께 중단(abort)됩니다 — 전형적인 "count * elem_size"
정수 오버플로로 인한 과소 할당 버그를 막습니다. 표준 라이브러리에서
호출자가 제어하는 개수를 원소 크기와 곱하는 모든 할당 지점(Vec,
HashMap의 버킷, Queue, MpscQueue, LFQueue 등)은 단순한 * 대신
이 함수를 거치도록 되어 있습니다 — alloc/alloc_zeroed/realloc_buf에
전달되는 크기가 작고 컴파일 타임에 증명 가능한 상수가 아니라 두 런타임
값의 곱인 경우 어디에서나 동일하게 사용합니다.
checked_add_size
unsigned long checked_add_size(unsigned long a, unsigned long b);
checked_mul_size의 덧셈판입니다: 합이 unsigned long을 넘치면 감싸지 않고 중단합니다. 여러 필드 크기를 더해 하나로 할당할 때 사용합니다.
할당 안전성
alloc/alloc_zeroed는 모든 할당 앞에 살아있는지 해제되었는지를
태그하는 작은 16바이트 헤더를 붙입니다. dealloc/realloc_buf는
동작하기 전에 이 태그를 확인합니다:
- 이중 해제(double-free) — 같은 포인터에 대해
dealloc()을 두 번 호출하면 얼로케이터의 프리 리스트를 손상시키는 대신 진단 메시지와 함께 중단됩니다. - 얼로케이터 불일치 —
alloc/alloc_zeroed가 반환한 적 없는 포인터(스택 주소,std/alloc/pool.h같은 다른 얼로케이터에서 온 포인터, 손상된 메모리 등)에 대해dealloc()을 호출하면 쓰레기를 해제하는 대신 중단됩니다. - NULL —
dealloc(NULL)은free(NULL)과 마찬가지로 안전하게 아무 동작도 하지 않습니다. - 해제 후 사용(재해제 형태) — 해제된 포인터가
dealloc/realloc_buf에 두 번째로 다시 전달되면 이중 해제와 동일한 방식으로 잡힙니다.
이를 뒷받침하는 것은 64개 항목의 격리 구역(quarantine)입니다: 해제된
블록은 이후 약 64번의 dealloc() 호출 동안 실제로 시스템 얼로케이터에
반환되지 않으므로, 이 플랫폼 자체의 얼로케이터가 해제된 블록의 첫
바이트를 거의 즉시 덮어쓰더라도 이중 해제 검사는 신뢰할 수 있게
유지됩니다(태그 하나만으로는 OS 얼로케이터가 그 메모리를 재사용하기
전에 발생하는, 같은 포인터에 대한 이중 해제만 잡을 수 있습니다). 사이에
64개 이상의 다른 dealloc() 호출이 끼어든 이중 해제는 더 약한 "살아
있는 포인터가 아님" 진단으로 대체됩니다 — 이는 알려진, 문서화된 한계이지
조용한 허점이 아닙니다.
크기 클래스 캐시에 들어가지 않는 블록(1 MB 초과, 또는 realloc_buf로
커진 블록)은 스레드마다 8 MB의 바이트 예산에도 들어갑니다. 격리된 그런
블록들이 그보다 커지면 가장 오래된 것부터 실제로 해제합니다. 그렇지 않으면
큰 해제 수십 번이 수백 MB를 재사용하지 못하게 붙잡아, 이후의 큰 할당이
모두 새 페이지에서 시작하게 됩니다.
다루지 않는 것
이 기능은 dealloc()/realloc_buf()를 다시 호출하지 않고 오래된
포인터를 통해 그저 읽거나 쓰기만 하는 해제 후 사용은 잡아내지 못합니다
— 이를 위해서는 16바이트 헤더로 할 수 있는 것을 훨씬 뛰어넘는,
섀도 메모리 계측(ASan 방식)이 필요합니다. 또한 이 기능은 경계 검사를
대체하지 않습니다: alloc(n) 다음에 buf[n]에 대한 범위 밖 쓰기를
하는 것은 여전히 정의되지 않은 동작이며, 이는 이 기능으로도 (원시
포인터이지 배열/슬라이스가 아니므로) 어떤 컴파일 타임 검사로도 잡히지
않습니다.
std/alloc/pool.h/slab.h/tlsf.h의 얼로케이터들은 자신들의 기존
블록별 메타데이터를 사용해 동등한 이중 해제/불일치 포인터 검사를
얻습니다(격리 구역은 필요 없습니다 — 이들의 해제된 블록은 시스템 얼로케이터에
반환되는 대신 얼로케이터 자체의 백킹 버퍼 안에 그대로 남아 있으므로,
alloc/dealloc의 태그처럼 외부 요인에 의해 훼손될 수 없습니다).
할당 성능
호스트 타깃에서 alloc은 해제된 블록을 크기 클래스별로 나눈 스레드별 캐시를 유지하므로, 해제 직후 비슷한 크기를 할당하면 malloc을 호출하지 않고 처리됩니다:
- 크기 클래스는 1.5배씩 증가합니다 — 16, 24, 32, 48, 64, 96, … 최대 1 MB까지(33개 클래스) — 따라서 요청은 최대 3분의 1까지만 패딩됩니다. 더 큰 요청은 곧장
malloc으로 갑니다. - 캐시 깊이는 슬롯 개수가 아니라 바이트 예산입니다: 작은 클래스는 최대 8,192개 블록, 큰 클래스는 그보다 적습니다(min(8192, 8 MB ÷ 클래스 크기), 최소 8). 모든 클래스는 약 0.96 MB 크기의 스레드별 평면 테이블 하나를 공유합니다.
- 슬랩은 1 KB 이하 클래스를 받칩니다(Linux와 macOS). 블록마다
malloc을 하는 대신, 할당한 스레드가 소유한 64 KB 슬랩에서 블록을 잘라 냅니다. 슬랩은 64 KB 경계에 정렬되어 있어 블록 주소를 마스킹하면 찾을 수 있습니다. 소유 스레드는 일반 목록에 해제하고, 다른 스레드의 해제는 슬랩의 락 없는 원격 스택에 쌓이며, 소유 스레드가 블록이 모자랄 때 그것을 한꺼번에 가져갑니다. 모든 블록이 다시 해제된 슬랩은 시스템에 돌려주되, 클래스마다 빈 슬랩을 최대 16개까지 남겨 둡니다. 위의 캐시가 슬랩 앞에 놓입니다. 모든 블록은 헤더를 유지하고, 해제된 블록은 재사용될 때까지 해제 표시가 남아 있으므로 이중 해제 검사는 그대로입니다. 해제 목록의 연결은 페이로드가 아니라 헤더에 있으므로, 댕글링 포인터를 통한 쓰기가 얼로케이터를 손상시킬 수 없습니다. 작은 블록 백만 개가 동시에 살아 있을 때alloc/dealloc은 이제malloc/free보다 빠릅니다(벤치마크 참고). 일부 블록이 살아 있는 채로 종료한 스레드의 슬랩은 다른 스레드가 그 블록들을 해제할 때까지 할당된 채 남습니다. - 캐시에 들어가지 못하는 더 큰 블록은 위에서 설명한 격리(quarantine)를 거쳐
malloc으로 돌아갑니다. - 프리스탠딩 타깃에는 캐시가 없습니다(스레드 로컬 저장소와 테이블 메모리가 필요하기 때문). 모든 요청은 런타임의
malloc으로 갑니다.
각 블록에는 여전히 16바이트 헤더가 붙으며, 이는 아주 작은 객체에서 가장 큰 영향을 줍니다: 24바이트 노드는 40바이트 요청을 차지합니다. 그래서 노드 기반 컨테이너는 노드마다 할당하지 않습니다 — List와 BST는 컨테이너별 NodePool(프리 리스트를 갖춘 청크 단위 범프 할당)에서 노드를 가져오며, 이는 노드마다 C의 malloc/free를 쓰는 것보다 약 2배 빠릅니다(벤치마크 참고). 직접 만든 고정 크기 객체에는 std/alloc/slab.h의 SlabAllocator와 alloc_t, 그리고 아레나 리전(new<R> T)이 같은 효과를 줍니다.
메모리 연산
safe_memcpy
generic<T> unsigned long safe_memcpy([]T dst, []const T src);
src에서 dst로 n바이트를 복사합니다. 원본과 대상 영역은 겹치면
안 됩니다. 겹치는 영역에는 safe_memmove를 사용합니다.
safe_memmove
generic<T> unsigned long safe_memmove([]T dst, []const T src);
src에서 dst로 n바이트를 복사합니다. 겹치는 영역에도 안전합니다.
safe_memset
generic<T> void safe_memset([]T s, int val); // every byte of s's elements
generic<T> void safe_memset_obj(&T v, int val); // one object
ptr부터 n바이트를 값 val(unsigned char로 해석됨)로 설정합니다.
safe_memcmp
generic<T> int safe_memcmp([]const T a, []const T b);
a와 b의 n바이트 메모리를 비교합니다. 반환값:
a가b보다 작으면< 0- 같으면
0 a가b보다 크면> 0
캐시와 정렬 헬퍼
#define MEM_CACHE_LINE_SIZE 64 // bytes (typical x86 / ARM / RISC-V L1)
unsigned long mem_align_up(unsigned long addr, unsigned long align); // align is a power of two
unsigned long mem_align_down(unsigned long addr, unsigned long align);
int mem_is_aligned(unsigned long addr, unsigned long align);
generic<T> ?&T mem_align_ptr(&T ptr, unsigned long align); // null if it can't be aligned
int mem_fits_page(unsigned long addr, unsigned long size); // 1 if [addr, addr+size) stays in one 4 KiB page
// locality: 0 = no reuse, 1 = low, 2 = moderate, 3 = high. __builtin_prefetch; no-op elsewhere.
generic<T> void mem_prefetch(const &T addr, int write, int locality);
generic<T> void mem_clflush(const &T addr); // write back + invalidate addr's line (x86 CLFLUSH, AArch64 DC CIVAC; else no-op)
generic<T> void mem_zero_secure([]T s); // zero with volatile stores the optimizer can't remove
generic<T> void mem_zero_secure_obj(&T v); // the same for one object
주소 산술 헬퍼는 일반 unsigned long 주소를 받고, 나머지는 위의 safe_mem* 함수들처럼 영역의 첫 바이트에 대한 참조를 받습니다.
예제
#include <std/mem.h>
#include <std/collections/slice.h>
#include <std/io.h>
int main() {
// A buffer for 10 ints, zeroed
?&heap int maybe = std::alloc(10UL * sizeof(int));
match (maybe) {
case null: { return 1; } // out of memory
case some(buf): {
struct Slice<int> s = std::Slice::of(buf, 10UL); // bounds-checked view of the 10 ints
std::safe_memset(s.as_slice(), 0);
s.set(0UL, 42);
s.set(1UL, 99);
// Duplicate into a second buffer
?&heap int copy = std::alloc(10UL * sizeof(int));
match (copy) {
case some(c): {
std::safe_memcpy(std::Slice::of(c, 10UL).as_slice(), s.as_slice()); // 10 elements
std::println_int(*std::Slice::of(c, 10UL).at(0UL)); // 42
}
case null: { }
}
std::dealloc(copy);
}
}
std::dealloc(maybe);
return 0;
}
플레이그라운드에서 실행TIP
여기의 모든 함수는 참조를 받고 반환하므로 unsafe가 필요 없습니다. 참조에는
길이가 없으므로 인덱싱은 Slice를 거칩니다. unsafe가 무엇을 면제해 주고
무엇을 면제해 주지 않는지는 안전성 모델을 참고합니다.
이중 해제는 잡히지, 조용히 넘어가지 않습니다
#include <std/mem.h>
#include <std/io.h>
int main() {
?&heap unsigned char p = std::alloc(64UL);
std::dealloc(p);
std::println("first dealloc OK");
// The compiler rejects a second dealloc of p; unsafe opts out of that
// check, so the runtime one fires instead.
unsafe { std::dealloc(p); } // aborts: "dealloc() called twice on the same pointer"
println("never reached");
return 0;
}
플레이그라운드에서 실행