free해도 돌아오지 않는다 —— Mere의 팽창과, macOS의 mmap, Linux의 임계값

내 언어 Mere로 쓴 Ruby 처리계를 50 safepoint마다 collection을 돌리는 시험에 걸자, 6 GB를 넘어도 멈추지 않았다. 원인은 셋이었다. 하나는 런타임의 이차 시간 순회. 또 하나는 macOS의 malloc으로, 지금의 macOS는 free한 영역을 RSS에도 footprint에도 남긴 채 요청해도 돌려주지 않는다. region의 블록 중 16 KB 이상을 mmap으로 받고 munmap으로 돌려주도록 했다. Linux의 glibc는 큰 것을 돌려주니 바꾸지 않아도 된다고 처음엔 판단했지만, 재 보니 Linux에서도 17초 만에 6 GB를 넘었다. glibc의 mmap 임계값은 한 번 free하면 올라간다. 시작할 때 임계값을 고정해 고쳤다. 해제 시점도, 평범한 프로그램의 속도도 그대로이고, 부풀던 프로그램은 RSS가 24~31% 줄었다. 도중에 두 번 잘못 판단했다.

merememorymacoslinuxmallocruntimeruby

Mere는 내가 나를 위해 만드는 작은 언어다. C를 내보내는 백엔드의 런타임은 메모리를 region(arena)으로 관리한다. region은 블록을 malloc으로 받아 이어 두고, region을 나갈 때 한꺼번에 free한다. GC는 없다. 메모리가 작고 해제 시점이 정해져 있다는 것이 이 언어의 장점 중 하나다.

그 위에 Mere로 쓴 Ruby 처리계 mere-ruby가 있다. 이쪽은 Ruby 값을 위한 자체 collector를 갖고 있고, Fiber는 Mere의 코루틴으로 만들었다.

이 글은 mere-ruby의 시험 하나가 6 GB를 넘어도 멈추지 않았던 이야기다. 원인은 런타임의 이차 시간 순회와 malloc이었다. malloc의 문제는 처음에 macOS만의 것이라고 생각했다. 나중에 Linux도 같다는 것을 알았고, OS마다 다른 방법으로 고쳤다. 수치는 Apple Silicon(RAM 32 GB)의 macOS 26.7.1과, Ubuntu 24.04(glibc 2.39, kernel 6.8, arm64) 컨테이너에서 측정했다.

표부터

고치기 전 고친 뒤
시험(멈춘 Fiber 수천 개, 50 safepoint마다 collection) 300초 안에 앞쪽 루프를 빠져나오지 못함. 이차 순회를 고친 뒤에도 6 GB 초과 181초에 끝까지, 최대 3.7 GB
같은 프로그램을 평범하게 돌렸을 때의 최대 RSS 1.08 GB 0.74 GB(−31%)
mere-ruby corpus 472개의 CPU 시간 75.3~77.8초 75.7~79.1초(오차 범위)
같은 472개의 최대 RSS 합계 79.7 GB 78.4 GB(−1.6%)
ruby의 test_kwarg_eval_memory_leak(증가 상한 1.2) 1.209로 실패 통과
400 MB region을 나온 직후의 RSS 841 MB 1 MB
Linux: 같은 시험 17초 만에 6 GB 초과 244초에 끝까지, 최대 3.6 GB
Linux: 평범하게 돌렸을 때의 최대 RSS 788 MB 598 MB(−24%)
compact한 map 8,000개의 region을 나온 직후의 RSS(macOS) 991 MB 42 MB

표의 “고친 뒤”에 있는 macOS의 시험 수치는 malloc 수정(v0.1.686)만 들어갔을 때의 것이다. 뒤의 map 수정(v0.1.688)까지 들어가면 최대는 2.5 GB로 내려간다.

끝나지 않는 시험

mere-ruby에는 collector의 구멍을 찾아내기 위한 모드가 있다. MERE_RUBY_GC_STRESS=50이면 50 safepoint마다 반드시 collection을 돌린다. corpus 472개 중 하나가 이 모드에서 끝나지 않았다. Enumerator를 만들어 한 걸음만 진행시키고 버리기를 수천 번 되풀이하는 프로그램이다. Enumerator는 하나씩 Fiber, 즉 멈춘 코루틴을 갖는다.

sample로 보니 시간의 95%가 런타임의 함수 하나에 있었다. region을 물릴 때, 멈춘 코루틴의 스택이 그 region을 가리키고 있지 않은지 확인하는 함수다. 가리키고 있으면 해제를 보류한다. 해제해 버리면 코루틴이 다시 돌 때 이미 해제된 영역을 읽게 된다.

여기에 이차 시간이 있었다. region 하나를 물릴 때마다 보류 중인 region을 전부 다시 확인했고, 하나마다 멈춘 코루틴 전부의 스택을 훑었다. 멈춘 Fiber가 수천 개면 매우 느리다.

