diff options
| -rw-r--r-- | docs/design/017-apps-on-one-host.md | 128 |
1 files changed, 128 insertions, 0 deletions
diff --git a/docs/design/017-apps-on-one-host.md b/docs/design/017-apps-on-one-host.md new file mode 100644 index 0000000..c9ee199 --- /dev/null +++ b/docs/design/017-apps-on-one-host.md @@ -0,0 +1,128 @@ +# 017 — 한 호스트의 여러 앱: 차례, 예산, 실패 경계 + +작성일: 2026-10-01. 상태: 설계. 1단계의 착수 기준을 적었다. +[외부 리뷰 004](../outside-feedback/feedback-004.md)의 "구현 방향에 대한 평가" 셋째에서 시작한다. 작은 OS의 운영 모델( +[007](007-execution-domains.md))을 Pure가 실제로 갖게 하는 단계다. + +## 문제 + +- **앱 하나가 프로세스 하나다.** `pure-serve`는 모듈 하나와 저장소 하나를 품는다. 앱 열 개는 프로세스 열 개다. 앱마다 + 연결 스레드, 소유자 스레드, 체크포인트를 쓰는 스레드, 포트, 키를 따로 가진다. 유휴 프로세스 하나가 자기만의 메모리 864 KiB를 + 쓴다([087](../research/087-small-reads-and-idle-memory.md)). 무엇을 언제 돌리고 누구에게 얼마를 주는지를 Pure가 아니라 + 리눅스가 정한다. 007은 이것을 출발점으로 삼지 않는다고 정했다. +- **한 프로세스 안에서도 경계가 없다.** + - 시간: 이벤트 하나의 실행량은 정적으로 묶여 있지만 목록 크기에 비례한다(블록 길이 × 항목 수, 목록은 1,600만 항목까지). + 한 이벤트가 소유자를 몇 초씩 잡을 수 있다. 보기와 질의도 연결 스레드를 그만큼 잡는다. + - 대기열: 소유자에게 넘긴 요청은 한없이 쌓인다(배열을 늘리고, 메모리가 다하면 멈춘다). + - 저장: 트리 저장소의 쓰기 차례가 프로세스에 하나다(`store/store-tree.c`의 `writers`). 서로 다른 저장소의 커밋도 동기화까지 + 서로 기다린다. 한 저장소의 동기화가 오래 걸리면(SD 카드에서는 몇 초도 걸린다) 그동안 다른 저장소는 아무것도 저장하지 + 못한다. + +## 원칙 + +- **앱은 실행 영역이다**(007). 모듈, 상태와 그 저장소, 입력과 출력, 예산을 가진다. 상태의 소유자는 하나라서 한 앱은 한 번에 + 한 스레드에서만 돈다. +- **앱마다 스레드를 두지 않는다**(007). 정해진 수의 실행 스레드가 일이 있는 앱을 차례로 돈다. 앱을 더해도 스레드는 늘지 않는다. +- **한도는 결정적인 단위로 센다.** 메모리는 저장 형식의 바이트([013](013-memory-budget.md)), 실행량은 명령 수, 대기열은 요청 + 수다. 같은 앱과 같은 입력이면 어느 기기에서나 같은 사건이 같은 이유로 거절된다. 벽시계로 세면 빠른 기기에서 되던 사건이 + 느린 기기에서 실패한다. +- **실패는 그 앱에서 멈춘다.** 모듈을 읽지 못하거나 저장소를 열지 못한 앱은 그 앱만 시작하지 않는다. 저장이 불확실해진 앱은 + 그 앱만 멈춘다. 다른 앱의 이벤트와 저장은 계속된다. +- **보호의 범위를 부풀리지 않는다.** 같은 프로세스 안에서 앱을 가르는 것은 검증기(검증된 모듈은 자기 상태 밖을 읽거나 쓰지 + 못한다, 008)와 예산이다. 호스트 자신의 결함(C 코드의 버그, 할당기의 고갈)은 모든 앱의 운명이다. 이것을 리눅스 프로세스만큼의 + 격리라고 말하지 않는다. + +## 모양 + +**호스트 구성.** `pure-serve --apps FILE`. 줄마다 앱 하나다(`#`부터 줄 끝은 주석). + +```text +# 이름 모듈 저장소 예산 +board apps/board.pmod stores/board memory=4194304 work=20000000 queue=1024 +counter apps/count.pmod stores/counter +``` + +- 이름은 `[a-z0-9_-]`의 1–32자다. 경로의 첫 마디가 된다. 두 앱이 같은 저장소를 가리키면 시작하지 않는다. +- 예산(모두 없으면 한도 없음, 지금처럼): `memory=` 상태 바이트(013의 `--memory`), `work=` 이벤트나 보기 하나의 실행량, + `queue=` 차례를 기다리는 요청 수. +- 지금의 `--module FILE --store DIR`는 이름 없는 앱 하나다. 경로와 옵션이 그대로다(`/views/NAME`, `--memory`, `--send` …). +- `--workers N`: 실행 스레드의 수. 앱 하나면 1이다(지금의 소유자 스레드와 같다). 여러 앱이면 상태를 가진 앱 수와 CPU 수 가운데 + 작은 것이다. 상태 없이 문서만 가진 앱은 지금처럼 연결 스레드가 돌린다. + +**경로.** 여러 앱이면 `/앱/views/NAME`, `/앱/inputs/NAME`, `/앱/queries/NAME`, `/앱/objects/KEY`다. 모르는 앱은 404 `no_app`, +멈춘 앱은 503 `stopped`(지금 `PURE_CLOSED`의 이름)다. 시작할 때 알리는 줄에 앱마다 상태와 까닭이 있다. + +**차례.** 연결 스레드는 요청을 그 앱의 대기열에 넘기고, 그 앱이 차례 줄에 없으면 줄 끝에 세운다. 실행 스레드는 줄 앞의 앱을 +가져와 한 차례를 돈다. 때가 된 타이머, 받은 답, 기다리는 요청을 한 번에 적용하고 한 번 저장하고 답을 돌려준다. 일이 남았으면 +그 앱을 줄 끝에 다시 세운다. + +- 한 차례가 받는 요청: 줄에 다른 앱이 없으면 기다리는 것을 모두, 있으면 256개까지(연결 스레드의 한 라운드와 같다). +- 저장 뒤의 모으기(답을 받은 클라이언트의 다음 요청을 잠깐 기다려 한 저장에 묶는 것, 지금의 소유자가 한다)는 줄에 다른 앱이 + 없을 때만 한다. 기다리는 앱이 있으면 실행 스레드는 그 앱으로 간다. +- 타이머는 앱마다 다음 때를 가진다. 실행 스레드는 줄이 비면 가장 이른 때까지 잔다. + +**실행량 예산(`work=`).** 이벤트 하나, 그리고 보기·질의 하나의 계산마다 센다. 단위는 명령이다. + +- 핸들러, 보기, 열, 필터, 키의 블록이 한 번 돌면 그 블록의 길이를 센다. +- 걷기(EACH)와 집계(FOLD), 걸러진 표의 필터는 도는 항목마다 블록 길이를 센다. 잎 단위로 돌아도(레인) 같다. 범위로 좁혀 + 일부만 돌면 도는 만큼이다. 모양 있는 집계(그룹·순위, 091)는 패스를 만들 때 항목 수 × 블록 길이를 한 번, 줄마다 키 블록을 + 센다. 실제로 도는 일을 결정적으로 센다. 명령마다 세지 않으므로 해석기의 안쪽 루프에 비용이 들지 않는다. +- 넘으면 그 사건은 새 실패 `work`로 실패하고, 실패한 사건처럼 아무것도 바뀌지 않는다. HTTP로는 422 `work`다. 상태가 같으면 + 다시 해도 같으므로 "잠시 뒤"(503)가 아니다. 앱이 일을 나눠야 한다(조각, 범위). +- 텍스트를 잇고 고치는 일은 바이트에 비례하고 명령으로 세지 않는다. 그 크기는 메모리 예산이 묶는다. + +**대기열 예산(`queue=`).** 앱의 대기열에 기다리는 요청이 한도면 새 요청은 503 `busy`다. 다른 앱의 요청은 받는다. 연결 +스레드의 payload 예산(`--budget`)은 그대로 호스트 전체의 것이다. + +**저장의 경계.** 트리 저장소의 쓰기 차례를 저장소마다 둔다. 같은 저장소의 스레드끼리만 차례를 기다린다. 묶음 커밋(그룹)도 +저장소마다다. 기기가 동기화를 한 줄로 세우는 것([084](../research/084-parallel-syncs.md))은 그대로지만, 한 저장소의 멈춘 +동기화가 다른 저장소의 커밋을 잡지 않는다. + +**실패 경계.** + +- 시작: 모듈을 읽지 못하거나 검증에 실패하거나 저장소를 열지 못한 앱(저장된 상태를 읽지 못하는 것도)은 멈춘 앱이 된다. + 나머지는 시작한다. +- 실행 중: 저장이 불확실해진 앱(지금의 `uncertain`)은 그 뒤의 사건을 거절한다. 그 앱만이다. +- 끝: 앱마다 센 것(사건, 차례, 거절한 것과 까닭, 저장)을 멈출 때 알린다. + +## 단계 + +1. **한 호스트, 여러 앱.** 위의 모양 전부: 구성, 경로, 차례와 실행 스레드, 실행량·대기열 예산, 저장소마다의 쓰기 차례, 실패 + 경계. 여러 앱의 구성에서 보냄(`--send`), 봉인, 답, 링크 포트, 표면은 아직 받지 않는다(앱 하나에서는 지금처럼 된다). +2. **앱 사이와 노드 사이.** 앱마다의 보냄과 답. 링크 포트 하나가 모든 앱의 입력을 받는다(스트림의 input 이름 `앱/입력`, 012의 + 형식은 그대로). 같은 호스트의 앱끼리는 소켓과 봉인 없이 건넨다(007: 내부 연결은 준비된 관계를 쓴다). 보냄마다의 스레드를 + 하나의 보내는 루프로 모은다. +3. **앱의 수명.** 달리는 호스트에 앱을 더하고, 멈추고, 모듈을 바꾼다. 불확실해진 앱을 저장소에서 다시 연다. 앱 경로의 인증과 + 함께다(CHECKPOINT의 남은 것). + +## 1단계의 착수 기준 + +구현하기 전에 정해 커밋한다. + +- **바꿀 결정:** 앱 여러 개를 한 프로세스에서 Pure의 차례로 돌릴지, 앱마다 프로세스를 띄워 리눅스에 맡길지다. 아래를 채우지 + 못하면 한 프로세스 모양을 다시 본다. +- **최소 확인:** + - 저장소: 한 저장소의 커밋이 동기화 중에 멈춰 있는 동안 다른 저장소가 커밋한다. 같은 저장소의 두 스레드는 여전히 차례다 + (`store/test-tree-store.c`). + - 실행량(test-module): 걷기, 집계(항목 단위·잎 단위·모양 있는 패스), 걸러진 표, 보기가 위의 규칙대로 센다. 넘으면 `work`로 + 실패하고 아무것도 바뀌지 않는다. 한 사건은 계획(잎 단위인지 항목 단위인지)과 상관없이 같은 양을 센다. + - 호스트(`tests/check.py`의 `server_apps`): 앱 셋의 경로와 격리(같은 이름의 상태, 같은 이름의 보기), 재시작 뒤의 상태. 모듈을 + 읽지 못하는 앱과 저장소를 열지 못하는 앱은 그 앱만 멈춘다. 실행 중 저장이 불확실해진 앱도 그 앱만 멈춘다. 대기열을 넘긴 + 앱은 503 `busy`이고 다른 앱은 계속 답한다. 실행량을 넘긴 사건은 422 `work`이고 바뀐 것이 없다. + - 앱 하나의 호스트는 지금과 같다. 모든 검사가 초록이다. +- **통과 조건:** RAM 저장소의 Pi 5에서 잰다. 비교 상대는 앱마다 프로세스 하나다. + - 격리: 앱 A를 64연결의 쓰기로 포화시키는 동안 앱 B(한 연결, 쓰기)의 지연 p99가, 같은 둘을 두 프로세스로 띄웠을 때의 1.5배를 + 넘지 않는다(실행 스레드 둘). + - 비용: 작은 앱 하나를 더할 때 늘어나는 자기만의 메모리(더티)가 유휴 프로세스 하나(864 KiB)의 4분의 1 미만이다. 앱을 + 100개까지 더해도 스레드 수는 그대로다. + - 처리량: A와 B의 처리량 합이 두 프로세스일 때보다 10% 넘게 낮지 않다. + - 앱 하나: TPC-C(RAM, W=1, 64터미널)와 은행 질의를 전과 후 번갈아 여섯 쌍 재서 중앙값이 3% 넘게 느려지지 않고, 은행 질의의 + 명령 수(110의 `count.py`)가 1% 넘게 늘지 않는다. + +## 하지 않는 것 + +- 이벤트를 중간에 멈추고 다른 앱에 넘기는 선점. 이벤트는 원자적이고 끝까지 돈다. 그 대신 실행량 예산이 한 이벤트의 길이를, + 차례의 요청 수가 한 차례의 길이를 묶는다. +- 실행 스레드에 앱을 고정하거나 앱마다 무게를 주는 스케줄링. 차례 줄 하나로 시작하고, 측정이 요구할 때 더한다. +- 앱들의 저장을 한 저장소, 한 동기화로 묶는 것. 동기화가 비싼 기기에서 이득일 수 있지만, 한 앱의 망가진 저장소가 모두를 + 멈추게 된다. 실패 경계를 먼저 두고, 묶음은 따로 잰다. |
