# 115 — 달리는 호스트의 앱을 더하고, 멈추고, 바꾸기 2026-10-04. [017](../design/017-apps-on-one-host.md)의 3단계를 잰다. 기준은 구현하기 전에 017에 적어 커밋했다(`e4c7f7e`). - 바꿀 결정: 달리는 호스트의 앱을 SIGHUP과 구성 파일로 더하고, 멈추고, 바꿀지. 채우지 못하면 앱을 바꿀 때 호스트를 다시 띄운다. - 통과 조건(RAM의 Pi 5): - 바꾸는 앱이 답하지 못하는 시간이 호스트를 다시 띄울 때보다 짧다(항목 1,000개와 100만 개). - 한 앱을 거듭 바꾸는 동안 다른 앱(한 연결)의 p99가 바꾸지 않을 때의 1.5배를 넘지 않는다. - 앱 하나의 호스트는 그대로다(TPC-C와 은행 질의의 중앙값이 3% 넘게 느려지지 않고, 명령 수가 1% 넘게 늘지 않는다). - 바꿀 때마다 남는 메모리를 밝힌다. ## 쉬운 말로 - **통과했다.** 호스트가 SIGHUP에 `--apps` 파일을 다시 읽고, 줄이 없어진 앱은 멈추고, 새 줄의 앱은 시작하고, 바뀐 줄의 앱은 그 자리에서 바꾼다. 항목 100만 개의 앱이 모듈을 바꾸는 동안 답하지 못한 시간은 1.1 ms다(호스트를 다시 띄우면 150 ms). 그동안 다른 앱의 p99는 그대로다(0.97배). 앱 하나의 호스트도 그대로다. - **처음 구현은 큰 앱에서 기준을 넘지 못했다.** 앱을 멈추고 저장소에서 다시 여는 것으로 바꿨다. 100만 항목의 앱은 150.6 ms를 답하지 못했다. 호스트를 다시 띄우는 것(149.7 ms)과 같았다. 시간을 나눠 보니 거의 전부(142 ms)가 저장소를 다시 읽는 데(`pure_app_start`) 들었다. 호스트를 다시 띄울 때도 같은 일을 하니 짧아질 수 없었다. - **고친 것은 다시 읽지 않는 것이다.** 저장소가 그대로인 앱은 받은 일을 다 적용하고 저장한 뒤, 메모리의 상태를 그대로 두고 모듈만 바꾼다(코어의 `pure_app_replace`: 상태와 outbox의 보냄을 이름으로 옮긴다). 출구만 새 모듈로 다시 만든다. 그러자 크기와 상관없이 1 ms 남짓이 됐다. 기록의 모양이 바뀌는 모듈(필드가 하나 늘어난 기록)은 목록을 새 모양으로 다시 만들고 이미지를 다시 써야 해서 172 ms가 들었다. 호스트를 다시 띄우면 다시 읽기에 그 일까지 더해 332 ms다. - **모듈을 바꿀 때마다 19 KB가 남는다.** 바뀐 모듈을 놓지 않는다. 그 모듈로 해석된 요청과 스냅숏이 아직 그것을 가리킬 수 있기 때문이다. 예산이나 배선만 바뀌면 남는 것이 없다. ## 결과 원시 결과와 명령은 [030-app-lifecycle](../../research/experiments/030-app-lifecycle/)에 있다(`runs.sh`의 단계 `build`, `window`, `isolation`, `pairs`, `counts`, `memory`; `window.py`, `isolation.py`, `memory.py`, `pairs.py`; 앱 `items.aup`과 그 다음 판 `items-next.aup`(보기 하나 더), `items-wide.aup`(기록에 필드 하나 더)). **답하지 못하는 시간**(`window.py`). 호스트(`--apps`, 저장소는 RAM, 코어 0–2)에 앱 하나가 항목을 든다(사건 하나가 목록을 두 배로 늘려 1,024개나 1,048,576개). 클라이언트(코어 3)가 한 연결로 쓰기를 쉬지 않고 보내고, 답을 받은 두 쓰기 사이의 가장 긴 틈을 잰다. 바꾸기는 줄이 다른 모듈 파일을 가리키게 하고 SIGHUP을 보내는 것이다(두 모듈을 번갈아). 다시 띄우기는 같은 변경을 전처럼 하는 것이다(줄을 바꾸고 SIGTERM, 다시 시작). 1,024개는 바꾸기 20번과 다시 띄우기 10번, 1,048,576개는 10번과 5번의 중앙값이다. | 앱, 바뀌는 것 | 멈추고 다시 열기(`12878cf`) | 그 자리에서(`7daf406`) | 호스트를 다시 띄우기 | |---|---:|---:|---:| | 1,024개, 보기 하나 더 | 1.52 ms | **1.06 ms** | 2.42 ms | | 1,024개, 기록에 필드 하나 더 | 1.58 ms | **1.31 ms** | 2.51–2.64 ms | | 1,048,576개, 보기 하나 더 | 150.42 ms | **1.12 ms** | 149.63–149.94 ms | | 1,048,576개, 기록에 필드 하나 더 | 337.33 ms | **171.54 ms** | 329.85–332.42 ms | 다시 띄우기의 범위는 두 빌드에서 잰 값이다. 처음 잰 것(`f5e9db5`)은 다시 띄울 때 모듈을 바꾸지 않았다(1,024개 1.59 대 3.05 ms, 100만 개 150.64 대 149.73 ms). 비교가 같은 변경이 되도록 고쳐 다시 쟀다. 멈추고 다시 열 때 100만 항목의 시간을 나누면, 멈추기 7–8 ms(상태를 놓기), 시작 142 ms(저장소를 다시 읽기)다. 호스트를 다시 띄울 때의 시작은 170 ms였다. 그 자리에서 바꾸면 이 둘이 없다. 기록의 모양이 바뀌면 목록 100만 항목을 새 모양으로 다시 만들고 이미지를 쓰는 데 약 170 ms가 든다. 다시 띄울 때도 같은 일을 다시 읽기에 더해서 한다. **다른 앱의 꼬리**(`isolation.py`). 같은 호스트(연결 스레드 둘, 실행 스레드 둘)에 100만 항목의 앱 A와 점수판 B. B에 한 연결의 점수를 10초 보내는 동안 A를 그대로 두거나 바꾼다. 바꾸는 쪽은 보기 하나 더인 모듈로 0.2초마다(판마다 50번), 기록에 필드 하나 더인 모듈로 0.4초마다(판마다 18번, 한 번에 약 170 ms의 일)다. 다섯 라운드, 순서를 번갈아. | B의 p99 중앙값 | 그대로 | 바꾸는 동안 | 비 | |---|---:|---:|---:| | 보기 하나 더, 0.2초마다 | 120 µs | 116 µs | 0.97 | | 기록에 필드 하나 더, 0.4초마다 | 120 µs | 116 µs | 0.97 | B는 5xx를 하나도 받지 않았다. 바꾸는 일은 파일을 읽는 스레드(`pure-apps`)에서 하고, 실행 스레드는 그 앱의 차례만 쉰다. **앱 하나의 호스트**(`runs.sh pairs`, 3단계 전 `e4c7f7e`와 후 `7daf406`, TPC-C W=1 64터미널 20초와 은행 질의, 여섯 쌍 번갈아, 부하 평균 0.9–1.5). | | 전 | 후 | |---|---:|---:| | TPC-C 초당 거래 중앙값 | 42,900 | 43,220 (+0.7%) | | 거래당 CPU | 44.4 µs | 44.4 µs | | `per_branch`, `in_credit`, `large`, `ranks` | 5.31, 0.85, 0.38, 6.81 ms | +0.1%, −2.4%, 0.0%, −0.7% | 쌍마다 TPC-C는 −3.8%에서 +6.2%로 흔들렸다(소유자의 코어 몫 0.87–0.91). 명령 수(025의 `count.py`, 전과 후를 두 번씩): | 한 번 물을 때 | 전 | 후 | |---|---:|---:| | `per_branch` | 26.980M | 26.827M (−0.57%) | | `in_credit` | 3.471M | 3.437M (−0.99%) | | `large` | 1.445M | 1.445M (+0.01%) | | `ranks` | 24.170M | 24.087M (−0.35%) | | `none`, `field`, `found` | 0.607M, 8.776M, 15.691M | +0.00%, +0.00%, +0.02% | 요청이 해석한 모듈을 들고 다니고 소유자와 보기가 그것을 견주는 일(원자적 읽기와 포인터 비교)은 보이지 않는다. **남는 메모리**(`memory.py`). 항목 1,000개의 앱을 100번 바꿔 데운 뒤, 1,000번 더 바꾼 앞뒤의 자기만의 더티 메모리. | 바꾸는 것 | 앞 → 뒤 | 한 번에 | |---|---:|---:| | 모듈(두 파일을 번갈아, 바꿀 때마다 하나를 읽는다) | 3,120 → 21,952 KiB | 19,284 B | | 큐 예산만(모듈은 그대로) | 1,200 → 1,200 KiB | 0 B | 한 번에 남는 것은 바뀐 모듈 하나(구조체 18,760 B와 모듈의 바이트)다. **검사**(`tests/check.py`의 `server_apps_lifecycle`). 한 호스트에서 앱을 더하고(바로 답한다), 지우고(404 `no_app`, 다른 프로세스가 그 저장소를 연다), 바꾼다. 받는 앱 둘(같은 호스트의 앱이 안쪽 출구로, 다른 노드가 링크로 보내는)과 보내는 앱 둘을 보내기 한가운데에서 바꾼다(바꿀 때 보낸 수가 0과 600 사이임을 단언한다). 받는 앱의 새 모듈은 입력의 번호가 다르고 보냄을 이름으로 받는다. 그동안 목록 보기를 쉬지 않고 읽는다. 600건씩이 모두 한 번씩 순서대로 도착하고, 200을 받은 쓰기는 한 번씩 적용되고 503 `stopped`를 받은 쓰기는 적용되지 않으며, 보기는 200이나 503 `stopped`만 답하고, 그대로인 앱은 하나도 실패하지 않는다. 저장소를 옮긴 앱은 새 저장소로 다시 시작하고, 저장소를 만들 수 없던 앱은 고친 뒤의 SIGHUP에 시작하고, 틀린 줄이 있는 파일은 아무것도 바꾸지 않고, `kill -9` 뒤에는 파일 그대로 시작한다. 혼자 10번, CPU 부하 아래 5번, ASan으로 빌드한 `pure-serve`로 6번 지났다. 처음 구현의 동시성 결함 둘은 이 검사가 아니라 코드를 읽어 찾았다. 보내는 앱이 멈추는 동안 받는 앱의 차례가 놓인 출구를 읽던 자리(`local_done`)와, 앱이 시작하기 전에 다른 앱의 출구가 그 소유자의 락을 쓰던 것(락을 자리와 함께 만든다)이다. 그때의 검사는 보내는 앱을 바꾸지 않아 둘이 있는 채로 세 번 지났다. 고친 뒤 검사를 넓혀(보내는 앱을 바꾸기, 바꾸는 동안 보기를 읽기) 그 길을 지나게 했고, 위의 10번, 5번, 6번은 고친 코드에서 지난 것이다. 그 자리에서 바꾸는데 새 모듈을 거절하거나 읽지 못하는 길(앱이 까닭을 남기고 멈춘다)은 검사하지 않았다. ## 판정 - **017의 3단계(수명)는 들어간다.** 네 조건을 모두 채웠다. 달리는 호스트의 앱은 SIGHUP과 파일로 더하고, 멈추고, 바꾼다. - 바꾸기는 그 자리에서 한다. 저장소가 바뀐 앱, 저장이 불확실해진 앱, 달리지 않던 앱만 저장소에서 다시 연다. - 파일을 읽는 스레드(`pure-apps`)가 여러 앱의 호스트마다 하나 늘었다. 앱 100개의 호스트는 스레드 넷이다(111에서 셋). ## 다음 - ~~**바뀐 모듈을 놓기.**~~ 했다(아래 "바뀐 모듈을 놓기"). - **3b: 경로의 인증.** 앱의 경로를 누가 부를 수 있는지. 그 뒤에 HTTP로 앱을 더하고 바꾸는 길. - 기록의 모양이 바뀌는 큰 목록의 교체(100만 항목 172 ms)는 목록을 새 모양으로 다시 쓰는 값이다. 그동안 그 앱만 쉰다. ## 바뀐 모듈을 놓기 (2026-10-19) 바꿀 때마다 19 KB가 남던 것을 놓는다. 교체 4,000번에 교체당 0 B다(전: 19,284 B). 은퇴한 4,100개가 모두 놓였다. - **두 문제로 나눴다.** - **같은지 묻기.** 여러 곳이 모듈의 주소로 "같은 모듈인가"를 물었다: 리더의 배치, 작업과 ViewJob의 route, 밀기 묶음, 링크, 바인딩, 질의, 마지막 발행. 모듈을 놓으면 새 모듈이 그 주소에 올 수 있다. 그래서 모듈마다 프로세스 안의 일련번호(`serial`, 읽을 때 붙고 다시 오지 않는다)를 두고 그것을 묻는다. - **살아 있어야 하는 것.** 옛 모듈을 실제로 읽는 것은 셋뿐이다: 그것을 돌리는 앱, 그것의 스냅숏, 그것으로 해석된 처리 중인 요청. serve의 모든 앱 모듈은 쥠의 수를 가진 `Loaded`다. 스냅숏 하나와 요청 하나가 하나씩 쥔다. - **놓는 때.** 바뀐 모듈은 은퇴하고, 앱을 바꾸는 스레드(`pure-apps`)가 셋이 모두 맞을 때 놓는다. - 어떤 앱도 그것을 돌리지 않는다. - 연결 스레드가 모두 은퇴 뒤에 고리를 한 번 시작했다(`resolve`가 모듈을 읽고 쥐기까지의 틈을 덮는다; 은퇴할 때 깨운다). - 아무것도 쥐지 않는다. 다른 앱의 링크는 받는 쪽의 잠금 안에서 묶고, 링크가 입력을 찾는 길은 `binding_lock` 안에서 읽는다. - **검증.** - `030/memory.py`: 위의 0 B. - `030/stress.py`: 연결 스레드 셋, 뷰 작업자 하나. 보기 읽기 여섯(본문 있는 보기 둘), 밀기 둘, 교체를 가로지르는 본문 쓰기를 돌리며, 같은 크기의 세 모듈을 차례로 바꾼다. 600번 가운데 586번이 놓인 모듈의 주소에 실렸고, 답 85,535개가 모두 맞다. ASan 빌드로 1,000번이 깨끗하다(ASan은 놓인 메모리를 격리해 주소가 다시 오지 않으니, 둘이 서로를 채운다). - 주소로 되돌린 변이(리더, 보기 작업, 밀기 묶음)는 이 시험에서 틀린 답을 내지 않았다. 요청은 답할 때까지 모듈을 쥐어 그동안 주소가 다시 오지 않고, 놓기는 연결 스레드의 다음 고리를 기다린다. 번호는 쥐지 않고 오래 남는 참조를 위한 방어다. - Pure와 Vega의 `make check`가 지난다. 은행 질의(s50 대 s51)는 모두 0.4% 안이고, 가장 가벼운 `none`이 실행마다 명령 50개 늘었다(+0.008%, 요청마다 쥐고 놓기). - **함께 고친 것.** 앱이 다시 요청을 받기 전에 새 스냅숏을 발행해서, 그 사이에 돈 밀기 묶음은 조용하다고 보고 앱의 다음 저장까지 기다렸다(`server_view_workers`가 부하에서 한 번 떨어졌다). 요청을 받기 시작한 뒤 밀기를 한 번 더 깨운다. - **남은 것.** 링크 리스너의 바인딩은 모듈마다 하나씩 쌓인다(입력마다 24 B). 놓인 모듈의 바인딩은 그 뒤 오는 것을 거절할 뿐이다. - 원본: `030/2026-10-19-memory.jsonl`, `-stress.jsonl`, 040의 `2026-10-19-counts-s51.jsonl`.