# 104 — 메모리가 어디로 가는가: 느린 수신자와 오래 붙든 스냅숏 2026-09-30. [외부 리뷰 004](../outside-feedback/feedback-004.md)의 둘째 권고에서 시작한다. 앱 전체의 메모리 예산을 정하기 전에, 느린 수신자와 오래 열린 화면 스냅숏이 있을 때 노드의 메모리가 어디로 가는지 잰다. 착수 기준: - **바꿀 결정.** 앱 전체 메모리 예산([013](../design/013-memory-budget.md))이 무엇을 세고, 넘칠 때 무엇을 할지. - **최소 확인.** 두 조건에서 상주 메모리가 무엇에 비례해 느는지 잰다. 한계가 없는 증가가 있으면, 예산보다 먼저 고친다. - **중단 기준.** 고친 것이 스냅숏의 읽기(연결 스레드, 표면, 체크포인트)를 하나라도 틀리게 하면 되돌린다. ## 쉬운 말로 - 알아낸 것: - **스냅숏 하나를 오래 붙들면 메모리가 끝없이 늘었다.** - 표면 하나가 항목 2만 개의 스냅숏을 붙든 채 글을 고치게 했다. 노드가 30초 만에 6.4 MiB에서 678 MiB가 됐다(고칠 때마다 3.5 KB). - 스냅숏들이 옛것에서 새것으로 사슬을 이루어, 하나를 붙들면 그 뒤의 모든 판을 붙들었기 때문이다. - 고쳤다. 이제 붙든 스냅숏은 그때의 상태 한 벌만 쥔다. 같은 경우가 6.5 MiB에서 10.4 MiB로 늘고 멈춘다. - **받는 쪽이 늦으면 보낸 것에 비례해 는다.** 이것은 한계가 아니라 설계다. 보낸 것은 전달될 때까지 앱의 상태이기 때문이다. - 보내지 못한 한 건마다 outbox에 221 B가 든다(100 B 글의 주문). - 링크(`pure://`)로 보내면 링크가 따로 사본을 들어 399 B, HTTP로 보내면 534 B다. - 그래서 예산은 "상태의 상한"과 "넘칠 때 거절"로 정해야 한다. 전송의 사본은 줄일 수 있는 상수배다([013](../design/013-memory-budget.md)). - **전송의 사본은 창만큼이다.** 013의 2단계 뒤, 링크와 HTTP 전달은 받는 쪽이 가진 것 너머로 4 MiB까지만 든다. 창이 찬 뒤로는 받는 쪽이 꺼져 있어도 보낸 한 건에 상주 약 220 B, outbox만큼만 는다. - **예산을 주면 멈춘다.** 013의 1단계(`--memory`) 뒤, 받는 쪽 없이 보내는 노드에 상태 4 MB를 주었다. 4초 남짓에 차고, 그 뒤로 보내는 요청은 503 `memory`를 받으며 상주 메모리가 9.6–9.8 MiB에 머문다. 예산이 없으면 30초 동안 45.9 MiB까지 는다. - **체크포인트는 쓰는 동안 고친 만큼 든다.** SD 카드에서 22.4 MB 목록의 이미지를 쓰는 8초쯤 동안 +3.0–4.4 MiB였다. 최고 상주는 상태 바이트의 약 2배다. - 아직 모르는 것: 몇 시간, 며칠 도는 노드에서 할당기가 쥐고 돌려주지 않는 메모리(조각남)가 어디까지 가는지는 재지 않았다. ## 방법 [`research/experiments/021-memory/run.py`](../../research/experiments/021-memory/run.py)가 경우마다 `pure-serve`를 띄운다. 클라이언트 넷이 연결을 유지한 채 쉬지 않고 보내고, 0.5초마다 노드의 상주 메모리(`/proc/PID/status`의 VmRSS)와 보낸 수를 적는다. 끝나면 저장소 크기와 노드의 종료 통계를 남긴다. | 경우 | 앱 | 조건 | |---|---|---| | outbox | 주문마다 출력으로 보내는 앱 | 출력에 연결이 없다. 보낸 것은 모두 outbox에 남는다 | | link-down | 같은 앱 | 출력을 아무도 듣지 않는 포트로 링크했다(`pure://`) | | wire-down | 같은 앱 | 출력을 아무도 듣지 않는 포트로 HTTP 전달한다 | | unheld | 항목 2만 개(각 100 B 글)를 두고 무작위 항목의 글을 고치는 앱 | 스냅숏을 붙드는 쪽이 없다 | | held | 같은 앱 | 표면 하나([hold-surface.c](../../research/experiments/021-memory/hold-surface.c))가 항목이 다 든 스냅숏을 끝까지 붙든다 | ## 결과 30초씩, 이 기기(Raspberry Pi 5)에서 잰 값이다(`research/experiments/021-memory/2026-09-30-memory-linux-aarch64.jsonl`, 고치기 전의 held는 `2026-09-30-held-before-linux-aarch64.jsonl`: `de2860a`의 pure-serve를 `PURE_SERVE`로 주었다). | 경우 | 보낸 수 | 상주 메모리 | 한 건마다 | 저장소 | |---|---:|---|---:|---:| | outbox | 202,763 | 3.0 → 45.7 MiB | 221 B | 34.7 MB | | link-down | 198,874 | 8.5 → 84.1 MiB | 399 B | 34.7 MB | | wire-down | 198,269 | 3.0 → 104.0 MiB | 534 B | 34.7 MB | | unheld | 206,179 | 6.5 → 6.8 MiB | 1.9 B | 19.3 MB | | held, 고치기 전(`de2860a`) | 203,791 | 6.4 → 677.8 MiB | 3,455 B | | | held | 205,354 | 6.5 → 10.4 MiB | 19.9 B | 19.3 MB | "한 건마다"는 늘어난 상주 메모리를 보낸 수로 나눈 것이다. ### 스냅숏의 사슬 - 앱의 스냅숏은 옛것이 다음 것의 참조를 쥐는 사슬이었다. 목록이 스냅숏을 뜬 뒤에 놓는 노드와 텍스트는, 그때 가장 새 스냅숏과 함께 풀렸다. 그래서 스냅숏이 풀리는 순서가 옛것부터로 정해졌다. - 그런데 스냅숏 하나를 오래 붙들면 그 뒤의 스냅숏이 모두 붙들렸고, 그동안 놓인 노드와 텍스트가 모두 남았다. 표면, 느린 읽기, 체크포인트가 스냅숏 하나를 오래 쥐면 노드의 메모리가 쓰기에 비례해 끝없이 늘었다. - 옛 스냅숏이 읽을 수 있는 것은 자기가 떠진 때 이미 있던 노드뿐이다. 그래서 이렇게 바꿨다(`eebdeea`). - 스냅숏에 번호(epoch)를 매긴다. 목록의 사본도 그 번호를 받고, 그 뒤에 만든 노드와 텍스트는 그 번호를 적는다. - 붙들린 스냅숏들은 함께 쓰는 등록부 아래 옛것부터 이어진다. - 스냅숏이 풀릴 때, 그것과 함께 가던 것 가운데 아직 붙들린 바로 앞 스냅숏이 읽을 수 있는 것(그 번호보다 먼저 만든 것)만 그 스냅숏에 넘기고, 나머지는 곧바로 풀어 준다. - 이음은 등록부의 스핀락 아래서 옮기고, 해제는 그 밖에서 한다. - 검사: test-module의 `snapshot_held`가 스냅숏 하나를 붙든 채 사건 4만 번과 스냅숏 4만 개를 지나간다. 뒤쪽 2만 번 동안 남은 할당은 0 B다. 옛 방식처럼 붙들게 하면 이 검사가 실패한다. 스냅숏 여덟 개를 무작위 순서로 풀며 내용을 대조하는 기존 검사와 두 스레드의 읽기 검사도 AddressSanitizer 아래에서 통과한다. ThreadSanitizer는 이 커널(가상 주소 47비트)에서 돌지 않는다. ### 느린 수신자 - 보낸 것은 받는 쪽이 저장했다고 할 때까지 앱의 상태다. 그래서 받는 쪽이 꺼져 있으면 outbox는 보낸 만큼 는다. 막을 수 없고, 막아서도 안 된다. 정할 것은 상한과 넘칠 때의 행동이다. - outbox 한 건은 221 B였다. 주문의 글 100 B, 출력 이름과 번호, 목록 노드와 할당의 머리다. - 전송은 outbox를 한 번 더 복사해 든다. 링크의 보내는 쪽은 자기 ring에(한 건에 178 B 더), HTTP 전달은 자기 큐에(313 B 더) 둔다. 둘 다 저장소에 이미 있는 것의 사본이다. ### 예산을 준 뒤 013의 1단계를 넣은 뒤(`883d64c`), 같은 앱을 같은 부하로 30초씩 다시 쟀다 (`research/experiments/021-memory/2026-09-30-budget-linux-aarch64.jsonl`). | 경우 | 받아들임 | 거절(503 `memory`) | 상주 메모리 | 상태 바이트 | |---|---:|---:|---|---:| | outbox, 예산 없음 | 203,928 | 0 | 3.0 → 45.9 MiB, 계속 는다 | 27,734,208 | | budget(`--memory 4000000`) | 29,411 | 177,258 | 3.0 → 9.6 MiB, 6초부터 9.6–9.8 MiB | 3,999,896 | - 예산은 4초 남짓에 찼다. 그 뒤로 보내는 요청은 곧바로 거절되어, 같은 시간에 받아들임과 거절을 합친 수가 예산 없는 경우와 비슷하다. - 늘어난 상주 메모리는 상태 바이트의 1.72배(예산), 1.62배(예산 없음)다. 보낸 것 한 건이 상태 136 B, 상주 221 B다. 013의 중단 기준(3배)보다 낮다. - 세는 비용: 가장 작은 사건(수 하나를 더함, 저장소 없음)이 상태 1개인 앱에서 125 → 136 ns(+8 %), 32개인 앱에서 321 → 349 ns(+9 %)다. 처음에는 사건마다 모든 상태를 두 번 걸어 125 → 146 ns(+17 %), 328 → 454 ns(+38 %)였다. 목록과 텍스트만 비트로 골라 걷게 했다(`883d64c`). 예산이 없으면 비용이 없다. HTTP로 받는 쓰기는 한 건에 수십 µs라 처리량에 보이지 않는다. ### 창을 둔 뒤 013의 2단계(보내는 곳마다 창)를 넣은 뒤, 받는 쪽이 꺼진 두 경우를 다시 쟀다 (`research/experiments/021-memory/2026-09-30-window-linux-aarch64.jsonl`, 창 4 MiB). | 경우 | 보낸 수 | 상주 메모리 | 한 건마다(30초 전체) | 창이 찬 뒤(8–30초) | |---|---:|---|---:|---:| | link-down, 창 전 | 198,874 | 8.5 → 84.1 MiB | 399 B | | | link-down | 202,933 | 8.5 → 57.6 MiB | 254 B | 219 B | | wire-down, 창 전 | 198,269 | 3.0 → 104.0 MiB | 534 B | | | wire-down | 202,831 | 3.0 → 54.5 MiB | 266 B | 218 B | - 창은 링크에서 6–8초 사이, HTTP에서 4–6초 사이에 찼다(보낸 수 4만–5만). 그 뒤로 는 것은 outbox 한 건(221 B, 위의 outbox 경우)과 같다. - 전송이 쥐는 것은 이제 보내는 곳마다 상수다. 예산과 함께면 노드의 상태 쪽 메모리는 예산의 약 1.7배와 창들의 합으로 묶인다. ### 체크포인트가 쓰는 동안 - 체크포인트는 목록 하나의 스냅숏을 쥐고 이미지를 쓴다(소유자 밖의 스레드). 그동안 앱이 고친 잎과 바꾼 텍스트는 스냅숏이 쥐고 있어 풀리지 않는다. 그래서 더 드는 것은 "쓰는 동안 고친 것"이고, 많아야 목록 한 벌이다. - unheld 경우(무작위 항목의 글을 고침)를 항목 수와 저장 장치를 바꿔 쟀다 (`research/experiments/021-memory/2026-09-30-checkpoint-linux-aarch64.jsonl`). 최고 상주는 0.5초마다 읽은 VmRSS와 끝의 VmHWM 가운데 큰 것이다(VmHWM은 42.5 MiB라 했지만 172초에 읽은 VmRSS가 42.6 MiB였다). | 저장소 | 항목 | 고친 수 | 체크포인트 | 상주(시작 → 끝) | 최고 | 상태 바이트 | |---|---:|---:|---:|---|---|---:| | tmpfs | 50,000 | 205,531(30초) | 4 | 11.0 → 11.5 MiB | 11.5 MiB | 5,600,000 | | SD 카드 | 50,000 | 36,522(30초) | 1 | 11.3 → 11.6 MiB | 11.6 MiB | 5,600,000 | | SD 카드 | 200,000 | 331,881(240초) | 2 | 35.1 → 42.5 MiB | 42.6 MiB | 22,400,000 | - tmpfs와 작은 목록(5.6 MB, 카드의 빠른 쓰기 구간 안)에서는 체크포인트가 보이지 않는다. 금방 끝나 고친 것이 적다. - SD 카드의 22.4 MB 목록에서는 두 번 모두 보인다. 6–10초에 +3.0 MiB, 164–172초에 +4.4 MiB다. 초당 1,400건을 고치는 동안 8초쯤 이어졌다. 고친 잎(짧은 수와 포인터)과 바뀐 글(각 100 B)이 쥐어졌고, 목록 한 벌(상주 약 37 MB)에는 한참 못 미친다. - 쓴 뒤에 그 메모리는 풀리지만 프로세스는 돌려주지 않고 다음에 쓴다(할당기가 쥔다). 그래서 상주가 새 높이에 머문다. - 최고 42.6 MiB는 상태 22.4 MB의 약 2배(1.99)다. 013의 상한(상태 한 벌과 스냅숏 한 벌)과 중단 기준(3배) 안이다.