C 동적 메모리 심화 — malloc 내부·정렬·단편화·커스텀 할당자·프로덕션 패턴
이 글의 핵심
malloc은 호출 한 줄로 끝나 보이지만 장시간 도는 서버에서는 단편화로 메모리 사용량이 계속 늘거나 스레드 경합으로 할당이 느려지는 문제가 생깁니다. arena와 tcache가 경합을 줄이는 방식, 캐시 라인과 false sharing의 관계, 아레나·리전 할당 같은 프로덕션 패턴과 검증 도구를 통해 언제 커스텀 할당자를 고려할지 판단할 수 있게 합니다.
C에서 malloc/free는 문법이 단순하지만, 그 아래에는 힙 자료구조, 정렬 제약, 단편화, 동시성 문제가 얽혀 있습니다. 이 글은 API 사용법보다 구현 관점의 직관, 정렬과 단편화를 다루는 방법, 커스텀 할당자를 설계할 때의 트레이드오프를 설명합니다. 특정 libc 버전의 소스 한 줄까지 맞추기보다는 디버깅과 튜닝, 설계 결정에 필요한 깊이를 목표로 합니다.
malloc/free 구현: dlmalloc 계열과 jemalloc
왜 “brk 올리기”로 끝나지 않는가
역사적으로 힙은 sbrk로 데이터 세그먼트 끝을 밀어 올리는 방식이었습니다. glibc도 메인 arena에서는 여전히 brk로 힙을 늘리지만, 큰 요청은 mmap으로 따로 받고, 추가 arena는 mmap으로 확보한 영역을 쪼개 씁니다. 메모리를 OS에 돌려주기 쉽고, 여러 스레드가 서로 다른 영역을 쓰게 할 수 있기 때문입니다. 결국 malloc은 OS에서 받은 가상 주소 덩어리를 청크 헤더(크기, 사용 여부, 이웃 병합 정보) 같은 메타데이터와 함께 관리하는 사용자 공간 할당자입니다.
dlmalloc(Doug Lea malloc) 계열과 glibc ptmalloc
리눅스 glibc의 malloc은 dlmalloc의 아이디어를 확장한 ptmalloc(ptmalloc2) 계열입니다. 청크, 빈, 병합이라는 큰 그림은 dlmalloc과 같고, 그 위에 멀티스레드용 arena 분리와 스레드별 캐시가 더해진 형태로 이해하면 됩니다. 세부 구현은 버전마다 다릅니다.
glibc의 두 가지 동작은 운영 중인 서버의 메모리 사용량을 이해하는 데 특히 중요합니다. 첫째, 스레드 경합을 줄이려고 arena를 여러 개 만드는데, 64비트에서 기본 상한이 코어 수의 8배라서 스레드가 많은 서버는 arena마다 남은 여유 공간 때문에 RSS가 예상보다 훨씬 커질 수 있습니다. MALLOC_ARENA_MAX=2 같은 환경 변수로 상한을 줄이면 메모리는 줄어들 수 있지만 경합이 늘 수 있으므로 측정하면서 조정합니다. 둘째, 기본적으로 128KB 이상 요청은 mmap으로 따로 받아 free 시 즉시 OS에 돌려주지만, 그보다 작은 블록은 힙에 남아 있다가 힙 꼭대기에 충분히 큰 빈 공간이 생겼을 때만 반환됩니다(이 기준값은 사용 패턴에 따라 동적으로 커지기도 합니다). 중간에 살아 있는 블록 하나가 그 위의 빈 공간 전체를 붙잡으므로, free를 충분히 했는데도 RSS가 줄지 않는 현상이 흔합니다. malloc_trim(0)을 호출하면 사용하지 않는 페이지를 OS에 반환하도록 요청할 수 있습니다. 참고로 glibc 2.26부터는 glibc 자체에도 스레드별 캐시(tcache)가 들어가 작은 할당의 fast path가 락 없이 동작합니다.
dlmalloc 스타일 힙의 핵심 자료구조
많은 범용 할당자가 공유하는 dlmalloc 계열의 구조는 대략 다음과 같습니다.
- 청크(chunk): 사용자에게 넘기는 영역 앞에 크기와 상태(사용 중/가용) 정보를 둡니다. 인접한 가용 청크는 병합(coalescing)해 큰 덩어리로 만듭니다. 이중 해제나 버퍼 오버플로로 이 헤더가 망가지면 힙 오염(heap corruption)으로 이어집니다.
- 빈(bin): 가용 청크를 크기별 리스트로 분류해 둡니다. 작은 청크는 정확한 크기 클래스로, 큰 청크는 크기 범위로 묶어
malloc시 적당한 블록을 빠르게 찾습니다. - 분할(splitting): 요청보다 큰 가용 블록을 찾으면 필요한 앞부분만 넘기고 나머지는 더 작은 가용 청크로 남깁니다.
이 구조는 평균적인 범용 워크로드에 강하지만, 크기가 들쭉날쭉한 할당과 해제가 섞이면 작은 구멍이 여기저기 남아 외부 단편화가 심해질 수 있습니다.
jemalloc: arena와 tcache
jemalloc(FreeBSD의 기본 할당자이며, Redis가 리눅스에서 기본으로 사용합니다)은 스레드 간 경합을 줄이려고 arena를 여러 개 두고 스레드를 라운드로빈으로 arena에 배정합니다. 또한 스레드별 캐시(tcache)로 작은 할당을 락 없이 처리합니다. 멀티스레드에서 작은 객체 할당이 많을 때 전역 락 병목이 줄어드는 것이 장점이고, 대신 arena와 캐시마다 여유 공간을 들고 있어 프로세스 메모리 사용량이 늘 수 있으므로 장기 실행 서버에서는 RSS 추이를 지켜보는 것이 좋습니다.
구현체별로 달라지는 것
같은 C 코드라도 glibc, musl, Windows UCRT, jemalloc, tcmalloc 중 무엇과 링크하느냐에 따라 단편화 양상, 최악 지연, 스레드 확장성이 달라집니다. 따라서 이론상 복잡도보다 실제 바이너리와 실제 부하에서 측정한 프로파일이 판단 기준이 되어야 합니다.
메모리 정렬(alignment) 요구사항
하드웨어와 ABI
CPU는 특정 타입의 로드를 자연 정렬(naturally aligned) 주소에서만 허용하거나, 비정렬 접근을 느린 경로로 처리하거나, 아키텍처에 따라 SIGBUS를 일으킵니다. C 표준 관점에서도 정렬 요구를 만족하지 않는 주소로 객체에 접근하는 것은 미정의 동작이므로, char[] 버퍼 중간 주소를 double *로 캐스팅해 읽는 식의 코드는 x86에서 우연히 동작하더라도 이식성이 없습니다.
C11 aligned_alloc, _Alignas, max_align_t
일반 malloc(size)이 보장하는 정렬은 기본 정렬, 즉 보통 _Alignof(max_align_t)(x86-64 glibc에서는 16바이트)입니다. 그보다 큰 정렬(AVX용 32바이트, 캐시 라인 64바이트, 페이지 4096바이트)이 필요하면 aligned_alloc이나 플랫폼별 API를 써야 합니다.
#include <stdlib.h>
#include <stdio.h>
#include <stdint.h>
int main(void) {
/* C11 원문은 size가 alignment의 배수일 것을 요구했고, C17(DR 460)에서 완화됨.
이식성을 위해 배수로 맞추고, alignment는 2의 거듭제곱으로 둔다. */
size_t align = 64;
size_t size = 4096;
void *p = aligned_alloc(align, size);
if (!p) {
return 1;
}
uintptr_t addr = (uintptr_t)p;
if (addr % align != 0) {
/* 정상적인 구현이라면 여기에 오지 않는다 */
free(p);
return 1;
}
free(p); /* aligned_alloc 결과는 일반 free로 해제한다 */
return 0;
}
위 예는 정렬된 블록을 받았는지 확인하는 패턴을 보여 줄 뿐이며, 실무에서는 SIMD 로드나 DMA처럼 정렬 요구가 있는 버퍼에 주로 씁니다. 실패 시 C 표준은 errno 설정을 요구하지 않지만, glibc 등 POSIX 구현은 보통 ENOMEM이나 EINVAL을 설정합니다.
이식성에서 가장 큰 함정은 Windows입니다. MSVC의 C 런타임은 aligned_alloc을 제공하지 않고 _aligned_malloc(size, align)을 쓰며, 이렇게 받은 포인터는 반드시 _aligned_free로 해제해야 합니다. 일반 free에 넘기면 힙 손상으로 크래시합니다. POSIX 환경에서 오래 쓰여 온 posix_memalign(&p, align, size)은 결과를 반환값(에러 코드)과 출력 인자로 나눠 주고, 정렬 값이 sizeof(void*)의 배수인 2의 거듭제곱이어야 한다는 조건이 있습니다. 여러 플랫폼을 지원하는 코드라면 이 차이를 한 곳의 래퍼 함수로 감싸 두는 편이 좋습니다.
캐시 라인과 false sharing
malloc은 캐시 라인 정렬을 보장하지 않습니다. 서로 다른 스레드가 같은 캐시 라인에 있는 서로 다른 변수를 갱신하면, 논리적으로는 공유하지 않는 데이터인데도 코어 사이에서 캐시 라인이 계속 무효화되는 false sharing이 일어나 성능이 크게 떨어집니다. 이때는 패딩을 넣거나 _Alignas(64)로 변수를 캐시 라인 경계에 떼어 놓습니다. 할당자 문제이면서 동시에 데이터 레이아웃 문제입니다.
메모리 단편화(fragmentation) 패턴
내부 단편화
할당자가 요청보다 큰 블록을 준 경우, 그 안의 남는 공간은 아무도 쓰지 못합니다. 크기 클래스 반올림, 청크 헤더, 정렬 패딩이 모두 여기에 해당합니다. 예를 들어 glibc에서 1바이트를 요청해도 최소 청크 크기(64비트에서 32바이트)가 소비되므로, 아주 작은 객체를 개별 할당으로 많이 만들면 이 손실이 누적됩니다.
외부 단편화
가용 메모리의 합은 충분한데 연속된 큰 덩어리가 없어 큰 요청을 기존 공간에서 채우지 못하는 현상입니다. 64비트 환경에서는 할당자가 OS에서 새 영역을 받아 오므로 실패보다는 RSS 증가로 나타나는 경우가 많습니다.
악화되는 전형적인 패턴은 다음과 같습니다. 무작위 크기를 계속 할당하면서 중간 블록만 해제하면 병합되지 못한 구멍이 남습니다. 수명이 긴 객체와 짧은 객체를 한 힙에 섞으면, 짧은 객체가 해제된 자리 사이사이에 긴 객체가 박혀 있어 병합과 반환이 어렵습니다. 반대로 나중에 할당한 것부터 해제하는 스택형 패턴은 범용 할당자에서도 단편화가 거의 생기지 않습니다.
완화 전략
- 고정 크기 풀: 특정
struct만 담는 풀을 따로 두면 모든 슬롯 크기가 같아 구멍이 생겨도 다음 할당이 그대로 재사용합니다. - 아레나: 수명이 같은 객체를 한 덩어리에서 잘라 쓰고, 개별
free없이 통째로 버립니다. - 압축(compaction): 객체를 옮겨 빈 공간을 모으는 방식인데, 포인터가 메모리 주소 그 자체인 C에서는 일반적으로 불가능합니다. 핸들이나 인덱스로 객체를 참조하도록 설계한 경우에만 가능합니다.
커스텀 할당자 설계
요구사항을 먼저 정한다
설계 전에 스레드 안전이 필요한지(전역 락, 스레드 로컬 풀), 최악 지연 한계가 있는지(실시간 시스템, 게임 프레임), 할당 실패 시 무엇을 할지(종료, 재시도, 대체 버퍼), 디버그 빌드에서 캐너리나 통계를 넣을지를 명시해 두면 구현 선택지가 크게 줄어듭니다.
범프(bump) 할당자
포인터를 한 방향으로만 밀어 할당하고, 다 쓰면 통째로 리셋하는 방식입니다. 개별 free는 없습니다. 파서, 컴파일러 IR, 프레임 단위 임시 데이터처럼 수명이 같은 객체를 한꺼번에 버리는 경우에 적합합니다.
#include <stddef.h>
#include <stdint.h>
typedef struct {
unsigned char *base;
size_t size;
size_t used;
} BumpArena;
void bump_init(BumpArena *a, void *backing, size_t nbytes) {
a->base = (unsigned char *)backing;
a->size = nbytes;
a->used = 0;
}
void *bump_alloc(BumpArena *a, size_t align, size_t size) {
uintptr_t p = (uintptr_t)(a->base + a->used);
uintptr_t aligned = (p + (align - 1)) & ~(uintptr_t)(align - 1);
size_t offset = (size_t)(aligned - (uintptr_t)a->base);
if (offset > a->size || size > a->size - offset) { /* 정렬 패딩만으로 넘치는 경우도 검사 */
return NULL;
}
void *ret = (void *)aligned;
a->used = offset + size;
return ret;
}
void bump_reset(BumpArena *a) {
a->used = 0;
}
align은 2의 거듭제곱이라고 가정했습니다. 실제 코드에서는 align 검증을 추가하고, 디버그 빌드에서 리셋 시 영역을 특정 패턴으로 채워 리셋 이후 남은 포인터 사용을 드러나게 하면 좋습니다.
고정 블록 풀
같은 크기의 슬롯을 미리 큰 배열로 잡아 자유 리스트로 연결합니다. malloc의 일반성은 없지만 단편화가 없고 할당 시간이 예측 가능합니다.
typedef union Slot { union Slot *next; unsigned char data[64]; } Slot; /* 블록 64바이트 */
typedef struct { Slot *free_list; } Pool;
void pool_init(Pool *p, Slot *slots, size_t n) {
p->free_list = NULL;
for (size_t i = 0; i < n; ++i) { /* 빈 슬롯을 연결 리스트로 엮음 */
slots[i].next = p->free_list;
p->free_list = &slots[i];
}
}
void *pool_alloc(Pool *p) {
Slot *s = p->free_list;
if (s) p->free_list = s->next; /* O(1), 탐색 없음 */
return s;
}
void pool_free(Pool *p, void *ptr) {
Slot *s = ptr;
s->next = p->free_list;
p->free_list = s;
}
빈 슬롯 자체의 메모리에 “다음 빈 슬롯” 포인터를 저장하므로 별도 메타데이터가 필요 없고, 할당과 해제가 모두 포인터 대입 두 번으로 끝납니다. 이 union의 정렬은 포인터 정렬만 보장하므로, long double이나 SIMD 타입을 담으려면 _Alignas로 정렬을 키워야 합니다.
이 단순함 때문에 실수를 잡아 주지도 않습니다. 같은 포인터를 연달아 두 번 pool_free하면 두 번째 호출에서 s->next가 자기 자신을 가리키게 되어, 이후 모든 pool_alloc이 같은 슬롯을 돌려주고 나머지 빈 슬롯은 리스트에서 사라집니다. 이 버그는 한참 뒤 서로 다른 객체가 같은 메모리를 덮어쓰는 증상으로 나타나 추적이 어렵습니다. ASan도 풀 내부의 해제는 알지 못하므로 use-after-free를 잡지 못합니다. 디버그 빌드에서는 해제된 슬롯을 특정 패턴(예: 0xDD)으로 채우고 이중 해제 검사용 플래그를 두거나, <sanitizer/asan_interface.h>의 ASAN_POISON_MEMORY_REGION 매크로로 해제된 슬롯을 독(poison) 처리해 두는 것이 좋습니다.
시그널 핸들러와 재진입
시그널 핸들러에서 malloc을 호출하는 것은 POSIX상 허용되지 않습니다. malloc과 free는 비동기 시그널 안전 함수 목록에 없으며, 메인 코드가 malloc 내부에서 arena 락을 잡은 순간 시그널이 도착해 핸들러가 다시 malloc을 부르면 같은 락을 기다리며 영원히 멈춥니다. printf도 내부에서 버퍼를 할당할 수 있어 같은 문제가 있습니다. 핸들러에서는 volatile sig_atomic_t 플래그를 세우거나 write로 파이프에 1바이트를 쓰는 정도만 하고, 실제 처리는 메인 루프에서 하는 것이 정석입니다. 커스텀 풀을 락 없이 스레드 로컬로 만들어도 같은 스레드 안에서의 재진입은 막아 주지 못하므로 시그널 문제의 해결책은 아닙니다.
프로덕션 C 메모리 패턴
아레나와 리전 할당
요청 처리, 프레임, 컴파일 패스 같은 단위로 메모리를 한꺼번에 버리는 모델은 해제 누락과 이중 해제를 구조적으로 줄입니다. 대신 아레나에서 받은 포인터를 아레나보다 오래 사는 곳에 저장하면 안 된다는 수명 계약을 지켜야 합니다.
캐시 친화적 배치
자주 함께 접근하는 필드는 같은 구조체나 인접 배열에 두고, 자주 쓰는 필드를 구조체 앞쪽에 모읍니다. 구조체 배열(AoS)과 배열 구조체(SoA) 중 무엇이 나은지는 한 번에 한 객체의 여러 필드를 읽는지, 여러 객체의 한 필드를 훑는지에 따라 결정됩니다.
관측과 검증 도구
AddressSanitizer(ASan)는 힙 버퍼 오버플로, use-after-free, 이중 해제 등을 런타임에 잡아 주며, 배포 바이너리와 별도로 빌드해 테스트에서 돌리는 것이 일반적입니다. glibc의 malloc_stats()나 jemalloc의 통계 API로 arena별 사용량을 볼 수 있고, heaptrack이나 perf 같은 프로파일러로 어떤 호출 경로에서 할당이 많이 나오는지 확인할 수 있습니다.
할당자를 바꾸거나 직접 만들기 전에, 버퍼 재사용이나 불필요한 문자열 복사 제거로 할당 횟수 자체를 줄이는 쪽이 더 효과적인 경우가 많습니다.
라이브러리와 런타임 선택
서버 바이너리에서 전역 malloc을 jemalloc이나 tcmalloc으로 교체해 이득을 보는 경우가 있지만, 처리량만이 아니라 RSS와 p99 지연을 함께 측정해야 합니다. 메모리 제한이 빡빡한 컨테이너 환경에서는 캐시를 많이 들고 있는 할당자가 오히려 역효과를 낼 수 있습니다.