# 087 — 작은 읽기와 유휴 메모리: 기능이 쌓이며 늘어난 비용을 되돌리기 2026-09-27. 봉인한 전달([086](086-sealed-sends.md))이 요청 경로에 비용을 더하지 않는지 A/B로 재다가, 두 빌드의 절대값이 모두 기록(2026-09-26, `156e1ad`의 [011 결과](../../research/experiments/011-http-frameworks/2026-09-26-pi5-sd.jsonl))보다 낮은 것을 보고 추적한 것. 착수 기준: - 바꿀 결정: 앱 모델, 스냅숏 읽기, 전달이 붙는 동안 작은 GET이 느려지고 유휴 메모리가 는 것을 그 기능들의 값으로 받아들일지, 원인을 찾아 되돌릴지. - 최소 확인: 같은 기기에서 옛 빌드와 번갈아 재서 차이가 잡음 밖인지 본다. 원인은 프로파일로 가린다. 고친 것마다 따로 잰다. - 중단 기준: 고친 것이 다른 작업(1 MiB 읽기, 내구 쓰기, 앱 읽기)을 잡음 밖으로 나쁘게 하면 되돌린다. ## 차이 64 B 문서 GET, 64연결 keep-alive, 한 스레드(`benchmarks/http/run.py`, 서버 CPU 0–2, 부하 CPU 3): | | GET/s | 유휴 RSS | 최대 RSS | |---|---:|---:|---:| | `156e1ad` (09-26) | 126.7k | 2,040 KiB | 2,560 KiB | | `04a8510` (086) | 116.1k | 4,488 KiB | 5,136 KiB | 한 커밋의 일이 아니었다. 중간 빌드들은 126.7k → 122.4k(`647d4b7`) → 120.2k(`8026654`) → 118.4k(`8398a1e`) → 116.5k로, 기능이 붙을 때마다 조금씩 줄었다. 이런 것은 이분 탐색보다 지금 경로를 프로파일해서 줄이는 편이 맞다. ## 도구 이 기기에는 perf가 없어서 [sample.c](../../benchmarks/sample.c)를 만들었다. 명령을 실행해 모든 스레드의 사용자 시간을 50 µs마다 표본으로 뜬다(`perf_event_open`의 task clock). 상속되는 스레드별 이벤트에는 커널이 링을 매핑해 주지 않아서 CPU마다 이벤트를 하나씩 연다. 표본은 매핑된 파일의 ELF 심볼 표로 이름을 붙인다. 심볼을 뺀 libc는 같은 빌드의 디버그 파일을 build id로 찾는다 (Raspberry Pi 저장소의 `libc6-dbg`를 설치하지 않고 풀었다). arm64에서는 링크 레지스터도 받는다. 그래서 공유 라이브러리의 말단 함수(memcpy, strcmp)에 쓴 시간을 부른 함수별로 다시 볼 수 있다. 같은 부하 5초 동안 전은 사용자 1.34 s·시스템 3.65 s, 뒤는 1.77 s·3.22 s였다. 요청당 사용자 시간이 2.2 µs에서 3.2 µs로 늘었고, 그 1 µs가 차이였다. 나눠 보면 이렇다. - **한 버스트에 같은 키가 두 번 들지 않게 하는 검사**가 두 층에 있었고 둘 다 선형이었다. 앱은 대기 중인 키를 strcmp로 훑었고, 호스트는 요청마다 앞선 요청들을 같은 방식으로 훑었다. 읽기도 이 검사를 거친다. 64연결이면 요청마다 약 31번, 두 번씩이다. `execute`와 `message`, 그 안의 strcmp를 합쳐 사용자 시간의 약 10%였다. - **시계**: 연결의 기한을 읽기와 쓰기마다 `clock_gettime`으로 새로 셌고, 이벤트마다 벽시계를 다시 읽었다. 요청마다 4–5번, 5.7%였다. - **응답 머리의 printf**: `snprintf` 세 번의 형식 해석이 사용자 시간의 약 16%였다(`__printf_buffer`, `strchrnul`, `_itoa_word`). 이것은 libc에 이름이 붙기 전에는 이름 없는 libc 시간 28%에 묻혀 있었다. - **답한 뒤의 recv**: 연결은 답한 뒤 다음 요청을 읽으러 recv를 불렀고 거의 매번 EAGAIN을 받았다. 요청마다 실패하는 시스템 호출이 하나였다. strace로는 보이지 않는다. strace가 서버를 느리게 해서 다음 요청이 이미 와 있기 때문이다. ## 고친 것 | | 64 B GET/s | p50 | p99 | |---|---:|---:|---:| | `156e1ad` (09-26) | 127.0k | 480 µs | 576 µs | | 전 (`04a8510`) | 116.1k | 512 µs | 672 µs | | + 버스트의 키를 한 번에 찾기 (`93faf3a`) | 121.8k | 496 µs | 640 µs | | + 시계를 한 라운드에 한 번 (`fbbf6f6`) | 124.1k | 496 µs | 640 µs | | + printf 없는 응답 머리 (`8f82c27`) | 131.9k | 464 µs | 576 µs | | + 짧게 읽으면 멈추기 (`242343e`) | **139.9k** | **448 µs** | **512 µs** | 여섯 빌드를 라운드마다 순서를 돌려 다섯 라운드씩 잰 중앙값이다. - **키 색인.** 앱이 대기 중인 문서를 키로 색인한다(FNV-1a 해시의 열린 주소법, 1 KiB). `pure_app_pending`이 한 번에 답하고 호스트는 그것을 묻는다. 현재 이벤트의 문서가 대기 중인지는 포인터로 훑는 대신 플래그로 안다. 버스트는 전과 같은 곳에서 나뉜다. 다만 읽기를 먼저 적용하는 소유자에서 같은 라운드에 읽은 키를 고치면, 앱이 혼자 저장하던 곳에서 이제 호스트가 버스트를 나눈다. 단위 검사가 200개 키에서 색인을 확인한다. - **시계.** 연결 스레드는 `epoll_wait`에서 돌아올 때마다 단조 시계를 한 번 읽고, 기한은 그것에서 센다(기본 10초, 1초마다 점검한다). 한 라운드의 이벤트는 라운드가 시작한 시각을 함께 쓴다. - **응답 머리.** 부분마다 제자리에 쓴다. 바이트는 전과 같다. 버퍼 끝에서 잘리므로, `snprintf`가 돌려주던 "썼을 길이"로 버퍼 밖을 보낼 수 있던 틈도 없어졌다. - **짧은 읽기.** 요청한 것보다 적게 읽었으면 소켓이 빈 것이다. 엣지 트리거 epoll이 다음 바이트가 오면 알려 준다(epoll(7), nginx도 이렇게 한다). 그래서 EAGAIN을 받으러 가지 않는다. 상대가 제 쪽을 닫았으면 전처럼 끝까지 읽는다. **다른 작업.** 마지막 빌드와 전을 번갈아 세 라운드씩 쟀다. 앱의 합계 GET은 111.5k → 125.4k/s, 20행 페이지는 86.2k → 94.3k, 쓰기와 섞인 읽기는 109.1k → 121.7k다. 1 MiB 읽기는 같다. 단계마다 여섯 라운드씩 잰 중앙값이 2,668–2,716/s이고 라운드 범위가 ±15%다. 내구 쓰기도 같다. 점수 8연결은 3,974 → 4,035/s, 섞인 쓰기는 2,955 → 3,115/s다. 연결마다 닫는 GET도 23.2k → 23.5k로 같다. ## 유휴 메모리 유휴 RSS의 절반은 OpenSSL이었다. 아무 일도 하지 않는 프로그램에 `-lssl -lcrypto`를 링크만 해도 RSS가 0.98 → 3.28 MiB가 되고, 그 프로세스만의 더티 페이지가 656 KiB 는다(라이브러리의 재배치한 데이터). 그런데 대부분의 서버는 https 수신자에 닿지도 않고 봉인하지도 않는다. OpenSSL을 쓰는 코드(TLS 클라이언트, 봉인)를 `pure-crypto.so`로 따로 빌드했다(`serve/crypto-openssl.c`, 함수 표 하나를 내보낸다). pure-serve는 https URL의 전달선을 열거나, 키를 읽거나, 봉인을 연결할 때 처음 실행 파일 옆의 그것을 연다 (`serve/crypto.c`). 없으면 시작할 때 알린다. OpenSSL이 필요 없는 것(키의 hex, 이 CPU가 봉인할 스위트)은 pure-serve에 남았다. | 유휴 pure-serve | RSS | PSS | 자기만의 더티 | |---|---:|---:|---:| | OpenSSL을 링크(전) | 4,496 kB | 1,639 kB | 1,296 kB | | OpenSSL을 정적으로 링크 | 3,840 kB | 2,292 kB | 2,272 kB | | 쓸 때만 매핑(지금) | **2,320 kB** | **881 kB** | **864 kB** | 정적 링크는 RSS만 조금 줄인다. 자기만의 메모리는 오히려 두 배가 된다. OpenSSL의 표를 모두 재배치하기 때문이다(바이너리는 7.9 MB). 봉인이나 TLS를 쓰는 서버는 전처럼 OpenSSL을 매핑한다. ## axum·drogon 옆에서 같은 기기에서 `a8d6b1b`를 axum 0.8.9, drogon 1.9.0과 다시 쟀다(세 라운드, 서버 순서를 돌린다; [읽기](../../research/experiments/011-http-frameworks/2026-09-27-pi5-sd-reads.jsonl), [앱](../../research/experiments/011-http-frameworks/2026-09-27-pi5-sd-app.jsonl)). pure-serve는 한 스레드, `-2`·`-3`은 연결 스레드 수다. axum과 drogon은 코어 셋을 모두 쓴다. | | pure-serve | pure-serve-2/3 | axum | drogon | |---|---:|---:|---:|---:| | 64 B GET, keep-alive (/s, CPU 1초당) | 139.3k, 139.7k | 150.5k, 99.2k | 127.0k, 62.6k | 146.7k, 62.6k | | 64 B GET, 연결마다 닫기 | 23.2k | 23.3k | 22.2k | 21.2k | | 1 MiB GET | **2,362** | 1,851 | 1,304 | 754 | | 앱 합계 GET | 125.7k | **153.2k** | 136.0k | 150.5k | | 앱 20행 페이지 | 93.5k | **147.6k** | 92.9k | 138.4k | | 내구 쓰기 64연결 | **21.3k** | 21.9k | 203 | 199 | | 내구 쓰기와 섞인 읽기 | 119.3k | 144.6k | 1,363 | 1,125 | | 유휴 RSS | **2,320 KiB** | 2,752–3,152 KiB | 2,768 KiB | 10,912 KiB | | 최대 RSS(문서, 앱) | 5,760, 3,152 KiB | 7,440, 3,824 KiB | 9,696, 6,608 KiB | 78,816, 11,920 KiB | axum과 drogon의 앱 읽기는 메모리 판(내구 쓰기 없음)의 값이다. 한 스레드의 pure-serve가 CPU 1초당 axum과 drogon의 2.2배를 답하고, 유휴 메모리는 셋 중 가장 작다. ## 다음 - 이제 CPU의 72%가 커널이다(루프백 TCP, 요청마다 recv와 writev). 연결마다 요청이 하나씩이라 응답을 모아 쓸 여지가 작다. - 사용자 시간의 나머지: 저장소 조회(카탈로그와 문서 트리) 약 23%, 요청 머리 해석 약 15%, 요청마다의 할당(드래프트, 익스텐트 배열) 약 8%.