멈춘 스택의 내용은 그 코루틴이 돌기 전까지 바뀌지 않는다. 그래서 보류 중인 region에 “마지막으로 확인한 시각”을 두고, pin하던 코루틴이 그 뒤로 하나도 돌지 않았다면 훑지 않고 보류한 채로 둔다. 답이 “아직 pin되어 있다”로 정해진 일만 생략하므로, region이 해제되는 시점은 바뀌지 않는다. OS와 관계없는 수정이고, v0.1.685가 되었다. 3000번의 루프는 300초 이상에서 46초가 되었다.

그래도 시험은 끝나지 않았다. 뒤쪽에서 6 GB를 넘었고, 감시자(RSS를 지켜보다 상한을 넘으면 죽이는 스크립트)가 죽였다.

짐작을 두 번 틀렸다

첫 짐작은 Fiber의 수였다. 멈춘 Fiber는 한 문장 안에서 1024개가 쌓이면 회수된다. GC_STRESS 아래에서 그것이 듣지 않아 두 배씩 쌓이는 게 아닐까. 출력을 넣어 재 보니 회수는 1024개에서 85개로 매번 듣고 있었다. 틀렸다.

다음 짐작은 보류 중인 region이었다. 멈춘 Fiber가 오래된 region을 계속 pin해서 쌓이는 게 아닐까. 물릴 때마다 보류의 수와 양을 내 보니 많을 때도 수십 MB였다. 이것도 틀렸다.

6 GB로 가는 도중의 메모리 내역을 vmmap으로 보았다.

MALLOC_SMALL           resident  446 MB
MALLOC_SMALL (empty)   resident  2.6 GB
MALLOC_LARGE           resident  650 MB
VM_ALLOCATE            resident  112 MB   ← Fiber의 스택

가장 큰 것은 MALLOC_SMALL (empty)였다. 이미 해제되었고, malloc이 끌어안고 있는 영역이다. 런타임은 region을 분명히 free하고 있었다. 돌아오지 않는 것은 그 너머였다.

free해도 macOS는 돌려주지 않는다

작은 C 프로그램으로 확인했다. 정해진 크기의 영역을 malloc으로 받아 전부 쓰고, 전부 free한 뒤, 그 직후의 양을 읽는다.

macOS 26.7.1(free 직후)

받아서 free 분류 RSS phys_footprint
64 KB × 1000 MALLOC_SMALL 64 MB 그대로 64 MB 그대로
1 MB × 200 MALLOC_SMALL 202 MB 그대로 202 MB 그대로
16 MB × 12 MALLOC_LARGE 193 MB 그대로 193 MB 그대로
64 KB × 1000을 mmap/munmap — 1 MB 1 MB

어떤 크기에서도 free만으로는 1바이트도 줄지 않았다. phys_footprint는 Activity Monitor의 메모리 열에 나오는 값이고, 메모리 부족 판정에 쓰이는 값이다. 그것도 줄지 않는다. malloc_zone_pressure_relief(NULL, 0), 즉 “메모리가 빠듯하니 돌려줄 수 있는 만큼 돌려 달라”는 요청에도 0바이트라고 답했다.

지금 macOS의 기본 할당기는 xzone malloc이고, 해제한 블록을 곁에 두었다가 다음 할당에 다시 쓰는 방침인 듯하다. 같은 현상을 다른 프로젝트도 macOS 26.3.1에서 보고했다(16 KB~4 MB 블록이 footprint에 남고, pressure relief는 0을 돌려준다).

Linux(glibc 2.39, free 직후)

받아서 free free 직후의 RSS malloc_trim(0) 뒤
64 KB × 1000 63 MB 그대로 1 MB
256 KB × 400 1 MB 1 MB
1 MB × 200 1 MB 1 MB
20 KB × 4000을 하나 걸러 free 77 MB 47 MB

glibc는 128 KiB 이상(그 뒤 동적으로 최대 32 MiB까지 올라간다)의 할당을 처음부터 mmap으로 받으므로, free하면 곧바로 돌아간다. 작은 것은 heap에 남지만, malloc_trim(0)을 부르면 heap 중간의 통째로 빈 페이지까지 돌려준다.

방침이 반대로 보였다. glibc는 큰 것을 곧바로 돌려주고, macOS는 돌려주지 않고 끌어안아 다시 쓴다. Mere의 region은 큰 단위로 할당과 해제를 되풀이한다. 끌어안긴 만큼이 그대로 부풀어 갔다 —— 고, 이때는 macOS만의 문제라고 생각했다.

고친 방법: 16 KB 이상은 직접 mmap한다

