84 할당자의 속 — 힙, 대안 할당자, 대안 표준 라이브러리
먼저 알아야 할 것
돌아보기
45장에서 malloc 한 번이 “창고 관리 사무소를 다녀오는 일”이라 했고, 83장에서는 그 창고가 주소 공간의 어느 자리에 있는지 보았다. 그러면 그 사무소는 안에서 정확히 무슨 장부를 쓰는가?
답. 두 가지를 적는다 — 어느 조각이 비었는가와 각 조각이 몇 바이트인가. 전자는 크기별 목록(빈)으로, 후자는 블록마다 붙는 머리말(헤더)로 관리하는 것이 고전적인 답이다. 이 두 장부를 어떻게 설계하느냐가 할당자(allocator)의 성능과 보안을 통째로 정한다. 이 장은 그 설계의 얼개다.
이 장의 필요성과 맥락
malloc 의 속을 이제 연다. 서른여덟 장을 미룬 것은 할당자의 내부가 83장의 기억 배치 위에서만 설명되기 때문이다. 그리고 이 장이 11부의 끝인 것도 맞다 — 표준 라이브러리를 다 읽고 나서야 「그것을 갈아 끼운다」는 이야기가 뜻을 갖는다.이 장이 끝나면
malloc의 속을 연다. 할당자가 빈 조각을 어떻게 관리하는지(빈·경계 태그·병합), 왜 갈래마다 캐시를 두는지, 단편화가 무엇이며 왜 회복되지 않는지. 이어서 표준 malloc을 갈아 끼우는 대안들(jemalloc·tcmalloc·mimalloc·snmalloc)과 대안 표준 라이브러리들(musl·picolibc 등), 그리고 할당을 아예 다른 모양으로 바꾸는 아레나(arena)·풀을 본다. 이 장의 끝이 제12부의 문이다.이 장에서 답할 질문
- 그러면
malloc은 이제 쓰지 말아야 하는가?
84.1 장부 ① — 블록마다 붙는 머리말
free(p)는 크기를 받지 않는다. 그런데도 몇 바이트를 반납하는지 알아야 한다. 답은 단순하다 — 돌려준 주소 바로 앞에 크기가 적혀 있다.
┌────────────┬──────────────────────────┐
│ 헤더 │ 사용자에게 준 자리 │
│ 크기·플래그 │ ← malloc 이 돌려준 주소 │
└────────────┴──────────────────────────┘이 머리말이 45장에서 본 사실 두 가지를 설명한다. 1바이트를 요청해도 실제 소비는 훨씬 크다(헤더 + 정렬 여백), 그리고 작은 조각을 수없이 만들면 관리 정보가 데이터만큼 커진다.
고전적인 설계(dlmalloc 계열)는 여기에 한 겹을 더 얹는다. 블록의 끝에도 크기를 적어 두는 것이다 — 경계 태그(boundary tag). 그러면 어떤 블록에서 “바로 앞 블록”의 크기를 즉시 알 수 있어, 해제할 때 이웃한 빈 조각과 합치는(coalescing) 일이 상수 시간에 끝난다. 조각이 잘게 흩어지는 것을 막는 가장 기본적인 장치다.
흔한 오해. “힙 블록의 헤더는 내부 사정이니 신경 쓸 필요 없다”
두 가지 이유로 신경 쓸 값이 있다.
첫째, 보안이다. 헤더가 사용자 데이터 바로 옆에 있으므로, 버퍼 넘침 하나로 이웃 블록의 헤더를 덮어쓸 수 있다. 그러면 할당자의 장부가 거짓말을 하게 되고, 공격자는 “다음 malloc이 내가 원하는 주소를 돌려주게” 만들 수 있다. 힙 익스플로잇이라 불리는 기법군 전체가 이 자리에서 자란다. 그래서 현대 할당자들은 방어를 넣는다 — 빈 목록의 포인터를 주소로 뒤섞어 두거나 (safe-linking), 이중 해제를 알아채는 표식을 넣거나, 헤더를 사용자 데이터와 아예 떨어뜨려 둔다(뒤의 mimalloc·snmalloc이 그 방향이다).
둘째, 메모리 회계다. “왜 100바이트짜리 백만 개가 100 MB가 아니라 160 MB를 먹는가”라는 질문의 답이 이 헤더와 정렬 여백이다.
84.2 장부 ② — 크기별 목록과 갈래별 캐시
빈 조각을 하나의 긴 목록으로 두면 요청마다 목록 전체를 훑어야 한다. 그래서 크기별로 목록을 나눈다 — 이것이 빈(bin)이다. glibc의 할당자를 예로 들면 대략 이런 층이 있다.
| 층 | 무엇 | 왜 |
|---|---|---|
| tcache | 갈래마다 따로 두는 작은 캐시 | 잠금 없이 즉시 꺼내 쓰려고 |
| fastbin | 작은 크기의 단일 연결 목록 | 합치지 않고 빨리 재사용 |
| smallbin | 정확한 크기별 목록 | 찾기가 상수 시간 |
| largebin | 범위별 정렬된 목록 | 알맞은 크기를 고르려고 |
| unsorted bin | 갓 해제된 것들의 임시 자리 | 다음 요청에 그대로 재사용 |
| top chunk | 힙의 맨 끝 남은 덩어리 | 모자라면 여기서 잘라 쓴다 |
표 84.1
핵심은 갈래별 캐시(tcache)다. 여러 갈래가 같은 장부를 만지면 잠금 때문에 느려지므로(80장), 갈래마다 작은 서랍을 따로 두고 거기서 먼저 꺼낸다. 서랍이 비면 그때만 공용 장부로 간다. 오늘날 빠른 할당자들의 공통 설계이고, 이름만 다를 뿐(tcache, thread cache, mimalloc의 로컬 힙) 착상은 같다.
여기에 83장에서 본 두 가지가 붙는다. 힙이 모자라면 brk로 늘리거나 (작은 요청), 큰 요청은 mmap으로 따로 받는다. 그리고 큰 블록을 해제하면 운영체제로 반납될 수 있지만, 작은 것들은 대개 장부에만 표시된다.
84.3 단편화 — 누수가 없는데도 메모리가 는다
단편화는 두 얼굴이다.
내부 단편화 — 요청보다 큰 조각을 주어서 생기는 낭비다. 24바이트를 요청했는데 32바이트 칸을 주면 8바이트가 죽는다. 크기 클래스를 쓰는 할당자의 숙명이고, 대신 속도를 얻는다.
외부 단편화 — 빈 공간의 총합은 넉넉한데 연속된 큰 덩어리가 없어 큰 요청이 실패하는 상태다. 작은 조각들이 큰 조각 사이사이에 박혀 있으면, 그 큰 조각들을 합칠 수 없다.
이것이 “누수가 없는데 메모리가 계속 는다”는 현상의 정체다. 오래 도는 서버에서 특히 잘 나타나는데, 원인은 대개 수명이 다른 것들을 섞어 할당해서다 — 오래 사는 작은 객체 하나가 큰 빈 구역 한복판에 박혀 그 구역 전체를 묶어 버린다.
| 완화하는 법 | 설명 |
|---|---|
| 수명이 비슷한 것끼리 모은다 | 아레나가 정확히 이 일을 한다(아래) |
| 크기가 같은 것은 풀로 | 조각이 생기지 않는다 |
| 장기 객체를 미리 잡는다 | 시작할 때 잡아 두면 중간에 박히지 않는다 |
| 할당자를 바꾼다 | 퍼지·감축 정책이 다른 것들이 있다(아래) |
| 주기적 재시작 | 정직한 최후의 수단 — 실제로 쓰인다 |
표 84.2
실제 사례. RSS 는 왜 안 줄어드는가
운영 현장에서 반복되는 대화가 있다. “메모리 누수가 있는 것 같다 — 요청이 끝났는데 프로세스 메모리(RSS)가 안 줄어든다.” 그런데 도구로 검사하면 누수는 없다.
이유는 45장에서 본 대로 free가 운영체제 반납이 아니기 때문이고, 여기에 두 가지가 겹친다. 첫째, 반납하려 해도 힙의 끝쪽이 비어야 줄일 수 있다 (top chunk 아래에 살아 있는 블록이 하나라도 있으면 못 줄인다). 둘째, 할당자는 반납을 아끼는 편이 유리하다고 판단한다 — 곧 또 요청이 올 테니까.
그래서 실무의 관측 규칙이 나온다. 메모리 문제를 진단할 때는 RSS 하나만 보지 않는다 — 할당자의 통계(예: malloc_info), 살아 있는 할당의 수와 크기 분포를 함께 본다. 그리고 정말로 돌려주고 싶으면 명시적으로 요청하는 길이 있다(glibc의 malloc_trim, jemalloc·mimalloc의 퍼지 설정).
84.4 표준 malloc을 갈아 끼우기 — 대안 할당자들
프로그램을 고치지 않고 할당자만 바꿀 수 있다. malloc·free는 결국 이름이므로, 다른 구현을 링크하거나(정적) 실행할 때 끼워 넣으면 (리눅스의 LD_PRELOAD) 그쪽이 쓰인다.
| 할당자 | 나온 곳 | 성격 |
|---|---|---|
| glibc malloc(ptmalloc) | GNU | 기본값. 균형형. tcache 로 개선됨 |
| jemalloc | FreeBSD → Meta | 크기 클래스와 아레나. 단편화 억제와 통계가 강점 |
| TCMalloc | 구글 | 갈래별 캐시 중심. 다중 갈래 처리량 |
| mimalloc | 마이크로소프트 | 비교적 새것(2019 ). 작고 빠르며 헤더 배치가 안전 지향 |
| snmalloc | MS 연구소 | 갈래 사이 해제를 메시지로 넘기는 설계 |
| scudo | LLVM | 보안 강화형. 안드로이드의 기본 |
표 84.3
바꾸면 얼마나 달라지는가 — 작업 부하에 따라 다르다가 정직한 답이다. 갈래가 많고 작은 할당이 폭주하는 서버에서는 수십 %가 바뀌기도 하고, 단일 갈래 계산 프로그램에서는 차이가 거의 없다. 그래서 규칙은 하나다: 재고 바꾼다. 그리고 바꾸기 전에 45장의 첫 번째 처방 — 뜨거운 루프에서 아예 할당하지 않기 — 을 먼저 시도하는 편이 대개 이득이 크다.
플랫폼 노트. 갈아 끼우는 실제 방법
- 실행할 때 —
LD_PRELOAD=/usr/lib/libjemalloc.so ./프로그램(리눅스). 코드도 빌드도 건드리지 않는다. - 링크할 때 —
-ljemalloc처럼 이어 붙이면 그쪽malloc이 쓰인다. - 윈도우 — 위 방식이 통하지 않는다. CRT 의 힙을 쓰거나, 할당자를 라이브러리로 링크하고 자기 코드에서 그 API 를 직접 부르는 편이 흔하다.
- 어디서든 통하는 길 — 애초에 코드가 할당자를 인자로 받게 짜는 것이다. 이 장의 마지막 절과 제12부가 그 방식이다.
84.5 대안 표준 라이브러리
할당자보다 한 겹 더 크게 갈아 끼우는 선택지도 있다 — 표준 라이브러리 구현 자체다. 61장에서 “표준은 목록이고 구현은 여럿”이라 한 것의 실물이다.
| 구현 | 쓰이는 자리 | 성격 |
|---|---|---|
| glibc | 주류 리눅스 배포판 | 기능이 가장 많다. 크고 확장이 풍부 |
| musl | 컨테이너·정적 링크·Alpine | 작고 단순. 정적 링크에 유리 |
| uClibc-ng | 작은 임베디드 리눅스 | 기능을 골라 줄인다 |
| picolibc | 베어메탈·RTOS | newlib 과 avr-libc 에서 갈라져 나온 소형 |
| newlib(-nano) | 임베디드 툴체인 기본 | arm-none-eabi-gcc 에 딸려 온다 |
| Bionic | 안드로이드 | 보안 지향(scudo 할당자) |
| UCRT | 윈도우 | MSVC·MinGW 가 쓰는 런타임 |
표 84.4
고르는 기준은 대개 셋이다 — 크기(임베디드·컨테이너), 정적 링크의 편의(musl 이 강점), 기능의 넓이(glibc 이 강점). 그리고 옮길 때 부딪히는 것은 대개 표준 C 가 아니라 확장이다: glibc 에만 있는 함수, 로케일 처리, 스레드·시그널의 미묘한 차이 같은 것들. 61장의 결론 — “표준에 있는 것만 쓰면 옮겨진다” — 이 여기서 값을 한다.
84.6 모양을 바꾸는 대안 — 아레나, 풀, 그리고 할당자를 인자로
지금까지는 같은 malloc을 더 잘 만드는 이야기였다. 다른 길이 있다. 할당의 모양 자체를 바꾸는 것이다.
아레나(리전, 범프 할당자). 큰 덩어리를 한 번 잡아 두고, 요청이 오면 포인터를 앞으로 밀기만 한다. 할당은 덧셈 한 번이라 거의 공짜이고, 해제는 개별로 하지 않는다 — 일이 끝나면 포인터를 처음으로 되돌려 통째로 버린다. 단편화가 없고, 시간이 일정하며, 해제를 잊을 수 없다. 대신 개별 해제를 포기한다.
이 방식은 “수명이 같은 것들”에 완벽하게 맞는다. 웹 서버의 요청 하나, 컴파일러의 함수 하나, 게임의 프레임 하나 — 시작과 끝이 분명한 작업 단위마다 아레나를 하나 두고 끝나면 리셋한다. 실제로 널리 쓰인다(아파치의 메모리 풀, PostgreSQL의 메모리 컨텍스트가 같은 착상이다).
풀(슬랩). 같은 크기의 조각만 다루는 할당자다. 미리 잘라 둔 칸들을 목록으로 관리하므로 할당·해제가 목록 조작 한 번이고, 내부 단편화도 없다. 노드가 수만 개인 자료구조나, 83장에서 본 임베디드의 고정 객체 관리에 맞는다.
할당자를 인자로 받기. 위 둘이 힘을 쓰려면 라이브러리가 “어디서 메모리를 얻을지”를 호출자에게 물어야 한다. 그래서 현대적인 C 라이브러리들은 할당 함수 묶음을 하나의 값으로 만들어 인자로 받는다. 59장에서 배운 함수 포인터의 표가 여기서 정확히 그 일을 한다.
실제 사례. proven의 메모리 모델 — 다음 부의 예고
이 책이 기대 온 라이브러리가 정확히 이 설계를 쓴다. proven 은 할당자를 하나의 값(proven_allocator_t)으로 다룬다 — 안에 alloc·realloc·free 함수 포인터와 문맥(ctx)이 들어 있는, 59장의 vtable 그대로다. 그리고 그 값을 만들어 주는 세 가지 공급자가 있다.
proven_heap_allocator()— 표준malloc을 그 인터페이스로 감싼 것.proven_arena_create(backing)— 이미 확보한 기억을 받아 범프 할당자로 만든다.proven_arena_reset으로 통째로 되돌리고,proven_arena_as_allocator로 같은 인터페이스에 끼운다.proven_pool_init(...)— 바탕 할당자 위에 같은 크기 조각의 풀을 얹는다.
이 구조가 주는 것이 셋이다. 첫째, 같은 자료구조 코드가 힙에서도, 아레나 에서도, 풀에서도 돈다 — 어디서 메모리를 얻는지는 인자가 정한다. 둘째, 83장에서 본 임베디드의 규범(동적 할당 금지)을 지킬 수 있다 — 정적 배열을 아레나의 바탕으로 주면 malloc이 한 번도 불리지 않는다. 셋째, 실패가 값으로 돌아온다(proven_result_mem_mut_t) — 널을 확인하는 대신 결과를 검사한다.
제12부에서 이 셋을 실제 코드로 만난다. 이 장에서 본 힙의 사정 — 비싸고, 단편화되고, 실패할 수 있고, 임베디드에서는 아예 금지되기도 한다는 것 — 이 그 설계의 이유 전부다.
문. 그러면 malloc은 이제 쓰지 말아야 하는가?
답. 아니다. malloc은 수명이 제각각인 것들을 다루는 범용 도구로서 여전히 정답이다. 요령은 도구를 목적에 맞추는 것이다.
- 수명이 작업 단위와 같다 → 아레나
- 크기가 모두 같다 → 풀
- 수명과 크기가 제각각이다 →
malloc(또는 그것을 감싼 할당자) - 실시간·안전 필수 → 정적 할당, 그리고 위 둘
그리고 어느 쪽을 쓰든 45장의 규율은 그대로다 — 소유를 정하고, 실패를 확인하고, 도구(ASan·Valgrind)로 검사한다.
복습 정리
| 기억할 것 | 요지 |
|---|---|
| 헤더 | 크기가 사용자 자리 바로 앞에 적힌다 — 넘침이 장부를 깬다 |
| 경계 태그 | 양쪽에 크기를 적어 두면 이웃과의 병합이 상수 시간 |
| 빈(bin) | 크기별 목록. 갈래별 캐시(tcache)가 잠금을 피한다 |
| 내부/외부 단편화 | 낭비 vs 연속된 큰 덩어리가 없어짐 |
| RSS | free 해도 안 줄 수 있다 — 누수와 구별할 것 |
| 대안 할당자 | jemalloc·TCMalloc·mimalloc·snmalloc·scudo. 재고 바꾼다 |
| 대안 libc | musl·picolibc·newlib·Bionic — 크기·정적링크·기능의 맞바꿈 |
| 아레나 | 밀기만 하고 통째로 버린다. 수명이 같은 것들에 |
| 풀 | 같은 크기만. 단편화 없음 |
| 할당자를 인자로 | 같은 코드가 힙·아레나·풀 어디서나 돈다(제12부) |
표 84.5
표준 라이브러리의 지형을 걸었고, 그 밑의 창고까지 열어 보았다. 다음 부는 이 모든 함정과 비용을 설계로 다루려는 시도다 — 이 책이 기대 온 라이브러리, proven이다.