macOS에서만, region의 블록 중 16 KB 이상을 malloc이 아니라 mmap으로 받고, 해제할 때는 munmap으로 돌려주도록 했다. 16 KB 미만은 지금까지처럼 malloc이다.

  • 16 KB로 한 이유: Apple Silicon의 페이지 크기이기 때문이다. mmap의 최소 단위는 한 페이지이므로, 이보다 작은 블록을 하나씩 mmap하면 16 KB로 올림된다.
  • 이때 macOS만으로 한 이유는 둘이었다(첫 번째는 나중에 틀린 것으로 드러난다).
    • glibc는 원래 큰 것을 mmap으로 돌려주므로, Linux에서 바꿀 이유가 약하다.
    • Linux에는 프로세스당 매핑 수의 상한 vm.max_map_count가 있다. 커널의 기본값은 65,530이고, 블록마다 mmap하면 닿을 수 있다(Ubuntu 24.04나 Fedora 39는 1,048,576으로 올렸지만, 예전 기본값인 기계가 아직 많다).
  • 블록을 늘릴 때: macOS에는 mremap이 없으므로, 블록을 늘리는 realloc은 새로 받아 복사하고 옛것을 돌려준다.

곁에 두고 다시 쓰는 cache도 시험했다. 돌려준 매핑을 32 MB까지 남겨, 같은 크기의 다음 할당에 넘긴다. 오히려 느려졌다. 크기가 딱 맞는 것밖에 쓸 수 없어, 찾는 수고만 늘었다. 넣지 않았다.

Mere의 장점이 깎이지 않는가

“이 변경이 Mere의 장점을 잃게 하지 않는가”라는 질문을 받고 측정했다.

  • 해제 시점은 바뀌지 않는다. region을 나올 때 블록을 돌려주는 것은 전과 같고, 돌려주는 곳이 malloc의 손에서 OS로 바뀌었을 뿐이다.
  • 속도는 거의 그대로다. corpus 472개의 CPU 시간은 오차의 폭(약 3%) 안이었다. 할당과 해제가 격렬한 한 개에서는 시스템 콜만큼 느려졌다(2.88 → 3.35초인 회차가 있었다. 다만 같은 바이너리도 2.81~3.26초의 폭이 있다).
  • 메모리는 작아진다. 평범한 프로그램에서는 조금, 부풀던 프로그램에서는 크게.

변경을 확인하는 게이트는, 400 MB까지 키운 region을 나온 직후에 밖에서 ps로 RSS를 읽고, 64 MiB 미만을 요구한다. 고친 런타임에서는 1 MB이고, malloc으로 되돌리는 독(poison)을 넣으면 841 MB가 되어 빨개진다.

재는 기준을 잘못 골랐다

한 번 틀린 결론을 냈다.

malloc의 동작은 환경 변수로도 바꿀 수 있다. MallocSpaceEfficient=1 등을 붙여 시험을 돌리고, 최대 RSS가 바뀌지 않는 것을 보고 “어떤 설정으로도 돌아오지 않는다”고 결론 내렸다. Mere의 changelog에도 한때 그렇게 썼다.

나중에 이 글을 위해 다시 재 보니 달랐다.

free 직후 64 KB × 1000 1 MB × 200 16 MB × 12
기본 RSS 64 / footprint 64 MB 202 / 202 MB 194 / 193 MB
MallocSpaceEfficient=1 RSS 64 / footprint 1 MB 202 / 2 MB 1 / 1 MB
MallocLargeCache=0 RSS 64 / footprint 1 MB 202 / 2 MB 2 / 1 MB

이 설정들에서는 해제한 영역에 “재사용 가능” 표시가 붙는다. footprint에서는 곧바로 빠지고, RSS에는 커널이 실제로 회수할 때까지 남는다. 나는 RSS의, 그것도 최댓값만 보고 있었다. 최댓값은 “해제한 뒤에 돌아왔는가”에 대해 아무것도 말해 주지 않는다. 해제하기 전에 쌓인 양이 그대로 최댓값으로 남기 때문이다.

결론은 바뀌지 않았다. 기본 설정에서는 어느 기준으로도 돌아오지 않는다. 환경 변수는 프로세스를 시작할 때의 설정이어서, 런타임이 안쪽에서 확실하게 바꿀 수 있는 것이 아니다. 그래도 changelog의 문구는 내보내기 전에 고쳤다.

두 번째 착오: Linux도 부풀고 있었다

이 글의 첫 판은 과제에 “Linux에서 부푸는 시험은 재지 않았다”고 적었다. 다음 날 쟀다. 같은 시험을 Linux에서 돌리자 17초 만에 6 GB를 넘었다.

glibc의 mmap 임계값은 동적이다. 처음에는 128 KiB 이상을 mmap으로 받지만, mmap으로 받은 블록을 한 번 free하면 임계값을 그 크기까지 끌어올린다. 그 뒤로 그 크기까지의 할당은 heap에서 받고, free해도 heap에 남는다. 위의 작은 C 프로그램은 할당과 해제를 한 번밖에 하지 않았다. 본 것은 첫 번째의 동작뿐이었다. Mere의 region은 비슷한 크기의 할당과 해제를 몇 번이고 되풀이한다.

방법(Linux) 평범하게 돌렸을 때의 최대 RSS 시험
기본 788 MB 17초 만에 6 GB 초과
64 MB 해제할 때마다 malloc_trim(0) 608 MB 105초 만에 6 GB 초과
임계값을 64 KiB로 고정 574 MB 207초에 끝까지, 최대 3.6 GB
16 KB 이상을 직접 mmap 555~564 MB 258초에 끝까지, 최대 3.6 GB

가끔 malloc_trim을 부르는 것만으로는 따라가지 못했다. v0.1.688에서, glibc일 때는 프로그램이 시작할 때 mallopt(M_MMAP_THRESHOLD, 64 KiB)로 임계값을 고정하도록 했다. 사용자가 MALLOC_MMAP_THRESHOLD_를 설정해 두었다면 아무것도 하지 않는다. 라이브러리로 빌드했을 때도 하지 않는다. 라이브러리가 프로세스 전체의 설정을 멋대로 바꾸지 않기 위해서다. 직접 mmap하는 것보다 빠르고, mmap이 실패하거나 개수 상한에 닿으면 glibc가 알아서 heap으로 돌아가므로 매핑 수 상한의 걱정도 없다.

게이트도 Linux에서 답하도록 했다. region을 나온 직후의 RSS가, 고친 런타임에서는 5 MB, 임계값을 동적인 채로 두는 독에서는 405 MB가 된다.

같은 버전에서, 확인하는 도중에 나온 다른 누수도 고쳤다. map이나 Vec은 compact하면 전용 arena를 갖는다. 그 arena가, map을 만든 region을 나와도 해제되지 않았다. map의 구조체는 region과 함께 사라지는데, arena는 누구와도 함께 사라지지 않았던 것이다. arena를 구조체가 놓인 region에 묶어, 함께 물리도록 했다. map 8,000개를 만든 region을 나온 직후의 RSS는 macOS에서 991 MB에서 42 MB가 되었다.

과제

  • 16 KB 미만의 블록은 지금도 malloc이 끌어안는다. 작은 region이 많은 프로그램에서는 아직 부풀 수 있다.
  • 블록마다 mmap하는 데는 시스템 콜 비용이 든다. 평범한 프로그램에서는 보이지 않지만, 할당과 해제가 격렬하면 드러난다. 비용을 늘리지 않고 다시 쓰는 방법은 아직 찾지 못했다.
  • 시험은 끝나게 되었지만, 최대는 3.7 GB다. 멈춘 Fiber 수천 개와 50 safepoint마다의 collection이라는 조건은 원래 무겁다.
  • Linux에서는 프로세스 전체의 malloc 임계값을 바꾸고 있다. Mere 프로그램이 FFI로 쓰는 C 라이브러리의 64 KiB 이상 할당도 mmap이 된다. 라이브러리로 빌드했을 때는 바꾸지 않는다.

시작하기 전의 나에게 할 말

  • 런타임이 free하고 있다면, 다음은 malloc을 의심한다. free와 “OS에 돌아간다” 사이에는 할당기가 있고, OS마다 방침이 다르다.
  • 최대 RSS로는 “돌아왔는가”를 알 수 없다. 해제 직후의 양을 읽는다. macOS에서는 RSS와 footprint를 둘 다 읽는다.
  • 짐작은 재서 지운다. Fiber의 수도, 보류 중인 region도, 출력 한 줄로 틀렸음이 드러났다. vmmap의 한 줄이 답이었다.
  • macOS의 수치는 버전마다 바뀐다. 할당기의 속까지 통째로 바뀌었다. 손 닿는 곳에서 잰 다음에 정한다.
  • 한 번의 할당과 해제로 할당기의 방침을 판단하지 않는다. glibc는 첫 번째에는 돌려주었고, 두 번째부터는 돌려주지 않았다. 실제 프로그램의 모양으로, 되풀이해서 잰다.
  • 감시자를 붙인다. 이 조사 전날, 감시자 없는 시험이 43 GB까지 늘어났고, macOS는 압축과 swap으로 버티다가 watchdog kernel panic으로 멈췄다. Linux였다면 OOM killer가 그 프로세스 하나를 죽이고 끝났을 장면이다.

이차 순회의 수정은 Mere v0.1.685에, macOS의 mmap은 v0.1.686에, Linux의 임계값과 map의 arena는 v0.1.688에 들어 있다. 모두 확인하는 게이트와, 수정을 빼면 게이트를 빨갛게 만드는 독이 Mere의 저장소에 있다.

← Back to Notes